Self-hosted deployment
This is a self-contained runbook for sysadmins. It does not assume a local Git checkout. You create a directory, paste the files shown here, choose a Docker Hub image tag, and start Docker Compose.
The normal fast path is:
- Run the TOW containers from Docker Hub.
- Add the local Docker nginx proxy with
compose.proxy.yml. - Test locally at
http://localhost:8080. - Optionally put your own nginx, load balancer, ingress, Caddy, Traefik, or corporate reverse proxy in front of that local Docker proxy.
The Docker proxy in this guide is HTTP-only by default. TLS is not required for local testing, and public TLS termination is left to your edge proxy or load balancer.
Before you start
Prepare these first:
| Requirement | Notes |
|---|---|
| Linux server or VM | Use a normal Docker host. Do not put Postgres data on a network filesystem. |
| Docker Engine with Compose plugin | Use docker compose, not the legacy docker-compose binary. |
| SMTP relay | Required for invites, verification, password recovery, and production notifications. |
| Public DNS and TLS | Needed only when exposing the app publicly. Not needed for the local smoke test. |
| Backup location | Back up both the database and backend file volume. Database-only backups do not include uploaded files. |
Pick a release
Choose one release tag and use it for every TOW image in the stack.
Docker Hub tag pages:
Use a pinned tag, not latest:
export TOW_IMAGE_TAG=v0.1.11
Create the deployment directory
sudo install -d -m 0750 /opt/tow
sudo chown "$USER":"$USER" /opt/tow
cd /opt/tow
mkdir -p config nginx backups authentik-brand
chmod 700 config backups
Create .env
Generate secrets with your password manager or:
openssl rand -base64 48
Create /opt/tow/.env:
umask 077
cat > .env <<'EOF'
# Release images. Use the same tag for backend, frontend, and docs.
TOW_IMAGE_TAG=v0.1.11
# Local smoke-test URL. Change these for production after the local test works.
PUBLIC_APP_URL=http://localhost:8080
# Local Docker proxy. Bind to 127.0.0.1 when another reverse proxy is in front.
PROXY_BIND=127.0.0.1
PROXY_PORT=8080
# Database.
POSTGRES_USER=tow
POSTGRES_DB=tow
POSTGRES_PASSWORD=replace-with-random-postgres-password
# Required. Used to sign sessions and auth state.
APP_SECRET=replace-with-random-app-secret-at-least-32-characters
# Search.
MEILISEARCH_API_KEY=replace-with-random-meilisearch-key
# AI integrations. Leave blank to disable the related capability.
OPENAI_API_KEY=
EXA_API_KEY=
# SMTP credentials. Keep host/port/TLS in config/tow.yaml.
SMTP_USERNAME=
SMTP_PASSWORD=
# Optional docs service.
DOCS_PORT=3001
# Optional direct/private-port branch.
BACKEND_PORT=8000
FRONTEND_PORT=3000
# Optional authentik/OIDC branch. Fill these before enabling the authentik profile.
AUTHENTIK_TAG=2026.5.0
AUTHENTIK_PORT=9000
AUTHENTIK_PUBLIC_URL=http://localhost:8080/authentik
AUTHENTIK_WEB__PATH=/authentik/
AUTHENTIK_BOOTSTRAP_EMAIL=admin@example.com
AUTHENTIK_BOOTSTRAP_PASSWORD=
AUTHENTIK_BOOTSTRAP_TOKEN=
AUTHENTIK_SECRET_KEY=
AUTHENTIK_POSTGRES_PASSWORD=
OIDC_CLIENT_ID=tow
OIDC_CLIENT_SECRET=
TOW_AUTHENTIK_APPLICATION_SLUG=tow
TOW_AUTHENTIK_APPLICATION_NAME=TOW
SIGNUP_ENABLED=false
EOF
Replace every replace-with-* value before first boot.
Do not share .env or expanded docker compose config output unless it is
redacted. Both contain secrets.
Create config/tow.yaml
Create the common runtime config first:
cat > config/tow.yaml <<'EOF'
runtime:
app_name: TOW
public_app_url: http://localhost:8080
backend_cors_origins:
- http://localhost:8080
ai:
openai_base_url:
openai_reasoning_model: gpt-5.2
openai_reasoning_effort: high
openai_fast_model: gpt-5.2
openai_embedding_model: text-embedding-3-small
embedding_dim: 1536
security:
session_cookie_name: tow_session
session_cookie_secure: false
session_max_age_seconds: 2592000
background:
operating_background_enabled: true
operating_background_interval_seconds: 900
ticket_conflict_scan_hour_utc: 2
jobs:
ai_queue_max_concurrency: 2
ai_queue_status_retention_seconds: 300
uploads:
upload_storage_path: /app/data/uploads
upload_max_bytes: 26214400
storage:
onboarding_backup_path: /app/data/onboarding_submissions.jsonl
search:
meilisearch_url: http://meilisearch:7700
meilisearch_index_prefix: tow
meilisearch_timeout_seconds: 3
worker_batch_size: 100
worker_poll_seconds: 2
worker_lock_timeout_seconds: 300
worker_max_attempts: 10
candidate_limit: 200
email:
transport: smtp
from_address: TOW <noreply@example.com>
reply_to:
file_dir: /app/data/emails
worker_batch_size: 10
worker_poll_seconds: 5
worker_lock_timeout_seconds: 600
smtp:
host: smtp.example.com
port: 587
tls_mode: starttls
timeout_seconds: 30
EOF
Now choose exactly one auth branch before first boot.
Auth branch A: built-in auth
Use this when TOW should handle local email/password accounts:
cat >> config/tow.yaml <<'EOF'
auth:
mode: builtin
oidc:
issuer:
client_id:
scopes:
- openid
- profile
- email
username_claim: preferred_username
email_claim: email
email_verified_claim: email_verified
given_name_claim: given_name
family_name_claim: family_name
groups_claim: groups
require_email_verified: false
provider_logout_enabled: false
signup:
enabled: false
oidc_start_url:
verification_token_ttl_hours: 24
resend_cooldown_seconds: 60
organization_auth:
enabled: false
allowed_source_types:
- ldap
- saml
- oidc
EOF
With built-in auth, the first registered user becomes the server admin and first organisation owner.
Auth branch B: authentik/OIDC
Use this when authentik should handle sign-in, MFA, LDAP, SAML, Active Directory, or upstream OIDC:
cat >> config/tow.yaml <<'EOF'
auth:
mode: oidc
oidc:
issuer: http://localhost:8080/authentik/application/o/tow/
client_id: tow
scopes:
- openid
- profile
- email
username_claim: preferred_username
email_claim: email
email_verified_claim: email_verified
given_name_claim: given_name
family_name_claim: family_name
groups_claim: groups
require_email_verified: false
provider_logout_enabled: false
signup:
enabled: false
oidc_start_url:
verification_token_ttl_hours: 24
resend_cooldown_seconds: 60
organization_auth:
enabled: true
allowed_source_types:
- ldap
- saml
- oidc
EOF
Also fill the authentik and OIDC secrets in .env before starting with the
authentik profile.
With OIDC auth, the first successful OIDC login becomes the server admin and first organisation owner.
Create compose.yml
Create the base stack. This file does not publish the app publicly by itself.
Use compose.proxy.yml for the local Docker proxy.
cat > compose.yml <<'EOF'
name: tow
x-backend-common: &backend-common
image: anistow/tow-backend:${TOW_IMAGE_TAG}
env_file:
- .env
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
TOW_CONFIG_PATH: /app/config/tow.yaml
MEILISEARCH_URL: http://meilisearch:7700
MEILISEARCH_API_KEY: ${MEILISEARCH_API_KEY}
volumes:
- tow_backend_data:/app/data
- ./config/tow.yaml:/app/config/tow.yaml:ro
depends_on:
db:
condition: service_healthy
meilisearch:
condition: service_healthy
x-authentik-common: &authentik-common
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG}
profiles: ["authentik"]
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_POSTGRES_PASSWORD}
AUTHENTIK_POSTGRESQL__CONN_MAX_AGE: 0
AUTHENTIK_POSTGRESQL__CONN_HEALTH_CHECKS: "true"
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
AUTHENTIK_WEB__PATH: ${AUTHENTIK_WEB__PATH}
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
volumes:
- tow_authentik_data:/data
- tow_authentik_media:/data/media
- tow_authentik_templates:/templates
depends_on:
authentik-db:
condition: service_healthy
authentik-redis:
condition: service_healthy
services:
db:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- tow_postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 5s
timeout: 5s
retries: 20
meilisearch:
image: getmeili/meilisearch:v1.12
environment:
MEILI_MASTER_KEY: ${MEILISEARCH_API_KEY}
MEILI_NO_ANALYTICS: "true"
volumes:
- tow_meilisearch:/meili_data
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:7700/health || exit 1"]
interval: 5s
timeout: 5s
retries: 20
backend:
<<: *backend-common
command: >
sh -c "alembic upgrade head &&
python -m app.scripts.rebuild_search_index --ensure-only &&
uvicorn app.main:app --host 0.0.0.0 --port 8000"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8000/health || exit 1"]
interval: 10s
timeout: 5s
retries: 20
search-worker:
<<: *backend-common
depends_on:
db:
condition: service_healthy
meilisearch:
condition: service_healthy
backend:
condition: service_healthy
command: python -m app.search_worker
email-worker:
<<: *backend-common
depends_on:
db:
condition: service_healthy
backend:
condition: service_healthy
command: python -m app.email_worker
migration-worker:
<<: *backend-common
depends_on:
db:
condition: service_healthy
backend:
condition: service_healthy
command: python -m app.migration_worker
frontend:
image: anistow/tow-frontend:${TOW_IMAGE_TAG}
environment:
API_PROXY_TARGET: http://backend:8000
depends_on:
backend:
condition: service_healthy
docs:
image: anistow/tow-docs:${TOW_IMAGE_TAG}
profiles: ["docs"]
ports:
- "127.0.0.1:${DOCS_PORT}:80"
authentik-db:
profiles: ["authentik"]
image: postgres:16-alpine
command: ["postgres", "-c", "max_connections=200"]
environment:
POSTGRES_USER: authentik
POSTGRES_PASSWORD: ${AUTHENTIK_POSTGRES_PASSWORD}
POSTGRES_DB: authentik
volumes:
- tow_authentik_postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"]
interval: 5s
timeout: 5s
retries: 20
authentik-redis:
profiles: ["authentik"]
image: redis:7-alpine
command: redis-server --save 60 1 --loglevel warning
volumes:
- tow_authentik_redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 20
authentik-server:
<<: *authentik-common
command: server
shm_size: 512mb
ports:
- "127.0.0.1:${AUTHENTIK_PORT}:9000"
healthcheck:
test: ["CMD", "ak", "healthcheck"]
interval: 10s
timeout: 5s
retries: 30
authentik-worker:
<<: *authentik-common
command: worker
shm_size: 512mb
authentik-bootstrap:
<<: *backend-common
profiles: ["authentik"]
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
TOW_CONFIG_PATH: /app/config/tow.yaml
MEILISEARCH_URL: http://meilisearch:7700
MEILISEARCH_API_KEY: ${MEILISEARCH_API_KEY}
AUTHENTIK_INTERNAL_URL: http://authentik-server:9000
AUTHENTIK_PUBLIC_URL: ${AUTHENTIK_PUBLIC_URL}
AUTHENTIK_WEB__PATH: ${AUTHENTIK_WEB__PATH}
PUBLIC_APP_URL: ${PUBLIC_APP_URL}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
TOW_AUTHENTIK_APPLICATION_SLUG: ${TOW_AUTHENTIK_APPLICATION_SLUG}
TOW_AUTHENTIK_APPLICATION_NAME: ${TOW_AUTHENTIK_APPLICATION_NAME}
SIGNUP_ENABLED: ${SIGNUP_ENABLED}
volumes:
- tow_backend_data:/app/data
- ./config/tow.yaml:/app/config/tow.yaml:ro
- tow_authentik_data:/data
- tow_authentik_media:/data/media
- ./authentik-brand:/tow-brand-source:ro
depends_on:
authentik-server:
condition: service_healthy
authentik-worker:
condition: service_started
command: python -m app.scripts.bootstrap_authentik
volumes:
tow_postgres:
name: tow_postgres
tow_backend_data:
name: tow_backend_data
tow_meilisearch:
name: tow_meilisearch
tow_authentik_postgres:
name: tow_authentik_postgres
tow_authentik_redis:
name: tow_authentik_redis
tow_authentik_data:
name: tow_authentik_data
tow_authentik_media:
name: tow_authentik_media
tow_authentik_templates:
name: tow_authentik_templates
EOF
Create compose.proxy.yml
This overlay adds one local HTTP nginx proxy. It routes /api to the backend,
/authentik/ to authentik when that profile is running, and everything else to
the frontend.
First create the directory:
mkdir -p nginx
Then create /opt/tow/nginx/default.conf with this exact file content:
resolver 127.0.0.11 valid=30s ipv6=off;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name _;
client_max_body_size 25m;
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_proxied any;
gzip_vary on;
gzip_types application/javascript application/json application/xml image/svg+xml text/css text/plain text/xml;
location ^~ /api/socket.io {
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
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-Port $server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location ^~ /api/ {
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
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-Port $server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location = /authentik {
return 301 /authentik/;
}
location ^~ /authentik/ {
set $authentik_upstream http://authentik-server:9000;
proxy_pass $authentik_upstream;
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-Port $server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location / {
set $frontend_upstream http://frontend:3000;
proxy_pass $frontend_upstream;
proxy_http_version 1.1;
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-Port $server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
Then create /opt/tow/compose.proxy.yml:
cat > compose.proxy.yml <<'EOF'
services:
proxy:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "${PROXY_BIND}:${PROXY_PORT}:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
EOF
Start and test locally
Validate the files:
docker compose --env-file .env -f compose.yml -f compose.proxy.yml config --quiet
Pull images:
docker compose --env-file .env -f compose.yml -f compose.proxy.yml pull
Start the stack:
docker compose --env-file .env -f compose.yml -f compose.proxy.yml up -d
Follow logs:
docker compose --env-file .env -f compose.yml -f compose.proxy.yml logs -f backend search-worker email-worker proxy
Check health:
curl -fsS http://localhost:8080/api/health
Open:
http://localhost:8080
Put it behind your own reverse proxy
Keep the Docker proxy bound to localhost:
PROXY_BIND=127.0.0.1
PROXY_PORT=8080
For production HTTPS, update .env:
PUBLIC_APP_URL=https://tow.example.com
Update config/tow.yaml:
runtime:
public_app_url: https://tow.example.com
backend_cors_origins:
- https://tow.example.com
security:
session_cookie_secure: true
Then restart:
docker compose --env-file .env -f compose.yml -f compose.proxy.yml restart backend search-worker email-worker migration-worker proxy
Example edge nginx config:
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 / {
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 300s;
proxy_send_timeout 300s;
}
}
The edge proxy does not need to know about TOW's internal frontend, backend, or authentik containers when it points at the Docker proxy.
Branch: authentik/OIDC under /authentik/
Use this branch when users should visit authentik under the same hostname:
http://localhost:8080/authentik/
For production behind an edge proxy, use:
https://tow.example.com/authentik/
1. Fill authentik secrets
Set these in .env:
AUTHENTIK_PUBLIC_URL=http://localhost:8080/authentik
AUTHENTIK_WEB__PATH=/authentik/
AUTHENTIK_BOOTSTRAP_EMAIL=admin@example.com
AUTHENTIK_BOOTSTRAP_PASSWORD=replace-with-random-admin-password
AUTHENTIK_BOOTSTRAP_TOKEN=replace-with-random-api-token
AUTHENTIK_SECRET_KEY=replace-with-random-authentik-secret
AUTHENTIK_POSTGRES_PASSWORD=replace-with-random-authentik-db-password
OIDC_CLIENT_ID=tow
OIDC_CLIENT_SECRET=replace-with-random-oidc-client-secret
SIGNUP_ENABLED=false
For production, change both URLs:
PUBLIC_APP_URL=https://tow.example.com
AUTHENTIK_PUBLIC_URL=https://tow.example.com/authentik
Also change runtime.public_app_url, runtime.backend_cors_origins, and the
OIDC issuer in config/tow.yaml.
2. Extract authentik branding assets
The authentik bootstrap container expects TOW branding assets. Extract them from the published frontend image:
set -a
. ./.env
set +a
rm -rf authentik-brand/*
docker run --rm \
-v "$PWD/authentik-brand:/out" \
"anistow/tow-frontend:${TOW_IMAGE_TAG}" \
sh -lc 'cp -a /usr/share/nginx/html/logo.svg /out/ && cp -a /usr/share/nginx/html/images /out/ && cp -a /usr/share/nginx/html/fonts /out/'
3. Start with the authentik profile
docker compose --env-file .env -f compose.yml -f compose.proxy.yml --profile authentik pull
docker compose --env-file .env -f compose.yml -f compose.proxy.yml --profile authentik up -d
docker compose --env-file .env -f compose.yml -f compose.proxy.yml --profile authentik logs -f authentik-server authentik-worker authentik-bootstrap backend proxy
Then visit:
http://localhost:8080/authentik/
The first successful OIDC login becomes the server admin and first organisation owner. Later OIDC users need invites or existing linked identities.
Public signup with authentik
Public signup requires working email delivery.
Set .env:
SIGNUP_ENABLED=true
Set config/tow.yaml:
auth:
signup:
enabled: true
oidc_start_url: http://localhost:8080/authentik/if/flow/tow-public-enrollment/
verification_token_ttl_hours: 24
resend_cooldown_seconds: 60
For production, use the HTTPS enrollment URL.
Restart the bootstrap and backend:
docker compose --env-file .env -f compose.yml -f compose.proxy.yml --profile authentik up -d authentik-bootstrap
docker compose --env-file .env -f compose.yml -f compose.proxy.yml restart backend email-worker
Branch: authentik on auth.example.com
Use this branch when authentik has its own hostname.
Set .env:
AUTHENTIK_PUBLIC_URL=https://auth.example.com
AUTHENTIK_WEB__PATH=/
Set config/tow.yaml:
auth:
mode: oidc
oidc:
issuer: https://auth.example.com/application/o/tow/
client_id: tow
Expose authentik through your edge proxy:
server {
listen 443 ssl;
server_name auth.example.com;
ssl_certificate /etc/letsencrypt/live/auth.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:9000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header 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-Proto $scheme;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
The authentik-server service binds 127.0.0.1:${AUTHENTIK_PORT}:9000 by
default. Keep that port private unless a trusted proxy on another host needs to
reach it.
Branch: direct private ports
Use this only for a private network, VPN-only access, or a platform that handles all public routing. This branch skips the Docker proxy.
Create /opt/tow/compose.direct.yml:
cat > compose.direct.yml <<'EOF'
services:
backend:
ports:
- "127.0.0.1:${BACKEND_PORT}:8000"
frontend:
ports:
- "${FRONTEND_PORT}:3000"
EOF
Start:
docker compose --env-file .env -f compose.yml -f compose.direct.yml up -d
For private HTTP access, set config/tow.yaml:
runtime:
public_app_url: http://tow.internal.example:3000
backend_cors_origins:
- http://tow.internal.example:3000
security:
session_cookie_secure: false
For any HTTPS access, use the public HTTPS URL and set
session_cookie_secure: true.
Optional docs service
The docs site is separate from the authenticated in-product /docs wiki.
Start the docs container on localhost port 3001:
docker compose --env-file .env --profile docs up -d docs
Expose it through your edge proxy:
server {
listen 443 ssl;
server_name docs.example.com;
ssl_certificate /etc/letsencrypt/live/docs.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/docs.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3001;
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-Proto $scheme;
}
}
The published docs image is built for / on its own docs host. Serving docs at
a path such as /help/ requires a docs image built with that base path.
Restart and upgrade
Restart after changing .env, config/tow.yaml, or nginx/default.conf:
docker compose --env-file .env -f compose.yml -f compose.proxy.yml restart backend search-worker email-worker migration-worker frontend proxy
Upgrade by changing TOW_IMAGE_TAG in .env, then:
docker compose --env-file .env -f compose.yml -f compose.proxy.yml pull
docker compose --env-file .env -f compose.yml -f compose.proxy.yml up -d
docker compose --env-file .env -f compose.yml -f compose.proxy.yml logs -f backend proxy
curl -fsS http://localhost:8080/api/health
Use the same release tag for tow-backend, tow-frontend, and tow-docs.
Backup
Create a timestamped backup directory:
cd /opt/tow
backup_dir="backups/tow-$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$backup_dir"
chmod 700 "$backup_dir"
Back up the app database:
docker compose --env-file .env -f compose.yml -f compose.proxy.yml exec -T db \
sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \
> "$backup_dir/tow-db.dump"
Back up backend files, including uploads:
docker run --rm \
-v tow_backend_data:/data:ro \
-v "$PWD/$backup_dir:/backup" \
alpine sh -lc 'cd /data && tar -czf /backup/tow-backend-data.tgz .'
Back up deployment files:
cp compose.yml "$backup_dir/"
cp compose.proxy.yml "$backup_dir/"
cp .env "$backup_dir/"
cp config/tow.yaml "$backup_dir/"
cp nginx/default.conf "$backup_dir/"
chmod 600 "$backup_dir/.env"
For authentik deployments, also back up authentik volumes:
for volume in tow_authentik_postgres tow_authentik_redis tow_authentik_data tow_authentik_media tow_authentik_templates; do
docker run --rm \
-v "$volume:/data:ro" \
-v "$PWD/$backup_dir:/backup" \
alpine sh -lc "cd /data && tar -czf /backup/${volume}.tgz ."
done
Write checksums:
(cd "$backup_dir" && sha256sum * > SHA256SUMS.txt)
Restore outline
- Install Docker and recreate
/opt/tow. - Restore
compose.yml,compose.proxy.yml,.env,config/tow.yaml, andnginx/default.conf. - Recreate
tow_backend_dataand extracttow-backend-data.tgz. - Recreate
tow_postgres, startdb, create the database, and restoretow-db.dumpwithpg_restore. - Restore authentik volumes if the deployment uses authentik.
- Start the stack with the same
TOW_IMAGE_TAG. - Check
/api/health, sign in, confirm uploads, and send a test email.
Final checklist
Before opening the deployment to users:
- Confirm
.envhas no placeholder secrets. - Confirm the local Docker proxy works at
http://localhost:8080. - Confirm your edge proxy forwards to the Docker proxy if exposing TOW publicly.
- Confirm
runtime.public_app_urlexactly matches the URL users visit. - Confirm
backend_cors_originsincludes the public app origin. - Confirm
session_cookie_secure: truefor HTTPS. - Confirm
/api/healthis healthy through the public URL. - Confirm
/api/socket.ioreaches the backend through the proxy. - Register the first admin account intentionally.
- Send a test email from Admin, Server Settings.
- Upload and download a small test attachment.
- Run a backup and verify
SHA256SUMS.txt.