Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/deploy-minimal-bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"nostream": patch
---

deploy: minimal server bootstrap with optional settings overrides

Add deploy/bootstrap.sh, document what operators must keep locally vs what
ships in the image, and stop seeding a full settings.yaml on first boot so
release defaults merge with optional overrides only.
152 changes: 78 additions & 74 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -1,112 +1,110 @@
# Production deployment

Minimal Docker Compose stack for running nostream in production. The relay
container uses a pre-built image from GHCR instead of building on the server.
uses a pre-built image from GHCR; migrations and default settings ship inside
that image.

This guide assumes a Linux host with Docker Engine and the Compose plugin
installed. Container images are published automatically after CI succeeds on pushes to
`main`. See [`docs/DEPLOYMENT.md`](../docs/DEPLOYMENT.md) for the CI/CD flow.

Migrations ship inside that image (`migrations/` and `knexfile.js`). The
`nostream-migrate` service is a one-shot container of the same image that
runs `knex migrate:latest` before the relay starts.
## What the server keeps locally

## Prerequisites
| Path | Required | Changes with releases? |
|------|----------|------------------------|
| `.env` | **Yes** | No — your secrets and tuning |
| `.nostr/data/` | Created at runtime | No — Postgres data |
| `docker-compose.yml` | Yes (via bootstrap) | **Yes** — re-run bootstrap or PR 2 auto-sync |
| `postgresql.conf` | Yes (via bootstrap) | Rarely |
| `.nostr/settings.yaml` | **Optional** | Your overrides only |

Before deploying the compose stack:
**Do not copy** onto the host: `migrations/`, `knexfile.js`, or a full
`settings.yaml` from older docs. Migrations run from the image; settings
defaults come from the image and merge with any optional overrides file.

