Skip to content
Go back

Deploying a static site to S3 and Cloudflare with GitHub Actions

Published:  at  03:30 PM

Push to main, and my portfolio is live about forty seconds later. No deploy platform, no monthly bill for the pipeline, just S3, Cloudflare, and a GitHub Actions workflow I can copy into any static project. Here is the whole thing.

What you get

A real deploy pipeline for a static site with three properties I care about. It is cheap, because S3 storage costs cents at this scale and Cloudflare’s CDN is free. It is fast, because the build and sync take under a minute. And it is boring, which for infrastructure is the highest compliment.

Every push to the main branch builds the site and ships it. Nothing to click.

The layout

The site is an Astro build that outputs a folder of static files. Those files live in an S3 bucket. Cloudflare sits in front for DNS, HTTPS, and edge caching, so visitors hit Cloudflare and S3 just holds the files.

The workflow

The Actions file does the obvious steps: check out, install, build, hand the built folder to a deploy action, then purge Cloudflare.

- uses: actions/checkout@v6
- uses: actions/setup-node@v6
  with:
    node-version: '24'
- run: npm ci
- run: npm run build

- name: Deploy to S3
  uses: ./.github/actions/deploy
  with:
    source_dir: './dist'
    bucket_name: ${{ secrets.S3_BUCKET }}
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    AWS_REGION: ${{ secrets.AWS_REGION }}

- name: Purge Cloudflare cache
  run: |
    curl -X POST \
      "https://api.cloudflare.com/client/v4/zones/${{ secrets.CF_ZONE_ID }}/purge_cache" \
      -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
      -H "Content-Type: application/json" \
      --data '{"purge_everything":true}'

The sync step is a small local action I wrote instead of pulling one off the marketplace. It runs in a Docker container built on the official amazon/aws-cli image, so the CLI is already there, and its entrypoint is four lines:

aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID"
aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY"
aws configure set default.region "$AWS_REGION"
aws s3 sync "$INPUT_SOURCE_DIR" "s3://$INPUT_BUCKET_NAME" --delete

The --delete flag matters. Without it, files you removed from the site stay live in the bucket forever.

Locking down the credentials

This pipeline needs two sets of secrets, and both should be scoped to almost nothing.

The AWS side gets an IAM user whose entire permission set is: write to this one bucket. Not admin, not full S3, just this bucket. The Cloudflare side gets an API token with a single permission, Cache Purge, on one zone. If either leaks, the worst anyone can do is redeploy or purge the cache of your marketing site.

Store the AWS keys, the bucket name, the Cloudflare token, and the zone ID as GitHub repository secrets. Scoping them down is the whole point.

The part people skip

S3 sync alone is not a deploy. Cloudflare caches your old files at the edge, so without a purge your visitors keep seeing the previous version until the cache expires on its own. That is the confusing bug where “I deployed but nothing changed.”

The purge_everything call clears it. On a busy site you would purge only the URLs that changed to keep more of the cache warm, but for a small site wiping everything on each deploy is fine and keeps the workflow simple. Copy the steps, scope the two secrets, and you have a production deploy pipeline you set up in an afternoon.



Next Post
You probably don't need a state management library