Adding CI/CD for AWS S3 and CloudFront deployment
A simple walkthrough on adding github actions CI/CD with AWS S3 and CloudFront.
In the previous blog we saw how to deploy a static site using AWS S3 and CloudFront. But there is one problem. Every time you make a change, you have to rebuild, re-upload the files to S3, and manually invalidate the CloudFront cache. Let’s automate that using GitHub Actions.
Before writing the workflow, it helps to map out what needs to happen:
- Checkout the code — GitHub spins up a fresh virtual machine for every run, so the first step is always cloning your repository onto it.
- Install Node.js — The runner has no dependencies pre-installed, so we set up the Node.js version we need.
- Install dependencies and build — Run
npm installfollowed bynpm run build. GitHub automatically uses the repository root as the working directory, so no need tocdanywhere unless you have a monorepo. - Configure AWS credentials — Authenticate the runner with AWS so it can talk to S3 and CloudFront.
- Push to S3 — Sync the build output folder to your S3 bucket.
- Invalidate the CloudFront cache — CloudFront caches your files at edge locations. After a new deploy, you need to invalidate that cache so users get the latest version instead of a stale one.
The workflow relies on several secrets and environment variables. Add these under Settings → Secrets and variables → Actions in your GitHub repository.
AWS and deployment secrets:
| Secret | Description |
|---|---|
AWS_ACCESS_KEY_ID | Access key for your IAM user |
AWS_SECRET_ACCESS_KEY | Secret key for your IAM user |
AWS_REGION | Region where your S3 bucket lives, e.g. us-east-1 |
S3_BUCKET_NAME | Name of your S3 bucket |
CLOUDFRONT_DISTRIBUTION_ID | Found on your CloudFront distribution page |
App environment variables (if any):
| Secret | Description |
|---|---|
VITE_BACKEND_API_URL | Or any other VITE_ variables your app needs at build time |
To push to S3 and invalidate CloudFront, you need:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY
Rather than using your root account or giving admin access, create a dedicated IAM user with only the permissions it needs.
Bucket access:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "BucketAccess", "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::your-bucket-name" } ]}Object access:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "ObjectAccess", "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::your-bucket-name/*" } ]}CloudFront invalidation:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "CloudFrontInvalidation", "Effect": "Allow", "Action": ["cloudfront:CreateInvalidation"], "Resource": "*" } ]}name: Deploy to prod
on: push: branches: - main
jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4
- name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 24
- name: Install dependencies run: npm install
- name: Build the application run: npm run build env: VITE_BACKEND_API_URL: ${{ secrets.VITE_BACKEND_API_URL }}
- name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_REGION }}
- name: Deploy to S3 run: aws s3 sync dist/ s3://${{ secrets.S3_BUCKET_NAME }} --delete
- name: Invalidate CloudFront cache run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"This workflow triggers on every push to main and handles the full deploy automatically.
Next, try replacing the access key credentials with OIDC. It’s more secure and removes the need to store long-lived secrets.
That’s it for this one. Hope it helps!