Docker & Compose
The Ductor container image, its ports and user, and the docker-compose stack.
Ductor ships a multi-stage Dockerfile (docker/Dockerfile) that produces a
small, non-root Alpine image, plus a docker-compose.yml that wires it to
TimescaleDB and Dragonfly for local development.
Use the Makefile targets, not raw compose
Prefer make up (core) and make up-full (core + observability). Both pass
--build and stamp the current git VERSION/BUILD_TIME into the image, so
you can never silently run a stale binary. A bare docker compose up -d
reuses the existing ductor:dev image, which may be out of date relative
to your working tree — use it only when you know the image is current, or add
--build yourself.
The image
The build has three stages:
| Stage | Base | Role |
|---|---|---|
builder | golang:1.25.12-alpine | Compiles the pure-Go binary (CGO_ENABLED=0, -trimpath, version stamped) |
production | alpine:3.21 | Runtime image; non-root user, binary at /usr/local/bin/ductor |
development | production | Adds bash, curl, jq, postgresql-client, redis for local work |
The web dashboard is built and served from the separate ductor-web repository,
so it is not part of this image.
Key properties of the production stage:
- Non-root — runs as user/group
ductor(uid 1000 / gid 1000). - Binary —
/usr/local/bin/ductor; configs at/app/configs;WORKDIR /app. - Exposed ports —
EXPOSE 8080 50051 9090. - Healthcheck — every 10s:
wget -q --spider http://localhost:8080/health. - Entrypoint —
["/usr/local/bin/ductor"].
gRPC now rides Connect on :8080
The serve command defines only --http-addr and --metrics-addr. There is
no separate gRPC listener — Connect/gRPC traffic is served on the HTTP port
(:8080) alongside REST. The image still bakes a legacy
CMD [..., "--grpc-addr=:50051", ...] and EXPOSEs 50051, but that flag no
longer exists, so production compose files override the command to drop it (see
Dokploy). The development target's command
already omits it.
Build it yourself with version metadata:
docker build -f docker/Dockerfile \
--target production \
--build-arg VERSION=$(git describe --tags --always) \
--build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
-t ductor:latest .make docker wraps this for you.
The compose stack
docker-compose.yml (project name ductor) defines the core services plus an
observability profile. Image tags below are the pinned values as of writing —
docker-compose.yml is the live source of truth.
Core services
| Service | Image (as of writing) | Ports (host → container) |
|---|---|---|
ductor | ductor:dev (built, development target) | 8080:8080, 9090:9090 |
timescale | timescale/timescaledb:2.25.0-pg16 | 5433:5432 |
dragonfly | docker.dragonflydb.io/dragonflydb/dragonfly:v1.39.0 | 6379:6379 |
Service names and the Postgres host port
Postgres is the timescale service; the Redis-compatible cache is
dragonfly. Inside the network Ductor reaches them at timescale:5432 and
dragonfly:6379. On the host, Postgres is published on 5433 by default
(override with PG_PORT) so it doesn't collide with a native Postgres.app or
system Postgres on 5432.
The compose file also maps 50051:50051 on the ductor service, but nothing
binds it — see the gRPC note above. HTTP (8080) carries both REST and
Connect/gRPC; metrics are on 9090.
The ductor service is wired with development defaults, including:
DUCTOR_DATABASE_URL: postgres://ductor:ductor@timescale:5432/ductor?sslmode=disable
DUCTOR_CACHE_URL: redis://:ductor@dragonfly:6379
DUCTOR_DATABASE_AUTO_MIGRATE: "true"
DUCTOR_AUTH_ENABLED: "false"
DUCTOR_AUTH_ALLOW_ANONYMOUS: "true"
DUCTOR_CONNECTOR_ENCRYPTION_KEY: <base64 32-byte dev key>Dragonfly flags
The dragonfly service runs with two flags Ductor requires:
--default_lua_flags=allow-undeclared-keys
--dbfilename=--default_lua_flags=allow-undeclared-keysis required by the tiered queue's custom-concurrency Lua. Those scripts build concurrency keys dynamically from queue-item data, which Dragonfly's strict mode would reject.--dbfilename=disables snapshot loading. In this cache/queue role the data is rebuildable, so snapshots are turned off.
Observability profile
VictoriaMetrics, VictoriaLogs, VictoriaTraces, vmagent, vmalert, Alertmanager,
Vector, and Grafana are defined behind the observability profile:
make up-full
# equivalently: docker compose --profile observability up -d --build| Service | Image (as of writing) | Host port |
|---|---|---|
| VictoriaMetrics | victoriametrics/victoria-metrics:v1.108.1 | 8428 |
| VictoriaLogs | victoriametrics/victoria-logs:v1.44.0 | 9428 |
| VictoriaTraces | victoriametrics/victoria-traces:v0.7.1 | 10428 (HTTP), 4317 (OTLP gRPC) |
| vmagent | victoriametrics/vmagent:v1.108.1 | 8429 |
| vmalert | victoriametrics/vmalert:v1.108.1 | 8880 |
| Alertmanager | prom/alertmanager:v0.27.0 | 9093 |
| Vector | timberio/vector:0.43.1-debian | — |
| Grafana | grafana/grafana:11.4.0 | 3000 |
See Observability for how these fit together.
Overriding config
Every setting is an env var on the ductor service. For anything larger than a
handful of overrides, pass a whole YAML document via DUCTOR_CONFIG, or mount a
ductor.yaml and point --config at it.
Auto-migrate is a dev convenience
The compose default DUCTOR_DATABASE_AUTO_MIGRATE: "true" is fine for local
work. In production, leave it false and run ductor migrate up explicitly —
see Database migrations.