Build and supply chain

Kaniko: building images without Docker, the secure way

Someone told you to "just mount the Docker socket into CI". What you heard was "give every pull request root on the build node". You heard right. Kaniko builds the same Dockerfile with no daemon at all.

The short answer

Build with kaniko: it runs a Dockerfile in userspace inside an ordinary container, with no Docker daemon and no privileged mode. Use the maintained Chainguard fork, build the executor image yourself and pin it by digest, pin base images by digest, build with --reproducible, and give each build one push-only credential.

Updated Houssam Hammoudi, CTOTested with kaniko v1.25.19 (chainguard-forks/kaniko, built from source), crane, Docker 28 for the socket comparison

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

The classic way to build images in CI is to mount the host's Docker socket into the job, or to run Docker-in-Docker in privileged mode. Both hand the job the Docker API of a daemon that runs as root.

Anyone who controls a job can then ask that daemon for anything: a privileged container, a container with the host's / mounted, or a list of every other job's containers and secrets on the node. In CI, "anyone who controls a job" means anyone who can open a pull request that changes the pipeline or the Dockerfile.

Kaniko removes the daemon. It reads the Dockerfile, pulls the base image, and runs each step inside its own container, then writes the layers to a registry or a tarball. The job has only the rights of its own container.

Two things have changed since most kaniko guides were written. The Google repository is archived, and the gcr.io/kaniko-project/executor images are no longer updated. The maintained fork does not publish images, so you build the executor yourself. That is a supply-chain step in its own right, and this page does it.

What the docs say

kaniko doesn't depend on a Docker daemon and executes each command within a Dockerfile completely in userspace.

Source: kaniko README

This is a supported replacement of the original GoogleContainerTools/kaniko repository, which was archived in June of 2025.

Source: chainguard-forks/kaniko README

Binary release artifacts such as container images are not published. The old container images as gcr.io/kaniko-project/executor and gcr.io/kaniko-project/warmer are unmaintained and no longer updated.

Source: chainguard-forks/kaniko README

First of all, only trusted users should be allowed to control your Docker daemon.

Source: Docker docs, Docker Engine security

The Google README now opens with "This project is archived and no longer developed or maintained." The fork (the old chainguard-dev/kaniko address redirects to chainguard-forks/kaniko) also says "No major feature work is planned." Neither README tells you how to get a trusted executor image now that none is published; that is left to you.

The secure configuration

1. Build the executor from the fork, once, in a trusted pipeline

The fork vendors its Go dependencies, so the build needs no module downloads after the source tarball.

