---
title: 'Preview Deployments'
source: 'https://academia.sh/en/courses/rendering-strategies/preview-deployments'
course: 'Rendering Strategies and Infrastructure'
language: en
updated: '2026-08-17T18:11:07+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```js
// 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.

```js
// 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.

```bash
#!/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.
