Skip to content

Container jobs

A job can run inside a container image you choose. Set container on the job:

job('build', {
runsOn: ['kici:os:linux'],
container: 'python:3.12-slim',
steps: [compile, test],
});

Every step then runs inside that image.

KiCI supplies its own runtime. It mounts a Node build and the step runner into the container, read-only, and runs the steps with that Node. In most setups it also clones your repository outside the image and copies the tree in.

So the image does not need Node, and does not need npm.

Every container job needs these two:

RequirementWhyWhen it is checked
A GNU C library (glibc) — the loader at /lib64/ld-linux-x86-64.so.2 on x64, or /lib/ld-linux-aarch64.so.1 on arm64The Node build KiCI mounts in is linked against glibcBefore the job starts
A shell at /bin/shSteps run shell commandsBefore the job starts

If the image fails either, the job fails immediately and the error names the reason — you do not wait for a build to reach its first step.

Two more apply only when your pool runs your image as the agent. See Which pool runs your image how to find out which case you are in:

RequirementWhyWhen it is checked
git on PATHThe agent clones your repository, and here the agent is inside your imageAt agent startup
bash on PATHThe agent needs it to run stepsAt agent startup

These two are not checked ahead of time. The agent fails to start, and the job waits for an agent that never arrives.

Most build images provide all four. python, golang, node and rust ship git and bash; their -slim variants often drop git — python:3.12-slim is one that does.

Alpine uses musl instead of glibc, so KiCI refuses it:

image 'alpine:3.20' uses musl libc; the musl runtime variant is not enabled
(glibc images only in this version). Use a glibc image — for example the
'-slim' rather than the '-alpine' tag.

Pick the glibc build of the same image. python:3.12-slim instead of python:3.12-alpine, node:24-slim instead of node:24-alpine.

A pool runs your image one of two ways, and they ask for different things.

  • The agent stays outside your image and starts a second container from it. The agent clones and copies the tree in, so your image needs only glibc and a shell. A bare-metal pool works this way, and so does any agent that has its own container runtime.
  • The agent runs inside your image. A container pool does this for a job that names its own image: your image becomes the agent. Your image then needs git and bash as well.

The second form fails at agent startup with:

Agent required-tools validation failed:
- 'git' not found on PATH — required for repository checkout
This agent runs inside the job's own container image, so that image must provide
these tools. Either add them to the image, or run the job on a pool whose agent
stays outside it.

If your image lacks git or bash, either add them, or run the job on a pool of the first kind.

Point container at a Dockerfile in your repository instead of naming an image. KiCI builds it before the job starts, and runs the job in the result:

job('build', {
runsOn: ['kici:os:linux'],
container: {
dockerfile: '.kici/ci.Dockerfile',
context: '.', // default: the repository root
target: 'ci', // optional build stage
args: { NODE_VERSION: '24' },
},
steps: [compile],
});

Set image or dockerfile, never both. KiCI rejects a workflow that sets both, or neither, when you define it.

The build runs on the agent that runs the job, after it clones your repository. So the build context is the tree at the commit that triggered the run, and your Dockerfile’s COPY sees exactly that code. .dockerignore applies as usual.

The image is built every run. Your container runtime’s layer cache does the work, exactly as it does on your own machine: a run that changes nothing below a COPY reuses those layers and finishes in seconds.

Nothing is uploaded. The image lives on the agent host that built it, and KiCI removes the tag after the job. A different host, or a host whose cache was pruned, builds again.

A build argument is recorded in the built image’s history, so anyone who can read the image can read the value. args therefore takes plain strings only — you cannot point one at a secret. Pass a secret to a step instead.

A Dockerfile build runs your RUN commands on the agent host, outside the sandbox that contains your job’s steps. So KiCI refuses one on an untrusted ref — a fork pull request, or a contributor whose access it cannot confirm.

Your operator allows it per organization:

Terminal window
kici-admin org-settings allow-untrusted-dockerfile-builds true --org <org>

A trusted ref — a push to your default branch, or a pull request from a contributor with write access — builds without that setting.

