S3 + CloudFront Deploys: What Each Gate Refuses to Ship
This site's GitHub Actions pipeline read as a list of refusals: a drift check that blocks every deploy, secrets that live in Parameter Store, a build that won't fabricate traffic, two cache policies, and the one redundant sleep I kept on purpose.
A deploy pipeline is a list of things you refuse to ship. The caching, the log summaries, the emoji: furniture. What matters is which failures the pipeline stops and which it lets through, and why the line sits where it does. This post reads this site's GitHub Actions workflow as that list.
(Updated September 2026 — rewritten from a step-by-step walkthrough into the decision behind each gate. The pipeline itself is unchanged. Updated again later that month: Gate 3, the GA4 view-count fetch, was retired from the build; the section stays as the record of why it existed.)
The shape
Every push to main runs three jobs. Two run in parallel, and both have to pass before the third starts:
infra_drift_check ──┐
├──▶ deploy: Parameter Store → build → S3
checks ─────────────┘ → invalidate CloudFront → smoke tests
(typecheck, lint)
The deploy job is the only one that touches AWS in a way that changes anything. Everything before it exists to refuse.
Gate 1: the template matches the stack, byte for byte
Before anything builds, a job downloads the deployed CloudFormation template and diffs it against infrastructure/cloudformation.yml in the repo. Not drift in the CloudFormation-console sense; a literal comparison of the two documents after normalization.
infra_drift_check:
name: Check CloudFormation Drift
runs-on: ubuntu-latest
steps:
- name: Verify CloudFormation template matches deployed stack
run: ./scripts/check-cloudformation-drift.sh
Any difference fails the job. Because deploy depends on it, any difference blocks every deploy, including a deploy that only adds a blog post.
That has teeth. The repo's history includes a commit whose only job is to re-sync the template with the deployed stack so deploys could move again. The template carries a hand-maintained environment variable for the view-count Lambda: a comma-separated list of post slugs. Edit that list in the repo without running the coordinated stack-deploy script, and the next content push fails at the drift check with nothing wrong in the content. The repo's agent guidelines carry a bolded warning about exactly that trap, because it is the kind of thing you rediscover at the worst possible moment. The rule that fell out of it: adding a post never touches the template. A stale slug in the Lambda's list is harmless (the list is stale today, in fact). A template that differs from the stack is not.
The alternative is letting infrastructure and code drift and reconciling them later, which is how a "content-only" deploy ends up riding on a CloudFront behavior nobody reviewed. I'd rather block a post than ship an unreviewed edge config.
Gate 2: secrets come from Parameter Store, and GitHub only holds the key
The deploy job reads its configuration from AWS Systems Manager Parameter Store at run time. Today that is one public value, the GA4 measurement ID for client-side tracking. Until the view-count fetch was retired, it was also the GA4 service account, masked before anything else could log it:
- name: Load environment variables from Parameter Store
run: |
GA4_SERVICE_ACCOUNT=$(aws ssm get-parameter \
--name "/asimon-blog/prod/ga4-service-account" \
--with-decryption \
--query "Parameter.Value" \
--output text)
# Mask in logs
echo "::add-mask::$GA4_SERVICE_ACCOUNT"
# Export for subsequent steps
echo "GA4_SERVICE_ACCOUNT=$GA4_SERVICE_ACCOUNT" >> $GITHUB_ENV
The decision was where the canonical copy lives. GitHub Secrets would have been fewer moving parts. Parameter Store won because more than one consumer needed the same values: this workflow and the Lambda that refreshes counts between deploys, and for a while the build script too. One store, one rotation, one audit trail in CloudTrail, and GitHub holds only the AWS credentials and resource identifiers. The workflow's role can read one path prefix and nothing else:
{
"Effect": "Allow",
"Action": ["ssm:GetParameter"],
"Resource": "arn:aws:ssm:us-east-2:*:parameter/asimon-blog/prod/*"
}
Gate 3 (retired): the build refused to fabricate traffic
Before the build, a script queried the GA4 Data API and wrote a per-post view-count snapshot that the site read at build time. The script failed the build if GA4 couldn't be reached. There was one override, a flag that substituted deterministic mock counts, and it existed for local builds where no credentials were present.
That flag was the sharpest edge in the pipeline. A local static build with mock data enabled overwrote the committed snapshot with fabricated numbers that looked entirely plausible in a diff, and the next commit carried them into the repo's data layer. The GA4 post covers that failure mode in detail. The production escape hatch was cleaner: when GA4 itself was down, a manual run with skip_ga4 wrote an empty snapshot, zero counts and no fabricated ones.
The step is gone. With nothing displaying the counts, the cleanest fix for a step that could fabricate data was to delete it, along with the flag, the secret load, and the snapshot file. The Lambda that refreshes counts between deploys still runs. A gate that guards a value nobody reads is not a refusal; it's a cost.
Gate 4: two cache policies, because one is wrong for half the files
- name: Deploy to S3
run: |
# Static assets: cache forever (hashed filenames)
aws s3 sync out/ s3://$BUCKET_NAME/ \
--delete \
--cache-control "public, max-age=31536000, immutable" \
--exclude "*.html" \
--exclude "*.txt"
# HTML files: cache briefly (content changes); --delete scoped to them
aws s3 sync out/ s3://$BUCKET_NAME/ \
--delete \
--exclude "*" \
--include "*.html" \
--include "*.txt" \
--cache-control "public, max-age=3600"
| File Type | Cache Duration | Why | |-----------|---------------|-----| | JS/CSS/Images | 1 year | Hashed filenames change on content change | | HTML | 1 hour | Content updates need to propagate | | XML (Atom) | 1 hour | Feed readers expect fresh content |
Next.js hashes asset filenames, so a one-year immutable policy on them is safe: a changed file is a new URL. HTML lives at a stable path and changes on every deploy, so it gets an hour. --delete keeps the bucket equal to the build output, which is what makes the drift check meaningful for content too: what's in S3 is what's in out/, nothing older. That sentence was false until September 2026. Only the asset sync carried --delete; the HTML sync did not, so when the knowledge-graph route was removed, its page kept serving from S3 for a day until I noticed. The second sync now scopes --delete to HTML and text with --exclude "*" first, and a deleted route is a deleted page.
One more step runs after the sync and re-uploads XML files with an explicit content type, because the Atom feed's type came through wrong without it and feed readers are unforgiving about that. It's the kind of step that only exists because the alternative was observed.
Gate 5: the invalidation blocks, and the sleep stays anyway
- name: Invalidate CloudFront
run: |
INVALIDATION_ID=$(aws cloudfront create-invalidation \
--distribution-id $DISTRIBUTION_ID \
--paths "/*" \
--query "Invalidation.Id" \
--output text)
# Wait for completion
aws cloudfront wait invalidation-completed \
--distribution-id $DISTRIBUTION_ID \
--id $INVALIDATION_ID
The wait blocks until the invalidation reports complete across edge locations. It adds 30–60 seconds to every deploy, and it's what lets the smoke tests trust that they're looking at the new build. The smoke-test step also carries its own sleep 30. That's redundant next to the blocking wait, and I've kept it deliberately: it is cheap insurance against the edge that reports complete a beat early, and thirty seconds is a price I'll pay to avoid chasing a phantom test failure.
Invalidation Costs
CloudFront includes 1,000 free invalidation paths per month. Using /* counts as one path, so deploying often costs nothing here.
Gate 6: smoke tests against the live site, after the fact
The last step runs Playwright against production:
- name: Run post-deployment smoke tests
run: |
sleep 30 # Wait for CDN propagation
npx playwright test --project=production-smoke --reporter=list
They check that the homepage returns 200, posts render, the www redirect works, security headers are present, and the Atom feed parses. A failure here does not roll anything back: the site is already live, and the pipeline tells you so. That is the right tier for a content site, and the Playwright post makes the case for why alert-and-investigate beats block-and-page at this stage. On a revenue system I would draw that line differently.
What each failure leaves behind
The pipeline is arranged so that no failure leaves production in a worse state than before the push:
- Drift check or lint fails → nothing runs; S3 untouched.
- Build fails → deploy stops; S3 untouched.
- S3 sync fails partway → CloudFront keeps serving cached content until the next successful deploy.
- Invalidation fails → old content is served for up to an hour, then expires naturally.
- Smoke tests fail → site is live; you have a failing check and a report artifact to read.
One manual input exists for the days when the pipeline's refusals are wrong: skip_tests, for a hotfix that can't wait for Playwright. It is workflow_dispatch only and never runs on a normal push.
What it costs, in time
A typical deployment:
| Step | Duration | |------|----------| | Drift check + typecheck/lint gates (parallel) | ~90s | | Checkout + Setup | ~15s | | Cache restore | ~5s | | Install deps | ~30s (cached) | | Build | ~30s | | S3 upload | ~20s | | CloudFront invalidation | ~45s | | Post-deploy tests | ~30s | | Total | ~4–5 minutes |
Push to live in under five minutes (recent runs: 3m35s–5m14s wall clock). About a third of that is refusals.
What the pipeline doesn't do
No rollback. No staging environment. No preview deploys per branch. For a static content site those would be gates with no failure behind them, and every gate has an operating cost: the drift check alone has stopped deploys until the template was re-synced with the stack. The workflow file is roughly 400 lines of YAML, and the deploy itself is three commands (build, sync, invalidate). The other several hundred lines are the refusals, and each one is there because I'd seen the alternative.
That is the same rule I apply to buy-vs-build decisions far from CI/CD: operational complexity survives the build. The pipeline you build is the pipeline you run, so build only the gates you're willing to be stopped by.
Next: What Blocks a Deploy covers the release-gate design that decides which test failures stop this pipeline and which only alert.