Lesson 17 / 19
Preview Deployments
Producing a separate environment for every change; a deterministic deployment id derived from content, a content-addressed store that shares blobs, previews served side by side with the release, data and side-effect isolation, and how long deployments live.
Contents
The previous two lessons looked at production: how the output is served and where the code that runs at request time lives. Seeing that a change works before it reaches production is a separate problem, and the Development Server lesson’s last sentence gave the reason — working in development mode is not proof that it works in production mode.
This lesson sets up a separate environment for every change: a deployment built in production mode, with its own address, that does not touch production and is not indexed.
What a Preview Deployment Must Satisfy
A preview deployment works only when it satisfies six conditions at once.
Same pipeline as the release. Same build steps, same mode, same checks. A preview produced by a different pipeline does not prove what it set out to prove.
Stable, derived address. The deployment’s id is derived from its content; the same change always yields the same id. The determinism condition from the Cache Busting lesson turns into an id here.
Able to live alongside the release. A preview’s existence does not change the release’s behavior in any way; both can be served from the same server at the same time.
Data and side-effect isolation. A preview does not write to production data and does not trigger outbound side effects.
Not indexed, access controlled. Preview addresses must not enter search indexes and must not be public.
Finite lifetime. The deployment is removed once the change closes.
Deployment Id and the Shared Store
The script below builds three branches — release and two changes — each with its own environment, writes the files to a content-addressed store, and produces an id for each deployment.
// preview.mjs -- per-change deployment: content-addressed id and shared store. import { createHash } from "node:crypto"; import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; const hash = (b) => createHash("sha256").update(b).digest("hex"); // Source tree in production. {{NAME}} placeholders are filled by the build-time // substitution from the Environment Configuration lesson. const BASE = { "document.html": '<!doctype html><html lang="en"><body>' + "<h1>North Slope Measurement Station</h1>" + "<p>endpoint: {{MEASUREMENT_ENDPOINT}}</p><p>branch: {{BRANCH}}</p></body></html>\n", "assets/station.css": ".measurement { font-variant-numeric: tabular-nums; }\n", "assets/entry.js": 'import "./panel.js";\n', "assets/panel.js": 'export const draw = (o) => o.map((x) => x.temperature).join(" ");\n', "assets/chart.js": "export const axis = (a, b) => [a, b];\n", }; // Three branches: release and two separate changes. Each branch builds with its own env. const BRANCH = { release: { change: {}, env: { MEASUREMENT_ENDPOINT: "https://measurements.north-slope.example/v1" } }, "threshold-warning": { change: { "assets/panel.js": 'export const draw = (o) =>\n' + ' o.map((x) => (x.temperature < -20 ? "!" : "") + x.temperature).join(" ");\n', }, env: { MEASUREMENT_ENDPOINT: "https://staging.measurements.north-slope.example/v1" }, }, "date-format": { change: { "assets/chart.js": "export const axis = (a, b) => [a, b, \"UTC\"];\n", "assets/station.css": ".measurement { font-variant-numeric: tabular-nums; font-feature-settings: \"ss01\"; }\n", }, env: { MEASUREMENT_ENDPOINT: "https://staging.measurements.north-slope.example/v1" }, }, }; const STORE = "store"; const counter = { written: 0, reused: 0 }; function build(branchName) { const { change, env } = BRANCH[branchName]; const vars = { ...env, BRANCH: branchName }; const manifest = {}; for (const path of Object.keys(BASE).sort()) { const source = change[path] ?? BASE[path]; const body = source.replace(/\{\{([A-Z_]+)\}\}/g, (t, name) => vars[name] ?? t); const h = hash(body); const target = join(STORE, h); if (existsSync(target)) counter.reused += 1; else { writeFileSync(target, body); counter.written += 1; } manifest[path] = h; } // Deployment id: hash of the sorted list of path-hash pairs. const id = hash(Object.entries(manifest).map(([path, h]) => path + " " + h).join("\n")) .slice(0, 12); mkdirSync(join("deployments", id), { recursive: true }); writeFileSync(join("deployments", id, "manifest.json"), JSON.stringify({ branch: branchName, files: manifest }, null, 2) + "\n"); return { id, manifest }; } rmSync(STORE, { recursive: true, force: true }); rmSync("deployments", { recursive: true, force: true }); mkdirSync(STORE, { recursive: true }); const result = {}; for (const branch of Object.keys(BRANCH)) result[branch] = build(branch); console.log("branch".padEnd(18) + "deployment id".padEnd(18) + "files"); for (const [branch, s] of Object.entries(result)) console.log(branch.padEnd(18) + s.id.padEnd(18) + Object.keys(s.manifest).length); console.log("\nstore: " + counter.written + " separate blobs written, " + counter.reused + " blobs reused (total " + (counter.written + counter.reused) + " file entries)"); // A second build from the same source must give the same id. const rebuild = Object.fromEntries(Object.keys(BRANCH).map((b) => [b, build(b).id])); console.log("second build gave the same ids: " + Object.entries(result).every(([b, s]) => s.id === rebuild[b])); // Mark the release deployment and write the ids to the file the server will read. writeFileSync("current.txt", result.release.id + "\n"); writeFileSync("ids.txt", Object.entries(result).map(([b, s]) => b + " " + s.id).join("\n") + "\n"); console.log("\ndifference between deployments (compared with release):"); for (const branch of ["threshold-warning", "date-format"]) { const diff = Object.keys(BASE).sort() .filter((path) => result[branch].manifest[path] !== result.release.manifest[path]); console.log(" " + branch.padEnd(18) + diff.length + "/" + Object.keys(BASE).length + " files different: " + diff.join(", ")); }
Serving Side by Side
All three deployments are served from the same server. The release sits at the root, previews sit under their ids.
// preview-server.mjs -- serves deployments side by side by their id. // The release deployment sits at the root, preview deployments sit under /p/<id>/. import { createServer } from "node:http"; import { readFileSync, existsSync } from "node:fs"; import { join, extname } from "node:path"; const RELEASE = readFileSync("current.txt", "utf8").trim(); const TYPE = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", }; createServer((request, response) => { response.sendDate = false; const part = request.url.split("?")[0].split("/").filter(Boolean); const preview = part[0] === "p"; const id = preview ? part[1] : RELEASE; const path = (preview ? part.slice(2) : part).join("/") || "document.html"; const manifestPath = join("deployments", id, "manifest.json"); if (!existsSync(manifestPath)) { response.writeHead(404, { "Content-Type": "text/plain" }).end("no such deployment\n"); return; } const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); const blob = manifest.files[path]; if (!blob) { response.writeHead(404, { "Content-Type": "text/plain" }).end("no such file\n"); return; } const body = readFileSync(join("store", blob)); const headers = { "Content-Type": TYPE[extname(path)] ?? "application/octet-stream", "Content-Length": body.length, "X-Deployment": id, "X-Branch": manifest.branch, }; // Preview deployments must not be indexed and must not be confused with the release address. if (preview) headers["X-Robots-Tag"] = "noindex, nofollow"; response.writeHead(200, headers).end(body); }).listen(8183, "127.0.0.1", () => console.log("server: 127.0.0.1:8183"));
The script below runs in the same directory as these two files. Port 8183 is arbitrary and must be free; if it is in use, change it in both files.
#!/usr/bin/env bash # Produces three deployments, serves all of them side by side from the same server. node preview.mjs WARN=$(awk '$1 == "threshold-warning" { print $2 }' ids.txt) DATE=$(awk '$1 == "date-format" { print $2 }' ids.txt) node preview-server.mjs > /dev/null & server=$! sleep 1 echo "--- release (root) ---" curl -sS -D - -o /dev/null http://127.0.0.1:8183/ | grep -iE '^(x-deployment|x-branch|x-robots-tag)' curl -sS http://127.0.0.1:8183/assets/panel.js echo "--- preview: threshold-warning ---" curl -sS -D - -o /dev/null "http://127.0.0.1:8183/p/$WARN/" | grep -iE '^(x-deployment|x-branch|x-robots-tag)' curl -sS "http://127.0.0.1:8183/p/$WARN/assets/panel.js" curl -sS "http://127.0.0.1:8183/p/$WARN/" echo "--- preview: date-format ---" curl -sS "http://127.0.0.1:8183/p/$DATE/assets/chart.js" curl -sS -o /dev/null -w 'unknown deployment id : %{http_code}\n' \ http://127.0.0.1:8183/p/000000000000/ kill "$server"
branch deployment id files
release e4fb083337b5 5
threshold-warning c4b66ffc753e 5
date-format 775994305775 5
store: 10 separate blobs written, 5 blobs reused (total 15 file entries)
second build gave the same ids: true
difference between deployments (compared with release):
threshold-warning 2/5 files different: assets/panel.js, document.html
date-format 3/5 files different: assets/chart.js, assets/station.css, document.html
--- release (root) ---
X-Deployment: e4fb083337b5
X-Branch: release
export const draw = (o) => o.map((x) => x.temperature).join(" ");
--- preview: threshold-warning ---
X-Deployment: c4b66ffc753e
X-Branch: threshold-warning
X-Robots-Tag: noindex, nofollow
export const draw = (o) =>
o.map((x) => (x.temperature < -20 ? "!" : "") + x.temperature).join(" ");
<!doctype html><html lang="en"><body><h1>North Slope Measurement Station</h1><p>endpoint: https://staging.measurements.north-slope.example/v1</p><p>branch: threshold-warning</p></body></html>
--- preview: date-format ---
export const axis = (a, b) => [a, b, "UTC"];
unknown deployment id : 404
The output makes four points.
The id comes from content. Ids produced a second time from the same source are identical. This makes a deployment’s source verifiable through its address, and it is the basis for the rollback operation in the next lesson: the version to return to corresponds to content, not a name.
The environment enters the id. In the threshold-warning branch, only one file was changed, yet two files differ from the release; the second is the document, and the reason for the difference is that the measurement endpoint changed. This measures the result stated in the Environment Configuration lesson about build-time substitution: when the environment changes, the output bytes change too, so the bytes tested in a preview are not the bytes that reach production. The only way to zero out this difference is to move environment-bound values into runtime configuration.
The store shares blobs. Fifteen file entries came down to ten separate blobs. The ratio improves as the file count grows: a deployment’s cost is not its whole output but only its changed files. In a release with hundreds of files, a one-line change writes only a handful of new blobs.
A preview is an id separate from the release. The response headers say which
deployment is served, previews are marked closed to indexing, and an undefined id gets
404. The release at the root is unaffected by the previews’ existence.
Data and Side-Effect Isolation
The easy part of a preview is the bytes; the hard part is what those bytes touch when they run.
The rule is one sentence: a preview cannot write to production data. Enforcing it is a configuration decision, not a code decision. The scope declaration from the Environment Configuration lesson does its second job here: the data endpoint’s address, which account the write key belongs to, and where outbound calls go are all set by environment variables. The preview environment binds these variables to staging counterparts.
It is wrong for this split to turn into an if (preview) branch in code. That branch
would also exist in the production build, and isolation is lost every time its condition
is evaluated wrong. If the environment draws the boundary instead, no path to the
staging endpoint exists in the production build at all.
Three classes of side effect are handled separately. Writes go to a separate data copy; the copy is a small, seeded set, not a mirror of production data — if it were a mirror, personal data would be visible from a preview address. Outbound notifications — email, a payment call, third-party triggers — are either disabled in a preview or redirected to a collector. Schema changes are applied to the preview’s own copy; a migration applied to a shared schema puts a change that has not yet shipped into production.
Access is part of isolation too. The X-Robots-Tag header is aimed only at indexers; it
does not block someone who knows the address. An unguessable id is not access control —
the address ends up in browser history, link previews, and referrer headers. Access
control is a separate layer.
Lifetime, Cost, and Cleanup
An environment per change multiplies the deployment count by the change count. Cost has two line items, and both can be bounded.
Storage is bounded by the shared blob store. Deleting a deployment means deleting its manifest; the blobs themselves are deleted only after confirming that no other manifest references them. A cleanup run without reference counting can delete the release deployment’s files too.
Running code, if it exists, has a cost set directly by which of the three forms from the Server-Requiring Deployment lesson is chosen. Previews are used sparsely and irregularly; this is the window in that lesson’s table where per-request instance looks best.
The lifetime rule is simple: a preview is removed once its change closes. It has one exception — every deployment that reaches production is kept. Kept deployments form a version history, and the next lesson’s rollback operation returns to exactly this history.
Summary
- A preview deployment must satisfy six conditions at once: same pipeline as the release, a derived address, the ability to live alongside the release, data isolation, no indexing, and a finite lifetime.
- The deployment id is a hash of path-hash pairs; the same source yields the same id, and the id becomes a verifiable name for the content.
- Environment variables enter the output’s bytes, so preview and release bytes diverge; the way to zero out that gap is moving values into runtime configuration.
- A content-addressed store shares blobs: in the example, fifteen file entries came down to ten blobs, and a deployment’s cost is only its changed files.
- Isolation is established through environment configuration, not a code branch; writes, outbound notifications, and schema changes are each handled separately.
- The no-indexing header is not access control; deleting a deployment must check blob reference counts, and deployments that reach production must be kept.
Next Step
A change tested in preview reaches every user at once when it goes to production. That means every flaw preview could not catch reaches everyone at once too: load behaviors that show up only under real traffic, edge cases found only in real data, and failures seen only on certain devices. The next lesson takes production release out of being a single switch flip; it routes a small share of traffic to the new version, watches the error rate against a threshold, and reverts once the threshold is crossed. What it reverts to is the deployments kept in this lesson, and the time that takes is a measurable quantity.
To keep your progress and take notes, Log in
My notes
Log in to take notes.