From dff00baca20dba191858abc1de50b69a743063e7 Mon Sep 17 00:00:00 2001 From: Avanti Date: Thu, 22 Oct 2026 20:22:06 +0530 Subject: [PATCH 01/14] docs: add first-party production setup guide and update README (#897) --- .changeset/operator-setup-guide.md | 6 + README.md | 9 +- docs/SETUP.md | 409 +++++++++++++++++++++++ website/site/src/data/site.ts | 11 +- website/site/src/pages/get-started.astro | 2 +- 5 files changed, 431 insertions(+), 6 deletions(-) create mode 100644 .changeset/operator-setup-guide.md create mode 100644 docs/SETUP.md diff --git a/.changeset/operator-setup-guide.md b/.changeset/operator-setup-guide.md new file mode 100644 index 000000000..a9ea6cdea --- /dev/null +++ b/.changeset/operator-setup-guide.md @@ -0,0 +1,6 @@ +--- +"nostream": patch +"nostream-website": patch +--- + +Add canonical first-party production relay setup guide at `/docs/setup` and replace outdated third-party guides in `README.md`. diff --git a/README.md b/README.md index 51afd0678..5528ebb0f 100644 --- a/README.md +++ b/README.md @@ -349,15 +349,16 @@ To clean up the build, coverage and test reports run: ``` pnpm clean ``` -## Full Guide +## Production Setup Guide -- [Set up a Paid Nostr relay with Nostream and ZEBEDEE](https://docs.zbdpay.com/payments/nostr/nostr-relay) by [André Neves](https://primal.net/andre) (CTO & Co-Founder at [ZEBEDEE](https://zebedee.io/)) -- [Set up a Nostr relay in under 5 minutes](https://andreneves.xyz/p/set-up-a-nostr-relay-server-in-under) by [André Neves](https://twitter.com/andreneves) (CTO & Co-Founder at [ZEBEDEE](https://zebedee.io/)) +For a step-by-step walkthrough covering host preparation, Docker Compose deployment, reverse proxy configuration with TLS, and verification, see the official [Production Setup Guide](https://nostream.dev/docs/setup). + +Prefer to explore all reference guides? See the [documentation index](https://nostream.dev/docs). ### Accepting Payments 1. Before you begin - - Complete one of the Quick Start guides in this document + - Complete the [Production Setup Guide](https://nostream.dev/docs/setup) or one of the Quick Start guides in this document - Create a `.env` file - On `.nostr/settings.yaml` file make the following changes: - Set `payments.enabled` to `true` diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 000000000..0b3f92d4a --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,409 @@ +# Production Relay Setup Guide + +> **Verified against:** nostream v3.6.2 (Ubuntu 24.04 LTS, Docker Engine 27+, Docker Compose v2) +> **Maintenance:** Maintained in-tree and reviewed whenever changes land in `deploy/` or compose configurations. + +This is the canonical first-party guide for deploying and operating a production [nostream](https://github.com/cameri/nostream) Nostr relay. It covers host preparation, the automated deployment bootstrap, reverse proxy configuration with automatic TLS, verification, and ongoing maintenance. + +--- + +## What You Need + +Before you begin, ensure you have: + +1. **A Linux host:** A VPS or dedicated server (Ubuntu 24.04 LTS or Debian 12 recommended) with at least: + - 2 vCPUs + - 4 GB RAM + - 20+ GB SSD disk space (PostgreSQL event store grows over time) + - A public IPv4 address (and optional IPv6) +2. **A domain name:** A domain or subdomain you control (e.g. `relay.example.com`). + - Create a DNS `A` record pointing `relay.example.com` to your server's IPv4 address. + - If using IPv6, create an `AAAA` record pointing to your server's IPv6 address. +3. **Open firewall ports:** + - Port `80` (HTTP, for ACME certificate issuance and redirect) + - Port `443` (HTTPS / WSS, for secure client connections) + - Port `22` (SSH, management) + +--- + +## Common Traps to Avoid + +If you have read older community guides, be aware of the following anti-patterns: + +- **Do NOT expose port 8008 publicly:** The relay binds to `127.0.0.1:8008` by default. Only your local reverse proxy (Caddy or Nginx) should connect to it. Exposing port 8008 directly bypasses TLS and proxy connection limits. +- **Do NOT copy `migrations/` or `knexfile.js` to the host:** Database migrations are baked inside the container image and run automatically through a one-shot container (`nostream-migrate`) during startup. +- **Do NOT copy a full `settings.yaml` to the host:** The relay automatically loads defaults from `resources/default-settings.yaml` inside the image. You only need to supply the specific keys you wish to override. Creating a full copy prevents you from automatically receiving new settings defaults added in future releases. +- **Do NOT install Docker via Snap:** Snap Docker packages apply sandbox restrictions that can interfere with volume mounts and loopback socket networking. Use Docker's official apt/yum repositories. + +--- + +## 1. Install Docker Engine and Compose + +Install Docker Engine and the Compose plugin from the official Docker repository: + +```bash +# Remove any conflicting packages +for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do + sudo apt-get remove -y $pkg || true +done + +# Add Docker's official GPG key +sudo apt-get update +sudo apt-get install -y ca-certificates curl gnupg +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg + +# Add the repository to Apt sources +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Install Docker packages +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +# Verify installation +docker --version +docker compose version +``` + +--- + +## 2. Get the Deployment Files + +Clone the repository to retrieve the deployment scripts and release-managed configuration files: + +```bash +git clone https://github.com/cameri/nostream.git ~/nostream +cd ~/nostream +``` + +--- + +## 3. Bootstrap the Deploy Root + +Run the bootstrap script to create the server layout at `/opt/nostream`: + +```bash +chmod +x deploy/bootstrap.sh +sudo ./deploy/bootstrap.sh /opt/nostream +``` + +This creates the production directory structure: + +```text +/opt/nostream/ +├── docker-compose.yml # Copied from deploy/docker-compose.prod.yml +├── postgresql.conf # Performance-tuned database config +├── .env # Environment secrets (mode 600) +└── .nostr/ + ├── data/ # PostgreSQL data directory (created at start) + └── db-logs/ # PostgreSQL log directory +``` + +--- + +## 4. Choose Your Relay Image + +The production compose stack uses pre-built images from GitHub Container Registry (GHCR): + +- `ghcr.io/cameri/nostream:latest` — Tracks the latest official release. +- `ghcr.io/cameri/nostream:vX.Y.Z` — Pins a specific release version (recommended for strict immutability, e.g. `ghcr.io/cameri/nostream:v3.6.2`). + +Pull the image onto the server: + +```bash +sudo docker pull ghcr.io/cameri/nostream:latest +``` + +> [!NOTE] +> `docker-compose.yml` sets `pull_policy: never` on relay services so that Docker Compose does not attempt to pull during startup or flapping network conditions. You explicitly pull images when deploying or upgrading. + +--- + +## 5. Configure Environment Variables + +Edit `/opt/nostream/.env` with your editor of choice: + +```bash +sudo nano /opt/nostream/.env +``` + +Generate secure secrets for the placeholders: + +```bash +# Generate a secret for the SECRET variable: +openssl rand -hex 128 + +# Generate passwords for DB_PASSWORD and REDIS_PASSWORD: +openssl rand -hex 32 +``` + +Update the values in `/opt/nostream/.env`: + +```ini +SECRET= + +DB_HOST=nostream-db +DB_PORT=5432 +DB_USER=nostr_ts_relay +DB_PASSWORD= +DB_NAME=nostr_ts_relay + +REDIS_HOST=nostream-cache +REDIS_PORT=6379 +REDIS_USER=default +REDIS_PASSWORD= + +RELAY_PORT=8008 +NOSTR_CONFIG_DIR=/home/node/.nostr + +DB_MIN_POOL_SIZE=16 +DB_MAX_POOL_SIZE=64 +DB_ACQUIRE_CONNECTION_TIMEOUT=60000 +WORKER_COUNT=2 + +RELAY_BROADCAST_FANOUT=false +RELAY_BROADCAST_STREAM_KEY=nostream:relay:broadcast +``` + +*(See [`CONFIGURATION.md`](../CONFIGURATION.md) for full descriptions of all environment variables.)* + +--- + +## 6. (Optional) Custom Relay Metadata and Settings + +By default, the relay uses internal defaults from the image (`resources/default-settings.yaml`). + +If you want to set your relay name, description, contact email, or pubkey, create an overrides file: + +```bash +sudo cp ~/nostream/deploy/settings.yaml.example /opt/nostream/.nostr/settings.yaml +sudo chown 1000:1000 /opt/nostream/.nostr/settings.yaml +sudo chmod 600 /opt/nostream/.nostr/settings.yaml +sudo nano /opt/nostream/.nostr/settings.yaml +``` + +Edit the fields you wish to override: + +```yaml +info: + relay_url: wss://relay.example.com + name: "My Nostr Relay" + description: "A community Nostr relay powered by nostream." + pubkey: "" + contact: "mailto:operator@example.com" +``` + +--- + +## 7. Start the Stack + +Navigate to `/opt/nostream` and start the containers in the background: + +```bash +cd /opt/nostream +sudo docker compose up -d +``` + +Verify that all services started successfully: + +```bash +sudo docker compose ps +``` + +You should see: +- `nostream-cache`: Up and healthy (Redis) +- `nostream-db`: Up and healthy (PostgreSQL) +- `nostream-migrate`: Exited with status `0` (migrations completed) +- `nostream`: Up and running on `127.0.0.1:8008` + +Verify readiness from the host loopback: + +```bash +curl -s http://127.0.0.1:8008/healthz +curl -s http://127.0.0.1:8008/readyz +``` + +Expected output for `/readyz`: +```json +{"status":"ready","database":{"ok":true},"cache":{"ok":true}} +``` + +--- + +## 8. Put the Relay Behind TLS + +The relay listens on `127.0.0.1:8008` and requires a reverse proxy to terminate TLS on port 443 and handle WebSocket upgrades. + +### Option A: Caddy (Recommended) + +Caddy provides automatic HTTPS with Let's Encrypt certificates and native WebSocket proxying out of the box with zero boilerplate. + +1. **Install Caddy:** + ```bash + sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list + sudo apt update + sudo apt install -y caddy + ``` + +2. **Configure `/etc/caddy/Caddyfile`:** + ```text + relay.example.com { + reverse_proxy 127.0.0.1:8008 + } + ``` + +3. **Reload Caddy:** + ```bash + sudo systemctl reload caddy + ``` + +Caddy automatically provisions your TLS certificate and proxies WebSocket traffic to the relay. + +--- + +### Option B: Nginx + Certbot (Alternative) + +If you already run Nginx: + +1. **Install Nginx and Certbot:** + ```bash + sudo apt update + sudo apt install -y nginx certbot python3-certbot-nginx + ``` + +2. **Obtain certificate:** + ```bash + sudo certbot certonly --nginx -d relay.example.com + ``` + +3. **Configure Nginx virtual host (`/etc/nginx/sites-available/nostream`):** + ```nginx + server { + server_name relay.example.com; + listen 80; + listen [::]:80; + return 301 https://$host$request_uri; + } + + server { + server_name relay.example.com; + listen 443 ssl http2; + listen [::]:443 ssl http2; + + ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://127.0.0.1:8008; + proxy_http_version 1.1; + + # WebSocket upgrade headers + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + + # Forwarded client headers + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + + # Prevent timeout on idle WebSocket connections + proxy_read_timeout 1h; + proxy_send_timeout 1h; + } + } + ``` + +4. **Enable and reload:** + ```bash + sudo ln -s /etc/nginx/sites-available/nostream /etc/nginx/sites-enabled/ + sudo nginx -t + sudo systemctl reload nginx + ``` + +--- + +## 9. Verify the Public Relay + +Once your DNS and reverse proxy are active, verify your relay from the outside world: + +### 1. NIP-11 Relay Information Document +Fetch the NIP-11 JSON description over HTTPS: + +```bash +curl -s -H 'Accept: application/nostr+json' https://relay.example.com/ | jq . +``` + +You should receive a JSON response describing your relay capabilities and supported NIPs. + +### 2. Live Health Endpoints +```bash +curl -sf https://relay.example.com/healthz && echo "OK" +curl -s https://relay.example.com/readyz | jq . +``` + +### 3. Connect a Nostr Client +Open your favorite Nostr client (Coracle, Primal, Amethyst, Snort, or Damus) and add: +```text +wss://relay.example.com +``` + +You can also test connecting directly using command-line utilities such as [`nak`](https://github.com/fiatjaf/nak): + +```bash +# Query the relay for latest metadata events +npx nak req -l 1 wss://relay.example.com +``` + +--- + +## 10. Operations and Maintenance + +### Viewing Logs +View live logs from all services or specifically the relay container: + +```bash +cd /opt/nostream +sudo docker compose logs -f --tail=100 nostream +``` + +### Updating the Relay +When a new nostream version is released: + +1. Update your repository clone: + ```bash + cd ~/nostream + git pull + ``` +2. Pull the updated Docker image: + ```bash + sudo docker pull ghcr.io/cameri/nostream:latest + ``` +3. Run the automated recreate script: + ```bash + sudo ./deploy/recreate-relay.sh /opt/nostream + ``` + +This runs database migrations, recreates the container, and verifies readiness. +For zero-downtime rolling deploys with blue/green backends, consult the [`Deploy Runbook & HAProxy Guide`](../docs/DEPLOY-RUNBOOK.md). + +### Backups +Back up the following paths regularly: +- `/opt/nostream/.env` (Secrets and credentials) +- `/opt/nostream/.nostr/data/` (PostgreSQL database volume) +- `/opt/nostream/.nostr/settings.yaml` (Optional custom settings) + +--- + +## Next Steps + +- **Monetize with Admission Fees:** See [`Accepting Payments`](../README.md#accepting-payments) to enable Lightning / LNURL / Zebedee pay-to-relay features. +- **Onion & I2P Hidden Services:** See [`CLI Reference & Common Workflows`](../CLI.md#common-workflows) to expose your relay anonymously. +- **Admin Console & Operations:** See [`Configuration`](../CONFIGURATION.md) to enable the web-based Operator Console at `/admin`. diff --git a/website/site/src/data/site.ts b/website/site/src/data/site.ts index e2c7fcd87..f8d8f624a 100644 --- a/website/site/src/data/site.ts +++ b/website/site/src/data/site.ts @@ -25,6 +25,14 @@ export const NAV = [ /** Repository markdown rendered under /docs/. Paths are relative to the repo root. */ export const DOCS = [ + { + slug: 'setup', + file: 'docs/SETUP.md', + title: 'Production setup guide', + group: 'Start here', + blurb: + 'Step-by-step production setup: host requirements, bootstrap, compose, reverse proxy with TLS, and verification.', + }, { slug: 'quick-start', file: 'README.md', @@ -120,4 +128,5 @@ export const NIP_TITLES: Record = { 66: 'Relay discovery & monitoring', } -export const nipUrl = (n: number) => `https://github.com/nostr-protocol/nips/blob/master/${String(n).padStart(2, '0')}.md` +export const nipUrl = (n: number) => + `https://github.com/nostr-protocol/nips/blob/master/${String(n).padStart(2, '0')}.md` diff --git a/website/site/src/pages/get-started.astro b/website/site/src/pages/get-started.astro index 1d3b3e59b..f472766a9 100644 --- a/website/site/src/pages/get-started.astro +++ b/website/site/src/pages/get-started.astro @@ -87,7 +87,7 @@ const agents = ['Claude Code', 'Cursor', 'Codex', 'OpenCode', 'Pi'] }

- Prefer to do it by hand? Choose a deploy path. Browse other schematics on + Prefer to do it by hand? Set it up yourself → or choose a deploy path. Browse other schematics on schemaformat.ai ↗.

From 06f08aded9340feb1979977ac8913961261f0960 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Wed, 28 Oct 2026 21:33:53 -0400 Subject: [PATCH 02/14] docs(setup): fix six defects found in review Review of the setup guide against the code found instructions that fail on a host the guide itself recommends: - Docker install used the Ubuntu apt tree unconditionally, so apt-get update 404s on Debian 12 (also offered as supported). Distro is now detected from /etc/os-release and unknown distros fail loudly instead of silently. - The relay trusts only loopback and leaves network.remoteIpHeader unset, so behind the documented proxy every client shares one rate-limit bucket. Documented remoteIpHeader and trustedProxies. - The /readyz example showed status 'ready' and a 'cache' key; the handler returns 'ok' and 'redis', and 503 when a dependency is down. - jq and openssl are used but were missing from prerequisites. - The guide is 'verified against v3.6.2' but pulls :latest; and because compose sets pull_policy: never, pulling a version tag while the compose file says :latest runs the wrong image. Both now stated. - The update path never re-synced docker-compose.yml, which bootstrap copies to /opt/nostream, so release-managed changes never arrived. Verified: site builds 15 pages, /docs/setup renders, install block passes bash -n, and the distro branch resolves for both debian/bookworm and ubuntu/noble (both dists return 200 from download.docker.com). --- docs/SETUP.md | 71 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 0b3f92d4a..73d483c30 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -2,6 +2,8 @@ > **Verified against:** nostream v3.6.2 (Ubuntu 24.04 LTS, Docker Engine 27+, Docker Compose v2) > **Maintenance:** Maintained in-tree and reviewed whenever changes land in `deploy/` or compose configurations. +> +> The steps below pull `:latest`, which may be newer than the version above. Pin a tag in both `docker-compose.yml` and the `docker pull` command (step 4) if you need a known version. This is the canonical first-party guide for deploying and operating a production [nostream](https://github.com/cameri/nostream) Nostr relay. It covers host preparation, the automated deployment bootstrap, reverse proxy configuration with automatic TLS, verification, and ongoing maintenance. @@ -23,6 +25,10 @@ Before you begin, ensure you have: - Port `80` (HTTP, for ACME certificate issuance and redirect) - Port `443` (HTTPS / WSS, for secure client connections) - Port `22` (SSH, management) +4. **A few command-line tools** used later in this guide. `curl` and `openssl` generate your secrets and probe the relay; `jq` pretty-prints the JSON responses: + ```bash + sudo apt-get install -y curl openssl jq + ``` --- @@ -39,7 +45,7 @@ If you have read older community guides, be aware of the following anti-patterns ## 1. Install Docker Engine and Compose -Install Docker Engine and the Compose plugin from the official Docker repository: +Install Docker Engine and the Compose plugin from the official Docker repository. The repository path differs by distribution — Debian and Ubuntu use separate trees, and pointing a Debian host at the Ubuntu tree makes `apt-get update` fail with a 404. ```bash # Remove any conflicting packages @@ -47,17 +53,27 @@ for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do sudo apt-get remove -y $pkg || true done -# Add Docker's official GPG key +# Add Docker's official GPG key. The distro is detected from /etc/os-release so this works on +# both Debian and Ubuntu; download.docker.com/linux/ only exists for the matching OS. +source /etc/os-release +case "${ID}" in + debian|ubuntu) DOCKER_DISTRO="${ID}" ;; + *) + echo "Unsupported distribution '${ID}'. Install Docker Engine manually; see https://docs.docker.com/engine/install/" >&2 + exit 1 + ;; +esac + sudo apt-get update sudo apt-get install -y ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings -curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +curl -fsSL "https://download.docker.com/linux/${DOCKER_DISTRO}/gpg" | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg # Add the repository to Apt sources echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ - $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${DOCKER_DISTRO} \ + ${VERSION_CODENAME} stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # Install Docker packages @@ -118,8 +134,15 @@ Pull the image onto the server: sudo docker pull ghcr.io/cameri/nostream:latest ``` -> [!NOTE] +> [!IMPORTANT] > `docker-compose.yml` sets `pull_policy: never` on relay services so that Docker Compose does not attempt to pull during startup or flapping network conditions. You explicitly pull images when deploying or upgrading. +> +> That also means **the tag in `docker-compose.yml` is the tag that runs.** Pulling `ghcr.io/cameri/nostream:v3.6.2` while the compose file still says `:latest` deploys `:latest`, not the version you pulled. To pin, set the same tag in both places: +> +> ```bash +> sudo sed -i 's|ghcr.io/cameri/nostream:.*|ghcr.io/cameri/nostream:v3.6.2|' /opt/nostream/docker-compose.yml +> sudo docker pull ghcr.io/cameri/nostream:v3.6.2 +> ``` --- @@ -197,6 +220,26 @@ info: contact: "mailto:operator@example.com" ``` +### Tell the relay about your reverse proxy + +By default the relay trusts **only loopback** as a proxy, and `network.remoteIpHeader` is unset. Behind Caddy or Nginx every request therefore appears to come from `127.0.0.1`, which means **all clients share one rate-limit bucket** — one busy client can rate-limit everyone else. + +If you fronted the relay with the proxy configured in step 8, uncomment and set these in the same file: + +```yaml +network: + # Which header the proxy sets. Every proxy below sets X-Forwarded-For by default. + remoteIpHeader: x-forwarded-for + # Only these addresses may set that header. Keep loopback, and add your proxy's address. + trustedProxies: + - "127.0.0.1" + - "::1" +``` + +Caddy and Nginx on the same host connect over loopback, so the defaults above already cover them. A proxy in another container or on another host needs its address added here; otherwise the header is ignored and the warning below appears in the logs. + +> `remoteIpHeader` set without a matching `trustedProxies` entry is silently ignored. The relay logs `WARNING: network.remoteIpHeader is set but network.trustedProxies is empty` at startup — if you see that line, clients are not being identified individually. + --- ## 7. Start the Stack @@ -227,10 +270,11 @@ curl -s http://127.0.0.1:8008/healthz curl -s http://127.0.0.1:8008/readyz ``` -Expected output for `/readyz`: +Expected output for `/readyz` (HTTP 200; the relay returns 503 when either dependency is down): ```json -{"status":"ready","database":{"ok":true},"cache":{"ok":true}} +{"status":"ok","database":{"ok":true},"redis":{"ok":true}} ``` +The key is `redis`, not `cache`, and a healthy status is `ok`. When relay broadcast fanout is enabled an extra `relayBroadcast` key appears. --- @@ -382,16 +426,21 @@ When a new nostream version is released: cd ~/nostream git pull ``` -2. Pull the updated Docker image: +2. Re-sync the release-managed compose file. `git pull` updates your clone, but the running stack reads `/opt/nostream/docker-compose.yml`, which is a copy taken at bootstrap time — it does not follow the repository on its own: + ```bash + sudo cp ~/nostream/deploy/docker-compose.prod.yml /opt/nostream/docker-compose.yml + ``` + Diff it first if you edited the copy by hand; this overwrites it. +3. Pull the updated Docker image: ```bash sudo docker pull ghcr.io/cameri/nostream:latest ``` -3. Run the automated recreate script: +4. Run the automated recreate script: ```bash sudo ./deploy/recreate-relay.sh /opt/nostream ``` -This runs database migrations, recreates the container, and verifies readiness. +This runs database migrations, recreates the container, and verifies readiness. For zero-downtime rolling deploys with blue/green backends, consult the [`Deploy Runbook & HAProxy Guide`](../docs/DEPLOY-RUNBOOK.md). ### Backups From 9668ac14503703d33e71da47ce0a5cc6223f5299 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Wed, 28 Oct 2026 21:39:22 -0400 Subject: [PATCH 03/14] docs(setup): make proxy trust mandatory and fix backup advice Second review pass, addressing what Greptile still flagged at 4/5 and two things its summary did not cover: - Proxy trust was written as conditional ('if you fronted the relay'), but the relay binds to 127.0.0.1 and cannot be exposed without a reverse proxy, so it applies to every deployment. Retitled required, and the text now says to add a network block rather than uncomment one: deploy/settings.yaml.example has no network section at all. - Backups told operators to copy .nostr/data while PostgreSQL was running, which captures a torn, mid-write state that may not restore. Replaced with a pg_dump/psql pair reading DB_USER and DB_NAME from .env, plus the files that are safe to copy directly. - Removed the '[!IMPORTANT]' alert. The site renderer does not support GitHub alert syntax, so it printed the marker as literal text -- the original '[!NOTE]' had the same defect on the published page. Verified: site builds 15 pages, no '[!' or stray '>' text in the rendered page, all 46 fences balanced. --- docs/SETUP.md | 54 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 73d483c30..01c0b2cd5 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -134,15 +134,14 @@ Pull the image onto the server: sudo docker pull ghcr.io/cameri/nostream:latest ``` -> [!IMPORTANT] -> `docker-compose.yml` sets `pull_policy: never` on relay services so that Docker Compose does not attempt to pull during startup or flapping network conditions. You explicitly pull images when deploying or upgrading. -> -> That also means **the tag in `docker-compose.yml` is the tag that runs.** Pulling `ghcr.io/cameri/nostream:v3.6.2` while the compose file still says `:latest` deploys `:latest`, not the version you pulled. To pin, set the same tag in both places: -> -> ```bash -> sudo sed -i 's|ghcr.io/cameri/nostream:.*|ghcr.io/cameri/nostream:v3.6.2|' /opt/nostream/docker-compose.yml -> sudo docker pull ghcr.io/cameri/nostream:v3.6.2 -> ``` +**Note on `pull_policy`.** `docker-compose.yml` sets `pull_policy: never` on relay services so that Docker Compose does not attempt to pull during startup or flapping network conditions. You explicitly pull images when deploying or upgrading. + +That also means **the tag in `docker-compose.yml` is the tag that runs.** Pulling `ghcr.io/cameri/nostream:v3.6.2` while the compose file still says `:latest` deploys `:latest`, not the version you pulled. To pin, set the same tag in both places: + +```bash +sudo sed -i 's|ghcr.io/cameri/nostream:.*|ghcr.io/cameri/nostream:v3.6.2|' /opt/nostream/docker-compose.yml +sudo docker pull ghcr.io/cameri/nostream:v3.6.2 +``` --- @@ -220,11 +219,11 @@ info: contact: "mailto:operator@example.com" ``` -### Tell the relay about your reverse proxy +### Tell the relay about your reverse proxy (required) -By default the relay trusts **only loopback** as a proxy, and `network.remoteIpHeader` is unset. Behind Caddy or Nginx every request therefore appears to come from `127.0.0.1`, which means **all clients share one rate-limit bucket** — one busy client can rate-limit everyone else. +The relay binds to `127.0.0.1` and **cannot be exposed without a reverse proxy** (step 8), so this applies to every deployment. By default it trusts only loopback and leaves `network.remoteIpHeader` unset, so every request arrives looking like `127.0.0.1` and **all clients share one rate-limit bucket** — one busy client can rate-limit everyone else. -If you fronted the relay with the proxy configured in step 8, uncomment and set these in the same file: +Add this to `/opt/nostream/.nostr/settings.yaml`. `deploy/settings.yaml.example` does not contain a `network` block, so add it rather than uncommenting: ```yaml network: @@ -444,10 +443,33 @@ This runs database migrations, recreates the container, and verifies readiness. For zero-downtime rolling deploys with blue/green backends, consult the [`Deploy Runbook & HAProxy Guide`](../docs/DEPLOY-RUNBOOK.md). ### Backups -Back up the following paths regularly: -- `/opt/nostream/.env` (Secrets and credentials) -- `/opt/nostream/.nostr/data/` (PostgreSQL database volume) -- `/opt/nostream/.nostr/settings.yaml` (Optional custom settings) + +Take two kinds, because they fail differently: + +**A logical database dump** is the one that restores reliably. Copying `/opt/nostream/.nostr/data/` while PostgreSQL is running captures a torn, mid-write state that may not start again; use `pg_dump` instead: + +```bash +set -a; . /opt/nostream/.env; set +a +sudo docker compose -f /opt/nostream/docker-compose.yml exec -T nostream-db \ + pg_dump -U "$DB_USER" "$DB_NAME" | gzip > ~/nostream-$(date +%F).sql.gz +``` + +Restore into an empty database: + +```bash +set -a; . /opt/nostream/.env; set +a +gunzip -c ~/nostream-2026-10-29.sql.gz | sudo docker compose -f /opt/nostream/docker-compose.yml exec -T nostream-db \ + psql -U "$DB_USER" -d "$DB_NAME" +``` + +**Configuration** can be copied directly — these are plain files: +- `/opt/nostream/.env` (secrets and credentials, mode 600) +- `/opt/nostream/.nostr/settings.yaml` (if you created one) +- `/opt/nostream/docker-compose.yml` (your copy may carry local edits) + +A filesystem snapshot of the whole host taken with the database stopped is the only safe way to back up the data directory itself. + +Test a restore before you need one. --- From 9d594b42587619de38a4a3b73daeaba18379b9c6 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Wed, 28 Oct 2026 21:44:11 -0400 Subject: [PATCH 04/14] docs(setup): fix proxy-trust placement and backup commands Greptile re-review caught three defects, two of them introduced by my previous commit: - Proxy trust was titled required but sat inside step 6 '(Optional)'. A required instruction under an optional heading gets skipped, which is exactly the client-collision bug it exists to prevent. Step 6 is now 'Configure Relay Settings': creating the file is required, proxy trust is a required subsection, metadata stays optional. - The backup commands read .env as the invoking user, but bootstrap installs it mode 600 root-owned, so the variables came back empty. Both commands now extract DB_USER and DB_NAME through sudo. - A failed pg_dump piped into gzip still exits 0 and leaves a valid gzip of nothing, so the backup looked successful. Added pipefail and a check that the archive opens and holds more than 1000 bytes. Verified: the sed extraction returns both values from a 600 .env; the guard rejects a gzip of empty input that plain gzip -t accepts; 28 bash blocks parse; site builds 15 pages. --- docs/SETUP.md | 58 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 01c0b2cd5..5a97825fd 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -195,50 +195,51 @@ RELAY_BROADCAST_STREAM_KEY=nostream:relay:broadcast --- -## 6. (Optional) Custom Relay Metadata and Settings +## 6. Configure Relay Settings -By default, the relay uses internal defaults from the image (`resources/default-settings.yaml`). - -If you want to set your relay name, description, contact email, or pubkey, create an overrides file: +Both the proxy settings below and any metadata overrides live in one file. Create it from the shipped example: ```bash sudo cp ~/nostream/deploy/settings.yaml.example /opt/nostream/.nostr/settings.yaml sudo chown 1000:1000 /opt/nostream/.nostr/settings.yaml sudo chmod 600 /opt/nostream/.nostr/settings.yaml -sudo nano /opt/nostream/.nostr/settings.yaml -``` - -Edit the fields you wish to override: - -```yaml -info: - relay_url: wss://relay.example.com - name: "My Nostr Relay" - description: "A community Nostr relay powered by nostream." - pubkey: "" - contact: "mailto:operator@example.com" ``` -### Tell the relay about your reverse proxy (required) +### Proxy trust (required) The relay binds to `127.0.0.1` and **cannot be exposed without a reverse proxy** (step 8), so this applies to every deployment. By default it trusts only loopback and leaves `network.remoteIpHeader` unset, so every request arrives looking like `127.0.0.1` and **all clients share one rate-limit bucket** — one busy client can rate-limit everyone else. -Add this to `/opt/nostream/.nostr/settings.yaml`. `deploy/settings.yaml.example` does not contain a `network` block, so add it rather than uncommenting: +`deploy/settings.yaml.example` has no `network` section, so add one: ```yaml network: - # Which header the proxy sets. Every proxy below sets X-Forwarded-For by default. + # Which header the proxy sets. Both proxies in step 8 set X-Forwarded-For by default. remoteIpHeader: x-forwarded-for - # Only these addresses may set that header. Keep loopback, and add your proxy's address. + # Only these addresses may set that header. trustedProxies: - "127.0.0.1" - "::1" ``` -Caddy and Nginx on the same host connect over loopback, so the defaults above already cover them. A proxy in another container or on another host needs its address added here; otherwise the header is ignored and the warning below appears in the logs. +Caddy and Nginx on the same host connect over loopback, so the list above covers them. A proxy in another container or on another host needs its address added here; otherwise the header is ignored. > `remoteIpHeader` set without a matching `trustedProxies` entry is silently ignored. The relay logs `WARNING: network.remoteIpHeader is set but network.trustedProxies is empty` at startup — if you see that line, clients are not being identified individually. +### Relay metadata (optional) + +To set your relay name, description, contact email or pubkey, add an `info` block to the same file: + +```yaml +info: + relay_url: wss://relay.example.com + name: "My Nostr Relay" + description: "A community Nostr relay powered by nostream." + pubkey: "" + contact: "mailto:operator@example.com" +``` + +Anything you omit falls back to `resources/default-settings.yaml` in the image. + --- ## 7. Start the Stack @@ -449,15 +450,26 @@ Take two kinds, because they fail differently: **A logical database dump** is the one that restores reliably. Copying `/opt/nostream/.nostr/data/` while PostgreSQL is running captures a torn, mid-write state that may not start again; use `pg_dump` instead: ```bash -set -a; . /opt/nostream/.env; set +a +set -o pipefail # otherwise the pipeline exits with gzip's status and a failed pg_dump looks fine +# .env is mode 600 and root-owned, so read just the two values we need. +DB_USER="$(sudo sed -n 's/^DB_USER=//p' /opt/nostream/.env)" +DB_NAME="$(sudo sed -n 's/^DB_NAME=//p' /opt/nostream/.env)" sudo docker compose -f /opt/nostream/docker-compose.yml exec -T nostream-db \ pg_dump -U "$DB_USER" "$DB_NAME" | gzip > ~/nostream-$(date +%F).sql.gz ``` +Check the dump opens and is not empty before trusting it — `pg_dump` writing into a pipe can fail while still leaving a valid gzip of nothing: + +```bash +gzip -t ~/nostream-$(date +%F).sql.gz && [ "$(gunzip -c ~/nostream-$(date +%F).sql.gz | wc -c)" -gt 1000 ] && echo "archive OK" +``` + Restore into an empty database: ```bash -set -a; . /opt/nostream/.env; set +a +# .env is mode 600 and root-owned, so read just the two values we need. +DB_USER="$(sudo sed -n 's/^DB_USER=//p' /opt/nostream/.env)" +DB_NAME="$(sudo sed -n 's/^DB_NAME=//p' /opt/nostream/.env)" gunzip -c ~/nostream-2026-10-29.sql.gz | sudo docker compose -f /opt/nostream/docker-compose.yml exec -T nostream-db \ psql -U "$DB_USER" -d "$DB_NAME" ``` From 30957598a907e35af83b0fcec28ac5e6cc5fc7d4 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Wed, 28 Oct 2026 21:49:15 -0400 Subject: [PATCH 05/14] docs(setup): handle quoted .env values and the silent settings reset Two more from the latest review, both consequences of edits I made: - The backup commands stripped the leading 'NAME=' but kept any quotes, so DB_USER="nostr_ts_relay" produced a quoted identifier and psql saw the wrong role. Both blocks now share an envval() helper that trims single and double quotes; verified against quoted, unquoted and single-quoted fixtures. - Step 6 tells operators to hand-edit settings.yaml, but Settings.create catches a parse error, logs it, and falls back to the image defaults. A YAML typo therefore discards the relay name, proxy trust and every other override while the container still reports healthy. The guide now says so and gives the log line to check. Verified: 29 bash blocks parse, 50 fences balanced, site builds 15 pages, envval returns correct values for all three quoting styles. --- docs/SETUP.md | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 5a97825fd..4e22b2902 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -240,6 +240,14 @@ info: Anything you omit falls back to `resources/default-settings.yaml` in the image. +**A malformed `settings.yaml` fails silently.** If the file does not parse, the relay logs the error and starts with the image defaults — so your relay name, proxy trust and every other override quietly disappear while the process still comes up healthy. After editing, check the log for `error reading config file`: + +```bash +sudo docker compose -f /opt/nostream/docker-compose.yml logs nostream | grep -i 'error reading config' +``` + +No output means it parsed. Note that the relay rewrites this file from the admin console, which drops comments but keeps the values. + --- ## 7. Start the Stack @@ -451,9 +459,10 @@ Take two kinds, because they fail differently: ```bash set -o pipefail # otherwise the pipeline exits with gzip's status and a failed pg_dump looks fine -# .env is mode 600 and root-owned, so read just the two values we need. -DB_USER="$(sudo sed -n 's/^DB_USER=//p' /opt/nostream/.env)" -DB_NAME="$(sudo sed -n 's/^DB_NAME=//p' /opt/nostream/.env)" +# .env is mode 600 and root-owned, so read just the two values we need, stripping any quotes. +envval() { sudo sed -n "s/^$1=//p" /opt/nostream/.env | tr -d "\"'"; } +DB_USER="$(envval DB_USER)" +DB_NAME="$(envval DB_NAME)" sudo docker compose -f /opt/nostream/docker-compose.yml exec -T nostream-db \ pg_dump -U "$DB_USER" "$DB_NAME" | gzip > ~/nostream-$(date +%F).sql.gz ``` @@ -467,9 +476,10 @@ gzip -t ~/nostream-$(date +%F).sql.gz && [ "$(gunzip -c ~/nostream-$(date +%F).s Restore into an empty database: ```bash -# .env is mode 600 and root-owned, so read just the two values we need. -DB_USER="$(sudo sed -n 's/^DB_USER=//p' /opt/nostream/.env)" -DB_NAME="$(sudo sed -n 's/^DB_NAME=//p' /opt/nostream/.env)" +# .env is mode 600 and root-owned, so read just the two values we need, stripping any quotes. +envval() { sudo sed -n "s/^$1=//p" /opt/nostream/.env | tr -d "\"'"; } +DB_USER="$(envval DB_USER)" +DB_NAME="$(envval DB_NAME)" gunzip -c ~/nostream-2026-10-29.sql.gz | sudo docker compose -f /opt/nostream/docker-compose.yml exec -T nostream-db \ psql -U "$DB_USER" -d "$DB_NAME" ``` From 1b0b0b4b60218d6549cfd0fdae765c27fd7077d6 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Wed, 28 Oct 2026 21:53:46 -0400 Subject: [PATCH 06/14] docs(setup): stop the proxy example from dropping default entries The trustedProxies sample listed only the loopback entries. mergeDeepRight replaces arrays outright rather than appending, so copying that block deleted 10.10.10.1 and ::ffff:10.10.10.1 -- the Docker bridge gateway a containerised proxy connects through. The example would have disabled proxy trust for exactly the deployment it warns about. The block now reproduces the full image default and says to append, not shorten. Also replaced the settings-error log check. 'docker compose logs | grep' only sees the current container, so after a restart the line is gone and silence proves nothing; it now checks the effective settings through the NIP-11 document, where a missing name means the file did not load. The field is top-level, not nested under info -- confirmed against the handler and by the existing 'includes the relay name' spec. Verified: ramda mergeDeepRight on the old vs new list, NIP-11 spec 52 passing, 29 bash blocks parse, site builds 15 pages. --- docs/SETUP.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 4e22b2902..d03808f5b 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -209,19 +209,22 @@ sudo chmod 600 /opt/nostream/.nostr/settings.yaml The relay binds to `127.0.0.1` and **cannot be exposed without a reverse proxy** (step 8), so this applies to every deployment. By default it trusts only loopback and leaves `network.remoteIpHeader` unset, so every request arrives looking like `127.0.0.1` and **all clients share one rate-limit bucket** — one busy client can rate-limit everyone else. -`deploy/settings.yaml.example` has no `network` section, so add one: +`deploy/settings.yaml.example` has no `network` section, so add one. Copy the default `trustedProxies` list **in full** and append to it — arrays are replaced wholesale by the settings merge, not appended, so a shortened list deletes addresses the image ships: ```yaml network: # Which header the proxy sets. Both proxies in step 8 set X-Forwarded-For by default. remoteIpHeader: x-forwarded-for - # Only these addresses may set that header. trustedProxies: - "127.0.0.1" + - "::ffff:127.0.0.1" - "::1" + - "10.10.10.1" + - "::ffff:10.10.10.1" + # Append your proxy's address if it is not one of the above. ``` -Caddy and Nginx on the same host connect over loopback, so the list above covers them. A proxy in another container or on another host needs its address added here; otherwise the header is ignored. +The list above is the image default. `10.10.10.1` is the Docker bridge gateway, which the shipped compose confines dynamic addresses to (`#972`); a proxy running in its own container reaches the relay through it. Caddy or Nginx installed directly on the host connect over loopback. > `remoteIpHeader` set without a matching `trustedProxies` entry is silently ignored. The relay logs `WARNING: network.remoteIpHeader is set but network.trustedProxies is empty` at startup — if you see that line, clients are not being identified individually. @@ -240,13 +243,15 @@ info: Anything you omit falls back to `resources/default-settings.yaml` in the image. -**A malformed `settings.yaml` fails silently.** If the file does not parse, the relay logs the error and starts with the image defaults — so your relay name, proxy trust and every other override quietly disappear while the process still comes up healthy. After editing, check the log for `error reading config file`: +**A malformed `settings.yaml` fails silently.** If the file does not parse, the relay logs the error and starts with the image defaults — so your relay name, proxy trust and every other override quietly disappear while the process still comes up healthy. + +Do not rely on a log grep to catch this: `docker compose logs` shows only the current container's output, so after any restart the line is gone and silence proves nothing. Verify the *effective* settings instead, which is what the relay actually loaded: ```bash -sudo docker compose -f /opt/nostream/docker-compose.yml logs nostream | grep -i 'error reading config' +curl -s -H 'Accept: application/nostr+json' http://127.0.0.1:8008/ | jq '{name, description}' ``` -No output means it parsed. Note that the relay rewrites this file from the admin console, which drops comments but keeps the values. +If `name` is `null` or shows the image default rather than the value you set, the file did not load. Note that the relay rewrites `settings.yaml` from the admin console, which drops comments but keeps values. --- From f71f20ad340a158d890add850f51c7bcad5c2797 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Wed, 28 Oct 2026 22:01:41 -0400 Subject: [PATCH 07/14] docs(setup): stop the metadata step from duplicating a YAML key, cover proxy CIDRs Two defects behind the repeated findings: - Step 6 said to 'add an info block', but deploy/settings.yaml.example already contains one. Following it produces a duplicated mapping key, js-yaml throws, and createSettings catches the error and falls back to image defaults -- so the operator loses every override, including the proxy trust the previous step just set. Now says to edit the existing block, and the reason is stated. - Proxy matching is exact (normalizeIpAddress strips ::ffff: and then compares with ===; there is no CIDR support), so a proxy container on any network other than the shipped one silently fails to match. Added how to read its address with docker inspect instead of guessing, and noted that a default Docker bridge is usually 172.17/172.18. Verified: every yaml block parses; example + network block combined parses with all 5 default entries intact; the inject template lists each network by name on a live container; 30 bash blocks parse; site builds 15 pages. --- docs/SETUP.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index d03808f5b..3c1179d9a 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -224,13 +224,21 @@ network: # Append your proxy's address if it is not one of the above. ``` -The list above is the image default. `10.10.10.1` is the Docker bridge gateway, which the shipped compose confines dynamic addresses to (`#972`); a proxy running in its own container reaches the relay through it. Caddy or Nginx installed directly on the host connect over loopback. +The list above is the image default. `10.10.10.1` is the gateway of the network the shipped compose defines; a proxy in its own container reaches the relay through it. Caddy or Nginx installed directly on the host connect over loopback. + +If your proxy runs in a container on a **different** network — a plain Docker default bridge is usually `172.17.0.1` or `172.18.0.1` — it will not match. Matching is exact, with no CIDR support, so read the proxy container's address rather than guessing: + +```bash +sudo docker inspect --format '{{range $net, $cfg := .NetworkSettings.Networks}}{{$net}}={{$cfg.IPAddress}} {{end}}' +``` + +Add that address to `trustedProxies`. > `remoteIpHeader` set without a matching `trustedProxies` entry is silently ignored. The relay logs `WARNING: network.remoteIpHeader is set but network.trustedProxies is empty` at startup — if you see that line, clients are not being identified individually. ### Relay metadata (optional) -To set your relay name, description, contact email or pubkey, add an `info` block to the same file: +To set your relay name, description, contact email or pubkey, **edit the `info` block that is already in the file** — do not add a second one. YAML rejects a duplicated key, and as above, a file that fails to parse is discarded entirely: ```yaml info: From 6c5cf97a09cf218dfb65762075a45cda0ae3d005 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Wed, 28 Oct 2026 22:06:39 -0400 Subject: [PATCH 08/14] docs(setup): correct which compose file defines the 10.10.10 network The proxy section said 10.10.10.1 was the gateway of the network the shipped compose defines. Only half true, and misleading in the way that matters: 10.10.10.0/24 with gateway 10.10.10.1 is pinned in the development docker-compose.yml. deploy/docker-compose.prod.yml -- the one this guide deploys -- has no networks section at all, so it uses the default bridge and publishes 127.0.0.1:8008. That changes the advice. A host-installed proxy arrives from loopback and needs no change; a proxied container arrives from its own address on the default bridge, which no shipped default covers. Replaced the guess with the two topologies spelled out and a docker inspect command that prints each network by name, plus a log check that the relay's view matches. Verified: the dev compose pins 10.10.10.0/24 gateway 10.10.10.1 at line 221; the prod compose has zero networks sections; both log formats ('rate limited'/'unauthorized request from') exist in src; the YAML fragment parses; 31 bash blocks parse; site builds 15 pages. --- docs/SETUP.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 3c1179d9a..de451f468 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -224,15 +224,31 @@ network: # Append your proxy's address if it is not one of the above. ``` -The list above is the image default. `10.10.10.1` is the gateway of the network the shipped compose defines; a proxy in its own container reaches the relay through it. Caddy or Nginx installed directly on the host connect over loopback. +The list above is the image default. Entries `10.10.10.x` come from the development `docker-compose.yml`, which pins its own subnet; the production compose uses the default bridge and publishes the relay on `127.0.0.1:8008`. -If your proxy runs in a container on a **different** network — a plain Docker default bridge is usually `172.17.0.1` or `172.18.0.1` — it will not match. Matching is exact, with no CIDR support, so read the proxy container's address rather than guessing: +Which address the relay sees depends on how your proxy reaches it, and matching is exact — no CIDR, no ranges — so work it out rather than guessing: + +- **Proxy installed on the host** (the Caddy and Nginx setups below) connects to `127.0.0.1:8008`. Traffic arrives from loopback, which is already in the list. No change needed. +- **Proxy in its own container** reaches the relay over the Docker network, so the source is the proxy container's own address. Read it, then add it: ```bash sudo docker inspect --format '{{range $net, $cfg := .NetworkSettings.Networks}}{{$net}}={{$cfg.IPAddress}} {{end}}' ``` -Add that address to `trustedProxies`. +```yaml + trustedProxies: + - "127.0.0.1" + # ... keep the other defaults ... + - "172.18.0.5" # <-- your proxy container +``` + +To confirm the relay agrees, look for a source address in its log after a client is rate-limited or rejected: + +```bash +sudo docker compose -f /opt/nostream/docker-compose.yml logs nostream | grep -oE '(rate limited|unauthorized request from) [0-9a-f.:]+' | sort -u +``` + +If every client shows the same address there, proxy trust is still not working. > `remoteIpHeader` set without a matching `trustedProxies` entry is silently ignored. The relay logs `WARNING: network.remoteIpHeader is set but network.trustedProxies is empty` at startup — if you see that line, clients are not being identified individually. From 2ae1d398f71a3260b863ba3af7df87b1c5ba2c55 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Wed, 28 Oct 2026 22:11:50 -0400 Subject: [PATCH 09/14] docs(setup): the client-address log line needs debug level The check printed nothing, for a reason worth stating: a bare logger() call goes through logAtLevel(instance, 'debug', ...) in logger-factory, and src/logger.ts defaults to level 'info'. So 'client connected' exists in every connection but is filtered out on a default deploy -- a verification step that silently proves nothing is worse than none. Now says to set LOG_LEVEL=debug (it reaches the container through the compose env_file), reconnect two clients, and compare; and to set it back afterwards because debug logs request headers for every connection. Verified: the debug default is the bare-call branch of createMessageLogger and LOG_LEVEL is read in src/logger.ts; util.format renders the message in the exact shape the grep expects; 31 bash blocks parse; site builds. --- docs/SETUP.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index de451f468..94d618c1e 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -242,13 +242,13 @@ sudo docker inspect --format '{{range $net, $cfg := .NetworkSe - "172.18.0.5" # <-- your proxy container ``` -To confirm the relay agrees, look for a source address in its log after a client is rate-limited or rejected: +The relay logs the address it resolved for each websocket client, but that line is written at `debug` level and the default is `info`, so it does not appear by default. Set `LOG_LEVEL=debug` in `/opt/nostream/.env`, restart, connect two different clients, then: ```bash -sudo docker compose -f /opt/nostream/docker-compose.yml logs nostream | grep -oE '(rate limited|unauthorized request from) [0-9a-f.:]+' | sort -u +sudo docker compose -f /opt/nostream/docker-compose.yml logs nostream | grep -oE 'client [0-9a-f.:]+ connected' | sort | uniq -c | sort -rn | head ``` -If every client shows the same address there, proxy trust is still not working. +If both clients show the **same** address, trust is not working — that is the collision this section prevents. If they show their own addresses, it is configured correctly. Turn the log level back to `info` afterwards; `debug` logs request headers for every connection. > `remoteIpHeader` set without a matching `trustedProxies` entry is silently ignored. The relay logs `WARNING: network.remoteIpHeader is set but network.trustedProxies is empty` at startup — if you see that line, clients are not being identified individually. From e2833b3299dea2b987ef468d174660461fbb29f5 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 31 Oct 2026 12:44:04 -0400 Subject: [PATCH 10/14] docs(setup): make the proxy trust instructions match what Docker forwards A published container port does not preserve loopback: a host-installed proxy reaches the relay as the compose network's gateway, not 127.0.0.1, so the default trustedProxies list does not cover it and every client shares one bucket. Point that case at the gateway with a command to read it, and stop calling the production stack the default bridge. The client-address grep also counted 'client connected from
' lines, whose first field is a hexadecimal id; match the ' connected:' suffix so only the address line is counted. The proxy check now asks for two different public IPs, because clients on one network legitimately share an address, and the untrusted-header warning is per connection rather than a startup line. --- docs/SETUP.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 94d618c1e..0b5904065 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -207,7 +207,7 @@ sudo chmod 600 /opt/nostream/.nostr/settings.yaml ### Proxy trust (required) -The relay binds to `127.0.0.1` and **cannot be exposed without a reverse proxy** (step 8), so this applies to every deployment. By default it trusts only loopback and leaves `network.remoteIpHeader` unset, so every request arrives looking like `127.0.0.1` and **all clients share one rate-limit bucket** — one busy client can rate-limit everyone else. +The relay binds to `127.0.0.1` and **cannot be exposed without a reverse proxy** (step 8), so this applies to every deployment. By default it trusts only loopback and leaves `network.remoteIpHeader` unset, so every request arrives carrying the proxy's address — or, for a published port, the Docker gateway that forwards it — and **all clients share one rate-limit bucket** — one busy client can rate-limit everyone else. `deploy/settings.yaml.example` has no `network` section, so add one. Copy the default `trustedProxies` list **in full** and append to it — arrays are replaced wholesale by the settings merge, not appended, so a shortened list deletes addresses the image ships: @@ -224,11 +224,14 @@ network: # Append your proxy's address if it is not one of the above. ``` -The list above is the image default. Entries `10.10.10.x` come from the development `docker-compose.yml`, which pins its own subnet; the production compose uses the default bridge and publishes the relay on `127.0.0.1:8008`. +The list above is the image default. Entries `10.10.10.x` come from the development `docker-compose.yml`, which pins its own subnet; the production compose publishes the relay on `127.0.0.1:8008` from its own bridge network (`nostream_default`), whose gateway no default entry covers. Which address the relay sees depends on how your proxy reaches it, and matching is exact — no CIDR, no ranges — so work it out rather than guessing: -- **Proxy installed on the host** (the Caddy and Nginx setups below) connects to `127.0.0.1:8008`. Traffic arrives from loopback, which is already in the list. No change needed. +- **Proxy installed on the host** (the Caddy and Nginx setups below) connects to `127.0.0.1:8008`, but the relay does not see loopback: Docker forwards the published port, so the source is typically the compose network's gateway address, which is not in the default list. The debug line below reports the address the relay actually resolved. Read it and add it: + ```bash + sudo docker network inspect nostream_default --format '{{(index .IPAM.Config 0).Gateway}}' + ``` - **Proxy in its own container** reaches the relay over the Docker network, so the source is the proxy container's own address. Read it, then add it: ```bash @@ -245,12 +248,12 @@ sudo docker inspect --format '{{range $net, $cfg := .NetworkSe The relay logs the address it resolved for each websocket client, but that line is written at `debug` level and the default is `info`, so it does not appear by default. Set `LOG_LEVEL=debug` in `/opt/nostream/.env`, restart, connect two different clients, then: ```bash -sudo docker compose -f /opt/nostream/docker-compose.yml logs nostream | grep -oE 'client [0-9a-f.:]+ connected' | sort | uniq -c | sort -rn | head +sudo docker compose -f /opt/nostream/docker-compose.yml logs nostream | grep -oE 'client [0-9a-f.:]+ connected:' | sort | uniq -c | sort -rn | head ``` -If both clients show the **same** address, trust is not working — that is the collision this section prevents. If they show their own addresses, it is configured correctly. Turn the log level back to `info` afterwards; `debug` logs request headers for every connection. +Use two clients on **different public IP addresses** for this check: clients on the same home network or VPN legitimately share one address, so two identical values prove nothing there. Compare each logged address with that client's expected public IP. If both match their own address, proxy trust is working; if both show the same address, trust is not working — that is the collision this section prevents. Turn the log level back to `info` afterwards; `debug` logs request headers for every connection. -> `remoteIpHeader` set without a matching `trustedProxies` entry is silently ignored. The relay logs `WARNING: network.remoteIpHeader is set but network.trustedProxies is empty` at startup — if you see that line, clients are not being identified individually. +> `remoteIpHeader` set without a matching `trustedProxies` entry is silently ignored. The next client connection logs `WARNING: network.remoteIpHeader is set but network.trustedProxies is empty` at `warn`, which is visible at the default level — if that line appears, clients are not being identified individually. It is written per connection, not once at startup, so look for it with `sudo docker compose logs nostream | grep remoteIpHeader` while a client is connected. ### Relay metadata (optional) From cdc70e909b98794e28f060425a942553193a35b9 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 31 Oct 2026 12:44:11 -0400 Subject: [PATCH 11/14] docs(setup): refresh release files on update and never leave a partial dump The update steps refreshed only the compose copy, so a release that changes postgresql.conf left the old file in /opt/nostream. Re-run bootstrap instead: it refreshes both release-managed files and keeps .env and .nostr/settings.yaml, which is what deploy/README.md already requires. The backup pipeline ran with pipefail, so a failed pg_dump did not report success, but it still wrote a fresh .sql.gz that looks like tonight's backup. Write to .part and rename only after the pipeline succeeds. The prerequisites now install git, which step 2 needs, and name Node.js as the one optional tool the npx nak check uses. --- docs/SETUP.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 0b5904065..71f01001f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -25,10 +25,11 @@ Before you begin, ensure you have: - Port `80` (HTTP, for ACME certificate issuance and redirect) - Port `443` (HTTPS / WSS, for secure client connections) - Port `22` (SSH, management) -4. **A few command-line tools** used later in this guide. `curl` and `openssl` generate your secrets and probe the relay; `jq` pretty-prints the JSON responses: +4. **A few command-line tools** used later in this guide. `git` fetches the deployment files; `curl` and `openssl` generate your secrets and probe the relay; `jq` pretty-prints the JSON responses: ```bash - sudo apt-get install -y curl openssl jq + sudo apt-get install -y git curl openssl jq ``` + Step 9's optional `npx nak` check needs Node.js 18 or newer (`sudo apt-get install -y nodejs npm`), installed only if you want that check; nothing else in this guide uses it. --- @@ -466,11 +467,12 @@ When a new nostream version is released: cd ~/nostream git pull ``` -2. Re-sync the release-managed compose file. `git pull` updates your clone, but the running stack reads `/opt/nostream/docker-compose.yml`, which is a copy taken at bootstrap time — it does not follow the repository on its own: +2. Refresh the release-managed files. `git pull` updates your clone, but the running stack reads the copies under `/opt/nostream` — `docker-compose.yml` and `postgresql.conf` — and they do not follow the repository on their own. Re-run bootstrap, which refreshes both and keeps your existing `.env` and `.nostr/settings.yaml`: ```bash - sudo cp ~/nostream/deploy/docker-compose.prod.yml /opt/nostream/docker-compose.yml + cd ~/nostream + sudo ./deploy/bootstrap.sh /opt/nostream ``` - Diff it first if you edited the copy by hand; this overwrites it. + Diff the copies first if you edited either by hand; bootstrap overwrites both. 3. Pull the updated Docker image: ```bash sudo docker pull ghcr.io/cameri/nostream:latest @@ -495,11 +497,15 @@ set -o pipefail # otherwise the pipeline exits with gzip's status and a failed envval() { sudo sed -n "s/^$1=//p" /opt/nostream/.env | tr -d "\"'"; } DB_USER="$(envval DB_USER)" DB_NAME="$(envval DB_NAME)" +dump="$HOME/nostream-$(date +%F).sql.gz" +# Write beside the final name and rename only once the whole pipeline succeeds: a failed +# pg_dump must not leave a fresh .sql.gz that looks like tonight's backup. sudo docker compose -f /opt/nostream/docker-compose.yml exec -T nostream-db \ - pg_dump -U "$DB_USER" "$DB_NAME" | gzip > ~/nostream-$(date +%F).sql.gz + pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$dump.part" \ + && mv "$dump.part" "$dump" || { rm -f "$dump.part"; false; } ``` -Check the dump opens and is not empty before trusting it — `pg_dump` writing into a pipe can fail while still leaving a valid gzip of nothing: +The rename only happens on success, so a failure leaves no `$dump` at all. Check the dump opens and is not empty before trusting it — `pg_dump` writing into a pipe can still produce a valid gzip of nothing: ```bash gzip -t ~/nostream-$(date +%F).sql.gz && [ "$(gunzip -c ~/nostream-$(date +%F).sql.gz | wc -c)" -gt 1000 ] && echo "archive OK" From f023328666d857389a9ddbcd68839f0f8162ec75 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 31 Oct 2026 12:44:38 -0400 Subject: [PATCH 12/14] docs(setup): point the TLS recipes at the proxy-trust step Both proxy recipes rely on step 6's network.remoteIpHeader and trustedProxies, and neither repeated them; say so where the recipes start, and note that bootstrap is idempotent so the refresh can run on every update. --- docs/SETUP.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 71f01001f..22ad7898b 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -323,6 +323,8 @@ The key is `redis`, not `cache`, and a healthy status is `ok`. When relay broadc The relay listens on `127.0.0.1:8008` and requires a reverse proxy to terminate TLS on port 443 and handle WebSocket upgrades. +Both options below depend on the proxy-trust settings from step 6. Without them the relay sees every client as the proxy — or, for a published port, the Docker gateway — and one busy client can rate-limit everyone else. + ### Option A: Caddy (Recommended) Caddy provides automatic HTTPS with Let's Encrypt certificates and native WebSocket proxying out of the box with zero boilerplate. @@ -467,7 +469,7 @@ When a new nostream version is released: cd ~/nostream git pull ``` -2. Refresh the release-managed files. `git pull` updates your clone, but the running stack reads the copies under `/opt/nostream` — `docker-compose.yml` and `postgresql.conf` — and they do not follow the repository on their own. Re-run bootstrap, which refreshes both and keeps your existing `.env` and `.nostr/settings.yaml`: +2. Refresh the release-managed files. `git pull` updates your clone, but the running stack reads the copies under `/opt/nostream` — `docker-compose.yml` and `postgresql.conf` — and they do not follow the repository on their own. When a release changes either file, re-run bootstrap, which refreshes both and keeps your existing `.env` and `.nostr/settings.yaml`; running it on every update is harmless and saves tracking which files changed: ```bash cd ~/nostream sudo ./deploy/bootstrap.sh /opt/nostream From 27d4ba1408b184b18e8667740a89ca29690c0685 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 31 Oct 2026 13:35:46 -0400 Subject: [PATCH 13/14] docs(setup): size the database for the host and restart it on config change Three review findings, all confirmed against the files the guide tells you to deploy: - postgresql.conf ships shared_buffers 4GB, maintenance_work_mem 1GB and effective_cache_size 12GB, while the guide recommends a 4 GB host. Those are startup allocations, so PostgreSQL alone would consume the machine. Step 3 now sizes them for the host and says what the shipped values assume. - A refreshed postgresql.conf never reaches a running server, and the update steps recreate only nostream and nostream-migrate. Added the restart and a SHOW shared_buffers check, with the note that compose mounts the file from /opt/nostream. - Renaming the README heading to "Production Setup Guide" left the table of contents pointing at #full-guide, a heading that no longer exists. --- README.md | 2 +- docs/SETUP.md | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5528ebb0f..921c3d8c6 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The project master repository is available on [GitHub](https://github.com/cameri - [Importing events from JSON Lines or JSON Arrays](#importing-events-from-json-lines-or-json-arrays) - [NIP-43 invite codes](#nip-43-invite-codes) - [Quick start (Standalone)](#quick-start-standalone) -- [Full guide](#full-guide) +- [Production Setup Guide](#production-setup-guide) - [Accepting payments](#accepting-payments) - [Running as a Service](#running-as-a-service) - [Troubleshooting](#troubleshooting) diff --git a/docs/SETUP.md b/docs/SETUP.md index 22ad7898b..a6cadebd3 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -15,7 +15,7 @@ Before you begin, ensure you have: 1. **A Linux host:** A VPS or dedicated server (Ubuntu 24.04 LTS or Debian 12 recommended) with at least: - 2 vCPUs - - 4 GB RAM + - 4 GB RAM — the database configuration this repository ships is tuned for a ~16 GB host, so on 4 or 8 GB you have to size it down in step 3 before starting the stack - 20+ GB SSD disk space (PostgreSQL event store grows over time) - A public IPv4 address (and optional IPv6) 2. **A domain name:** A domain or subdomain you control (e.g. `relay.example.com`). @@ -120,6 +120,25 @@ This creates the production directory structure: └── db-logs/ # PostgreSQL log directory ``` +### Size the database for your host + +The `postgresql.conf` that bootstrap installs is `postgresql.conf` from the repository root, tuned for a large host: `shared_buffers = 4GB`, `maintenance_work_mem = 1GB` and `effective_cache_size = 12GB`. Those are startup allocations, so on the 4 GB minimum host this guide recommends, PostgreSQL alone reserves the whole machine and the relay, Redis and the OS are left to the kernel's OOM killer. + +On anything smaller than about 16 GB, lower the three values before you start the stack. These are safe for the stated minimum: + +```bash +# 4 GB host. Use 2GB / 512MB / 6GB on an 8 GB host. +sudo sed -i \ + -e 's/^shared_buffers = .*/shared_buffers = 1GB/' \ + -e 's/^maintenance_work_mem = .*/maintenance_work_mem = 256MB/' \ + -e 's/^effective_cache_size = .*/effective_cache_size = 3GB/' \ + /opt/nostream/postgresql.conf +``` + +The rule of thumb behind those numbers: `shared_buffers` at 25% of RAM, `maintenance_work_mem` at RAM/16 up to 1 GB, and `effective_cache_size` at 50–75% of RAM. `postgresql.conf` is a release-managed file, so an update can overwrite your edits — the update section below says how to reapply them and how to make a running database pick them up. + +If you raise the host size instead, run `free -g` and compare: the shipped values want a 16 GB machine, not 8. + --- ## 4. Choose Your Relay Image @@ -483,6 +502,14 @@ When a new nostream version is released: ```bash sudo ./deploy/recreate-relay.sh /opt/nostream ``` +5. If the release changed `postgresql.conf` — or you re-applied your own sizing after step 2 overwrote it — restart the database. Step 4 recreates `nostream` and `nostream-migrate` only, and none of the settings in that file (`shared_buffers`, `maintenance_work_mem`, `max_connections`) apply to a running server: + ```bash + envval() { sudo sed -n "s/^$1=//p" /opt/nostream/.env | tr -d "\"'"; } + sudo docker compose -f /opt/nostream/docker-compose.yml restart nostream-db + sudo docker compose -f /opt/nostream/docker-compose.yml exec -T nostream-db \ + psql -U "$(envval DB_USER)" -d "$(envval DB_NAME)" -c 'SHOW shared_buffers;' + ``` + If the value does not match the file, the container is still reading a stale copy: check that `docker-compose.yml` mounts `/opt/nostream/postgresql.conf` and not a path inside the image. This runs database migrations, recreates the container, and verifies readiness. For zero-downtime rolling deploys with blue/green backends, consult the [`Deploy Runbook & HAProxy Guide`](../docs/DEPLOY-RUNBOOK.md). From f9a981a9397d51d38f214f94cc51bd284f1a3efa Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 31 Oct 2026 13:41:14 -0400 Subject: [PATCH 14/14] docs(setup): wait for PostgreSQL before reading its settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker compose restart` returns as soon as the container is recreated, so the SHOW shared_buffers check could run while the server was still starting, and the compose healthcheck allows a 360 s start period. Poll pg_isready first, bounded at two minutes, and point at the database log for the case that never becomes ready — a rejected postgresql.conf stops PostgreSQL from starting. --- docs/SETUP.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index a6cadebd3..5e1c221c8 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -505,11 +505,18 @@ When a new nostream version is released: 5. If the release changed `postgresql.conf` — or you re-applied your own sizing after step 2 overwrote it — restart the database. Step 4 recreates `nostream` and `nostream-migrate` only, and none of the settings in that file (`shared_buffers`, `maintenance_work_mem`, `max_connections`) apply to a running server: ```bash envval() { sudo sed -n "s/^$1=//p" /opt/nostream/.env | tr -d "\"'"; } - sudo docker compose -f /opt/nostream/docker-compose.yml restart nostream-db - sudo docker compose -f /opt/nostream/docker-compose.yml exec -T nostream-db \ + compose() { sudo docker compose -f /opt/nostream/docker-compose.yml "$@"; } + compose restart nostream-db + # `restart` returns before PostgreSQL accepts connections, and the healthcheck's + # 360 s start period means a slow start is normal, not a failure. + for _ in $(seq 1 60); do + compose exec -T nostream-db pg_isready -q -U "$(envval DB_USER)" 2>/dev/null && break + sleep 2 + done + compose exec -T nostream-db \ psql -U "$(envval DB_USER)" -d "$(envval DB_NAME)" -c 'SHOW shared_buffers;' ``` - If the value does not match the file, the container is still reading a stale copy: check that `docker-compose.yml` mounts `/opt/nostream/postgresql.conf` and not a path inside the image. + The loop gives up after two minutes rather than hanging forever. If `pg_isready` never succeeds, read `compose logs nostream-db` before anything else — a rejected `postgresql.conf` stops PostgreSQL from starting at all. If the value does not match the file once it does answer, the container is still reading a stale copy: check that `docker-compose.yml` mounts `/opt/nostream/postgresql.conf` and not a path inside the image. This runs database migrations, recreates the container, and verifies readiness. For zero-downtime rolling deploys with blue/green backends, consult the [`Deploy Runbook & HAProxy Guide`](../docs/DEPLOY-RUNBOOK.md).