Skip to content

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

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 .env is written. Add --yes / -Yes to 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 legacy docker-compose, though the scripts fall back to it if present).
  • git to clone the repository.
  • The setup scripts need a secure random source. On macOS/Linux that’s openssl (or /dev/urandom via xxd/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
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:

  1. Download the executable for your platform, srn-home-server-migrations.zip, and SHA256SUMS.txt from the current published release, then verify both downloads against the checksums.
  2. Put the executable in a dedicated directory and create .env there. 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, and FILES_SERVER_URL.
  3. Extract srn-home-server-migrations.zip in that directory. Before first start, confirm that migrations/mysql/*.js is next to the executable. The binary finds that directory automatically; set SRN_MIGRATIONS_DIR to its absolute path only when you store it elsewhere. Database migrations run at startup, so take a database backup before upgrading.
  4. Start the binary from the directory containing .env:

    chmod +x srn-home-server-linux-x64
    ./srn-home-server-linux-x64
    

    On Windows PowerShell, run ./srn-home-server-windows-x64.exe instead.

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 on localhost or 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 sets COOKIE_DOMAIN, COOKIE_SECURE=true, and computes PUBLIC_FILES_SERVER_URL and 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_PORT in .env, then docker compose up -d again. 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 fills req.secure / req.protocol / req.ip from the X-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:

  • app joins the external network, in addition to the internal one. It still needs standard-red-notes to 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, n8n and docker-socket-proxy stay 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 a 127.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 as docker 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 (server container, port 3000)
  • /files/ -> the files service (server container, port 3104; the front door strips the /files prefix). Point PUBLIC_FILES_SERVER_URL at 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 /sockets paths need the WebSocket Upgrade headers - the front door already sends them, but YOUR proxy must pass Upgrade/Connection through too. Legacy realtime opens wss://<host>/sockets; the primary item sync transport opens wss://<host>/sockets/sync. (WEB_SOCKET_SERVER_URL is 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 neither
    

    These 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 (requires COOKIE_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 AUTH frame, 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_PROXY set correctly the server logs the client’s address (from X-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-app
  • ghcr.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 down keeps your data (it lives in named volumes). docker compose down -v deletes 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:

  1. Stops writers and creates an independently verified cold archive of the exact MariaDB volume.
  2. Checks at most the 20 newest, strictly named .env.bak.YYYYMMDDHHMMSS siblings, 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.
  3. Protects the overwritten current .env in the durable recovery directory, then atomically restores the complete authenticated prior environment.
  4. Recreates the intended db, server, and app stack, 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.