Skip to content

Commit f3a0066

Browse files
committed
deploy: self-host the PWA behind nginx and systemd
Railway runs app/pit. dev.moshcode.sh is meant to run on a plain VPS instead, and there was nothing in the repo describing how. apps/pwa/deploy/ is that kit: a systemd unit, an nginx vhost, an environment file for a public box, and bootstrap.sh, which does every step that does not need root and prints the four that do. The split is deliberate. Root steps are a hand-off on our boxes, so rather than half-provision and leave you debugging the script, bootstrap.sh finishes with a smoke test: it boots the app on a scratch port exactly as systemd will and waits for /healthz. If that passes, the app is known-good before nginx exists, so every later failure is the proxy or the certificate. Details worth keeping: - The unit is rendered, not hardcoded. It resolves node from process.execPath rather than PATH, because a mise shim needs mise's environment and systemd has neither it nor the working directory — a unit built from the shim dies at 203/EXEC, an error naming no code. - ReadWritePaths carves out data/ from ProtectHome=read-only. Without it SQLite reports "attempt to write a readonly database" at the first write rather than at startup. - No EnvironmentFile. src/config.mjs already loads .env with its own parser, and systemd's does not strip the same quotes, so a quoted secret would arrive still quoted and fail far from here. - npm ci, not pnpm: apps/pwa carries its own lockfile and is deployed as its own root, so a pnpm install at the repo root does not give this directory its dependencies. - The vhost goes in after certbot issues. nginx refuses to start when an ssl_certificate path is missing, so installing it first takes down every other site on the box. The DNS preflight probes a name nobody would create and compares it to the target. moshcode.sh has a wildcard, which is how dev.moshcode.sh resolves today while having no record of its own — "it resolves" would otherwise have green-lit a certbot run that issues for the wrong box. Verified end to end on a clean copy: npm ci, generated SESSION_SECRET (32 bytes, mode 600), rendered both files, and the app answered /healthz with {"ok":true,"env":"production"}. Re-run is idempotent. systemd-analyze verify passes. 547/547 apps/pwa tests still green.
1 parent 8191e93 commit f3a0066

6 files changed

Lines changed: 588 additions & 0 deletions

File tree

apps/pwa/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,8 @@ data/
44
*.db-*
55
.env
66
.env.local
7+
# Rendered by deploy/bootstrap.sh — holds one box's absolute paths, so it is
8+
# generated per machine rather than committed.
9+
deploy/out/
710
npm-debug.log*
811
.DS_Store

