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:
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
};APP_VERSION=v1 npx vue-cli-service build
APP_VERSION=v2 npx vue-cli-service build2. Server layout
/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>.css3. 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.
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:
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.
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.
#!/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 gray.sh 15
bash gray.sh 100
bash gray.sh 0These 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.
<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
- Check that entries, dynamic chunks and CSS image/font URLs contain the version.
- Repeated HTML requests with one gray_id should remain stable; test 0%, 15%, 100% without exact small-sample ratios.
- Test known assets under
/releases/v1/and/releases/v2/, cache headers and missing-asset 404s. - Return HTML for SPA deep links, route APIs separately, avoid shared cohort HTML caching and stale Service Worker entries.
- Simulate a v2 entry failure, allow at most one recovery attempt and retain assets for open pages after rollback.