Deploy
Three images, one Helm chart: how Cookiebot v2 ships
Three images, one Helm chart. The chart lives here because its shape versions with the code; the values live in the infrastructure repo, because that is where a cluster decides what runs.
Images
One Dockerfile, three outputs, selected by --build-arg SERVICE. Podman locally,
Docker in CI — the Dockerfile uses nothing BuildKit-only, so both work:
podman build --build-arg SERVICE=cb-gateway -t cb-gateway:dev .
podman build --build-arg SERVICE=cb-api -t cb-api:dev .
podman build --build-arg SERVICE=cb-worker -t cb-worker:dev .Quote the tag: in zsh, -t $s:test silently applies the :t history modifier and
you get cb-gatewayest. Use -t "${s}:test".
Measured sizes (linux/arm64, COMPILE=cython)
| Image | Size | Why it differs |
|---|---|---|
cb-api | 338 MB | |
cb-gateway | 344 MB | aiogram |
cb-worker | 493 MB | ffmpeg (~150 MB installed) |
Verified absent from cb-api: duckdb, tg_sandbox. Verified absent from
cb-gateway: ffmpeg. The per-service split does what it claims.
The biggest remaining item is not ours to strip. Inside the image:
132.7M site-packages/_polars_runtime_32 <- 40% of the image
17.9M granian
16.1M grpc (otel OTLP exporter)
15.0M psycopg_binary.libs (alembic DDL only; asyncpg does runtime SQL)
11.5M Cython (runtime dependency — see below)
57.9M /usr/lib (interpreter + libstdc++)polars is a cb-core dependency used for analytics rollups, so every service
carries 133 MB of it. Moving it to an extra that only cb-worker and cb-api
install would cut cb-gateway — the one that scales by replica count — roughly
in half. That is a pyproject.toml change, not a packaging change.
CI publishes them to GHCR on every push to main
(.github/workflows/docker.yml): dev-<sha> is what GitOps pins, dev is a
moving tag for humans poking at the cluster. Pull requests build and smoke-test
but never push — a PR must not be able to publish an image the cluster pulls.
Base image: Wolfi
cgr.dev/chainguard/wolfi-base. The ask was "small, glibc, with a package
manager", and this is the one that is all three:
| glibc | package manager | notes | |
|---|---|---|---|
| Wolfi | ✅ | apk | ~12 MB base, current packages, this is the pick |
| Alpine | ❌ musl | apk | smaller, but asyncpg / polars / obstore / blake3 publish manylinux wheels — musl means building them from source |
| debian-slim | ✅ | apt | ~75 MB base, older Python |
| distroless | ✅ | ❌ | no way to add ffmpeg or libstdc++ without vendoring by hand |
What actually makes the image small
Not one trick — five, in descending order of how many bytes they save:
- Per-service installs.
uv sync --package cb-workerinstalls that member and its dependencies only.cb-workernever carries FastAPI, andtelegram-sandbox(DuckDB, ~50 MB) is a dev dependency from another repository, so no image carries it at all. - ffmpeg only in
cb-worker. ~80 MB the gateway and API have no use for. - Builder/runtime split. uv, gcc, headers and the uv cache stay in the builder stage.
- Stripped
.sofiles. Debug symbols in wheels are pure weight in a container. - Bytecode without sources.
UV_COMPILE_BYTECODE=1plusPYTHONOPTIMIZE=2at build time, then the.pyfiles are deleted (--build-arg STRIP_SOURCE=falseto keep them when you need readable tracebacks inside the container).
"Compile the Python to a binary"
Two things are worth separating, because only one of them is free.
Optimised code — on by default. This repo already compiles its hot path with
Cython under a benchmark gate: a module ships compiled only if it reaches 1.5×,
and packages/cb-core/setup.py documents the three modules that were removed
from that list for failing it. The image build runs the same setup.py, so the
.so lands in the venv exactly as it does locally. On top of that, everything
else ships as -OO bytecode.
A single binary — --build-arg COMPILE=nuitka, off by default. Implemented,
and here is the honest accounting before you turn it on:
- It does not shrink the big things.
pydantic-core,asyncpg,polars,obstore,blake3,uvloopandgranianare already-compiled extension modules; Nuitka copies them verbatim. What it removes is the interpreter's stdlib source and our own pure-Python — tens of MB, not hundreds. - It breaks anything resolved dynamically unless named explicitly. aiogram's
handler discovery, alembic's migration modules and OpenTelemetry's
entry-point instrumentation all qualify; the
--include-packagelist in the Dockerfile covers the ones we know about, and a new dependency can add another. - Build time goes from about a minute to about ten.
Worth it for a fixed edge deployment. Not obviously worth it for a cluster that
pulls a layer once and caches it — which is why cython is the default.
Either way the entrypoint is deploy/docker/launcher.py: one module with static
imports that starts granian or arq in-process. scripts/cb.py shells out to
uv run granian ..., which is right for a laptop and wrong for a container —
it would need uv, the lockfile and the workspace at runtime.
CB_SERVICE=cb-gateway → granian, ASGI, cb_gateway.main:app on :8081
CB_SERVICE=cb-api → granian, ASGI, cb_api.main:app on :8000
CB_SERVICE=cb-worker → arq, WorkerSettings
CB_SERVICE=migrate → ensure_schema(), then exitChart
helm install cookiebot deploy/helm/cookiebot -n cookiebot-uat --create-namespaceDeploys the three services plus, on by default and each independently switchable: a self-hosted Telegram Bot API server, Valkey, and a CloudNativePG/Citus cluster. It does not install the CNPG operator — that is a cluster concern.
| Value | Default | Turn it off when |
|---|---|---|
telegramBotApi.enabled | true | you want api.telegram.org and a public webhook |
valkey.enabled | false-able | a managed Redis exists → set valkey.externalDsn |
citus.enabled | true | managed Postgres exists → citus.externalDsn / externalDsnSecret |
objectStorage.enabled | false | a real bucket exists → set config.storageUri instead |
migrations.enabled | true | never, really — it is what stops N replicas racing the same DDL |
Object storage
config.storageUri: "memory://" is not a neutral default: media, meme
templates and every /random object live in a process-local dictionary that a
restart empties, and nothing reports it — the features simply behave as though
nothing was ever uploaded. A cluster with no cloud bucket behind it needs one
in the namespace, which is what objectStorage.enabled deploys (MinIO, one
replica, one PVC).
The application never learns which it is talking to. cb_core.storage speaks
S3 through obstore, and obstore reads the endpoint and credentials out of the
ambient AWS_* environment, so the chart injects those (including
AWS_ALLOW_HTTP, without which the Rust layer refuses a plaintext endpoint and
says so in terms of TLS rather than of the scheme) and leaves the code path
alone.
Two things follow from that:
- With
objectStorage.enabled=trueandconfig.storageUrileft at its default, the chart resolves the URI tos3://<objectStorage.bucket>. An explicitconfig.storageUrialways wins, so a production values file naming a real bucket is never second-guessed. - The bucket is created by a PostSync Job (
mc mb --ignore-existing), because neither MinIO nor obstore creates one on first write — a missing bucket surfaces as a 404 on every upload, which reads like a code bug.
The credential is not generated by the chart. A chart that mints a random
password writes a different one on every upgrade unless it reads the cluster
back, and Argo's server-side diff cannot do that reliably — so
objectStorage.credentialsSecret names a Secret created out of band
(scripts/cookiebot_env.py in the infrastructure repo writes it once and then
leaves it alone, because MinIO reads the pair at process start).
The webhook path is entirely private
With telegramBotApi.enabled=true and local: true, the chart sets
CB_WEBHOOK_BASE_URL to a ClusterIP address. The gateway registers that with
setWebhook at startup (cb_gateway/ingest.py), and our own Bot API server
POSTs updates to it from inside the cluster:
Telegram DCs ──MTProto──► telegram-bot-api (--local) ──HTTP──► cb-gatewayNo public hostname, no ingress, no tunnel entry — and no 20 MB download cap,
which is what the v1 media path kept tripping over. --local is what makes an
http:// webhook on a private address legal.
Two prerequisites the chart cannot enforce:
telegram-api-id/telegram-api-hashin the Secret, from my.telegram.org. These are user credentials, not bot ones.- The bot must be logged out of
api.telegram.org. Telegram gives no delivery guarantee to a local server otherwise, and refuseslogOutwhile a webhook is set.scripts/cookiebot_bot.pyin the infrastructure repo does the delete-then-logout dance once, behind a confirmation.
Secrets
The chart never creates a Secret holding a credential; it only references one.
secrets.name (default cookiebot-secrets) must carry:
| Key | Used by |
|---|---|
bot-tokens | CB_BOT_TOKENS — {"skin": "123:ABC"} |
webhook-secret | CB_WEBHOOK_SECRET, checked on every update |
telegram-api-id, telegram-api-hash | the Bot API server |
bot-token | tooling only |
Plus, optionally, whatever keys secrets.*Key name — anthropicApiKeyKey,
openaiApiKeyKey, and googleSearchApiKeyKey/googleSearchCxKey for
x_image_search. Each is injected only when the value name is set, so an
unset one is a deployment where that feature degrades (no LLM provider, or
"I couldn't find an image") rather than a pod that will not start. Both Google
keys are needed together: the API key and the Programmable Search engine id.
With objectStorage.enabled, a second Secret
(objectStorage.credentialsSecret, default cookiebot-object-storage) carries
access-key / secret-key: MinIO's root credential, and what every pod signs
its S3 requests with.
CB_PG_DSN comes from the Secret CloudNativePG generates (<release>-citus-app).
GitOps
The infrastructure repo pins the chart through a multi-source Argo Application:
chart from this repository, values from that one. See
gitops/apps/cookiebot-uat.yaml and gitops/workloads/cookiebot-uat/values.yaml
in home-self-hosted.
Four things that only a real build catches
Every one of these passed a syntax check and failed on first run. They are called out because each has a guard in the Dockerfile now, and removing the guard reintroduces the bug.
addgroup nonrootfails —wolfi-basealready definesnonrootat 65532. JustUSER 65532:65532.- Deleting
.pywithoutcompileall -bbreaks every import. Python does not load__pycache__/mod.cpython-313.pyconcemod.pyis gone; sourceless imports need the.pycat the source path, which is what-bwrites. The first image built cleanly and could not import its own launcher. - uv installs workspace members as editable — a
.pthpointing at/src, which the runtime stage does not have.import cb_coreworked in the builder and failed in the shipped image. Hence--no-editable, and hence the import check that runs in the runtime stage: the builder cannot catch this, it still has/srcon disk. Cythonis a runtime dependency, not a build one.cb_core/dedupe.pyandtextmatch.pyare written in pure-Python mode andimport cythonat import time. Deleting the package saves 11 MB and breaks every import ofcb_core.storage.
Plus one in the launcher: granian 2.x takes loop=Loops.auto, not loop_opt.
An unknown keyword raises TypeError in the constructor — at startup, in the
cluster, after a green build.
Local sanity checks
helm lint deploy/helm/cookiebot
helm template cookiebot deploy/helm/cookiebot -n cookiebot-uat | kubectl apply --dry-run=client -f -
podman build --build-arg SERVICE=cb-api -t cb-api:test .
podman run --rm --entrypoint /opt/cb/bin/python cb-api:test -c "import cb_launcher, cb_core"
podman images localhost/cb-api:test --format '{{.Size}}'End to end, with real dependencies:
podman network create cbnet
podman run -d --name pgtest --network cbnet \
-e POSTGRES_USER=cookiebot -e POSTGRES_PASSWORD=cookiebot -e POSTGRES_DB=cookiebot \
postgres:17-alpine
podman run -d --name vktest --network cbnet valkey/valkey:8-alpine
podman run -d --name cbtest --network cbnet -p 18000:8000 \
-e CB_SERVICE=cb-api \
-e CB_PG_DSN=postgresql://cookiebot:cookiebot@pgtest:5432/cookiebot \
-e CB_REDIS_DSN=redis://vktest:6379/0 \
-e CB_AUTO_MIGRATE=false -e CB_TRACES_ENABLED=false cb-api:test
curl -s localhost:18000/healthz # {"status":"ok","service":"cb-api","cython":true}
curl -s localhost:18000/readyz # {"ready":true,"postgres":true,"valkey":true}
podman rm -f cbtest pgtest vktest && podman network rm cbnetCB_SERVICE=migrate needs the citus extension, so it only completes against
the cnpg-citus image — against plain Postgres it fails at
CREATE EXTENSION citus and exits 1, which is what makes it usable as an
init container that blocks a bad rollout.
If a build dies with no space left on device, it is the podman VM, not your
disk: podman system prune -f (that also removes stopped containers and
dangling images).