apps/pwa/deploy/.env.example

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# dev.moshcode.sh — the self-hosted dev instance of the PWA.
2+
#
3+
# Copy to apps/pwa/.env on the target box. `bootstrap.sh` does that for you and
4+
# fills SESSION_SECRET with real entropy; everything here is the shape it writes.
5+
#
6+
# This is deliberately NOT apps/pwa/.env.example. That one describes a laptop:
7+
# NODE_ENV=development, localhost, an insecure session secret. A box on the
8+
# public internet that inherits those settings serves secure-cookie-less
9+
# sessions signed with a secret published in git, so the two files stay
10+
# separate rather than one growing comments about when to ignore it.
11+
12+
# `production` is what sets `secure` on the session cookie (see src/config.mjs).
13+
# It is the environment's posture, not a claim that this is the prod deploy.
14+
NODE_ENV=production
15+
16+
# Plain HTTP on loopback. nginx holds the certificate and proxies here — see
17+
# the comment at the top of nginx-vhost.conf for why the app must not own it.
18+
PORT=8790
19+
20+
# Drives the WebAuthn relying-party ID, which is this hostname.
21+
#
22+
# A passkey is bound to the rpID it was registered under, so passkeys created
23+
# on app.moshcode.sh DO NOT WORK HERE and vice versa. Everyone testing against
24+
# this box registers a second passkey. That is inherent to WebAuthn, not a
25+
# misconfiguration to hunt for the first time someone cannot sign in.
26+
PUBLIC_ORIGIN=https://dev.moshcode.sh
27+
28+
# Where /pit and /n/<name> consider themselves to live. Left equal to
29+
# PUBLIC_ORIGIN so a name published on this box shows dev URLs; point it at
30+
# https://pit.moshcode.sh if you want this instance to render pages that
31+
# advertise prod's namespace instead.
32+
PIT_ORIGIN=https://dev.moshcode.sh
33+
34+
# datastore.
35+
#
36+
# `file:` keeps the dev instance's data on the box and independent of prod —
37+
# which is the point of a dev environment. Set a Turso URL + token instead if
38+
# you want it reading a shared database, but note that migrations run at boot
39+
# (src/migrate.mjs), so pointing a dev instance at prod's database MIGRATES
40+
# PROD the next time this service restarts.
41+
DATABASE_URL=file:./data/dev.db
42+
DATABASE_AUTH_TOKEN=
43+
44+
# bootstrap.sh replaces this with `openssl rand -hex 32`. If you are writing
45+
# this file by hand, do the same — leaving a literal here signs every session
46+
# on the box with a value that is in git.
47+
SESSION_SECRET=
48+
49+
# CLI ↔ app signed approval ingest. Must match the moshcode CLI's
50+
# MOSHCODE_WEBHOOK_SECRET, or signed ingest 401s with a valid-looking request.
51+
MOSHCODE_WEBHOOK_SECRET=
52+
53+
# Everything below is optional and inert when blank — the app degrades to "that
54+
# feature is off" rather than failing to boot. Fill in only what you are
55+
# actually exercising on this box.
56+
57+
# email via Resend
58+
RESEND_API_KEY=
59+
RESEND_FROM=moshcode <notify@moshcoding.com>
60+
61+
# web push (VAPID) — generate with:
62+
# node -e "console.log(require('web-push').generateVAPIDKeys())"
63+
# Use a DIFFERENT keypair from prod. A subscription is bound to the key that
64+
# created it, so sharing one lets this box push to prod's subscribers.
65+
VAPID_PUBLIC=
66+
VAPID_PRIVATE=
67+
VAPID_SUBJECT=mailto:anthony@profullstack.com
68+
69+
# CoinPay: usage-credit top-ups + "sign in with CoinPay".
70+
# The redirect URI is derived as PUBLIC_ORIGIN/auth/coinpay/callback, so this
71+
# host must be registered with CoinPay separately from prod's before the OAuth
72+
# round-trip will complete.
73+
COINPAY_API_BASE=https://coinpayportal.com
74+
COINPAY_BUSINESS_ID=
75+
COINPAY_WEBHOOK_SECRET=
76+
COINPAY_OAUTH_AUTHORIZE_URL=
77+
COINPAY_OAUTH_TOKEN_URL=
78+
COINPAY_OAUTH_USERINFO_URL=
79+
COINPAY_OAUTH_CLIENT_ID=
80+
81+
# optional channel providers
82+
TELEGRAM_BOT_TOKEN=
83+
SLACK_WEBHOOK_URL=

