Self-hosting Standard Red Notes
Run your own Standard Red Notes server with Docker Compose. It is fully featured
by default. One setup script generates a correct .env (with securely-generated
secrets), then a single docker compose up brings the whole stack online.
Standard Red Notes is licensed under AGPL-3.0; it is a self-hosted fork of Standard Notes. See the license file for details.
- Deploy in 5 minutes
- What the stack contains
- Prerequisites
- Configuration (the
.envfile) - Standalone home-server release
- The srn-admin binary
- Choosing a domain and ports
- Running behind a reverse proxy
- Start, stop, and upgrade
- Deploy a verified GHCR image pair
- Where your data lives
- Backup and restore
- Troubleshooting
Deploy in 5 minutes
A friendly walkthrough for a first-time, single-machine install. By the end you will have the web app open in your browser.
1. Get the code
git clone https://github.com/supermarsx/standard-red-notes.git standard-red-notes
cd standard-red-notes
2. Run the setup script
The script checks that Docker is installed, asks a few questions (you can press
Enter through them for a localhost install), generates all the secrets, and
writes a complete .env file.
macOS / Linux
./scripts/setup.sh
Windows (PowerShell)
./scripts/setup.ps1
Tip: add
--up(bash) or-Up(PowerShell) to build and start the stack automatically once the.envis written. Add--yes/-Yesto accept all defaults without prompts.
3. Start the stack (if you didn’t use --up)
docker compose up -d --build
The first run downloads images and builds the app and server, which can take a
few minutes. Watch progress with docker compose logs -f.
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.
Prerequisites
- Docker with the Compose v2 plugin (
docker compose, not the legacydocker-compose, though the scripts fall back to it if present).- macOS / Windows: Docker Desktop
- Linux: Docker Engine + compose plugin
- git to clone the repository.
- The setup scripts need a secure random source. On macOS/Linux that’s
openssl(or/dev/urandomviaxxd/od), already present on virtually all systems. On Windows the PowerShell script uses the .NET cryptographic RNG, so no extra tooling is required.
You do not need Node.js, Yarn, or a database installed on the host - the containers provide all of that.
Configuration (the .env file)
Everything is driven by a single .env file in the repo root. The setup scripts
generate it for you; .env.example documents every key with placeholder values.
The real .env is git-ignored and must never be committed - it holds your
secrets.
The multi-container Compose file fails closed when either MariaDB password is
missing or empty. Before the database can start, a networkless one-shot check
also rejects the published example placeholders and reuse of the same value for
the application and root accounts. It reports variable names only, never secret
values. Run one of the setup scripts to generate independent random values. The
SQLite-based docker-compose.single.yml topology does not use MariaDB and is
therefore intentionally outside this gate; isolated server CI/test Compose files
retain their explicit test-only credentials.
Required variables
These must be present and non-empty or the stack will not start. The secrets are 64-character hex strings (32 random bytes); the encryption key in particular must be exactly 32 bytes of hex (the auth service validates this and refuses to boot otherwise).
| Variable | Purpose | How it’s generated |
|---|---|---|
AUTH_JWT_SECRET |
Signs/verifies cross-service JWTs across the server and the websocket-gateway. | openssl rand -hex 32 / .NET RNG |
AUTH_SERVER_ENCRYPTION_SERVER_KEY |
Server-side encryption key for sensitive auth data (for example MFA secrets). Purpose-specific keys derived from it also protect email relay credentials and queued message payloads. Must be exactly 32 bytes of hex. | openssl rand -hex 32 / .NET RNG |
VALET_TOKEN_SECRET |
Signs the short-lived valet tokens that authorize file uploads/downloads. | openssl rand -hex 32 / .NET RNG |
AUTH_SERVER_PSEUDO_KEY_PARAMS_KEY |
Seed for pseudo key-params returned on login for unknown accounts (prevents user enumeration). The container auto-generates one if unset, but it would then change on every restart - so it is pinned in .env. |
openssl rand -hex 32 / .NET RNG |
WEBSOCKET_GATEWAY_INTERNAL_SECRET |
Shared secret authenticating the server -> websocket-gateway internal calls. Must match on both. | openssl rand -hex 32 / .NET RNG |
WEB_SOCKET_CONNECTION_TOKEN_SECRET |
Signs the short-lived tokens browsers use to open a realtime websocket connection. | openssl rand -hex 32 / .NET RNG |
ASSISTANT_SUBSCRIPTION_ENCRYPTION_KEY |
Encrypts optional guided ChatGPT/Codex pairing credentials in the persistent gateway store. Supported setup and LXC installers generate it once and preserve it automatically; never rotate it while pairing data exists. | Automatic installer secret; manual RNG only for custom deployments |
MYSQL_PASSWORD |
Password for the application database user. | openssl rand -hex 32 / .NET RNG |
MYSQL_ROOT_PASSWORD |
MariaDB root password. | openssl rand -hex 32 / .NET RNG |
MYSQL_DATABASE |
Database name. | Your choice (default standard_notes_db) |
MYSQL_USER |
Application database user. | Your choice (default std_notes_user) |
DB_CONNECTION_LIMIT |
Per-process TypeORM connection pool ceiling. | 20 |
DB_MAX_CONNECTIONS |
MariaDB server connection ceiling. Keep above total service pools. | 150 |
DB_INNODB_BUFFER_POOL_SIZE |
MariaDB InnoDB cache size. Tune with DB_MEM_LIMIT. |
512M |
DB_MAX_ALLOWED_PACKET |
Maximum MariaDB packet for large encrypted payloads. | 128M |
APP_PORT |
The public app port. The nginx front door serves the web UI and proxies the API, files, and websocket same-origin. The optional workflows profile has a separate loopback-only development port. | Your choice (default 3001) |
PUBLIC_FILES_SERVER_URL |
Public URL clients use to reach the files service. It is the app origin + /files (the front door’s prefix-strip proxy). |
Computed by the script |
PUBLIC_URL |
Canonical app origin used to isolate external integration hostnames. | Computed from the app origin |
AUTH_SERVER_U2F_RELYING_PARTY_ID |
WebAuthn/hardware-key relying-party ID (your host). | Computed (host of your domain, or localhost) |
AUTH_SERVER_U2F_EXPECTED_ORIGIN |
Allowed WebAuthn origins. | Computed from your domain + app port |
Cookie / domain variables
| Variable | Purpose | Default |
|---|---|---|
COOKIE_DOMAIN |
Domain scope for the auth session cookie. Empty = host-only (works on localhost and bare hosts/IPs). Set to your domain for an HTTPS deployment. | empty |
COOKIE_SECURE |
Send the auth cookie only over HTTPS. Set true for real HTTPS deployments; false for plain-http localhost. |
false |
Optional variables
Left commented in .env; docker-compose.yml applies sensible defaults when
they’re unset. These include logging (LOG_LEVEL), the shared persisted admin
overlay (SERVER_SETTINGS_PATH, normally leave it inside the mounted
server-data volume), cookie tuning
(COOKIE_SAME_SITE, COOKIE_PARTITIONED), feature/entitlement mode
(STANDARD_RED_FEATURES_MODE, STANDARD_RED_ENTITLEMENT_MODE, defaulting to
fully-included), revision retention (REVISIONS_RETENTION_DAYS,
REVISIONS_MAX_COUNT_PER_ITEM), 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.
Email delivery topology and reliability.
The full multi-service Compose topology uses Redis for a bounded encrypted email
queue and supports up to 20 prioritized SMTP, SendGrid, Mailgun, and AWS SES
relay profiles. The queue worker performs retry, fallback, per-relay rate limiting,
dead-letter handling, and redacted attempt logging. The single/home in-memory
topology retains direct SMTP compatibility and returns 501 from the advanced
relay, queue, and log endpoints; its compatible test action remains available.
Redis Cluster deliberately uses the same SMTP fallback because its node-local AOF
durability cannot be established with WAITAOF.
The supplied Redis service uses AOF with CACHE_APPENDFSYNC=everysec. A producer
reports a new job as accepted only after Redis confirms a local AOF fsync through
WAITAOF; a retry with the same deterministic delivery identifier is
idempotent. This protects queue acceptance, not exactly-once provider delivery.
An accepted provider request followed by a lost response can still be delivered
again.
Queued published reminders are distinguishable from the auth service’s own email
reminders. On restart with REMINDER_DELIVERY_ENABLED=false, the worker settles
only published-reminder jobs before the provider boundary; account and auth
email-reminder delivery remain governed by their own switches.
Mutable queue limits do not select a new Redis namespace. Existing jobs retain their enqueue-time retry and expiry values and remain visible after a limit change. Producer readiness is still bound to the exact current policy, so a rolling deployment safely pauses new acceptance until auth and gateway agree.
| Variable | Default | Purpose |
|---|---|---|
CACHE_APPENDFSYNC |
everysec |
Redis background AOF policy; keep AOF enabled. |
EMAIL_QUEUE_MAX_JOB_BYTES |
26214400 |
Maximum encrypted payload size for one job (25 MiB). |
EMAIL_QUEUE_MAX_TOTAL_BYTES |
67108864 |
Total encrypted email-queue budget (64 MiB). |
EMAIL_QUEUE_MAX_ATTEMPTS |
5 |
Maximum delivery attempts before dead-lettering. |
EMAIL_QUEUE_RETENTION_MS |
2592000000 |
Ready/leased queue retention (30 days). |
EMAIL_QUEUE_DEAD_RETENTION_MS |
2592000000 |
Dead-letter retention (30 days). |
EMAIL_QUEUE_LEASE_MS |
120000 |
Worker lease, renewed by a heartbeat during provider work. |
EMAIL_DELIVERY_WORKER_INTERVAL_MS |
5000 |
Queue polling interval. |
EMAIL_DELIVERY_WORKER_BATCH_SIZE |
25 |
Maximum jobs processed per worker tick. |
EMAIL_DELIVERY_RETRY_BASE_MS |
30000 |
Initial retry delay. |
EMAIL_DELIVERY_RETRY_MAX_MS |
21600000 |
Maximum retry delay (6 hours). |
EMAIL_DELIVERY_LOG_RETENTION_MS |
2592000000 |
Redacted attempt-log retention (30 days). |
EMAIL_DELIVERY_LOG_MAX_ENTRIES |
10000 |
Maximum retained attempt-log records. |
EMAIL_ATTACHMENT_MAX_BYTE_SIZE is validated against
EMAIL_QUEUE_MAX_JOB_BYTES on the durable topology. Queue storage includes two
base64 expansions plus JSON/envelope overhead, so the raw attachment limit must
be materially smaller than the encrypted job limit; an unsafe configured pair
fails startup instead of producing deterministic backup-delivery failures.
The worker refuses readiness when Redis reports maxmemory at or below the
configured email-queue budget plus 64 MiB of safety headroom. Managed Redis may
deny CONFIG GET; in that case the worker continues with the strict queue byte
budget and emits a redacted warning. Do not rotate
AUTH_SERVER_ENCRYPTION_SERVER_KEY while relay settings or queued jobs must
remain readable: drain the queue and verify protected backups first. See
Administration for relay and queue operation.
Server-wide shared access key (optional obfuscation gate)
Two variables control it, both OFF by default (leaving them unset means zero behavior change for existing installs):
| Variable | Description | Default |
|---|---|---|
SHARED_SERVER_ACCESS_KEY |
The shared secret. Empty/unset disables the gate entirely. | empty (off) |
SHARED_SERVER_ACCESS_KEY_MODE |
all = every request must present the key (the /healthcheck path is always exempt so the container stays healthy); registration = only new account sign-ups require the key, leaving existing users (sync, sign-in) unaffected. |
all (only relevant once a key is set) |
When enabled, the api-gateway requires the key in the X-Shared-Server-Key
header and rejects non-matching requests with a generic 401. The comparison is
constant-time and the key is never logged.
On each client, enter the same key under Preferences -> Security -> Server Access Key. It is stored locally on that device (never synced) and attached to outgoing requests automatically. Because sign-in and registration also pass through the gate, configure the key on a device before signing in to a gated server.
Standalone home-server release
The published srn-home-server binary is an advanced, backend-only deployment
option for an existing MySQL and Redis environment. It does not include the web
app, database, cache, or reverse proxy. Use the
Docker quickstart when you need the complete stack.
For a standalone install:
- Download the executable for your platform,
srn-home-server-migrations.zip, andSHA256SUMS.txtfrom the current published release, then verify both downloads against the checksums. - Put the executable in a dedicated directory and create
.envthere. Start from the checked-in home-server environment sample, use the configuration reference for secrets, cookies, limits, and optional integrations, and add the direct service values required by the binary:DB_TYPE=mysql,DB_HOST,DB_PORT,DB_USERNAME,DB_PASSWORD,DB_DATABASE,CACHE_TYPE=redis,REDIS_URL,REDIS_HOST,REDIS_PORT,PORT,FILE_UPLOAD_PATH, andFILES_SERVER_URL. - Extract
srn-home-server-migrations.zipin that directory. Before first start, confirm thatmigrations/mysql/*.jsis next to the executable. The binary finds that directory automatically; setSRN_MIGRATIONS_DIRto its absolute path only when you store it elsewhere. Database migrations run at startup, so take a database backup before upgrading. -
Start the binary from the directory containing
.env:chmod +x srn-home-server-linux-x64 ./srn-home-server-linux-x64On Windows PowerShell, run
./srn-home-server-windows-x64.exeinstead.
After it starts, follow Choosing a domain and ports and Running behind a reverse proxy before exposing it publicly. The binary is only the backend, so point a separately deployed Standard Red Notes client at that public server URL.
The srn-admin binary
srn-admin is the administrative CLI for a Standard Red Notes deployment. Like
the tools above it is published as native binaries (Windows, macOS, and Linux)
with a SHA256SUMS.txt for verification. Unlike srn-home-server, it is not
a standalone offline tool: every command runs against a running SRN stack and
its database, so a reachable server and database must already be up.
In the Docker stack the supported invocation runs it inside the server
container, which already holds the database connection and server configuration:
docker compose exec server srn-admin help
Administrator access is a persisted server-side role, not an email-based environment setting. Register the intended account first, then bootstrap it locally:
docker compose exec server srn-admin roles grant <user> ADMIN_USER
You can download and checksum the binary like the other published tools to keep it on an operator machine, but it always targets a live deployment — it manages an existing stack rather than starting a server of its own.
Choosing a domain and ports
- Local-only (default). Leave the domain blank. The app is reachable at
http://localhost:3001. Cookies are host-only and non-secure, which works onlocalhostor any bare host/IP over plain HTTP. - Behind a domain over HTTPS. Give the setup script your domain (e.g.
notes.example.com) and answer “yes” to the HTTPS question. The script setsCOOKIE_DOMAIN,COOKIE_SECURE=true, and computesPUBLIC_FILES_SERVER_URLand the WebAuthn origins for you. Terminate TLS at a reverse proxy (nginx/Caddy/Traefik) in front of the published ports - Compose itself serves plain HTTP on the host ports. See Running behind a reverse proxy for the full proxy config (TRUST_PROXY, nginx/Traefik examples, websocket upgrade). - Port already in use? Re-run the setup script and choose a different host
port, or edit
APP_PORTin.env, thendocker compose up -dagain. It is the only public app port — the API gateway and files service are internal-only and reached through the app front door. The optional n8n development mapping remains host-loopback-only.
Running behind a reverse proxy
For any internet-facing deployment you should terminate TLS at a reverse proxy
(nginx, Traefik, or Caddy) in front of the stack. The stack has a single
front door: the app container’s nginx (host port APP_PORT, default 3001)
serves the web UI and already proxies the /v1 API, the /files/ endpoints,
and the realtime /sockets websocket same-origin to the internal-only server
container. Your proxy therefore needs exactly one upstream - the app port -
and one certificate.
Required environment
TLS is terminated at the proxy, so the containers receive plain HTTP. They must trust the proxy’s forwarded headers and the cookie must be marked Secure:
| Variable | Set to | Why |
|---|---|---|
TRUST_PROXY |
usually leave at the default | Makes the server honor X-Forwarded-Proto / X-Forwarded-For so req.secure and the client IP are correct. The default (loopback, linklocal, uniquelocal) trusts a proxy on loopback or a private/Docker network - which is exactly the case when the proxy is another container or runs on the same host. Set it to true, a hop count, or a CSV of proxy IPs/subnets only if your proxy reaches the stack from a public IP. |
ENFORCE_HTTPS_FROM_PROXY |
true |
Enables the validated outer-proxy contract: the app front door trusts one exact X-Forwarded-Proto: http or https value and preserves the proxy’s sanitized X-Forwarded-For chain. http redirects to PUBLIC_URL; https enables HSTS. Startup fails without a pathless HTTPS origin and loopback bind. Keep the app port private, and make the outer proxy overwrite both headers so clients cannot enter the trusted chain. |
APP_BIND_ADDRESS |
127.0.0.1 |
Keeps the published inner HTTP port reachable only by a reverse proxy on the same host. Trusted-proxy mode refuses to start with any other declared bind. If Traefik reaches app:8080 over a private Docker network, remove the Compose ports entry and leave this safety declaration at 127.0.0.1. Direct HTTP/LAN mode retains the compatible 0.0.0.0 default only while proxy trust is disabled. |
COOKIE_SECURE |
true |
The auth cookie is then only sent over HTTPS. Without this the browser may drop it on an HTTPS origin and every request 401s. |
COOKIE_DOMAIN |
your domain (e.g. notes.example.com) |
Scopes the auth cookie to your host. Leave empty only for bare-host/IP setups. |
PUBLIC_FILES_SERVER_URL |
https://notes.example.com (or a files subpath/host) |
The public URL clients use to reach the files service - must be the HTTPS URL the browser can reach, routed by the proxy. |
PUBLIC_URL |
https://notes.example.com |
Canonical app origin used for external-link hostname isolation; do not derive it from forwarded Host. |
AUTH_SERVER_U2F_EXPECTED_ORIGIN |
https://notes.example.com |
WebAuthn/hardware-key origin must match the HTTPS origin. |
AUTH_SERVER_U2F_RELYING_PARTY_ID |
notes.example.com |
WebAuthn relying-party id (the host, no scheme/port). |
Why
TRUST_PROXY? Express only fillsreq.secure/req.protocol/req.ipfrom theX-Forwarded-*headers when “trust proxy” is configured. Without it, the server thinks every request is plain HTTP from the proxy’s address.
Standard Red Notes external host
Route the whole hostname to the app front door (app container, host port
APP_PORT/3001, container port 8080). The front door’s nginx
(app/docker/nginx.conf) then fans out by path on the internal Docker network:
/-> the web UI (static bundle)/v1,/v2,/auth,/subscription,/healthcheck-> the API gateway (servercontainer, port 3000)/files/-> the files service (servercontainer, port 3104; the front door strips the/filesprefix). PointPUBLIC_FILES_SERVER_URLat the public app origin +/files, e.g.https://notes.example.com/files./sockets-> the websocket gateway (in-process inside the api-gateway on port 3000). The/socketspaths need the WebSocket Upgrade headers - the front door already sends them, but YOUR proxy must passUpgrade/Connectionthrough too. Legacy realtime openswss://<host>/sockets; the primary item sync transport openswss://<host>/sockets/sync. (WEB_SOCKET_SERVER_URLis container-internal - the api-gateway minting tokens against itself - and should stay at its default.) The web client does not hard-code an API origin - it defaults to its own origin and follows the gateway’s advertised files URL - so single-origin routing works out of the box. n8n is deliberately not part of this path router.
Worker WebSocket sync and HTTP fallback
Durable item sync prefers a dedicated client Worker connected to
/sockets/sync. The client first reads
GET /v1/sockets/sync/capabilities, then uses its normal authenticated session
to request a one-use ticket from POST /v1/sockets/sync/ticket. Neither the
bearer credential nor ticket is placed in the URL: the WebSocket URL must have
no query string, and the ticket is sent in the first protocol AUTH frame.
Normal HTTP sync remains the same-command fallback for unavailable sockets,
disconnects, and the operator kill switch.
The transport defaults on. Set exact WEBSOCKET_SYNC_ENABLED=false to disable
it; any other non-empty value is a startup error. An empty
WEBSOCKET_SYNC_ALLOWED_ORIGINS derives one exact HTTP(S) origin from
PUBLIC_URL. Explicit entries are comma-separated exact origins; wildcard,
null, file:, credential-bearing, path, query, and fragment values are
rejected. Redis backs one-use tickets, command leases, and the fleet-wide
per-user socket budget, so every production replica observes the same state.
If the exact origin, durable sync backend, connection secret, or Redis state is
missing, capability negotiation stays closed and the client uses HTTP.
Relevant tuning variables are WEBSOCKET_SYNC_MAX_SOCKETS_PER_USER (default
4), WEBSOCKET_SYNC_REDIS_KEY_PREFIX (default srn:ws-sync:v1),
WEBSOCKET_SYNC_REDIS_OPERATION_TIMEOUT_MS (default 1500),
WEBSOCKET_SYNC_COMMAND_LEASE_TTL_MS (default 30000), and
WEBSOCKET_SYNC_SOCKET_LEASE_TTL_MS (default 75000). The single-container
and LXC topologies do not bundle Redis; provide REDIS_HOST/REDIS_PORT to
enable socket sync there, or leave them unset for HTTP fallback. 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.
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 the worker
WebSocket capability closed while HTTP sync remains 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.
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 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.
nginx example
TLS terminates at nginx; 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;
# Realtime websocket - WebSocket Upgrade pass-through is required.
location /sockets {
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
}
# 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; }
}
Set in .env: ENFORCE_HTTPS_FROM_PROXY=true,
APP_BIND_ADDRESS=127.0.0.1, COOKIE_SECURE=true,
COOKIE_DOMAIN=notes.example.com,
PUBLIC_URL=https://notes.example.com,
PUBLIC_FILES_SERVER_URL=https://notes.example.com/files, and the WebAuthn
origins. (WEB_SOCKET_SERVER_URL is container-internal and should stay at its
default.) TRUST_PROXY can stay at its default when nginx runs on the same host
(loopback) or on the Docker network; set it to nginx’s address otherwise.
Traefik example
Traefik (v2/v3) with the Docker provider. Only the app service needs to join
Traefik’s network and be labeled - it is the single front door and proxies the
API, files, and websocket internally. Traefik forwards X-Forwarded-* and
proxies the WebSocket Upgrade/Connection headers automatically, so no
special websocket config is needed beyond the one router.
# In docker-compose.yml (see the commented examples there):
services:
app:
networks: [standard-red-notes, proxy]
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.srn-web.rule=Host(`notes.example.com`)"
- "traefik.http.routers.srn-web.entrypoints=websecure"
- "traefik.http.routers.srn-web.tls.certresolver=le"
- "traefik.http.routers.srn-web.middlewares=srn-hsts"
- "traefik.http.services.srn-web.loadbalancer.server.port=8080"
# The public HTTP router never serves the app; it only redirects to TLS.
- "traefik.http.routers.srn-web-http.rule=Host(`notes.example.com`)"
- "traefik.http.routers.srn-web-http.entrypoints=web"
- "traefik.http.routers.srn-web-http.middlewares=srn-https-redirect"
- "traefik.http.routers.srn-web-http.service=srn-web"
- "traefik.http.middlewares.srn-https-redirect.redirectscheme.scheme=https"
- "traefik.http.middlewares.srn-https-redirect.redirectscheme.permanent=true"
- "traefik.http.middlewares.srn-hsts.headers.stsseconds=31536000"
- "traefik.http.middlewares.srn-hsts.headers.stsincludesubdomains=false"
- "traefik.http.middlewares.srn-hsts.headers.stspreload=false"
networks:
proxy:
external: true
Because the proxy and the stack share the proxy Docker network (a private
subnet), the default TRUST_PROXY already trusts Traefik - no override needed.
Use the same .env values as the nginx example. Keep Traefik’s forwarded-header
trust in its safe default or limit forwardedHeaders.trustedIPs to known
upstream proxies; never enable insecure forwarding. Confirm the web entrypoint
is actually exposed on port 80 and websecure on 443 in Traefik’s static config.
Separate n8n hostname
If you enable the workflows profile, give n8n a second router such as
automation.example.net -> n8n:5678. Do not add an n8n path to the Standard Red
Notes host. Configure n8n’s own TLS-facing URL, trusted proxy hops, secure
cookie, owner account, and project/user policy. The Standard Red Notes
WORKFLOWS_ENABLED gates reveal only the external link.
See Workflows with n8n for complete nginx/Traefik topology, environment values, URL rejection rules, MCP connection, and revocation.
Manual verification
-
Public HTTP never serves application content. A request to the outer proxy must preserve the method with a canonical HTTPS redirect:
curl -si -X POST http://notes.example.com/v1/login | sed -n '1p;/^[Ll]ocation:/p' # expect: HTTP/... 308 # expect: Location: https://notes.example.com/v1/login -
HTTPS emits the bounded HSTS policy. Check both the SPA and the dedicated sandbox response; neither policy opts all subdomains into HSTS or preload:
curl -sSI https://notes.example.com/ | grep -i '^strict-transport-security:' curl -sSI https://notes.example.com/sandbox.html | grep -i '^strict-transport-security:' # expect: Strict-Transport-Security: max-age=31536000 -
Inner transport gate fails safely. From the Docker host only (the app port must not be public), verify local health remains HTTP, exact trusted values work, and a proxy chain/mixed value is not trusted:
curl -fsS http://127.0.0.1:3001/health curl -sSI -H 'X-Forwarded-Proto: http' http://127.0.0.1:3001/ | sed -n '1p;/^[Ll]ocation:/p' curl -sSI -H 'X-Forwarded-Proto: https' http://127.0.0.1:3001/ | grep -i '^strict-transport-security:' curl -sSI -H 'X-Forwarded-Proto: https,http' http://127.0.0.1:3001/ | grep -Ei '^(HTTP/|Location:|Strict-Transport-Security:)' # exact http => 308 to PUBLIC_URL; exact https => HSTS; mixed => ordinary 200 with neitherThese inner checks apply only with
ENFORCE_HTTPS_FROM_PROXY=true. They do not replace the public HTTP redirect/HSTS checks above. -
Secure cookie behind the proxy. Against the API gateway, send a forwarded HTTPS header and confirm the auth cookie comes back
Secure(requiresCOOKIE_SECURE=true):curl -sik -H 'X-Forwarded-Proto: https' \ -H 'Content-Type: application/json' \ -d '{"email":"you@example.com","password":"...","api":"20200115"}' \ https://notes.example.com/v1/login | grep -i set-cookie # expect: Set-Cookie: access_token_...; HttpOnly; Secure; ... -
Websocket upgrade through the proxy. Confirm the proxy upgrades the sync connection (HTTP 101). This transport-only probe deliberately omits the first
AUTHframe, so the server closes it after the handshake:curl -fsS https://notes.example.com/v1/sockets/sync/capabilities # expect capabilities[0]: id=ws-sync, version=1, endpoint=/sockets/sync curl -sik --http1.1 --max-time 2 \ -H 'Origin: https://notes.example.com' \ -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ -H 'Sec-WebSocket-Version: 13' \ -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ https://notes.example.com/sockets/sync # expect: HTTP/1.1 101 Switching Protocols -
Real client IP in logs. With
TRUST_PROXYset correctly the server logs the client’s address (fromX-Forwarded-For), not the proxy’s.
Start, stop, and upgrade
# Build (if needed) and start everything in the background
docker compose up -d --build
# Follow logs
docker compose logs -f
docker compose logs -f server # one service
# Check status / health
docker compose ps
# Stop (containers removed, named volumes / data kept)
docker compose down
# Upgrade: pull newer base images and rebuild the app/server images
git pull
docker compose pull # refresh mariadb / redis / floci images
docker compose up -d --build # rebuild app/server/gateway and restart
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 a verified GHCR image pair
Successful trusted-main CI publishes the exact app and server images that passed the disposable-stack and hardening gates. They are a coordinated pair:
ghcr.io/supermarsx/standard-red-notes-appghcr.io/supermarsx/standard-red-notes-server
Both use the same unique, non-floating, retry-stable tag
sha-<40-character-commit>-run-<run-id>.<producer-attempt>. No main or
latest tag is published. The initial container stream is linux/amd64 only;
do not deploy it as a native arm64 image. Treat the pair as consumable only
after the publish-containers job succeeds and its summary lists both
digest-qualified references. A failed job can leave one retry-stable tag in
GHCR; that partial tag is not a release or a deployment input.
GHCR packages are private when first created unless the repository owner makes
them public. Public packages can be pulled anonymously. For a private package,
authenticate with a GitHub token that has only the required read:packages
access:
printf '%s' "$GHCR_TOKEN" | docker login ghcr.io \
--username "$GITHUB_USER" --password-stdin
unset GHCR_TOKEN
Start from a configured checkout whose database and application secrets are already present. Select one tag from one successful workflow summary for both images, pull the complete pair, prove its operating system and architecture, take the required data snapshot, and then start without invoking either Dockerfile:
export SRN_IMAGE_TAG='sha-<40-character-commit>-run-<run-id>.<producer-attempt>'
export APP_IMAGE="ghcr.io/supermarsx/standard-red-notes-app:${SRN_IMAGE_TAG}"
export SERVER_IMAGE="ghcr.io/supermarsx/standard-red-notes-server:${SRN_IMAGE_TAG}"
docker compose pull app server
test "$(docker image inspect --format '/' "$APP_IMAGE")" = linux/amd64
test "$(docker image inspect --format '/' "$SERVER_IMAGE")" = linux/amd64
# Back up MariaDB, uploads, redis-data, server-data, and the protected environment first.
docker compose up -d --no-build --pull never --wait --wait-timeout 900
docker compose ps
For production, prefer the two digest-qualified references written to the successful workflow summary. The app and server manifest digests are different; copy both from the same coordinated run:
export APP_IMAGE='ghcr.io/supermarsx/standard-red-notes-app@sha256:<app-manifest-digest>'
export SERVER_IMAGE='ghcr.io/supermarsx/standard-red-notes-server@sha256:<server-manifest-digest>'
docker compose pull app server
docker compose up -d --no-build --pull never --wait --wait-timeout 900
The successful CI job verifies each provenance bundle from GHCR. An operator can repeat that check for both selected digest references (while authenticated to a private package):
gh attestation verify "oci://${APP_IMAGE}" \
--bundle-from-oci \
--deny-self-hosted-runners \
--repo supermarsx/standard-red-notes \
--source-digest '<40-character-source-commit>' \
--source-ref refs/heads/main \
--signer-workflow supermarsx/standard-red-notes/.github/workflows/ci.yml
gh attestation verify "oci://${SERVER_IMAGE}" \
--bundle-from-oci \
--deny-self-hosted-runners \
--repo supermarsx/standard-red-notes \
--source-digest '<40-character-source-commit>' \
--source-ref refs/heads/main \
--signer-workflow supermarsx/standard-red-notes/.github/workflows/ci.yml
Verify the exposed /.well-known/srn-deployment.json after startup. Its
revision and version must agree with the selected pair. Keep the previous app
and server digest references until the observation window passes.
Rollback uses those two previous digest references with the same pull and
up --no-build --pull never commands. Do not run docker compose down -v:
application rollback must preserve the current volumes, while a database/files
rollback is a separate recovery decision based on schema compatibility and the
pre-upgrade snapshot.
docker compose downkeeps your data (it lives in named volumes).docker compose down -vdeletes the volumes and all your data - only use it for a clean reset.
Where your data lives
Data is stored in Docker named volumes, so it survives docker compose down
and container rebuilds:
| Volume | Holds | Notes |
|---|---|---|
mysql-data |
Legacy MySQL 8.4 database from older Compose releases. | Migration source only; never mount it into MariaDB. |
mariadb-data |
The MariaDB database - all accounts, notes, and revisions. | The one to back up. |
redis-data |
Redis AOF for cache/sessions/pub-sub and encrypted queued mail. | Back up to preserve pending/dead email jobs and logs. |
uploads |
Uploaded file attachments stored by the files service. | Back this up alongside the DB if you use file uploads. |
server-data |
Gateway admin settings, encrypted relay profiles, and subscription pairings. | Back up with the matching encryption keys. |
server-logs |
Server process logs. | Disposable. |
mcp-data |
MCP bridge local state (only with the mcp profile). |
Disposable. |
n8n-data |
n8n database/config/credentials (only with workflows). |
Back up with the matching N8N_ENCRYPTION_KEY. |
List them with docker volume ls | grep standard-red-notes.
Upgrade from the legacy MySQL volume
Older Compose releases ran mysql:8.4 on mysql-data. Current releases run
MariaDB on a separate mariadb-data volume. The db-volume-preflight one-shot
service checks both datastores before db starts. If it finds an initialized
legacy database and an uninitialized MariaDB database, startup fails instead of
silently presenting an empty installation.
MySQL and MariaDB datadirs are not interchangeable. Do not rename the volume,
copy its files into mariadb-data, or point the MariaDB service at
mysql-data. Migrate with SQL while the old MySQL service is still available:
set -eu
# 1. On the old release, stop every application writer but leave MySQL running.
docker compose stop app server
# 2. Export the application database with routines, events, and triggers.
docker compose exec -T db sh -ec \
'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --events --triggers --hex-blob --set-gtid-purged=OFF --column-statistics=0 "$MYSQL_DATABASE"' \
> legacy-mysql.sql
test -s legacy-mysql.sql
# 3. Stop the old stack without deleting either named volume, then update.
docker compose down
git pull
# 4. Initialize only the new MariaDB volume. --no-deps intentionally bypasses
# the legacy-volume gate for this isolated migration container.
docker compose run --detach --name srn-mariadb-migration --no-deps db
health=starting
for attempt in $(seq 1 90); do
health=$(docker inspect --format '' srn-mariadb-migration)
[ "$health" = healthy ] && break
[ "$(docker inspect --format '' srn-mariadb-migration)" = true ] || {
docker logs srn-mariadb-migration
exit 1
}
sleep 2
done
[ "$health" = healthy ] || {
docker logs srn-mariadb-migration
exit 1
}
# 5. Restore the logical dump, then stop and remove only the one-off container.
docker exec -i srn-mariadb-migration sh -ec \
'exec mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' \
< legacy-mysql.sql
docker stop --time 60 srn-mariadb-migration
docker rm srn-mariadb-migration
# 6. The normal gate now sees an initialized mariadb-data and permits startup.
docker compose up -d --build
docker compose ps
If the preflight already blocks and no logical dump exists, do not remove or
rename either volume. Restore the pre-upgrade Compose manifest in a separate
working directory and use its MySQL 8.4 service against mysql-data only long
enough to take the dump, or restore a previously tested logical backup. For a
custom Compose project name, inspect the exact volume names first with
docker volume inspect; the default names are
standard-red-notes_mysql-data and standard-red-notes_mariadb-data.
Before reopening writes, verify representative accounts, note/revision counts,
attachments, authentication, sync, and a fresh database backup. Retain
legacy-mysql.sql and the untouched mysql-data volume until those checks and
a restore drill pass. Do not run docker compose down -v during migration.
Backup and restore
The critical data is the MariaDB volume, uploads if you store attachments,
redis-data while queued/dead email or its redacted delivery history matters,
and server-data when you use persisted administrator settings, encrypted relay
profiles, or encrypted ChatGPT/Codex pairing. Snapshot redis-data and
server-data with the matching AUTH_SERVER_ENCRYPTION_SERVER_KEY; restoring
encrypted queue/settings state with a different key makes it unreadable.
For a consistent infrastructure snapshot, stop application writers first,
allow the server’s graceful stop to finish, then stop Redis before copying or
snapshotting its volume. The supplied server shutdown budget is 75 seconds:
provider calls stop at 30 seconds, supervisord gives the gateway 60 seconds to
durably settle its current claim, and Compose retains 15 seconds for supervisor
teardown. Run docker compose stop -t 75 app server and wait for it to finish,
then run docker compose stop cache. Do not use docker compose down -v; it
deletes the named volumes.
Back up the database (logical dump, while the stack is running):
# Reads MYSQL_* from your .env
docker compose exec db sh -c \
'exec mariadb-dump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines "$MYSQL_DATABASE"' \
> backup.sql
Restore into a running stack:
docker compose exec -T db sh -c \
'exec mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' \
< backup.sql
Drill the restore path without touching the live database:
node scripts/verify-backup-restore.mjs
The drill restores the dump into a temporary srn_restore_* database, compares
tables, row counts, and checksums, then drops only that temporary database.
Back up uploaded files:
docker run --rm -v standard-red-notes_uploads:/data -v "$PWD":/backup \
alpine tar czf /backup/uploads-backup.tar.gz -C /data .
Keep your .env backed up in a safe place too: if you lose
AUTH_SERVER_ENCRYPTION_SERVER_KEY or change the other secrets, existing users
can be locked out and encrypted server-side data becomes unreadable.
For database tuning and restore drills, see Operations hardening.
Troubleshooting
A service is unhealthy / keeps restarting.
Check docker compose ps and docker compose logs <service>. server has a
long start_period (90s) because it boots several processes under supervisord;
give it a minute on first run before assuming failure.
Realtime updates / the websocket gateway aren’t working after a reset. The SNS topics and SQS queues are created by the bootstrap script on every floci start (its state is in-memory). If the bootstrap didn’t run or something looks wrong, just restart the emulator:
docker compose restart floci
Confirm the queues exist (the -compat image ships awslocal):
docker compose exec floci awslocal sqs list-queues
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 server intentionally failed closed.
Verify PUBLIC_URL (or the exact allowed-origin list), the two WebSocket
secrets, the durable syncing service, and Redis reachability. HTTP sync should
continue while you repair the socket plane.
If you previously ran the LocalStack-based stack, a leftover
standard-red-notes_localstack-data volume can be deleted — floci doesn’t use
it: docker volume rm standard-red-notes_localstack-data.
“Port is already allocated” on startup.
Another process owns one of your host ports. Change the *_PORT values in
.env and run docker compose up -d again.
The stack exits immediately complaining a variable is “not set”.
Your .env is missing a required secret (e.g. AUTH_JWT_SECRET,
VALET_TOKEN_SECRET, or AUTH_SERVER_ENCRYPTION_SERVER_KEY). Restore the
known-good protected environment backup, or compare .env.example and fill in
real 64-char hex values. Normal setup reruns deliberately do not regenerate an
existing file.
Logged in but every request returns 401.
Cookie settings don’t match how you’re reaching the app. For plain-http
localhost use empty COOKIE_DOMAIN and COOKIE_SECURE=false; for an HTTPS
domain set COOKIE_DOMAIN to your domain and COOKIE_SECURE=true.
Database connection errors on first boot.
MariaDB takes a few seconds to initialize a brand-new mariadb-data volume. The
server waits on the db healthcheck, but if you changed MYSQL_* values after the
volume was already initialized, the credentials will not change inside the
database. Do not use docker compose down -v: that deletes the database.
If this started after setup was rerun or .env was overwritten, run one command
from the repository checkout on the affected Docker host:
npm run recover:database
The automatic path does not change MariaDB credentials. Setup rotates JWT, encryption, token, WebSocket, and integration secrets as well as database passwords, so repairing only MariaDB can silently strand encrypted data and sessions. Recovery instead:
- Stops writers and creates an independently verified cold archive of the exact MariaDB volume.
- Checks at most the 20 newest, strictly named
.env.bak.YYYYMMDDHHMMSSsiblings, newest first. It accepts only regular, operator-owned, protected files with bounded database/user identifiers, validates each through its own scoped Compose config, and requires both its root and app credentials to authenticate to its declared database. Secrets go to MariaDB over standard input; logs contain fingerprints, never values. - Protects the overwritten current
.envin the durable recovery directory, then atomically restores the complete authenticated prior environment. - Recreates the intended
db,server, andappstack, requires database, backend, and front-door readiness, and runs a logical backup/restore drill.
The printed recovery directory is outside the repository and OS temporary
folders: %LOCALAPPDATA%\StandardRedNotes\recovery on Windows,
~/Library/Application Support/StandardRedNotes/recovery on macOS, or
${XDG_STATE_HOME:-~/.local/state}/standard-red-notes/recovery on Linux. Backup
archives, checksums, and the displaced environment are operator-only.
Normal setup reruns validate and reuse an existing .env without rotating any
configured secret. The one migration exception is an older keyless environment:
setup adds ASSISTANT_SUBSCRIPTION_ENCRYPTION_KEY only after proving that no
encrypted pairing file exists. --force-overwrite / -ForceOverwrite is
required for an intentional rotation.
Exceptional and intentional rotation controls
Use `--previous-env-file` to select a specific protected backup, `--backup-dir` to select another protected durable location, and `--env-file`, `--compose-file`, or `--project-name` for an isolated deployment. These are advanced overrides; the normal recovery needs none of them. An intentional database-only rotation, where every non-database server secret was deliberately kept stable, uses `--rotate-database-credentials`. That mode backs up first, bounds every MariaDB account host it will touch, and repairs the root/app accounts. It never starts `--skip-grant-tables`. If no trusted prior credential authenticates, it changes no SQL and prints the manual maintenance boundary.For deployment security, continue with Operations Hardening. Before mixing original Standard Notes clients or moving an original vault, use Standard Notes Compatibility.