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, in a scope bound to the named context (kici-admin context bind). A prod: reference is read through the bindings of the prod context, the same way a job’s context secrets are. If no scope bound to the prod context carries the secret, the reference is refused: a scope that only shares the context’s name is not read. 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.

A job that one of its own contexts held (required reviewers, a wait timer) gets its registry credentials when the hold is released. The approval stands for that context, so its rules are not run again. A reference to any other context is still checked against that context’s rules at release.

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 job without container runs in the agent’s own image when a container pool of the auto-scaler runs it. The published image, quay.io/kici-dev/kici-agent, is Debian 13 with these tools:

ToolsDebian package
git, bash, curl, perlas named
gzip, xz, unzip, zstdgzip, xz-utils, unzip, zstd
ps, hostname, ip, ping, whichprocps, hostname, iproute2, iputils-ping, debianutils
jq, file, less, rsync, sudojq, file, less, rsync, sudo
node, npm, pnpm, yarnthe image’s Node.js build

Steps run as the node user. In a container that the auto-scaler starts, node can run sudo without a password, so a step can install a package:

step('install-tree', async (ctx) => {
await ctx.$`sudo apt-get update`;
await ctx.$`sudo apt-get install -y tree`;
});

To install packages before any step runs, use the apt init preset: init: { apt: { packages: ['make'] } }.

Each container that the auto-scaler starts from the pool’s image runs one job and is then removed. A package that one job installs never reaches another job.

In an agent container that you start yourself, steps cannot run sudo. Start it with --group-add 1990 to give its steps the same sudo. The container then runs job after job, so a change one job makes as root stays for the next job.

The image has no compilers, no Python, no editors and no ssh client. When a job needs many tools, set container on the job to a fuller image, such as debian:13 or your own image. That is faster than installing the tools in every run.

A job that pushes with an SSH-key git credential needs an ssh client: see Git credentials.

A container job needs a container runtime on the host that runs it. Each agent reports whether its host has one, as the kici:runtime:docker or kici:runtime:podman label.

KiCI checks that label for you. A container: job runs only on the agent the auto-scaler started for it, or on an agent that reports a runtime. When no running agent fits, the auto-scaler starts one for the job. An agent started inside a job’s own image runs only that job.

An agent older than KiCI 0.10.0 can run container jobs through a remote DOCKER_HOST without reporting a runtime label, so its missing label proves nothing. KiCI does not keep container jobs off such an agent.

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 hosts have a runtime and some do not, 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 before it clones anything. The error names the missing runtime and the agent’s labels.

An agent that stays outside your image can install your .kici/ dependencies on its own host. It installs them into the checkout before it copies the tree into the container. Your npm registry then needs to be reachable from the agent host only, not from the job’s network. This is what lets a container job use a private registry, or one that only the agent host can resolve.

A container job’s host install runs no code from your repository. The agent installs on its host only when all of these are true:

  • Install scripts are disabled on the agent, which is the default. An agent with KICI_ALLOW_INSTALL_SCRIPTS=true leaves the install to the container.
  • The agent injects the KiCI runtime. The steps then run on the KiCI runtime, in a glibc image on the same CPU architecture as the host, so the modules the host installs load in the container.
  • The run has no cached dependencies. When the orchestrator sends a cached dependency tarball, the container restores it.
  • .kici/ is a plain npm or pnpm project with registry dependencies only. A yarn project, a workspace, a pnpm hook (a .pnpmfile.cjs or a pnpmfile setting), a git, file, link or URL dependency or override, or a symlinked .kici file leaves the install to the container. So does a registry in your registries: block or .kici/.npmrc, or a lockfile URL, outside the registries the operator allows, or an .npmrc with CRLF line endings or another control character.
  • The agent has its own npm or pnpm to run the install. On a Debian or Ubuntu agent host, the separately packaged npm counts when it is version 11.10.0 or later.
  • With an agent npm older than 11.15.0, .kici/ has a lockfile that pins every package to a registry tarball. That npm cannot refuse a URL dependency itself, so the agent runs npm ci. Commit the package-lock.json that npm install writes, and keep it in step with package.json. Each locked package needs its resolved URL, so do not set omit-lockfile-registry-resolved.

The agent runs its own npm or pnpm in a separate directory, not in your checkout. The install reads only the registry and registry-auth keys of your .kici/.npmrc, plus the registries the job’s registries: resolved. Naming a registry in your workflow does not let the host install contact it. Each registry must be the public npm registry, one the agent’s own npm configuration names, or one the operator lists in KICI_HOST_INSTALL_REGISTRIES. An install secret reaches the host install only when a registry-auth value in your .kici/.npmrc references it. TLS and proxy settings come from the agent’s own configuration. The install runs with the agent’s install environment, not your job’s env. See Agent configuration for the exact allowlist.

When the install runs inside the container, the registry must be reachable from the job’s network. A failed install on the host fails the job with the installer’s error: its exit code, the end of its stderr and stdout, and for npm the end of npm’s debug log. The job’s setup log records the host checkout and the host install. kici runs logs <run-id> prints it under a <job> › (setup) heading, the dashboard shows it as the job’s Setup section, and kici-admin runs logs <run-id> --job <job-id> --step=-1 prints it on its own.

All of this applies to a container job’s own install. The jobs that prepare your workflow run on the agent host. The __build__, __init__, __dynamic__ and __globaleval__ jobs install .kici/ with your project’s own package manager, and all of them except __build__ also import your workflow module there. __globaleval__ is the pre-run evaluation of an organization-wide workflow. An agent runs those jobs too unless its operator set KICI_ROLES to leave them to dedicated agents. See Agent configuration.

  • 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.