Files
Evil0ctal de4854d95e ci: publish the images to Docker Hub, and fill in the README
**Publishing.** `docker-publish.yml` builds and pushes on a branch push, a `v*`
tag, or by hand, for linux/amd64 and linux/arm64. Two images, and deliberately
not three: the app (api, worker and migrate are one image differing only in the
verb) and the Go downloader sidecar. browser-rpc is not published — it installs
CloakBrowser from a pinned commit passed in as a build argument, so an image
built here would ship somebody else's browser under this project's name and make
the pin ours rather than the operator's. It stays a local build.

`latest` moves only on a semver tag. A branch push gets a branch tag and an
immutable sha tag, so pulling `latest` gets a release somebody decided to make
rather than whatever was merged an hour ago. A fork without credentials builds
without pushing instead of failing, and a manual run defaults to build-only so
the workflow can be exercised without publishing.

The two slow parts no longer run under emulation. The console stage emits
JavaScript and CSS, which are not architecture-specific, so it is pinned to
BUILDPLATFORM and built once; the downloader cross-compiles with GOARCH and runs
its tests natively, because a cross-compiled test binary cannot be executed at
the point it is built. Verified: a two-platform downloader build produces a
manifest carrying both linux/amd64 and linux/arm64.

The smoke test is what the image is actually for. `--version` would have tested
the usage message and nothing else - the entrypoint takes a ROLE, not a flag - so
it runs `dtk --version` through a shell, checks the console assets reached the
runtime stage (a separate build stage that can silently produce nothing), and
checks the entrypoint still refuses an unknown role.

**README.** Adds what v4's has and v5's did not: a capability matrix per platform
(checked against the endpoint tables, including that Douyin's follower and
following lists are absent because Douyin serves them only to a signed-in
session), the link formats accepted, the stack, contact details, star history,
and CI and Docker Hub badges.

The Chinese README's sponsor block was English prose. It is Chinese now; the
English one stays English. Both files now carry the same twelve sections.

`DTK_IMAGE` and `DTK_DOWNLOADER_IMAGE` make the image name overridable, because
the README was about to document a variable compose did not read. The default
stays a local name: `up` on a clean machine should produce something runnable
without depending on a registry. The `pull` step is spelled out, since these
services declare both `image` and `build` and compose builds rather than pulls
when the image is absent.

CI now also watches `main`, since v5 is what `main` will hold and a workflow that
only watches the old branch name goes quiet exactly when the rename matters.
2026-09-10 03:33:59 -07:00

561 lines
23 KiB
YAML

# 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: