Deploying Standard Red Notes
There are three ways to run Standard Red Notes. All three self-host to
themselves (the web app syncs to its own origin — never api.standardnotes.com)
and keep your notes end-to-end encrypted.
| Mode | Containers / services | Datastore | Best for |
|---|---|---|---|
| A. Full multi-container (default) | app, server (6 node services under supervisord), MariaDB, Redis, floci (SNS/SQS) | MySQL + Redis | Production, many users, horizontal scaling, realtime push |
| B. All-in-one single container | 1 container (home-server + nginx under supervisord) | embedded sqlite + in-memory cache | Local use, a household/small team, the simplest Docker deploy |
| C. LXC / systemd | native systemd service + nginx (no Docker) | embedded sqlite + in-memory cache | Proxmox / lxd system containers, bare VMs, Docker-averse hosts |
Modes B and C run the home-server: a single Node process that mounts auth, syncing, files, revisions and the api-gateway together, with in-process domain events (no SNS/SQS) — so they need no MySQL, no Redis, and no floci. The trade-off vs. Mode A: no horizontal scaling, and live realtime push (the websocket bridge) is disabled without Redis, so clients fall back to normal periodic sync. Everything else — accounts, notes, files, revisions, admin panel, AI proxy, OCR, CalDAV — works the same.
Providing a private REDIS_HOST/REDIS_PORT enables worker WebSocket sync for
Modes B and C without changing their SQLite/in-memory data path. This connector
supports only host and port, without Redis authentication or TLS, so it is
supported only when Redis stays on the same private trusted network. Their
durable sync adapter remains in-process. Only Mode A’s separate API gateway and
syncing server use the setup-generated SYNCING_SERVER_INTERNAL_GRPC_AUTH_SECRET
to authenticate durable gRPC command/status metadata.
A. Full multi-container
The production-grade MariaDB/Redis stack keeps its existing application
topology. This revision also mounts server-data at the gateway data directory
so administrator settings and encrypted ChatGPT/Codex pairings survive a server
container replacement. Back up that volume with its matching pairing encryption
key; see Backups and recovery.
cp .env.example .env # set real secrets for any non-local deploy
docker compose up -d
# open http://localhost:3001
The scripts/setup.sh and scripts/setup.ps1 helpers validate and reuse an
existing .env on every normal rerun without rotating configured secrets. An
older keyless environment receives a one-time assistant subscription encryption
key only after setup proves that no encrypted pairing file exists. Silently
rotating database, session, encryption, and WebSocket
credentials can disconnect an initialized MariaDB volume and invalidate live
sessions. Use --force-overwrite or -ForceOverwrite only for an intentional
rotation. If setup was overwritten accidentally, recover the complete prior
environment with npm run recover:database; do not delete the database volume.
When setup starts the stack (--up / -Up), it also refuses a dirty Git
checkout, stamps both app and server images with the exact checked-out commit,
and waits until the live same-origin app proves that both tiers are that same
release. The immutable root-owned marker is public at
/.well-known/srn-deployment.json; the server exposes its independently read
copy as deployment in /healthcheck/readiness. Missing, malformed, writable,
runtime-redirected, or mismatched markers are never reported as release
identity. Empty markers remain supported for anonymous local builds.
This provenance contract covers the core app/server images, the all-in-one image, and the LXC release. The optional MCP profile is a separate client/tool release and is intentionally outside the app/server equality gate.
See the top of docker-compose.yml and docs/self-hosting.md for the full env
reference, reverse-proxy (Traefik) examples, and optional profiles (mcp,
workflows).
B. All-in-one single container (new)
One image, zero external services. Ideal when you just want notes running on a laptop, NAS, or a small VPS.
# Optional: copy the env template and set anything you want to customize
cp .env.single.example .env
# Build + run
docker compose -f docker-compose.single.yml up -d --build
# open http://localhost:3001 (change with APP_PORT)
Secrets. With none supplied, the container generates strong per-instance
secrets on first boot and persists them to the single-data volume
(/data/secrets.env), so sessions and encrypted MFA stay valid across restarts.
For a backup/restore-friendly deploy, pin them in .env
(openssl rand -hex 32; ENCRYPTION_SERVER_KEY must be 64 hex chars). There is
no published-default-secret to worry about — nothing insecure ships enabled.
Persistence. The named volume single-data holds the sqlite database,
uploaded files, generated secrets, admin overrides and feature stores (all under
/data). Back it up to back up the whole instance.
Ports. Only 8080 (nginx) is published, mapped to APP_PORT (default 3001).
The home-server binds 127.0.0.1:3000 inside the container, so nginx is its
only network edge; nginx reverse-proxies /v1, /auth, /files, /sockets
to it same-origin.
Common env (all optional; see .env.single.example):
| Var | Purpose |
|---|---|
APP_PORT |
Host port (default 3001) |
PUBLIC_FILES_SERVER_URL |
Public /files URL behind a domain, e.g. https://notes.example.com/files |
PUBLIC_URL |
Canonical app origin used for external-link hostname isolation |
WORKFLOWS_ENABLED, WORKFLOWS_PUBLIC_URL |
Optional discovery link to a separately authenticated n8n origin |
COOKIE_DOMAIN, COOKIE_SECURE |
Set COOKIE_SECURE=true + your domain behind HTTPS |
SYNC_SERVER |
Force the app’s sync origin (default: its own origin) |
OCR_ENABLED |
Client-side PDF OCR toggle |
SHARED_SERVER_ACCESS_KEY |
Optional access gate (X-Shared-Server-Key) |
ASSISTANT_* |
AI assistant proxy (Anthropic / OpenAI-compatible / Ollama) |
After registering the intended administrator, persist the server-controlled role locally:
docker compose -f docker-compose.single.yml exec app srn-admin roles grant <user> ADMIN_USER
Manage:
docker compose -f docker-compose.single.yml logs -f
docker compose -f docker-compose.single.yml down # keep data
docker compose -f docker-compose.single.yml down -v # DELETE data volume
Behind a reverse proxy / HTTPS. Front the container with Caddy/nginx/Traefik
and set COOKIE_SECURE=true, COOKIE_DOMAIN=…,
PUBLIC_URL=https://notes.example.com, and
PUBLIC_FILES_SERVER_URL=https://notes.example.com/files. If n8n is enabled,
route its distinct TLS hostname directly to n8n; see
Workflows with n8n.
Forwarded client IP (TRUST_PROXY / CLIENT_IP_HEADER)
The server resolves each request’s real client IP in one canonical place and
uses it for rate limiting, the admin IP allow/block lists, and the IP recorded on
sessions / forwarded to the auth server (x-origin-ip). Getting this right matters
for security: if the app trusts forwarded headers it should not, a remote attacker
can spoof any IP (dodging rate limits/blocks and poisoning session audit records).
Security model — only trust forwarded headers when you are actually behind a proxy that sets them and strips inbound copies.
TRUST_PROXY— controls Express’strust proxy, i.e. which upstream hops may setX-Forwarded-For/X-Forwarded-Proto.req.ip(and therefore the resolved client IP) only reflectsX-Forwarded-Forfor hops you trust here. Accepted forms:- unset / empty → the safe default
loopback, linklocal, uniquelocal. This trusts a proxy on loopback or a private/Docker network but not arbitrary public clients — so direct access keeps working and a remote client cannot spoofX-Forwarded-For. true/false→ trust all hops / trust none. Usetrueonly when the proxy is the sole ingress (it appends the real client and clients cannot reach the app directly).- a number (e.g.
1) → trust exactly N proxy hops closest to the app. - a CSV of IPs/subnets and/or preset names (e.g.
127.0.0.1, 172.16.0.0/12, orloopback,linklocal,uniquelocal) → trust exactly those. Recommended when you know your proxy’s address.
- unset / empty → the safe default
CLIENT_IP_HEADER(optional, default empty = OFF) — when set (e.g.X-Real-IP, or Cloudflare’sCF-Connecting-IP), the client IP is taken from that single named header (leftmost value) and it takes precedence overreq.ip. When empty, behavior is exactly today’sreq.ip. ⚠️ This header is spoofable unless your deployment is genuinely behind a proxy that sets it AND strips any inbound copy the client sent. Do not enable it on a directly-reachable instance. It composes with (does not replace)TRUST_PROXY.
Default = can’t spoof. With neither variable set beyond the built-in default, a
direct client’s forged X-Forwarded-For / X-Real-IP is ignored and the resolved
IP is its real socket address — unchanged from prior behavior. Both settings are
boot-time only; the admin panel’s Server tab shows their current values
read-only (changing them requires editing the environment and redeploying).
Architecture
flowchart LR
Browser["Browser"]
subgraph Container["Single container"]
Nginx["nginx on port 8080<br/>SPA and same-origin proxy"]
Home["home-server on 127.0.0.1:3000<br/>auth, sync, files, revisions, API gateway"]
Cache["In-memory cache<br/>and in-process events"]
end
Data[("/data volume<br/>SQLite, uploads, and generated secrets")]
Browser --> Nginx
Nginx -->|"API and file requests"| Home
Nginx -->|"static web app"| Browser
Home --> Cache
Home --> Data
Why nginx + home-server (not home-server serving static itself): it reuses the
app image’s CSP inline-script self-heal (app/docker/docker-entrypoint.sh)
verbatim, so the served Content-Security-Policy hash always matches the served
inline bootstrap script across OCR/SYNC templating — the exact behavior the
multi-container app image already ships.
Note — sqlite migration compatibility shim. Several of the server’s sqlite migrations were authored MySQL-first (double-quoted SQL string literals), which the fork’s
better-sqlite312.x (SQLite with DQS off) rejects on first boot. Modes B and C therefore runserver/docker/single/fix-sqlite-migrations.jsat start/install: it rewrites those literals to single quotes in the compileddist/migrations/sqlite/*.jsonly (never repo source), is idempotent, and becomes a no-op once the migrations are corrected upstream. Mode A (MySQL) is unaffected — it never executes the sqlite migrations.
C. LXC / systemd (new)
Run natively (no Docker) inside a Debian/Ubuntu LXC system container or VM. Same single-process backend as Mode B, installed as a systemd service with nginx in front.
# inside a fresh Debian 12+/Ubuntu 22.04+ container, as root:
git clone https://github.com/<owner>/standard-red-notes.git /opt/standard-red-notes
cd /opt/standard-red-notes/deploy/lxc
REPO_URL=https://github.com/<owner>/standard-red-notes.git ./install.sh
Full copy-paste steps (Proxmox pct / incus container creation, upgrade,
backup, HTTPS) are in deploy/lxc/README.md. The installer is idempotent,
persists secrets under /var/lib/standard-red-notes, and installs
standard-red-notes.service (journalctl -u standard-red-notes -f). The Node
backend binds only 127.0.0.1:3000; nginx is the public listener.
Opt-in container restart (Redis / MariaDB)
The admin panel (Preferences → Admin → Server) can restart the sibling server
processes out of the box — they run under supervisord inside the server container,
so the gateway drives them with allow-listed supervisorctl calls (no extra
setup). The WebSocket gateway control lives here too; because the realtime
gateway runs in-process inside the API gateway, that button restarts the
api-gateway program under the hood (it will briefly drop your admin connection).
The Redis cache and MariaDB db containers, however, run outside that
supervisord. Restarting them requires talking to the Docker daemon, which the
server container deliberately cannot do (the raw docker socket is never mounted
into it). This capability is therefore OFF by default and gated behind an
opt-in, least-privilege docker-socket-proxy sidecar.
Security model
- The raw
/var/run/docker.sockis mounted only into thedocker-socket-proxycontainer (read-only), never into the server container. - The proxy denies everything by default; only
ALLOW_RESTARTS=1is enabled, so the sole reachable Docker operation is restart a container. No image pull, no container create/exec, no volume/network access. - The gateway restarts only an allowlist of container names (
cache,db); any other name is rejected before any HTTP call. - When the flag is off or the proxy is unreachable, the endpoint returns 503 and the UI shows the controls as unavailable — never an error.
Enable it
-
Start the proxy with the
opscompose profile (additive — the basedocker compose upis unchanged):docker compose --profile ops up -d docker-socket-proxy -
Turn the capability on for the server and point it at the proxy, then recreate the server so it picks up the env:
# in your .env (or the shell environment used for compose) SERVICE_CONTROL_DOCKER_ENABLED=true SERVICE_CONTROL_DOCKER_PROXY_URL=http://docker-socket-proxy:2375 docker compose --profile ops up -dContainer names default to
<project>-<service>-1(e.g.standard-red-notes-cache-1), matching the compose projectname:. Override the project prefix withSERVICE_CONTROL_DOCKER_PROJECT, or map names explicitly withSERVICE_CONTROL_DOCKER_CONTAINERS=cache=my-redis,db=my-mariadb.
Once enabled and reachable, an “Infrastructure containers” section appears under
Server health with a danger-confirmed Restart for Redis and MariaDB. Every
restart is admin-gated and audit-logged (admin.container-control). To turn the
feature back off, unset SERVICE_CONTROL_DOCKER_ENABLED and stop the proxy
(docker compose --profile ops stop docker-socket-proxy).
Verifying the CSP self-heal
All three modes serve the SPA with a Content-Security-Policy that pins the single
inline bootstrap <script> by its sha256. The served hash is recomputed from the
actual served script at start/install, so it always matches. To verify:
BASE=http://localhost:3001 # or your host
# 1. Extract the sha256 the served CSP pins:
curl -fsSI "$BASE/" | tr ';' '\n' | grep -o "sha256-[A-Za-z0-9+/=]*"
# 2. Hash the served inline script body and compare (Linux/macOS shell):
curl -fsS "$BASE/" \
| awk 'BEGIN{RS="</script>"} /<script>/{ sub(/.*<script[^>]*>/,""); print; exit }' \
| tr -d '\n' > /tmp/inline.js
printf 'sha256-%s\n' "$(openssl dgst -binary -sha256 /tmp/inline.js | openssl base64)"
The token from step 1 and step 2 must match. If runtime templating or hash
installation fails, Mode A stops before nginx, Mode B stops before supervisord
can launch nginx, and the Mode C installer stops before switching the live
release. None substitutes unsafe-inline to serve an unpinned app shell. The
container’s own build/curl verification is captured in the PR description.