Skip to main content

Production hardening

The stack ships with a bundled nginx proxy in front of the application containers; nothing else is reachable from outside the Docker network. This page covers how that proxy meets the public internet.

Choose a TLS layout

Your own reverse proxy in front (recommended). The installer's default for production: the bundled proxy stays private on loopback ports (PROXY_BIND=127.0.0.1, PROXY_HTTP_PORT=8080, PROXY_HTTPS_PORT=8443 in .env; 80/443 stay free for your edge proxy on the same host), and your nginx, Caddy, Traefik, load balancer, or ingress terminates TLS and forwards to 127.0.0.1:8080. Use this when the host already runs a web server or your platform handles certificates.

The bundled proxy serves TLS itself (TLS_MODE=provided). Place fullchain.pem and privkey.pem in deploy/certs/ and the bundled nginx serves HTTPS directly on ports 80/443. Renewal is your responsibility (for example a certbot deploy hook that replaces the files and runs docker compose restart proxy). If either file is missing or unreadable, nginx starts in HTTP-only mode.

Either way, runtime.public_app_url in config/tow.yaml, PUBLIC_APP_URL in .env, and the URL users actually visit must agree, and HTTPS deployments need session_cookie_secure: true. The installer writes all of these consistently; check them after manual edits.

Edge proxy configuration

Point your edge proxy at the bundled proxy (127.0.0.1:8080 in this example; match your PROXY_HTTP_PORT). Two details are load-bearing:

  • Keep the exact /api/mcp location ahead of the catch-all so Bearer credentials reach the backend and Streamable HTTP responses are not buffered.
  • Set proxy_read_timeout 3600s. AI chat responses stream over /api/chat/stream and can pause for minutes between events while the model runs tools. With nginx's default 60-second timeout the proxy cuts the stream and chat fails with a network error.
  • Send X-Forwarded-Proto from the edge (the example's proxy_set_header X-Forwarded-Proto $scheme; does this). The bundled proxy passes it through to the app and to bundled authentik. Without it, authentik sees plain HTTP and advertises an http:// OIDC issuer, and logins fail with "OIDC discovery issuer did not match configured issuer".
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

server {
listen 443 ssl;
server_name tow.example.com;

ssl_certificate /etc/letsencrypt/live/tow.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tow.example.com/privkey.pem;

client_max_body_size 25m;

location = /api/mcp {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 300s;
client_max_body_size 256k;
}

location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 300s;
}
}

The edge proxy never needs to know about TOW's internal frontend, backend, or authentik containers; it only talks to the bundled proxy. Realtime updates use Socket.IO at /api/socket.io; the Upgrade headers above allow WebSocket connections, with HTTP fallback when a proxy cannot upgrade.

After changing .env or config/tow.yaml, reconcile the stack (a plain restart keeps the old container environment):

docker compose up -d --wait --force-recreate

Content Security Policy

TOW sends two Content-Security-Policy headers from its bundled frontend. Nothing about them needs configuring in a normal deployment. If you run your own edge proxy, pass both headers through unchanged and do not add CSP headers of your own on top.

The first header is enforced, but it does only one thing: it stops other websites from embedding TOW pages in frames (clickjacking protection). The one exception is the built-in PDF viewer (/pdf-sandbox.html), which TOW itself embeds and which therefore accepts frames from TOW's own origin only.

The second header is the strict policy, and for now it is delivered as Content-Security-Policy-Report-Only. Report-only means the browser blocks nothing; it only reports would-be violations to the backend at /api/security/csp-report. That is deliberate while the policy is proven out in real deployments: you get the monitoring today, and nothing can break. The policy itself forbids inline and dynamically generated scripts and keeps workers and frames on TOW's own origin. It does not yet use Trusted Types (a newer browser guard against HTML injection) because the rich-text editor's paste handling is not compatible with it yet; TOW leaves the directive out rather than weaken it just to silence reports.

The one setting you might need is CSP_CONNECT_SRC. Standard installations serve the API from the same origin under /api and can skip this. Only if your frontend was built with an external VITE_API_BASE_URL do you need to list the extra API and WebSocket origins in .env, separated by spaces, and then recreate the frontend container:

CSP_CONNECT_SRC=https://api.example.com wss://api.example.com

Keep that list down to origins the application actually talks to. And if you ever tune the policy by hand: the PDF viewer runs in a sandbox with its own, deliberately narrower policy, so never add blob: to the main application's script-src or worker-src.

Trusted proxy addresses

The backend records session and personal-access-token IPs from X-Forwarded-For / X-Real-IP only when the immediate peer is inside runtime.trusted_proxy_cidrs (config/tow.yaml). The default covers loopback and private ranges; narrow it to your proxy's addresses if the backend is reachable from anything else.

Upload size and body limits

PROXY_CLIENT_MAX_BODY_SIZE in .env (default 25m) must match uploads.upload_max_bytes in config/tow.yaml and the client_max_body_size of your edge proxy; the smallest of the three wins.

Direct private ports (no proxy at all)

For VPN-only or platform-routed deployments you can skip the bundled proxy: publish the backend and frontend ports through an ignored compose.override.yaml and disable the proxy service:

compose.override.yaml
services:
backend:
ports:
- "127.0.0.1:${BACKEND_PORT:-8000}:8000"

frontend:
ports:
- "${FRONTEND_PORT:-3000}:3000"

proxy:
profiles: ["bundled-proxy"]

Set runtime.public_app_url and backend_cors_origins to the URL clients actually use, and keep session_cookie_secure matched to the scheme.

Optional docs service

docker compose --profile docs up -d --wait docs serves this documentation on 127.0.0.1:3001 (see DOCS_BIND / DOCS_PORT in .env); expose it through your edge proxy on its own hostname. The published docs image is built for / on its own docs host; serving it under a path like /help/ requires a custom image build.

Trusting an internal certificate authority

If the services TOW talks to present certificates issued by an internal corporate CA — the OIDC issuer, IMAP mailboxes, an internal AI gateway or OpenAI-compatible endpoint, automation webhook targets, Meilisearch, or migration sources — outbound calls fail with CERTIFICATE_VERIFY_FAILED until the CA is trusted. Configure it once in tow.yaml; the setting survives every upgrade because the installer never rewrites an existing config/tow.yaml.

Export the root CA certificate (not the server's leaf certificate) as PEM and paste it as a block scalar:

security:
additional_ca_certificates: |
-----BEGIN CERTIFICATE-----
...root CA certificate data...
-----END CERTIFICATE-----

Multiple PEM blocks can be concatenated in the same value, for example a root plus an issuing intermediate CA. Then restart the backend and its workers — docker compose up -d is not enough, because Compose does not recreate containers when only the contents of a bind-mounted file changed:

docker compose restart backend search-worker email-worker inbound-email-worker migration-worker

At startup each backend process merges the configured certificates with the public CA bundle and trusts both — public CAs keep working; nothing is replaced. Invalid PEM data fails startup with a clear error instead of silently connecting without the CA.

Notes:

  • Inbound TLS termination at the edge proxy is separate; see Choose a TLS layout.
  • The per-migration ca_certificate fields (Jira, Confluence, Zammad) remain for one-off sources and add to this deployment-wide trust rather than replacing it.
  • A pre-existing SSL_CERT_FILE environment override keeps working and is used as the base bundle that the configured certificates merge on top of.

Secrets hygiene

  • Never share .env or raw docker compose config output; both contain secrets.
  • .env, config/tow.yaml, deploy/certs/, and compose.override.yaml are operator-owned: updates never replace them, and they belong in your configuration management, not in personal-data backups.
  • See Security and AI data controls for session, cookie, and AI data settings.