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.
Your image needs almost nothing
Section titled “Your image needs almost nothing”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.
What the image must provide
Section titled “What the image must provide”Every container job needs these two:
| Requirement | Why | When 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 arm64 | The Node build KiCI mounts in is linked against glibc | Before the job starts |
A shell at /bin/sh | Steps run shell commands | Before 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:
| Requirement | Why | When it is checked |
|---|---|---|
git on PATH | The agent clones your repository, and here the agent is inside your image | At agent startup |
bash on PATH | The agent needs it to run steps | At 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 and other musl images
Section titled “Alpine and other musl images”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.
Which pool runs your image how
Section titled “Which pool runs your image how”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 checkoutThis agent runs inside the job's own container image, so that image must providethese tools. Either add them to the image, or run the job on a pool whose agentstays outside it.If your image lacks git or bash, either add them, or run the job on a pool of the first kind.
Build the image from a Dockerfile
Section titled “Build the image from a Dockerfile”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.
What is reused between runs
Section titled “What is reused between runs”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.
Build arguments are not secrets
Section titled “Build arguments are not secrets”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.
Who may build
Section titled “Who may build”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:
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.
Requirements
Section titled “Requirements”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.
Private images
Section titled “Private images”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.
Naming the registry
Section titled “Naming the registry”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.
Credentials that only exist at run time
Section titled “Credentials that only exist at run time”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 }The default agent image
Section titled “The default agent image”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:
| Tools | Debian package |
|---|---|
git, bash, curl, perl | as named |
gzip, xz, unzip, zstd | gzip, xz-utils, unzip, zstd |
ps, hostname, ip, ping, which | procps, hostname, iproute2, iputils-ping, debianutils |
jq, file, less, rsync, sudo | jq, file, less, rsync, sudo |
node, npm, pnpm, yarn | the 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.
Where container jobs run
Section titled “Where container jobs run”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.
Where your .kici dependencies install
Section titled “Where your .kici dependencies install”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=trueleaves 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.cjsor apnpmfilesetting), a git, file, link or URL dependency or override, or a symlinked.kicifile leaves the install to the container. So does a registry in yourregistries:block or.kici/.npmrc, or a lockfile URL, outside the registries the operator allows, or an.npmrcwith 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 runsnpm ci. Commit thepackage-lock.jsonthatnpm installwrites, and keep it in step withpackage.json. Each locked package needs itsresolvedURL, so do not setomit-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.
Limits worth knowing
Section titled “Limits worth knowing”- 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.
gitinside your steps still needs git in the image. KiCI clones for you, but a step that runsgititself uses the image’s own copy.