Skip to content

Commit 8fb224b

Browse files
deploy: production serve config, pm2 ecosystem, nginx, runbook
deploy/serve.prod.js — curated public plugin set (identity, fediverse, forge, gallery, feeds, dashboard); no terminal/tunnel/open-corsproxy; binds 127.0.0.1; loopback == PUBLIC_URL so pod-owning writes keep a consistent Host behind TLS (the 403 finding). deploy/ecosystem.config.cjs (pm2; .cjs because the repo is type:module), deploy/nginx-melvin.me.conf (Host passthrough + ws upgrade + no-buffering for git/streams), deploy/README.md runbook. serve.prod.js boot-tested locally.
1 parent 06036e3 commit 8fb224b

4 files changed

Lines changed: 327 additions & 0 deletions

File tree

deploy/README.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Deploying JSS + plugins to a public host (pm2 + nginx)
2+
3+
The runbook for putting this repo on a box like `melvin.me`. The code needs
4+
**no build step** — deploying is `git pull` + `pm2 restart`. The care is all
5+
in three things: which plugins you expose, keeping the public Host consistent,
6+
and persisting the data root.
7+
8+
## Prerequisites (check on the box first)
9+
10+
```bash
11+
node -v # need a modern node (18+; repo tested on 24)
12+
git --version # need >= 2.38 for forge PR merges (git merge-tree)
13+
ls /usr/lib/git-core/git-http-backend # forge/gitscratch git-over-HTTP (present with git)
14+
pm2 -v # process manager
15+
```
16+
17+
- **git < 2.38** (e.g. Ubuntu 22.04 ships 2.34): the forge still hosts/browses,
18+
but PR *merges* return 501 until you upgrade git (a PPA, or 24.04 ships 2.43).
19+
- **JSS version**: this repo pins `javascript-solid-server ^0.0.219`. `npm ci`
20+
installs it; if the box already runs an older JSS for existing pods, verify
21+
those pods still boot on 0.0.219 before cutting over.
22+
23+
## 1. Get the code
24+
25+
```bash
26+
sudo mkdir -p /var/lib/jss && sudo chown ubuntu:ubuntu /var/lib/jss # PERSISTENT data root
27+
cd /home/ubuntu
28+
git clone https://github.com/JavaScriptSolidServer/plugins.git
29+
cd plugins && git checkout gh-pages && npm ci
30+
```
31+
32+
The data root (`/var/lib/jss`) holds pods, forge repos, issue indexes, and the
33+
**Blocktrails trail private keys**. Keep it OUTSIDE the checkout (so `git pull`
34+
never touches it) and back it up. Losing the trail keys orphans your anchors.
35+
36+
## 2. The Host-consistency requirement (read this — it's the one real gotcha)
37+
38+
JSS mints WebIDs against `idpIssuer` (`https://melvin.me`) and checks pod
39+
ownership against the **request Host**. Behind a TLS proxy this means two rules:
40+
41+
1. **nginx passes `Host` through unchanged**`proxy_set_header Host $host;`
42+
(in the supplied conf). A rewritten Host = 403 on users' own writes.
43+
2. **The server's own loopback presents the public Host.** `serve.prod.js`
44+
sets every plugin's `loopbackUrl` to `PUBLIC_URL`, so add a hosts entry so
45+
that self-call resolves locally instead of hair-pinning out to the internet:
46+
47+
```bash
48+
echo "127.0.0.1 melvin.me" | sudo tee -a /etc/hosts
49+
```
50+
51+
Now a plugin's loopback to `https://melvin.me/...` hits local nginx →
52+
JSS, presenting `Host: melvin.me`, and pod-owning writes pass WAC.
53+
54+
## 3. nginx
55+
56+
```bash
57+
sudo cp deploy/nginx-melvin.me.conf /etc/nginx/sites-available/melvin.me
58+
sudo ln -sf /etc/nginx/sites-available/melvin.me /etc/nginx/sites-enabled/
59+
# ensure this line exists in http{} (nginx.conf) for WebSocket upgrades:
60+
# map $http_upgrade $connection_upgrade { default upgrade; '' close; }
61+
sudo nginx -t && sudo systemctl reload nginx
62+
```
63+
64+
TLS cert via certbot if you don't have one: `sudo certbot --nginx -d melvin.me`.
65+
66+
## 4. pm2
67+
68+
```bash
69+
# Edit deploy/ecosystem.config.cjs: cwd, PUBLIC_URL, DATA — then:
70+
pm2 start deploy/ecosystem.config.cjs
71+
pm2 save
72+
pm2 startup # once, run the line it prints, so pm2 resurrects on reboot
73+
pm2 logs jss # watch it come up
74+
```
75+
76+
## 5. Verify
77+
78+
```bash
79+
curl -s https://melvin.me/dashboard/ -o /dev/null -w '%{http_code}\n' # 200
80+
curl -s https://melvin.me/forge/ -o /dev/null -w '%{http_code}\n' # 200
81+
# register a pod, then confirm a pod-owning write round-trips (the Host check):
82+
# open https://melvin.me/forge/ , sign in, open an issue — 200, not 403.
83+
```
84+
85+
Point Phanpy (or any Mastodon client) at `https://melvin.me` to exercise the
86+
fediverse surface.
87+
88+
## Updating
89+
90+
```bash
91+
cd /home/ubuntu/plugins && git pull && npm ci && pm2 restart jss
92+
```
93+
94+
## The plugin set
95+
96+
`serve.prod.js` ships a curated public surface (identity, fediverse, forge,
97+
gallery, feeds, dashboard). Deliberately OFF: `terminal/` (a remote shell —
98+
never public), `tunnel/`, an open `corsproxy/`, plus a batch left off just to
99+
keep the surface small. Each is one line to enable in `serve.prod.js`; the
100+
OFF block there lists them with reasons. `admin/` and `metrics/` are gated on
101+
`ADMIN_AGENTS` / `METRICS_TOKEN` env vars — they don't load without them.

