--- title: Ship a site — put a static site or a Node app live on your own server summary: DNS record, Nginx server block, TLS, systemd service, a one-command deploy script that builds locally and fails closed, and verification from outside. when_to_use: The server already exists (see new-server.md) and you want to publish a new subdomain, a static site, a single-page app, or a Node API — or move one off Vercel/Netlify/Render/Heroku. prerequisites: A server built with new-server.md (Nginx, origin wildcard certificate, Postgres, an SSH alias). The project builds locally (`npm run build`). related: [workflows/new-server.md, workflows/lean-production.md, workflows/backups-and-restore.md, skills/ship-a-site/SKILL.md, reference/gotchas.md] last_verified: 2026-09-26 --- # Ship a site ## Goal `https://` serves the project over valid TLS through Cloudflare, an app (if any) runs as a capped systemd service on `127.0.0.1`, and every future change ships with **one command** that tests, builds on the laptop, backs up the database before migrating, restarts, and proves the result from outside. About ten minutes for a static site, thirty for an app. ## Inputs (placeholders used) | Placeholder | Meaning | Example | |---|---|---| | `` | the full hostname being shipped | `app.example.com` | | `` | the registrable domain the wildcard cert covers | `example.com` | | `` | SSH alias of the server (points at the IP) | `web1` | | `` | the server's IPv4 | `203.0.113.10` | | `` | service / folder / database name | `notes` | | `` | free local port for the app | `3004` | | `` | the non-root user apps run as | `deploy` | | `` | where Nginx serves static files from | `/var/www/app.example.com` | | `` / `` | the app's database and role | `notes` / `notes` | Conventions this assumes (change them consistently if yours differ): - App code and its `.env` in `/home//apps/` (owned by the deploy user — no permission fights on deploy). - Static files in `/var/www/` owned by root, mode 644. - Backups in `/home//backups`, helper scripts in `/home//bin`. Scripts referenced below live in `skills/ship-a-site/scripts/`. ## Steps ### 1. Pick a port and confirm the hostname is free (server) ```bash ssh 'sudo ss -tlnp | grep LISTEN; ls /etc/nginx/sites-enabled/' ``` Choose a `` nothing listens on. Static sites skip the port. **Check:** `` is absent from the `ss` output and `` has no file yet. ### 2. DNS record (Cloudflare dashboard) Add `A → `. **Proxied (orange) is correct straight away** when the wildcard origin certificate already covers `*.`. (Grey cloud is only for a server with no origin certificate yet.) **Check (laptop):** ```bash dig +short @1.1.1.1 # Cloudflare IPs (104.x / 172.6x.x) when proxied ``` If `dig` is right but the browser is not, it's a cache (OS or browser), not the server — see Traps. ### 3. The app listens on loopback only (code) Behind Nginx an app never needs a public interface. Binding to `127.0.0.1` makes it *incapable* of accepting outside connections rather than relying on the firewall. ```ts const HOST = process.env.HOST ?? "127.0.0.1"; const PORT = Number(process.env.PORT ?? 3000); app.listen({ host: HOST, port: PORT }); ``` Also, in the app: - `GET /api/health` → 200 (used by the deploy script). - Every API response sends `Cache-Control: no-store`. Cloudflare caches by **file extension**; an authenticated `/api/export.csv` without it is cached at the edge and served to anyone. One hook covers it (Fastify: `app.addHook("onSend", ...)`), plus a test asserting the header. - Trust the proxy for the client IP via `X-Real-IP` (set by Nginx from Cloudflare's header). Never key rate limits on the first entry of `X-Forwarded-For`. - Cookies `Secure`, `HttpOnly`, `SameSite=Lax`. A `Secure` cookie is dropped over plain HTTP — login "does nothing" until TLS works. **Check:** `npm run build && node dist/index.js` locally, then `curl -s localhost:/api/health` → 200. ### 4. Prepare the server side of the app (server, first time only) ```bash ssh mkdir -p ~/apps/ ~/backups ~/bin nano ~/apps//.env && chmod 600 ~/apps//.env ``` `.env` holds `DATABASE_URL`, `PORT`, secrets. No leading whitespace (a pasted indent turns `PORT` into `" PORT"`); quote any value containing spaces, `<` or `>`. Create the database and role as in `new-server.md` step 11. Copy the helper scripts once: ```bash scp skills/ship-a-site/scripts/{backup-db.sh,restore-db.sh,add-site.sh} :bin/ ssh 'chmod +x ~/bin/*.sh' ``` **Check:** `ssh 'ls -l ~/apps//.env'` shows `-rw-------`. ### 5. Nginx server block (server) ```bash ssh 'sudo ~/bin/add-site.sh --proxy ' # or for a static site / static web build: ssh 'sudo ~/bin/add-site.sh --static ' ``` The script writes `/etc/nginx/sites-available/`, links it, runs `nginx -t`, and reloads **only if the test passes** (it disables the site again otherwise). Then add the shared includes and any app-specific locations by hand — shapes are in `skills/ship-a-site/references/nginx-blocks.md`: - `include snippets/security-headers.conf;` and `include snippets/block-scanners.conf;` - SPA: `try_files $uri $uri/ /index.html;` — static docs: `try_files $uri $uri.html $uri/ =404;` - web build + API: `root ;` plus `location /api/ { proxy_pass ... }` - `/assets/` immutable for a year; `index.html` and `sw.js` `no-cache`; icons 1 h - every `location` that has its own `add_header` repeats the security-headers include ```bash ssh 'sudo nginx -t && sudo systemctl reload nginx' ``` **Check:** `sudo nginx -t` prints `syntax is ok` and `test is successful`. ### 6. systemd unit (server, apps only) ```bash # laptop — set your real values once APP=notes; DEPLOY_USER=deploy; PORT=3004 sed -e "s//$APP/g" -e "s//$DEPLOY_USER/g" -e "s//$PORT/g" \ skills/ship-a-site/scripts/app.service.template > "/tmp/$APP.service" scp "/tmp/$APP.service" :/tmp/ ssh "sudo mv /tmp/$APP.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable $APP" ``` The unit runs `node --max-old-space-size=128 dist/index.js` with `MemoryMax=256M` — see `lean-production.md` for why this is not optional on a small server. **Check:** `systemctl cat ` shows your values, no `<` placeholders left. ### 7. The deploy script (laptop, in the app repo) ```bash mkdir -p scripts && cp /skills/ship-a-site/scripts/deploy.sh.template scripts/deploy.sh chmod +x scripts/deploy.sh # fill in the CONFIG block ``` What it does, in order — and why the order matters: ``` tests → build (laptop) → back up DB → rsync API → npm ci --omit=dev + migrate → rsync web to a staging dir → server publishes as root:644 → restart → health check on 127.0.0.1 → check from outside → print memory ``` - **Build on the laptop.** `tsc`/Vite are the biggest memory spikes a small server would ever take. - **Back up before migrating.** Code rolls back; a migration does not. There is deliberately no auto-rollback — on failure it prints the logs, the previous commit and where the dump is, and a human decides. - **Two-hop web publish.** macOS ships `openrsync`, which has no `--chown`. Ship to a folder the deploy user owns, then the server's GNU rsync publishes with sudo. - **No `--delete` on publish.** Old hashed chunks stay 30 days so a tab opened before the deploy can still lazy-load its screens. - **Fail closed.** `set -euo pipefail`; any failed step stops before the restart. A build that failed must never lead to restarting on a stale `dist/`. A static-only site needs just: build → rsync to staging → publish → verify. **Check:** `./scripts/deploy.sh` ends with `Deployed to https://`. ### 8. Prove the service, don't assume it (server, apps only) ```bash ssh 'systemctl show -p MainPID --value ; sudo pkill -9 -f "[d]ist/index.js"; sleep 8; systemctl show -p MainPID,NRestarts --value ' ``` The `[d]` bracket stops `pkill -f` matching its own command line (which would kill your SSH session). Then reboot once: ```bash ssh 'sudo reboot' ``` **Check:** new PID with `NRestarts` +1 after the kill; after the reboot (wait ~2 min; 502 for the first 20 s is normal) the site answers 200 with no manual action. ### 9. Verify from outside (laptop) Checking from the server proves little. Check the way a visitor arrives: ```bash curl -s -o /dev/null -L -w "%{http_code} tls=%{ssl_verify_result} http/%{http_version}\n" https:/// curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http:/// curl -sI https:///index.html | grep -ciE 'x-content-type|x-frame|referrer|strict-transport' # 4 curl -sI https:///api/health | grep -iE 'cache-control|cf-cache-status' # no-store, not HIT ``` For a PWA also: ```bash curl -s -o /dev/null -w '%{content_type}\n' https:///manifest.webmanifest # application/manifest+json curl -sI https:///sw.js | grep -i cache-control # no-cache ``` **Check:** `200 tls=0 http/2`, `301 -> https://...`, `4`, `no-store`. ### 10. Measure (server) ```bash ssh 'systemctl show -p MemoryCurrent -p MemoryPeak -p NRestarts; free -m' ``` Write the number down. Check again a day later (see `lean-production.md`). **Check:** `MemoryCurrent` is well under `MemoryMax`, `NRestarts=0`. ### 11. Backups (server, apps with a database) ```bash ssh 'crontab -l; echo "5 1 * * * $HOME/bin/backup-db.sh "' # add via crontab -e ssh '~/bin/backup-db.sh && ~/bin/restore-db.sh ' ``` **Check:** the drill ends with `RESULT: backup is good`. Details in `backups-and-restore.md`. ## Traps | Symptom | Cause | Fix | |---|---|---| | Browser shows cert error, `curl` is fine | OS and browser DNS caches | flush both (macOS `dscacheutil -flushcache; killall -HUP mDNSResponder`; Chrome `chrome://net-internals/#dns`); confirm with `curl -w '%{remote_ip}'` | | Login "does nothing", no error | `Secure` cookie dropped over plain HTTP | get TLS working first; never set `NODE_ENV=development` to "fix" it | | New file on server, old file in browser | Cloudflare edge cache (`cf-cache-status: HIT`) | purge; content-hash filenames; short max-age on fixed names | | Returning visitors see the previous build | service worker cache-first on navigations, or `sw.js` cached | network-first for `mode === "navigate"`; `sw.js` `no-cache` | | One user's data served to strangers | per-user response with a cacheable extension and no `Cache-Control` | `no-store` on every API response + a test | | Security headers missing on one path | that `location` has its own `add_header` | repeat the include there | | PWA won't install | `.webmanifest` served as octet-stream | `conf.d/pwa-types.conf` | | iPhone home-screen icon is a screenshot | iOS ignores manifest icons | `` | | Deploy dies at "migrate" after sourcing `.env` | unquoted value with `<` or spaces read as shell syntax | quote it: `EMAIL_FROM="Name >"` | | Deploy "succeeds" but old code runs | build step failed silently, restart on stale `dist/` | build failure must abort (`|| fail`) | | `403` on a freshly deployed asset | a tool wrote it mode 600 (e.g. an SVG optimizer) | publish with `--chmod=D755,F644`; check every asset returns 200 | | `git pull` on the server refuses to fast-forward | the server's own `npm install` rewrote `package-lock.json` | `git checkout -- package-lock.json`, or stop building on the server | | 502 right after reboot, service `active` | app still starting | wait 1–2 min before debugging | | `pkill -f` killed the SSH session | pattern matched itself | `"[d]ist/index.js"` | ## Done when - [ ] `https://` → `200 tls=0 http/2`; `http://` → 301 - [ ] security headers on `/` and `/index.html` - [ ] API responses carry `Cache-Control: no-store`; `cf-cache-status` is never `HIT` for them - [ ] the app listens on `127.0.0.1:` only - [ ] the service restarts after `kill -9` and after a reboot - [ ] `./scripts/deploy.sh` is one command, backs up before migrating, fails closed - [ ] memory recorded after deploy (and a day later) - [ ] nightly backup scheduled and one restore drill passed