Deploying my portfolio to AWS - Part 2: smoothing out the deploy and fixing broken routing
The deploy goes from four manual commands to a single local command, and missing URLs now return a proper 404 instead of the raw bucket error. Two gotchas along the way: one from Windows and one from git.
Introduction
In Part 1 I left the site working: private S3 bucket, CloudFront with OAC, and a CloudFront Function fixing the Next.js routing. But each update needed four commands typed by hand, and that doesn't let me optimize the redeploy process.
This part covers how I lowered the cognitive cost of a deploy, going from "clone repo, install deps, build, sync, invalidate" to a single command from my local terminal.
Custom error responses: presenting errors with dignity
Before writing the Bash deploy script, one loose thread from Part 1. When someone hit a non-existent URL, CloudFront was propagating the raw S3 error, an XML saying something like AccessDenied with HTTP 403.
Problems with that:
- Terrible UX. The user sees raw XML instead of a designed page.
- Implementation info leaks. That AccessDenied tells the world I'm using S3 with restrictive permissions, which shouldn't be public information.
- Confusing SEO. Google indexes differently depending on the HTTP code. A 403 on a URL that should be 404 confuses the crawler.
The decision: map both 403 and 404 from the S3 origin to my own 404.html (which Next.js emits automatically on npm run build), serving it with HTTP 404.
In CloudFront, inside the distribution, Error pages, Create custom error response, I set two rules:
| Setting | Value |
|---|---|
| HTTP error code | 403: Forbidden (and an identical rule for 404) |
| Error caching minimum TTL | 10 seconds |
| Response page path | /404.html |
| HTTP Response code | 404: Not Found |
Design decision: 10-second TTL. The default is 300 (five minutes). If CloudFront sees a 403 and caches it aggressively, any correction I push to S3 takes five full minutes to reach users. With 10 seconds, transient errors heal fast on their own, without needing explicit invalidations.
Design decision: 403 mapped explicitly to 404. The origin error (S3 to CloudFront) is always 403 with OAC, by AWS security design: S3 doesn't reveal whether the object is missing or permission-denied. Rewriting it to 404 for the client gives them exactly the right REST semantics ("the resource doesn't exist") without leaking the implementation detail.
Successful test:
curl -I https://<MY_URL>/nonexistent-path/
# HTTP/2 404
# content-type: text/html
Where do deploys run? A decision tree
Here's the interesting inflection point. I had three options to automate updates:
| Option | Setup effort | Day-to-day UX | Security |
|---|---|---|---|
| A) CloudShell + script | 5 min | Open browser, console, CloudShell, run script | Credentials never touch local disk |
| B) Local AWS CLI + script | 15 min | One command from the same terminal | Access keys on local disk |
| C) GitHub Actions | 30 min | git push and it deploys itself | OIDC, no persistent keys |
For a personal portfolio all three are valid. My reasoning:
I didn't pick A (CloudShell) because it breaks my flow. I edit in VSCode, commit locally, and opening a browser + AWS console just to run four commands is unnecessary friction. Every context switch kills productivity.
I didn't pick C (GitHub Actions) yet because I wanted to document the evolution. Understanding the script manually first makes the Actions workflow later make sense.
I picked B (local AWS CLI) as the first pass at this. Considerations:
- Access keys sit on local disk. It's a manageable risk on my personal laptop, mitigated by OS permissions and by using an IAM user with a scoped-down permission set (not the root user).
- It's a transferable skill. The AWS CLI is useful in any future project.
- The second phase (GitHub Actions with OIDC) will drop these access keys eventually.
Local AWS CLI setup
Setup took about 15 minutes:
- Create access keys for the dedicated IAM user in the console: IAM, Users, my user, Security credentials, Create access key, CLI type, then save the Access Key ID and Secret in a password manager. The Secret is shown only once.
- Install AWS CLI v2 on Windows from the official MSI.
- Configure credentials:
aws configure
# Paste Access Key ID, Secret, region (us-east-1), and output (json)
This creates ~/.aws/credentials and ~/.aws/config, accessible only to my Windows user. To verify:
aws sts get-caller-identity
# "Arn": "arn:aws:iam::<MY_ACCOUNT_ID>:user/<MY_USER>"
Decision: don't use root. The AWS root user is for account admin (billing, closing the account), not for operational work. If an IAM user gets compromised, the blast radius is contained (revoke keys, recreate the user). Once more, I set custom policies following least-privilege (only the specific bucket and distribution).
The deploy.sh script
The script lives at the repo root, versioned in git.
#!/usr/bin/env bash
set -euo pipefail
BUCKET="<MY_BUCKET>"
DISTRIBUTION_ID="<MY_DIST_ID>"
# Pre-flight: branch check
if [[ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]]; then
read -r -p "Deploy from non-main branch? [y/N] " confirm
[[ "$confirm" == "y" ]] || exit 1
fi
# Pre-flight: AWS auth
aws sts get-caller-identity > /dev/null || {
echo "Run: aws configure"; exit 1
}
# Pipeline
git pull --ff-only
npm ci --ignore-scripts
rm -rf out
npm run build
[[ -d "out" ]] || { echo "Build failed"; exit 1; }
aws s3 sync ./out "s3://${BUCKET}" --delete --no-progress
aws cloudfront create-invalidation \
--distribution-id "$DISTRIBUTION_ID" \
--paths "/*"
Decision 1: set -euo pipefail.
If any command fails, the script exits. This is critical: I don't want to invalidate the cache if the build failed, or sync stale files to S3 because npm broke. Without this directive, bash keeps running commands even after one fails, which is a recipe for disaster.
Decision 2: npm ci --ignore-scripts.
I use npm ci over npm install because ci strictly respects package-lock.json and fails on drift, while install can resolve different versions. A pipeline should be reproducible. The --ignore-scripts flag prevents any package from running postinstall scripts during install, a basic defense against supply-chain attacks.
Decision 3: --delete on s3 sync.
Without this flag, files I remove from the repo keep living in S3 forever. It's the classic "the site looks weird after a refactor" bug, because the browser loads old JS chunks that don't exist in the new build.
Decision 4: invalidating /.
AWS gives 1000 invalidation paths per month for free, and / counts as a single path (not N paths, one per file). For 5 to 10 monthly deploys I'm far below the limit. A future optimization would be selective invalidation (/blog/* or /about/*) when a deploy only touches specific sections, which keeps the cache warm on the rest of the site.
Git Bash in VS Code
Worth mentioning that Git Bash ships with Git for Windows, and since I was already using it, that made the decision straightforward. On Windows, .sh scripts require Git Bash or WSL. PowerShell is for .ps1 scripts. If I wanted a cross-platform equivalent I'd have to write both a deploy.sh and a deploy.ps1.
Wrapping up
After this second part, the state is:
- The site serves a clean 404 page for missing URLs.
- A full deploy is a single command: ./deploy.sh.
- The script has pre-flight validations.
- Cache invalidation runs automatically on every deploy.
Average time for a full update is about two minutes, from git push to the site being live worldwide.
Coming up in Part 3
There's still room for improvement: I have to remember to run ./deploy.sh after every push to main. The next level is removing that step entirely with GitHub Actions:
- A workflow in .github/workflows/deploy.yml that triggers on every push to main.
- AWS credentials via OIDC, no access keys stored in GitHub Secrets. Safer and with no manual rotation.
- A job that does build, sync and invalidation on GitHub runners.
- The custom domain with ACM and observability with CloudWatch.
The goal: push to main from VSCode and, three minutes later, the site is updated, with me out of the loop. That change is what Part 3 walks through.