Build and deploy workflows

From a clean checkout to a live URL: reproducible builds, atomic release, previews and rollback.

Build once, ship the artefact

A deployment should be the same actions repeated every time, on a machine that starts clean. Two rules make that work: install dependencies from a lockfile (npm ci, not npm install) and never edit files on the server after they are uploaded.

# local: reproduce exactly what CI will do
rm -rf node_modules dist
npm ci                 # installs from package-lock.json, fails on drift
npm run build          # writes dist/
node --check src/build.js 2>/dev/null || true

# inspect the output before shipping it
find dist -name "*.html" | wc -l
du -sh dist
name: deploy
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run build
      - name: Assert output exists
        run: test -f dist/index.html
      - uses: actions/upload-pages-artifact@v3
        with:
          path: dist

The assertion step matters more than it looks: without it, a build that silently emitted nothing can still be "successfully" deployed over a working site.

Atomic release and rollback

Upload into a new directory and switch a symlink at the end, so visitors never observe a half-uploaded site. Rollback then means pointing the symlink back at the previous release, which takes effect immediately.

# versioned releases behind a symlink
RELEASE=/srv/app/releases/$(date +%Y%m%d%H%M%S)
mkdir -p "$RELEASE" && cp -r dist/. "$RELEASE"/
ln -sfn "$RELEASE" /srv/app/current     # atomic switch

# rollback
ln -sfn /srv/app/releases/20260917120000 /srv/app/current
ln -sfn "$RELEASE" /srv/app/current
  • Keep the last few releases on disk; deleting them on success removes your fastest rollback.
  • Unhashed HTML should be short-lived in caches so a deploy is visible without purging.
  • Hashed assets can be immutable, so old and new pages can coexist during a switch.
  • Never deploy from a laptop by hand - if it is not reproducible, it cannot be rolled back.

Preview environments and secrets

Every pull request should get its own URL. Preview builds catch broken links, wrong asset paths and layout regressions before they reach production, and they cost nothing on most static platforms because they are just another directory.

main        -> https://example.com
pr-142      -> https://pr-142.preview.example.com
local       -> http://localhost:3000

# build-time variables are PUBLIC once baked into the output:
PUBLIC_SITE_URL=https://example.com   ok
API_SECRET_KEY=...                    never - it ends up in the HTML
⚠️
Anything available at build time is public: environment variables are compiled into the output, and a static host has no runtime to read them from. Secrets belong on the server - a function, an API, or a form service.

FAQ

Why does my deploy look unfinished for a minute?
Files are uploaded one by one unless you switch atomically. Publish to a new directory and flip a symlink, or use a platform that treats each deploy as an immutable snapshot.
Should I build on the server?
No. Build in CI, ship the artefact. Building on the server makes the release unreproducible and forces dependencies and toolchains onto production.

Static versus dynamic hosting A GitHub Actions workflow

Last refreshed 2026-09-18.