
In a previous post, I described how I migrated flanagan.io from a hosted WordPress site to a static site served from AWS S3 and CloudFront, managed by a Makefile. The pipeline worked, but after living with it for a while I noticed some things that bothered me:
- Directory and makefile target naming that didn’t quite reflect reality
- A not so subtle bug in the GitHub sync step
- No way to recover if a deploy went sideways
I spent about an hour co-working through the Makefile with Claude, and the result is a noticeably cleaner, more robust pipeline.
What Was Wrong with the Naming
The original Makefile used three directories: curr_public_static, prev_public_static, and diff_public_static. The intent was that curr held the new export you were about to deploy and prev held the last-deployed baseline used for comparison. The problem is that “current” implies what’s live right now—which is the opposite of how the directories were actually used. Anyone reading the Makefile cold (including future me) would have to think backwards to understand it.
The fix was to rename them so the names match the mental model: next_public_static for the incoming export, curr_public_static for what’s actually live on S3, and diff_public_static for changed items. After a deploy, next is promoted to curr as the new baseline. The direction of everything in the Makefile now reads naturally.
CURR_DIR := $(HOME)/Local Sites/flanaganio/app/curr_public_static NEXT_DIR := $(HOME)/Local Sites/flanaganio/app/next_public_static DIFF_DIR := $(HOME)/Local Sites/flanaganio/app/diff_public_static PREV_DIR := $(HOME)/Local Sites/flanaganio/app/prev_public_static
I also replaced the hardcoded /Users/kelly.flanagan/ path prefix with $(HOME), so the Makefile isn’t tied to a specific username. This became an issue when I changed machines and forced to adopt this new user name.
A Bug the Renaming Exposed
Once the names were fixed, a bug in github_copy became obvious. The original code set LOCAL_DIR := $(PREV_DIR) and synced from there to GitHub—meaning every deploy pushed the old version of the site to GitHub, not the new one. It had always been wrong; the confusing names just obscured it. With clear names, syncing curr (old) instead of next (new) to GitHub was immediately visible. The fix was one line.
Pre-flight Check
If Simply Static fails silently and exports an empty or near-empty directory, the old pipeline would happily sync nothing to S3 and wipe the live site. A check_next target now runs first and aborts if next_public_static contains fewer than 100 files.
check_next:
@file_count=$$(find "$(NEXT_DIR)" -type f | wc -l | tr -d ' '); \
if [ "$$file_count" -lt $(MIN_FILE_COUNT) ]; then \
echo "ERROR: $(NEXT_DIR) contains only $$file_count files (minimum: $(MIN_FILE_COUNT)). Aborting."; \
exit 1; \
fi
@echo "Pre-flight check passed: $$(find "$(NEXT_DIR)" -type f | wc -l | tr -d ' ') files in next_public_static."
Smarter CloudFront Invalidation
The original pipeline invalidated all paths on every deploy—which works, but clears the entire cache even when only one post changed. Since diff_dir already knows exactly which files changed, it made sense to invalidate only those paths. The updated target does that, with a fallback to all paths when more than 50 files changed.
invalidate_cloudfront_caches:
@echo "Creating CloudFront invalidation."
@file_count=$$(find "$(DIFF_DIR)" -type f | wc -l | tr -d ' '); \
if [ "$$file_count" -gt 50 ]; then \
echo "$$file_count files changed — invalidating /*"; \
aws cloudfront create-invalidation --distribution-id "$(CF_DISTRIBUTION_ID)" --paths "/*" > /dev/null; \
else \
paths=$$(find "$(DIFF_DIR)" -type f | sed "s|$(DIFF_DIR)||g" | tr '\n' ' '); \
echo "Invalidating $$file_count paths."; \
aws cloudfront create-invalidation --distribution-id "$(CF_DISTRIBUTION_ID)" --paths $$paths > /dev/null; \
fi
@echo "Invalidation request submitted."
Rollback
The original pipeline had no recovery path. If a deploy produced a broken site, the only option was to re-export, fix whatever was wrong, and deploy again—hoping the fix worked. The updated pipeline adds a fourth directory, prev_public_static, which holds a snapshot of curr taken just before promotion. If something goes wrong after a deploy, make rollback restores the previous version to S3, invalidates CloudFront, and resets curr to match.
backup_curr:
@echo "Backing up $(CURR_DIR) to $(PREV_DIR)"
@mkdir -p "$(PREV_DIR)"
@rsync -a --delete "$(CURR_DIR)/" "$(PREV_DIR)/"
@echo "Backup complete."
rollback:
@if [ ! -d "$(PREV_DIR)" ] || [ -z "$$(ls -A "$(PREV_DIR)")" ]; then \
echo "ERROR: No previous version found at $(PREV_DIR). Cannot rollback."; \
exit 1; \
fi
@echo "Rolling back to previous version..."
@aws s3 sync "$(PREV_DIR)/" "$(S3_BUCKET)" --delete --quiet
@echo "S3 restore complete."
@aws cloudfront create-invalidation --distribution-id "$(CF_DISTRIBUTION_ID)" --paths "/*" > /dev/null
@echo "CloudFront invalidation submitted."
@rsync -a --delete "$(PREV_DIR)/" "$(CURR_DIR)/"
@echo "Rollback complete. Note: next_public_static still contains the failed deploy — re-export before deploying again."
@echo "$$(date '+%Y-%m-%d %H:%M:%S') Rollback complete" >> "$(LOG_FILE)"
This is single-level rollback—one deploy back. Good enough for a personal blog where I’m the only one making changes.
Other Small Things
A few other improvements accumulated along the way. The –exact-timestamps flag on aws s3 sync was replaced with the default ETag-based comparison, which is more reliable when file timestamps get reset after a fresh export. Error handling within recipe steps was tightened—critical command sequences now use && instead of ;, so a failure stops the chain rather than silently continuing. The CloudFront invalidation no longer swallows stderr, so actual AWS errors surface instead of being hidden behind a cheerful “Invalidation request submitted.” And a deploy.log file now records a timestamped line on each successful deploy and rollback, so there’s a simple record of when things ran.
The full deploy chain now looks like this:
deploy: clean check_next fix_feed diff_dir s3_upload invalidate_cloudfront_caches github_copy backup_curr next_to_curr
@echo "Deployment complete."
@echo "$$(date '+%Y-%m-%d %H:%M:%S') Deployment complete" >> "$(LOG_FILE)"
Working with Claude
I’ve used AI tools before to generate boilerplate or look up syntax, but this session felt different. Rather than asking Claude to write the Makefile from scratch, I described what I had and talked through what bothered me about it. Claude spotted the naming inversion and the GitHub bug independently—I hadn’t framed either as a bug, just noticed the names felt off. From there it was a back-and-forth: Claude would propose a change, I’d push back or ask why, and we’d refine it together. The rollback design in particular went through a few iterations before landing on something I was happy with.
The end result is a Makefile I’d actually feel comfortable handing to someone else to use, because the next time I look at it, I will be a different person having no idea how it works. Claude will explain ti to me then as well.
Makefile
.PHONY: deploy check_next diff_dir s3_upload invalidate_cloudfront_caches github_copy backup_curr next_to_curr rollback fix_feed clean help
# Directory paths
CURR_DIR := $(HOME)/Local Sites/flanaganio/app/curr_public_static
NEXT_DIR := $(HOME)/Local Sites/flanaganio/app/next_public_static
DIFF_DIR := $(HOME)/Local Sites/flanaganio/app/diff_public_static
PREV_DIR := $(HOME)/Local Sites/flanaganio/app/prev_public_static
LOG_FILE := $(HOME)/Local Sites/flanaganio/app/deploy.log
S3_BUCKET := s3://flanaganio-static/
CF_DISTRIBUTION_ID := E1JU16LZYIE94I
GITHUB_DIR := $(HOME)/Local Sites/flanaganio/app/flanagan.io
# Rsync options: compare next against curr to find only changed files
RSYNC_OPTS := -ac --delete --compare-dest="$(CURR_DIR)/"
# Minimum file count in next_public_static — abort if below this threshold
MIN_FILE_COUNT := 100
# Full deployment: clean, pre-flight, fix feed, diff, S3 upload, CloudFront, GitHub, backup curr, promote next→curr
deploy: clean check_next fix_feed diff_dir s3_upload invalidate_cloudfront_caches github_copy backup_curr next_to_curr
@echo "Deployment complete."
@echo "$$(date '+%Y-%m-%d %H:%M:%S') Deployment complete" >> "$(LOG_FILE)"
# Abort if next_public_static looks empty or incomplete
check_next:
@file_count=$$(find "$(NEXT_DIR)" -type f | wc -l | tr -d ' '); \
if [ "$$file_count" -lt $(MIN_FILE_COUNT) ]; then \
echo "ERROR: $(NEXT_DIR) contains only $$file_count files (minimum: $(MIN_FILE_COUNT)). Aborting to prevent a broken deploy."; \
exit 1; \
fi
@echo "Pre-flight check passed: $$(find "$(NEXT_DIR)" -type f | wc -l | tr -d ' ') files in next_public_static."
# Compare $(NEXT_DIR) against $(CURR_DIR) and output changed files to $(DIFF_DIR)
diff_dir:
@echo "Comparing $(NEXT_DIR) against $(CURR_DIR)"
# Raise open-file limit to prevent rsync failures on large site exports
@ulimit -n 65536 && \
rm -rf "$(DIFF_DIR)" && \
mkdir -p "$(DIFF_DIR)" && \
rsync $(RSYNC_OPTS) "$(NEXT_DIR)/" "$(DIFF_DIR)/"
@file_count=$$(find "$(DIFF_DIR)" -type f | wc -l | tr -d ' '); \
total_size=$$(du -sh "$(DIFF_DIR)" | awk '{print $$1}'); \
echo "Comparison complete."; \
echo "Files changed: $$file_count ($$total_size)"
# Upload diff directory to S3
s3_upload:
@echo "Uploading $(DIFF_DIR) to $(S3_BUCKET)"
@aws s3 sync "$(DIFF_DIR)/" "$(S3_BUCKET)" --quiet
@echo "Upload complete."
# Invalidate only the changed CloudFront paths; fall back to /* if count exceeds 50
invalidate_cloudfront_caches:
@echo "Creating CloudFront invalidation."
@file_count=$$(find "$(DIFF_DIR)" -type f | wc -l | tr -d ' '); \
if [ "$$file_count" -gt 50 ]; then \
echo "$$file_count files changed — invalidating /*"; \
aws cloudfront create-invalidation --distribution-id "$(CF_DISTRIBUTION_ID)" --paths "/*" > /dev/null; \
else \
paths=$$(find "$(DIFF_DIR)" -type f | sed "s|$(DIFF_DIR)||g" | tr '\n' ' '); \
echo "Invalidating $$file_count paths."; \
aws cloudfront create-invalidation --distribution-id "$(CF_DISTRIBUTION_ID)" --paths $$paths > /dev/null; \
fi
@echo "Invalidation request submitted."
# Sync next_public_static to GitHub and push
github_copy:
@echo "Syncing $(NEXT_DIR) to GitHub."
@rsync -a --delete --exclude ".git/" --exclude ".DS_Store" --quiet "$(NEXT_DIR)/" "$(GITHUB_DIR)/"
@cd "$(GITHUB_DIR)" && \
if [ -n "$$(git status --porcelain)" ]; then \
UPDATED_COUNT=$$(git status --porcelain | wc -l | tr -d ' '); \
git add . >/dev/null && \
git commit -m "Update static site $$(date '+%Y-%m-%d %H:%M:%S')" && \
git push --quiet origin main; \
TOTAL_SIZE=$$(du -sh "$(GITHUB_DIR)" | awk '{print $$1}'); \
echo "Updated $$UPDATED_COUNT files to GitHub (current size $$TOTAL_SIZE)."; \
else \
echo "No changes to commit."; \
fi
@echo "GitHub sync complete."
# Promote next_public_static → curr_public_static for the next deployment's baseline
next_to_curr:
@echo "Promoting $(NEXT_DIR) to $(CURR_DIR)"
@rsync -a --delete "$(NEXT_DIR)/" "$(CURR_DIR)/"
@echo "Promotion complete."
# Save curr_public_static → prev_public_static before promoting next (enables rollback)
backup_curr:
@echo "Backing up $(CURR_DIR) to $(PREV_DIR)"
@mkdir -p "$(PREV_DIR)"
@rsync -a --delete "$(CURR_DIR)/" "$(PREV_DIR)/"
@echo "Backup complete."
# Restore prev_public_static to S3 and reset curr (single-level rollback)
rollback:
@if [ ! -d "$(PREV_DIR)" ] || [ -z "$$(ls -A "$(PREV_DIR)")" ]; then \
echo "ERROR: No previous version found at $(PREV_DIR). Cannot rollback."; \
exit 1; \
fi
@echo "Rolling back to previous version..."
@aws s3 sync "$(PREV_DIR)/" "$(S3_BUCKET)" --delete --quiet
@echo "S3 restore complete."
@aws cloudfront create-invalidation --distribution-id "$(CF_DISTRIBUTION_ID)" --paths "/*" > /dev/null
@echo "CloudFront invalidation submitted."
@rsync -a --delete "$(PREV_DIR)/" "$(CURR_DIR)/"
@echo "Rollback complete. Note: next_public_static still contains the failed deploy — re-export before deploying again."
@echo "$$(date '+%Y-%m-%d %H:%M:%S') Rollback complete" >> "$(LOG_FILE)"
# Fix relative URLs in RSS feed before deployment
fix_feed:
@echo "Converting relative URLs to absolute in RSS feed."
@if [ -f "$(NEXT_DIR)/feed/index.xml" ]; then \
sed -i '' -E \
's|/wp-content/|https://flanagan.io/wp-content/|g; \
s|href="/|href="https://flanagan.io/|g; \
s|src="/|src="https://flanagan.io/|g; \
s|srcset="/|srcset="https://flanagan.io/|g; \
s|<link>/|<link>https://flanagan.io/|g' \
"$(NEXT_DIR)/feed/index.xml"; \
echo "RSS feed URLs updated successfully."; \
else \
echo "RSS feed not found at $(NEXT_DIR)/feed/index.xml"; \
fi
# Clean diff directory
clean:
@echo "Cleaning up $(DIFF_DIR)"
@rm -rf "$(DIFF_DIR)"
@echo "Done."
# List available targets
help:
@echo ""
@echo "Available targets:"
@echo " deploy Full deployment: clean, pre-flight check, fix feed, diff, S3 upload, CloudFront invalidation, GitHub push, backup curr, promote next→curr"
@echo " rollback Restore prev_public_static to S3 and reset curr (single-level)"
@echo " check_next Abort if next_public_static has fewer than $(MIN_FILE_COUNT) files"
@echo " fix_feed Rewrite relative URLs to absolute in the RSS feed"
@echo " diff_dir Compute changed files between next and curr"
@echo " s3_upload Upload changed files to S3"
@echo " invalidate_cloudfront_caches Invalidate changed CloudFront paths (or /* if >50 files changed)"
@echo " github_copy Sync next to the GitHub repo and push"
@echo " backup_curr Save curr_public_static to prev_public_static"
@echo " next_to_curr Promote next_public_static to curr_public_static"
@echo " clean Remove the diff directory"
@echo ""