Deployment
slimTDS supports three deployment modes. Choose based on your network topology.
Deployment modes
Section titled “Deployment modes”| Mode | Compose files | TLS | Real client IP |
|---|---|---|---|
dev | docker-compose.yml + auto-merge of docker-compose.override.yml | TLS handled by the local environment (Caddyfile.dev runs with auto_https off) | local (no proxy) |
cf_flex | docker-compose.yml + docker-compose.prod.cf.yml | Cloudflare terminates TLS; Caddy listens on :80 | CF-Connecting-IP header; Caddy trusts Cloudflare IP ranges |
cf_full | docker-compose.yml + docker-compose.prod.cf.yml | Cloudflare Origin Certificate; Caddy listens on :443 | CF-Connecting-IP header; Caddy trusts Cloudflare IP ranges |
direct | docker-compose.yml + docker-compose.prod.direct.yml | Caddy auto-TLS via Let’s Encrypt; ports 80 + 443 must be public | trusted proxies only (no intermediate proxy) |
Both cf_flex and cf_full use the same Caddyfile.cf. The listen port is controlled by CF_LISTEN_PORT, which entrypoint.sh sets to 443 for cf_full and 80 for cf_flex.
The direct mode uses Caddyfile.direct, which uses the $DOMAIN environment variable for the Caddy site address and sets the ACME email to admin@{$DOMAIN}.
How a mode is selected
Section titled “How a mode is selected”DEPLOY_MODE in .env is the single source of truth. On container start, docker/entrypoint.sh reads it and selects the Caddyfile:
dev → config/frankenphp/Caddyfile.devcf_flex → config/frankenphp/Caddyfile.cf (CF_LISTEN_PORT=80)cf_full → config/frankenphp/Caddyfile.cf (CF_LISTEN_PORT=443)direct → config/frankenphp/Caddyfile.directAny other value causes entrypoint.sh to exit with an error.
Configuration (.env)
Section titled “Configuration (.env)”Copy .env.example to .env with make env, which also generates random values for APP_SECRET and ADMIN_PASSWORD. Key variables:
| Variable | Purpose |
|---|---|
DEPLOY_MODE | One of dev, cf_flex, cf_full, direct |
DOMAIN | Public hostname (required for direct mode; optional for cf_*) |
APP_SECRET | 64 hex chars; signs sessions and CSRF tokens — rotate to invalidate all sessions |
APP_TZ | Application timezone, e.g. Europe/Moscow. PostgreSQL sessions are set to this timezone so timestamptz reads come back localised; storage remains UTC |
DB_HOST / DB_PORT / DB_NAME / DB_USER / DB_PASSWORD | PostgreSQL connection |
DB_DSN | Full PDO DSN, constructed from the DB_* vars above |
ADMIN_LOGIN | Admin username (default admin) |
ADMIN_PASSWORD | Seeded once by admin:init; change via UI or admin:set-password afterwards |
TRUSTED_PROXIES | Unused — read by nothing. Real client IP is handled by Caddyfile.cf and App\Shared\RealIp; see Reverse Proxy |
MAXMIND_ACCOUNT_ID / MAXMIND_LICENSE_KEY | Free GeoLite2 credentials for geo-targeting filters |
TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID | Telegram bot for daily digest and hourly alerts |
FRANKENPHP_WORKER_MODE | 1 (default, in-memory worker) or 0 (CGI-like, easier to debug) |
Bringing it up
Section titled “Bringing it up”Development
Section titled “Development”make env # copy .env.example and generate secrets (only needed once)make migrate # run Phinx migrationsmake up # start db + app + cron containersBrowse to /admin/login on the host where you exposed slimTDS.
Production — behind Cloudflare
Section titled “Production — behind Cloudflare”-
Set
DEPLOY_MODE=cf_full(orcf_flex) in.env. SetAPP_SECRET,ADMIN_PASSWORD, and any other required vars. -
Run migrations and bootstrap the admin account:
Terminal window docker compose -f docker-compose.yml -f docker-compose.prod.cf.yml run --rm app php bin/console admin:init -
Start the stack:
Terminal window make prod-up-cfmake prod-up-cfverifies thatDEPLOY_MODEstarts withcfbefore proceeding. It runs:Terminal window docker compose -f docker-compose.yml -f docker-compose.prod.cf.yml up -d
Production — direct (Caddy Let’s Encrypt)
Section titled “Production — direct (Caddy Let’s Encrypt)”-
Set
DEPLOY_MODE=directandDOMAIN=tds.example.comin.env. Ports 80 and 443 must be publicly reachable. -
Run migrations and bootstrap the admin account:
Terminal window docker compose -f docker-compose.yml -f docker-compose.prod.direct.yml run --rm app php bin/console admin:init -
Start the stack:
Terminal window make prod-up-directmake prod-up-directverifies bothDEPLOY_MODE=directand thatDOMAINis set before proceeding.
Stopping production
Section titled “Stopping production”make prod-downmake prod-down reads DEPLOY_MODE from .env and tears down the appropriate compose stack automatically.
Initial bootstrap details
Section titled “Initial bootstrap details”Admin account
Section titled “Admin account”The first admin is created by admin:init, which reads ADMIN_LOGIN and ADMIN_PASSWORD from .env. This command is idempotent — it skips if the account already exists:
docker compose exec app php bin/console admin:initAfter first login, change the password at /admin/settings or via CLI:
docker compose exec app php bin/console admin:set-password admin <new-password>MaxMind GeoLite2
Section titled “MaxMind GeoLite2”GeoLite2 databases are not bundled. Without them, GeoLookup silently skips and all geo-filter conditions evaluate to no-match (expected behaviour).
- Sign up for a free MaxMind account
- Generate a license key and add to
.env:MAXMIND_ACCOUNT_ID=123456MAXMIND_LICENSE_KEY=xxxxxxxxxxxx - Run the one-time download:
This populates
Terminal window docker compose --profile geo up geoipupdate./geoip-data/withGeoLite2-City.mmdb,GeoLite2-Country.mmdb, andGeoLite2-ASN.mmdb.
The geoip:check cron job (runs daily at 06:00 UTC) logs a warning and exits non-zero if any .mmdb file is missing or older than 14 days. (Telegram notification of stale GeoIP is delivered separately by the hourly telegram:alerts job.)
Telegram alerts
Section titled “Telegram alerts”- Create a bot via @BotFather and copy the token.
- Find your chat ID (e.g. forward any message to @userinfobot).
- Add to
.env:TELEGRAM_BOT_TOKEN=1234567890:AAAAA...TELEGRAM_CHAT_ID=-100xxxxxxxx
The cron container sends a daily digest at 10:00 UTC and hourly system alerts automatically.
Backup and restore
Section titled “Backup and restore”Backup
Section titled “Backup”docker compose exec app php bin/console db:backupDumps are written to /app/var/backups/ inside the app container in PostgreSQL custom format. The daily cron (db:backup, 01:00 UTC) keeps files from the past 14 days and removes older ones automatically.
Restore
Section titled “Restore”# List available dumpsdocker compose exec app ls /app/var/backups/
# Restore (the 'yes' argument confirms the destructive operation)docker compose exec app php bin/console db:restore slimtds_2026-04-24_03-00-00.dump yesTroubleshooting
Section titled “Troubleshooting”FrankenPHP worker mode crashes on startup
Section titled “FrankenPHP worker mode crashes on startup”If a global bootstrap error occurs (e.g. a bad .env value or missing migration), the worker may crash in a restart loop. Fall back to classic mode temporarily:
FRANKENPHP_WORKER_MODE=0Then check make logs for the PHP error. Remember to re-enable worker mode after fixing the issue.
GeoIP databases missing or stale
Section titled “GeoIP databases missing or stale”docker compose --profile geo up geoipupdatemake test touches my dev data
Section titled “make test touches my dev data”It should not. make test uses db-test — a separate PostgreSQL container with tmpfs storage — so the dev DB is never touched. If you suspect contamination, check that DB_DSN in .env points to the dev DB, not the test one.
