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 distname: 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: distThe 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 HTMLFAQ
Why does my deploy look unfinished for a minute?
Should I build on the server?
Related
Static versus dynamic hosting A GitHub Actions workflow
Last refreshed 2026-09-18.