Skip to content
Ganeswar Velvadapu

Adding CI/CD for AWS S3 and CloudFront deployment

A simple walkthrough on adding github actions CI/CD with AWS S3 and CloudFront.

AWS , CI/CD 2 min read

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 install followed by npm run build. GitHub automatically uses the repository root as the working directory, so no need to cd anywhere 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:

SecretDescription
AWS_ACCESS_KEY_IDAccess key for your IAM user
AWS_SECRET_ACCESS_KEYSecret key for your IAM user
AWS_REGIONRegion where your S3 bucket lives, e.g. us-east-1
S3_BUCKET_NAMEName of your S3 bucket
CLOUDFRONT_DISTRIBUTION_IDFound on your CloudFront distribution page

App environment variables (if any):

SecretDescription
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_ID
  • AWS_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!