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.
┌─ 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>/collaband nginx upgrades to the local ws. The Hocuspocus document id travels in-band, so the/collabpath prefix needs no rewriting.
- 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/AAAArecord for<app-host>at the Linode's IP. (Just the one name — collab shares this host under/collab, §7.)
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_keysThen 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.
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 statusDo 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.ufwon the instance looks correct, the services are up, yet outside connections time out (not "refused"). This cost the first deploy ~2 hours: certbot failed withTimeout 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 onufw. 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.
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 nodejsNodeSource 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 npmExpect v24.x, npm 11.x, and both resolving under /usr/bin.
If
npmresolves to/usr/local/bin/npm, stop and fix it./usr/local/binprecedes/usr/binonPATH, so a hand-installed npm there silently shadows NodeSource's — and since/usr/localis not dpkg-managed,aptwill never touch it. Every futureapt upgrade nodejsinstalls a/usr/bin/npmthat 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@10to 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 earlierapt install -y nodejs npm), installing NodeSource fails withUnable to satisfy dependenciesand a wall ofnode-* : Depends: nodejs:anylines. The cause is in the last three lines of that error: NodeSource'snodejsdeclaresProvides: nodejs-legacy, Ubuntu'slibnode127declaresConflicts: nodejs-legacy, andlibnode127underpins every packagednode-*library on the box. apt is right — the two stacks cannot coexist.Take the services down first; purging removes
/usr/bin/nodeand 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 firstPurging
libnode127cascades to the whole distro JS stack:nodejs,npm, ~60node-*packages, pluseslint/webpack/terser. That is the correct set to lose — none of it is used by MultiBlog, which gets its entire toolchain fromnode_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 nodejsThen re-run the
/usr/localcheck above — a purge does not remove it — and rebuildnode_modulesfrom scratch, since what's there was installed under the old Node/npm:cd /srv/multiblog && rm -rf node_modules .next && npm ciResume 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 absoluteExecStartpaths (e.g./home/deploy/.nvm/versions/node/v24.x.x/bin/npm) plusEnvironment=PATH=including thatbin— and everynvm installthereafter silently invalidates them. NodeSource keeps the units correct as written; prefer it.
sudo apt install -y postgresql postgresql-contrib
systemctl status postgresql # should be active + enabled26.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.
sudo apt install -y nginx
systemctl status nginx # active + enabledConfig comes in §7. (If you ran ufw allow 'Nginx Full' before this, it still applies once
nginx is up.)
sudo mkdir -p /srv/multiblog
sudo chown deploy:deploy /srv/multiblognext 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.0GiSwap 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 withJavaScript heap out of memoryeven 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 build3072 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.shsets it already (§10).
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 psqlCREATE ROLE multiblog WITH LOGIN PASSWORD '<strong-password>';
CREATE DATABASE multiblog OWNER multiblog;
\qConnection 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 24is a good source — or URL-encode any reserved characters.
Quick sanity check: psql "postgresql://multiblog:<pw>@127.0.0.1:5432/multiblog" -c '\conninfo'.
.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_URLare required behind a reverse proxy.src/lib/auth.ts(NextAuth v5) sees the incoming request via nginx, so theHost/X-Forwarded-*headers come from the proxy, not the original client connection. WithouttrustHost, v5 refuses to honor those headers in production and sign-in redirects/callbacks break. Setting it via env (rather thantrustHost: truein theNextAuth({...})config) means the same code runs unchanged in dev, where it's not needed.
NEXT_PUBLIC_COLLAB_URLis baked into the client bundle atnpm run build. It's aNEXT_PUBLIC_var, inlined at build time (used inPostEditor.tsxandLiveHistoryViewer.tsx). It must be set to the finalwss://URL before you build — changing it later requires a rebuild, not just a service restart. Same discipline for anything elseNEXT_PUBLIC_, includingNEXT_PUBLIC_SITE_TITLEbelow.
NEXT_PUBLIC_COLLAB_URLdescribes 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 toCOLLAB_INTERNAL_URL, defaulting tohttp://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, notNEXT_PUBLIC_, so changing it is a restart, not a rebuild.This used to be derived from
NEXT_PUBLIC_COLLAB_URLby rewritingwss://tohttps://, which producedhttps://<app-host>/collab/admin/….location /collabbelow has no URI part, so nginx forwards the path unmodified and the collab server — which matches on/admin/…— never recognized it, answering Hocuspocus's default200 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.envneeds to change; you do need a rebuild + restart ofmultiblog-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.
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, sopackage.jsoncarries anallowScriptsblock approving the four this project needs:@prisma/engines(query-engine binaries),esbuild(the platform binarytsxneeds, without which the collab service won't start),prisma, andunrs-resolver. A cleannpm cishould print noallow-scriptswarnings 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-scriptson the box:deploy.shstarts withgit pull, so a server-side edit topackage.jsonbecomes a merge conflict on the next deploy.Client generation never depended on any of this — step 4 above runs
npx prisma generateexplicitly rather than relying on a postinstall hook.
Pre-flight: run a production build+start locally before deploying.
next devdoes not enforce Next.js's static/dynamic-rendering split, so a whole class of errors only appears undernext build/next start— e.g. calling a dynamic API (auth(), which reads cookies) inside a statically-generated route throwsDYNAMIC_SERVER_USAGEat 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 starton your dev machine (with the prod-style.envvalues, sincenext startalso enforcesAUTH_TRUST_HOST/AUTH_URL) catches it before it reaches a live page.
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.targetsudo systemctl daemon-reload
sudo systemctl enable --now multiblog-web multiblog-collabRedeploys (§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:prodand notnpm 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, inpackage.json) runs it once, no watcher.tsxis 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'snpm cinote).
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 andnginx -tfails 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 bothufwand any Linode Cloud Firewall allow it (§2c). A Cloud Firewall silently dropping 80 is a common cause of certbot'sTimeout during connect.
-
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 itsdefault_servercan'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 nginxBoth
listenlines matter, even on a first deploy. If this box ever gets a second site (another subdomain, its own bootstrap block) and that block is missinglisten [::]:80;, IPv6 traffic for it has no listener of its own and silently falls through to whichever other enabled block does claim[::]:80instead — 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'sserver_nameand IPv4 config are completely correct. -
Issue the cert:
sudo apt install -y certbot python3-certbot-nginx sudo certbot certonly --nginx -d <app-host>
-
Swap in the real config — replace that file's contents with the full config below (§7b, edited for
<app-host>), thensudo 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-runssl_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).
/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 ownserver {}withlocation /→:1234, add a DNS record and a cert covering that name (a 2-name SAN cert or a wildcard), and setNEXT_PUBLIC_COLLAB_URL="wss://<collab-host>". Path-based is simpler and single-cert, so it's the default.
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 thatnext devnever showed. A server exception here renders a generic error page while the service staysactive, so a 500 is easy to miss — if the page errors, the real cause (e.g.DYNAMIC_SERVER_USAGE) is injournalctl -u multiblog-web. Don't take a running service as proof the page rendered.
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.gzShip 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:
.envpublic/banner.*(the landing-page banner, PLAN.md §17b)site-icons/master.png(site icons, docs/FAVICON.md)
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-collabChanging 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.)
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/uniblog → uniblog-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 SomeNamefolds the unquoted identifier tosomename— connecting to.../SomeNameafterward then fails with "database does not exist".CREATE DATABASE "SomeName"preserves the case. Once created that way, referencing it inDATABASE_URLorpsql -d SomeNameworks fine unquoted — connection parameters are passed literally, not parsed as SQL identifiers, so folding only bites atCREATE DATABASEtime.
RAM is the real constraint on a small instance, not any of the above. A second full stack means two
next startprocesses, 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; checkfree -hunder normal load once both are up, and consider resizing the plan rather than trusting swap to absorb a second instance indefinitely.