Knowing It Works
A fixed list to check after every release, alerts that name an action, and the runbook each alert points at.
fetch('/health.json', { cache: 'no-store' })Shipping ends when you know the release works, not when the deploy command exits. Three things make that possible: a check you run after every release, alerts that fire on things users feel, and a written response for each alert.
After a release
Keep the list short and identical every time. Load the application in a fresh tab. Confirm the release the host is serving is the one you deployed. Sign in. Complete the single flow the product exists for. Watch the error rate for ten minutes. A built single-page application fails in ways a successful deploy cannot see: a cached index.html pointing at hashed assets that no longer exist, or runtime configuration aimed at the wrong API.
Alerts
Alert on symptoms. Errors above the normal rate, sign-in failing, LCP at the 75th percentile crossing its threshold, the health file not serving the release you shipped. Give each alert a name that describes the user's experience, and carry the action and a runbook link in the alert itself. CPU at 91% names no action and wakes someone for nothing.
Runbooks
One page per alert: how to confirm it is real, how to tell whether the release caused it, how to roll back, who to tell. Write it when you create the alert. Nobody writes one at three in the morning.
An alert that fires weekly and is always ignored is worse than no alert, because it teaches the team that alerts can be ignored.
Example
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const outdated = ref(false)
let timer: number | undefined
async function check(): Promise<void> {
try {
const response = await fetch('/health.json', { cache: 'no-store' })
if (!response.ok) return
const health = (await response.json()) as { release: string }
outdated.value = health.release !== import.meta.env.VITE_RELEASE
} catch {
// Offline or blocked: leave the banner as it is and check again later.
}
}
function reload(): void {
window.location.reload()
}
onMounted(() => {
void check()
timer = window.setInterval(check, 60_000)
})
onUnmounted(() => {
window.clearInterval(timer)
})
</script>
<template>
<p v-if="outdated" role="status">
A newer version of this application is available.
<button type="button" @click="reload">Reload</button>
</p>
</template>
<style scoped>
p {
padding: 0.5rem 0.75rem;
}
</style>When to use it
- Deciding whether a release that deployed cleanly is actually working for users.
- Catching a cached index.html that points at asset files the new upload replaced.
- Replacing a 2am page that says CPU 91% with one that says what to do about it.
- Telling a long-lived tab that a newer release is live so it reloads instead of calling an API that has moved on.
More examples
Writing the release into the build output
The plugin writes `dist/health.json` after the bundle is written, so the deployed files state which release they are. `apply: 'build'` keeps it out of the dev server. The same shell variable feeds the application: environment variables that already exist when Vite runs take priority over `.env` files and, because it starts with `VITE_`, it appears on `import.meta.env`. Build it with `VITE_RELEASE=$(git rev-parse --short HEAD) npm run build` and the file, the bundle and your deploy log all name the same release.
// vite.config.ts
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { defineConfig, type Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'
function healthFile(release: string): Plugin {
return {
name: 'health-file',
apply: 'build',
writeBundle(options) {
writeFileSync(join(options.dir ?? 'dist', 'health.json'), JSON.stringify({ release }))
},
}
}
export default defineConfig({
plugins: [vue(), healthFile(process.env.VITE_RELEASE ?? 'dev')],
build: {
sourcemap: 'hidden',
},
})The post-release check, as a script
Run this from CI immediately after the deploy, with the release you expected. The first check catches a deploy that did not take. The third is the one that catches the classic static-hosting failure: an `index.html` served from cache that references hashed asset files the new upload replaced, so the page loads and then renders nothing. A check you run by hand is a check you skip at the end of a long day, so make the pipeline fail instead.
# scripts/smoke.sh - bash scripts/smoke.sh https://app.example.com 4f2a9c1
set -euo pipefail
BASE="$1"
EXPECTED="$2"
RELEASE=$(curl -fsS -H 'Cache-Control: no-cache' "$BASE/health.json" | jq -r .release)
if [ "$RELEASE" != "$EXPECTED" ]; then
echo "serving $RELEASE, expected $EXPECTED"
exit 1
fi
HTML=$(curl -fsS "$BASE/")
echo "$HTML" | grep -q '<div id="app">' || { echo 'index.html is not the built app'; exit 1; }
ASSET=$(echo "$HTML" | grep -o '/assets/[^"]*\.js' | head -n 1)
curl -fsSI "$BASE$ASSET" > /dev/null || { echo "index references a missing asset: $ASSET"; exit 1; }
echo "ok: $EXPECTED"An alert that names an action
The alert is named for what the user experiences, not for the metric. `for: 10m` stops a thirty-second blip during a deploy from paging anyone. The annotations carry the first thing to check and a link to the page that says what to do, so whoever is woken does not have to reconstruct the reasoning. `js_errors_total` is the counter fed by your error reporter, labelled with the release, which is what makes the first instruction answerable.
# ops/alerts/web.yml
groups:
- name: web
rules:
- alert: CheckoutFailingForUsers
expr: sum(rate(js_errors_total{route="checkout"}[5m])) > 0.5
for: 10m
labels:
severity: page
annotations:
summary: Users are hitting errors in checkout
action: >-
Compare the release label on the errors with the current release.
If they start with it, roll back; otherwise check the payments API.
runbook: https://wiki.internal/runbooks/checkout-failing
Discussion