deploy/ecosystem.config.cjs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// pm2 ecosystem file for the JSS + plugins production server.
2+
//
3+
// pm2 start deploy/ecosystem.config.cjs
4+
// pm2 save # persist across reboots (with `pm2 startup` once)
5+
// pm2 logs jss # tail
6+
// pm2 restart jss # after a git pull
7+
//
8+
// Note on the .cjs extension: this repo is `"type": "module"`, so a plain
9+
// `.js` file here is parsed as ESM and pm2's `module.exports` would throw.
10+
// `.cjs` forces CommonJS, which is what pm2 expects for its config. (The app
11+
// itself — serve.prod.js — stays `.js`/ESM; only THIS config needs .cjs.)
12+
//
13+
// Edit the env block for your host, or better: keep secrets in the shell/
14+
// systemd env and leave this file free of them. Values here are safe defaults.
15+
16+
module.exports = {
17+
apps: [
18+
{
19+
name: 'jss',
20+
script: 'deploy/serve.prod.js',
21+
cwd: '/home/ubuntu/plugins', // the checkout of JavaScriptSolidServer/plugins
22+
interpreter: 'node',
23+
exec_mode: 'fork', // single instance — JSS holds a process-global data root
24+
instances: 1,
25+
autorestart: true,
26+
max_restarts: 10,
27+
kill_timeout: 5000, // give fastify.close() room on SIGINT
28+
env: {
29+
NODE_ENV: 'production',
30+
PUBLIC_URL: 'https://melvin.me',
31+
DATA: '/var/lib/jss', // PERSISTENT — pods, forge repos, Blocktrails keys
32+
PORT: '3240',
33+
BIND: '127.0.0.1',
34+
// METRICS_TOKEN: 'set-a-long-random-string-to-expose-/metrics',
35+
// ADMIN_AGENTS: 'https://melvin.me/melvin/profile/card#me',
36+
},
37+
},
38+
],
39+
};

