# Docker & Compose (/docs/deployment/docker)



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.

<Callout title="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.
</Callout>

## The image [#the-image]

The build has three stages:

```mermaid
flowchart LR
  builder["builder<br/>golang alpine"] -->|copy binary| production["production<br/>alpine runtime"]
  production -->|extends| development["development<br/>+ dev tools"]
```

| 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"]`.

<Callout type="warn" title="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 `EXPOSE`s `50051`, but that flag no
  longer exists, so production compose files override the command to drop it (see
  [Dokploy](/docs/deployment/dokploy)). The `development` target's command
  already omits it.
</Callout>

Build it yourself with version metadata:

```bash
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 [#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 [#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`              |

<Callout type="warn" title="Service names and the Postgres host port">
  Postgres is the &#x2A;*`timescale`*&#x2A; service; the Redis-compatible cache is
  &#x2A;*`dragonfly`**. Inside the network Ductor reaches them at `timescale:5432` and
  `dragonfly:6379&#x60;. On the host, Postgres is published on &#x2A;*`5433`** by default
  (override with `PG_PORT`) so it doesn't collide with a native Postgres.app or
  system Postgres on `5432`.
</Callout>

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:

```yaml
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 [#dragonfly-flags]

The `dragonfly` service runs with two flags Ductor requires:

```text
--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 [#observability-profile]

VictoriaMetrics, VictoriaLogs, VictoriaTraces, vmagent, vmalert, Alertmanager,
Vector, and Grafana are defined behind the `observability` profile:

```bash
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](/docs/operations/observability) for how these fit together.

## Overriding config [#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.

<Callout title="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](/docs/operations/migrations).
</Callout>
