Skip to content

Deployment

slimTDS supports three deployment modes. Choose based on your network topology.

ModeCompose filesTLSReal client IP
devdocker-compose.yml + auto-merge of docker-compose.override.ymlTLS handled by the local environment (Caddyfile.dev runs with auto_https off)local (no proxy)
cf_flexdocker-compose.yml + docker-compose.prod.cf.ymlCloudflare terminates TLS; Caddy listens on :80CF-Connecting-IP header; Caddy trusts Cloudflare IP ranges
cf_fulldocker-compose.yml + docker-compose.prod.cf.ymlCloudflare Origin Certificate; Caddy listens on :443CF-Connecting-IP header; Caddy trusts Cloudflare IP ranges
directdocker-compose.yml + docker-compose.prod.direct.ymlCaddy auto-TLS via Let’s Encrypt; ports 80 + 443 must be publictrusted 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}.

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.dev
cf_flex → config/frankenphp/Caddyfile.cf (CF_LISTEN_PORT=80)
cf_full → config/frankenphp/Caddyfile.cf (CF_LISTEN_PORT=443)
direct → config/frankenphp/Caddyfile.direct

Any other value causes entrypoint.sh to exit with an error.

Copy .env.example to .env with make env, which also generates random values for APP_SECRET and ADMIN_PASSWORD. Key variables:

VariablePurpose
DEPLOY_MODEOne of dev, cf_flex, cf_full, direct
DOMAINPublic hostname (required for direct mode; optional for cf_*)
APP_SECRET64 hex chars; signs sessions and CSRF tokens — rotate to invalidate all sessions
APP_TZApplication 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_PASSWORDPostgreSQL connection
DB_DSNFull PDO DSN, constructed from the DB_* vars above
ADMIN_LOGINAdmin username (default admin)
ADMIN_PASSWORDSeeded once by admin:init; change via UI or admin:set-password afterwards
TRUSTED_PROXIESUnused — read by nothing. Real client IP is handled by Caddyfile.cf and App\Shared\RealIp; see Reverse Proxy
MAXMIND_ACCOUNT_ID / MAXMIND_LICENSE_KEYFree GeoLite2 credentials for geo-targeting filters
TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_IDTelegram bot for daily digest and hourly alerts
FRANKENPHP_WORKER_MODE1 (default, in-memory worker) or 0 (CGI-like, easier to debug)
Terminal window
make env # copy .env.example and generate secrets (only needed once)
make migrate # run Phinx migrations
make up # start db + app + cron containers

Browse to /admin/login on the host where you exposed slimTDS.

  1. Set DEPLOY_MODE=cf_full (or cf_flex) in .env. Set APP_SECRET, ADMIN_PASSWORD, and any other required vars.

  2. 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
  3. Start the stack:

    Terminal window
    make prod-up-cf

    make prod-up-cf verifies that DEPLOY_MODE starts with cf before 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)”
  1. Set DEPLOY_MODE=direct and DOMAIN=tds.example.com in .env. Ports 80 and 443 must be publicly reachable.

  2. 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
  3. Start the stack:

    Terminal window
    make prod-up-direct

    make prod-up-direct verifies both DEPLOY_MODE=direct and that DOMAIN is set before proceeding.

Terminal window
make prod-down

make prod-down reads DEPLOY_MODE from .env and tears down the appropriate compose stack automatically.

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:

Terminal window
docker compose exec app php bin/console admin:init

After first login, change the password at /admin/settings or via CLI:

Terminal window
docker compose exec app php bin/console admin:set-password admin <new-password>

GeoLite2 databases are not bundled. Without them, GeoLookup silently skips and all geo-filter conditions evaluate to no-match (expected behaviour).

  1. Sign up for a free MaxMind account
  2. Generate a license key and add to .env:
    MAXMIND_ACCOUNT_ID=123456
    MAXMIND_LICENSE_KEY=xxxxxxxxxxxx
  3. Run the one-time download:
    Terminal window
    docker compose --profile geo up geoipupdate
    This populates ./geoip-data/ with GeoLite2-City.mmdb, GeoLite2-Country.mmdb, and GeoLite2-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.)

  1. Create a bot via @BotFather and copy the token.
  2. Find your chat ID (e.g. forward any message to @userinfobot).
  3. 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.

Terminal window
docker compose exec app php bin/console db:backup

Dumps 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.

Terminal window
# List available dumps
docker 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 yes

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=0

Then check make logs for the PHP error. Remember to re-enable worker mode after fixing the issue.

Terminal window
docker compose --profile geo up geoipupdate

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.

slimTDS statistics dashboard