bash
TAG=v1.25.19
wget https://github.com/chainguard-forks/kaniko/archive/refs/tags/${TAG}.tar.gz
sha256sum ${TAG}.tar.gz          # record it; compare it on every rebuild
tar xzf ${TAG}.tar.gz && cd kaniko-${TAG#v}

CGO_ENABLED=0 go build -mod=vendor -trimpath \
  -ldflags "-s -w -X github.com/chainguard-dev/kaniko/pkg/version.version=${TAG}" \
  -o executor ./cmd/executor

2. Package it without a daemon, and pin it

kaniko cannot build its own image from a Dockerfile: it runs from /kaniko, and copying a new /kaniko/executor over the running one fails. crane assembles the image instead: a minimal base plus one layer.

bash
mkdir -p layer/kaniko/.docker && cp executor layer/kaniko/executor
tar --owner=0 --group=0 -C layer -cf executor-layer.tar kaniko

STATIC=$(crane digest cgr.dev/chainguard/static:latest)
crane mutate --platform linux/amd64 "cgr.dev/chainguard/static@${STATIC}" \
  --append executor-layer.tar \
  --entrypoint /kaniko/executor \
  -e PATH=/kaniko -e DOCKER_CONFIG=/kaniko/.docker/ -e HOME=/root -e USER=root \
  -u 0 -w /workspace \
  -t registry.example.com/tools/kaniko-executor:v1.25.19
# crane prints the pushed digest. Pin that digest everywhere, and sign it.

3. Build application images with it

dockerfile
# Dockerfile: the base image is pinned by digest, never by a moving tag.
FROM cgr.dev/chainguard/wolfi-base@sha256:<digest>
COPY hello.sh /usr/local/bin/hello.sh
RUN chmod 0555 /usr/local/bin/hello.sh
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/hello.sh"]

The build is one ordinary, unprivileged container: no socket, no --privileged. Written as a docker run (the form the test below used):

bash
docker run --rm \
  -v "$PWD:/workspace:ro" \
  -v "$PWD/out:/out" \
  registry.example.com/tools/kaniko-executor@sha256:<executor-digest> \
  --context dir:///workspace \
  --dockerfile /workspace/Dockerfile \
  --destination registry.example.com/team-a/app:1.0.0 \
  --digest-file /out/app.digest \
  --reproducible

--reproducible strips timestamps so the same inputs give the same digest. --digest-file records the exact digest that was pushed, which is what you sign, scan and deploy. For pull requests, add --no-push --tar-path /out/app.tar: the build is tested, and the job holds no push credential at all.

On Kubernetes, the same arguments go into a Job, and the CI job only creates that Job and waits for it. The executor image has no shell, so CI systems that run each step through sh inside the job container cannot use it directly. Some teams add busybox to a separate "debug" executor for those systems; the Job keeps the image smaller and the CI job simpler. The companion page on containing a root kaniko build has the full pod spec: five capabilities, no service account token, egress to the registry only.

Prove it

The executor built from the fork's source reports its version:

bash
docker run --rm example.com/tools/kaniko-executor:v1.25.19 version
text
Kaniko version :  v1.25.19

Two builds of the same Dockerfile and context, to a tarball, with no daemon access (log lines about snapshots trimmed):

bash
docker run --rm -v ctx:/workspace:ro -v out:/out example.com/tools/kaniko-executor:v1.25.19 \
  --context dir:///workspace --dockerfile /workspace/Dockerfile --destination example.com/team-a/app:1.0 \
  --no-push --tar-path /out/app.tar --digest-file /out/app.digest --reproducible
text
level=info msg="Retrieving image cgr.dev/chainguard/wolfi-base@sha256:<digest> from registry cgr.dev"
level=info msg="Building stage 'cgr.dev/chainguard/wolfi-base@sha256:<digest>' [idx: '0', base-idx: '-1']"
level=info msg="COPY hello.sh /usr/local/bin/hello.sh"
level=info msg="RUN chmod 0555 /usr/local/bin/hello.sh"
level=info msg="Running: [/bin/sh -c chmod 0555 /usr/local/bin/hello.sh]"
level=info msg="USER 65532:65532"
level=info msg="ENTRYPOINT [\"/usr/local/bin/hello.sh\"]"
level=info msg="Skipping push to container registry due to --no-push flag"
digest: sha256:357eed1e52a71547c29d70646f4ea6e921eb39dd2fcc063b0fb2124fb8096a71

The second build printed the same lines and the same digest:

text
digest: sha256:357eed1e52a71547c29d70646f4ea6e921eb39dd2fcc063b0fb2124fb8096a71

The image runs as a non-root user:

bash
docker load -i out/app.tar && docker run --rm example.com/team-a/app:1.0
text
Loaded image: example.com/team-a/app:1.0
built by kaniko, running as uid 65532

For comparison, what a CI job with the Docker socket can do in one line: ask the daemon for a privileged container.

bash
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:28-cli \
  docker run --rm --privileged alpine:3.22 grep CapEff /proc/self/status
text
CapEff:	000001ffffffffff

Every capability bit set. From there, mounting the host's disk is one more flag.

Mistakes people make

Still pulling gcr.io/kaniko-project/executor:latest

Those images are frozen at v1.24.0 and will not get fixes. A floating tag on an unmaintained image is two problems at once. Build from the fork and pin your own digest.

Trying to build the kaniko image with kaniko

Copying a new executor to /kaniko/executor fails with "text file busy", because kaniko is running from that path. Assemble the executor image with crane (or another daemonless tool) instead.

Assuming kaniko makes untrusted builds safe

Kaniko runs as root inside its container. Its README says it "does not make it safe to run untrusted builds". Drop capabilities, remove the service account token, restrict egress, and use a dedicated node pool.

A push credential in every build

A RUN step can read the kaniko Docker config. Pull request builds use --no-push --tar-path and get no credential. Main-branch builds get a credential that can push to one repository.

Floating base images

FROM python:3 builds something different every week, and a registry compromise flows straight into your image. Pin base images by digest and update them with a reviewed pull request.

Reusing the kaniko container for a second build

Kaniko unpacks the base image over its own filesystem. One build per container, then throw it away.

Checklist

  • No CI job mounts the Docker socket or runs privileged Docker-in-Docker.
  • The kaniko executor is built from the maintained fork at a pinned tag, with the source tarball hash recorded.
  • The executor image is pinned by digest (and signed) in every pipeline.
  • Every FROM in every Dockerfile is pinned by digest.
  • Builds use --reproducible and --digest-file.
  • Pull request builds use --no-push --tar-path and hold no registry credential.
  • Main-branch builds hold a push credential for one repository only.
  • Each build runs in a fresh container or pod.
  • The kaniko pod follows the containment settings on the companion page.

The Docker socket was never a build tool; it was a root shell with good marketing. Kaniko builds the same image and hands out a lot less.

H2-CSDE

Learn it on a live range

Building without Docker, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.

Start free

H2 Scanner

Want this caught before it merges?

The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.

Talk to us