Operations Hardening
This page is the operator-facing checklist for the production Docker stack. It covers the parts that protect availability and blast radius: MariaDB durability, connection limits, Redis/cache ceilings, request limits, signup controls, and container hardening.
Database Resilience
The multi-container stack stores primary state in the mariadb-data named
volume. The database is internal-only: it publishes no host port and is reachable
only on the Compose network.
The MariaDB service now starts with explicit safety defaults:
| Control | Default | Why it exists |
|---|---|---|
DB_MAX_CONNECTIONS |
150 |
Keeps a runaway client pool from exhausting the server. |
DB_CONNECTION_LIMIT |
20 per Node process |
Bounds each TypeORM pool. Keep the DB max above the sum of active pools. |
DB_MAX_QUERY_EXECUTION_TIME |
45000 ms |
Logs slow queries for diagnosis. |
DB_INNODB_BUFFER_POOL_SIZE |
512M |
Gives InnoDB a bounded cache within the default DB_MEM_LIMIT=1g. |
DB_MAX_ALLOWED_PACKET |
128M |
Allows large encrypted payloads without leaving the packet size unbounded. |
DB_INNODB_FLUSH_LOG_AT_TRX_COMMIT |
1 |
Favors crash durability by flushing transaction logs at commit. |
local_infile |
disabled | Removes an unnecessary file-loading surface. |
The db healthcheck runs a real SELECT 1 against the configured application
database instead of only checking that the MariaDB process is alive.
Connection Budget
The server container runs several Node services under supervisord. Each
MySQL-backed package uses DB_CONNECTION_LIMIT; the current default is 20.
For a single server container, DB_MAX_CONNECTIONS=150 leaves headroom for auth,
syncing, revisions, websocket/legacy packages, migration/admin tasks, and a
short overlap during restarts.
If you scale out more server containers, increase DB_MAX_CONNECTIONS and
DB_MEM_LIMIT together. A practical starting formula is:
DB_MAX_CONNECTIONS >= (server replicas * DB-using processes per replica * DB_CONNECTION_LIMIT) + admin headroom
Do not raise the per-process pool first. Raise it only when live metrics show connection wait time is the bottleneck.
Backup And Restore
Take logical database backups while the stack is running:
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
Run the non-destructive restore drill before trusting a backup procedure:
node scripts/verify-backup-restore.mjs
The drill dumps the live MariaDB database, restores it into a temporary
srn_restore_* database, compares the restored table list, row counts, and
table checksums, then drops only the temporary database. Run it when the system
is idle so live writes do not change the source database while the comparison is
in progress. Use --keep-backup or --output backup.sql when you want to keep
the generated dump for inspection.
flowchart LR
Live[("Live MariaDB")]
Dump["Logical SQL dump"]
Restore[("Temporary srn_restore database")]
Compare{"Tables, row counts,<br/>and checksums match?"}
Pass["Report success"]
Fail["Report mismatch"]
Cleanup["Drop temporary database<br/>and remove temporary dump"]
Live -->|"mariadb-dump"| Dump
Dump -->|"restore"| Restore
Live --> Compare
Restore --> Compare
Compare -->|yes| Pass
Compare -->|no| Fail
Pass --> Cleanup
Fail --> Cleanup
Back up the uploads volume with the database if you use file attachments.
Keep .env backed up separately. Losing server secrets can invalidate sessions
or make server-side encrypted settings unreadable.
Restart Safety
The admin UI can restart Redis and MariaDB only when the optional ops profile
is running and the server has SERVICE_CONTROL_DOCKER_ENABLED=true. The server
never receives the raw Docker socket. It talks to docker-socket-proxy, which is
configured to permit only container restart endpoints.
Database restarts are intentionally visible operations. Use them for controlled maintenance, not routine remediation. Back up before risky changes such as database image upgrades, memory reductions, or major version moves.
Operation Limits
The stack has several independent limits. They are intentionally layered because each protects a different failure mode.
| Surface | Default | Control |
|---|---|---|
| Gateway JSON/body payload | 50 MB |
HTTP_REQUEST_PAYLOAD_LIMIT_MEGABYTES |
| File upload chunk | 100,000,000 bytes |
MAX_CHUNK_BYTES |
| File download request deadline | 30 seconds |
FILE_DOWNLOAD_DEADLINE_MS |
| Absolute attachment size | 5 GiB |
MAX_ATTACHMENT_BYTE_SIZE |
| Login/recovery attempts | 10/min/IP |
RATE_LIMIT_LOGIN_MAX, RATE_LIMIT_WINDOW_SECONDS |
| Registration and magic-link-sensitive calls | 5/min/IP |
RATE_LIMIT_REGISTRATION_MAX |
| Authenticated expensive endpoints | off | RATE_LIMIT_USER_MAX, RATE_LIMIT_USER_WINDOW_SECONDS |
| AI daily request cap | off | ASSISTANT_DAILY_REQUEST_LIMIT |
| AI token windows | off | ASSISTANT_5H_TOKEN_LIMIT, ASSISTANT_WEEKLY_TOKEN_LIMIT |
| Server-side OCR | off | OCR_SERVER_ENABLED, OCR_SERVER_MAX_PAGES, OCR_SERVER_MAX_IMAGE_BYTES |
| Revision retention | unlimited | REVISIONS_RETENTION_DAYS, REVISIONS_MAX_COUNT_PER_ITEM |
Unauthenticated rate limits are Redis-backed and fail open if Redis is down, so a cache outage cannot lock legitimate users out of their notes. The tradeoff is that IP blocks and rate limits temporarily degrade during a Redis outage.
For public instances, consider setting:
REGISTRATION_INVITE_ONLY=true
REGISTRATION_APPROVAL_REQUIRED=true
REGISTRATION_SIGNUPS_PER_IP_MAX=5
REGISTRATION_SIGNUPS_PER_IP_WINDOW_HOURS=24
REGISTRATION_MAX_TOTAL_ACCOUNTS=<your planned capacity>
For expensive authenticated operations, consider:
RATE_LIMIT_USER_MAX=30
RATE_LIMIT_USER_WINDOW_SECONDS=60
ASSISTANT_DAILY_REQUEST_LIMIT=100
ASSISTANT_5H_TOKEN_LIMIT=200000
ASSISTANT_WEEKLY_TOKEN_LIMIT=1000000
Tune these to your hardware and user count. A small personal instance can keep most authenticated limits off; a public instance should set them deliberately.
Redis Limits
Redis is used for cache, rate-limit counters, transient operation state, and event plumbing. It is persisted with append-only files, but it is not the source of truth for notes.
The Compose service now sets:
CACHE_MEM_LIMIT=256m
CACHE_MAXMEMORY=192mb
CACHE_MAXMEMORY_POLICY=noeviction
CACHE_MAXMEMORY is below the container memory limit so Redis returns controlled
write errors instead of being OOM-killed. noeviction avoids silently discarding
keys that may represent rate-limit or transient operation state. If you operate
a high-churn instance and accept eviction semantics, choose a policy explicitly.
WebSocket Sync Safety
Worker WebSocket sync is the preferred transport, with the durable HTTP command path as fallback. It is advertised only when the exact browser origin, durable sync backend, and fleet-shared Redis ticket/lease/socket stores are all ready. This keeps a partial rollout from accepting work that another replica cannot resume safely.
| Variable | Default | Operational effect |
|---|---|---|
WEBSOCKET_SYNC_ENABLED |
true |
Exact false is the emergency kill switch. Any other non-empty boolean spelling fails startup. |
WEBSOCKET_SYNC_ALLOWED_ORIGINS |
derived from PUBLIC_URL |
Comma-separated exact origins; wildcard, null, file:, URL paths, credentials, queries, and fragments are rejected. |
WEBSOCKET_SYNC_MAX_SOCKETS_PER_USER |
4 |
Fleet-wide live worker-socket budget for one user. |
WEBSOCKET_SYNC_REDIS_KEY_PREFIX |
srn:ws-sync:v1 |
Namespace for tickets, command leases, and socket leases. Use a distinct value when multiple installations share Redis. |
WEBSOCKET_SYNC_REDIS_OPERATION_TIMEOUT_MS |
1500 |
Fails capability/commands closed when Redis does not answer promptly. |
WEBSOCKET_SYNC_COMMAND_LEASE_TTL_MS |
30000 |
Bounds ownership of one durable command across reconnects/replicas. |
WEBSOCKET_SYNC_SOCKET_LEASE_TTL_MS |
75000 |
Bounds stale fleet-wide socket reservations after a dead process. |
SYNCING_SERVER_INTERNAL_GRPC_AUTH_SECRET |
generated by setup | Separate HMAC key for standalone API-gateway to syncing-server durable command/status metadata. Missing closes socket capability; never reuse AUTH_JWT_SECRET. HomeServer direct calls do not use it. |
Single-container and LXC deployments accept only REDIS_HOST/REDIS_PORT for
this socket plane; that connector has no Redis authentication or TLS support.
Use it only on the same private trusted network. Otherwise leave Redis unset and
retain HTTP fallback.
On a multi-container upgrade from a keyless release, a normal setup rerun adds
only this missing key using one atomic, permission-preserving .env migration
and timestamped backup. It refuses malformed or duplicate assignments and does
not rotate an existing valid key.
The browser first checks the unauthenticated capability document, then mints a
one-use ticket over its authenticated HTTP session. The upgrade URL contains no
credential or ticket; the ticket arrives in the first AUTH frame. A transport
probe therefore needs only an exact allowed Origin and a valid WebSocket key:
curl -fsS "$PUBLIC_URL/v1/sockets/sync/capabilities"
# enabled: {"capabilities":[{"id":"ws-sync","version":1,"endpoint":"/sockets/sync"}]}
curl -sik --http1.1 --max-time 2 \
-H "Origin: $PUBLIC_URL" \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
"$PUBLIC_URL/sockets/sync"
# expect 101; the unauthenticated probe then closes because it sends no AUTH frame
During shutdown the public capability provider is cleared before the gateway is drained, and the HTTP listener closes only after that drain. Monitor Redis latency/errors and empty capability responses together; HTTP sync should remain available while the socket plane is intentionally closed.
Docker Image And Runtime Hardening
The stack is designed so the app front door is the only publicly reachable Standard Red Notes service. The server, database, cache, queue emulator, MCP bridge, and docker socket proxy remain internal-only. The optional n8n profile publishes a loopback-only development port; production should remove that mapping and use a dedicated TLS proxy hostname.
Runtime hardening currently includes:
| Service | Hardening |
|---|---|
app |
Unprivileged nginx image, no-new-privileges, all capabilities dropped, memory/PID limits, tmpfs scratch paths. |
server |
Unprivileged srn user, no compiler toolchain in runtime stage, no-new-privileges, all capabilities dropped, memory/PID limits, internal-only ports. |
db |
Internal-only MariaDB, least capabilities for official entrypoint ownership drop, memory/PID/no-file limits, graceful stop period. |
cache |
Internal-only Redis, least capabilities for user drop, memory/PID/no-file limits, explicit Redis maxmemory. |
floci |
Internal-only SNS/SQS emulator, no-new-privileges, all capabilities dropped, memory/PID limits. |
n8n |
Optional profile, unprivileged image, all capabilities dropped, persistent credential volume, loopback-only development port; independent TLS/auth in production. |
docker-socket-proxy |
Optional profile, raw socket mounted only here, all Docker API surfaces denied except restart endpoints. |
| single-container profile | Unprivileged srn user, capability drop, no-new-privileges, memory/PID limits, tmpfs /tmp, one published port. |
read_only is not globally enabled because several containers intentionally
rewrite runtime config, write supervisor logs, write sqlite/uploads, or maintain
database/cache state. Writable paths are constrained with named volumes and tmpfs
where the current images support it safely.
Image Pinning
Compose supports image override variables for production pinning:
MARIADB_IMAGE=mariadb:12.3.2
REDIS_IMAGE=redis:8.8.0-alpine
FLOCI_IMAGE=floci/floci:1.5.33-compat
N8N_IMAGE=n8nio/n8n:2.32.6
DOCKER_SOCKET_PROXY_IMAGE=tecnativa/docker-socket-proxy:v0.4.2
For reproducible production pulls, replace mutable tags with exact tags or
repo@sha256:<digest> values after your own image update review. This is
especially important for optional services that otherwise track moving upstream
tags, such as n8n.
Verification Commands
Check the rendered Compose model:
docker compose config
docker compose -f docker-compose.single.yml config
Check service health:
docker compose ps
docker compose exec db sh -c \
'mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" -e "SELECT 1"'
Check key MariaDB variables:
docker compose exec db sh -c \
'mariadb -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE" -e "SHOW VARIABLES WHERE Variable_name IN (\"max_connections\", \"max_allowed_packet\", \"innodb_buffer_pool_size\", \"innodb_flush_log_at_trx_commit\", \"local_infile\")"'
Check Redis memory policy:
docker compose exec cache redis-cli CONFIG GET maxmemory
docker compose exec cache redis-cli CONFIG GET maxmemory-policy
Run the e2e safety gates after hardening changes:
$env:APP_URL = "http://localhost:3001"
npm --prefix e2e test -- app-opens.spec.ts --project=chromium
npm --prefix e2e test -- encryption-data-safety.spec.ts --project=chromium
npm --prefix e2e run test:ops-load
node scripts/verify-backup-restore.mjs
The ops load gate can be scaled without editing code:
$env:OPS_LOAD_NOTES = "250"
$env:OPS_LOAD_CLIENTS = "4"
$env:OPS_REDIS_WORKERS = "4"
$env:OPS_REDIS_OPS_PER_WORKER = "500"
npm --prefix e2e run test:ops-load
It registers a real account, pushes encrypted notes to the server, signs in parallel clients, verifies pulled note integrity, runs concurrent Redis SET/GET/INCR churn, checks Redis throughput, and confirms MariaDB persisted the expected note rows.