Back to Blog
AWS CI/CD GitHub Actions CloudFront DevOps

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.

Automating Cloud Deployments with GitHub Actions, S3, and CloudFront

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:

  1. Static website hosting disabled — we serve through CloudFront, not S3 directly
  2. Block all public access enabled — only CloudFront should access the bucket
  3. 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 CodeResponse Page PathResponse CodeCache TTL
403/index.html2000
404/index.html2000

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):

ServiceMonthly 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 certificateFree
GitHub ActionsFree (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:

SecretDescription
AWS_ACCESS_KEY_IDIAM user access key
AWS_SECRET_ACCESS_KEYIAM user secret key
AWS_REGIONe.g., us-east-1
S3_BUCKETBucket name (not ARN)
CF_DISTRIBUTION_IDCloudFront distribution ID

What This Gets You

After this one-time setup:

  • Push to main and 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.