mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-10 19:03:28 +02:00
docs: document reverse-proxy deployment, asset caching, and scaling (#872)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 AV2–AV4), 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;
|
||||
}
|
||||
Reference in New Issue
Block a user