Blue-Green Deploys for Static Sites

On a managed static host, every deploy is already atomic: the platform uploads the new version in full and switches to it in one step, keeping the old one for rollback. Self-hosted static sites — an S3 bucket behind CloudFront, a VM behind a load balancer, object storage in a regulated cloud account — do not get that for free. A deploy that syncs files into the live bucket has a window in which some pages are new and some old, and a rollback means syncing the old files back while readers watch.

Blue-green deployment restores atomicity and instant rollback with a simple idea: keep two complete copies of the site, deploy to the idle one, verify it, then switch all traffic at once. This guide implements blue-green for a documentation site on S3 and CloudFront, with verification before the switch, the switch itself as a CloudFront origin path change, rollback in one command, and measurements from a year of use. It is part of Rollbacks and Deploy Safety for Static Sites, and compares with the approaches in Atomic Deploys vs Incremental Uploads.

Prerequisites

  • A static site hosted on S3 with CloudFront (the same pattern works with GCS and Cloud CDN, or Azure Storage and Front Door).
  • CI with credentials that can write to the bucket and update the distribution, ideally via OIDC as in Securing Deploy Credentials with GitHub OIDC.
  • Smoke tests that can run against an arbitrary base URL.

The Layout

One bucket holds two prefixes, blue/ and green/, each a complete copy of the site. CloudFront has one origin pointing at the bucket, with an origin path of either /blue or /green. A small state file in the bucket, live.json, records which colour is live and which commit it contains. A second CloudFront distribution — or a second behaviour on a staging hostname — always points at the idle colour, so it can be tested through the same CDN path readers use.

Blue-green layout for a static site on S3 and CloudFront The production CloudFront distribution for docs.example.com points at origin path /blue, which holds the live version. A staging hostname points at /green, which holds the idle copy where the next version is deployed and verified. A live.json file records which colour is live. Switching changes the production origin path to /green in one update. Two environments behind one CDN docs.example.com production distribution origin path /blue (live) blue/ prefix complete site, commit 4c1e9a2 serving readers green/ prefix complete site, commit 9b7d013 deployed and verified next.docs.example.com staging distribution always points at the idle colour
Both colours are complete sites; nothing is ever uploaded into the live prefix.

Step 1: Deploy to the Idle Colour

The deploy job reads live.json, picks the other colour, and syncs the build into that prefix — here with a full sync, since the prefix is not serving anyone:

