Why Automate a Static Site?
When I first set up this portfolio on AWS S3 + CloudFront, I was manually running
aws s3 sync from my terminal every time I made a change.
It worked, but it was tedious — and more importantly, it wasn't the right way to do things.
A real deployment pipeline means pushing code and walking away. The infrastructure does the rest.
This post walks through exactly how I set up a GitHub Actions CI/CD pipeline that
deploys this site automatically on every push to main.
The Stack
- GitHub Actions — runs the pipeline on push events
- AWS S3 — stores and serves the static files
- AWS CloudFront — CDN that caches content at edge locations
- AWS IAM — scoped deploy user with least-privilege permissions
- GitHub Secrets — secure storage for AWS credentials
Step 1 — Create a Scoped IAM Deploy User
The first thing I did was create a dedicated IAM user for deployments. Never use your root account or personal IAM user for CI/CD — if the credentials leak, the blast radius needs to be minimal.
The deploy user has only two permissions:
Step 2 — Store Credentials in GitHub Secrets
After creating the IAM user, I generated access keys and stored them in GitHub Secrets under Settings → Secrets and variables → Actions:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_REGIONS3_BUCKETCLOUDFRONT_DISTRIBUTION_ID
Step 3 — Write the GitHub Actions Workflow
I created .github/workflows/deploy.yml at the root of the repo.
It runs on every push to main and does three things:
checks out the code, syncs it to S3, and invalidates the CloudFront cache.
Why the --delete Flag Matters
The --delete flag on aws s3 sync
removes files from S3 that no longer exist in the repo. Without it, deleted or renamed
files stay in the bucket forever — users might hit stale URLs returning old content,
and your bucket accumulates junk over time.
Why You Must Invalidate CloudFront
CloudFront caches files at edge locations around the world. Even after S3 is updated,
users may still receive the old cached version for hours — or until the TTL expires.
The create-invalidation call with path /*
forces all edge locations to fetch fresh content from S3 immediately after every deploy.
The Full Flow
Lessons Learned
- IAM least-privilege is non-negotiable — scope your deploy user to only what it needs
- Always invalidate after sync — CloudFront will silently serve stale content otherwise
- Exclude .git and .github from the S3 sync — you don't want those files in your bucket
- GitHub Actions is free for public repos — no infrastructure to maintain, native GitHub integration
- Test the pipeline by breaking it intentionally — introduce a typo, push, and verify it fails gracefully