Deploying Standard Red Notes
Automatic app-update checks use the authenticated API_RPC WebSocket lane when
it is ready. GET /v1/deployment/web reads the active frontend marker through
the stable web facade, so a frontend-only handover is detected even while the
backend remains on its previous build. The browser uses the public marker over
HTTP during startup, while signed out, or when that socket route is unavailable.
The server coalesces marker reads and caches successful results for five seconds.
WEB_APP_INTERNAL_ORIGIN optionally names that trusted facade origin. Compose
sets http://app:8080 automatically; the all-in-one image uses
http://127.0.0.1:8080, and the LXC installer uses its local nginx listen port.
These defaults require no additional deployment argument. Custom topologies may
override the origin; without it the server uses the configured PUBLIC_URL.
The origin must contain only HTTP(S), host and optional port, with no credentials,
path, query or fragment. Marker reads never follow redirects or forward session
credentials. An unavailable frontend marker is reported as unavailable, never
replaced by the backend’s independent build identity.
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 (5 services as 9 supervisord programs), 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. That
now includes realtime: the gateway attaches without REDIS_HOST and keeps
the push bridge, the collaboration plane and the sync lane’s ticket, lease and
socket-budget state in the one process that holds the sockets. Push, live
collaboration, presence, comments, realtime invites and push-approved
multi-factor prompts all work out of the box. The trade-off vs. Mode A is no
horizontal scaling, plus two properties of an in-process plane: the realtime
state is lost on restart (clients re-discover and re-sync; nothing durable is at
stake), and the invite-event stream is memory-resident, so a client offline
across a restart catches up over HTTP instead of over the socket.
REDIS_HOST/REDIS_PORT is for a MULTI-REPLICA gateway: several gateway
processes against one database have to agree on room epochs, editor leases,
tickets and socket budgets, and only Redis gives them that. Setting it on Modes
B and C is supported and changes nothing about 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, so they need no gRPC switch: only Mode A
binds the durable command port, with SERVICE_PROXY_TYPE=grpc plus the
setup-generated SYNCING_SERVER_INTERNAL_GRPC_AUTH_SECRET that authenticates
durable gRPC command/status metadata. Mode A leaves SERVICE_PROXY_TYPE empty by
default, so realtime item sync is opt-in there; every other realtime capability
is on out of the box.
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
Every other srn-admin command works the same way here. Two cannot complete
from the CLI on this topology, and say so rather than failing obscurely:
fix-quota and delete-user. Both work by publishing a domain event that
another service acts on, and this deployment delivers events by direct call
inside the one server process — which srn-admin is not. Run those two from the
web console (Settings → Admin, see Administration)
instead, where they execute inside that process. Everything that only touches
the database — users, roles, groups,
flags, bans, registration policy, webhooks, IP lists, locks, audit — runs from
the CLI.
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).
Browser caching across redeploys
Every release rebuilds the web app. The entry files keep their names
(index.html, app.js, app.css, service-worker.js,
manifest.webmanifest), so the nginx in all three modes serves them, and
everything else without a content hash in its name, with
Cache-Control: no-cache: the browser keeps its copy but asks the server
before using it again, and gets a short 304 Not Modified when nothing
changed. Lazy-loaded parts of the app are named <id>.<content hash>.app.js
(and .app.css), and the hashed web workers <content hash>.worker.js;
a name like that can never change its contents, so those are served
public, max-age=31536000, immutable. A script, stylesheet, source map or
.wasm file that does not exist is a 404, never the app page.
A tab that stays open across a redeploy keeps running the version it loaded. When it next needs a part of the app it has not loaded yet, that file no longer exists on the server, and the tab shows “This tab is running an older version of the app” with a Reload button. It never reloads by itself; if anything is not saved yet, the browser asks before leaving.
To check what your public origin sends (replace the host; the chunk name is
any *.app.js other than app.js from the browser’s network panel):
BASE=https://notes.example.com
curl -sI "$BASE/" | grep -i '^cache-control' # no-cache
curl -sI "$BASE/app.js" | grep -i '^cache-control' # no-cache
curl -sI "$BASE/1234.0123456789abcdef0123.app.js" -o /dev/null -w '%{http_code}\n' # 404 for a name that does not exist
If cache-control is missing or different on the public origin but correct on
the container’s own port, something in front of the app is rewriting it.
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.
Assistant internet search
Both root Compose deployments start a private assistant-search SearXNG service. It publishes no host port and shares a dedicated outbound-capable network only with the server (the app in single-container Compose). It is not a readiness dependency: search failures do not prevent app startup. Admin Assistant internet access and user tool permissions still control requests; disabling access leaves the idle sidecar running.
The bundled configuration retains upstream default engines and explicitly enables Bing. Engines can refuse or throttle requests; configuring them does not bypass their restrictions. See the official engine configuration, Docker installation, and JSON formats.
| Setting | Default / purpose |
|---|---|
SEARCH_PROVIDER |
searxng; also supports brave and serper |
SEARCH_SEARXNG_URL |
http://assistant-search:8080/search, used only for SearXNG |
SEARCH_API_URL |
Explicit endpoint override; otherwise Brave uses https://api.search.brave.com/res/v1/web/search, Serper uses https://google.serper.dev/search |
SEARCH_API_KEY |
External provider key, retained only on the server |
ASSISTANT_SEARCH_IMAGE |
Official multi-platform image pinned by digest in Compose; override to upgrade |
ASSISTANT_SEARCH_MEM_LIMIT |
512m |
WEB_SEARCH_TIMEOUT_MS / WEB_FETCH_TIMEOUT_MS |
12000 / 15000 |
WEB_SEARCH_MAX_BYTES / WEB_FETCH_MAX_BYTES |
2097152 / 5242880 |
WEB_FETCH_MAX_CHARS |
100000 extracted text bound |
For Brave, set SEARCH_PROVIDER=brave and SEARCH_API_KEY, leaving SEARCH_API_URL empty. For your own SearXNG instance, set SEARCH_API_URL to its JSON-enabled /search endpoint. External providers do not use the bundled endpoint; the sidecar still starts unless removed through your Compose override.
Normal and seamless Compose deployments include this unprofiled service. Standalone docker run, LXC, and source deployments do not create SearXNG automatically: configure a reachable provider there. Only the configured search origin may reach private addresses; search redirects are rejected and public-page fetch SSRF restrictions remain enforced. The sidecar runs unprivileged, generates a random secret at startup, and uses temporary cache storage.