1. Install [Docker Engine](https://docs.docker.com/engine/install/) and the
Compose plugin on the host.
2. Create a deploy directory (for example `/opt/nostream`).
3. Copy `deploy/docker-compose.prod.yml` to `docker-compose.yml` in that directory.
4. Copy `deploy/settings.yaml.example` to `.nostr/settings.yaml` and edit for
your relay.
5. Create `.env` from `deploy/env.example` with production secrets.
6. Copy `postgresql.conf` from the repository root into the deploy directory.
7. Load `ghcr.io/cameri/nostream:main` on the host (see
[Image delivery on restricted networks](#image-delivery-on-restricted-networks)
if `docker pull` fails).
## Quick start

Do not copy `migrations/` or `knexfile.js` onto the host. Compose does not
mount them; changing files on disk will not change what the migrate service
runs.
From a git checkout on the server (or after copying the `deploy/` folder):

## Server layout
```bash
chmod +x deploy/bootstrap.sh
./deploy/bootstrap.sh /opt/nostream
```

Edit `/opt/nostream/.env`, load `ghcr.io/cameri/nostream:main`, then:

```bash
cd /opt/nostream
docker compose up -d
```

Bootstrap copies release-managed files (`docker-compose.yml`, `postgresql.conf`)
from this repository. You only maintain `.env` and optional settings overrides.

## Prerequisites

1. [Docker Engine](https://docs.docker.com/engine/install/) and the Compose plugin
2. `ghcr.io/cameri/nostream:main` loaded on the host (see
[Image delivery](#image-delivery-on-restricted-networks) if `docker pull` fails)

## Server layout after bootstrap

```
/opt/nostream/
├── docker-compose.yml # copy from deploy/docker-compose.prod.yml
├── docker-compose.yml # from deploy/docker-compose.prod.yml
├── postgresql.conf # from repository root
├── .env # secrets (never commit)
├── .nostr/
│ ├── settings.yaml # copy from deploy/settings.yaml.example
│ └── data/ # Postgres data (created on first start)
└── postgresql.conf # from repository root
└── .nostr/
├── settings.yaml # optional overrides only
└── data/ # Postgres data (created on first start)
```

## Services

| Service | Image | Notes |
|-------------------|--------------------------------|------------------------------------------------------------|
| nostream | ghcr.io/cameri/nostream:main | `pull_policy: never` when the image is pre-loaded |
| nostream-db | postgres:15 | |
| nostream-cache | redis:7.0.5-alpine3.16 | |
| nostream-migrate | ghcr.io/cameri/nostream:main | one-shot `knex migrate:latest`; same image as the relay |

The relay listens on `127.0.0.1:8008` by default. Expose it publicly with a
reverse proxy or tunnel (for example Cloudflare Tunnel) in front of that address.
| Service | Image | Notes |
|-------------------|--------------------------------|---------------------------------------------------------|
| nostream | ghcr.io/cameri/nostream:main | `pull_policy: never` when the image is pre-loaded |
| nostream-db | postgres:15 | |
| nostream-cache | redis:7.0.5-alpine3.16 | |
| nostream-migrate | ghcr.io/cameri/nostream:main | one-shot `knex migrate:latest`; same image as the relay |

The relay service waits for `nostream-migrate` to exit 0
(`service_completed_successfully`) before it starts.
The relay listens on `127.0.0.1:8008`. Expose it with a reverse proxy or
tunnel (for example Cloudflare Tunnel).

## Deploy
The relay waits for `nostream-migrate` to exit 0 before it starts.

```bash
cd /opt/nostream
## Settings

mkdir -p .nostr/data .nostr/db-logs
chmod 755 .nostr
chown 1000:1000 .nostr/settings.yaml
chmod 600 .env .nostr/settings.yaml
Without `.nostr/settings.yaml`, the relay uses `resources/default-settings.yaml`
from the container image. When a release adds new settings keys, they appear
automatically from the image defaults.

docker pull postgres:15
docker pull redis:7.0.5-alpine3.16
To override specific values:

```bash
cp deploy/settings.yaml.example /opt/nostream/.nostr/settings.yaml
# edit overrides only — not a full copy of default-settings.yaml
chown 1000:1000 /opt/nostream/.nostr/settings.yaml
chmod 600 /opt/nostream/.nostr/settings.yaml
docker compose up -d
docker compose logs -f nostream-migrate
docker compose logs -f nostream
```

Or use the admin API/UI once `admin.enabled` is configured.

## Verify

```bash
docker compose ps
curl -s http://127.0.0.1:8008/
curl -s -H 'Accept: application/nostr+json' http://127.0.0.1:8008/
```

The second command should return NIP-11 relay metadata JSON.

## Image delivery on restricted networks

Some hosts cannot reach GHCR over IPv4. Workarounds:
Some hosts cannot reach GHCR over IPv4:

- **nostream image:** build or pull elsewhere, then `docker save` → transfer →
`docker load` on the server. Keep `pull_policy: never` on the nostream and
nostream-migrate services. One image is enough; migrations are already in it.
- **postgres / redis:** usually available from Docker Hub; if not, use the same
save/load approach.

## Settings file permissions

The nostream container runs as the `node` user (uid 1000). Ensure
`.nostr/settings.yaml` is owned by uid 1000 and readable by that user:

```bash
chown 1000:1000 .nostr/settings.yaml
chmod 600 .nostr/settings.yaml
```

Without this, the relay falls back to default settings from the image.
`docker load`. Keep `pull_policy: never` on nostream and nostream-migrate.
- **postgres / redis:** usually on Docker Hub; use save/load if needed.

## Updating

Expand All @@ -117,13 +115,19 @@ docker pull ghcr.io/cameri/nostream:main # or: docker load -i nostream-main.ta
docker compose up -d
```

`pull_policy: never` means Compose will not fetch a new digest by itself.
Load or pull the image first, then `up`. Compose recreates containers whose
image id changed, so `nostream-migrate` runs `migrate:latest` against the
schema baked into that image (no-op when already applied).

If migrate does not re-run after a load, recreate it explicitly:
If migrate does not re-run after a load:

```bash
docker compose up -d --force-recreate nostream-migrate nostream
```

When compose or `postgresql.conf` change in a release, re-run bootstrap against
the new checkout (or copy the updated files). Automated sync is planned separately.

## Refresh release-managed files

```bash
./deploy/bootstrap.sh /opt/nostream
```

Existing `.env` and `.nostr/settings.yaml` are preserved.
63 changes: 63 additions & 0 deletions deploy/bootstrap.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail

# Bootstrap a production nostream host from this repository's deploy/ directory.
#
# Usage:
# ./deploy/bootstrap.sh [/opt/nostream]
#
# Creates the server layout, copies release-managed files from deploy/, and
# prepares .env for secrets. settings.yaml is optional — the relay uses image
# defaults until you add overrides (admin UI/API or .nostr/settings.yaml).

TARGET="${1:-/opt/nostream}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"

require_file() {
if [[ ! -f "$1" ]]; then
echo "error: required file not found: $1" >&2
exit 1
fi
}

require_file "$SCRIPT_DIR/docker-compose.prod.yml"
require_file "$SCRIPT_DIR/env.example"
require_file "$REPO_ROOT/postgresql.conf"

mkdir -p "$TARGET/.nostr/data" "$TARGET/.nostr/db-logs"

install -m 644 "$SCRIPT_DIR/docker-compose.prod.yml" "$TARGET/docker-compose.yml"
install -m 644 "$REPO_ROOT/postgresql.conf" "$TARGET/postgresql.conf"

if [[ ! -f "$TARGET/.env" ]]; then
install -m 600 "$SCRIPT_DIR/env.example" "$TARGET/.env"
echo "Created $TARGET/.env — edit secrets before starting the stack."
else
echo "Keeping existing $TARGET/.env"
fi

if [[ ! -f "$TARGET/.nostr/settings.yaml" ]]; then
echo "No settings.yaml created — relay will use defaults from the container image."
echo "Add overrides later via the admin API or copy deploy/settings.yaml.example."
else
echo "Keeping existing $TARGET/.nostr/settings.yaml"
fi

chmod 755 "$TARGET/.nostr"

cat <<EOF

Bootstrap complete: $TARGET

Next steps:
1. Edit $TARGET/.env (SECRET, DB_PASSWORD, REDIS_PASSWORD)
2. Load ghcr.io/cameri/nostream:main on this host
3. cd $TARGET && docker compose up -d

Optional relay overrides:
cp $SCRIPT_DIR/settings.yaml.example $TARGET/.nostr/settings.yaml
chown 1000:1000 $TARGET/.nostr/settings.yaml
chmod 600 $TARGET/.nostr/settings.yaml

EOF
32 changes: 12 additions & 20 deletions deploy/settings.yaml.example
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
# Copy to .nostr/settings.yaml on the server and edit for your relay.
# Values here override resources/default-settings.yaml from the image.
# Optional relay overrides — copy to .nostr/settings.yaml only when needed.
# Omit this file entirely to run with resources/default-settings.yaml from the image.
# Values here override image defaults (deep merge). New release keys come from the image automatically.

info:
relay_url: wss://relay.tnsor.network
name: relay.tnsor.network
relay_url: wss://relay.example.com
name: relay.example.com
description: A Nostr relay powered by nostream.
pubkey: ""
contact: mailto:operator@tnsor.network
terms_of_service: https://relay.tnsor.network/terms
privacy_policy: https://relay.tnsor.network/privacy
contact: mailto:operator@example.com

payments:
enabled: false
# payments:
# enabled: false

nip45:
enabled: true
# nip66:
# enabled: false

nip66:
enabled: false

workers:
count: 2

admin:
enabled: false
# admin:
# enabled: false
39 changes: 22 additions & 17 deletions src/utils/settings-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ import yaml from 'js-yaml'
import { mergeDeepRight } from 'ramda'

import { Settings } from '../@types/settings'
import {
getConfigBaseDir,
getDefaultSettingsFilePath,
getSettingsAuditLogPath,
getSettingsBackupDir,
getSettingsFilePath,
} from './settings-paths'

export {
getConfigBaseDir,
getDefaultSettingsFilePath,
getSettingsAuditLogPath,
getSettingsBackupDir,
getSettingsFilePath,
} from './settings-paths'

export type ValidationIssue = {
path: string
Expand All @@ -20,16 +35,6 @@ type PathToken =
index: number
}

export const getConfigBaseDir = (): string => process.env.NOSTR_CONFIG_DIR ?? join(process.cwd(), '.nostr')

export const getSettingsFilePath = (): string => join(getConfigBaseDir(), 'settings.yaml')

export const getDefaultSettingsFilePath = (): string => join(process.cwd(), 'resources', 'default-settings.yaml')

export const getSettingsBackupDir = (): string => join(getConfigBaseDir(), 'backups')

export const getSettingsAuditLogPath = (): string => join(getConfigBaseDir(), 'settings-audit.jsonl')

export const toCategoryLabel = (key: string): string => {
return key
.split(/[_\-.]/)
Expand Down Expand Up @@ -282,16 +287,10 @@ const pathExistsInSchema = (schema: unknown, tokens: PathToken[]): boolean => {

export const ensureSettingsExists = (): void => {
const configDir = getConfigBaseDir()
const settingsPath = getSettingsFilePath()
const defaultsPath = getDefaultSettingsFilePath()

if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true })
}

if (!fs.existsSync(settingsPath)) {
fs.copyFileSync(defaultsPath, settingsPath)
}
}

export const loadDefaults = (): Settings => {
Expand All @@ -301,7 +300,13 @@ export const loadDefaults = (): Settings => {

export const loadUserSettings = (): Settings => {
ensureSettingsExists()
const raw = fs.readFileSync(getSettingsFilePath(), 'utf-8')
const settingsPath = getSettingsFilePath()

if (!fs.existsSync(settingsPath)) {
return {} as Settings
}

const raw = fs.readFileSync(settingsPath, 'utf-8')
return (yaml.load(raw) as Settings) ?? ({} as Settings)
}

Expand Down
Loading
Loading