https://<domain> 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.
| Placeholder | Meaning | Example |
|---|---|---|
<domain> |
the full hostname being shipped | app.example.com |
<base-domain> |
the registrable domain the wildcard cert covers | example.com |
<server> |
SSH alias of the server (points at the IP) | web1 |
<server-ip> |
the server's IPv4 | 203.0.113.10 |
<app> |
service / folder / database name | notes |
<app-port> |
free local port for the app | 3004 |
<deploy-user> |
the non-root user apps run as | deploy |
<web-root> |
where Nginx serves static files from | /var/www/app.example.com |
<db-name> / <db-user> |
the app's database and role | notes / notes |
Conventions this assumes (change them consistently if yours differ):
.env in /home/<deploy-user>/apps/<app> (owned by the deploy
user — no permission fights on deploy)./var/www/<domain> owned by root, mode 644./home/<deploy-user>/backups, helper scripts in /home/<deploy-user>/bin.Scripts referenced below live in skills/ship-a-site/scripts/.
ssh <server> 'sudo ss -tlnp | grep LISTEN; ls /etc/nginx/sites-enabled/'
Choose a <app-port> nothing listens on. Static sites skip the port.
Check: <app-port> is absent from the ss output and <domain> has no file yet.
Add A <sub> → <server-ip>. Proxied (orange) is correct straight away when the
wildcard origin certificate already covers *.<base-domain>. (Grey cloud is only for
a server with no origin certificate yet.)
Check (laptop):
dig +short <domain> @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.
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.
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).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.X-Real-IP (set by Nginx from Cloudflare's
header). Never key rate limits on the first entry of X-Forwarded-For.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:<port>/api/health → 200.
ssh <server>
mkdir -p ~/apps/<app> ~/backups ~/bin
nano ~/apps/<app>/.env && chmod 600 ~/apps/<app>/.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:
scp skills/ship-a-site/scripts/{backup-db.sh,restore-db.sh,add-site.sh} <server>:bin/
ssh <server> 'chmod +x ~/bin/*.sh'
Check: ssh <server> 'ls -l ~/apps/<app>/.env' shows -rw-------.
ssh <server> 'sudo ~/bin/add-site.sh <domain> --proxy <app-port>'
# or for a static site / static web build:
ssh <server> 'sudo ~/bin/add-site.sh <domain> --static <web-root>'
The script writes /etc/nginx/sites-available/<domain>, 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;try_files $uri $uri/ /index.html; — static docs: try_files $uri $uri.html $uri/ =404;root <web-root>; plus location /api/ { proxy_pass ... }/assets/ immutable for a year; index.html and sw.js no-cache; icons 1 hlocation that has its own add_header repeats the security-headers includessh <server> 'sudo nginx -t && sudo systemctl reload nginx'
Check: sudo nginx -t prints syntax is ok and test is successful.
# laptop — set your real values once
APP=notes; DEPLOY_USER=deploy; PORT=3004
sed -e "s/<app>/$APP/g" -e "s/<deploy-user>/$DEPLOY_USER/g" -e "s/<app-port>/$PORT/g" \
skills/ship-a-site/scripts/app.service.template > "/tmp/$APP.service"
scp "/tmp/$APP.service" <server>:/tmp/
ssh <server> "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 <app> shows your values, no < placeholders left.
mkdir -p scripts && cp <kit>/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
tsc/Vite are the biggest memory spikes a small server
would ever take.openrsync, which has no --chown. Ship to a
folder the deploy user owns, then the server's GNU rsync publishes with sudo.--delete on publish. Old hashed chunks stay 30 days so a tab opened before
the deploy can still lazy-load its screens.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 <commit> to https://<domain>.
ssh <server> 'systemctl show -p MainPID --value <app>; sudo pkill -9 -f "[d]ist/index.js"; sleep 8; systemctl show -p MainPID,NRestarts --value <app>'
The [d] bracket stops pkill -f matching its own command line (which would kill
your SSH session). Then reboot once:
ssh <server> '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.
Checking from the server proves little. Check the way a visitor arrives:
curl -s -o /dev/null -L -w "%{http_code} tls=%{ssl_verify_result} http/%{http_version}\n" https://<domain>/
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://<domain>/
curl -sI https://<domain>/index.html | grep -ciE 'x-content-type|x-frame|referrer|strict-transport' # 4
curl -sI https://<domain>/api/health | grep -iE 'cache-control|cf-cache-status' # no-store, not HIT
For a PWA also:
curl -s -o /dev/null -w '%{content_type}\n' https://<domain>/manifest.webmanifest # application/manifest+json
curl -sI https://<domain>/sw.js | grep -i cache-control # no-cache
Check: 200 tls=0 http/2, 301 -> https://..., 4, no-store.
ssh <server> 'systemctl show <app> -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.
ssh <server> 'crontab -l; echo "5 1 * * * $HOME/bin/backup-db.sh <app>"' # add via crontab -e
ssh <server> '~/bin/backup-db.sh <app> && ~/bin/restore-db.sh <app>'
Check: the drill ends with RESULT: backup is good. Details in
backups-and-restore.md.
| 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 | <link rel="apple-touch-icon"> |
Deploy dies at "migrate" after sourcing .env |
unquoted value with < or spaces read as shell syntax |
quote it: EMAIL_FROM="Name <noreply@<domain>>" |
| 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" |
https://<domain> → 200 tls=0 http/2; http:// → 301/ and /index.htmlCache-Control: no-store; cf-cache-status is never HIT for them127.0.0.1:<app-port> onlykill -9 and after a reboot./scripts/deploy.sh is one command, backs up before migrating, fails closed