Skip to content

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’s trust proxy, i.e. which upstream hops may set X-Forwarded-For / X-Forwarded-Proto. req.ip (and therefore the resolved client IP) only reflects X-Forwarded-For for 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 spoof X-Forwarded-For.
    • true / false → trust all hops / trust none. Use true only 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, or loopback, linklocal, uniquelocal) → trust exactly those. Recommended when you know your proxy’s address.
  • CLIENT_IP_HEADER (optional, default empty = OFF) — when set (e.g. X-Real-IP, or Cloudflare’s CF-Connecting-IP), the client IP is taken from that single named header (leftmost value) and it takes precedence over req.ip. When empty, behavior is exactly today’s req.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-sqlite3 12.x (SQLite with DQS off) rejects on first boot. Modes B and C therefore run server/docker/single/fix-sqlite-migrations.js at start/install: it rewrites those literals to single quotes in the compiled dist/migrations/sqlite/*.js only (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.sock is mounted only into the docker-socket-proxy container (read-only), never into the server container.
  • The proxy denies everything by default; only ALLOW_RESTARTS=1 is 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

  1. Start the proxy with the ops compose profile (additive — the base docker compose up is unchanged):

    docker compose --profile ops up -d docker-socket-proxy
    
  2. 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 -d
    

    Container names default to <project>-<service>-1 (e.g. standard-red-notes-cache-1), matching the compose project name:. Override the project prefix with SERVICE_CONTROL_DOCKER_PROJECT, or map names explicitly with SERVICE_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.

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.