Content-Addressed Static Deploys with Node and Nginx

A static site can be deployed with rsync, but two details make a large difference: asset URLs should change when content changes, and the live release should switch in one filesystem operation. These two properties remove most stale-cache and half-deployed-page problems.

This tutorial builds a small pipeline using Node.js for asset fingerprinting, release directories for history, and an atomic symlink switch for activation. The same model works behind Nginx, Caddy, or a CDN origin.

Use a simple source layout

site/
├── public/
│   ├── index.html
│   └── assets/
│       ├── app.css
│       └── app.js
├── scripts/
│   ├── build.mjs
│   └── deploy.sh
└── package.json

HTML is mutable because it points at the current asset names. CSS and JavaScript become immutable because their filenames include a digest of their content.

Fingerprint assets during the build

The script below copies public to dist, renames CSS and JavaScript files, and rewrites references in every HTML file.

import { createHash } from "node:crypto";
import { cp, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";

const source = path.resolve("public");
const output = path.resolve("dist");
const assets = path.join(output, "assets");

await rm(output, { recursive: true, force: true });
await mkdir(output, { recursive: true });
await cp(source, output, { recursive: true });

const replacements = new Map();

for (const name of await readdir(assets)) {
  if (!name.endsWith(".css") && !name.endsWith(".js")) continue;

  const oldPath = path.join(assets, name);
  const content = await readFile(oldPath);
  const digest = createHash("sha256").update(content).digest("hex").slice(0, 12);
  const extension = path.extname(name);
  const stem = path.basename(name, extension);
  const hashedName = `${stem}.${digest}${extension}`;

  await rename(oldPath, path.join(assets, hashedName));
  replacements.set(`/assets/${name}`, `/assets/${hashedName}`);
}

async function htmlFiles(directory) {
  const found = [];
  for (const entry of await readdir(directory, { withFileTypes: true })) {
    const fullPath = path.join(directory, entry.name);
    if (entry.isDirectory()) found.push(...await htmlFiles(fullPath));
    if (entry.isFile() && entry.name.endsWith(".html")) found.push(fullPath);
  }
  return found;
}

for (const file of await htmlFiles(output)) {
  let html = await readFile(file, "utf8");
  for (const [before, after] of replacements) html = html.replaceAll(before, after);
  await writeFile(file, html);
}

Run it with node scripts/build.mjs. A file such as app.css becomes app.2f73b18d591c.css. If its bytes do not change, neither does the URL.

Fail the build on broken references

Before deployment, scan generated HTML and confirm every root-relative asset exists. This catches a misspelled filename or a reference the build script did not rewrite.

for (const file of await htmlFiles(output)) {
  const html = await readFile(file, "utf8");
  for (const match of html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)) {
    const target = path.join(output, match[1]);
    await readFile(target); // throws when the generated reference is broken
  }
}

Place this check at the end of the build script. A static build should be self-contained before any network call begins.

Publish into an immutable release directory

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

release=$(git rev-parse --short=12 HEAD)
host="web@example.net"
root="/srv/www/notes"

node scripts/build.mjs

rsync -az "dist/assets/" "$host:$root/assets/"
rsync -az --delete --exclude assets "dist/" "$host:$root/releases/$release/"
ssh "$host" "test -f '$root/releases/$release/index.html'"

ssh "$host" "
  set -eu
  ln -sfn '$root/releases/$release' '$root/current.next'
  mv -Tf '$root/current.next' '$root/current'
"

Assets go into a shared content-addressed directory and are never overwritten because each filename includes its digest. HTML goes into an immutable release directory. The upload cannot change the live HTML because Nginx still serves current. The final mv replaces the symlink atomically on Linux, so requests see either the old release or the new release, not a mixture.

Use a deployment identifier that is immutable. A full Git SHA is ideal. A short SHA is convenient, but make it long enough to avoid collisions in the repository.

Set cache policy by file type

server {
    root /srv/www/notes/current;

    location /assets/ {
        alias /srv/www/notes/assets/;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    location / {
        add_header Cache-Control "no-cache";
        try_files $uri $uri.html $uri/ =404;
    }
}

Hashed assets can be cached for a year because changed content gets a different URL. HTML should revalidate, because it contains the pointers to the current hashes. Old HTML remains valid because the shared asset directory retains every published digest. Garbage-collect unused digests only after no retained release references them and the maximum HTML cache window has passed.

Rollback by switching the pointer

previous="a18df0d21b7c"
root="/srv/www/notes"

ln -sfn "$root/releases/$previous" "$root/current.next"
mv -Tf "$root/current.next" "$root/current"

Rollback does not rebuild or copy anything. It changes one symlink. Keep a small number of releases and delete older ones only after their maximum HTML cache window has passed.

Verify the release from the outside

base="https://notes.example.net"

curl -fsS "$base/" | grep -Eo '/assets/[^" ]+\.(css|js)'
curl -fsSI "$base/" | grep -i '^cache-control:'
curl -fsSI "$base/assets/app.2f73b18d591c.css" | grep -i '^cache-control:'

Also record the release SHA in a small text file or response header. “The deploy command succeeded” and “the public site serves the intended release” are different facts.

What this buys you

The pipeline has no build service dependency and no database. Yet it gives you immutable assets, atomic activation, deterministic rollback, and an exact release identity. A managed static host may implement the same ideas for you. Understanding the mechanics is still useful when debugging caches, building an internal tool, or deploying to a small server.

← All notesHome