Skip to content
Created: May 1, 2024

Static asset canary releases ​

Serve new HTML to a cohort while other users receive the old version. The key is stable assignment, fixed asset versions, retained artifacts and rollback. This example uses Nginx, a persistent cohort cookie and versioned directories.

Nginx / CDN assignment ​

Assign at the HTML entry with a stable user identifier or a first-visit cookie, rather than rerandomizing each request. Versioned asset URLs keep open pages on their corresponding artifacts.

CDN edge assignment ​

CDN rules or edge functions can implement this logic if supported. Bypass shared HTML caching or partition it by version, rather than serving one user’s entry to everyone. Share assets by versioned URL.

HTML entry selection ​

HTML references /releases/v1/js/app.hash.js or /releases/v2/js/app.hash.js. Dynamic imports and CSS image/font URLs must follow the same base/publicPath.

Application-level switching ​

Loading another module version internally requires state, shared dependencies and initialization handling. Full SPAs usually select at the HTML entry; server rollback does not switch running pages automatically.

CI/CD rollout ​

Deploy complete artifacts before opening a small cohort, then increase traffic based on observations. Rollback changes new entry selection while retaining assets for open pages.

Vue deployment example ​

This build targets Vue CLI; Vite uses the corresponding base setting. v1/v2 are illustrative: use immutable release identifiers and never overwrite long-cached assets.publicPath.

1. Build versioned artifacts ​

vue.config.js:

js
const version = process.env.APP_VERSION || 'v1';
if (!/^v[0-9]+$/.test(version)) throw new Error('Invalid release version');
module.exports = {
  publicPath: '/releases/' + version + '/',
  outputDir: 'dist/' + version
};
bash
APP_VERSION=v1 npx vue-cli-service build
APP_VERSION=v2 npx vue-cli-service build

2. Server layout ​

text
/var/www/my-app/releases/
  v1/index.html
  v1/js/app.<hash>.js
  v1/css/app.<hash>.css
  v2/index.html
  v2/js/app.<hash>.js
  v2/css/app.<hash>.css

3. Nginx routing ​

Place this in the http context; port 8080 is for local verification. Use the real domain and HTTPS in production, adding Secure to cookies. Cohort cookies are not authorization credentials.

nginx
map $cookie_gray_id $gray_seed {
    "~^[0-9a-f]{32}$" $cookie_gray_id;
    default $request_id;
}
map $cookie_gray_id $gray_seed_cookie {
    "~^[0-9a-f]{32}$" "";
    default "gray_id=$gray_seed; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax";
}
# Included exactly once in the http context.
include /etc/nginx/gray-split.conf;
map $cookie_version $version {
    v1 v1;
    default $gray_candidate;
}
server {
    listen 8080;
    server_name localhost;
    location ~ ^/releases/v[0-9]+/index\.html$ {
        root /var/www/my-app;
        try_files $uri =404;
        add_header Cache-Control "no-store" always;
    }
    location /releases/ {
        root /var/www/my-app;
        try_files $uri =404;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
    # SPA entry only; real applications need separate API/proxy locations.
    location / {
        root /var/www/my-app/releases/$version;
        try_files /index.html =404;
        add_header Cache-Control "no-store" always;
        add_header Set-Cookie $gray_seed_cookie always;
    }
}

Initial /etc/nginx/gray-split.conf:

nginx
split_clients "$gray_seed:release-v2" $gray_candidate {
    10% v2;
    *   v1;
}

split_clients hashes the whole stable identifier and covers every input instead of taking a hexadecimal request_id digit. First-visit HTML sets gray_id for reuse. Percentages partition hash space; user counts vary statistically. Disabled cookies or simultaneous first requests may change assignment, while assets remain versioned.split_clients.

4. Rollback ​

Map every input to v1, run nginx -t and reload. Open v2 pages and lazy imports may still need v2; retain its directory for page sessions, caches and Service Worker use.

nginx
split_clients "$gray_seed:release-v2" $gray_candidate {
    * v1;
}

Rollout percentage script ​

Accept integers 0–100 with explicit endpoint branches. The file must already exist, and the runner needs write and Nginx permissions. Serialize concurrent rollout jobs in CI.

bash
#!/usr/bin/env bash
set -euo pipefail

percent=${1:-}
if [[ ! "$percent" =~ ^(0|[1-9][0-9]?|100)$ ]]; then
  echo "Usage: gray.sh <integer 0-100>" >&2
  exit 1
fi
conf=/etc/nginx/gray-split.conf
backup=$(mktemp)
pending=$(mktemp /etc/nginx/gray-split.conf.XXXXXX)
trap 'rm -f "$backup" "$pending"' EXIT
cp "$conf" "$backup"
{
  echo 'split_clients "$gray_seed:release-v2" $gray_candidate {'
  if (( percent == 0 )); then
    echo '    * v1;'
  elif (( percent == 100 )); then
    echo '    * v2;'
  else
    echo "    $percent% v2;"
    echo '    * v1;'
  fi
  echo '}'
} > "$pending"
# Keep the existing file permissions.
cat "$pending" > "$conf"
if ! nginx -t; then
  cat "$backup" > "$conf"
  echo "Invalid configuration; restored previous file" >&2
  exit 1
fi
if ! nginx -s reload; then
  cat "$backup" > "$conf"
  nginx -s reload || true
  echo "Reload failed; restored previous file" >&2
  exit 1
fi
echo "Configured canary hash-space share: $percent%"
bash
bash gray.sh 15
bash gray.sh 100
bash gray.sh 0

These configure 15%, full rollout and rollback. Recovery cookies temporarily retain v1 outside normal assignment. Use observations to decide traffic increases.

Recovery from failed canary resources ​

Place this before entry resources in both HTML versions. It handles script/link failures only for the specified version, cannot eliminate every white screen and should not treat every business error as a rollback reason.

html
<script>
(function () {
  const failedVersion = 'v2';
  const retryKey = 'gray_retry:' + failedVersion;
  window.addEventListener('error', function (event) {
    const target = event.target;
    if (!target || !['SCRIPT', 'LINK'].includes(target.tagName)) return;
    const resource = target.src || target.href;
    if (!resource) return;
    const url = new URL(resource, location.href);
    if (url.origin !== location.origin ||
        !url.pathname.startsWith('/releases/' + failedVersion + '/')) return;
    try {
      if (sessionStorage.getItem(retryKey)) return;
      sessionStorage.setItem(retryKey, 'done');
    } catch {
      return; // Storage unavailable: avoid uncontrolled reload loops.
    }
    document.cookie = 'version=v1; Path=/; Max-Age=3600; SameSite=Lax';
    location.reload();
  }, true);
})();
</script>

Catch dynamic-import failures at the call site and decide initialization recovery using framework hooks and business state. Stable releases can also fail, so retain an error view and diagnostic path. Update version and retry keys each release.

Verification and cache requirements ​

  1. Check that entries, dynamic chunks and CSS image/font URLs contain the version.
  2. Repeated HTML requests with one gray_id should remain stable; test 0%, 15%, 100% without exact small-sample ratios.
  3. Test known assets under /releases/v1/ and /releases/v2/, cache headers and missing-asset 404s.
  4. Return HTML for SPA deep links, route APIs separately, avoid shared cohort HTML caching and stale Service Worker entries.
  5. Simulate a v2 entry failure, allow at most one recovery attempt and retain assets for open pages after rollback.