Skip to content

Latest commit

 

History

History
789 lines (637 loc) · 37.3 KB

File metadata and controls

789 lines (637 loc) · 37.3 KB

MultiBlog — Deployment runbook

Concrete, self-managed deployment onto a fresh Linode running Ubuntu 26.04 LTS, built from nothing: OS provisioning, a non-root user, firewall, and installs of Node, Postgres, and nginx, then the app itself. nginx is the reverse proxy; TLS is a free single-domain Let's Encrypt cert via certbot (§7a). Collab runs path-based under /collab on the app host, so there's just one hostname and one cert. No containers, no external spam service, no email provider.

Scope of this first deploy: provision the box, stand up two Node services (Next.js app + Hocuspocus collab) behind nginx, create one Postgres database, apply migrations, seed one real admin.


0. Topology

                    ┌─ Linode (Ubuntu 26.04) ──────────────────────────────────────
                    │
  Internet ─443/TLS─┼─▶ nginx ─┬─ /       ─▶ 127.0.0.1:3000  next start  (systemd)
   (Let's Encrypt)  │          └─ /collab ─▶ 127.0.0.1:1234  hocuspocus  (systemd)
                    │
                    │   next start + hocuspocus ─▶ 127.0.0.1:5432  postgres (local)
                    │
                    └──────────────────────────────────────────────────────────────
  • App and collab are separate long-running processes, each its own systemd unit, both proxied under a single hostname — the app at /, collab at /collab.
  • Postgres binds to localhost; nothing but nginx (80/443) and SSH (22) is exposed.
  • The collab port (1234) is never opened on the firewall — nginx proxies WebSocket traffic to it. Browsers connect to wss://<app-host>/collab and nginx upgrades to the local ws. The Hocuspocus document id travels in-band, so the /collab path prefix needs no rewriting.

2. Provision the Linode from scratch

2a. Create the instance

  • Image: Ubuntu 26.04 LTS. If Linode's image list doesn't offer it yet, fall back to 24.04 LTS — every step below works unchanged on 24.04. The distro's own Node version doesn't matter either way: §2d installs Node 24 from NodeSource, whose repo is distro-agnostic.
  • Plan: a 1 GB Nanode is fine at runtime (the two Node services + Postgres are light), but 1 GB is not enough for next build — you must add swap (§2h) or the build gets OOM-killed. Add SSH keys during creation if you can.
  • Point DNS: an A/AAAA record for <app-host> at the Linode's IP. (Just the one name — collab shares this host under /collab, §7.)

2b. Initial setup & hardening

SSH in as root first, then:

apt update && apt upgrade -y
timedatectl set-timezone UTC            # or your zone

# non-root deploy user with sudo
adduser deploy                          # set a password
usermod -aG sudo deploy

# give it your SSH key
mkdir -p /home/deploy/.ssh
cp ~/.ssh/authorized_keys /home/deploy/.ssh/    # or paste your pubkey in
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh && chmod 600 /home/deploy/.ssh/authorized_keys

Then harden SSH — edit /etc/ssh/sshd_config (or a drop-in in /etc/ssh/sshd_config.d/):

PermitRootLogin no
PasswordAuthentication no

sudo systemctl restart ssh. Confirm you can log in as deploy in a new session before closing the root one. Everything from here runs as deploy with sudo.

2c. Firewall

sudo ufw allow OpenSSH        # 22
sudo ufw allow 'Nginx Full'   # 80 + 443  (available after nginx is installed in 2f;
                              # or: sudo ufw allow 80,443/tcp)
sudo ufw enable
sudo ufw status

Do not open 1234 or 5432 — both stay localhost-only behind nginx / the loopback.

Also check for a Linode Cloud Firewall — this is separate from ufw. It's a network-level firewall configured in the Linode Cloud Manager (your Linode → Network, or the Firewalls section), and if one is attached without inbound rules for 80/443 it silently drops that traffic before it ever reaches the box. ufw on the instance looks correct, the services are up, yet outside connections time out (not "refused"). This cost the first deploy ~2 hours: certbot failed with Timeout during connect (likely firewall problem) and the site was unreachable from the internet despite everything on the box being right. Either add inbound TCP 80/443 (0.0.0.0/0, ::/0) rules to the Cloud Firewall, or detach it and rely on ufw. A quick way to tell it apart from a box-local problem: from any other machine, curl -sv --max-time 8 http://<box-ip>/ — a hang/timeout points upstream (Cloud Firewall), a connection refused points at the box.

2d. Install Node 24 (NodeSource)

Use NodeSource. Do not apt install nodejs npm. Confirmed on a real 26.04 box: the distro path leaves you unable to move off it later (see the recovery box below), and the one-word npm in that command is what causes the damage.

Node 24 (Krypton) over 26.04's own 22.22.1: Node 22 has been in maintenance-only since 2025-10-21 and goes EOL 2027-04-30, while 24 is EOL 2028-04-30 — a full extra year for a one-time install. Every dependency in package.json is satisfied by 24, and the newer toolchain (Prisma 7, ESLint 10) requires ≥ 24 outright.

curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs

NodeSource now serves a distro-agnostic nodistro repo — there is no per-codename repo to lag behind a just-released Ubuntu, so 26.04 works the same as any other release. Its nodejs package declares Provides: npm and bundles a matching npm (24.18.0 ships npm 11.16.0), so npm is never separately versioned and never stale. It installs system-wide to /usr/bin/node and /usr/bin/npm, which is exactly what the §6 units expect.

Verify — check the paths, not just the versions:

node -v && npm -v && which -a node npm

Expect v24.x, npm 11.x, and both resolving under /usr/bin.

If npm resolves to /usr/local/bin/npm, stop and fix it. /usr/local/bin precedes /usr/bin on PATH, so a hand-installed npm there silently shadows NodeSource's — and since /usr/local is not dpkg-managed, apt will never touch it. Every future apt upgrade nodejs installs a /usr/bin/npm that stays masked. The tell is a version mismatch with the Node you just installed: npm 10.9.8 alongside Node 24.18.0 means the npm came from a Node 22.23.1 era, not from this install.

This is the residue of the old distro-first instructions, which told you to sudo npm install -g npm@10 to paper over the distro's stale npm. Never hand-install npm globally on this box — NodeSource's bundled one is always correct.

Confirm NodeSource's npm is present before deleting anything, or you'll be left with no npm at all:

/usr/bin/npm -v && dpkg -S /usr/bin/npm      # expect 11.x, owned by `nodejs`
ls -la /usr/local/bin/ /usr/local/lib/node_modules/

If that listing holds only npm/npx, remove just those — do not blanket-delete /usr/local/lib/node_modules, which is also where any other global CLI would live:

sudo rm -f /usr/local/bin/npm /usr/local/bin/npx
sudo rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npx
hash -r                                       # bash caches resolved paths; without this
which -a npm && npm -v                        # your shell still points at the deleted one

Recovery: libnode127 Conflicts nodejs-legacy. If the box already has the distro stack (from an earlier apt install -y nodejs npm), installing NodeSource fails with Unable to satisfy dependencies and a wall of node-* : Depends: nodejs:any lines. The cause is in the last three lines of that error: NodeSource's nodejs declares Provides: nodejs-legacy, Ubuntu's libnode127 declares Conflicts: nodejs-legacy, and libnode127 underpins every packaged node-* library on the box. apt is right — the two stacks cannot coexist.

Take the services down first; purging removes /usr/bin/node and nginx will 502 in the gap:

sudo systemctl stop multiblog-web multiblog-collab
dpkg -l | grep -E 'nodejs|npm|libnode|^ii  node-' > ~/node-packages-before.txt
sudo apt purge libnode127        # NOT -y — read the cascade first

Purging libnode127 cascades to the whole distro JS stack: nodejs, npm, ~60 node-* packages, plus eslint/webpack/terser. That is the correct set to lose — none of it is used by MultiBlog, which gets its entire toolchain from node_modules. Read the list before confirming; anything proposed outside that JS cluster means something else on the box is wired into the distro Node, and you should stop.

sudo apt autoremove --purge
sudo apt install -y nodejs

Then re-run the /usr/local check above — a purge does not remove it — and rebuild node_modules from scratch, since what's there was installed under the old Node/npm:

cd /srv/multiblog && rm -rf node_modules .next && npm ci

Resume at §5 step 4 (npx prisma generate) and restart the units when the build succeeds.

nvm is the last resort, not an equal option. It installs Node under the user's home rather than /usr/bin, so the §6 units need absolute ExecStart paths (e.g. /home/deploy/.nvm/versions/node/v24.x.x/bin/npm) plus Environment=PATH= including that bin — and every nvm install thereafter silently invalidates them. NodeSource keeps the units correct as written; prefer it.

2e. Install Postgres

sudo apt install -y postgresql postgresql-contrib
systemctl status postgresql      # should be active + enabled

26.04's default repo ships a current major (16/17-class). That's fine for a fresh DB — you do not need to match dev's PG 14, and you do not need the PGDG apt repo. The default cluster already listens only on localhost:5432 (listen_addresses = 'localhost'), and Ubuntu's default pg_hba.conf accepts password auth on 127.0.0.1/32 (scram-sha-256), so the app connects over the loopback with a password and no pg_hba edits are required.

2f. Install nginx

sudo apt install -y nginx
systemctl status nginx           # active + enabled

Config comes in §7. (If you ran ufw allow 'Nginx Full' before this, it still applies once nginx is up.)

2g. App directory

sudo mkdir -p /srv/multiblog
sudo chown deploy:deploy /srv/multiblog

2h. Swap (required on the 1 GB Nanode)

next build peaks well above 1 GB of RAM; on a 1 GB instance it will be OOM-killed partway through with no useful error. Add 2 GB of swap once, up front:

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab   # persist across reboots
free -h                                                       # confirm Swap: 2.0Gi

Swap only has to cover the build; steady-state runtime stays comfortably under 1 GB. (If you later resize to a ≥2 GB plan, the swap is harmless to leave in place.)

Swap alone isn't enough — confirmed on a real 1 GB Nanode. next build's TypeScript step still crashed with JavaScript heap out of memory even with the swap above active and almost entirely free. That error is V8 hitting its own internal heap ceiling, not an OS-level OOM-kill — V8 auto-sizes that ceiling from physical RAM alone and ignores swap, so on a ~956 MB box the default lands too low to get through the build regardless of how much swap is sitting idle. Fix: explicitly raise the ceiling so V8 actually reaches into the swap:

NODE_OPTIONS="--max-old-space-size=3072" npm run build

3072 MB fits inside the ~3.4 GB combined RAM+swap with headroom left for Postgres/nginx/ sshd. The build runs slower once it's actually swapping, but completes. This applies to every build on this box, not just the first — deploy/deploy.sh sets it already (§10).


3. Create the database + role

As the postgres system user, create a password-protected role and the DB (production uses a password — unlike the dev box's passwordless trust setup, which you should not copy):

sudo -u postgres psql
CREATE ROLE multiblog WITH LOGIN PASSWORD '<strong-password>';
CREATE DATABASE multiblog OWNER multiblog;
\q

Connection string for the app:

DATABASE_URL="postgresql://multiblog:<strong-password>@127.0.0.1:5432/multiblog?schema=public"

Keep the password URL-safe. It's embedded in DATABASE_URL, so a password containing @ : / ? # % will mis-parse the connection string (e.g. a @ looks like the host delimiter). Use an alphanumeric password — openssl rand -hex 24 is a good source — or URL-encode any reserved characters.

Quick sanity check: psql "postgresql://multiblog:<pw>@127.0.0.1:5432/multiblog" -c '\conninfo'.


4. Environment variables (prod .env, never committed)

.env* is gitignored — create it directly at /srv/multiblog/.env (systemd loads it via EnvironmentFile). Full set:

# WARNING: delete all inline # comments, else systemd interprets them as part of the variable name

DATABASE_URL="postgresql://multiblog:<pw>@127.0.0.1:5432/multiblog?schema=public"

AUTH_SECRET="<openssl rand -base64 32>"     # generate FRESH — do not reuse the dev secret
AUTH_TRUST_HOST=true
AUTH_URL="https://<app-host>"               # canonical https origin

APP_URL="https://<app-host>"                # absolute links (reset links, RSS)
COLLAB_PORT=1234
NEXT_PUBLIC_COLLAB_URL="wss://<app-host>/collab"   # path-based; see note below
COLLAB_INTERNAL_URL="http://127.0.0.1:1234"        # optional — only if collab is on another host

NEXT_PUBLIC_SITE_TITLE="<your blog's real name>"   # optional — omit to keep "MultiBlog"

SITE_BANNER="/banner.png"                   # optional — landing-page banner; omit to show none
SITE_BANNER_ASPECT="4724 / 1609"            # optional — defaults to "3 / 1"
SITE_BANNER_ALT=""                          # optional — empty is correct for a decorative banner

RESEND_API_KEY="re_..."                     # optional — omit to keep sendMail() logging instead of sending
MAIL_FROM="MultiBlog <noreply@your-domain>" # optional — required alongside RESEND_API_KEY to actually send
RESEND_INVITE_TEMPLATE_ID="tmpl_..."        # optional — invite email only; omit for a plain text/subject send

AUTH_TRUST_HOST/AUTH_URL are required behind a reverse proxy. src/lib/auth.ts (NextAuth v5) sees the incoming request via nginx, so the Host/X-Forwarded-* headers come from the proxy, not the original client connection. Without trustHost, v5 refuses to honor those headers in production and sign-in redirects/callbacks break. Setting it via env (rather than trustHost: true in the NextAuth({...}) config) means the same code runs unchanged in dev, where it's not needed.

NEXT_PUBLIC_COLLAB_URL is baked into the client bundle at npm run build. It's a NEXT_PUBLIC_ var, inlined at build time (used in PostEditor.tsx and LiveHistoryViewer.tsx). It must be set to the final wss:// URL before you build — changing it later requires a rebuild, not just a service restart. Same discipline for anything else NEXT_PUBLIC_, including NEXT_PUBLIC_SITE_TITLE below.

NEXT_PUBLIC_COLLAB_URL describes how the browser reaches collab, and nothing else. The Next server also talks to the collab process directly, over plain HTTP, for four endpoints (/admin/ydoc-snapshot, /admin/annotation-mark, /admin/annotation-unmark, /admin/annotation-flush). Those go to COLLAB_INTERNAL_URL, defaulting to http://127.0.0.1:${COLLAB_PORT}leave it unset in the standard single-box layout above; set it only if the collab server runs on a different host. It is bare, not NEXT_PUBLIC_, so changing it is a restart, not a rebuild.

This used to be derived from NEXT_PUBLIC_COLLAB_URL by rewriting wss:// to https://, which produced https://<app-host>/collab/admin/…. location /collab below has no URI part, so nginx forwards the path unmodified and the collab server — which matches on /admin/… — never recognized it, answering Hocuspocus's default 200 Welcome to Hocuspocus! instead. Every one of those endpoints was a silent no-op in production while the websocket worked fine; the visible symptom was "Annotation can't be empty." on posting an annotation. Fixed 2026-08-11 — see PLAN.md §13m. If you are upgrading past that commit, nothing in .env needs to change; you do need a rebuild + restart of multiblog-web.

NEXT_PUBLIC_SITE_TITLE (src/lib/site-config.ts) is env-sourced for the same reason as SITE_BANNER* below — a real deployment's identity should live in this gitignored file, not a tracked one a git pull could revert. Leave it unset to keep the "MultiBlog" default. Unlike SITE_BANNER*, it's NEXT_PUBLIC_, so — like NEXT_PUBLIC_COLLAB_URL — changing it later needs a rebuild (§5 step 7), not just a service restart.

SITE_BANNER/SITE_BANNER_ASPECT/SITE_BANNER_ALT (src/lib/site-banner.ts, PLAN.md §17b) configure the landing page's banner image — deliberately bare, not NEXT_PUBLIC_, since they're read server-side only. Changing any of them needs a service restart, not a rebuild — the opposite of NEXT_PUBLIC_SITE_TITLE just above, worth noting since the two otherwise look like the same kind of setting. The image itself lives at /srv/multiblog/public/banner.* (gitignored, same reasoning as .env) — swap the file directly on the server; that needs neither a restart nor a rebuild, since public/ is served straight off disk at runtime. Leave SITE_BANNER unset to show no banner at all.

The landing page's preamble paragraph(s) are separate from all of this — not an env var, but the body of whichever Doc is titled exactly FRONT PAGE (PLAN.md §17c). Seed one after the first deploy with npx tsx scripts/seed-front-page.ts (create-if-absent, safe to re-run).

RESEND_API_KEY/MAIL_FROM (src/lib/mail.ts) wire real delivery through Resend — reset links, admin-issued invite links, and RAISED-annotation notifications all actually send once both are set; leaving either unset keeps every environment on the original logging stub, so a from-scratch deploy still works with no mail configuration at all. APP_URL matters regardless, for the RSS feed's absolute links and every mailed link's URL. Before setting a real key, add the sending domain's SPF/DKIM/DMARC records — Resend's dashboard emits the exact TXT records once the domain is added there, and this is the single biggest determinant of whether mail reaches an inbox rather than a spam folder or a hard rejection. Full design, the deliverability argument, and the invite feature: docs/EMAIL.md.

AUTH_SECRET also signs the short-lived collab JWTs (src/lib/collab-token.ts), so the app and collab services must share the same value — both units point at this one .env.


5. First deploy — step by step

As deploy, in /srv/multiblog:

# 1. Get the code
git clone <repo> .            # or rsync the tree up

# 2. Install deps (need dev deps: prisma CLI, tsx, typescript are all build/runtime here)
npm ci

# 3. Create .env (§4) at /srv/multiblog/.env

# 4. Generate the Prisma client (gitignored — src/generated/prisma is not in the repo)
npx prisma generate

# 5. Apply migrations to the fresh DB  (deploy, NOT dev)
npx prisma migrate deploy

# 6. Seed the first admin — a fresh DB has no users and nothing in the UI creates
#    the first one; scripts/test-user.ts refuses non-@example.com addresses, so it
#    can't do this either (see that script's own header comment for details)
npx tsx scripts/create-admin.ts <email> "<Your Name>" <initials> '<password>'

# 6b. Site icons (docs/FAVICON.md) — gitignored, so `git clone` above brought none.
#     Without this step the site simply has no favicon/manifest icons (Next
#     emits no <link> tags for files that don't exist) rather than anything
#     broken — safe to skip on a first deploy and come back to.
scp master.png deploy@<host>:/srv/multiblog/site-icons/master.png
npx tsx scripts/build-icons.ts

# 7. Build the Next app  (NEXT_PUBLIC_COLLAB_URL must already be set — see §4 note;
#    on the 1 GB Nanode, swap alone isn't enough — see the NODE_OPTIONS note in §2h.
#    Icons must exist BEFORE this step if you want them: Next content-hashes
#    src/app/icon.png et al. into the emitted <link> href at build time, not
#    at request time — step 6b has to run first, not after.)
NODE_OPTIONS="--max-old-space-size=3072" npm run build

# 8. Install & start the systemd units (§6), configure nginx (§7), then verify (§8)

Note on npm ci vs npm ci --omit=dev: use the full install. prisma, tsx, and typescript live in devDependencies but are needed at deploy/runtime here — prisma migrate deploy/generate, and tsx actually runs the collab server in prod (§6). Pruning dev deps would break the collab service and future migrations.

Install scripts are pre-approved in package.json; a warning here means something changed. npm 11.16 (bundled with Node 24) reports dependencies whose install hooks you haven't reviewed. The field is advisory in 11.16 — the scripts run either way — but a future npm release flips it to blocking, so package.json carries an allowScripts block approving the four this project needs: @prisma/engines (query-engine binaries), esbuild (the platform binary tsx needs, without which the collab service won't start), prisma, and unrs-resolver. A clean npm ci should print no allow-scripts warnings at all.

Approvals are pinned to exact versions, so any dependency bump that changes one of those four re-triggers the warning by design — that is the prompt to re-review, not a fault. Re-approve in the repo and commit it, never with npm approve-scripts on the box: deploy.sh starts with git pull, so a server-side edit to package.json becomes a merge conflict on the next deploy.

Client generation never depended on any of this — step 4 above runs npx prisma generate explicitly rather than relying on a postinstall hook.

Pre-flight: run a production build+start locally before deploying. next dev does not enforce Next.js's static/dynamic-rendering split, so a whole class of errors only appears under next build/next start — e.g. calling a dynamic API (auth(), which reads cookies) inside a statically-generated route throws DYNAMIC_SERVER_USAGE at build/serve time but renders fine in dev. The first deploy 500'd on every post page for exactly this reason. npm run build && npm start on your dev machine (with the prod-style .env values, since next start also enforces AUTH_TRUST_HOST/AUTH_URL) catches it before it reaches a live page.


6. systemd units

Both are provided ready-to-copy in deploy/multiblog-web.service and deploy/multiblog-collab.service.

/etc/systemd/system/multiblog-web.service:

[Unit]
Description=MultiBlog Next.js app
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=deploy
WorkingDirectory=/srv/multiblog
EnvironmentFile=/srv/multiblog/.env
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/usr/bin/npm run start
Restart=on-failure
RestartSec=3

[Install]
WantedBy=multi-user.target

/etc/systemd/system/multiblog-collab.service:

[Unit]
Description=MultiBlog Hocuspocus collab server
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=deploy
WorkingDirectory=/srv/multiblog
EnvironmentFile=/srv/multiblog/.env
Environment=NODE_ENV=production
ExecStart=/usr/bin/npm run collab:prod
Restart=on-failure
RestartSec=3

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now multiblog-web multiblog-collab

Redeploys (§10, deploy/deploy.sh) restart these units via sudo systemctl restart. deploy's plain sudo group membership (§2b) still prompts for a password for that, which breaks a non-interactive redeploy — grant a NOPASSWD rule scoped to exactly this one command (never a blanket NOPASSWD: ALL):

echo 'deploy ALL=(root) NOPASSWD: /bin/systemctl restart multiblog-web multiblog-collab' \
  | sudo tee /etc/sudoers.d/multiblog
sudo visudo -cf /etc/sudoers.d/multiblog    # validate syntax before it's trusted
sudo chmod 440 /etc/sudoers.d/multiblog

(next start reads PORT; the collab server reads COLLAB_PORT from the env file. The /usr/bin/npm paths above assume the NodeSource install in §2d — if you fell back to nvm, swap them per that caveat.)

Why collab:prod and not npm run collab. The dev script (tsx watch server/collab.ts) restarts on every file change — fine locally, wrong for a long-running service. collab:prod (tsx server/collab.ts, in package.json) runs it once, no watcher. tsx is a runtime dependency here — it's what actually executes the TS entrypoint — so it must stay installed on the server; don't prune devDeps below what this needs (§5's npm ci note).


7. nginx

One server block per host: the app at /, collab under /collab, TLS via a free single-domain Let's Encrypt cert — path-based collab means one hostname, so no wildcard and no DNS-API plumbing (an HTTP-01 challenge over port 80 is enough). The full config matches deploy/nginx-app.conf.sample.

Order matters — chicken-and-egg. The full config (§7b) listens on 443 and references /etc/letsencrypt/live/<app-host>/…pem, which do not exist until certbot runs. Enable that config first and nginx -t fails on the missing cert, so nginx won't start — issue the cert before installing the 443 block. Prerequisites: DNS for <app-host> already resolves to this box, and port 80 is reachable from the internet — which means both ufw and any Linode Cloud Firewall allow it (§2c). A Cloud Firewall silently dropping 80 is a common cause of certbot's Timeout during connect.

7a. Bootstrap nginx and issue the cert

  1. Bootstrap nginx with an HTTP-only block so certbot has something to serve the challenge from. Write this to /etc/nginx/sites-available/multiblog, enable it, and remove the stock default site so its default_server can't shadow it:

    server {
        listen 80;
        listen [::]:80;
        server_name <app-host>;
        root /var/www/html;   # anything; certbot only needs to answer /.well-known/…
    }
    sudo ln -s /etc/nginx/sites-available/multiblog /etc/nginx/sites-enabled/
    sudo rm -f /etc/nginx/sites-enabled/default
    sudo nginx -t && sudo systemctl reload nginx

    Both listen lines matter, even on a first deploy. If this box ever gets a second site (another subdomain, its own bootstrap block) and that block is missing listen [::]:80;, IPv6 traffic for it has no listener of its own and silently falls through to whichever other enabled block does claim [::]:80 instead — a working connection to the wrong site, not an error, which is far harder to spot than a refused one. It shows up as certbot's HTTP-01 check 404ing (via the other site's redirect-to-https) even though this block's server_name and IPv4 config are completely correct.

  2. Issue the cert:

    sudo apt install -y certbot python3-certbot-nginx
    sudo certbot certonly --nginx -d <app-host>
  3. Swap in the real config — replace that file's contents with the full config below (§7b, edited for <app-host>), then sudo nginx -t && sudo systemctl reload nginx. The 443 block now finds the cert.

Step 2 writes /etc/letsencrypt/live/<app-host>/fullchain.pem and privkey.pem — exactly the paths the config below points at. (Alternatively certbot --nginx without certonly rewrites the server block for you in one shot — fine too, but then certbot owns the TLS lines instead of this file, and you'd skip step 3.)

Renewal is automatic: the certbot package installs a systemd timer. Ensure nginx reloads on renew and verify the whole path:

echo 'sudo systemctl reload nginx' | sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo certbot renew --dry-run

ssl_certificate must be the fullchain (leaf + intermediates), which is what the path above gives you — a leaf-only file breaks chain-building for some clients. The private key stays root-owned and out of the repo (the config below only carries the path, no key material).

7b. The full config

/etc/nginx/sites-available/multiblog (same path as the bootstrap block in §7a — this replaces it), matching deploy/nginx-app.conf.sample:

server {
    listen 80;
    listen [::]:80;
    server_name <app-host>;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name <app-host>;

    # TLS — issued by `certbot certonly --nginx -d <app-host>` (§7a above).
    ssl_certificate     /etc/letsencrypt/live/<app-host>/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/<app-host>/privkey.pem;

    # Next.js app
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;   # required for NextAuth trustHost (§4)
    }

    # Hocuspocus collab websocket. The document id travels in-band (not in the
    # URL), so /collab is proxied untouched — no prefix rewrite needed.
    location /collab {
        proxy_pass http://127.0.0.1:1234;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 3600s;      # keep idle editing sockets alive
        proxy_send_timeout 3600s;
    }
}

X-Forwarded-Proto/Host must be forwarded or NextAuth (with trustHost) can't build correct https callback URLs. The X-Real-IP/X-Forwarded-For headers matter too: submitComment records the commenter IP for rate-limiting, and getClientIp() (src/lib/request-ip.ts) already reads X-Forwarded-For then X-Real-IP — so with the two headers set above, the limiter sees the real client IP rather than 127.0.0.1. Nothing to change in the app; just don't drop those headers.

No path rewrite is needed for collab: HocuspocusProvider opens the socket at exactly NEXT_PUBLIC_COLLAB_URL (wss://<app-host>/collab) and sends the document id (the post id) in-band, not in the URL — so nginx just has to hand /collab to :1234 untouched.

Reload after editing: sudo nginx -t && sudo systemctl reload nginx.

Alternative (not used here): a separate collab subdomain. If you ever want collab on its own host (collab.<domain>) — e.g. to tune its timeouts in isolation — give it its own server {} with location /:1234, add a DNS record and a cert covering that name (a 2-name SAN cert or a wildcard), and set NEXT_PUBLIC_COLLAB_URL="wss://<collab-host>". Path-based is simpler and single-cert, so it's the default.


8. Verify

  • systemctl status multiblog-web multiblog-collab — both active. Logs: journalctl -u multiblog-web -f.
  • curl -I https://<app-host>/ → 200, home page renders.
  • Sign in as the seeded admin — confirms auth + trustHost + DB.
  • Open a post editor, type — confirms the collab WebSocket (wss://) connects (status line goes 🟢 Live). If it stays 🟡/🔴, check the collab unit logs and nginx upgrade headers.
  • Publish a post and load its public /[slug]do this specifically, not just the home page: it's the statically-generated (SSG) page class, and the one most likely to expose a build/runtime split issue that next dev never showed. A server exception here renders a generic error page while the service stays active, so a 500 is easy to miss — if the page errors, the real cause (e.g. DYNAMIC_SERVER_USAGE) is in journalctl -u multiblog-web. Don't take a running service as proof the page rendered.

9. Backups

Daily pg_dump off-box (cron), and test a restore once — an untested backup isn't one.

# /etc/cron.d/multiblog-backup  (adjust destination)
0 3 * * *  deploy  pg_dump "postgresql://multiblog:<pw>@127.0.0.1:5432/multiblog" | gzip > /var/backups/multiblog-$(date +\%F).sql.gz

Ship the dumps somewhere off the box (Linode Object Storage / another host). The postCollab BYTEA and postCollabUpdate log are included in a normal pg_dump, so live editing state survives a restore. So are contributor avatars (user_avatar.bytes, PLAN.md §17n) — roughly 5KB each, and deliberately in Postgres rather than object storage so one dump remains the whole backup at this scale.

user_invite.token holds a live invite's raw link in plaintext (docs/EMAIL.md §5's deliberate trade — the value is nulled once accepted/revoked, but a pending invite's dump carries a working credential for as long as it stays unconsumed), so treat these dumps with the same care as .env below, not merely as application data.

A pg_dump-only backup strategy misses whatever lives only on the box's filesystem, not in Postgres — a full recovery needs these backed up too:

  • .env
  • public/banner.* (the landing-page banner, PLAN.md §17b)
  • site-icons/master.png (site icons, docs/FAVICON.md)

10. Redeploy flow (subsequent deploys)

cd /srv/multiblog
git pull
npm ci
npx prisma generate
npx prisma migrate deploy          # applies any new migrations, no-op if none
npm run build                      # re-inline NEXT_PUBLIC_* if any changed
sudo systemctl restart multiblog-web multiblog-collab

Changing the site icon (docs/FAVICON.md) isn't part of this flow — it's not triggered by a code change, so git pull won't touch it. Replace site-icons/master.png and run npx tsx scripts/build-icons.ts before the npm run build step above; the icon hash is computed at build time, so a redeploy that skips this just rebuilds with the same icons.

These steps are packaged as deploy/deploy.sh (run it from /srv/multiblog as deploy). No zero-downtime story is needed at hobby scale — the restart blip is seconds. (Docker Compose remains an easy later upgrade for reproducibility, per PLAN.md §7.)


11. Running a second instance on the same box

Node, Postgres, nginx, ufw, swap, and certbot are all shared, host-level installs (§2b-§2h) — none of that repeats. Everything else is per instance and needs its own copy, because NEXT_PUBLIC_COLLAB_URL (and any other NEXT_PUBLIC_*) is baked into the build at compile time (§4), so two hostnames can never share one build:

Per-instance First instance value(s)
App directory /srv/multiblog
Postgres role + database multiblog / multiblog
Web port / collab port 3000 / 1234
systemd unit names multiblog-web / multiblog-collab
nginx server_name + server block(s) <app-host>
TLS cert (certbot -d <host>) one per hostname
/etc/sudoers.d/* NOPASSWD grant scoped to that instance's unit names
.env (own AUTH_SECRET, DB, ports, URLs)
Backup cron entry

Follow §2g-§10 again with a new directory, DB name, port pair, unit names, and hostname — deploy/deploy.sh needs no edits and no env vars: it derives WEB_UNIT/COLLAB_UNIT from its own directory's name (/srv/unibloguniblog-web/uniblog-collab), so naming the directory after the instance is what makes the table above self-consistent. Only set WEB_UNIT/COLLAB_UNIT explicitly if a unit's name won't match its directory (see the script's own header comment). Before picking ports, confirm they're actually free (ss -ltnp), since 3000/1234 are already taken.

A mixed-case database name needs quoting or Postgres silently lowercases it. CREATE DATABASE SomeName folds the unquoted identifier to somename — connecting to .../SomeName afterward then fails with "database does not exist". CREATE DATABASE "SomeName" preserves the case. Once created that way, referencing it in DATABASE_URL or psql -d SomeName works fine unquoted — connection parameters are passed literally, not parsed as SQL identifiers, so folding only bites at CREATE DATABASE time.

RAM is the real constraint on a small instance, not any of the above. A second full stack means two next start processes, two Hocuspocus processes, and two more Postgres backends running concurrently — steady-state, not just at build time. The 1 GB Nanode swap math in §2h was sized for one instance's build; check free -h under normal load once both are up, and consider resizing the plan rather than trusting swap to absorb a second instance indefinitely.