# Production orchestration for a single host. # # see docker/README.md write .env with random secrets # docker compose -p dtk -f docker/compose.yml up -d # docker compose -p dtk -f docker/compose.yml logs api # setup token # # Only `api` publishes a port, bound to loopback. postgres and redis sit on an # internal network with no route off the host at all. Everything the containers # need beyond that comes from ../.env, which docker/README.md shows how to write # and which is never committed (docs/design/08-security.md, # docs/design/09-deployment.md). # # Note on ../.env: compose resolves `env_file` paths relative to this file, so # service variables come from the repository root .env even though the project # directory is docker/. Interpolation in *this* file (the published address # below) does not: it reads docker/.env or the shell. Publish elsewhere with # DTK_BIND_HOST=0.0.0.0 docker compose -p dtk -f docker/compose.yml up -d # or point compose at the root file with COMPOSE_ENV_FILES=.env. name: dtk x-app-image: &app-image # Overridable so an operator can run a published image instead of building # one. Left as a local name by default: the repository is the source of # truth, and `up` on a clean machine should produce something runnable # without depending on a registry being reachable or current. image: ${DTK_IMAGE:-dtk-app}:${DTK_IMAGE_TAG:-dev} build: context: .. dockerfile: docker/Dockerfile args: # Bump together with the runtime pin in pyproject.toml. PYTHON_VERSION: "3.12.8" x-app-env: &app-env env_file: # Missing on a fresh clone; the entrypoint then refuses to start with an # actionable message instead of compose failing with a file-not-found. - path: ../.env required: false #: Resource ceilings for the two Python app services. #: #: These existed nowhere before, on any service. Without them a saturated disk #: queue or a runaway job slows every Postgres write, which slows the api, which #: fails /readyz - and api is `restart: unless-stopped`, so it gets restarted in #: a loop while the actual cause carries on. "Overload cannot destabilise the #: main API" was an assumption until this block existed; the kernel is what #: makes it true, not the fact that the work happens in another container. #: #: Sized from measurement on 2026-09-08 rather than guessed: api was using #: 104 MiB and worker 71 MiB at rest, so 512M is roughly five times headroom - #: enough that a large page or a burst of concurrent tasks has room, low enough #: that a leak is stopped rather than swallowed by an 8 GiB host. #: #: pids_limit matters as much as memory here: a fork bomb, or a subprocess leak, #: exhausts the host's process table and takes down containers that are behaving #: perfectly. 256 is far above what an asyncio service needs and far below the #: default of unlimited. x-app-limits: &app-limits mem_limit: 512m memswap_limit: 512m cpus: 2.0 pids_limit: 256 x-app-hardening: &app-hardening read_only: true tmpfs: - /tmp:size=64m,mode=1777 security_opt: - no-new-privileges:true cap_drop: - ALL logging: driver: json-file options: max-size: "10m" max-file: "3" #: Backups are the one thing these containers write to disk. The image is #: read-only by design, so they get a named volume and DTK_BACKUP_DIR pointing #: at it - shared, because the worker writes the archive and the API is what #: lists and serves it. x-app-backups: &app-backups volumes: - backup-data:/var/lib/dtk/backups environment: DTK_BACKUP_DIR: /var/lib/dtk/backups x-app: &app <<: [*app-image, *app-env, *app-hardening, *app-backups, *app-limits] services: # ---------------------------------------------------------------------- # Data stores. Internal network only: neither of them has any business # reaching the internet, and nothing outside the compose project reaches them. # ---------------------------------------------------------------------- postgres: # Measured at 112 MiB at rest, but Postgres' working set is shared_buffers # plus per-connection memory plus the OS page cache it relies on, so this is # the one service where a tight limit degrades correctness rather than # protecting it. 2g leaves room for the pool and for TimescaleDB's chunk # metadata; it exists to stop a runaway query taking the host, not to be a # budget Postgres has to live inside. mem_limit: 2g pids_limit: 256 image: timescale/timescaledb-ha:pg17 env_file: - path: ../.env required: false environment: # POSTGRES_PASSWORD comes from ../.env. The master key does not: the # database has no use for the key that encrypts its own contents, so it is # blanked out here even though the shared env file carries it. DTK_SECRET_KEY: "" POSTGRES_DB: dtk POSTGRES_USER: dtk POSTGRES_HOST_AUTH_METHOD: scram-sha-256 volumes: # PGDATA for the timescaledb-ha image. Mounting the usual # /var/lib/postgresql/data instead would leave the real data directory # inside the container and lose the database on the first rebuild. - postgres-data:/home/postgres/pgdata/data networks: - data healthcheck: test: ["CMD-SHELL", "pg_isready -U dtk -d dtk"] interval: 10s timeout: 5s retries: 12 start_period: 30s restart: unless-stopped stop_grace_period: 1m logging: driver: json-file options: max-size: "10m" max-file: "3" redis: # 8 MiB at rest. The queue, token buckets and the response cache all live # here, and Redis dies rather than swaps when it hits a ceiling, so the # limit is generous relative to the measurement. mem_limit: 512m pids_limit: 128 image: redis:8-alpine # The image's entrypoint only drops privileges when its first argument is # literally `redis-server`, and the password has to come from the container's # environment (see the command below), so the drop is done here instead. # Without this, redis would run as root. user: redis env_file: - path: ../.env required: false environment: DTK_SECRET_KEY: "" # $$ escapes compose interpolation, so REDIS_PASSWORD is expanded by the # shell inside the container from the env file rather than being written # into the resolved compose config. # # maxmemory is set well under mem_limit and is not optional. The response # cache writes one entry per distinct request and keeps it for the TTL in # cache.content_ttl (30 min by default); measured entries run 16-200 KB, so # a busy instance can put hundreds of megabytes in here inside one TTL # window. With no ceiling that ends as an OOM kill of the whole container - # the queue included - rather than as a cache miss. # # volatile-lru, not allkeys-lru: `tasks:queue` is a list with no expiry and # so is never an eviction candidate, and neither is sched:lastused. What # can be evicted is what carries a TTL - cache entries, rate-limit # counters, scheduler token buckets, console sessions - all of which are # rebuildable or merely inconvenient to lose. A queued task is not. command: - sh - -c - | if [ -z "$$REDIS_PASSWORD" ]; then echo "redis: REDIS_PASSWORD is empty; write .env first (see docker/README.md)" >&2 exit 1 fi exec redis-server \ --requirepass "$$REDIS_PASSWORD" \ --appendonly yes \ --save 60 1 \ --maxmemory ${DTK_REDIS_MAXMEMORY:-320mb} \ --maxmemory-policy volatile-lru volumes: - redis-data:/data networks: - data healthcheck: # The queue lives here, so an unauthenticated PONG is not good enough: # authenticate the probe the same way the application does. test: ["CMD-SHELL", "REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli ping | grep -q PONG"] interval: 10s timeout: 5s retries: 12 start_period: 10s restart: unless-stopped stop_grace_period: 30s logging: driver: json-file options: max-size: "10m" max-file: "3" # ---------------------------------------------------------------------- # Schema. A one-shot task rather than something api and worker each attempt: # concurrent CREATE TABLE races are how a first boot corrupts itself, and no # user should have to remember to run migrations after a pull. # ---------------------------------------------------------------------- migrate: <<: *app command: ["migrate"] networks: - data depends_on: postgres: condition: service_healthy restart: "no" # A one-shot container has no healthcheck: its exit status is its health, # which is what service_completed_successfully below reads. # ---------------------------------------------------------------------- # Application. Same image, different first argument. # ---------------------------------------------------------------------- api: <<: *app command: ["api"] environment: # Listens on every interface *inside* the container; exposure is decided # by the published address below, not by this value. DTK_BIND_HOST: "0.0.0.0" DTK_BIND_PORT: "8000" DTK_CONSOLE_DIR: /app/web/dist # Repeated rather than inherited: a YAML merge key replaces `environment` # wholesale instead of merging it, so a service that declares any variable # of its own loses every one from the anchor. The API lists and serves the # archives the worker writes, so the two must name the same directory. DTK_BACKUP_DIR: /var/lib/dtk/backups DTK_MEDIA_DIR: /var/lib/dtk/media volumes: # Repeated for the same YAML reason as the environment above: naming any # volume here replaces the anchor's list wholesale. - backup-data:/var/lib/dtk/backups # Read-only, and the only component besides the downloader that sees the # media at all. The console needs to hand a stored file to a browser, and # the API is the only part of this system with authentication, scopes, # rate limiting and an audit trail - so it serves the bytes and the # downloader stays what its own header insists it must remain: a sink, # never a relay. `:ro` because indexing and serving are both reads; a # download is deleted through the downloader, which owns the writes. - media-data:/var/lib/dtk/media:ro ports: # Loopback by default. Serving the outside world is an explicit act, and # it is on the operator to put TLS in front of it. - "${DTK_BIND_HOST:-127.0.0.1}:${DTK_BIND_PORT:-8000}:8000" networks: - data - edge depends_on: postgres: condition: service_healthy redis: condition: service_healthy migrate: condition: service_completed_successfully healthcheck: # Readiness, not liveness: `up --wait` should return when the API can # actually serve, and a reverse proxy wants the same signal. browser-rpc # is an optional dependency and must not appear in it. test: - CMD - python - -c - | import sys, urllib.request with urllib.request.urlopen("http://127.0.0.1:8000/readyz", timeout=3) as r: sys.exit(0 if r.status == 200 else 1) interval: 15s timeout: 5s retries: 5 start_period: 40s restart: unless-stopped stop_grace_period: 30s worker: <<: *app command: ["worker"] networks: - data - edge depends_on: postgres: condition: service_healthy redis: condition: service_healthy migrate: condition: service_completed_successfully healthcheck: # The worker has no port to probe. This asserts that the interpreter runs # and that the path to Redis - where leases, budgets and the queue live - # is open; a worker that cannot reach Redis does no work at all. test: - CMD - python - -c - | import os, socket, sys, urllib.parse url = urllib.parse.urlparse(os.environ.get("DTK_REDIS_URL", "redis://redis:6379/0")) socket.create_connection((url.hostname or "redis", url.port or 6379), 5).close() sys.exit(0) interval: 20s timeout: 5s retries: 5 start_period: 20s restart: unless-stopped # Long enough for an in-flight upstream request to finish and release its # identity lease instead of leaving it to expire by TTL. stop_grace_period: 45s # Scale with: docker compose -p dtk -f docker/compose.yml up -d --scale worker=N # Throughput is bounded by the identity pool, not by this number. # ---------------------------------------------------------------------- # Headless browser RPC. Optional by design: a deployment that imports its # cookies by hand does not need the heaviest container in the stack. # # docker compose -p dtk -f docker/compose.yml --profile browser up -d # # Set DTK_BROWSER_RPC_URL=http://browser-rpc:9000 in ../.env to let api and # worker use it. Left unset they run manual-import only, which is the correct # degraded mode rather than an error. # ---------------------------------------------------------------------- browser-rpc: # The heavy one, and the reason this block exists. Measured on 2026-09-08 # while warm: 2.57 GiB resident and 629% CPU - it legitimately uses six # cores' worth of Chromium. Left unbounded it is the service that starves # the api and postgres it shares a host with, which is the exact failure # "isolate the heavy work in its own container" is supposed to prevent and # does not, on its own. # # 4g is roughly 1.5x the warm measurement, so a second context has room and # a leak still hits a wall. The CPU cap is deliberately below the six cores # observed: signing and minting are off the request path (doc 01), so this # service is exactly the one that should yield when the machine is busy. # # pids_limit is high because Chromium is many processes per context, not # because anything here is careless: a renderer, a GPU process and several # utility processes per tab, times the warm contexts. mem_limit: 4g cpus: 4.0 pids_limit: 1024 profiles: - browser image: dtk-browser-rpc:${DTK_IMAGE_TAG:-dev} build: context: .. dockerfile: docker/Dockerfile.browser args: # Pin the backend to one repository and one commit. Several GitHub orgs # publish identically described CloakBrowser repositories, so a floating # tag is not a version (docs/design/04-transport-signing.md). CLOAKBROWSER_REPO: ${CLOAKBROWSER_REPO:-https://github.com/CloakHQ/cloakbrowser} CLOAKBROWSER_COMMIT: ${CLOAKBROWSER_COMMIT:-} # Same env file as the rest of the stack, for the same reason: every # DTK_BROWSER_* knob the README documents - the backend name above all - has # to be settable in one place. `environment:` wins over `env_file:`, so only # what the container's own topology fixes is repeated below. env_file: - path: ../.env required: false environment: # This container holds no credentials and never talks to the database, so # the master key that arrives with the shared env file is blanked here. DTK_SECRET_KEY: "" # Fixed by the container, not by the operator: the bind address has to # match `expose` and the healthcheck, and the profile root has to match # the tmpfs mounted below. DTK_BROWSER_BIND_HOST: "0.0.0.0" DTK_BROWSER_BIND_PORT: "9000" DTK_BROWSER_PROFILE_ROOT: /profiles # # Everything else - DTK_BROWSER_BACKEND, WARM_CONTEXTS, # WARM_REFRESH_SECONDS, MAX_CONCURRENT_MINTS, SIGN_PROXY_URL, # GEO_PROBE_URL, DEFAULT_COUNTRY - is deliberately absent so ../.env can # set it. The service defaults already match what this file used to pin # (cloak, 1, 1800, 2, unset), and backend selection stays explicit: there # is no fallback between backends, so an unset value means "cloak", never # "whatever starts". expose: - "9000" networks: # Reaches the internet through the identity's proxy; never touches the # database network. - edge read_only: true tmpfs: # Browser profiles live in RAM: a mint profile is single-use, and a tmpfs # makes that structural rather than a promise about cleanup code. # # /tmp is the budget that matters, and it is sized from a measurement # rather than a guess. The driver passes --disable-dev-shm-usage itself # (read off a live launch line on 2026-09-08), so every renderer's shared # memory lands here and NOT in /dev/shm below, which was observed at 0% # while /tmp sat at 57%. Each resident context holds roughly 290M of # deleted-but-open files - invisible to du, visible to df - so 1g fitted # barely three, and opening a fourth killed Chromium with SIGSEGV and # "Target crashed". That reaches the caller as a signing failure and # reads exactly like a platform block. # # Since signing now keeps one context per identity (see # browser_rpc/service.py), the ceiling is roughly # 300M x DTK_BROWSER_WARM_CONTEXTS x 2 platforms + a mint in flight. # 3g covers the default of 1 with room to raise it to 4. It is RAM: this # is a memory budget, not free space. - /tmp:size=3g,mode=1777 - /profiles:size=1g,mode=0700,uid=10001,gid=10001 # Kept, but do not count on it: the driver disables /dev/shm use, so this is # insurance for a future build that stops doing so, not the fix for renderer # crashes. The fix is the /tmp size above. shm_size: 1gb # Chromium's own sandbox needs to create user namespaces. Granting one # capability is the lesser evil against running the browser with # --no-sandbox, which removes the renderer boundary entirely. cap_drop: - ALL cap_add: - SYS_ADMIN security_opt: - no-new-privileges:true # Chromium leaves zombies behind; PID 1 has to reap them. init: true healthcheck: # A 200 is not enough here: the service answers /rpc/health even when the # browser backend failed to start, precisely so the operator gets a reason # instead of a restart loop. The reason lives in the body, so the probe # reads it - otherwise an image built without CLOAKBROWSER_COMMIT reports # itself healthy and mints nothing. test: - CMD - python - -c - | import json, sys, urllib.request with urllib.request.urlopen("http://127.0.0.1:9000/rpc/health", timeout=5) as r: body = json.load(r) sys.exit(0 if r.status == 200 and body.get("status") == "ok" else 1) interval: 30s timeout: 10s retries: 3 start_period: 60s restart: unless-stopped stop_grace_period: 30s logging: driver: json-file options: max-size: "10m" max-file: "3" # ---------------------------------------------------------------------- # Media downloader. Optional by design, like browser-rpc: an instance that # only wants metadata never needs to store a byte of video. # # docker compose -p dtk -f docker/compose.yml --profile downloader up -d # # Set DTK_DOWNLOADER_URL=http://downloader:9100 in ../.env to let api and # worker use it. Left unset, POST /api/v1/downloads answers 501 with an # actionable message and everything else is unaffected. # # This is the only container in the stack that dials arbitrary CDN hosts, so # it is also the one with the least in it: a scratch image holding one static # Go binary, no shell, no package manager, and exactly one writable path. # ---------------------------------------------------------------------- downloader: # Sized from what the work actually is. Transfers are streamed with # io.Copy, so resident memory is a handful of 32 KiB buffers per concurrent # file plus the runtime - not the size of the files. 512m is generous # against that and still a wall a leak hits. # # The CPU cap is the deliberate one. Pulling a dozen files at once is IO, # not compute, and the reason this service exists in its own container is # that its overload must not reach the api it shares a host with. Two cores # is more than enough to saturate a home connection. mem_limit: 512m memswap_limit: 512m cpus: 2.0 pids_limit: 128 profiles: - downloader image: ${DTK_DOWNLOADER_IMAGE:-dtk-downloader}:${DTK_IMAGE_TAG:-dev} build: context: .. dockerfile: docker/Dockerfile.downloader args: VERSION: ${DTK_IMAGE_TAG:-dev} env_file: - path: ../.env required: false environment: # This container holds no credentials and never touches the database. # Both are blanked even though the shared env file carries them: the # downloader is the component whose compromise would matter most, and it # is the one that needs neither. DTK_SECRET_KEY: "" DTK_DATABASE_URL: "" DTK_REDIS_URL: "" # Fixed by the container: the bind address has to match `expose`, the # healthcheck and the volume mounted below. DTK_DOWNLOADER_BIND: "0.0.0.0:9100" DTK_DOWNLOADER_ROOT: /var/lib/dtk/media # # DTK_DOWNLOADER_WORKERS, ITEM_WORKERS, QUEUE, TIMEOUT_SECONDS and TOKEN # are deliberately absent so ../.env can set them. The defaults - 4, 4, # 64, 900s, no token - are what this file used to pin. expose: - "9100" networks: # The CDNs, and nothing else. It has no route to postgres or redis # because it has no reason to reach either. - edge volumes: - media-data:/var/lib/dtk/media read_only: true security_opt: - no-new-privileges:true cap_drop: - ALL healthcheck: # The binary probes itself: a scratch image has no curl, and adding one # would undo the reason this image is scratch. test: ["CMD", "/usr/local/bin/downloader", "healthcheck"] interval: 30s timeout: 5s retries: 3 start_period: 10s restart: unless-stopped # Long enough for an in-flight transfer to be cancelled and its .part # removed, rather than left behind for the next run to overwrite. stop_grace_period: 30s logging: driver: json-file options: max-size: "10m" max-file: "3" networks: # No gateway: postgres and redis cannot reach anything off this host. data: internal: true edge: {} volumes: postgres-data: redis-data: backup-data: # The media volume. Named rather than a bind mount so a default install # cannot write into the checkout. The downloader mounts it read-write and is # the only component that writes; the api mounts it read-only so the console # can hand a stored file to a browser behind `media:read`. The worker still # only indexes what is stored and never opens a file. media-data: