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/mcplocation 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/streamand 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-Protofrom the edge (the example'sproxy_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 anhttp://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:
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_certificatefields (Jira, Confluence, Zammad) remain for one-off sources and add to this deployment-wide trust rather than replacing it. - A pre-existing
SSL_CERT_FILEenvironment override keeps working and is used as the base bundle that the configured certificates merge on top of.
Secrets hygiene
- Never share
.envor rawdocker compose configoutput; both contain secrets. .env,config/tow.yaml,deploy/certs/, andcompose.override.yamlare 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.