deploy/nginx-melvin.me.conf

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# nginx TLS reverse proxy for the JSS + plugins server.
2+
# Assumes certbot/Let's Encrypt manages the cert (adjust paths if not).
3+
#
4+
# sudo cp deploy/nginx-melvin.me.conf /etc/nginx/sites-available/melvin.me
5+
# sudo ln -sf /etc/nginx/sites-available/melvin.me /etc/nginx/sites-enabled/
6+
# sudo nginx -t && sudo systemctl reload nginx
7+
#
8+
# Two mount choices (pick one — see deploy/README.md):
9+
# A) JSS IS the site → proxy "/" to JSS (this file, default)
10+
# B) forge alongside a site → proxy "/forge/", "/dashboard/", … sub-paths
11+
12+
upstream jss_upstream {
13+
server 127.0.0.1:3240;
14+
keepalive 32;
15+
}
16+
17+
server {
18+
listen 443 ssl http2;
19+
listen [::]:443 ssl http2;
20+
server_name melvin.me;
21+
22+
ssl_certificate /etc/letsencrypt/live/melvin.me/fullchain.pem;
23+
ssl_certificate_key /etc/letsencrypt/live/melvin.me/privkey.pem;
24+
25+
# git push can send large packs; media uploads stream too.
26+
client_max_body_size 512m;
27+
28+
location / {
29+
proxy_pass http://jss_upstream;
30+
proxy_http_version 1.1;
31+
32+
# CRITICAL: pass the public Host through unchanged. JSS mints WebIDs and
33+
# checks pod ownership against the request Host; a rewritten Host is the
34+
# 403-on-your-own-writes bug. Do NOT set proxy_set_header Host to the
35+
# upstream address.
36+
proxy_set_header Host $host;
37+
proxy_set_header X-Real-IP $remote_addr;
38+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
39+
proxy_set_header X-Forwarded-Proto $scheme;
40+
proxy_set_header X-Forwarded-Host $host;
41+
42+
# WebSocket upgrade (notifications, relay/webrtc if enabled).
43+
proxy_set_header Upgrade $http_upgrade;
44+
proxy_set_header Connection $connection_upgrade;
45+
46+
# git-http-backend and streamed archives/uploads: don't buffer.
47+
proxy_buffering off;
48+
proxy_read_timeout 300s;
49+
}
50+
}
51+
52+
# Needed for the WebSocket Connection header above; put in http{} (nginx.conf)
53+
# if not already present:
54+
# map $http_upgrade $connection_upgrade { default upgrade; '' close; }
55+
56+
server {
57+
listen 80;
58+
listen [::]:80;
59+
server_name melvin.me;
60+
return 301 https://$host$request_uri;
61+
}

