feat: publish Kafka-linked image variants (#448)

* feat: publish Kafka-linked images for backend, consumer, worker and tracking as -kafka tag variants built per-arch on native runners, because the cgo librdkafka link cannot cross-compile on the build-go path

* feat: carry BUILT_AT inside the Go Kafka targets' build-args instead of the shared build-native block, so tracking and realtime stop warning about an unconsumed arg, and emit the matrix with printf because echo expands the separating backslash-n in some shells
This commit is contained in:
Matthew Meszaros
2026-09-11 22:31:41 -07:00
committed by GitHub
parent d42aeea1aa
commit 280a3e64ac
7 changed files with 141 additions and 68 deletions
+73 -37
View File
@@ -23,10 +23,12 @@ jobs:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
# JSON lists consumed as matrix inputs. `go` services cross-compile both
# arches on one runner; `native` services (Rust, Elixir) have no
# cross-compiler, so each arch builds on a native runner and the digests
# are merged into one manifest.
# JSON consumed as matrix inputs. `go` is a list of service names that
# cross-compile both arches on one runner. `native` is a list of
# {service, suffix, args} objects that cannot cross-compile, so each arch
# builds on a native runner and the digests are merged into one manifest:
# Rust and Elixir have no cross-compiler, and the Kafka variants of the Go
# services link librdkafka through cgo, which needs a native toolchain.
go: ${{ steps.matrix.outputs.go }}
native: ${{ steps.matrix.outputs.native }}
steps:
@@ -87,28 +89,53 @@ jobs:
UPDATER: ${{ steps.filter.outputs.updater }}
TRACKING: ${{ steps.filter.outputs.tracking }}
REALTIME: ${{ steps.filter.outputs.realtime }}
BUILT_AT: ${{ github.event.head_commit.timestamp }}
run: |
go=""
native=""
# A service that talks to the event bus gets a second, Kafka-linked
# image published as :<tag>-kafka. backend, consumer, worker and
# tracking are the four that do; forms and updater never touch it, and
# realtime is fed by the others rather than reading the bus itself.
add() {
case "$1" in
backend|consumer|worker)
go="$go\"$1\","
native="$native{\"service\":\"$1\",\"suffix\":\"-kafka\",\"args\":\"GO_TAGS=kafka\\nBUILT_AT=$BUILT_AT\"},"
;;
forms|updater)
go="$go\"$1\","
;;
tracking)
native="$native{\"service\":\"tracking\",\"suffix\":\"\",\"args\":\"\"},"
native="$native{\"service\":\"tracking\",\"suffix\":\"-kafka\",\"args\":\"CARGO_FEATURES=kafka\"},"
;;
realtime)
native="$native{\"service\":\"realtime\",\"suffix\":\"\",\"args\":\"\"},"
;;
esac
}
if [ "$EVENT" = "workflow_dispatch" ]; then
sel="${SELECTED:-all}"
for s in backend consumer worker forms updater; do
if [ "$sel" = "all" ] || [ "$sel" = "$s" ]; then go="$go\"$s\","; fi
done
for s in tracking realtime; do
if [ "$sel" = "all" ] || [ "$sel" = "$s" ]; then native="$native\"$s\","; fi
for s in backend consumer worker forms updater tracking realtime; do
if [ "$sel" = "all" ] || [ "$sel" = "$s" ]; then add "$s"; fi
done
else
if [ "$BACKEND" = "true" ]; then go="$go\"backend\","; fi
if [ "$CONSUMER" = "true" ]; then go="$go\"consumer\","; fi
if [ "$WORKER" = "true" ]; then go="$go\"worker\","; fi
if [ "$FORMS" = "true" ]; then go="$go\"forms\","; fi
if [ "$UPDATER" = "true" ]; then go="$go\"updater\","; fi
if [ "$TRACKING" = "true" ]; then native="$native\"tracking\","; fi
if [ "$REALTIME" = "true" ]; then native="$native\"realtime\","; fi
[ "$BACKEND" = "true" ] && add backend
[ "$CONSUMER" = "true" ] && add consumer
[ "$WORKER" = "true" ] && add worker
[ "$FORMS" = "true" ] && add forms
[ "$UPDATER" = "true" ] && add updater
[ "$TRACKING" = "true" ] && add tracking
[ "$REALTIME" = "true" ] && add realtime
true
fi
echo "go=[${go%,}]" >> "$GITHUB_OUTPUT"
echo "native=[${native%,}]" >> "$GITHUB_OUTPUT"
# printf, not echo: the Go Kafka args carry a literal \n separating two
# build-args, and echo expands backslash escapes in some shells, which
# would split the value across two lines and break both the JSON and
# the single-line $GITHUB_OUTPUT format.
printf 'go=[%s]\n' "${go%,}" >> "$GITHUB_OUTPUT"
printf 'native=[%s]\n' "${native%,}" >> "$GITHUB_OUTPUT"
# Go services: the Dockerfiles build on $BUILDPLATFORM and cross-compile to
# each target arch, so one amd64 runner produces both platforms without QEMU.
@@ -154,18 +181,19 @@ jobs:
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}
# Rust (tracking) and Elixir (realtime) have no cross-compiler; an emulated
# arm64 build under QEMU runs for an hour or more. Build each arch on a
# native runner and merge the digests into one manifest (the
# Everything that cannot cross-compile: Rust (tracking), Elixir (realtime),
# and the Kafka variants of the Go services, which link librdkafka through
# cgo. An emulated arm64 build under QEMU runs for an hour or more, so build
# each arch on a native runner and merge the digests into one manifest (the
# Docker-documented multi-runner pattern).
build-native:
name: Build ${{ matrix.service }} (${{ matrix.platform }})
name: Build ${{ matrix.target.service }}${{ matrix.target.suffix }} (${{ matrix.platform }})
needs: changes
if: needs.changes.outputs.native != '[]'
strategy:
fail-fast: false
matrix:
service: ${{ fromJSON(needs.changes.outputs.native) }}
target: ${{ fromJSON(needs.changes.outputs.native) }}
platform: [linux/amd64, linux/arm64]
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
permissions:
@@ -174,11 +202,18 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Prepare platform pair
# `key` is the per-variant identifier: the image name plus the tag suffix,
# so a Kafka build never shares a cache scope or a digest artifact with
# the plain build of the same service.
- name: Prepare identifiers
id: prep
run: echo "pair=${PLATFORM//\//-}" >> "$GITHUB_OUTPUT"
run: |
echo "pair=${PLATFORM//\//-}" >> "$GITHUB_OUTPUT"
echo "key=${SERVICE}${SUFFIX}" >> "$GITHUB_OUTPUT"
env:
PLATFORM: ${{ matrix.platform }}
SERVICE: ${{ matrix.target.service }}
SUFFIX: ${{ matrix.target.suffix }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -194,15 +229,16 @@ jobs:
id: build
uses: docker/build-push-action@v6
with:
context: ${{ matrix.service == 'tracking' && './tracking' || '.' }}
file: ${{ matrix.service == 'tracking' && './tracking/Dockerfile' || format('deploy/docker/{0}.Dockerfile', matrix.service) }}
context: ${{ matrix.target.service == 'tracking' && './tracking' || '.' }}
file: ${{ matrix.target.service == 'tracking' && './tracking/Dockerfile' || format('deploy/docker/{0}.Dockerfile', matrix.target.service) }}
platforms: ${{ matrix.platform }}
build-args: |
VERSION=dev-${{ github.sha }}
COMMIT=${{ github.sha }}
cache-from: type=gha,scope=${{ matrix.service }}-${{ steps.prep.outputs.pair }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}-${{ steps.prep.outputs.pair }}
outputs: type=image,name=${{ env.IMAGE_PREFIX }}/${{ matrix.service }},push-by-digest=true,name-canonical=true,push=true
${{ matrix.target.args }}
cache-from: type=gha,scope=${{ steps.prep.outputs.key }}-${{ steps.prep.outputs.pair }}
cache-to: type=gha,mode=max,scope=${{ steps.prep.outputs.key }}-${{ steps.prep.outputs.pair }}
outputs: type=image,name=${{ env.IMAGE_PREFIX }}/${{ matrix.target.service }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
@@ -213,19 +249,19 @@ jobs:
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.service }}-${{ steps.prep.outputs.pair }}
name: digests-${{ steps.prep.outputs.key }}-${{ steps.prep.outputs.pair }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-native:
name: Merge ${{ matrix.service }} manifest
name: Merge ${{ matrix.target.service }}${{ matrix.target.suffix }} manifest
needs: [changes, build-native]
if: needs.changes.outputs.native != '[]'
strategy:
fail-fast: false
matrix:
service: ${{ fromJSON(needs.changes.outputs.native) }}
target: ${{ fromJSON(needs.changes.outputs.native) }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -235,7 +271,7 @@ jobs:
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-${{ matrix.service }}-*
pattern: digests-${{ matrix.target.service }}${{ matrix.target.suffix }}-linux-*
merge-multiple: true
- name: Log in to GHCR
@@ -252,9 +288,9 @@ jobs:
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.sha }} \
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:dev \
$(printf '${{ env.IMAGE_PREFIX }}/${{ matrix.service }}@sha256:%s ' *)
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.target.service }}:${{ github.sha }}${{ matrix.target.suffix }} \
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.target.service }}:dev${{ matrix.target.suffix }} \
$(printf '${{ env.IMAGE_PREFIX }}/${{ matrix.target.service }}@sha256:%s ' *)
# A package on GHCR is created private and does not inherit the repository's
# visibility, and no API can change that: an owner has to flip it by hand.
+47 -22
View File
@@ -129,17 +129,28 @@ jobs:
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}
# Rust (tracking) and Elixir (realtime) have no cross-compiler; an emulated
# arm64 build under QEMU runs for an hour or more. Build each arch on a
# native runner and merge the digests into one manifest (the
# Docker-documented multi-runner pattern).
# Everything that cannot cross-compile: Rust (tracking), Elixir (realtime),
# and the Kafka variants of the bus-facing Go services, which link librdkafka
# through cgo. An emulated arm64 build under QEMU runs for an hour or more, so
# build each arch on a native runner and merge the digests into one manifest
# (the Docker-documented multi-runner pattern).
#
# The Kafka variants publish under the same image names with a -kafka tag
# suffix, so an operator on Kafka pins ...:prod-kafka and gets the same
# release built with EVENTBUS_PROVIDER=kafka available.
build-native:
name: Build ${{ matrix.service }} (${{ matrix.platform }})
name: Build ${{ matrix.target.service }}${{ matrix.target.suffix }} (${{ matrix.platform }})
needs: validate-tag
strategy:
fail-fast: false
matrix:
service: [tracking, realtime]
target:
- { service: tracking, suffix: "", args: "" }
- { service: tracking, suffix: "-kafka", args: "CARGO_FEATURES=kafka" }
- { service: realtime, suffix: "", args: "" }
- { service: backend, suffix: "-kafka", args: "GO_TAGS=kafka" }
- { service: consumer, suffix: "-kafka", args: "GO_TAGS=kafka" }
- { service: worker, suffix: "-kafka", args: "GO_TAGS=kafka" }
platform: [linux/amd64, linux/arm64]
runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
permissions:
@@ -148,11 +159,18 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Prepare platform pair
# `key` is the per-variant identifier: the image name plus the tag suffix,
# so a Kafka build never shares a cache scope or a digest artifact with
# the plain build of the same service.
- name: Prepare identifiers
id: prep
run: echo "pair=${PLATFORM//\//-}" >> "$GITHUB_OUTPUT"
run: |
echo "pair=${PLATFORM//\//-}" >> "$GITHUB_OUTPUT"
echo "key=${SERVICE}${SUFFIX}" >> "$GITHUB_OUTPUT"
env:
PLATFORM: ${{ matrix.platform }}
SERVICE: ${{ matrix.target.service }}
SUFFIX: ${{ matrix.target.suffix }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -168,15 +186,16 @@ jobs:
id: build
uses: docker/build-push-action@v6
with:
context: ${{ matrix.service == 'tracking' && './tracking' || '.' }}
file: ${{ matrix.service == 'tracking' && './tracking/Dockerfile' || format('deploy/docker/{0}.Dockerfile', matrix.service) }}
context: ${{ matrix.target.service == 'tracking' && './tracking' || '.' }}
file: ${{ matrix.target.service == 'tracking' && './tracking/Dockerfile' || format('deploy/docker/{0}.Dockerfile', matrix.target.service) }}
platforms: ${{ matrix.platform }}
build-args: |
VERSION=${{ github.ref_name }}
COMMIT=${{ github.sha }}
cache-from: type=gha,scope=${{ matrix.service }}-${{ steps.prep.outputs.pair }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}-${{ steps.prep.outputs.pair }}
outputs: type=image,name=${{ env.IMAGE_PREFIX }}/${{ matrix.service }},push-by-digest=true,name-canonical=true,push=true
${{ matrix.target.args }}
cache-from: type=gha,scope=${{ steps.prep.outputs.key }}-${{ steps.prep.outputs.pair }}
cache-to: type=gha,mode=max,scope=${{ steps.prep.outputs.key }}-${{ steps.prep.outputs.pair }}
outputs: type=image,name=${{ env.IMAGE_PREFIX }}/${{ matrix.target.service }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
@@ -187,18 +206,24 @@ jobs:
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.service }}-${{ steps.prep.outputs.pair }}
name: digests-${{ steps.prep.outputs.key }}-${{ steps.prep.outputs.pair }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-native:
name: Merge ${{ matrix.service }} manifest
name: Merge ${{ matrix.target.service }}${{ matrix.target.suffix }} manifest
needs: [validate-tag, build-native]
strategy:
fail-fast: false
matrix:
service: [tracking, realtime]
target:
- { service: tracking, suffix: "" }
- { service: tracking, suffix: "-kafka" }
- { service: realtime, suffix: "" }
- { service: backend, suffix: "-kafka" }
- { service: consumer, suffix: "-kafka" }
- { service: worker, suffix: "-kafka" }
runs-on: ubuntu-latest
permissions:
contents: read
@@ -208,7 +233,7 @@ jobs:
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-${{ matrix.service }}-*
pattern: digests-${{ matrix.target.service }}${{ matrix.target.suffix }}-linux-*
merge-multiple: true
- name: Log in to GHCR
@@ -225,11 +250,11 @@ jobs:
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.ref_name }} \
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.minor }} \
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.major }} \
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:prod \
$(printf '${{ env.IMAGE_PREFIX }}/${{ matrix.service }}@sha256:%s ' *)
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.target.service }}:${{ github.ref_name }}${{ matrix.target.suffix }} \
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.target.service }}:v${{ needs.validate-tag.outputs.minor }}${{ matrix.target.suffix }} \
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.target.service }}:v${{ needs.validate-tag.outputs.major }}${{ matrix.target.suffix }} \
-t ${{ env.IMAGE_PREFIX }}/${{ matrix.target.service }}:prod${{ matrix.target.suffix }} \
$(printf '${{ env.IMAGE_PREFIX }}/${{ matrix.target.service }}@sha256:%s ' *)
# The `warmbly` CLI is a plain static binary, so it cross-compiles for every
# platform on one runner.
+3 -1
View File
@@ -42,7 +42,9 @@ docker build -f web/Dockerfile -t warmbly/web web/
docker build -f admin/Dockerfile -t warmbly/admin admin/
```
The default builds have no Kafka/Avro support; add `--build-arg GO_TAGS=kafka` (Go images) or `--build-arg CARGO_FEATURES=kafka` (tracking) to opt in.
The default builds have no Kafka/Avro support; add `--build-arg GO_TAGS=kafka` (Go images) or `--build-arg CARGO_FEATURES=kafka` (tracking) to opt in. Those builds link librdkafka through cgo, which cannot cross-compile, so each architecture has to be built on a machine of that architecture.
CI already publishes them, so you rarely need to: `backend`, `consumer`, `worker`, and `tracking` each get a second tag with a `-kafka` suffix (`ghcr.io/warmbly/warmbly/backend:prod-kafka`).
GitHub Actions publishes these to GHCR automatically. See [the self-hosting guide](https://docs.warmbly.com/development/deployment-guide/).
+3 -2
View File
@@ -60,8 +60,9 @@ REDIS=redis://localhost:6379
# === Provider switches (no-cloud defaults) ===
AWS_CONFIG_ENABLED=false # true => read secrets from AWS SSM/Secrets Manager
# Event bus. nats (default): one small JetStream binary. kafka: build the images
# with GO_TAGS=kafka / CARGO_FEATURES=kafka and set KAFKA_* below.
# Event bus. nats (default): one small JetStream binary. kafka: run the -kafka
# images (ghcr.io/warmbly/warmbly/backend:prod-kafka and the same for consumer,
# worker, tracking) and set KAFKA_* below.
EVENTBUS_PROVIDER=nats
NATS_URL=nats://localhost:4222
# NATS_STREAM_NAME=warmbly
@@ -289,7 +289,7 @@ On `filesystem`, a remote worker writes blobs to its own disk rather than a volu
| Variable | What it does | Default | Restart needed |
|---|---|---|---|
| `EVENTBUS_PROVIDER` | `nats` or `kafka`. Kafka needs images built with `GO_TAGS=kafka` | `nats` under compose, `kafka` for a bare binary | yes |
| `EVENTBUS_PROVIDER` | `nats` or `kafka`. Kafka needs the `-kafka` images, or a build with `GO_TAGS=kafka` | `nats` under compose, `kafka` for a bare binary | yes |
| `NATS_URL` | JetStream address. Credentials in the URL are honored by every service, including the Rust tracking publisher: `nats://user:pass@host:4222` for a user, `nats://token@host:4222` for a token, `tls://` for TLS | `nats://nats:4222` | yes |
| `NATS_CREDS` | Path to a NATS credentials file (user JWT plus nkey seed), for a bus that authenticates with JWT rather than a token. Synadia Cloud and any nsc-managed account issue one | unset | yes |
| `NATS_CREDS_B64` | The same file, base64 encoded, as a single line. This is the form the fleet uses: a node receives environment variables rather than files, and the env file docker reads cannot express a multi-line value. Takes precedence over `NATS_CREDS` | unset | yes |
@@ -566,7 +566,7 @@ The Rust open and click service. It reads its own environment, so these have to
| `TRACKING_SCANNER_NETWORKS` | Extra scanner sources, comma separated, each a CIDR or `asn:<number>` with an optional label. For sources that never carry a person's own request, so their pixel fetches and their clicks are both treated as automated | empty |
| `TRACKING_SCANNER_CLICK_NETWORKS` | The same, for sources that also proxy a mail client's own image fetches. Only their click tickets are treated as automated, because their pixel fetches are genuine opens | empty |
| `TRACKING_SCANNER_ASN_HEADER` | Header a trusted proxy sets with the source ASN, which is what makes `asn:` entries match. Read only from a proxy in `TRACKING_TRUSTED_PROXIES`. On Cloudflare, a transform rule writing `ip.src.asnum`. Empty disables ASN matching | empty |
| `EVENTBUS_PROVIDER` | `nats` or `kafka`. Kafka needs an image built with `CARGO_FEATURES=kafka` | `nats` |
| `EVENTBUS_PROVIDER` | `nats` or `kafka`. Kafka needs the `tracking:*-kafka` image, or a build with `CARGO_FEATURES=kafka` | `nats` |
| `NATS_URL`, `NATS_SUBJECT_PREFIX` | JetStream address and subject prefix. The publish subject is `<prefix>.<topic>` | `nats://localhost:4222`, `warmbly` |
| `KAFKA_TRACKING_TOPIC` | Event topic, read by the Rust publisher **and** the Go subscriber | `tracking-events` |
| `KAFKA_BOOTSTRAP_SERVERS`, `KAFKA_SASL_USERNAME`, `KAFKA_SASL_PASSWORD` | Broker transport when `EVENTBUS_PROVIDER=kafka` | unset |
@@ -329,7 +329,7 @@ DEPLOYMENT_MODE=self_hosted
# AWS_SECRET_ACCESS_KEY=
# KMS_PROVIDER=aws
# KMS_AWS_KEY_ID=alias/warmbly
# EVENTBUS_PROVIDER=kafka # needs images built with GO_TAGS=kafka
# EVENTBUS_PROVIDER=kafka # needs the -kafka images (see below)
# KAFKA_BOOTSTRAP_SERVERS=broker:9092
# PRIMARY_DB=postgres://user:pass@host:5432/warmbly?sslmode=require
# REDIS=redis://host:6379
@@ -633,7 +633,16 @@ Pair it with `DISABLE_PASSWORD_LOGIN=true` for an SSO-only deployment.
Three things to know:
- **Bare binaries default to the cloud values** (`kafka`, `avro`, `s3`, `aws`). Compose, the Makefile, and both env templates set the local values for you. A hand-rolled environment must set them or the process exits at boot.
- Kafka and Avro are also build-time opt-ins: `--build-arg GO_TAGS=kafka` for Go, `--build-arg CARGO_FEATURES=kafka` for tracking.
- Kafka and Avro are build-time opt-ins, so `EVENTBUS_PROVIDER=kafka` needs a build that has them. Each release publishes those builds as a second tag on the same image, suffixed `-kafka`, for the four services that touch the bus:
```
ghcr.io/warmbly/warmbly/backend:prod-kafka
ghcr.io/warmbly/warmbly/consumer:prod-kafka
ghcr.io/warmbly/warmbly/worker:prod-kafka
ghcr.io/warmbly/warmbly/tracking:prod-kafka
```
`forms`, `updater`, `realtime`, `web`, and `admin` never read the bus and have one image each. To build your own instead, pass `--build-arg GO_TAGS=kafka` (Go) or `--build-arg CARGO_FEATURES=kafka` (tracking); cgo cannot cross-compile, so each architecture has to be built on a machine of that architecture.
- `PUBSUB_ENABLED` must match across backend, consumer, and realtime.
## Optional subsystems
+2 -2
View File
@@ -10,14 +10,14 @@ Warmbly's services communicate asynchronously through a pluggable event bus. The
The bus is an abstraction (`internal/infrastructure/eventbus/`) with two providers, selected by `EVENTBUS_PROVIDER`:
- `nats`: NATS JetStream. One durable stream (default name `warmbly`) holds every topic as a subject under a prefix: topic `jobs.worker-events` becomes subject `warmbly.jobs.worker-events`. Messages are retained for 7 days, consumer groups map to JetStream durable consumers, and failed handlers are redelivered up to 10 times. This is what `docker-compose.yml` runs.
- `kafka`: Apache Kafka behind the `kafka` Go build tag. The default binaries do not include it (no librdkafka, no CGO); build with `GO_TAGS=kafka` to enable it, otherwise `EVENTBUS_PROVIDER=kafka` fails at boot with a clear error.
- `kafka`: Apache Kafka behind the `kafka` Go build tag. The default binaries do not include it (no librdkafka, no CGO), so `EVENTBUS_PROVIDER=kafka` on a default build fails at boot with a clear error. Every service that reads the bus publishes a second image for it, the same tag with a `-kafka` suffix: `ghcr.io/warmbly/warmbly/backend:prod-kafka`, and the same for `consumer`, `worker`, and `tracking`. Building your own needs `GO_TAGS=kafka` (Go) or `CARGO_FEATURES=kafka` (tracking).
Message encoding is orthogonal to transport, selected by `CODEC_PROVIDER`:
- `json`: plain JSON, no external dependencies. Required for the self-host stack (the worker command and result envelopes carry untyped bodies Avro cannot serialize).
- `avro`: Avro with Confluent Schema Registry (`SCHEMA_REGISTRY_URL` plus optional key/secret), also behind the `kafka` build tag. The topic name is used as the Schema Registry subject.
The Rust tracking service follows the same switch: it publishes JSON to JetStream by default, and only speaks Kafka plus Avro when compiled with its `kafka` cargo feature.
The Rust tracking service follows the same switch: it publishes JSON to JetStream by default, and only speaks Kafka plus Avro when compiled with its `kafka` cargo feature, which is what the `tracking:*-kafka` image is.
## Topics