Create, verify, and restore a project backup with zip
To make a ZIP backup reflect your current project, create a fresh archive on each run. Running
zip -r against an existing archive adds or updates entries but can retain files you deleted from
the source. The Info-ZIP manual
documents this behavior. Below, a Bash script builds a new archive, checks it, and only then replaces
the previous backup.
This walkthrough targets Linux with Bash, Info-ZIP Zip 3.0, UnZip 6.0, GNU coreutils, findutils, and
diffutils. Check zip -v and unzip -v for the Info-ZIP versions. The script uses GNU mv -T, so
it needs adaptation for macOS. Make is optional.
Stop builds and other processes that write to the project before archiving. The result represents
a directory that stays unchanged during the run; zip does not take a filesystem snapshot. Use
ordinary files, directories, and symbolic links, with no control characters in their names.
Prepare a small project
Run this block in Bash. It creates a disposable project and refuses to reuse an existing
zip-demo directory. The parentheses keep your shell in its original directory.
(
set -euo pipefail
mkdir zip-demo
cd zip-demo
mkdir -p project_directory/src project_directory/node_modules project_directory/dist
printf 'version one\n' > project_directory/src/app.txt
printf 'project notes\n' > 'project_directory/read me.txt'
printf 'dependency\n' > project_directory/node_modules/omit.txt
printf 'generated\n' > project_directory/dist/omit.txt
)
Create a fresh archive on every run
Save this script as zip-demo/backup.sh. It archives project_directory relative to its working
directory. Keep the script, temporary archive, and final archive beside the project, outside the
source tree.
#!/usr/bin/env bash
set -euo pipefail
umask 077
unset ZIPOPT UNZIP UNZIPOPT
source_dir=project_directory
backup_date=$(date +%Y-%m-%d)
archive_name="project_backup_$backup_date.zip"
if [[ ! -d "$source_dir" || -L "$source_dir" ]]; then
printf 'Source must be a real directory: %s\n' "$source_dir" >&2
exit 1
fi
if [[ -L "$archive_name" || ( -e "$archive_name" && ! -f "$archive_name" ) ]]; then
printf 'Destination must be a regular file or absent: %s\n' "$archive_name" >&2
exit 1
fi
find "$source_dir" -type d -print > /dev/null
work_dir=$(mktemp -d './.zip-backup.XXXXXXXX')
trap 'rm -rf -- "$work_dir"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
zip -q -6 -r -MM -y "$work_dir/archive.zip" "$source_dir" \
-x '*/.git' '*/.git/*' \
'*/node_modules' '*/node_modules/*' \
'*/dist' '*/dist/*' \
'*.tmp' '*.tmp/*' '*.temp' '*.temp/*' \
'*/.DS_Store' '*/.DS_Store/*'
unzip -tq "$work_dir/archive.zip"
mv -fT -- "$work_dir/archive.zip" "$archive_name"
printf 'Backup created: %s\n' "$archive_name"
Run it from the directory where you created zip-demo:
(cd zip-demo && bash backup.sh)
On success, the last line names project_backup_YYYY-MM-DD.zip using the machine’s local date.
A successful run on the same day replaces that file without prompting. Added files appear,
changed files are reread, and deleted files disappear, even when a change preserves a file’s size
and modification time. With unchanged inputs, the restored contents remain the same; identical
archive bytes are not promised.
The destination is replaced only after compression and verification succeed. A missing source, unreadable file, or failed verification returns a nonzero status without the success message and leaves the previous backup in place. A destination directory or symbolic link is rejected. Run one backup at a time in a directory you control.
The initial find checks that the entire source tree can be traversed, including excluded
directories. This matters because zip can silently skip an unreadable directory even with -MM.
The options do specific jobs: -r traverses the directory, -MM makes missing or unreadable input
files fatal, and -y stores symbolic links rather than their targets. Quoting the exclusion patterns
leaves matching to zip. These are
Info-ZIP options.
The script clears inherited ZIP/UnZip options so shell defaults cannot silently change its behavior.
GNU mv -fT replaces the destination
file, treating it as a file path rather than a directory to move into.
Choose what to exclude
The example omits .git, node_modules, and dist at any depth, names ending in .tmp or
.temp, and .DS_Store. Matching also covers directories with those names and their contents.
For example, */node_modules/* matches both project_directory/node_modules/omit.txt and a
nested package’s dependencies.
These exclusions suit a source handoff whose dependencies and build output can be recreated. Remove exclusions if those files are part of what you need to restore. Dotfiles are otherwise included, so inspect the archive before sharing it: this list is not a secret detector. If you change it, change the comparison exclusions below too.
Run the same script from Make
Optionally save this as zip-demo/Makefile, with a tab before bash. Run make -C zip-demo archive
from the parent directory. The phony target runs every time, and Make reports a failed script as
a failed target.
.PHONY: archive
archive:
bash backup.sh
Restore and compare the files
Run this block from the parent of zip-demo on the same day as the backup. For an older backup,
replace the date expression with its actual filename. Restore only an archive you trust.
(
set -euo pipefail
cd zip-demo
unset UNZIP UNZIPOPT ZIPINFO ZIPINFOOPT
archive_name="project_backup_$(date +%Y-%m-%d).zip"
unzip -tq "$archive_name"
unzip -Z1 "$archive_name"
mkdir restored
unzip -q "$archive_name" -d restored
diff -r --no-dereference \
-x .git -x node_modules -x dist -x '*.tmp' -x '*.temp' -x .DS_Store \
project_directory restored/project_directory
)
The member listing should contain src/app.txt and read me.txt beneath project_directory/,
without the dependency and generated files. The restored tree keeps that top-level directory.
diff produces no output and exits zero when the included file contents, directory structure,
and symbolic-link targets match. It does not compare ownership, permissions, or timestamps.
mkdir restored deliberately fails if that destination already exists, before extraction touches
it. To repeat a restore, choose a new directory name in both the extraction and comparison commands.
If extraction fails partway through, keep that partial directory separate and retry into a fresh one.
The UnZip manual explains
that -t decompresses entries and checks their stored CRCs. That detects damaged data; it does not
prove you included the right files. The separate restore and comparison checks that part, provided
the source has remained unchanged since the backup.
Choose a compression level
Change -6 in the script when you want to compare levels:
| Option | Purpose |
|---|---|
-0 | Store files without compression |
-1 | Favor compression speed |
-6 | Use Info-ZIP’s default compression level |
-9 | Spend more time trying to compress smaller |
The compression-level options control compression effort. There is no fixed percentage saving, and a higher level need not produce a meaningfully smaller archive. Compare elapsed time and archive size on your actual project, then restore each candidate. Remember that this script replaces the same day’s output, so save each candidate separately if you want to retain it.
Encrypt separately when needed
This archive is unencrypted. Info-ZIP’s zip -e uses weak traditional ZIP encryption; the
Info-ZIP FAQ warns about its limitations.
For sensitive material, choose a separate encryption workflow such as
age, which supports recipient keys and passphrases. Confirm
that the recipient can decrypt and restore it before relying on that workflow. Encryption also
leaves the original unencrypted ZIP on disk unless you manage that copy separately.
Handle failures and limits
If a run fails, inspect its diagnostic and exit status; an older ZIP at the destination is not evidence that today’s attempt worked. Keep enough free space for the previous archive and the new one at the same time. On an ordinary failure, the exit trap removes the temporary directory; forced termination or power loss can leave it behind.
This example checks a project’s file contents and paths on Linux. It does not establish a complete system-backup format for ACLs, extended attributes, special files, or hard-link relationships. Large archives may require Zip64 support in the recipient’s extractor. Keep older successful backups according to your own retention needs, and test restoration with the tools that will actually consume them.