deploy/serve.prod.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Production composition for a public JSS + plugins deployment (e.g. melvin.me
2+
// under pm2 behind nginx TLS). This is the hardened sibling of ../serve.js:
3+
// same loader, a CURATED plugin set, bound to loopback, origin-consistent.
4+
//
5+
// PUBLIC_URL=https://melvin.me DATA=/var/lib/jss node deploy/serve.prod.js
6+
//
7+
// Differences from serve.js (the demo) that matter in production:
8+
// 1. Plugin set is curated — no remote shell (terminal/), no reverse tunnel
9+
// (tunnel/), no open forward proxy (corsproxy/). See the OFF block below;
10+
// each is one line to enable once you've decided it's safe.
11+
// 2. Binds 127.0.0.1 — nginx (or your TLS edge) is the only thing that
12+
// reaches it. Never expose the node port directly.
13+
// 3. loopbackUrl == PUBLIC_URL, NOT http://127.0.0.1:PORT. A WebID minted
14+
// under the public host fails core's ownership check when a plugin's
15+
// loopback write arrives with a different Host (the exact 403 we hit in
16+
// testing). Sending loopback to the public origin keeps Host consistent.
17+
// REQUIREMENT: the box must resolve PUBLIC_URL's host to itself — add
18+
// `127.0.0.1 melvin.me` to /etc/hosts so the self-call stays local and
19+
// still presents Host: melvin.me to nginx. See deploy/README.md.
20+
// 4. PUBLIC_URL and DATA are required — fail fast rather than boot on a
21+
// localhost default that would mint wrong-origin identities.
22+
23+
import fs from 'node:fs';
24+
import path from 'node:path';
25+
import { fileURLToPath } from 'node:url';
26+
import { createServer } from 'javascript-solid-server/src/server.js';
27+
28+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
29+
const at = (p) => path.join(__dirname, '..', p); // plugin dirs live at the repo root
30+
31+
// ---- required environment ---------------------------------------------------
32+
const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/+$/, '');
33+
if (!/^https?:\/\//.test(PUBLIC_URL)) {
34+
throw new Error('PUBLIC_URL must be set to the public origin, e.g. https://melvin.me');
35+
}
36+
const DATA = process.env.DATA;
37+
if (!DATA) {
38+
throw new Error('DATA must be set to a PERSISTENT data root (pods, forge repos, trail keys live here)');
39+
}
40+
const PORT = Number(process.env.PORT || 3240);
41+
const HOST = process.env.BIND || '127.0.0.1'; // behind a TLS proxy; do not use 0.0.0.0
42+
const PODS = path.join(DATA, 'pods');
43+
fs.mkdirSync(PODS, { recursive: true });
44+
45+
// Loopback carries the PUBLIC host so pod-owning writes pass WAC (see header #3).
46+
const LOOPBACK = PUBLIC_URL;
47+
const origin = { baseUrl: PUBLIC_URL, loopbackUrl: LOOPBACK };
48+
49+
// ---- the curated public surface ---------------------------------------------
50+
// Grouped by role. Comment a line out to drop it; move one up from the OFF
51+
// block to add it. Every entry is WAC-governed regardless — this is about
52+
// attack surface and relevance, not trust.
53+
const plugins = [
54+
// Identity & discovery
55+
{ module: at('webfinger/plugin.js'), prefix: '/webfinger', config: { podsRoot: PODS, ...origin } },
56+
{ module: at('didweb/plugin.js'), prefix: '/didweb', config: { podsRoot: PODS, ...origin } },
57+
{ module: at('nip05/plugin.js'), prefix: '/nip05', config: { podsRoot: PODS } },
58+
59+
// Fediverse / social (point Phanpy / a Mastodon client at PUBLIC_URL)
60+
{ module: at('activitypub/plugin.js'), prefix: '/activitypub', config: { ...origin } },
61+
{ module: at('mastodon/plugin.js'), prefix: '/mastodon', config: { ...origin } },
62+
{ module: at('bluesky/plugin.js'), prefix: '/bluesky', config: { ...origin } },
63+
64+
// Content & the forge
65+
{ module: at('forge/plugin.js'), prefix: '/forge' },
66+
{ module: at('gallery/plugin.js'), prefix: '/gallery' },
67+
{ module: at('micropub/plugin.js'), prefix: '/micropub', config: { ...origin } },
68+
{ module: at('rss/plugin.js'), prefix: '/feed', config: { ...origin } },
69+
{ module: at('oembed/plugin.js'), prefix: '/oembed', config: { ...origin } },
70+
{ module: at('search/plugin.js'), prefix: '/search', config: { ...origin } },
71+
72+
// Ops
73+
{
74+
module: at('dashboard/plugin.js'),
75+
prefix: '/dashboard',
76+
config: { probes: { pay: { probe: '/paid/demo', expect: [402] } } },
77+
},
78+
];
79+
80+
// metrics: exposes process/plugin gauges — token-guard it on a public box.
81+
if (process.env.METRICS_TOKEN) {
82+
plugins.push({ module: at('metrics/plugin.js'), prefix: '/metrics', config: { loopbackUrl: LOOPBACK, token: process.env.METRICS_TOKEN } });
83+
}
84+
85+
// admin: the operator home has no operator seam yet — it serves OPEN unless
86+
// you pass an agent allowlist. Only enable WITH ADMIN_AGENTS set.
87+
if (process.env.ADMIN_AGENTS) {
88+
plugins.push({
89+
module: at('admin/plugin.js'),
90+
prefix: '/admin',
91+
config: { podsRoot: PODS, adminAgents: process.env.ADMIN_AGENTS.split(',').map((s) => s.trim()).filter(Boolean) },
92+
});
93+
}
94+
95+
// ---- deliberately OFF (enable only with intent) -----------------------------
96+
// terminal/ — a shell over WebSocket. Never on a public box.
97+
// tunnel/ — reverse HTTP tunnel ("ngrok"); SSRF-shaped egress.
98+
// corsproxy/ — forward proxy; safe only with a tight allowlist config.
99+
// relay/ — Nostr relay; fine, but it's a public write endpoint — opt in.
100+
// webrtc/ — WebRTC signaling; opt in if you actually use it.
101+
// gitscratch/ — ephemeral git remotes; forge/ supersedes it for hosting.
102+
// pay/ — 402 wall-report/demo, not a homepage feature.
103+
// s3 webdav carddav caldav jmap remotestorage matrix backup capability
104+
// otp shortlink sparql — all WAC-safe and one line each to add; left off to
105+
// keep the public surface small. Add the ones you want, with `...origin`.
106+
107+
const fastify = createServer({
108+
root: PODS,
109+
idp: true,
110+
idpIssuer: PUBLIC_URL, // canonical public origin — WebIDs/actors mint against this
111+
plugins,
112+
});
113+
114+
for (const sig of ['SIGINT', 'SIGTERM']) {
115+
process.on(sig, async () => {
116+
await fastify.close().catch(() => {});
117+
process.exit(0);
118+
});
119+
}
120+
121+
await fastify.listen({ port: PORT, host: HOST });
122+
console.log(`jss (prod) listening on ${HOST}:${PORT} — public origin ${PUBLIC_URL}`);
123+
console.log(` forge: ${PUBLIC_URL}/forge/`);
124+
console.log(` dashboard: ${PUBLIC_URL}/dashboard/`);
125+
console.log(` fediverse: point a Mastodon client (Phanpy) at ${PUBLIC_URL}`);
126+
console.log(` ${plugins.length} plugin(s) loaded; data root ${DATA}`);

0 commit comments

Comments
 (0)