Blue-Green Asset Deploys with Fingerprinted Files
Blue-green deployment is normally explained with two identical server fleets and a load balancer that flips between them. A fingerprinted static bundle does not behave like a server fleet: every file name already contains a digest of its bytes, so two releases can live in the same directory forever without ever colliding. Once you accept that, the shape of the problem changes — there is nothing to duplicate, nothing to drain, and the only thing that genuinely has two colours is the HTML document that names the hashes.
The property that collapses the two environments
A content-hashed file is content-addressed. app.a1b2c3d4.js is not “version 42 of the app bundle”; it is those exact bytes, and any build that produces those bytes produces that name. Two consequences follow directly:
- Uploads are additive. Publishing release 43 into a prefix that already holds release 42 never overwrites anything, because a changed file gets a new name and an unchanged file gets the same name with identical bytes. There is no in-place mutation to be atomic about.
- Generations overlap for free. A browser holding a page from release 42 keeps requesting release 42’s hashes. They are still there. Nobody has to route that browser anywhere special.
So the “blue” and “green” halves of a static deploy are not two copies of the asset tree. They are two HTML documents pointing into one shared, append-only asset tree — which is why the whole exercise costs a few hundred kilobytes of duplicated HTML rather than a second copy of your bundle.
What actually collides: the unhashed remainder
The additive model holds only for files whose names carry a digest. Every build ships a remainder that does not: index.html, sw.js, manifest.webmanifest, robots.txt, favicons, and anything copied verbatim out of public/. Those names are fixed, so writing release 43’s copy destroys release 42’s copy. That remainder is the entire justification for a real two-prefix blue/green.
Four other situations break the shared prefix as well, and they are worth recognising before you assume additive is always enough:
- The hash function or length changed. Switching a bundler from MD5-truncated-to-8 to xxhash, or from 8 to 12 characters, renames every file. The old names remain valid, but the sweeper logic and the manifest diff become meaningless across the boundary.
publicPathor the CDN prefix changed. Release 43’s manifest now names/static/v2/app.b5e6f7a8.js; release 42’s browsers still ask for/assets/app.a1b2c3d4.js. Both paths have to answer during the overlap.- A fixed-name file changed meaning. A service worker precache list, a
/assets/manifest.json, animportmap.json— a single fixed name that release 42’s JavaScript will fetch at runtime and get release 43’s contents. This is the failure behind service workers serving stale HTML with new hashes. - You need a clean deletion boundary. If compliance requires that a withdrawn release stop being reachable, “additive and never delete” is the wrong model; a per-release prefix you can drop in one call is the right one.
Strategy comparison
| Strategy | Cutover time | Rollback time | Storage cost | Complexity |
|---|---|---|---|---|
Additive /assets/ + HTML swap |
One object write, ~1 s | One object write, ~1 s | Delta only — shared chunks stored once | Lowest; no routing layer |
Two prefixes /assets/blue/, /assets/green/ |
One object write, ~1 s | One object write, ~1 s | 2× the full bundle, always | Moderate; build must know its slot |
Per-release /r/<release-id>/ |
One object write, ~1 s | One object write, ~1 s | N× the full bundle for N retained releases | Moderate; sweeper is trivial |
| Two origins + CDN origin switch | 3–20 min for the distribution to redeploy | Same 3–20 min | 2× bundle plus a second bucket | High; config API, ETag races |
| Edge worker routing | Instant on the next request | Instant on the next request | Delta only | Highest; a worker is now in the request path |
Read the rollback column first. The three swap-a-document rows roll back at the speed of one PUT plus one HTML purge, which is the property you are actually buying. The origin-switch row is the one that looks like classic blue-green and performs worst: a CloudFront distribution update propagates in minutes, so your worst-case incident is bounded by AWS’s config plane rather than by your own pipeline. Use it only when the two colours differ in ways a path prefix cannot express, such as a different origin type entirely.
The two-prefix and per-release rows differ mainly in retention. blue/green gives you exactly one previous generation and reuses each slot on every second deploy — which means the slot you are about to overwrite must be provably unreferenced. A per-release directory keyed by a timestamped identifier never reuses a name, so the sweeper decides retention by age instead of by parity. Unless you are storage-constrained, prefer the per-release directory for the unhashed remainder and keep the hashed files additive in one shared prefix. That hybrid is the configuration the rest of this page builds.
The ordering rule that has no exceptions
Assets first, HTML last. Always, in every strategy above, including rollbacks and including canaries. The HTML is the only artefact that names hashes, so it is the only artefact whose publication can create a request for something that does not exist yet.
The verification step is not ceremony. Object stores are eventually consistent for listings, multi-region buckets replicate asynchronously, and a partially-uploaded sync leaves a subset of the new hashes live. Checking that every URL in the new manifest resolves is the cheapest way to turn “probably finished” into “finished”. The manifest is what makes that check possible at all — see asset manifest generation for the shapes and how to emit one.
Cutover mechanisms
HTML swap at the origin. Copy the parked document over the served one and purge that single path. This is the default and the one to reach for first. It requires that HTML is served with a short, revalidating TTL so the edge picks up the new object promptly — the settings are covered in cache-control, immutable and TTL tuning, and pairing a short max-age with stale-while-revalidate keeps the swap from becoming a latency spike.
CDN origin switch. Repoint the distribution’s origin path at the new release directory. On CloudFront:
#!/usr/bin/env bash
set -euo pipefail
DISTRIBUTION=E1234567890ABC
RELEASE=20260729T101500Z-9f2c41ab77de
aws cloudfront get-distribution-config --id "$DISTRIBUTION" > /tmp/dist.json
ETAG=$(jq -r .ETag /tmp/dist.json)
# Only the default (HTML) behaviour's origin moves. The /assets/* behaviour
# must be bound to a second origin whose OriginPath stays empty, or the swap
# will relocate the immutable prefix too and orphan every cached hash.
jq --arg p "/releases/$RELEASE" \
'.DistributionConfig.Origins.Items
|= map(if .Id == "html-origin" then .OriginPath = $p else . end)
| .DistributionConfig' /tmp/dist.json > /tmp/new-config.json
aws cloudfront update-distribution --id "$DISTRIBUTION" \
--if-match "$ETAG" --distribution-config file:///tmp/new-config.json \
--query 'Distribution.Status' --output text
The --if-match guard is mandatory: two concurrent deploys without it silently clobber each other’s configuration. The comment in that script is the trap people hit — moving OriginPath on the behaviour that also serves /assets/* breaks every previously issued hash at once, which is the exact opposite of what blue-green is for.
Edge worker routing. A worker in front of the origin decides which HTML generation a request receives, while letting hashed assets fall through untouched. This is the only mechanism that supports a genuine percentage split.
// Cloudflare Worker — routes HTML to a release slot, leaves hashed assets alone.
const SLOTS = { blue: '20260718T090300Z-4c81de20aa19', green: '20260729T101500Z-9f2c41ab77de' };
const GREEN_SHARE = 0.1;
export default {
async fetch(request) {
const url = new URL(request.url);
// Immutable, content-addressed: identical for every slot. Never rewrite.
if (url.pathname.startsWith('/assets/')) {
return fetch(request);
}
const cookie = request.headers.get('Cookie') || '';
const pinned = /(?:^|;\s*)slot=(blue|green)/.exec(cookie);
const slot = pinned ? pinned[1] : (Math.random() < GREEN_SHARE ? 'green' : 'blue');
const doc = url.pathname.endsWith('/') ? `${url.pathname}index.html` : url.pathname;
const origin = new URL(`/releases/${SLOTS[slot]}${doc}`, url.origin);
const upstream = await fetch(new Request(origin, request));
const response = new Response(upstream.body, upstream);
response.headers.set('Cache-Control', 'no-store');
response.headers.set('X-Release-Slot', slot);
response.headers.append('Set-Cookie', `slot=${slot}; Path=/; Max-Age=3600; SameSite=Lax`);
return response;
},
};
The cookie pin matters more than the split percentage. Without it a single user reloading twice gets two different HTML generations, and any page that lazily imports a chunk after a soft navigation will request a hash from the generation it did not start in. Pinning for an hour keeps a session inside one generation for its whole life.
The deploy workflow
This is the complete pipeline: build, publish additively, verify, park the release HTML, then promote. The same workflow performs a rollback — you dispatch it with a promote input naming an older release identifier, and it skips straight to the cutover job.
# .github/workflows/blue-green-assets.yml
name: Blue-green asset deploy
on:
push:
branches: [main]
workflow_dispatch:
inputs:
promote:
description: Existing release id to promote (blank builds a new one)
required: false
default: ''
concurrency:
group: asset-deploy
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
BUCKET: my-app-assets
CDN: https://cdn.example.com
DISTRIBUTION: E1234567890ABC
AWS_REGION: eu-west-1
ROLE: arn:aws:iam::111122223333:role/asset-deploy
jobs:
build:
if: github.event.inputs.promote == ''
runs-on: ubuntu-24.04
outputs:
release: ${{ steps.ident.outputs.release }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.11.1
cache: npm
- run: npm ci
- run: npm run build
- id: ident
run: echo "release=$(date -u +%Y%m%dT%H%M%SZ)-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: dist-${{ github.run_id }}
path: dist
retention-days: 30
publish:
if: github.event.inputs.promote == ''
needs: build
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
with:
name: dist-${{ github.run_id }}
path: dist
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.ROLE }}
aws-region: ${{ env.AWS_REGION }}
- name: Sync hashed assets additively
run: |
aws s3 sync dist/assets "s3://$BUCKET/assets/" \
--no-progress --size-only \
--cache-control 'public, max-age=31536000, immutable'
- name: Park the release without serving it
env:
RELEASE: ${{ needs.build.outputs.release }}
run: |
aws s3 cp dist/index.html "s3://$BUCKET/releases/$RELEASE/index.html" \
--cache-control 'no-store' --content-type 'text/html; charset=utf-8'
aws s3 cp dist/.vite/manifest.json \
"s3://$BUCKET/releases/$RELEASE/manifest.json" --cache-control 'no-store'
- name: Every hash in the new manifest must already resolve
run: |
set -euo pipefail
jq -r '.[] | .file, (.css // [])[]' dist/.vite/manifest.json | sort -u > /tmp/urls
while read -r f; do
code=$(curl -s -o /dev/null -w '%{http_code}' "$CDN/$f")
[ "$code" = 200 ] || { echo "MISSING $f -> $code"; exit 1; }
done < /tmp/urls
echo "$(wc -l < /tmp/urls) assets confirmed live"
cutover:
needs: [build, publish]
if: always() && needs.publish.result != 'failure' && needs.publish.result != 'cancelled'
runs-on: ubuntu-24.04
environment: production
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.ROLE }}
aws-region: ${{ env.AWS_REGION }}
- name: Promote the release HTML
env:
RELEASE: ${{ github.event.inputs.promote || needs.build.outputs.release }}
run: |
set -euo pipefail
test -n "$RELEASE"
aws s3 cp "s3://$BUCKET/releases/current.json" \
"s3://$BUCKET/releases/previous.json" --cache-control 'no-store' || true
aws s3 cp "s3://$BUCKET/releases/$RELEASE/index.html" "s3://$BUCKET/index.html" \
--metadata-directive REPLACE \
--content-type 'text/html; charset=utf-8' \
--cache-control 'public, max-age=60, stale-while-revalidate=30'
printf '{"release":"%s"}\n' "$RELEASE" > /tmp/current.json
aws s3 cp /tmp/current.json "s3://$BUCKET/releases/current.json" \
--content-type 'application/json' --cache-control 'no-store'
- name: Invalidate the HTML only
run: |
aws cloudfront create-invalidation --distribution-id "$DISTRIBUTION" \
--paths '/index.html' '/releases/current.json' \
--query 'Invalidation.Id' --output text
Three details carry the design. concurrency.group with cancel-in-progress: false serialises deploys so two cutovers cannot interleave. The invalidation lists exactly two paths — never /assets/* — because immutable objects have nothing to invalidate and wildcard invalidations cost money and time. And the artefact retention of 30 days is what makes promote usable weeks later without a rebuild; the broader mechanics are in rolling back fingerprinted assets in CI/CD.
Verifying that both generations resolve during the overlap
The interesting failure is not “the new release is broken” — CI catches that. It is “the new release is fine and the old one silently stopped working”, which is what an over-eager sweeper or a --delete flag produces. Run this against production after every cutover, and on a schedule while a canary is live:
#!/usr/bin/env bash
# overlap-check.sh — every asset of BOTH live generations must still answer 200.
set -euo pipefail
CDN=https://cdn.example.com
CURR=$(curl -fsS "$CDN/releases/current.json" | jq -r .release)
PREV=$(curl -fsS "$CDN/releases/previous.json" | jq -r .release)
echo "current=$CURR previous=$PREV"
status=0
for rel in "$PREV" "$CURR"; do
bad=0
total=0
while read -r f; do
total=$((total + 1))
read -r code cc < <(curl -sI "$CDN/$f" \
| tr -d '\r' \
| awk 'NR==1{c=$2} tolower($1)=="cache-control:"{$1=""; sub(/^ /,""); h=$0} END{print c, h}')
if [ "$code" != 200 ]; then
echo " BROKEN $rel $f -> $code"
bad=$((bad + 1))
elif [ "${cc#*immutable}" = "$cc" ]; then
echo " WEAK $rel $f -> cache-control: $cc"
fi
done < <(curl -fsS "$CDN/releases/$rel/manifest.json" \
| jq -r '.[] | .file, (.css // [])[]' | sort -u)
echo "$rel: $total assets, $bad broken"
[ "$bad" -eq 0 ] || status=1
done
exit "$status"
A non-zero exit means a browser still holding the previous document is about to hit a 404 on its next lazy chunk. That is the condition to page on, and it is far more common than a bad new release.
Retention and the sweeper that must not delete too much
Additive deploys grow the prefix forever, so something eventually has to remove old objects. The rule is not “delete anything older than N days” — it is “delete anything that no retained release manifest names, where the oldest retained release is older than the longest possible lifetime of an HTML document in a browser cache.”
The horizon is longer than people expect. A document served with max-age=60 is not gone after 60 seconds: a suspended browser tab, an offline-capable service worker, or a corporate proxy can hold it for days. Anchor the horizon on the longest of those, not on the header. A mark-and-sweep job that respects both constraints:
#!/usr/bin/env bash
# sweep-assets.sh — remove objects no retained release manifest names.
# Dry run by default; pass --apply to delete.
set -euo pipefail
BUCKET=my-app-assets
KEEP=10 # retained generations; the oldest must predate the delete horizon
# Release ids are timestamp-prefixed, so lexicographic order is chronological.
mapfile -t RELEASES < <(
aws s3 ls "s3://$BUCKET/releases/" \
| awk '/PRE/ {print $2}' | tr -d '/' \
| grep -E '^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}$' \
| sort | tail -n "$KEEP"
)
[ "${#RELEASES[@]}" -ge 2 ] || { echo "refusing to sweep with < 2 manifests"; exit 1; }
: > /tmp/referenced
for rel in "${RELEASES[@]}"; do
aws s3 cp "s3://$BUCKET/releases/$rel/manifest.json" - \
| jq -r '.[] | .file, (.css // [])[]' >> /tmp/referenced
done
sort -u /tmp/referenced -o /tmp/referenced
aws s3 ls "s3://$BUCKET/assets/" --recursive | awk '{print $4}' | sort -u > /tmp/present
comm -23 /tmp/present /tmp/referenced > /tmp/orphans
echo "retained=${#RELEASES[@]} present=$(wc -l < /tmp/present) orphaned=$(wc -l < /tmp/orphans)"
if [ "${1:-}" = "--apply" ]; then
while read -r key; do aws s3 rm "s3://$BUCKET/$key"; done < /tmp/orphans
fi
Two guards earn their place. The job aborts if fewer than two manifests were found, because a listing failure that returns zero manifests would otherwise mark the entire prefix as orphaned and delete the live release. And it is dry-run by default, printing counts you can sanity-check against the previous run before anything is removed. Enable object versioning on the bucket regardless; it is the only cheap undo for a sweeper bug.
The manifest paths must be normalised to bucket keys before the comm. If your manifest stores /assets/app.a1b2c3d4.js with a leading slash while aws s3 ls prints assets/app.a1b2c3d4.js, every object looks orphaned. Assert that overlap in CI rather than discovering it at 3 a.m.
Canary: two documents live at once
A percentage split makes the overlap deliberate rather than incidental. Both generations are served simultaneously, so both asset sets must resolve for the whole canary window — which is exactly what overlap-check.sh asserts. Three things change under a canary:
- The sweeper must be paused or made canary-aware.
current.jsonnames one release; during a split, two are live. Write both intocurrent.jsonas an array, and have the sweeper union them. Varyand cookie-based routing interact badly with caching. The HTML must not be cached at a shared edge keyed without the slot cookie, or a fraction of users receive the other generation’s document. Serving the split document withno-storeat the edge, as the worker above does, is the simple answer.- Analytics needs the slot. The
X-Release-Slotheader exists so that error-rate comparisons are per-generation. A canary you cannot measure is just a slow deploy.
When to reconsider
Skip blue-green entirely when your HTML is not a static artefact. A server-rendered application that reads the manifest at request time already performs the cutover atomically the moment it reloads the manifest, and the parked-release directory adds ceremony without adding safety.
Prefer the plain additive deploy — no release directories, no slots — when you ship a single-page app with one index.html, no service worker, and no fixed-name runtime files. In that shape the only thing a release directory buys you is a rollback that a re-run of the previous build would also produce, and it is not worth the sweeper complexity.
Prefer a full second origin when the two colours differ structurally: a different bucket region, a different CDN, a migration between providers, or a build whose publicPath changed. Those cannot be expressed as a path prefix under one origin, and trying to force them into one produces a worse outage than the slow config-plane cutover you were avoiding.
Finally, reconsider the whole model if your builds are not reproducible. Blue-green assumes an unchanged file keeps its hash across releases; if every build renames everything, the prefix grows by a full bundle per deploy, the sweeper never finds shared objects, and the “additive is cheap” premise disappears. Fix determinism first — the ordering and manifest handoff details assume it too.
Related
- CI/CD asset pipeline integration — parent guide covering build, upload ordering and credentials
- Rolling back fingerprinted assets — restoring a previous generation from a retained manifest
- Asset manifest generation — the artefact every cutover and sweep decision reads
- Immutable caching and TTL tuning — how long the HTML swap actually takes to reach users
- Atomic CDN deploys with GitHub Actions — the single-generation pipeline this extends