JSTGTECH
← Back to blog

CI/CD for a static site: build, sync, and invalidate

5 min read

Deploying a static site sounds like it should be a solved problem: build it, copy the files to a bucket, done. In practice the pipeline has three separate failure modes that don’t show up until production — stale HTML served from cache, orphaned objects left in the bucket after a rename, and an invalidation bill that surprises you if you get cache-control wrong. This site’s own deploy pipeline (.github/workflows/deploy.yml) hits all three concerns in about 20 lines, so I’ll use it as the working example rather than a hypothetical.

The three stages, and why order matters

A static-site deploy is really three distinct jobs glued together:

  1. Build — turn source into a dist/ directory of static files.
  2. Sync — reconcile that directory with what’s in S3, including deleting anything that’s no longer there.
  3. Invalidate — tell CloudFront’s edge caches to stop serving the old versions of whatever changed.

They have to run in that order and each has to fully succeed before the next starts — a partial sync followed by an invalidation just serves a broken mix of old and new files faster. Keep them as separate steps (not one giant shell script) so a failure at step 2 shows up clearly in the Actions log instead of buried in a 40-line run: block.

- run: npm run build

- name: Sync to S3
  run: aws s3 sync ./dist "s3://my-bucket" --delete

- name: Invalidate CloudFront
  run: |
    aws cloudfront create-invalidation \
      --distribution-id "$DISTRIBUTION_ID" \
      --paths "/*"

That’s the naive version. It works, but it’s wrong in two specific ways — neither obvious until you look closely at cache-control and --delete together.

Sync: cache-control headers aren’t optional

aws s3 sync doesn’t set a useful Cache-Control header by default — S3 serves objects with no explicit caching directive, which browsers and CloudFront interpret conservatively. For a static site built by a bundler (Astro, Vite, Next static export, etc.), you want two very different caching policies in the same deploy:

  • Fingerprinted assets (/_astro/chunk-a1b2c3.js, hashed CSS, images) — the filename changes when the content changes, so it’s safe to cache these forever. public, max-age=31536000, immutable.
  • HTML, sitemap, RSS — the URL stays the same (/blog/index.html) but the content changes on every deploy. These need max-age=0, must-revalidate so a browser or CDN edge always re-checks before serving a cached copy.

You can’t set one blanket --cache-control flag for the whole sync and get both right, so split it into two sync calls with complementary --exclude/--include filters:

- name: Sync to S3
  run: |
    aws s3 sync ./dist "s3://$BUCKET" \
      --delete \
      --exclude "*.html" \
      --exclude "*.xml" \
      --cache-control "public,max-age=31536000,immutable"

    aws s3 sync ./dist "s3://$BUCKET" \
      --delete \
      --exclude "*" \
      --include "*.html" \
      --include "*.xml" \
      --cache-control "public,max-age=0,must-revalidate"

This is exactly what this site’s deploy.yml does. The --exclude/ --include pairs are inverses of each other on purpose — every object in dist/ is claimed by exactly one of the two calls, never both, never neither.

The --delete gotcha: it respects filters, both ways

Here’s the part that isn’t obvious from the CLI docs’ one-line description. aws s3 sync --delete removes destination objects that aren’t present in the source and match the command’s own filters. It does not delete everything in the bucket that the source lacks — it only considers objects within the scope defined by --exclude/--include for that specific invocation.

That’s exactly why running the two-pass sync above is safe: the first call (non-HTML) only ever deletes stale non-HTML objects, and the second call (HTML/XML) only ever deletes stale HTML/XML objects. Neither pass can accidentally delete the other’s files, because each treats them as excluded and therefore invisible.

The gotcha is what happens if your two filter sets aren’t exact complements — say you add a new file extension to the build output (a .webmanifest, a .txt) and forget to add it to either pass’s include list. It won’t get deleted when removed (each --delete ignores it), but it also won’t get the cache-control header you intended on either pass — it silently falls through both filters. Test a rename/removal locally against a scratch bucket (aws s3 sync --dryrun) whenever you touch the exclude patterns, not just when you add new ones.

Invalidate: scoped paths vs. wildcard, and the pricing surprise

The instinct once you’ve fixed caching is to invalidate narrowly — pass the exact paths that changed instead of /*, on the theory that a full wildcard invalidation is expensive because it touches every object at the edge. That instinct is backwards for CloudFront specifically, and it’s worth knowing why before you build a diff-based “only invalidate what changed” step.

CloudFront invalidation pricing is **per path string submitted in the request, not per object matched**. The first 1,000 paths per month are free; after that it’s $0.005 per path. --paths "/*" is one path as far as billing is concerned, regardless of how many thousands of objects it actually clears. Compare that to a “smart” pipeline that diffs the build and submits one path per changed file — a typical content update touching 15 files costs the same order of magnitude as 15 separate wildcard deploys, and if you ever invalidate per-object on a big rebuild (hundreds of pages) you can burn through the free tier in a single deploy.

For a low-traffic personal site or portfolio, /* on every deploy is both simpler and, counter-intuitively, usually cheaper than trying to be clever about scoping. It’s the right default. The one place scoping earns its complexity is a high-frequency deploy pipeline (many deploys per hour, e.g. a CMS with instant-publish) where wildcard invalidations would otherwise queue up and a --paths "/blog/*" "/index.html" pattern targeting only the collections that actually changed keeps the queue from backing up — CloudFront processes invalidations from a single distribution somewhat serially, and a backlog of full-site wildcards delays the one that matters.

- name: Invalidate CloudFront
  run: |
    aws cloudfront create-invalidation \
      --distribution-id "$DISTRIBUTION_ID" \
      --paths "/*"

If you do scope it, don’t hand-roll the diff from git diff --name-only — map source paths to routes, not files. A change to a shared layout component invalidates every page that uses it, not the one file that changed; a naive file-based diff will under-invalidate and leave stale pages in cache with no error to tell you it happened.

Rolling this out

Add the two-pass sync and cache-control split first, deploy once, and check response headers with curl -I against a fingerprinted asset and against / to confirm the immutable/must-revalidate split landed correctly before you touch the invalidation step. Then switch the invalidation from whatever ad-hoc scoping you had to /* and watch a billing cycle — for most personal and small-business traffic levels you’ll stay inside the free 1,000-path tier for months. Only reach for path-scoped invalidations once you have actual evidence (a CloudFront invalidation queue backing up, or genuinely exceeding the free tier) rather than optimizing against an assumption about cost that, for this specific service, runs the opposite direction from most people’s intuition.

Related posts