Reduced deploy time to 4 seconds with automatic backups and rollback support
Every time I made changes to hofiverse.com I was manually running hugo build and copying files over to my Unraid server. It worked, but it was tedious and error prone. I decided to write a deploy script that handles the whole process with a single command.
The Problem#
My workflow before the script looked like this:
- Make changes locally
- Run
hugo --minifyto build the site - Manually rsync the output to
/mnt/user/websites/hofiverseon my Unraid server - Hope I did not forget a step
There was no backup of the previous site before deploying, no log of what happened, and no safety net if something went wrong mid-deploy.
The Goal#
A single command that handles the entire deploy reliably. Before writing anything I mapped out what the script needed to do:
- Run a Hugo build first and abort if it fails
- Back up the current prod site before overwriting it
- Keep only the last 3 backups and clean up older ones automatically
- Rotate logs so they do not pile up indefinitely
- Check for disk space before attempting a backup
- Show a clean summary at the end with elapsed time and file count
- Support a dry run mode for testing without making any changes
The Build#
The Investigation#
The site lives on my laptop at ~/hofiverse-site. The production files are served by a Caddy container on my Unraid server from /mnt/user/websites/hofiverse. Backups and logs go to /mnt/user/backup/hofiverse-site.
To deploy from my laptop I needed the Unraid shares mounted over NFS. I added NFS exports for both the websites and backup shares in the Unraid UI, then mounted them on my laptop:
sudo mkdir -p /mnt/unraid/websites
sudo mkdir -p /mnt/unraid/backup
sudo mount -t nfs YOUR-UNRAID-IP:/mnt/user/websites /mnt/unraid/websites
sudo mount -t nfs YOUR-UNRAID-IP:/mnt/user/backup /mnt/unraid/backupWith those mounted, the script can read and write to the Unraid server as if they were local directories.
The Pivot#
The first version of the script had no pre-flight checks. It would attempt the Hugo build, and if that failed it would still try to deploy whatever was in the public/ folder from the last successful build. That meant a failed build could silently deploy stale content.
The fix was making the build step a hard gate. If Hugo exits with a non-zero code, the script stops immediately before touching anything in production.
The Fix#
The full script lives at ~/deploy.sh. Here is how each section works.
Pre-flight checks
Before doing anything destructive the script verifies that Hugo is installed, both directories exist, and there is enough free disk space on the backup share:
if ! command -v hugo &>/dev/null; then
echo "ERROR: Hugo is not installed or not in PATH."
exit 1
fiBuild
hugo --minify
if [ $? -ne 0 ]; then
echo "ERROR: Hugo build failed. Aborting."
exit 1
fiBackup and rotation
Before deploying, the current prod directory is copied to the backup share with a timestamp in the folder name:
cp -r "$PROD_DIR" "$BACKUP_DIR/hofiverse_$TIMESTAMP"After the backup is created, the script trims any backups beyond the last 3:
ls -dt "$BACKUP_DIR"/hofiverse_* | tail -n +4 | xargs rm -rfThe same pattern applies to log files, keeping only the last 10.
Deploy
rsync -av --delete "$SITE_DIR/public/" "$PROD_DIR/"The --delete flag removes any files from prod that no longer exist in the build output, keeping the production directory clean.
Dry run mode
Passing --dry-run makes the script simulate every step without writing anything. Hugo still runs so build errors surface, but the backup, cleanup, and rsync all print what they would do instead of doing it:
~/deploy.sh --dry-runSummary
At the end of every run the script prints a summary:
========================================
Deploy Summary
========================================
Mode: LIVE
Status: SUCCESS
Files built: 47
Elapsed: 4s
Log: /mnt/unraid/backup/hofiverse-site/logs/deploy_20260320_091223.log
Completed: Fri Mar 20 09:12:27 AM MST 2026
========================================Results#
The whole process now takes one command and about 4 seconds. The last 3 versions of the site are always available if I need to roll back, and every deploy leaves a log I can check if something looked off.
What I Would Do Differently#
The NFS mounts need to be active before the script runs or it will fail silently trying to write to an unmounted path. I worked around this by adding the mounts to /etc/fstab with the _netdev flag, but a better solution would be having the script detect whether the mounts are active and either mount them automatically or exit with a clear error message.
The backup step also copies the entire production directory every time regardless of what changed. For a small static site this is fine, but an incremental backup using rsync snapshots would be more efficient and use less disk space over time.
What Is Next#
The script currently requires the NFS mounts to be set up manually on any new machine. Packaging the full setup, including the mount configuration and the script itself, into a bootstrap script would make it portable across machines without any manual steps.