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, least-privilege IAM, and a real cost breakdown.
Deploying a website should not be a process.
No FTP uploads. No manual steps. No “did this actually go live?” moments. But for many teams, deployments are still slower and more fragile than they should be.
Slow or manual deployments create real risk. Bugs take longer to fix, rollbacks are harder, and small changes become unnecessarily expensive. Most teams overcomplicate deployments. In most cases, that complexity is unnecessary. Static sites do not need container orchestration, multi-stage deployment pipelines, or infrastructure that takes longer to understand than the application itself.
This is the deployment pattern we use across projects to keep deployments fast, predictable, and boring.
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.
This is one of those small details that matters more over time than on day one. Without --delete, your deployment slowly drifts out of sync with reality.
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 within a few 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.
What We Avoid
We intentionally avoid adding layers that do not solve a real problem:
- No containers for static sites
- No multi-stage deployment pipelines
- No infrastructure that requires constant maintenance
The goal is reliability through simplicity, not flexibility for its own sake.
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 |
This is often an order of magnitude cheaper than managed platforms, without sacrificing performance. Compare it to a managed hosting platform at $20-50/month or a CMS like HubSpot at $300+/month. CloudFront serves content from 400+ edge locations with sub-100ms latency in most regions. Actual costs will vary depending on traffic patterns.
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 |
Why This Setup Works
This pipeline removes deployment as a concern. Once it is in place, your team can focus on building, not shipping.
- Push to
mainand your site is live in 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 at moderate traffic
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.
If your deployment process feels more complicated than it should, it probably is. Most teams can simplify significantly without losing capability, and often improve reliability in the process. If you want a second set of eyes on your setup, we are happy to take a look.