kitdocs · github

Ship a site — put a static site or a Node app live on your own server

raw markdown workflows/ship-a-site.md verified 2026-09-26

Goal

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.

Inputs (placeholders used)

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):

Scripts referenced below live in skills/ship-a-site/scripts/.

Steps

1. Pick a port and confirm the hostname is free (server)

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.

2. DNS record (Cloudflare dashboard)

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.

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.

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:

Check: npm run build && node dist/index.js locally, then curl -s localhost:<port>/api/health → 200.

4. Prepare the server side of the app (server, first time only)

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-------.

5. Nginx server block (server)

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:

ssh <server> '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)

# 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.

7. The deploy script (laptop, in the app repo)

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

A static-only site needs just: build → rsync to staging → publish → verify.

Check: ./scripts/deploy.sh ends with Deployed <commit> to https://<domain>.

8. Prove the service, don't assume it (server, apps only)

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.

9. Verify from outside (laptop)

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.

10. Measure (server)

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.

11. Backups (server, apps with a database)

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.

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 <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"

Done when