docs: document reverse-proxy deployment, asset caching, and scaling (#872)

This commit is contained in:
l5y
2026-07-28 21:57:00 +02:00
committed by GitHub
parent 5d2790bb48
commit c5bde6280b
3 changed files with 191 additions and 0 deletions
+80
View File
@@ -61,6 +61,8 @@ Additional environment variables are optional:
| `FEDERATION` | `1` | Controls whether the instance announces itself and crawls peers (`1`) or stays isolated (`0`). |
| `PRIVATE` | `0` | Restricts public visibility and disables chat/message endpoints when set to `1`. |
| `CONNECTION` | `/dev/ttyACM0` | Serial device, TCP endpoint, or Bluetooth target used by the ingestor to reach the radio. |
| `MIN_THREADS` | `16` | Minimum Puma worker threads kept warm on the web service. |
| `MAX_THREADS` | `96` | Maximum Puma worker threads on the web service. Each active `/api/events` (SSE) stream pins one thread, so keep this above your peak concurrent SSE clients plus API/ingest headroom. |
The ingestor posts to the URL configured via `INSTANCE_DOMAIN` (defaulting to
`http://web:41447` in the provided compose file). Use `CHANNEL_INDEX` to select
@@ -116,6 +118,84 @@ docker compose pull
docker compose up -d
```
## Running behind a reverse proxy (TLS + static assets)
The web container serves plain HTTP on port `41447`. For any public deployment,
terminate TLS in a reverse proxy in front of it. A ready-to-adapt nginx example
lives at [`deploy/nginx.example.conf`](deploy/nginx.example.conf); the notes
below explain the parts that matter.
**Forwarded headers (required).** The app derives its public scheme and host —
used for `INSTANCE_DOMAIN`, page metadata, the sitemap, and federation links —
from `X-Forwarded-Proto` and the `Host` header. Forward both, or generated URLs
resolve to the wrong scheme/host:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
**Static-asset caching.** Every JS module and `base.css` is served with a
`?v=<APP_VERSION>` query, and the layout emits one `<script type="importmap">`
that rewrites the whole module graph to those versioned URLs (SPEC `AV2``AV4`).
Versioned JS/CSS are therefore safe to cache **immutably** for a year; images,
icons, and fonts are *not* versioned and keep a short TTL with revalidation (a
stale logo is cosmetic — `AV4`). Two ways to realize this:
1. **Any deployment (portable):** have the app emit the headers itself. The
container bakes assets into the image at `/app/public` with no volume, so a
host proxy cannot read them from disk — this is the only option for the
Compose/GHCR stack. Tracked in
[#870](https://github.com/l5yth/potato-mesh/issues/870).
2. **Bare-metal (repo checkout):** serve `/assets/` straight from nginx off disk
(the `location /assets/` block in the example). This also keeps the ~50
per-page ES-module requests off the single Ruby process. For containers, only
do this if you bind-mount `web/public` into an nginx sidecar.
Three things that bite in practice — all handled in the example file:
- **Filesystem permissions:** the proxy worker user (`http`, `www-data`, …) must
be able to traverse to and read `web/public`. Check with
`namei -l <path>/web/public/assets/styles/base.css` — every parent directory
needs `o+x`, or disk-served assets return `403`.
- **Upstream keepalive** needs both `proxy_http_version 1.1` and
`proxy_set_header Connection ""`.
- **TLS session resumption:** Certbot's `options-ssl-nginx.conf` sets
`ssl_session_tickets off` (forward secrecy) and ships its own
`ssl_session_cache`. Adding `ssl_session_tickets on;` in the same server block
is a fatal *duplicate-directive* error; even placed correctly it trades away
forward secrecy unless you rotate ticket keys. Leaving it off costs ~1 RTT on
cold TLS 1.3 connections — usually the right call.
Verify after `nginx -t && systemctl reload nginx`:
```bash
curl -sD- -H 'Accept-Encoding: gzip' https://<host>/assets/js/app/main.js?v=<ver> -o /dev/null \
| grep -i 'cache-control\|content-encoding'
# expect: cache-control: public, max-age=31536000, immutable and content-encoding: gzip
```
## Performance & scaling
The web app runs as a **single Puma process** with a bounded thread pool
(`MIN_THREADS:MAX_THREADS`, default `16:96`). CRuby serialises Ruby execution on
one global lock, so a single expensive request can delay others — keep hot read
paths cheap and let the reverse proxy absorb static traffic.
- **Thread pool.** Each live-update SSE stream (`GET /api/events`) pins one
thread for its lifetime, so size `MAX_THREADS` above your expected concurrent
SSE clients plus API/ingest headroom (that is why the floor is 16, not Puma's
MRI default of 5). Override with `MIN_THREADS` / `MAX_THREADS`.
- **Static assets.** Serve `/assets/` from the reverse proxy (or via app-level
immutable headers) so the module fan-out and revalidations never touch Ruby —
see the section above.
- **Cluster (multi-process) mode is not supported out of the box.** Live updates
use an in-process pub/sub, so events would not fan out across workers; the
per-process response cache and the background retention/federation threads
would also need per-worker handling. To scale on one host today: front it with
the reverse proxy, serve assets from disk, and keep queries cheap.
## Troubleshooting
- **Serial device permissions (Linux/macOS):** grant access with `sudo chmod 666
+2
View File
@@ -123,6 +123,8 @@ The web app can be configured with environment variables (defaults shown):
| `LIVE_SAFETY_POLL_SECONDS` | `300` | Slow fallback poll cadence (seconds) the dashboard uses while live SSE updates are active. |
| `SSE_HEARTBEAT_SECONDS` | `15` | Heartbeat interval (seconds) for the live-update SSE stream so dead connections are detected and proxies do not buffer it. |
| `SSE_MAX_LIFETIME_SECONDS` | `600` | Maximum lifetime (seconds) of a single SSE connection before the server closes it, prompting the client to reconnect and resync. |
| `MIN_THREADS` | `16` | Minimum Puma worker threads kept warm. |
| `MAX_THREADS` | `96` | Maximum Puma worker threads. Each active `/api/events` SSE stream pins one thread, so keep this above your peak concurrent SSE clients plus API/ingest headroom. |
| `OG_IMAGE_URL` | _unset_ | Optional absolute URL for the social preview image. Must use an `http://` or `https://` scheme; values with other schemes are ignored. Most social platforms (Facebook, LinkedIn, Slack, iMessage) require **HTTPS** to render the card. When set, replaces the runtime-generated `/og-image.png` so deployments without Chromium (or with size-conscious images) can point at a CDN. |
| `OG_IMAGE_TTL_SECONDS` | `3600` | Cache lifetime for the runtime-generated dashboard screenshot served at `/og-image.png`. |
| `FERRUM_BROWSER_PATH` | `/usr/bin/chromium` (Docker) | Path to the headless Chromium binary used by the Open Graph preview generator. |
+109
View File
@@ -0,0 +1,109 @@
# Copyright © 2025-26 l5yth & contributors
# Licensed under the Apache License, Version 2.0 (see LICENSE)
#
# Example nginx reverse proxy for a bare-metal PotatoMesh web deployment.
# Terminates TLS, forwards to the Puma app, and serves /assets/ straight from
# disk with SPEC AV4-aligned cache headers. Copy the relevant blocks into your
# nginx config, replace every <PLACEHOLDER>, then `nginx -t && systemctl reload nginx`.
#
# DOCKER NOTE: the container image bakes assets into /app/public with no host
# volume, so the `location /assets/` disk-serving block below does NOT apply to
# the Compose/GHCR deployment. There, get immutable asset caching from the app
# itself (see https://github.com/l5yth/potato-mesh/issues/870) or bind-mount
# web/public into an nginx sidecar. The TLS + proxy parts apply to any deployment.
# ---------------------------------------------------------------------------
# http { } context — place alongside your other http-level directives
# ---------------------------------------------------------------------------
# Persistent connections to the app so each request doesn't reopen a TCP socket.
# Requires `proxy_http_version 1.1` + `proxy_set_header Connection ""` below.
upstream potatomesh {
server 127.0.0.1:41447; # <APP_HOST:PORT> — match the app's PORT / -p flag
keepalive 32;
}
# Silences the "could not build optimal types_hash" warning once gzip_types grows.
types_hash_max_size 2048;
# ---------------------------------------------------------------------------
# server { } context
# ---------------------------------------------------------------------------
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name <mesh.example.org>;
client_max_body_size 50m;
# --- TLS (Certbot-managed example) -------------------------------------
# Certbot's options-ssl-nginx.conf sets `ssl_session_tickets off` (forward
# secrecy) and its own ssl_session_cache. Do NOT add `ssl_session_tickets on;`
# in this block — the include already sets it, and a second one is a fatal
# duplicate-directive error. Leaving tickets off means TLS 1.3 can't resume
# (~1 RTT on cold connections); only enable it with rotated
# ssl_session_ticket_key files if that RTT genuinely matters to you.
ssl_certificate /etc/letsencrypt/live/<mesh.example.org>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<mesh.example.org>/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# --- Static assets straight from disk (bare-metal only — see DOCKER NOTE) --
# Versioned JS/CSS carry ?v=<APP_VERSION> and the whole module graph is
# remapped via <script type="importmap"> (SPEC AV2AV4), so they are safe to
# cache immutably for a year. Images/icons/fonts are NOT versioned, so they
# keep a short TTL + revalidation (AV4: a stale logo is cosmetic).
#
# The nginx worker user (http / www-data) must be able to traverse to and
# read this directory. Verify with:
# namei -l <root>/assets/styles/base.css # every parent needs o+x
location /assets/ {
root /opt/potato-mesh/web/public; # <path to web/public>; /assets/x → <root>/assets/x
access_log off;
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_types text/css text/javascript application/javascript image/svg+xml;
# Optional: precompress *.js/*.css at release time and add `gzip_static on;`
# for max compression at zero per-request CPU.
# Versioned executable/style assets → immutable for a year.
location ~* \.(?:js|mjs|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*" always; # add_header is not inherited into nested locations
}
# Unversioned images/icons/fonts → short TTL, always revalidate.
location ~* \.(?:png|svg|ico|jpe?g|gif|webp|woff2?)$ {
add_header Cache-Control "public, max-age=3600, must-revalidate";
add_header Access-Control-Allow-Origin "*" always;
}
}
# --- Everything else → the Puma app ------------------------------------
location / {
proxy_pass http://potatomesh;
proxy_http_version 1.1; # required for upstream keepalive
proxy_set_header Connection ""; # clear hop-by-hop header so sockets are reused
# The app derives its public scheme/host (INSTANCE_DOMAIN, metadata,
# sitemap, federation links) from these — set them or vanity/OpenGraph
# URLs resolve to the wrong scheme/host.
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;
# Live-update SSE (GET /api/events) holds a long-lived streaming response.
proxy_read_timeout 3600;
proxy_send_timeout 3600;
}
}
# --- Redirect plain HTTP to HTTPS ------------------------------------------
server {
listen 80;
listen [::]:80;
server_name <mesh.example.org>;
return 301 https://$host$request_uri;
}