Files
Evil0ctal 6d053c2b66 fix(install): follow the release, put a failed upgrade back, and stop guessing
The CloakBrowser commit lives in one place now: docker/compose.yml's default,
which moves with the checkout together with the Playwright pins in
docker/browser_rpc/requirements.txt (a verified-with line, held equal by a
hygiene test). The installer no longer writes it into .env, and an upgrade
comments out the two values earlier versions wrote there. A frozen copy is how
an install kept 0.5.10 after the requirements had moved to Playwright 1.64.

Installs and upgrades follow releases, not main:
- A fresh install clones main and checks out the latest release's tag, and
  pins DTK_IMAGE_TAG to that version instead of `latest`. DTK_VERSION picks
  another (5.1.6, 5.1, latest, sha-<commit>); DTK_REPO_URL a mirror.
- An upgrade checks out the target's tag and hands over to that tag's
  installer. Then: compose's own config check, pull, browser build, a backup
  (--no-backup skips it), migrations, and only then the pin and the restart.
  Anything that fails before the restart, Ctrl-C included, puts the checkout
  and .env back. A release that lacks the database's migration is refused
  before anything is pulled. "Already on" needs the running version, .env and
  the checkout to agree. dtkctl is rewritten only when it has no upgrade
  command, so edits to its compose line survive.
- The v4 branch's V4.x releases are never taken for the latest. No python3 is
  needed for detection, the release check or version comparison.

Fresh installs on more machines: ceilings come from what Docker can use (the
VM on Docker Desktop), memswap_limit follows mem_limit (4.6-7.6 GiB hosts
failed to start), no service asks for more CPUs than exist (1-CPU hosts died
at the first migration), Redis' maxmemory stays under its ceiling, and an
edited compose.host.yml is kept as .old. An existing install is found from
container labels, also under --yes and DTK_INSTALL_DIR; volumes of the same
project without its .env stop a new install that could not open them.
Presets are validated and taken out of the environment, Ctrl-C stops the
script, and the model download no longer runs chmod -R on what was typed.

The menu: Stop keeps the containers, Back returns, removal stops when
`down -v` fails, restore takes a name from the list and says it fills in
rather than rolls back, settings list in full and set over stdin, logs show
every service, restart applies .env, free disk only offers this install's
images, transcription goes back off when it does not come up, and an empty
password cancels.

An install found without being named - the checkout the script is in, the
directory it runs from, the default one, Docker's labels - has to be a checkout
of this project whose dtkctl belongs to the user or root and is writable by
nobody else; a stray file called dtkctl is never run. Piped through curl | bash
the script has no directory of its own, and no longer takes the parent of the
current one for it.

The two installers are compared line by line for code now, not only for
control-flow words: a changed number or flag in one used to pass.
2026-10-20 18:13:05 -07:00

633 lines
25 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
# The transcriber's network. Cancelling a transcription from the console
# tells the sidecar directly, the way a cancelled download tells the
# downloader; without this the call failed on DNS and was swallowed.
- compute
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
- compute
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}
# The commit docker/browser_rpc/requirements.txt was verified with. It
# is a default here, not a line in .env, so that it moves with the
# checkout together with those requirements. When .env carried it, an
# install from September kept CloakBrowser 0.5.10 after the checkout's
# requirements had moved to the Playwright 1.64 that 0.6.3 was fixed
# for - a pair nobody had run. Set CLOAKBROWSER_COMMIT only to choose
# another revision on purpose; tests/unit/test_repo_hygiene.py keeps
# this, the requirements and the installers' CLOAKBROWSER_PIN equal.
CLOAKBROWSER_COMMIT: ${CLOAKBROWSER_COMMIT:-fbf005bc199920b592df17ac4d489324e29d56d8}
# 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"
transcriber:
mem_limit: 4g
memswap_limit: 4g
cpus: 2.0
profiles:
- transcriber
image: ${DTK_TRANSCRIBER_IMAGE:-dtk-transcriber}:${DTK_IMAGE_TAG:-dev}
build:
context: ..
dockerfile: docker/Dockerfile.transcriber
env_file:
- path: ../.env
required: false
environment:
DTK_SECRET_KEY: ""
DTK_DATABASE_URL: ""
DTK_REDIS_URL: ""
DTK_TRANSCRIBER_MODEL_DIR: /models/funasr
DTK_TRANSCRIBER_MEDIA_ROOT: /var/lib/dtk/media
expose:
- "9200"
networks:
- compute
volumes:
- media-data:/var/lib/dtk/media:ro
- ${DTK_TRANSCRIBER_MODEL_PATH:-./models/funasr-onnx}:/models/funasr:ro
read_only: true
tmpfs:
- /tmp:size=1g,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 256
healthcheck:
test:
- CMD
- python
- -c
- |
import json, os, urllib.request
token = os.environ.get("DTK_TRANSCRIBER_TOKEN", "")
request = urllib.request.Request("http://127.0.0.1:9200/health")
if token:
request.add_header("X-Transcriber-Token", token)
with urllib.request.urlopen(request, timeout=5) as response:
health = json.load(response)
if health.get("status") != "ok":
raise SystemExit(1)
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
restart: unless-stopped
networks:
# No gateway: postgres and redis cannot reach anything off this host.
data:
internal: true
edge: {}
# The transcriber, and the api and worker that call it. No gateway either:
# the models are mounted, so the sidecar has no reason to reach anything.
compute:
internal: true
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: