Self-hosting Standard Red Notes
Run your own Standard Red Notes server with Docker Compose. It is fully featured
by default. One setup script generates a correct .env (with securely-generated
secrets), then a single docker compose up brings the whole stack online.
Standard Red Notes is licensed under AGPL-3.0; it is a self-hosted fork of Standard Notes. See the license file for details.
- Deploy in 5 minutes
- What the stack contains
- Prerequisites
- Configuration (the
.envfile) - Standalone home-server release
- The srn-admin binary
- Choosing a domain and ports
- Running behind a reverse proxy
- Start, stop, and upgrade
- Deploy script arguments
- Deploy a verified GHCR image pair
- Where your data lives
- Backup and restore
- Troubleshooting
Deploy in 5 minutes
A friendly walkthrough for a first-time, single-machine install. By the end you will have the web app open in your browser.
1. Get the code
git clone https://github.com/supermarsx/standard-red-notes.git standard-red-notes
cd standard-red-notes
2. Run the setup script
The script checks that Docker is installed, asks a few questions (you can press
Enter through them for a localhost install), generates all the secrets, and
writes a complete .env file.
macOS / Linux
./scripts/setup.sh
Windows (PowerShell)
./scripts/setup.ps1
Tip: add
--up(bash) or-Up(PowerShell) to build and start the stack automatically once the.envis written. Add--yes/-Yesto accept all defaults without prompts.
3. Start the stack (if you didn’t use --up)
docker compose up -d --build
The first run downloads images and builds the app and server, which can take a
few minutes. Watch progress with docker compose logs -f.
For the bundled Compose app image, repeat builds reuse compiled libraries when
only app/packages/web changes. Library sources and shared build configuration
have their own Docker layer, separate from the final web compilation. The first
build after this layer change still compiles everything; subsequent UI-only
builds on the same Docker builder should show the library build as CACHED.
Changing a library, shared configuration, dependency manifest or lockfile
rebuilds the affected layers. Pruning the builder cache or passing --no-cache
also removes this benefit. The web bundle, sync worker and deployment identity
still build together from current sources, without a shared webpack cache.
This optimization does not change backend startup or readiness wait times.
Build concurrency is bounded by default: two library builds at a time, 32
webpack module jobs, and two JavaScript minifier workers per compilation.
These settings reduce simultaneous compiler memory use; they are not a total
RAM limit. Worker subcompilations and concurrent server builds also use memory.
On a smaller host, add these build settings to your Compose .env:
SRN_BUILD_JOBS=1
WEBPACK_PARALLELISM=16
WEBPACK_MINIFY_WORKERS=1
The defaults are 2, 32, and 2, respectively. SRN_BUILD_JOBS accepts
integers from 1 to 64; WEBPACK_PARALLELISM accepts 1 to 1000;
WEBPACK_MINIFY_WORKERS accepts 0 to 64. Set minifier workers to 0 to minify
serially inside the compiler process without spawning minifier workers.
Lower concurrency may lengthen builds; increase it only when the host has enough
free memory. These are build-time settings, so rebuild
the app to apply them. For direct docker build, pass the same names using
--build-arg. Existing compiled library layers remain reusable unless their
inputs or library job setting change. The web bundle still builds cold.
For a local build outside Docker, set WEBPACK_PARALLELISM and
WEBPACK_MINIFY_WORKERS in the build process environment before running
yarn workspace @standardnotes/web build from app. SRN_BUILD_JOBS controls
the Docker library build; for local library builds use Yarn’s --jobs option.
4. Open the app
Go to http://localhost:3001 (or the app port / domain you chose). Create an account and you’re in. The fork is fully featured by default, without subscription provisioning. Optional integrations still require operator configuration, and administrative capabilities require an administrator role.
That’s it. To stop the stack later: docker compose down.
What the stack contains
docker-compose.yml uses two private bridge networks. Core services use
standard-red-notes. The arbitrary-code n8n trust domain uses only
workflows-mcp, with the authenticated MCP service dual-homed so it can reach
the core API without giving n8n a direct service-network route or Compose DNS
address for the server, database, cache, or event emulator. Publicly exposed
endpoints remain reachable like they are to any external client:
| Service | Image | Purpose |
|---|---|---|
app |
built from ./app |
The web client (nginx serving the built web app). Published on APP_PORT (default 3001). |
server |
built from ./server |
The all-in-one Standard Notes server: api-gateway, auth, syncing-server, files, and revisions run together under supervisord (MODE=self-hosted). The realtime websocket gateway runs IN-PROCESS inside the api-gateway on the SAME port (no separate process). Internal-only — publishes NO host ports; the app front door proxies the API + websocket (container port 3000) and files (container port 3104) same-origin. |
db |
mariadb:12.3.2 |
Primary datastore for accounts, notes, sync, and revisions. |
cache |
redis:8.8.0-alpine |
Cache, sessions, realtime pub/sub, and the encrypted email-delivery queue. Persists with AOF; email enqueue additionally requires a local WAITAOF acknowledgement. |
floci |
floci/floci:1.5.33-compat |
Local AWS SNS/SQS emulator (floci.io, MIT). The server publishes domain events to SNS topics; the in-container websocket-gateway and server workers consume SQS queues. Bootstrapped on every start (see below). Replaces LocalStack: no auth token required (LocalStack 2026.3.0+ demands one even for SNS/SQS, which is why we last pinned localstack:4.4.0), and it is far lighter (single native binary vs a Python runtime). It is LocalStack wire-compatible; the current LocalStack escape hatch and required auth-token migration are documented in docker-compose.yml. |
mcp |
built from ./mcp |
Optional authenticated MCP bridge. It is the only service on both standard-red-notes and workflows-mcp: the first reaches the API and the second accepts n8n calls at mcp:3010. Only runs with the mcp profile. |
n8n |
n8nio/n8n:2.32.6 |
Optional operator-managed automation service under the workflows profile. It joins only workflows-mcp, has independent authentication and a loopback-only development port; production uses a separate TLS hostname and proxy network. |
The SNS/SQS bootstrap
On every start, floci runs
server/docker/localstack_bootstrap.sh (mounted into its LocalStack-compatible
init/ready.d directory — the script name is historical). That script creates
the SNS topics and SQS queues and wires up the subscriptions the server relies
on—including the websocket-local-queue that the realtime gateway consumes.
Credential-bearing Nextcloud backup requests use
nextcloud-backup-local-topic, which has exactly one subscription to
syncing-server-local-queue; it is deliberately absent from the auth, files,
and websocket queues. The server entrypoint supplies the matching
AUTH_SERVER_NEXTCLOUD_BACKUP_SNS_TOPIC_ARN automatically. External AWS
deployments must provision the same isolated topology before enabling scheduled
Nextcloud backups; see Backups and recovery.
floci’s queue state is in-memory, so the bootstrap re-runs on each container
start (all its calls are idempotent) — there is no emulator data volume to
manage. See Troubleshooting if realtime updates aren’t
flowing.
The bootstrap talks to the emulator with plain curl and finishes in about a
second. floci stops the emulator when a ready hook outlives
FLOCI_INIT_HOOKS_TIMEOUT_SECONDS (floci’s default is 30 seconds; compose sets
180), and a restarted emulator starts empty and runs the hook again. An older
bootstrap started the Python AWS CLI once per resource and could overrun 30
seconds on a busy host, so the emulator restarted in a loop and no event
reached any service. The same script with --check is floci’s health check: it
creates nothing and passes only when every topic, queue, subscription and the
websocket redrive policy exists, so floci is healthy only when events can be
delivered and server waits for that. The server’s queue consumers keep
polling through an emulator restart, logging an error for each failed poll,
and resume on their own once the bootstrap has recreated their queues; events
published while the emulator was down are lost.
Prerequisites
- Docker with the Compose v2 plugin (
docker compose, not the legacydocker-compose, though the scripts fall back to it if present).- macOS / Windows: Docker Desktop
- Linux: Docker Engine + compose plugin
- git to clone the repository.
- The setup scripts need a secure random source. On macOS/Linux that’s
openssl(or/dev/urandomviaxxd/od), already present on virtually all systems. On Windows the PowerShell script uses the .NET cryptographic RNG, so no extra tooling is required.
You do not need Node.js, Yarn, or a database installed on the host - the containers provide all of that.
Configuration (the .env file)
Everything is driven by a single .env file in the repo root. The setup scripts
generate it for you; .env.example documents every key with placeholder values.
The real .env is git-ignored and must never be committed - it holds your
secrets.
The multi-container Compose file fails closed when either MariaDB password is
missing or empty. Before the database can start, a networkless one-shot check
also rejects the published example placeholders and reuse of the same value for
the application and root accounts. It reports variable names only, never secret
values. Run one of the setup scripts to generate independent random values. The
SQLite-based docker-compose.single.yml topology does not use MariaDB and is
therefore intentionally outside this gate; isolated server CI/test Compose files
retain their explicit test-only credentials.
Required variables
These must be present and non-empty or the stack will not start. The secrets are 64-character hex strings (32 random bytes); the encryption key in particular must be exactly 32 bytes of hex (the auth service validates this and refuses to boot otherwise).
| Variable | Purpose | How it’s generated |
|---|---|---|
AUTH_JWT_SECRET |
Signs/verifies cross-service JWTs across the server and the websocket-gateway. | openssl rand -hex 32 / .NET RNG |
AUTH_SERVER_ENCRYPTION_SERVER_KEY |
Server-side encryption key for sensitive auth data (for example MFA secrets). Purpose-specific keys derived from it also protect email relay credentials and queued message payloads. Must be exactly 32 bytes of hex. | openssl rand -hex 32 / .NET RNG |
VALET_TOKEN_SECRET |
Signs the short-lived valet tokens that authorize file uploads/downloads. | openssl rand -hex 32 / .NET RNG |
AUTH_SERVER_PSEUDO_KEY_PARAMS_KEY |
Seed for pseudo key-params returned on login for unknown accounts (prevents user enumeration). The container auto-generates one if unset, but it would then change on every restart - so it is pinned in .env. |
openssl rand -hex 32 / .NET RNG |
WEBSOCKET_GATEWAY_INTERNAL_SECRET |
Loopback-only credential for minting a legacy connection token. Treat it as one of the most sensitive values here: whoever holds it can open a realtime socket as any user. The front doors now blank the header on /sockets, and the gateway refuses an internal mint that arrives proxied or from a non-loopback peer, but never expose it to a client or a browser. |
openssl rand -hex 32 / .NET RNG |
WEB_SOCKET_CONNECTION_TOKEN_SECRET |
Signs the short-lived tokens browsers use to open a realtime websocket connection. | openssl rand -hex 32 / .NET RNG |
ASSISTANT_SUBSCRIPTION_ENCRYPTION_KEY |
Encrypts optional guided ChatGPT/Codex pairing credentials in the persistent gateway store. Supported setup and LXC installers generate it once and preserve it automatically; never rotate it while pairing data exists. | Automatic installer secret; manual RNG only for custom deployments |
MYSQL_PASSWORD |
Password for the application database user. | openssl rand -hex 32 / .NET RNG |
MYSQL_ROOT_PASSWORD |
MariaDB root password. | openssl rand -hex 32 / .NET RNG |
MYSQL_DATABASE |
Database name. | Your choice (default standard_notes_db) |
MYSQL_USER |
Application database user. | Your choice (default std_notes_user) |
DB_CONNECTION_LIMIT |
Per-process TypeORM connection pool ceiling. | 20 |
DB_MAX_CONNECTIONS |
MariaDB server connection ceiling. Keep above total service pools. | 150 |
DB_INNODB_BUFFER_POOL_SIZE |
MariaDB InnoDB cache size. Tune with DB_MEM_LIMIT. |
512M |
DB_MAX_ALLOWED_PACKET |
Maximum MariaDB packet for large encrypted payloads. | 128M |
APP_PORT |
The public app port. The nginx front door serves the web UI and proxies the API, files, and websocket same-origin. The optional workflows profile has a separate loopback-only development port. | Your choice (default 3001) |
PUBLIC_FILES_SERVER_URL |
Public URL clients use to reach the files service. It is the app origin + /files (the front door’s prefix-strip proxy). |
Computed by the script |
PUBLIC_URL |
Canonical app origin used to isolate external integration hostnames. | Computed from the app origin |
AUTH_SERVER_U2F_RELYING_PARTY_ID |
WebAuthn/hardware-key relying-party ID (your host). | Computed (host of your domain, or localhost) |
AUTH_SERVER_U2F_EXPECTED_ORIGIN |
Allowed WebAuthn origins. | Computed from your domain + app port |
Cookie / domain variables
| Variable | Purpose | Default |
|---|---|---|
COOKIE_DOMAIN |
Domain scope for the auth session cookie. Empty = host-only (works on localhost and bare hosts/IPs). Set to your domain for an HTTPS deployment. | empty |
COOKIE_SECURE |
Send the auth cookie only over HTTPS. Set true for real HTTPS deployments; false for plain-http localhost. |
false |
Optional variables
Left commented in .env; docker-compose.yml applies sensible defaults when
they’re unset. These include logging (LOG_LEVEL), the shared persisted admin
overlay (SERVER_SETTINGS_PATH, normally leave it inside the mounted
server-data volume), cookie tuning
(COOKIE_SAME_SITE, COOKIE_PARTITIONED), feature/entitlement mode
(STANDARD_RED_FEATURES_MODE, STANDARD_RED_ENTITLEMENT_MODE, defaulting to
fully-included), revision retention and caps
(REVISIONS_RETENTION_DAYS, REVISIONS_MAX_COUNT_PER_ITEM, default 900 days and
200 per note; REVISIONS_MAX_COUNT_PER_USER, REVISIONS_MAX_COUNT_TOTAL,
REVISIONS_MAX_BYTES_PER_USER, REVISIONS_MAX_BYTES_TOTAL), the optional Assistant/LLM proxy
(ASSISTANT_*, except the setup-generated subscription encryption key), operation limits (RATE_LIMIT_*, REGISTRATION_*, upload and
request caps), and the optional MCP bridge (STANDARD_RED_NOTES_*). See
.env.example for the full list and Operations hardening
for the database, Redis, operation-limit, and image-pinning model.
WebSocket traffic and Admin Abuse. Admin → Abuse exposes six persisted WebSocket traffic settings. A saved override applies to existing and new sync sockets within five seconds; resetting a field restores its environment baseline. The high-flow defaults are recommended for ordinary self-hosted use:
| Environment baseline | Default | Meaning, per sync socket |
|---|---|---|
WEBSOCKET_SYNC_INGRESS_FRAME_BURST |
1024 | JSON frames in one burst |
WEBSOCKET_SYNC_INGRESS_FRAMES_PER_SECOND |
256 | Sustained JSON frames/second |
WEBSOCKET_SYNC_INGRESS_BYTE_BURST |
33554432 | 32 MiB raw JSON burst |
WEBSOCKET_SYNC_INGRESS_BYTES_PER_SECOND |
8388608 | 8 MiB/second sustained JSON |
WEBSOCKET_SYNC_QUEUE_FRAMES |
64 | Unprocessed frames retained per socket |
WEBSOCKET_SYNC_QUEUE_BYTES |
16777216 | 16 MiB unprocessed raw JSON retained |
All JSON lanes share that socket budget: note commands, API calls, assistant stream credit/control frames, invitations and keepalives. They are not six separate allowances and are not account-wide bans. Other devices have their own socket budgets. Assistant response credits are coalesced over 250 ms, or at a 64 KiB/half-window threshold, so each small response chunk does not create a separate upstream frame. Slow consumers still receive only bounded byte credit.
The 512 KiB individual JSON-frame cap and authenticated operation/session checks remain. FILES_V1 binary uploads retain their separate 64 MiB burst, 16 MiB/second and eight-frame queue allowance; authentication HTTP limits and per-account socket counts are unchanged. Queue settings bound raw retained ingress bytes, not total process RAM: parsed objects, active responses and other sockets add memory. Reduce queue bytes on memory-constrained hosts rather than disabling limits. Bounds remain enforced even for admin-set values.
Admin Abuse records actual WebSocket rate-limit and backpressure refusals in its throttle counters and recent-event ring, separately from blocked IPs or banned accounts. Each closed socket contributes at most one pressure event. The ring holds at most 200 newest events, and counters/ring expire after 24 hours without throttling. Client diagnostics retain page-lifetime incident history but raise repeated-fallback findings only for events within the last five minutes; old recovered incidents do not mean that the lane is still failing.
Assistant chat run coordination. Signed-in chat clients coordinate active runs through account-scoped Redis leases: a 30-second lease renewed every 10 seconds. Redis failure refuses a coordinated run instead of silently allowing another device to start it. No extra setting is required. In server proxy mode, requests carrying a chat lease also reserve one active generation; even the same owner cannot send two overlapping generations. The server keeps that reservation while the upstream runs, aborts on lost ownership, and bounds each protected generation to five minutes, including an upstream iterator that stops responding. Legacy requests without a chat proof keep their existing behavior. Direct connections rely on participating clients for coordination; the server cannot enforce or cancel a request sent directly to another provider.
Assistant SSE responses send a comment keepalive every 15 seconds during silent provider work. This prevents idle proxy timeouts; it does not extend the total request budget. The assistant streaming socket route permits 330 seconds (the five-minute protected generation limit plus cleanup margin); other RPC routes retain their 120-second maximum.
Abandoned chat fence counters expire after 90 days; opaque lease identities still reject stale renewals and releases after that counter retention resets. Stopping a server-proxied run signals cancellation to its provider, but does not promise that an external provider will stop work or reverse billing immediately.
Email delivery topology and reliability.
Nothing is configured out of the box, and until it is, no notification leaves
the server. SMTP_HOST and SMTP_FROM are both empty by default in
.env.example and .env.single.example, and with them empty every
user-facing message is dropped: the registration confirmation link and its
resend, the sign-in notice, the magic-link verification code, the e-mail-changed
confirmation, the account-approval notice, shared-vault and shared-subscription
invitations, e-mail backups, and e-mail reminders. To turn delivery on, set
SMTP_HOST, SMTP_PORT, SMTP_FROM and (if the relay authenticates)
SMTP_USER + SMTP_PASS; SMTP_SECURE=true selects implicit TLS, otherwise
STARTTLS is required unless SMTP_ALLOW_INSECURE=true, which is accepted only
for a loopback, RFC1918 or *.localhost relay host. Verify with
POST /v1/admin/email-delivery/test from the admin panel: it reports success
only when the relay accepted the message. On the multi-service topology a
notification produced while no relay is enabled is retained by the event
transport and delivered once one is; on the single-container topology there is
no e-mail queue, so an undelivered notification is lost — the per-message
SmtpEmailSender warning in the log is the only record.
The full multi-service Compose topology uses Redis for a bounded encrypted email
queue and supports up to 20 prioritized SMTP, SendGrid, Mailgun, and AWS SES
relay profiles. The queue worker performs retry, fallback, per-relay rate limiting,
dead-letter handling, and redacted attempt logging. The single/home in-memory
topology retains direct SMTP compatibility and returns 501 from the advanced
relay, queue, and log endpoints; its compatible test action remains available.
Redis Cluster deliberately uses the same SMTP fallback because its node-local AOF
durability cannot be established with WAITAOF.
The supplied Redis service uses AOF with CACHE_APPENDFSYNC=everysec. A producer
reports a new job as accepted only after Redis confirms a local AOF fsync through
WAITAOF; a retry with the same deterministic delivery identifier is
idempotent. This protects queue acceptance, not exactly-once provider delivery.
An accepted provider request followed by a lost response can still be delivered
again.
Queued published reminders are distinguishable from the auth service’s own email
reminders. On restart with REMINDER_DELIVERY_ENABLED=false, the worker settles
only published-reminder jobs before the provider boundary; account and auth
email-reminder delivery remain governed by their own switches.
Mutable queue limits do not select a new Redis namespace. Existing jobs retain their enqueue-time retry and expiry values and remain visible after a limit change. Producer readiness is still bound to the exact current policy, so a rolling deployment safely pauses new acceptance until auth and gateway agree.
| Variable | Default | Purpose |
|---|---|---|
CACHE_APPENDFSYNC |
everysec |
Redis background AOF policy; keep AOF enabled. |
EMAIL_QUEUE_MAX_JOB_BYTES |
26214400 |
Maximum encrypted payload size for one job (25 MiB). |
EMAIL_QUEUE_MAX_TOTAL_BYTES |
67108864 |
Total encrypted email-queue budget (64 MiB). |
EMAIL_QUEUE_MAX_ATTEMPTS |
5 |
Maximum delivery attempts before dead-lettering. |
EMAIL_QUEUE_RETENTION_MS |
2592000000 |
Ready/leased queue retention (30 days). |
EMAIL_QUEUE_DEAD_RETENTION_MS |
2592000000 |
Dead-letter retention (30 days). |
EMAIL_QUEUE_LEASE_MS |
120000 |
Worker lease, renewed by a heartbeat during provider work. |
EMAIL_DELIVERY_WORKER_INTERVAL_MS |
5000 |
Queue polling interval. |
EMAIL_DELIVERY_WORKER_BATCH_SIZE |
25 |
Maximum jobs processed per worker tick. |
EMAIL_DELIVERY_RETRY_BASE_MS |
30000 |
Initial retry delay. |
EMAIL_DELIVERY_RETRY_MAX_MS |
21600000 |
Maximum retry delay (6 hours). |
EMAIL_DELIVERY_LOG_RETENTION_MS |
2592000000 |
Redacted attempt-log retention (30 days). |
EMAIL_DELIVERY_LOG_MAX_ENTRIES |
10000 |
Maximum retained attempt-log records. |
EMAIL_ATTACHMENT_MAX_BYTE_SIZE is validated against
EMAIL_QUEUE_MAX_JOB_BYTES on the durable topology. Queue storage includes two
base64 expansions plus JSON/envelope overhead, so the raw attachment limit must
be materially smaller than the encrypted job limit; an unsafe configured pair
fails startup instead of producing deterministic backup-delivery failures.
The worker refuses readiness when Redis reports maxmemory at or below the
configured email-queue budget plus 64 MiB of safety headroom. Managed Redis may
deny CONFIG GET; in that case the worker continues with the strict queue byte
budget and emits a redacted warning. Do not rotate
AUTH_SERVER_ENCRYPTION_SERVER_KEY while relay settings or queued jobs must
remain readable: drain the queue and verify protected backups first. See
Administration for relay and queue operation.
Server-wide shared access key (optional obfuscation gate)
Two variables control it, both OFF by default (leaving them unset means zero behavior change for existing installs):
| Variable | Description | Default |
|---|---|---|
SHARED_SERVER_ACCESS_KEY |
The shared secret. Empty/unset disables the gate entirely. | empty (off) |
SHARED_SERVER_ACCESS_KEY_MODE |
all = every request must present the key (the /healthcheck path is always exempt so the container stays healthy); registration = only new account sign-ups require the key, leaving existing users (sync, sign-in) unaffected. |
all (only relevant once a key is set) |
When enabled, the api-gateway requires the key in the X-Shared-Server-Key
header and rejects non-matching requests with a generic 401. The comparison is
constant-time and the key is never logged.
On each client, enter the same key under Preferences -> Security -> Server Access Key. It is stored locally on that device (never synced) and attached to outgoing requests automatically. Because sign-in and registration also pass through the gate, configure the key on a device before signing in to a gated server.
Standalone home-server release
The published srn-home-server binary is an advanced, backend-only deployment
option for an existing MySQL and Redis environment. It does not include the web
app, database, cache, or reverse proxy. Use the
Docker quickstart when you need the complete stack.
For a standalone install:
- Download the executable for your platform,
srn-home-server-migrations.zip, andSHA256SUMS.txtfrom the current published release, then verify both downloads against the checksums. - Put the executable in a dedicated directory and create
.envthere. Start from the checked-in home-server environment sample, use the configuration reference for secrets, cookies, limits, and optional integrations, and add the direct service values required by the binary:DB_TYPE=mysql,DB_HOST,DB_PORT,DB_USERNAME,DB_PASSWORD,DB_DATABASE,CACHE_TYPE=redis,REDIS_URL,REDIS_HOST,REDIS_PORT,PORT,FILE_UPLOAD_PATH, andFILES_SERVER_URL. - Extract
srn-home-server-migrations.zipin that directory. Before first start, confirm thatmigrations/mysql/*.jsis next to the executable. The binary finds that directory automatically; setSRN_MIGRATIONS_DIRto its absolute path only when you store it elsewhere. Database migrations run at startup, so take a database backup before upgrading. -
Start the binary from the directory containing
.env:chmod +x srn-home-server-linux-x64 ./srn-home-server-linux-x64On Windows PowerShell, run
./srn-home-server-windows-x64.exeinstead.
After it starts, follow Choosing a domain and ports and Running behind a reverse proxy before exposing it publicly. The binary is only the backend, so point a separately deployed Standard Red Notes client at that public server URL.
The srn-admin binary
srn-admin is the administrative CLI for a Standard Red Notes deployment. Like
the tools above it is published as native binaries (Windows, macOS, and Linux)
with a SHA256SUMS.txt for verification. Unlike srn-home-server, it is not
a standalone offline tool: every command runs against a running SRN stack and
its database, so a reachable server and database must already be up.
In the Docker stack the supported invocation runs it inside the server
container, which already holds the database connection and server configuration:
docker compose exec server srn-admin help
Administrator access is a persisted server-side role, not an email-based environment setting. Register the intended account first, then bootstrap it locally:
docker compose exec server srn-admin roles grant <user> ADMIN_USER
You can download and checksum the binary like the other published tools to keep it on an operator machine, but it always targets a live deployment — it manages an existing stack rather than starting a server of its own.
Choosing a domain and ports
- Local-only (default). Leave the domain blank. The app is reachable at
http://localhost:3001. Cookies are host-only and non-secure, which works onlocalhostor any bare host/IP over plain HTTP. - Behind a domain over HTTPS. Give the setup script your domain (e.g.
notes.example.com) and answer “yes” to the HTTPS question. The script setsCOOKIE_DOMAIN,COOKIE_SECURE=true, and computesPUBLIC_FILES_SERVER_URLand the WebAuthn origins for you. Terminate TLS at a reverse proxy (nginx/Caddy/Traefik) in front of the published ports - Compose itself serves plain HTTP on the host ports. See Running behind a reverse proxy for the full proxy config (TRUST_PROXY, nginx/Traefik examples, websocket upgrade). - Port already in use? Re-run the setup script and choose a different host
port, or edit
APP_PORTin.env, thendocker compose up -dagain. It is the only public app port — the API gateway and files service are internal-only and reached through the app front door. The optional n8n development mapping remains host-loopback-only.
Running behind a reverse proxy
For any internet-facing deployment you should terminate TLS at a reverse proxy
(nginx, Traefik, or Caddy) in front of the stack. The stack has a single
front door: the app container’s nginx (host port APP_PORT, default 3001)
serves the web UI and already proxies the /v1 API, the /files/ endpoints,
and the realtime /sockets websocket same-origin to the internal-only server
container. Your proxy therefore needs exactly one upstream - the app port -
and one certificate.
Required environment
TLS is terminated at the proxy, so the containers receive plain HTTP. They must trust the proxy’s forwarded headers and the cookie must be marked Secure:
| Variable | Set to | Why |
|---|---|---|
TRUST_PROXY |
usually leave at the default | Makes the server honor X-Forwarded-Proto / X-Forwarded-For so req.secure and the client IP are correct. The default (loopback, linklocal, uniquelocal) trusts a proxy on loopback or a private/Docker network - which is exactly the case when the proxy is another container or runs on the same host. Set it to true, a hop count, or a CSV of proxy IPs/subnets only if your proxy reaches the stack from a public IP. |
ENFORCE_HTTPS_FROM_PROXY |
true |
Enables the validated outer-proxy contract: the app front door trusts one exact X-Forwarded-Proto: http or https value and preserves the proxy’s sanitized X-Forwarded-For chain. http redirects to PUBLIC_URL; https enables HSTS. Startup fails without a pathless HTTPS origin and loopback bind. Keep the app port private, and make the outer proxy overwrite both headers so clients cannot enter the trusted chain. |
APP_BIND_ADDRESS |
127.0.0.1 |
Keeps the published inner HTTP port reachable only by a reverse proxy on the same host. Trusted-proxy mode refuses to start with any other declared bind. If Traefik reaches app:8080 over a private Docker network, remove the Compose ports entry and leave this safety declaration at 127.0.0.1. Direct HTTP/LAN mode retains the compatible 0.0.0.0 default only while proxy trust is disabled. |
COOKIE_SECURE |
true |
The auth cookie is then only sent over HTTPS. Without this the browser may drop it on an HTTPS origin and every request 401s. |
COOKIE_DOMAIN |
your domain (e.g. notes.example.com) |
Scopes the auth cookie to your host. Leave empty only for bare-host/IP setups. |
PUBLIC_FILES_SERVER_URL |
https://notes.example.com (or a files subpath/host) |
The public URL clients use to reach the files service - must be the HTTPS URL the browser can reach, routed by the proxy. |
PUBLIC_URL |
https://notes.example.com |
Canonical app origin used for external-link hostname isolation; do not derive it from forwarded Host. |
AUTH_SERVER_U2F_EXPECTED_ORIGIN |
https://notes.example.com |
WebAuthn/hardware-key origin must match the HTTPS origin. |
AUTH_SERVER_U2F_RELYING_PARTY_ID |
notes.example.com |
WebAuthn relying-party id (the host, no scheme/port). |
Why
TRUST_PROXY? Express only fillsreq.secure/req.protocol/req.ipfrom theX-Forwarded-*headers when “trust proxy” is configured. Without it, the server thinks every request is plain HTTP from the proxy’s address.
Attaching to the proxy’s Docker network
If the proxy is itself a container - Nginx Proxy Manager, Traefik, a Caddy
container - it can reach the app over a shared Docker network by name, and then
no host port needs publishing at all. docker-compose.proxy.yml is an overlay
that does exactly that, applied on top of docker-compose.yml so the base file
stays upgradable:
# Find the network your proxy already uses
docker network ls
# Deploy onto it. Say it once: the deploy script remembers it.
./deploy.sh --proxy-network=npm_default
# Every later deploy, including after a git pull
./deploy.sh
The usual mistake is the network’s name. A proxy started with Docker Compose
sits on <its-project>_default - npm_default for a Nginx Proxy Manager stack
kept in a directory called npm - and not on anything called proxy. On a
first install with a terminal attached and no proxy flag, ./deploy.sh asks
whether the stack sits behind a containerised proxy and lists the networks on
the host, with the ones a recognisable proxy container (Nginx Proxy Manager,
Traefik, Caddy, nginx, OpenResty, HAProxy) is attached to first. It never asks
under --yes, under --dry-run, or without a terminal; there, pass the flag.
Then point the proxy at http://standard-red-notes-app:8080. Compose also
registers the plain service name app as an alias, but app is a name many
stacks on a shared proxy network will claim, so prefer the explicit one.
--proxy-alias (or SRN_PROXY_ALIAS) changes it, and SRN_PROXY_NETWORK
names the network if you are applying the overlay without the deploy script.
The overlay makes three changes:
appjoins the external network, in addition to the internal one. It still needsstandard-red-notesto reach the server, database and cache.- Nothing else joins it. A proxy network is shared with every other
container that proxy serves, so anything on it is reachable by all of them.
db,cache,floci,server,mcp,n8nanddocker-socket-proxystay internal. Nothing is lost: the app container’s nginx is the single front door and already proxies every backend path same-origin, so one upstream is all a proxy ever needs. - The published host port is removed. Behind a proxy, a port on every host
interface is a second front door that bypasses it - no TLS, no proxy auth, no
access log. Add
--publish-ports(or-f docker-compose.proxy-publish.yml) to keep a127.0.0.1-only port for local debugging as well.
The network must already exist. ./deploy.sh will not create one unless you
pass --create-network, because a proxy normally brings its own and a second
network with the same purpose leaves a stack that looks deployed and is
unreachable. Your proxy container has to be on the network too:
docker network connect npm_default <your-proxy-container>
PUBLIC_URL and PUBLIC_FILES_SERVER_URL are the other two silent ones. Left
at http://localhost:3001, the gateway hands browsers links, redirects and
file URLs pointing at the container instead of your domain, and the websocket
origin allowlist derived from PUBLIC_URL rejects your real origin. In proxy
mode ./deploy.sh warns about these, and about ENFORCE_HTTPS_FROM_PROXY and
TRUST_PROXY disagreeing with the rest of the configuration, but it never
edits .env.
What the deploy script remembers
./deploy.sh used to forget everything between runs. A plain ./deploy.sh
after ./deploy.sh --proxy-network=npm_default recreated the app container
off the proxy network and with its host port published on
APP_BIND_ADDRESS again: the site went dark behind the proxy, an unproxied
port opened, and the script reported success. The choices that say which stack
this is and how it is reached are therefore written to .deploy-state, beside
deploy.sh, just before the stack is started:
| Remembered | Said with |
|---|---|
| Which Compose file | --single, --file |
| The Compose project name | -p, or COMPOSE_PROJECT_NAME in the environment |
| The proxy network, its alias, and the optional loopback port | --proxy-network, --proxy-alias, --publish-ports |
Every run prints the file it read and what it took from it before it does anything else:
Remembered settings
File: /srv/standard-red-notes/.deploy-state (saved 2026-10-12T11:46:54Z)
Loaded: the multi-container stack (docker-compose.yml), Compose's default project name
behind reverse-proxy network 'npm_default' as 'standard-red-notes-app', no host port
This run: as remembered.
.deploy-state is a short key=value file you can read. It holds no secrets -
those stay in .env, which the script still never writes - and Git ignores it,
so it survives git pull and never shows up as a local change. It is parsed
strictly and never executed: a line the script does not understand stops the
deploy instead of being guessed at. --dry-run prints what would be written
and writes nothing. A deployment that never used any of these flags gets no
file at all and behaves exactly as it did before.
A deployment that was already behind a proxy network before settings were
remembered has no .deploy-state. The first plain ./deploy.sh reads the
answer off the app container - Compose records which files a container was
created from - carries the proxy network forward, and saves it. A network you
attached by hand with docker network connect does not count.
Changing or leaving the proxy network
Nothing about the proxy changes unless you say so on the command line. What you say is announced before anything is pulled or built, and is then remembered in turn:
./deploy.sh --proxy-network=other_default # move to another proxy network
./deploy.sh --publish-ports # also publish 127.0.0.1:APP_PORT
./deploy.sh --no-publish-ports # stop doing that
./deploy.sh --no-proxy # leave: publish the host port again
--no-proxy is the only way out of proxy mode. In particular, a remembered
network that no longer exists - the proxy stack was removed, or its network
renamed - stops the deploy, names the network, and leaves the running stack
untouched. It does not fall back to publishing a port. Either move the
deployment with --proxy-network=<its-network>, or leave deliberately with
--no-proxy; if the proxy stack is merely stopped, start it and re-run.
--single, --file and -p are different in kind: they say which
deployment this is. A command line that names a different one from the one
remembered is refused rather than merged, because carrying one stack’s proxy
settings over to another would put two app containers on the proxy network
under the same name. To run a second deployment from the same checkout, give it
its own file with --state-file .deploy-state.staging on every run. To replace
the remembered deployment, delete .deploy-state and run the new command.
Deleting .deploy-state is not a way off a proxy network. While the stack
exists, the next run reads the network back off the app container. Only when
the file is deleted and the containers are gone (docker compose down) is
there nothing left to read, and that run needs --proxy-network again.
Starting and restarting by hand
A hand-typed Compose command can lose the proxy network the same way a
forgetful deploy did. docker compose up -d does not merely start what is
stopped: it recreates every container whose configuration differs from the
files it is given, and without the overlay that is the app container, off the
proxy network and with its host port published.
For the multi-container stack the deploy script closes that gap. From what it
remembers it generates docker-compose.override.yml, the one file Compose
loads by itself, so a bare command in the checkout acts on the same project, on
the same network:
docker compose up -d # safe once ./deploy.sh has run in proxy mode
docker compose logs -f app
The script keeps that file only after asking Compose to render both a bare
invocation and its own and finding them identical, and its closing summary says
which it is (By hand: ...). It never touches an override file it did not
generate: if you keep your own, or set COMPOSE_FILE, the summary tells you
that a bare command is not covered. Git ignores the generated file.
Two cases it cannot cover, because Compose is told not to load the override:
- an explicit
-f, such asdocker compose -f docker-compose.yml up -d; - the all-in-one stack, which is always addressed with
-f docker-compose.single.yml.
For those, use ./deploy.sh, or repeat every file and the network name:
SRN_PROXY_NETWORK=npm_default docker compose \
-f docker-compose.yml -f docker-compose.proxy.yml up -d
The all-in-one container behind a proxy
The all-in-one topology takes the same treatment, with an overlay of its own because it has no internal network to keep:
./deploy.sh --single --proxy-network=npm_default
./deploy.sh # later: still the all-in-one stack, still on npm_default
docker-compose.single.proxy.yml joins the container to the proxy network
under the same alias and removes its published port; --publish-ports works
here too. --single is remembered like everything else, so a later plain
./deploy.sh cannot start the multi-container stack beside it by mistake.
Standard Red Notes external host
Route the whole hostname to the app front door (app container, host port
APP_PORT/3001, container port 8080). The front door’s nginx
(app/docker/nginx.conf) then fans out by path on the internal Docker network:
/-> the web UI (static bundle)/v1,/v2,/auth,/subscription,/healthcheck-> the API gateway (servercontainer, port 3000)/files/-> the files service (servercontainer, port 3104; the front door strips the/filesprefix). PointPUBLIC_FILES_SERVER_URLat the public app origin +/files, e.g.https://notes.example.com/files./sockets-> the websocket gateway (in-process inside the api-gateway on port 3000). The/socketspaths need the WebSocket Upgrade headers - the front door already sends them, but YOUR proxy must passUpgrade/Connectionthrough too. Legacy realtime openswss://<host>/sockets; the primary item sync transport openswss://<host>/sockets/sync. (WEB_SOCKET_SERVER_URLis container-internal - the api-gateway minting tokens against itself - and should stay at its default.) The web client does not hard-code an API origin - it defaults to its own origin and follows the gateway’s advertised files URL - so single-origin routing works out of the box. n8n is deliberately not part of this path router.
Worker WebSocket sync and HTTP fallback
Durable item sync prefers a dedicated client Worker connected to
/sockets/sync. The client first reads
GET /v1/sockets/sync/capabilities, then uses its normal authenticated session
to request a one-use ticket from POST /v1/sockets/sync/ticket. Neither the
bearer credential nor ticket is placed in the URL: the WebSocket URL must have
no query string, and the ticket is sent in the first protocol AUTH frame.
Normal HTTP sync remains the same-command fallback for unavailable sockets,
disconnects, and the operator kill switch.
The transport defaults on. Set exact WEBSOCKET_SYNC_ENABLED=false to disable
it; any other non-empty value is a startup error. An empty
WEBSOCKET_SYNC_ALLOWED_ORIGINS derives one exact HTTP(S) origin from
PUBLIC_URL, and any browser origin that matches the upgrade’s own Host is
accepted as a same-origin fallback. Explicit entries are comma-separated exact
origins; wildcard, null, file:, credential-bearing, path, query, and
fragment values are rejected. On a multi-container deployment Redis backs
one-use tickets, command leases and the fleet-wide per-user socket budget, so
every replica observes the same state; a single process keeps the same three in
memory.
What actually gates the lane
Three conditions gate the socket transport. If any is unmet, capability negotiation returns an empty list and every client uses HTTP:
| Condition | Unmet code | What it needs |
|---|---|---|
| Connection-token secret | WEB_SOCKET_CONNECTION_TOKEN_SECRET_MISSING |
WEB_SOCKET_CONNECTION_TOKEN_SECRET set to at least 32 bytes |
| Kill switch off | WEBSOCKET_SYNC_DISABLED_BY_CONFIGURATION |
WEBSOCKET_SYNC_ENABLED is not the exact string false |
| Somewhere to keep the state | REDIS_UNBOUND |
Either plane: a multi-container gateway needs REDIS_URL (or REDIS_HOST/REDIS_PORT) and not CACHE_TYPE=memory; the single container and LXC keep it in-process and never report this code |
A fourth, separate condition gates only the SYNC_ITEMS operation:
| Condition | Unmet code | What it needs |
|---|---|---|
| Durable command port | SYNCING_SERVER_GRPC_UNBOUND |
SERVICE_PROXY_TYPE=grpc plus SYNCING_SERVER_GRPC_URL |
This one is not all-or-nothing. When it is unmet the socket still opens and
still serves live collaboration, API RPC, invite events, the assistant stream
and the files lane; only item sync falls back to HTTP, and the boot log says so
once:
[ws-sync] SYNC_ITEMS will not be advertised (durable-backend-unavailable).
SERVICE_PROXY_TYPE is the switch that binds that port, and it is empty by
default: the bundled Compose stack talks HTTP between its own services. Set
SERVICE_PROXY_TYPE=grpc in .env and recreate the server service to enable
realtime SYNC_ITEMS; Compose passes the value through as
API_GATEWAY_SERVICE_PROXY_TYPE and the entrypoint supplies the matching
AUTH_SERVER_GRPC_URL / SYNCING_SERVER_GRPC_URL. Flipping it moves every
api-gateway-to-auth and api-gateway-to-syncing-server call onto gRPC, not just
SYNC_ITEMS. The single container and the LXC install use an in-process durable
adapter instead and never need this switch.
The files lane over the socket needs one more container-internal URL,
WEBSOCKET_SYNC_FILES_URL (Compose default http://localhost:3104, passed
through as API_GATEWAY_WEBSOCKET_SYNC_FILES_URL). Without it the gateway
logs Realtime FILES_V1 transport not advertised and uploads and downloads use
the ordinary HTTP path.
Tuning variables
| Variable | Default | Notes |
|---|---|---|
WEBSOCKET_SYNC_MAX_SOCKETS_PER_USER |
4 |
Fleet-wide per-user sync-socket budget. |
WEBSOCKET_SYNC_REDIS_KEY_PREFIX |
srn:ws-sync:v1 |
Ticket, lease and budget keys. |
WEBSOCKET_SYNC_REDIS_OPERATION_TIMEOUT_MS |
1500 |
Per Redis operation. |
WEBSOCKET_SYNC_COMMAND_LEASE_TTL_MS |
30000 |
Per-device command lease. |
WEBSOCKET_SYNC_SOCKET_LEASE_TTL_MS |
75000 |
Socket-budget reservation. |
WEBSOCKET_MAX_CONNECTIONS_PER_USER |
16 |
Legacy /sockets lane; 1..1024 or the boot fails. |
WEB_SOCKET_CONNECTION_TOKEN_TTL |
60s |
See the format note below. |
WEBSOCKET_REDIS_NAMESPACE |
empty | See the namespace note below. |
WEBSOCKET_SYNC_PUSH_ENABLED |
empty (off) | See the push note below. |
WEBSOCKET_SYNC_PUSH_MAX_BYTES |
204800 |
Byte cap on inlined push payloads. |
WEB_SOCKET_CONNECTION_TOKEN_TTL accepts <n>s, <n>m, <n>h, or a bare
integer meaning seconds. It is validated at boot: an unparseable or
zero value stops the api-gateway with the variable named rather than minting
unusable tokens. A bare 60 therefore means 60 seconds, which is also the
default.
WEBSOCKET_REDIS_NAMESPACE (empty by default) prefixes every realtime Redis
name: the push channel, the collaboration relay channel, the collaboration room
keys, the SQS dedup keys and the invite-event streams. Set it to a value
matching ^[a-z0-9:_-]{1,64}$ when two stacks share one Redis instance; leave
it empty and the names are byte-identical to previous releases, so a rolling
upgrade keeps replicas talking. An invalid value behaves differently by
topology on purpose: the multi-container api-gateway refuses to start and logs
the variable name, while the single container and LXC keep serving HTTP and
record the precondition WEBSOCKET_REDIS_NAMESPACE_INVALID rather than
publishing on a sibling stack’s un-namespaced channels.
WEBSOCKET_SYNC_PUSH_ENABLED is off unless set to the exact string true.
With it off, a save publishes the plain ITEMS_CHANGED_ON_SERVER notification
and the receiving device pulls the change over HTTP. With it on, the changed
encrypted payloads are also inlined into the push. Inlining is off by default
because the sync token a push carries is the saving request’s own timestamp, and
no receiver ever holds that value, so the receiver’s strict base-token check
always failed and re-pulled over HTTP anyway: inlining only doubled the bytes on
the wire. Cross-device latency is the same either way.
WEBSOCKET_SYNC_PUSH_MAX_BYTES (default 200 KiB) bounds the serialised
payloads; a larger change set degrades to the plain notification rather than
overflowing a frame or the 256 KiB SNS message limit.
The single-container and LXC topologies bundle no Redis and need none: the
gateway attaches with REDIS_HOST unset 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. The boot line Realtime WebSocket
gateway attached to the HomeServer HTTP server reports pushBridge:
in-process, and the admin diagnostics panel shows the same value; REDIS_UNBOUND
is not reported there, because nothing is missing. Two properties follow from
that plane: the realtime state is lost on restart (clients re-discover their
rooms and re-sync), and the invite-event stream is memory-resident, so a client
offline across a restart catches up over HTTP instead of over the socket.
Set REDIS_HOST/REDIS_PORT on those topologies only to run several gateway
replicas against one database. Their connector supports only host and port,
without Redis authentication or TLS, so external Redis is supported only on the
same private trusted network. Never publish it or route it across a trust
boundary.
The durable gRPC command secret
In the multi-process topology, setup also generates
SYNCING_SERVER_INTERNAL_GRPC_AUTH_SECRET. This purpose-specific key signs the
API-gateway’s durable gRPC command/status metadata and is verified by the
syncing-server; never reuse AUTH_JWT_SECRET. Missing it keeps SYNC_ITEMS
closed while the rest of the socket and HTTP sync remain available. The bundled
HomeServer uses a direct in-process durable adapter and intentionally does not
invent or validate gRPC credentials.
When upgrading an older multi-container installation, run the normal setup
script once (./scripts/setup.sh --yes or ./scripts/setup.ps1 -Yes) before
recreating the server. If this purpose-specific key is absent, setup generates
exactly one 32-byte value and adds it through one atomic .env rewrite with a
timestamped backup; it preserves every existing value, comment, and permission.
A valid existing key is never rotated, while a duplicate or malformed value
stops migration without modifying .env. Recreate the bundled server only
after that migration so its API-gateway and syncing-server receive the same key.
Desktop, mobile and the browser extension
Those clients have no configurable gateway URL. They derive
ws(s)://<sync-host>/sockets from the server they already sync with and open
the legacy lane on every launch that has a session, which carries push,
realtime invites, push-approved multi-factor prompts and the collaboration
relay. They do not use the worker sync lane: their page origin is file:, which
can never pass the origin check, so item sync there stays on HTTP by design. A
gateway that answers 503 or closes the socket is retried when the app returns to
the foreground, regains connectivity or signs in again, not on a fixed timer.
Forwarded headers and the same-origin fallback
ENFORCE_HTTPS_FROM_PROXY=true is defense in depth, not a TLS terminator. The
outer proxy is the public trust boundary: it must overwrite (not append to)
both X-Forwarded-Proto and X-Forwarded-For, redirect its own public HTTP
listener to HTTPS, and emit HSTS on HTTPS responses. The inner app nginx drops
client-supplied X-Forwarded-For by default; it preserves a chain only in this
validated trusted mode. Do not publish APP_PORT to untrusted networks; bind it
to loopback or reach the app service only over the proxy’s private Docker
network. Existing installations must set both the flag and
APP_BIND_ADDRESS=127.0.0.1 after verifying those conditions; new setup-script
installs do so when you answer that the configured domain is served over HTTPS.
The flag also decides whether the realtime same-origin fallback can work behind
TLS. When WEBSOCKET_SYNC_ALLOWED_ORIGINS and PUBLIC_URL are both empty, the
gateway accepts an upgrade whose browser Origin equals the origin it rebuilds
from the request’s own Host plus its effective scheme. Inside the container
that scheme is http unless the front door forwards the validated outer
X-Forwarded-Proto, which only happens in this trusted mode — so behind a TLS
terminator the fallback needs ENFORCE_HTTPS_FROM_PROXY=true, and a proxy that
appends a second comma-separated value to X-Forwarded-Proto fails the check
outright. Setting PUBLIC_URL to the exact browser origin is the simpler route:
an explicit origin is matched directly and no proxy header is consulted.
The LXC installer preserves its documented externally reachable HTTP topology
and does not enable this inner forwarded-header gate. Its nginx always replaces
X-Forwarded-For with the immediate peer address, so a client cannot spoof
request.ip; when an outer proxy is used, the gateway deliberately sees that
proxy address. For LXC, restrict the container port to the proxy and enforce the
308 redirect plus HSTS at that outer proxy; see deploy/lxc/README.md.
Compose: dropping host ports
The default docker compose up publishes the single app port (APP_PORT) so
the stack works standalone; the server service publishes nothing. When a proxy
fronts the stack you can drop even that port: attach the app service to a
shared Docker network and let the proxy reach it by service name.
docker-compose.yml ships commented examples: create the network once with
docker network create proxy, then uncomment the # - proxy network line (and
the Traefik labels: block) on the app service. Leaving the examples
commented keeps the default flow unchanged.
If you deploy with ./deploy.sh, prefer
the overlay to editing
docker-compose.yml. It makes the same change without touching the base file -
which the script would otherwise refuse to deploy as an uncommitted change -
and the script remembers it on every later run.
nginx example
This example also applies to OpenResty. TLS terminates at the outer proxy;
it forwards plain HTTP to the single app front door
(host port APP_PORT, default 3001), which handles all path routing itself.
Note the explicit Upgrade/Connection handling on the websocket location and
the unbuffered /files/ location for large uploads/downloads.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name notes.example.com;
# Keep the method and body (important for API clients); never serve the app
# itself on this public listener.
return 308 https://notes.example.com$request_uri;
}
server {
listen 443 ssl http2;
server_name notes.example.com;
ssl_certificate /etc/letsencrypt/live/notes.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/notes.example.com/privkey.pem;
client_max_body_size 0; # allow large file uploads (server enforces its own limit)
# Start with the apex host only. Add includeSubDomains/preload only after
# every subdomain is permanently HTTPS-capable.
add_header Strict-Transport-Security "max-age=31536000" always;
# Overwrite forwarded transport metadata; never pass a client-supplied value.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
# Covers both /sockets and /sockets/sync; keep the complete request path.
# ^~ prevents a regex location from overriding the websocket route.
location ^~ /sockets {
# No trailing slash or URI suffix: do not strip the /sockets prefix.
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
# This location defines proxy_set_header, so nginx no longer inherits
# any server-level proxy headers. Repeat the complete trusted set.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 1h; # keep long-lived sockets open
proxy_send_timeout 1h;
proxy_buffering off;
}
# Files - stream large encrypted chunks without buffering them on disk.
location /files/ {
proxy_pass http://127.0.0.1:3001;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 1h;
}
# Everything else (web UI and /v1 API) - the app front door routes by path.
location / { proxy_pass http://127.0.0.1:3001; }
}
Replace 127.0.0.1:3001 with the app front door’s actual address if the proxy
runs in another container or the published APP_PORT differs. Apply the
WebSocket headers and timeouts at every reverse proxy between the browser and
the app. The bundled app Nginx already provides them on its internal hop.
In Nginx Proxy Manager, edit the notes Proxy Host, open Details, enable
Websockets Support, and save. Keep its working forwarding host and port;
the main route can carry both HTTP and WebSocket traffic, so a separate
/sockets location is not required. If you already use a custom /sockets
location, ensure it keeps these settings and the same working app upstream.
Do not paste the complete map/server example into NPM’s Advanced field.
If upgrades return 502 while ordinary HTTP requests succeed, inspect that
proxy host’s error log for the failed upstream connection or response.
The per-host log is /data/logs/proxy-host-<id>_error.log. Compare the upstream
host and port for failed /sockets requests with those for working HTTP
requests. For example, HTTP using port 8080 while sockets use port 80 and report
connect() failed (111: Connection refused) identifies a mismatched upstream;
changing WebSocket headers or timeouts cannot open that refused port. In NPM,
check Custom Locations and Advanced for an override and either correct
its port or remove it so the main forwarding destination handles both paths.
Redact socket tokens and cookies before sharing logs.
An HTTP 502 Bad Gateway in diagnostics can mean the app or its backend is down, restarting, or unreachable from the proxy. It can also mean the proxy targets the wrong host or port. Check container status and startup logs, then the proxy’s upstream error, before changing socket settings. A 502 is a proxy response, not proof that the database is down or a request timed out.
Stamped web builds automatically check /.well-known/srn-deployment.json on
startup, every minute while visible, and when returning online or to the tab.
When the server serves a different revision, a persistent toast offers
Refresh app. No diagnostic run is required. Service-worker updates offer
the same action; they do not reload a running editor automatically. Refresh
uses the existing unsaved-changes guard. Unstamped development builds skip
the revision comparison, and failed/offline checks do not announce an update.
Validate the configuration before reloading the outer proxy:
nginx -t && nginx -s reload
For OpenResty, use openresty -t && openresty -s reload, or the deployment’s
equivalent container/service commands. The upgrade headers and read timeout
follow the Nginx WebSocket proxying documentation.
A successful handshake and authenticated control-plane read establish that
WebSocket traffic can pass, but do not establish that note syncing completes.
Repeated DEGRADED transitions with ack-timeout about 20 seconds apart match
the client’s command acknowledgement deadline. Increasing the proxy timeout
does not extend that deadline or repair a stalled command. Check the deployed
gateway and syncing-server logs for the same timestamps; the sync round-trip
check must finish before declaring the lane healthy. A proxy idle timeout is
usually accompanied by an upstream timeout in the proxy’s error log.
SERVICE_PROXY_TYPE=grpc describes internal communication between the gateway
and backend services. Keep /sockets as HTTP WebSocket proxying; it does not
require grpc_pass at the public Nginx/OpenResty proxy.
Set in .env: ENFORCE_HTTPS_FROM_PROXY=true,
APP_BIND_ADDRESS=127.0.0.1, COOKIE_SECURE=true,
COOKIE_DOMAIN=notes.example.com,
PUBLIC_URL=https://notes.example.com,
PUBLIC_FILES_SERVER_URL=https://notes.example.com/files, and the WebAuthn
origins. (WEB_SOCKET_SERVER_URL is container-internal and should stay at its
default.) TRUST_PROXY can stay at its default when nginx runs on the same host
(loopback) or on the Docker network; set it to nginx’s address otherwise.
Traefik example
Traefik (v2/v3) with the Docker provider. Only the app service needs to join
Traefik’s network and be labeled - it is the single front door and proxies the
API, files, and websocket internally. Traefik forwards X-Forwarded-* and
proxies the WebSocket Upgrade/Connection headers automatically, so no
special websocket config is needed beyond the one router.
# In docker-compose.yml (see the commented examples there):
services:
app:
networks: [standard-red-notes, proxy]
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.srn-web.rule=Host(`notes.example.com`)"
- "traefik.http.routers.srn-web.entrypoints=websecure"
- "traefik.http.routers.srn-web.tls.certresolver=le"
- "traefik.http.routers.srn-web.middlewares=srn-hsts"
- "traefik.http.services.srn-web.loadbalancer.server.port=8080"
# The public HTTP router never serves the app; it only redirects to TLS.
- "traefik.http.routers.srn-web-http.rule=Host(`notes.example.com`)"
- "traefik.http.routers.srn-web-http.entrypoints=web"
- "traefik.http.routers.srn-web-http.middlewares=srn-https-redirect"
- "traefik.http.routers.srn-web-http.service=srn-web"
- "traefik.http.middlewares.srn-https-redirect.redirectscheme.scheme=https"
- "traefik.http.middlewares.srn-https-redirect.redirectscheme.permanent=true"
- "traefik.http.middlewares.srn-hsts.headers.stsseconds=31536000"
- "traefik.http.middlewares.srn-hsts.headers.stsincludesubdomains=false"
- "traefik.http.middlewares.srn-hsts.headers.stspreload=false"
networks:
proxy:
external: true
Because the proxy and the stack share the proxy Docker network (a private
subnet), the default TRUST_PROXY already trusts Traefik - no override needed.
Use the same .env values as the nginx example. Keep Traefik’s forwarded-header
trust in its safe default or limit forwardedHeaders.trustedIPs to known
upstream proxies; never enable insecure forwarding. Confirm the web entrypoint
is actually exposed on port 80 and websecure on 443 in Traefik’s static config.
Separate n8n hostname
If you enable the workflows profile, give n8n a second router such as
automation.example.net -> n8n:5678. Do not add an n8n path to the Standard Red
Notes host. Configure n8n’s own TLS-facing URL, trusted proxy hops, secure
cookie, owner account, and project/user policy. The Standard Red Notes
WORKFLOWS_ENABLED gates reveal only the external link.
See Workflows with n8n for complete nginx/Traefik topology, environment values, URL rejection rules, MCP connection, and revocation.
Manual verification
-
Public HTTP never serves application content. A request to the outer proxy must preserve the method with a canonical HTTPS redirect:
curl -si -X POST http://notes.example.com/v1/login | sed -n '1p;/^[Ll]ocation:/p' # expect: HTTP/... 308 # expect: Location: https://notes.example.com/v1/login -
HTTPS emits the bounded HSTS policy. Check both the SPA and the dedicated sandbox response; neither policy opts all subdomains into HSTS or preload:
curl -sSI https://notes.example.com/ | grep -i '^strict-transport-security:' curl -sSI https://notes.example.com/sandbox.html | grep -i '^strict-transport-security:' # expect: Strict-Transport-Security: max-age=31536000 -
Inner transport gate fails safely. From the Docker host only (the app port must not be public), verify local health remains HTTP, exact trusted values work, and a proxy chain/mixed value is not trusted:
curl -fsS http://127.0.0.1:3001/health curl -sSI -H 'X-Forwarded-Proto: http' http://127.0.0.1:3001/ | sed -n '1p;/^[Ll]ocation:/p' curl -sSI -H 'X-Forwarded-Proto: https' http://127.0.0.1:3001/ | grep -i '^strict-transport-security:' curl -sSI -H 'X-Forwarded-Proto: https,http' http://127.0.0.1:3001/ | grep -Ei '^(HTTP/|Location:|Strict-Transport-Security:)' # exact http => 308 to PUBLIC_URL; exact https => HSTS; mixed => ordinary 200 with neitherThese inner checks apply only with
ENFORCE_HTTPS_FROM_PROXY=true. They do not replace the public HTTP redirect/HSTS checks above. -
Secure cookie behind the proxy. Against the API gateway, send a forwarded HTTPS header and confirm the auth cookie comes back
Secure(requiresCOOKIE_SECURE=true):curl -sik -H 'X-Forwarded-Proto: https' \ -H 'Content-Type: application/json' \ -d '{"email":"you@example.com","password":"...","api":"20200115"}' \ https://notes.example.com/v1/login | grep -i set-cookie # expect: Set-Cookie: access_token_...; HttpOnly; Secure; ... -
Websocket upgrade through the proxy. Confirm the proxy upgrades the sync connection (HTTP 101). This transport-only probe deliberately omits the first
AUTHframe, so the server closes it after the handshake:curl -fsS https://notes.example.com/v1/sockets/sync/capabilities # expect capabilities[0]: id=ws-sync, version=1, endpoint=/sockets/sync curl -sik --http1.1 --max-time 2 \ -H 'Origin: https://notes.example.com' \ -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ -H 'Sec-WebSocket-Version: 13' \ -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ https://notes.example.com/sockets/sync # expect: HTTP/1.1 101 Switching Protocols -
Real client IP in logs. With
TRUST_PROXYset correctly the server logs the client’s address (fromX-Forwarded-For), not the proxy’s.
Start, stop, and upgrade
# Build (if needed) and start everything in the background
docker compose up -d --build
# Follow logs
docker compose logs -f
docker compose logs -f server # one service
# Check status / health
docker compose ps
# Stop (containers removed, named volumes / data kept)
docker compose down
# Upgrade: pull newer base images and rebuild the app/server images
git pull
docker compose pull # refresh mariadb / redis / floci images
docker compose up -d --build # rebuild app/server/gateway and restart
./deploy.sh handles the source-build update as one command, and checks the
result: it fast-forwards the checkout (--ff-only, so a diverged branch fails
loudly instead of producing a merge commit), takes a logical database backup
and refuses to continue if the dump is truncated, rebuilds with the images
stamped with the exact commit, and then waits for the healthchecks, makes a
real request to the readiness endpoint and confirms the live deployment
identity is the commit it just built. On a host with no .env it runs
scripts/setup.sh first; it never overwrites an existing one. --dry-run
prints every command it would run, and ./deploy.sh --help lists the flags.
It also remembers how the stack was deployed - which Compose file, which
project name, and which reverse-proxy network - so the same plain ./deploy.sh
redeploys the same way after every git pull. See
What the deploy script remembers.
This source-build path remains the default. ./scripts/setup.sh --up and
./scripts/setup.ps1 -Up still build the checked-out app and server
Dockerfiles and verify that clean checkout’s deployment identity; they do not
silently select registry images.
Deploy script arguments
Run the Bash script from the checkout for the examples below. It selects the
multi-container stack by default, or reuses the deployment recorded in
.deploy-state. The full flow checks configuration, pulls Git with
--ff-only, runs first-install setup when needed, backs up running data,
confirms the rebuild, saves deployment settings, builds and starts Compose,
then checks health, readiness and deployment identity. Git pull, requested
network creation, setup and backup can already have happened when the final
rebuild confirmation appears.
./deploy.sh --help
./deploy.sh --dry-run # inspect the plan without changing the deployment
./deploy.sh # normal install or update, with prompts
./deploy.sh --update --yes # configured deployment, no prompts
Arguments that take a value use a separate word, such as --timeout 900.
Only --proxy-network=NAME and --proxy-alias=NAME also accept the equals
form. Quote paths containing spaces. Unknown options and missing values are
errors; there is no passthrough for arbitrary Compose arguments.
Frontend handovers
For an existing bundled stack with separate app and server containers:
./deploy.sh --rolling-web --yes # update backend too; keep the web frontend serving
./deploy.sh --web-only --yes # update only the web frontend
./deploy.sh --web-rollback --yes # return to the previous healthy frontend
These modes keep the public app container, port and reverse-proxy alias in place. A replacement frontend starts privately, with no additional published port. Deployment waits for its Docker health check, checks that the public container can reach it and that the backend readiness endpoint answers through the existing public frontend, validates nginx configuration, then gracefully reloads nginx to switch frontend traffic. The served HTML and JavaScript are compared with the replacement before success is reported. A failed handover restores the previous routing. Existing API, file-transfer and WebSocket routes are preserved; Nginx Proxy Manager needs no upstream change.
--rolling-web validates replacement frontend health and private reachability
before updating the backend, database and other enabled services. A failed
frontend candidate therefore leaves the running backend untouched. It still
backs up before deployment. API calls, sync and collaboration can be interrupted while
those services restart. The availability guarantee here concerns the web
frontend. --web-only leaves those services running and skips the database
backup. Git pull and build stamping otherwise follow the normal deploy flags.
Use it for frontend changes compatible with the backend version already running;
frontend and backend revision markers can intentionally differ afterward.
One previous frontend stays running for rollback and for immutable lazy-loaded
assets requested by older open tabs. Older managed frontend containers are
stopped gracefully and removed after the next successful handover. Rollback
changes the frontend only; it does not roll back databases, backend code or
runtime administration settings. --timeout gives each handover wait phase its
own budget: replacement health, private reachability, backend readiness, and
verification of the newly served bundle. The default is 600 seconds per phase;
build time does not consume it. A 389-second build followed by a 158-second
server startup is supported. The server’s 90-second Docker healthcheck start
period is an initial grace window, not the total startup allowance. The old
frontend keeps serving while the replacement waits, and a failed readiness
check leaves its routing unchanged. These flags are one-run choices and are
not remembered implicitly.
The ignored .deploy-web/<project>.json holds routing/rollback state. Keep it
while using handovers. The public container acts as the stable nginx front door,
so docker ps continues to show its original image; the deployment marker and
frontend release containers identify the web build actually served. Ordinary
container restarts preserve this routing. A conventional full deploy without
these flags replaces that front door as usual; use that maintenance path for
nginx configuration/image, container hardening, public-origin/HTTPS settings,
public-port or network changes. Handovers refuse those origin, port and network
changes and the all-in-one --single stack.
No host Node.js installation is required for local Docker: when Node is absent, the script builds and uses a small Docker-based deployment controller. Remote Docker contexts require Node on the deploy host. An operator using private registry credential helpers should also run the host Node path so the normal Docker client configuration remains available.
Topology and saved settings
| Argument | Behavior | Saved for later runs? |
|---|---|---|
--single |
Select docker-compose.single.yml: one app container with SQLite, uploads and generated secrets in single-data. It does not require multi-container .env setup. |
Yes |
-f PATH, --file PATH |
Select one custom Compose file compatible with the Standard Red Notes multi-container service layout. Use --single for the all-in-one topology: a custom filename does not select its setup or backup rules. |
Yes |
-p NAME, --project NAME |
Set the Compose project name. Names contain lowercase letters, digits, _ or -, start with a letter or digit, and have at most 128 characters. |
Yes; an exported COMPOSE_PROJECT_NAME is remembered too |
--state-file PATH |
Read and write deployment identity and proxy choices in this file instead of the checkout’s .deploy-state. Use a separate file for each deployment. |
The selected path must be supplied again; the file stores the choices below |
--setup |
Force first-install setup. Refuse an existing .env, or missing .env with this project’s existing containers or volumes. Not applicable to --single. |
No |
--update |
Force the update path. Multi-container mode requires an existing valid .env; --single does not. |
No |
With neither forced mode, a missing .env selects setup, a valid one selects
update, and an incomplete existing one stops with instructions to recover its
original values. The deploy script delegates fresh .env generation to
scripts/setup.sh; it never overwrites an existing environment. In single
mode, the container creates and retains its secrets on first boot.
Omitted topology and project arguments reuse saved settings. A supplied
--single, --file or project name that identifies a different deployment is
refused, rather than carrying the old proxy settings into a second stack.
An explicit project argument takes precedence over exported
COMPOSE_PROJECT_NAME; without either, the saved project is used. If no project
was saved, Compose’s normal .env, top-level name: and directory-name
resolution applies. See What the deploy script remembers
for adoption of older running stacks and the generated Compose override.
The default environment, Compose files and state file are located relative to
the script’s checkout. Explicit relative --file, --state-file and
--backup-dir paths are relative to the caller’s working directory. A custom
Compose file inside the checkout is stored relative to that checkout; an
outside file is stored as an absolute path.
# All-in-one first install; later ./deploy.sh keeps this topology
./deploy.sh --single --yes
# A compatible custom stack with its own identity; repeat --state-file later
./deploy.sh --file ./docker-compose.custom.yml --project notes-staging \
--state-file ./.deploy-state.staging --yes
./deploy.sh --state-file ./.deploy-state.staging --yes
A second stack also needs distinct ports, storage and proxy aliases. A state
file separates the script’s remembered identity; it does not rewrite the
Compose file or provide a separate .env.
Reverse proxy and host ports
| Argument | Behavior | Saved for later runs? |
|---|---|---|
--proxy-network, --proxy-network NAME, --proxy-network=NAME |
Enable proxy mode. With no name, reuse the saved network, or use proxy if none was saved. Apply the multi-container or single-container proxy overlay, attach only app to the external network, and remove its normal host-port publication. |
Yes |
--proxy-alias NAME, --proxy-alias=NAME |
Give the app a distinct network alias; default standard-red-notes-app. |
Yes |
--create-network |
Create the selected proxy network if missing. The proxy container must also join it; creating the network does not attach the proxy. | No |
--publish-ports |
In proxy mode, additionally publish APP_PORT on 127.0.0.1 for local access. |
Yes |
--no-publish-ports |
Remove that additional loopback publication while retaining proxy mode. | Yes |
--no-proxy |
Explicitly leave proxy mode and restore normal host publication on APP_BIND_ADDRESS and APP_PORT. |
Yes |
Network and alias names start with a letter or digit, continue with letters,
digits, _, . or -, and have at most 128 characters. Select the network
your proxy actually uses, such as npm_default. Point it to
http://standard-red-notes-app:8080, or your chosen alias on port 8080,
and enable WebSocket forwarding for both socket paths.
Proxy mode is selected by a flag, saved state, adoption of an older running
proxy deployment, or the interactive first-install question. That question is
skipped with --yes, --dry-run or without a terminal; supply the network
explicitly for unattended first installs. SRN_PROXY_NETWORK supplies the
network when using the Compose overlay directly; exporting it alone does not
select proxy mode in deploy.sh.
Alias precedence is: command-line argument, exported SRN_PROXY_ALIAS,
SRN_PROXY_ALIAS in .env, saved alias, default alias. Publication flags
override saved publication settings. A missing saved network stops deployment
unless --create-network is supplied; it never silently exits proxy mode.
Use --no-proxy to leave deliberately, rather than deleting state.
# First install behind an existing Nginx Proxy Manager network
./deploy.sh --proxy-network npm_default --url https://notes.example.com --yes
# Distinct alias and optional loopback access; these proxy choices persist
./deploy.sh --proxy-network npm_default --proxy-alias notes-main \
--publish-ports --yes
./deploy.sh --no-publish-ports --yes
# Create a shared network only when you intend to attach the proxy to it
./deploy.sh --proxy-network notes-proxy --create-network --yes
# Restore the base Compose host-port publication
./deploy.sh --no-proxy --yes
--single uses docker-compose.single.proxy.yml; other topologies use
docker-compose.proxy.yml. Loopback publication adds
docker-compose.proxy-publish.yml after the proxy overlay. The script passes
these files explicitly. For the default multi-container deployment and default
state file, it also manages its own generated docker-compose.override.yml
so bare Compose commands retain the saved project and proxy settings. It
does not replace an override you authored. Custom files, single mode and
alternate state files need the deploy script or their complete Compose command;
see Starting and restarting by hand.
Update, backup and verification controls
These arguments affect only the current run; none is stored in deployment state.
| Argument | Behavior and default |
|---|---|
--no-pull |
Skip git pull --ff-only and build the checked-out revision. It does not disable builds or mean “do not pull Docker images.” The script does not perform the separate docker compose pull from the manual upgrade example. |
--no-backup |
Skip the pre-build snapshot. Default: back up the running data service before rebuilding. |
--backup-dir DIR |
Write snapshots here. Default: <checkout>/backups. Create the directory if needed. |
--allow-dirty |
Permit staged or unstaged tracked content changes. If such changes exist, build unstamped images and skip deployment-identity verification; a clean checkout still receives its commit stamp. Untracked files and line-ending-only differences are not treated as tracked content changes. |
--timeout SECONDS |
Container health-wait budget after Compose starts, and an independent budget for each web-handover readiness phase. Default 600; positive integer. Build time is excluded. |
--log-lines N |
Tail this many lines from a failing service. Default 200; positive integer. |
--url BASE |
Override the readiness and deployment-identity probe origin, for example https://notes.example.com. The readiness path is appended automatically; omit it from the argument. |
-y, --yes |
Accept confirmation without prompts; also pass --yes to first-install setup. It does not bypass configuration, dirty-checkout, backup, state or identity checks. |
-n, --dry-run |
Print planned mutations and saved settings without pulling, setup, snapshots, builds, restart or state writes. Read-only Git, Docker, network and Compose queries still run and can refuse an invalid plan. |
-h, --help |
Print the argument reference and exit before deployment checks. |
The multi-container snapshot is a MariaDB SQL dump, including routines and
events; it is not a full backup of uploads, Redis or .env. Single mode
archives /data, including SQLite, uploads and generated secrets, while the
container is running; this is not a stopped-volume recovery drill. If the
relevant db or app service is not running, the script reports that there is
no live data to dump and continues. A failed snapshot stops deployment. SQL
dumps must be at least 1 KiB and end with a completion trailer; single-mode
archives have a 10 KiB size floor, which does not prove archive consistency.
See Backup and restore for complete recovery
requirements.
By default, the HTTP probe uses the host endpoint returned by
compose port app 8080, with .env values as a fallback if no publication is
reported. In proxy mode without a published port, it checks the proxy network
and skips a host-origin probe unless --url is supplied. An explicit URL
tests the proxy’s public path. Deployment identity also needs the local Node
verifier for a host-origin check; a skipped check is reported, not treated as
proof that the expected commit is running.
# Check the plan, then deploy exactly the checked-out revision without Git pull
./deploy.sh --no-pull --dry-run
./deploy.sh --no-pull --yes --timeout 900 --log-lines 300 \
--backup-dir /srv/notes-backups --url https://notes.example.com
# Development-only modified-source build: no commit identity claim when dirty
./deploy.sh --allow-dirty --no-pull --yes
--single and --file conflict. So do --proxy-network with --no-proxy,
and --publish-ports with --no-publish-ports. Proxy alias, network creation
and publication arguments require proxy mode; --no-proxy cannot be combined
with --create-network, --publish-ports or --proxy-alias. Choose one forced
mode: if both --setup and --update are supplied, the parser uses the last
one. Repeating a value-taking option likewise uses its last value, subject to
the saved-identity checks above.
Deploy a verified GHCR image pair
Successful trusted-main CI publishes the exact app and server images that passed the disposable-stack and hardening gates. They are a coordinated pair:
ghcr.io/supermarsx/standard-red-notes-appghcr.io/supermarsx/standard-red-notes-server
Both use the same unique, non-floating, retry-stable tag
sha-<40-character-commit>-run-<run-id>.<producer-attempt>. No main or
latest tag is published. The initial container stream is linux/amd64 only;
do not deploy it as a native arm64 image. Treat the pair as consumable only
after the publish-containers job succeeds and its summary lists both
digest-qualified references. A failed job can leave one retry-stable tag in
GHCR; that partial tag is not a release or a deployment input.
GHCR packages are private when first created unless the repository owner makes
them public. Public packages can be pulled anonymously. For a private package,
authenticate with a GitHub token that has only the required read:packages
access:
printf '%s' "$GHCR_TOKEN" | docker login ghcr.io \
--username "$GITHUB_USER" --password-stdin
unset GHCR_TOKEN
Start from a configured checkout whose database and application secrets are already present. Select one tag from one successful workflow summary for both images, pull the complete pair, prove its operating system and architecture, take the required data snapshot, and then start without invoking either Dockerfile:
export SRN_IMAGE_TAG='sha-<40-character-commit>-run-<run-id>.<producer-attempt>'
export APP_IMAGE="ghcr.io/supermarsx/standard-red-notes-app:${SRN_IMAGE_TAG}"
export SERVER_IMAGE="ghcr.io/supermarsx/standard-red-notes-server:${SRN_IMAGE_TAG}"
docker compose pull app server
test "$(docker image inspect --format '/' "$APP_IMAGE")" = linux/amd64
test "$(docker image inspect --format '/' "$SERVER_IMAGE")" = linux/amd64
# Back up MariaDB, uploads, redis-data, server-data, and the protected environment first.
docker compose up -d --no-build --pull never --wait --wait-timeout 900
docker compose ps
For production, prefer the two digest-qualified references written to the successful workflow summary. The app and server manifest digests are different; copy both from the same coordinated run:
export APP_IMAGE='ghcr.io/supermarsx/standard-red-notes-app@sha256:<app-manifest-digest>'
export SERVER_IMAGE='ghcr.io/supermarsx/standard-red-notes-server@sha256:<server-manifest-digest>'
docker compose pull app server
docker compose up -d --no-build --pull never --wait --wait-timeout 900
The successful CI job verifies each provenance bundle from GHCR. An operator can repeat that check for both selected digest references (while authenticated to a private package):
gh attestation verify "oci://${APP_IMAGE}" \
--bundle-from-oci \
--deny-self-hosted-runners \
--repo supermarsx/standard-red-notes \
--source-digest '<40-character-source-commit>' \
--source-ref refs/heads/main \
--signer-workflow supermarsx/standard-red-notes/.github/workflows/ci.yml
gh attestation verify "oci://${SERVER_IMAGE}" \
--bundle-from-oci \
--deny-self-hosted-runners \
--repo supermarsx/standard-red-notes \
--source-digest '<40-character-source-commit>' \
--source-ref refs/heads/main \
--signer-workflow supermarsx/standard-red-notes/.github/workflows/ci.yml
Verify the exposed /.well-known/srn-deployment.json after startup. Its
revision and version must agree with the selected pair. Keep the previous app
and server digest references until the observation window passes.
Rollback uses those two previous digest references with the same pull and
up --no-build --pull never commands. Do not run docker compose down -v:
application rollback must preserve the current volumes, while a database/files
rollback is a separate recovery decision based on schema compatibility and the
pre-upgrade snapshot.
docker compose downkeeps your data (it lives in named volumes).docker compose down -vdeletes the volumes and all your data - only use it for a clean reset.
Where your data lives
Data is stored in Docker named volumes, so it survives docker compose down
and container rebuilds:
| Volume | Holds | Notes |
|---|---|---|
mysql-data |
Legacy MySQL 8.4 database from older Compose releases. | Migration source only; never mount it into MariaDB. |
mariadb-data |
The MariaDB database - all accounts, notes, and revisions. | The one to back up. |
redis-data |
Redis AOF for cache/sessions/pub-sub and encrypted queued mail. | Back up to preserve pending/dead email jobs and logs. |
uploads |
Uploaded file attachments stored by the files service. | Back this up alongside the DB if you use file uploads. |
server-data |
Gateway admin settings, encrypted relay profiles, and subscription pairings. | Back up with the matching encryption keys. |
server-logs |
Server process logs. | Disposable. |
mcp-data |
MCP bridge local state (only with the mcp profile). |
Disposable. |
n8n-data |
n8n database/config/credentials (only with workflows). |
Back up with the matching N8N_ENCRYPTION_KEY. |
List them with docker volume ls | grep standard-red-notes.
Upgrade from the legacy MySQL volume
Older Compose releases ran mysql:8.4 on mysql-data. Current releases run
MariaDB on a separate mariadb-data volume. The db-volume-preflight one-shot
service checks both datastores before db starts. If it finds an initialized
legacy database and an uninitialized MariaDB database, startup fails instead of
silently presenting an empty installation.
MySQL and MariaDB datadirs are not interchangeable. Do not rename the volume,
copy its files into mariadb-data, or point the MariaDB service at
mysql-data. Migrate with SQL while the old MySQL service is still available:
set -eu
# 1. On the old release, stop every application writer but leave MySQL running.
docker compose stop app server
# 2. Export the application database with routines, events, and triggers.
docker compose exec -T db sh -ec \
'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --events --triggers --hex-blob --set-gtid-purged=OFF --column-statistics=0 "$MYSQL_DATABASE"' \
> legacy-mysql.sql
test -s legacy-mysql.sql
# 3. Stop the old stack without deleting either named volume, then update.
docker compose down
git pull
# 4. Initialize only the new MariaDB volume. --no-deps intentionally bypasses
# the legacy-volume gate for this isolated migration container.
docker compose run --detach --name srn-mariadb-migration --no-deps db
health=starting
for attempt in $(seq 1 90); do
health=$(docker inspect --format '' srn-mariadb-migration)
[ "$health" = healthy ] && break
[ "$(docker inspect --format '' srn-mariadb-migration)" = true ] || {
docker logs srn-mariadb-migration
exit 1
}
sleep 2
done
[ "$health" = healthy ] || {
docker logs srn-mariadb-migration
exit 1
}
# 5. Restore the logical dump, then stop and remove only the one-off container.
docker exec -i srn-mariadb-migration sh -ec \
'exec mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' \
< legacy-mysql.sql
docker stop --time 60 srn-mariadb-migration
docker rm srn-mariadb-migration
# 6. The normal gate now sees an initialized mariadb-data and permits startup.
docker compose up -d --build
docker compose ps
If the preflight already blocks and no logical dump exists, do not remove or
rename either volume. Restore the pre-upgrade Compose manifest in a separate
working directory and use its MySQL 8.4 service against mysql-data only long
enough to take the dump, or restore a previously tested logical backup. For a
custom Compose project name, inspect the exact volume names first with
docker volume inspect; the default names are
standard-red-notes_mysql-data and standard-red-notes_mariadb-data.
Before reopening writes, verify representative accounts, note/revision counts,
attachments, authentication, sync, and a fresh database backup. Retain
legacy-mysql.sql and the untouched mysql-data volume until those checks and
a restore drill pass. Do not run docker compose down -v during migration.
Backup and restore
The critical data is the MariaDB volume, uploads if you store attachments,
redis-data while queued/dead email or its redacted delivery history matters,
and server-data when you use persisted administrator settings, encrypted relay
profiles, or encrypted ChatGPT/Codex pairing. Snapshot redis-data and
server-data with the matching AUTH_SERVER_ENCRYPTION_SERVER_KEY; restoring
encrypted queue/settings state with a different key makes it unreadable.
For a consistent infrastructure snapshot, stop application writers first,
allow the server’s graceful stop to finish, then stop Redis before copying or
snapshotting its volume. The supplied server shutdown budget is 75 seconds:
provider calls stop at 30 seconds, supervisord gives the gateway 60 seconds to
durably settle its current claim, and Compose retains 15 seconds for supervisor
teardown. Run docker compose stop -t 75 app server and wait for it to finish,
then run docker compose stop cache. Do not use docker compose down -v; it
deletes the named volumes.
Back up the database (logical dump, while the stack is running):
# Reads MYSQL_* from your .env
docker compose exec db sh -c \
'exec mariadb-dump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines "$MYSQL_DATABASE"' \
> backup.sql
Restore into a running stack:
docker compose exec -T db sh -c \
'exec mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' \
< backup.sql
Drill the restore path without touching the live database:
node scripts/verify-backup-restore.mjs
The drill restores the dump into a temporary srn_restore_* database, compares
tables, row counts, and checksums, then drops only that temporary database.
Back up uploaded files:
docker run --rm -v standard-red-notes_uploads:/data -v "$PWD":/backup \
alpine tar czf /backup/uploads-backup.tar.gz -C /data .
Keep your .env backed up in a safe place too: if you lose
AUTH_SERVER_ENCRYPTION_SERVER_KEY or change the other secrets, existing users
can be locked out and encrypted server-side data becomes unreadable.
For database tuning and restore drills, see Operations hardening.
Troubleshooting
A service is unhealthy / keeps restarting.
Check docker compose ps and docker compose logs <service>. server has a
long start_period (90s) because it boots several processes under supervisord;
give it a minute on first run before assuming failure.
Realtime updates / the websocket gateway aren’t working after a reset. The SNS topics and SQS queues are created by the bootstrap script on every floci start (its state is in-memory). If the bootstrap didn’t run or something looks wrong, just restart the emulator:
docker compose restart floci
Confirm the whole topology exists (this is the emulator’s health check; it prints what is missing and exits non-zero):
docker compose exec floci bash /etc/localstack/init/ready.d/localstack_bootstrap.sh --check && echo topology ok
Check that the emulator is not restart-looping. A restart count that keeps
rising, or Hook script timed out in its log, means the bootstrap is
overrunning floci’s hook limit and every restart empties the queues:
docker inspect -f 'restarts=' "$(docker compose ps -q floci)"
docker compose logs --no-log-prefix floci 2>&1 | grep -E 'Hook script timed out|topology ready'
The -compat image also ships awslocal for manual inspection, for example
docker compose exec floci awslocal sqs list-queues.
Restarting the emulator drops whatever was in flight, and nothing re-drives
those pushes; connected clients pick the changes up on their next sync, which
happens as soon as a window regains focus or comes back online. Durable
SYNC_ITEMS commands are journaled in the outbox and are retried.
For item-sync sockets, also inspect the capability response:
curl -fsS https://notes.example.com/v1/sockets/sync/capabilities
An empty capabilities array means the socket transport failed closed. Exactly
three things can cause it: a missing WEB_SOCKET_CONNECTION_TOKEN_SECRET,
WEBSOCKET_SYNC_ENABLED=false, or unreachable Redis. An unbound gRPC proxy does
not empty this list; it only withholds the SYNC_ITEMS operation from an
otherwise working socket. HTTP sync continues while you repair the socket plane.
The gateway runs in-process inside the api-gateway, and supervisord keeps that
process’s output in a file rather than on the container’s stdout, so
docker compose logs server shows only supervisord bookkeeping. Read the
gateway’s own lines instead:
docker compose exec server tail -n 200 /var/lib/server/logs/api-gateway.log
docker compose exec server supervisorctl status
curl -fsS https://notes.example.com/healthcheck/readiness
The readiness payload carries a gateway.realtime block reporting whether the
gateway is attached, which push bridge it uses, whether that bridge and the SQS
consumer are running, whether the collaboration relay is healthy, and how many
pushes have been dispatched. It is informational: a Redis blip degrades realtime
without failing the healthcheck and restarting the container. The same object
appears in the admin panel’s sync diagnostics.
If you stop a worker by hand, stop it through supervisord
(supervisorctl stop <program>) rather than killing the process: every program
is now configured to stop and kill as a process group, so no orphaned Node
process is left polling the queue.
If you previously ran the LocalStack-based stack, a leftover
standard-red-notes_localstack-data volume can be deleted — floci doesn’t use
it: docker volume rm standard-red-notes_localstack-data.
“Port is already allocated” on startup.
Another process owns one of your host ports. Change the *_PORT values in
.env and run docker compose up -d again.
The stack exits immediately complaining a variable is “not set”.
Your .env is missing a required secret (e.g. AUTH_JWT_SECRET,
VALET_TOKEN_SECRET, or AUTH_SERVER_ENCRYPTION_SERVER_KEY). Restore the
known-good protected environment backup, or compare .env.example and fill in
real 64-char hex values. Normal setup reruns deliberately do not regenerate an
existing file.
Logged in but every request returns 401.
Cookie settings don’t match how you’re reaching the app. For plain-http
localhost use empty COOKIE_DOMAIN and COOKIE_SECURE=false; for an HTTPS
domain set COOKIE_DOMAIN to your domain and COOKIE_SECURE=true.
Database connection errors on first boot.
MariaDB takes a few seconds to initialize a brand-new mariadb-data volume. The
server waits on the db healthcheck, but if you changed MYSQL_* values after the
volume was already initialized, the credentials will not change inside the
database. Do not use docker compose down -v: that deletes the database.
If this started after setup was rerun or .env was overwritten, run one command
from the repository checkout on the affected Docker host:
npm run recover:database
The automatic path does not change MariaDB credentials. Setup rotates JWT, encryption, token, WebSocket, and integration secrets as well as database passwords, so repairing only MariaDB can silently strand encrypted data and sessions. Recovery instead:
- Stops writers and creates an independently verified cold archive of the exact MariaDB volume.
- Checks at most the 20 newest, strictly named
.env.bak.YYYYMMDDHHMMSSsiblings, newest first. It accepts only regular, operator-owned, protected files with bounded database/user identifiers, validates each through its own scoped Compose config, and requires both its root and app credentials to authenticate to its declared database. Secrets go to MariaDB over standard input; logs contain fingerprints, never values. - Protects the overwritten current
.envin the durable recovery directory, then atomically restores the complete authenticated prior environment. - Recreates the intended
db,server, andappstack, requires database, backend, and front-door readiness, and runs a logical backup/restore drill.
The printed recovery directory is outside the repository and OS temporary
folders: %LOCALAPPDATA%\StandardRedNotes\recovery on Windows,
~/Library/Application Support/StandardRedNotes/recovery on macOS, or
${XDG_STATE_HOME:-~/.local/state}/standard-red-notes/recovery on Linux. Backup
archives, checksums, and the displaced environment are operator-only.
Normal setup reruns validate and reuse an existing .env without rotating any
configured secret. The one migration exception is an older keyless environment:
setup adds ASSISTANT_SUBSCRIPTION_ENCRYPTION_KEY only after proving that no
encrypted pairing file exists. --force-overwrite / -ForceOverwrite is
required for an intentional rotation.
Exceptional and intentional rotation controls
Use `--previous-env-file` to select a specific protected backup, `--backup-dir` to select another protected durable location, and `--env-file`, `--compose-file`, or `--project-name` for an isolated deployment. These are advanced overrides; the normal recovery needs none of them. An intentional database-only rotation, where every non-database server secret was deliberately kept stable, uses `--rotate-database-credentials`. That mode backs up first, bounds every MariaDB account host it will touch, and repairs the root/app accounts. It never starts `--skip-grant-tables`. If no trusted prior credential authenticates, it changes no SQL and prints the manual maintenance boundary.For deployment security, continue with Operations Hardening. Before mixing original Standard Notes clients or moving an original vault, use Standard Notes Compatibility.