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.

One prefix, two generations Two index.html documents, release 42 and release 43, both point into a single immutable assets prefix. The prefix holds the hashed files unique to each release plus a vendor chunk whose bytes did not change and which therefore exists only once. ONE PREFIX, TWO GENERATIONS /assets/ — one immutable prefix blue — release 42 app.a1b2c3d4.js main.9b4e7f02.css green — release 43 app.b5e6f7a8.js main.2c8d0e11.css shared by both generations vendor.7f3e91c0.js — identical bytes, one upload index.html release 42 90% of traffic index.html release 43 canary 42 hashes 43 hashes A deploy only adds files. Nothing under /assets/ is overwritten or deleted at cutover.
Blue and green are HTML documents, not asset trees — the hashed files coexist in a single append-only prefix.

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:

  1. 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.
  2. publicPath or 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.
  3. A fixed-name file changed meaning. A service worker precache list, a /assets/manifest.json, an importmap.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.
  4. 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.

Asset-first ordering Two lanes. The correct lane uploads assets, verifies every hashed URL resolves, then publishes HTML, producing no 404 window. The broken lane publishes HTML first and uploads assets afterwards, producing 404s for every request that lands in the gap. ASSET-FIRST ORDERING IS NOT OPTIONAL Correct: assets, then HTML 1 · upload assets additive, no deletes 2 · verify hashes every URL returns 200 3 · publish HTML zero-404 window Broken: HTML, then assets 1 · publish HTML new hashes referenced 2 · upload assets 20 s to 4 min later every hit in the gap 404, then blank page The blast radius is not the deploy duration — it is the deploy duration times your peak request rate.
Step 2 is what makes step 3 safe: publish the document only after every hash it names already answers with 200.

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.”

What the sweeper may delete Five release generations shown as bars whose length is the age of the release. A vertical delete horizon marks the maximum HTML cache lifetime plus a safety margin. Only generations whose bars extend past the horizon are safe to remove. WHAT THE SWEEPER MAY DELETE release 39 no HTML refers to it safe release 40 last served 9 days ago safe to sweep release 41 at the horizon keep until horizon release 42 still referenced HTML may still name it release 43 current release live deploy time delete horizon = max HTML TTL + margin older
Age alone never authorises a delete — the object must also be absent from every retained manifest.

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.json names one release; during a split, two are live. Write both into current.json as an array, and have the sweeper union them.
  • Vary and 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 with no-store at the edge, as the worker above does, is the simple answer.
  • Analytics needs the slot. The X-Release-Slot header 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.