Deployment

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:

copy binary extends buildergolang alpine productionalpine runtime development+ dev tools
StageBaseRole
buildergolang:1.25.12-alpineCompiles the pure-Go binary (CGO_ENABLED=0, -trimpath, version stamped)
productionalpine:3.21Runtime image; non-root user, binary at /usr/local/bin/ductor
developmentproductionAdds 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 portsEXPOSE 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 writingdocker-compose.yml is the live source of truth.

Core services

ServiceImage (as of writing)Ports (host → container)
ductorductor:dev (built, development target)8080:8080, 9090:9090
timescaletimescale/timescaledb:2.25.0-pg165433:5432
dragonflydocker.dragonflydb.io/dragonflydb/dragonfly:v1.39.06379: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-keys is 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
ServiceImage (as of writing)Host port
VictoriaMetricsvictoriametrics/victoria-metrics:v1.108.18428
VictoriaLogsvictoriametrics/victoria-logs:v1.44.09428
VictoriaTracesvictoriametrics/victoria-traces:v0.7.110428 (HTTP), 4317 (OTLP gRPC)
vmagentvictoriametrics/vmagent:v1.108.18429
vmalertvictoriametrics/vmalert:v1.108.18880
Alertmanagerprom/alertmanager:v0.27.09093
Vectortimberio/vector:0.43.1-debian
Grafanagrafana/grafana:11.4.03000

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.