Skip to content

Deploying it

What a Dutest other people depend on needs, beyond the one in install and run: where its bytes go, which keys hold the only copy of something, and how agents arrive.

Images

The Gradle build containerises the controller and the agent through Jib, which assembles and uploads an image over the registry API. That needs no Docker daemon on the machine doing it, and Gradle fetches the JDK the build asks for, so a release can be cut from anything with a network.

./gradlew -Pimage.tag=2026.8.31 :controller:jib :agent:jib

Tag with the commit and deploy that tag. latest is a convenience, and a rollback that has to work is a rollback to a tag naming what it is.

On Kubernetes

The repository carries manifests under deploy/kubernetes: the controller as a Deployment, PostgreSQL, a blob store, and agents as a StatefulSet.

Agents are a StatefulSet rather than a Deployment for a reason worth keeping if you write your own: each replica needs a volume of its own to keep its credential on, and an ordinal that is the same agent after a restart. Replicas of a Deployment share one volume, so they share one token file, so they are one agent with several streams displacing each other.

Scaling then needs nothing issued and nobody signed in, as long as the controller and the agents read the same join token:

kubectl scale statefulset/dutest-agent --namespace dutest --replicas=6

Agents that appear when there is work

An enrolled agent is a machine that stays. The controller can also schedule its own: when work is queued that nothing connected can take, it creates a Kubernetes Job running the agent image. That agent enrols itself with a single-use token, runs exactly one job, and ends — taking its workspace, its caches and anything the build left behind with it.

dutest:
  agent:
    kubernetes:
      enabled: true
      image: registry.example.com/dutest/agent:2026.8.31
      # A pod dials this. Nothing in the process can work out its own address.
      controller-host: dutest-controller
      max-agents: 6

It needs permission to create and delete Jobs in its namespace — a Role and a RoleBinding on its service account — and says at startup if it is turned on and cannot work, rather than quietly never scaling.

What it starts a pod for is work that has nowhere to run, not queue depth. A job an idle agent could take needs no pod, and a job no pod of that shape could run — a Docker job where the pods are native, or one requiring a capability they do not advertise — never gets one. The two kinds run together, and that is the point: an enrolled agent keeps its warm caches and builds fastest, and these absorb the rush it cannot get to.

A pod can carry a Docker daemon of its own, under dutest.agent.kubernetes.docker-in-docker, which is how a build gets a database or a message broker without one being deployed for it. It is off by default because that daemon runs privileged: anything that gets into it is root on the node. A pod that has one advertises dockerd=true, so a job that starts containers can require it and wait rather than fail on the first one it starts. That is a different claim from the Docker runtime, which is where a job's own steps run; the pod also offers that runtime when it carries a daemon.

Pulling a private image

A job's image is pulled by the daemon beside the agent, minutes after the agent's own image was. On Kubernetes that means imagePullSecrets on the pod does nothing for it: that is the kubelet's credential, for the agent's image, and the daemon inside the pod has never heard of it.

An agent presents its own credential instead, from three environment variables:

Variable What it is
REGISTRY_USERNAME The account the fleet pulls as
REGISTRY_PASSWORD Its password or token
REGISTRY_URL The registry it is for, such as https://harbor.example.com

It is only ever presented to the registry named by REGISTRY_URL. Without that, a credential set once on an agent would go out with every pull — a working password for your Harbor sent to Docker Hub along with a request for postgres:17-alpine.

That is the fleet's answer, for an installation behind one company registry. A pipeline that needs a different credential for one image names a secret variable beside that image instead, and that wins; registry credentials in the reference says how.

More than one controller

Several controllers can share a database. Each drains the same queue safely and gives work to the agents connected to itself, leaving anything it cannot serve for one that can. The pages say the same thing whichever controller answers, because whether an agent is up comes from its heartbeat rather than from the streams one pod happens to hold.

The limit worth knowing: a controller reaches only its own agents. A job waits until a controller that owns a matching agent picks it up, so spread agents across controllers rather than pointing them all at one.

Where the bytes go

Logs and artifacts go to dutest.storage.filesystem.root by default, which wants a volume that survives the pod. The other option is any S3-compatible bucket, which is what more than one controller wants, since a directory on one of them is not a store the others can read.

dutest:
  storage:
    type: s3
    s3:
      bucket: dutest-artifacts
      region: eu-west-1

Retention is off until you set it. dutest.retention.days and dutest.retention.builds keep builds by age and by count, and each plan can override both. A build named by a release is never removed, nor the newest successful build, nor anything still running.

The two keys that hold the only copy

Secrets are encrypted at rest with a key generated on first use into dutest.secrets.key-file, or set from wherever you keep keys as dutest.secrets.encryption-key. Artifacts are signed for provenance with a second one, in dutest.provenance.key-file.

Back both up, and prefer a Secret to a file on the artifact volume: losing the volume then costs artifacts, rather than every stored credential on the server.

In front of it

Terminate TLS in front of the controller and let dutest.base-url name the address people actually use. Agents connect to the gRPC port — 9090 by default — which is a separate route from the HTTP one, and an agent outside the cluster needs it published.

Watching it

The meters are at /actuator/prometheus: jobs queued and running, agents connected and how many have room for more work, what each plan's builds did and how long they took, and how long a job waited before an agent picked it up.

That last one is the number that says whether to add agents. Build duration cannot: an hour queued and a minute compiling looks exactly like an hour compiling.

Every job also records what it spent — peak memory against its limit, CPU used, bytes read and written through the disk, how full the workspace's volume got against what it holds, bytes pulled and pushed over the network, and whether anything in it was killed for running out of memory. It is on a Metrics tab of the build, charted over the run as well as summarised, because a peak says how high a build went and not how long it was there — and because a job holding no memory and burning no CPU for an hour is a job waiting on a network, which nothing else here can tell from a slow compile.

Controller settings

Setting Default Notes
spring.datasource.url jdbc:postgresql://localhost:5432/dutest
spring.grpc.server.port 9090 Where agents connect
dutest.base-url http://localhost:8080 Every address handed out: links in a forge or a notification, and the webhook URL a repository page shows
dutest.auth.local-login-enabled true Turn off for OIDC only
dutest.auth.bootstrap-admin-password generated Logged once on first start
dutest.storage.type filesystem Or s3
dutest.storage.filesystem.root ./data/blobs Logs and artifacts
dutest.secrets.encryption-key unset Base64 32-byte AES key
dutest.secrets.key-file ./data/secret-key Where a generated key is kept
dutest.agent.join-token unset Shared secret an agent may join with; at least 32 characters
dutest.agent.kubernetes.enabled false Schedule an agent per queued job
dutest.agent.kubernetes.max-agents 10 Ceiling on pods in flight
dutest.audit.retention-days 2555 How far back the trail answers; 0 keeps everything
spring.mail.host unset Required for email notifications