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 |