LIVE=$(aws s3 cp s3://docs-bucket/live.json - | jq -r .colour)
IDLE=$([ "$LIVE" = blue ] && echo green || echo blue)
aws s3 sync dist/ "s3://docs-bucket/$IDLE/" --delete --only-show-errors \
  --exclude '*.html' --cache-control 'public, max-age=31536000, immutable'
aws s3 sync dist/ "s3://docs-bucket/$IDLE/" --delete --only-show-errors \
  --exclude '*' --include '*.html' --cache-control 'public, max-age=0, s-maxage=300, stale-while-revalidate=86400'
echo "IDLE=$IDLE" >> "$GITHUB_ENV"

The idle prefix may take a minute to upload; nobody sees it until the switch. The staging distribution serves it at next.docs.example.com for verification.

Step 2: Verify Before Switching

The same smoke tests that run after production deploys, as in Running Smoke Tests Against a Preview URL, run against the staging hostname: the version stamp matches the commit, twenty key pages return 200 with expected headings, the redirect and header table passes, and every asset referenced from the home page and three templates returns 200. The switch only happens if they pass. On this site the checks took 48 seconds and blocked four deploys in a year — two broken builds that passed CI because a template error only appeared with production data, and two missing redirects.

Sequence of deploying, verifying and switching a blue-green release CI reads live.json and finds blue live. It uploads the new build to the green prefix. Smoke tests run against the staging hostname that serves green. When they pass, CI updates the production distribution's origin path from /blue to /green and writes live.json. CloudFront propagates the change in about a minute and readers are served green. Blue stays intact for rollback. One blue-green release CI deploy job S3 bucket blue/ and green/ Staging host serves idle colour CloudFront prod docs.example.com read live.json → blue live upload build to green/ smoke tests against green origin path /blue → /green write live.json (green)
Readers never see a partially uploaded site, and rollback is the same switch in reverse.

Step 3: Switch Atomically

The switch is one distribution update changing the origin path, followed by an invalidation of HTML so edge caches do not serve blue's pages for up to their s-maxage:

aws cloudfront get-distribution-config --id "$DIST" > cfg.json
ETAG=$(jq -r .ETag cfg.json)
jq --arg p "/$IDLE" '.DistributionConfig.Origins.Items[0].OriginPath = $p | .DistributionConfig' cfg.json > new.json
aws cloudfront update-distribution --id "$DIST" --if-match "$ETAG" --distribution-config file://new.json
aws cloudfront wait distribution-deployed --id "$DIST"
aws cloudfront create-invalidation --distribution-id "$DIST" --paths '/*.html' '/' '/*/'
printf '{"colour":"%s","sha":"%s","at":"%s"}' "$IDLE" "$GITHUB_SHA" "$(date -u +%FT%TZ)" | aws s3 cp - s3://docs-bucket/live.json

Hashed assets have different names across versions, so no asset invalidation is needed. Distribution updates propagated in 40–90 seconds; during that window, some edge locations serve blue and some green, but every edge serves a complete, consistent version, which is the property that matters.

Step 4: Roll Back

Rollback is the same switch pointed at the previous colour, which is still intact:

./switch.sh "$(aws s3 cp s3://docs-bucket/live.json - | jq -r '.colour | if . == "blue" then "green" else "blue" end')"

It took 70 seconds end to end on average, with no rebuild and no upload. The previous colour is only overwritten by the next deploy, so there is always exactly one known-good version to return to. For more than one step back, keep a third prefix or versioned prefixes (releases/9b7d013/) and switch the origin path to any of them, which generalizes blue-green to a list of releases as in Zero-Downtime Deploys with Symlink Swaps.

The Cost of Two Copies

Blue-green doubles storage: the site's 1.4 GB became 2.8 GB, about 0.03 USD a month extra on S3. Deploys upload a full copy rather than only changed files, which took 64 seconds instead of 6 with the changed-files approach from Deploying Only Changed Files. The two can be combined — copy the live prefix to the idle prefix server-side with aws s3 sync s3://bucket/blue s3://bucket/green, then upload only changed files — which brought the upload back to about 15 seconds.

Deploy and rollback time with in-place sync and with blue-green Paired bars comparing in-place sync with blue-green. Rollback took 240 seconds with in-place sync of the previous build and 70 seconds with a blue-green switch. Time during which readers could see a mix of old and new pages was about 190 seconds with in-place sync and 0 with blue-green. Deploy and rollback times In-place sync Blue-green switch Rollback time 240 s 70 s Mixed-version window 190 s 0 s
Medians over a year of deploys; rollback measured from command to all edges serving the previous version.

Operating Blue-Green Well

A few habits make the pattern reliable. The deploy job holds a concurrency lock (as in Concurrency Groups to Cancel Stale Builds) so two deploys never write to the idle colour at once. live.json is the single source of truth, and the switch script refuses to run if the distribution's actual origin path disagrees with it — a mismatch means someone changed the distribution by hand. A daily check confirms that both prefixes contain a complete site with a valid version stamp, so a rollback target is never discovered to be broken during an incident. And the staging hostname is behind authentication and noindex, since it serves a full copy of the site.

Measured Impact

One year before (in-place s3 sync) and after (blue-green):

MeasureIn-place syncBlue-green
Deploys where readers saw mixed versionsevery deploy (~190 s window)none
Broken deploys reaching readers50 (4 blocked by pre-switch checks)
Median rollback time4 min (re-sync previous build)70 s
Rollbacks performed32
Extra storage cost per month—~0.03 USD

Pitfalls & Rollback

  • Deploying into the live colour by mistake. Derive the idle colour from live.json and verify before writing.
  • Forgetting to invalidate HTML after the switch. Edges keep serving the old colour's pages until s-maxage expires.
  • Testing the idle colour directly from S3. Test through the CDN path so headers and routing match production.
  • Two deploys racing. Serialize deploys so the idle colour has one writer.
  • Rollback: switch back to the previous colour; to abandon blue-green entirely, point the origin path at the live colour and deploy in place again.

Conclusion

Blue-green gives a self-hosted static site the same guarantees managed hosts provide: readers only ever see complete versions, a new version is verified through the real CDN before it goes live, and rollback is a one-step switch to a version that is still sitting there. On this site it removed the mixed-version window from every deploy, stopped four broken releases before readers saw them, and cut rollback to about a minute for a few cents a month.

FAQ

What is a blue-green deploy?

A release technique with two complete environments. One serves production traffic while the new version is deployed to the other and verified; then traffic switches to the new environment in one step. The old environment stays intact, so rollback is switching back.

Do managed static hosts need blue-green deploys?

Not usually. Netlify, Vercel, Cloudflare and GitHub Pages already deploy atomically and keep previous versions for instant rollback. Blue-green is most useful for self-hosted sites on object storage or servers, where uploads are not atomic by default.

Where should the switch happen?

At the layer that all traffic passes through and that changes atomically, typically the CDN origin or origin path setting, a load balancer target group, or a symlink on a server. Switching DNS works but is slow and uneven because of caching.

What should be verified before switching?

That the new environment serves the expected version, that key pages return 200 with the right content, that redirects and headers behave correctly, and that assets referenced by the HTML exist. The same smoke tests used after deploy can run against the idle environment before the switch.

Filed underHow-tosAWS (S3 and CloudFront)