A schedule fire and the auto-scaler’s kici.scaler.scale-up / kici.scaler.scale-down events are trusted refs. No run causes them. So a cron-fired Dockerfile job builds, and so does one in a provisioning or teardown workflow.

Every other internal trigger runs at the trust tier of the run that caused it. A workflow triggered by ctx.emit() carries the emitting run’s tier. A workflow triggered by a run completing carries that run’s tier. So a completion or an emit from an untrusted ref cannot build a Dockerfile.

The agent host needs docker or podman on its PATH, not only a container runtime socket. Agents report this themselves, and KiCI routes a Dockerfile job only to one that can build — so a pool without a CLI is skipped rather than failing your job partway. The built image itself needs only what any container job’s image needs: a glibc and /bin/sh. It never needs git or bash, because a built image is always run by an agent that stays outside it.

Point auth at secrets that hold the registry credentials. Every value is the name of a secret, in <context>:<secret-name> form — the same form gitCredentials uses:

job('build', {
runsOn: ['kici:os:linux'],
container: {
image: 'reg.internal:5000/acme/ci:1.2',
auth: { usernameSecret: 'prod:REGISTRY_USER', tokenSecret: 'prod:REGISTRY_TOKEN' },
},
steps: [compile],
});

Store the secrets first with kici-admin secret set. Pasting a token straight into the workflow is rejected when the workflow is defined, because a token written into .kici/ would be committed to your repository.

The named context’s protection rules run before the secret is read, exactly as they do for git credentials. A prod: reference from a branch the prod context restricts is refused, and the job is dispatched with no registry credentials — so a private image fails to pull rather than being pulled from a branch the context does not allow. The rule that refused it is named in your orchestrator’s log, not in the run.

An untrusted ref receives no registry credentials. A fork pull request is dispatched without them, so a private base image fails to pull and a public one is unaffected. The run’s reduced-privilege note says so.

The username is not a secret, so you may write it directly:

auth: { username: 'ci-bot', tokenSecret: 'prod:REGISTRY_TOKEN' }

Your orchestrator resolves these names at dispatch and sends only the resolved credentials to the agent. The agent never reads your secret store.

auth also takes a registry — the registry host the credentials belong to, such as reg.internal:5000.

With image it is optional, because KiCI reads the host off the image reference. With dockerfile it is required: the base image is named inside your Dockerfile, so there is nothing to read it from. A dockerfile job whose auth omits registry is refused when you define the workflow.

container: {
dockerfile: '.kici/ci.Dockerfile',
auth: {
registry: 'reg.internal:5000',
usernameSecret: 'prod:REGISTRY_USER',
tokenSecret: 'prod:REGISTRY_TOKEN',
},
},

With dockerfile, these credentials pull the Dockerfile’s own FROM base — not a job image, since the job image is the one KiCI builds.

A token fetched during the run — from a cloud registry’s login command, for example — has no secret to name. Use the *Value half of the pair instead:

auth: { username: 'AWS', tokenValue: fetchedAtRuntime }

A container job needs a container runtime on the host that runs it. KiCI does not check that for you: your orchestrator cannot see what a given agent host has installed.

The host also needs a copy of the KiCI runtime to mount in. Every pool your auto-scaler provisions gets one automatically, from the agent image the pool is configured with. An agent you start by hand needs KICI_RUNTIME_IMAGE set to a kici-agent image — see Agent configuration. Without it, the job runs on the image’s own node, so the image must ship one.

If some of your pools have a runtime and some do not, label them and say so on the job:

job('build', {
runsOn: ['kici:os:linux', 'kici:runtime:docker'],
container: 'python:3.12-slim',
steps: [compile],
});

A job that reaches a host with no runtime fails with an error naming what is missing, rather than running incorrectly.

  • glibc only. A musl image fails the preflight. Support for musl is a planned follow-up.
  • The image is pulled fresh when it is not already on the host. A large image costs that pull on the first job that uses it.
  • git inside your steps still needs git in the image. KiCI clones for you, but a step that runs git itself uses the image’s own copy.