--- name: ship-a-site description: Put a website, single-page app or Node API live on your own Linux server (VPS/droplet) behind Cloudflare — DNS record, Nginx server block, TLS with an origin certificate, a capped systemd service, a one-command deploy script, backups and a restore drill — or build a brand-new hardened server from scratch. Use whenever the user wants to ship, publish, deploy, self-host or put something online; add a subdomain; move off Vercel/Netlify/Render/Heroku to a VPS; set up a server for a client; or when a self-hosted site is broken (502, certificate errors, service not starting, stale files after a deploy, security headers missing, memory climbing). --- # Ship a site Two jobs, very different sizes: | Situation | Path | Time | |---|---|---| | New site/app on a server that already exists | **Fast path** below | ~10–30 min | | Brand-new server (fresh box, a client) | `references/new-server.md` | ~2 hours | If the user has a filled-in `references/server-profile.md` (copied from `server-profile.template.md`), **read it first** — real hostnames, paths and ports instead of guesses. Never write the user's real IPs, tokens or secrets into files that get committed or published. Verify rather than assume: `dig`, `ss -tlnp`, `systemctl`, `nginx -t` before acting. --- ## Before anything: know which machine you are on Cloudflare's proxy carries HTTP/HTTPS only, so `ssh user@domain` **stops working** once a domain is proxied. Use the IP through an SSH alias (`ssh `). Label every command you give the user with **where it runs** — laptop or server. --- ## Fast path: a new site on an existing server ### 1. DNS `A → `. **Proxied is correct immediately** when a wildcard origin certificate covers `*.`. Grey cloud only when the origin has no cert yet (proxying then gives 5xx everywhere). Check: `dig +short @1.1.1.1`. ### 2. What is behind it - **Static** → `/var/www//`, root-owned, 644. - **App** → listens on `127.0.0.1:` only; pick a free port with `sudo ss -tlnp`. Code + `.env` (600) in `/home//apps/`. Every API response sends `Cache-Control: no-store`; `/api/health` returns 200. ### 3. Nginx `sudo scripts/add-site.sh --proxy ` or `--static ` — writes the block, runs `nginx -t`, reloads only on success. Then include the shared snippets and caching rules from `references/nginx-blocks.md`. Always `sudo nginx -t && sudo systemctl reload nginx`. ### 4. Service (apps) `scripts/app.service.template` → `/etc/systemd/system/.service`: `node --max-old-space-size=128 dist/index.js`, `MemoryMax=256M`, `Restart=always`. Never `tsx`, `ts-node`, `nodemon`, a dev server or `npm start` in production. Prove it: `pkill -9 -f "[d]ist/index.js"` → new PID; reboot → comes back alone. Details: `references/systemd-units.md`. ### 5. Deploy script `scripts/deploy.sh.template` → the app repo's `scripts/deploy.sh`: tests → build **on the laptop** → back up DB → rsync → `npm ci --omit=dev` → migrate → publish web via a staging dir → restart → health check → check from outside → print memory. Backup comes **before** migrating; no auto-rollback (print the rollback command instead); fail closed if any build fails. ### 6. Verify from outside ```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' ``` Want `200 tls=0 http/2`, `301`, and `4`. ### 7. Backups (apps with a DB) `scripts/backup-db.sh ` nightly from cron, `scripts/restore-db.sh ` drill once now and monthly, `scripts/pull-backups.sh` on the laptop via launchd (not macOS cron). Measure memory after the deploy and again a day later. --- ## When something is stale or wrong Work outwards: **service worker → browser → Cloudflare edge → origin.** `curl -sI https:///file | grep -i cf-cache-status` rules out two layers. Compare with the origin directly: `curl -sI -k --resolve :443: https:///file`. DNS looks wrong but `dig` is right → OS and browser caches, not the server. For any error text, search `references/gotchas.md` first. --- ## Costs State recurring costs as *per month, until removed*. **Snapshots bill monthly per snapshot forever** — take one only right before a risky change and delete it once proven. Prefer the provider's flat-price automated backups. Before upgrading a server, measure (`free -m`, per-service `MemoryCurrent`); if resizing, CPU/RAM only — disk resizes are permanent. --- ## Files | File | Use it when | |---|---| | `references/server-profile.template.md` | copy to `server-profile.md` and fill in once per server (keep it private) | | `references/new-server.md` | building a server from scratch | | `references/nginx-blocks.md` | writing server blocks, headers, caching, PWA, real-IP | | `references/systemd-units.md` | turning an app into a capped service | | `references/gotchas.md` | something is broken, or before touching SSH/Postgres/TLS/caching | | `scripts/add-site.sh` | server: create + test + reload an Nginx site | | `scripts/app.service.template` | the systemd unit | | `scripts/deploy.sh.template` | the app repo's one-command deploy | | `scripts/backup-db.sh` / `restore-db.sh` | server: nightly dump / restore drill / real recovery | | `scripts/pull-backups.sh` + `.plist.template` | laptop: off-machine copy via launchd |