apps/pwa/deploy/README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Running the PWA on your own box
2+
3+
Railway runs `app.moshcode.sh` and `pit.moshcode.sh`. This directory is for the
4+
other case — a plain VPS with nginx and systemd, which is how `dev.moshcode.sh`
5+
is meant to run and how anyone self-hosting the app would do it.
6+
7+
| file | what it is |
8+
|---|---|
9+
| `bootstrap.sh` | does every step that does not need root, then prints the four that do |
10+
| `.env.example` | the dev instance's environment — **not** the same as `apps/pwa/.env.example` |
11+
| `moshcode-dev.service` | systemd unit template |
12+
| `nginx-vhost.conf` | nginx vhost template |
13+
| `out/` | where `bootstrap.sh` writes the rendered copies. Git-ignored — it holds this box's paths |
14+
15+
## Before you start
16+
17+
**The DNS record has to exist**, because certbot validates over HTTP-01:
18+
19+
```
20+
dev A <this box's IPv4>
21+
```
22+
23+
Point it at the machine that will actually serve the app. If that machine is
24+
IPv6-only, do **not** publish an AAAA-only record for a URL you intend to share
25+
— it is unreachable from every IPv4-only network. Put the `A` record on a box
26+
that has IPv4 and reverse-proxy to the v6 machine, the way `dns.moshcode.sh`
27+
already works.
28+
29+
## The whole thing
30+
31+
```sh
32+
./apps/pwa/deploy/bootstrap.sh
33+
```
34+
35+
Then run the four `sudo` commands it prints. That is the entire process.
36+
37+
`bootstrap.sh` is safe to re-run. It will not overwrite an existing `.env`, and
38+
it regenerates the rendered files every time — so after moving the checkout or
39+
changing the port, re-run it and reinstall the unit.
40+
41+
Options: `APP_HOST=` `APP_PORT=` `APP_USER=` `NODE_BIN=` as environment
42+
variables, `--render-only` to emit configs without installing or booting
43+
anything, `--skip-smoke` to skip the boot test.
44+
45+
## Why it stops before root
46+
47+
Root steps are a hand-off on our boxes, and a provisioning script that
48+
half-succeeds leaves you debugging the script instead of the service. So
49+
`bootstrap.sh` takes it as far as it can go unprivileged and finishes with a
50+
**smoke test**: it boots the app on a scratch port, exactly as systemd will,
51+
and waits for `/healthz`.
52+
53+
That test is the useful part. If it passes, the app is known-good before nginx
54+
exists, so every later failure is the proxy or the certificate — and the first
55+
question when a deploy misbehaves is already answered.
56+
57+
## Order matters in one place
58+
59+
Install the vhost **after** certbot has issued, not before. The vhost
60+
references `/etc/letsencrypt/live/<host>/fullchain.pem`, and nginx refuses to
61+
start when an `ssl_certificate` path does not exist — so installing it first
62+
takes down every other site on the box until you notice. The printed commands
63+
are in the right order already.
64+
65+
## Things that will look like bugs
66+
67+
- **Your passkeys do not work here.** A passkey is bound to the WebAuthn
68+
relying-party ID, which is the hostname. `app.moshcode.sh` and
69+
`dev.moshcode.sh` are different rpIDs, so everyone registers a second passkey
70+
on the dev box. Inherent to WebAuthn, not something to fix.
71+
- **Migrations run at boot**, inside the app. A restart is also the upgrade.
72+
The corollary is that pointing a dev instance's `DATABASE_URL` at prod's
73+
database **migrates prod** on the next restart — `.env.example` defaults to a
74+
local file for exactly that reason.
75+
- **`203/EXEC` from systemd** means the unit's `ExecStart` path is wrong,
76+
usually because the unit was copied from another box. Re-run `bootstrap.sh`;
77+
it resolves the real `node` binary rather than a mise shim, which is a path
78+
that only works with mise's environment loaded.
79+
- **A large publish batch 413s.** The API takes up to 50 items, but
80+
`express.json()` in `src/server.mjs` uses its 100kb default, which a batch of
81+
substantial posts exceeds. The vhost allows 2m so nginx is not a second,
82+
more confusing limit — the app's own ceiling is the real one.
83+
84+
## Verifying, one layer at a time
85+
86+
Every layer fails identically in a browser, so do not debug from one.
87+
88+
```sh
89+
sudo systemctl status moshcode-dev # is it running
90+
journalctl -u moshcode-dev -n 50 --no-pager
91+
curl -fsS http://127.0.0.1:8790/healthz # the app, no nginx involved
92+
curl -fsS https://dev.moshcode.sh/healthz # the whole path
93+
```
94+
95+
If the first `curl` passes and the second fails, it is nginx or the
96+
certificate. If the first fails, stop looking at nginx.

0 commit comments

Comments
 (0)