Automating Cloud Deployments with GitHub Actions, S3, and CloudFront
How to set up a zero-downtime deployment pipeline for static sites using GitHub Actions, S3, and CloudFront — with cache invalidation, branch previews, and cost breakdown.
Every static site we deploy at KeyQ follows the same pattern: push to main, build automatically, deploy to S3, invalidate the CloudFront cache. The entire pipeline runs in under 2 minutes and costs almost nothing. Once set up, you never think about deployments again.
Here is the complete setup, including the pieces that tutorials usually skip.
The Pipeline
name: Deploy to AWS
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- 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: Sync to S3
run: aws s3 sync ./dist s3://${{ secrets.S3_BUCKET }} --delete
- name: Invalidate CloudFront cache
run: >
aws cloudfront create-invalidation
--distribution-id ${{ secrets.CF_DISTRIBUTION_ID }}
--paths "/*"
This is the actual workflow we use. Let’s break down each piece.
S3 Sync With —delete
The --delete flag removes files from S3 that no longer exist in your build output. Without it, old files accumulate over time — outdated pages remain accessible, old assets waste storage, and your S3 bucket drifts from your build output.
aws s3 sync ./dist s3://your-bucket --delete
S3 sync is smart about unchanged files: it compares checksums and only uploads files that have actually changed. A typical deployment of a 50-page site uploads 5-10 files, not 500.
CloudFront Cache Invalidation
CloudFront caches your content at edge locations worldwide. When you deploy new content, you need to tell CloudFront to fetch fresh copies. The wildcard invalidation clears everything:
aws cloudfront create-invalidation --distribution-id YOUR_ID --paths "/*"
AWS gives you 1,000 free invalidation paths per month. A single /* wildcard counts as one path, so even if you deploy 20 times a day, you are well within the free tier.
Invalidation typically completes in 1-2 minutes. During that window, some users may see the old version — this is not a concern for most static sites. If you need atomic deployments, consider using S3 versioning with a Lambda@Edge function that serves from a specific version.
The IAM Policy
Your deployment user needs minimal permissions. Here is a least-privilege IAM policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::your-bucket",
"arn:aws:s3:::your-bucket/*"
]
},
{
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::YOUR_ACCOUNT:distribution/YOUR_DISTRIBUTION_ID"
}
]
}
Do not use your root account or an admin user for deployments. Create a dedicated IAM user with only these permissions and store the credentials as GitHub secrets.
S3 Bucket Configuration
Your S3 bucket needs:
- Static website hosting disabled — we serve through CloudFront, not S3 directly
- Block all public access enabled — only CloudFront should access the bucket
- Bucket policy granting CloudFront access via Origin Access Control (OAC):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-bucket/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::YOUR_ACCOUNT:distribution/YOUR_DISTRIBUTION_ID"
}
}
}
]
}
OAC is the modern replacement for Origin Access Identity (OAI). If you are still using OAI, consider migrating — OAC supports more S3 features and is the recommended approach.
CloudFront Configuration
Key settings for a static site distribution:
Default root object: index.html — so requests to / serve your homepage.
Custom error responses: Configure 403 and 404 errors to return /404.html with a 404 status code. S3 returns 403 (not 404) for missing objects when public access is blocked, so you need both.
SSL certificate: Use AWS Certificate Manager (ACM) to provision a free certificate for your domain. The certificate must be in us-east-1 regardless of where your bucket is.
Price class: If your audience is primarily in North America, use “Price Class 100” to reduce costs by limiting edge locations.
CloudFront Functions: Attach a Viewer Request function for URL rewrites (trailing slash normalization, redirects from old URLs, www/non-www consolidation). These run at the edge with sub-millisecond overhead.
Custom Error Page for SPAs
If you are deploying a single-page application (React, Vue, etc.) rather than a static site, configure CloudFront to return index.html for 404 errors with a 200 status code. This allows client-side routing to handle URLs like /dashboard/settings:
| Error Code | Response Page Path | Response Code | Cache TTL |
|---|---|---|---|
| 403 | /index.html | 200 | 0 |
| 404 | /index.html | 200 | 0 |
For a static site with pre-rendered pages (like Astro), you want to return your actual 404.html with a proper 404 status code so search engines know the page does not exist.
Cost Breakdown
For a static site with moderate traffic (50,000-100,000 page views/month):
| Service | Monthly Cost |
|---|---|
| S3 storage | ~$0.02 |
| S3 requests | ~$0.05 |
| CloudFront data transfer | ~$0.85 |
| CloudFront requests | ~$0.10 |
| Route 53 hosted zone | $0.50 |
| ACM certificate | Free |
| GitHub Actions | Free (within limits) |
| Total | ~$1.52 |
Compare this to a managed hosting platform at $20-50/month or a CMS like HubSpot at $300+/month. The performance is better, too — CloudFront serves content from 400+ edge locations with sub-100ms latency globally.
GitHub Secrets Setup
Store these as repository secrets in GitHub:
| Secret | Description |
|---|---|
AWS_ACCESS_KEY_ID | IAM user access key |
AWS_SECRET_ACCESS_KEY | IAM user secret key |
AWS_REGION | e.g., us-east-1 |
S3_BUCKET | Bucket name (not ARN) |
CF_DISTRIBUTION_ID | CloudFront distribution ID |
What This Gets You
After this one-time setup:
- Push to
mainand your site is live in under 2 minutes - No SSH, no FTP, no manual deploys
- Rollback by reverting a Git commit and pushing
- Full audit trail of every deployment via Git history
- Global CDN distribution with HTTPS
- Monthly cost under $2
The simplicity of this pipeline is the point. Static sites do not need Kubernetes, Docker, or blue-green deployments. They need a build step, a file copy, and a cache clear. Everything else is overhead.
KeyQ builds and manages cloud infrastructure on AWS for businesses that want to move fast without the operational burden. Let’s talk about your deployment pipeline.