Containing a root Kaniko build, the secure way
You moved off the Docker socket, celebrated, and then read the fine print: kaniko still runs as root. It is a smaller root, in a box you choose. The job now is to make that box as small as it can be.
The short answer
Run kaniko as uid 0 but with every capability dropped except CHOWN, DAC_OVERRIDE, FOWNER, SETUID and SETGID, no privilege escalation, the default seccomp profile, no service account token, a read-only context, a push-only credential for one repository, egress to the registry only, and a dedicated node pool.
On this page
What goes wrong
Kaniko builds an image by unpacking the base image over its own root
filesystem and running each RUN line there. That is why it needs uid 0
inside its container. It does not need the Docker daemon, and it does not
need privileged: true.
The trap is to stop there. A kaniko pod with the default container settings
still has fourteen Linux capabilities, a mounted service account token, full
network egress and, often, a registry credential that can push to every
repository. Every RUN line in the Dockerfile runs as root with all of that.
The Dockerfile comes from a pull request, so anyone who can open one can run
code there.
What that code can do: read the push credential and overwrite a production image tag, call the Kubernetes API with the pod token, scan your internal network, or mine coins on a node that also runs production pods. None of these needs a container escape.
What the docs say
kaniko by itself does not make it safe to run untrusted builds inside your cluster, or anywhere else.
Source: kaniko README, Security
kaniko relies on the security features of your container runtime to provide build security.
Source: kaniko README, Security
In particular, it cannot use chroot or bind-mount because its container must not require privilege, so it unpacks directly into its own container root and may overwrite anything already there.
Source: kaniko README, Known Issues
The README tells you the runtime must do the work, but it never lists which capabilities kaniko needs. So most setups keep the runtime defaults, which are far wider than a build requires. The Google repository is archived; the same text is in the maintained fork at chainguard-forks/kaniko.
The secure configuration
A Job in its own namespace. Each line that matters has a comment.
apiVersion: v1
kind: Namespace
metadata:
name: build
labels:
# kaniko must run as uid 0, so "restricted" cannot pass.
# "baseline" still forbids privileged, host namespaces and hostPath.
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/warn: restricted
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: kaniko
namespace: build
automountServiceAccountToken: false # a build step never needs the Kubernetes API
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: kaniko-egress
namespace: build
spec:
podSelector:
matchLabels:
app: kaniko
policyTypes: [Ingress, Egress]
ingress: [] # nothing may connect to a build pod
egress:
- to: # DNS only to the cluster resolver
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
- to: # the one registry (mirror) the build may use
- ipBlock:
cidr: 192.0.2.10/32
ports:
- { protocol: TCP, port: 443 }
---
apiVersion: batch/v1
kind: Job
metadata:
name: build-app
namespace: build
spec:
backoffLimit: 0
activeDeadlineSeconds: 1800 # a hung or abused build dies after 30 minutes
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app: kaniko
spec:
serviceAccountName: kaniko
automountServiceAccountToken: false
restartPolicy: Never
hostUsers: false # user namespace: uid 0 in the pod is not uid 0 on the node
runtimeClassName: gvisor # optional second wall; remove if the pool has no gVisor
enableServiceLinks: false
nodeSelector:
pool: build # builds never share a node with production pods
tolerations:
- { key: dedicated, operator: Equal, value: build, effect: NoSchedule }
securityContext:
seccompProfile:
type: RuntimeDefault
initContainers:
- name: fetch-context
image: cgr.dev/chainguard/git@sha256:0000000000000000000000000000000000000000000000000000000000000000
args: ["clone", "--depth=1", "--branch=main", "https://git.example.com/team-a/app.git", "/workspace"]
securityContext:
runAsUser: 65532
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
volumeMounts:
- { name: workspace, mountPath: /workspace }
containers:
- name: kaniko
# Build your own executor image from the maintained fork and pin it by digest.
image: registry.example.com/tools/kaniko-executor@sha256:0000000000000000000000000000000000000000000000000000000000000000
args:
- --context=dir:///workspace
- --dockerfile=/workspace/Dockerfile
- --destination=registry.example.com/team-a/app:1.0.0
- --digest-file=/dev/termination-log
- --reproducible
- --cleanup
securityContext:
runAsUser: 0 # kaniko unpacks the base image over its own root
runAsNonRoot: false
privileged: false
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
add: [CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID]
resources:
requests: { cpu: "500m", memory: 1Gi, ephemeral-storage: 4Gi }
limits: { cpu: "2", memory: 2Gi, ephemeral-storage: 8Gi }
volumeMounts:
- { name: workspace, mountPath: /workspace, readOnly: true }
- { name: push-credentials, mountPath: /kaniko/.docker, readOnly: true }
volumes:
- name: workspace
emptyDir: { sizeLimit: 1Gi }
- name: push-credentials
secret:
secretName: team-a-app-push # push-only robot for ONE repository
items:
- { key: .dockerconfigjson, path: config.json }Replace the zero digests with real ones. The two optional walls,
hostUsers: false and runtimeClassName: gvisor, depend on your cluster:
user namespaces need Kubernetes 1.33 or later with a runtime and kernel that
support them, and gVisor needs the RuntimeClass installed. They were not run
for this page; test a real build on your own pool before you rely on them.
The same limits on a plain Docker host, for a runner that is not on Kubernetes:
docker run --rm \
--cap-drop ALL \
--cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \
--cap-add SETUID --cap-add SETGID \
--security-opt no-new-privileges \
--memory 2g --pids-limit 256 \
-v "$PWD/ctx:/workspace:ro" \
-v "$PWD/out:/out" \
"$KANIKO_IMAGE" \
--context dir:///workspace \
--destination example.com/team-a/app:1.0 \
--no-push --tar-path /out/app.tar --digest-file /out/app.digestAdd --cap-add FSETID only if a build must keep setuid or setgid bits on
files, and ask why it needs them first.
Prove it
The test Dockerfile prints what a RUN step can see, then installs a
package and adds a user, which is what most real builds do.
FROM cgr.dev/chainguard/wolfi-base@sha256:<digest>
RUN id && grep -E '^(CapEff|NoNewPrivs|Seccomp):' /proc/self/status && ls -l /var/run/docker.sock 2>&1 || true
RUN apk add --no-cache curl >/dev/null && adduser -D -u 10001 app
USER 10001First, too tight. With no capabilities at all, kaniko cannot even stage the Dockerfile:
build --cap-drop ALLError: error resolving dockerfile path: copying dockerfile: chown /kaniko/Dockerfile: operation not permittedThen the five capabilities on this page (kaniko log lines trimmed):
build --cap-drop ALL --cap-add CHOWN,DAC_OVERRIDE,FOWNER,SETUID,SETGIDlevel=info msg="RUN id && grep -E '^(CapEff|NoNewPrivs|Seccomp):' /proc/self/status && ls -l /var/run/docker.sock 2>&1 || true"
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
CapEff: 00000000000000cb
NoNewPrivs: 1
Seccomp: 2
ls: /var/run/docker.sock: No such file or directory
level=info msg="RUN apk add --no-cache curl >/dev/null && adduser -D -u 10001 app"
level=info msg="USER 10001"
level=info msg="Skipping push to container registry due to --no-push flag"
sha256:aaeeee1092b13ea4b41aae3bccdc73025717e5d04b48566eccb662166d37e142The build step is root, but a small root: five capabilities, no new
privileges, seccomp in filter mode (2), and no Docker socket. For
comparison, a container with default settings:
docker run --rm alpine:3.22 grep CapEff /proc/self/status
capsh --decode=00000000000000cb ; capsh --decode=00000000a80425fbCapEff: 00000000a80425fb
0x00000000000000cb=cap_chown,cap_dac_override,cap_fowner,cap_setgid,cap_setuid
0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcapNine capabilities gone, including NET_RAW (raw packets on the node network)
and MKNOD. The Kubernetes manifests pass schema validation:
kubeconform -strict -summary -kubernetes-version 1.34.0 kaniko-job.yamlSummary: 4 resources found in 1 file - Valid: 4, Invalid: 0, Errors: 0, Skipped: 0Mistakes people make
Adding privileged: true "because root is needed anyway"
Root in a container and a privileged container are different things.
privileged: true gives all capabilities, all devices and no seccomp. Kaniko
needs none of that. If a build only works privileged, the build is doing
something a build should not do.
A registry credential that can push anywhere
The kaniko pod holds whatever credential you mount. A RUN step can read
/kaniko/.docker/config.json. Give each pipeline a robot account that can
push to one repository and nothing else, and never mount a credential into a
build that runs pull requests from forks.
Builds on the production node pool
Capabilities limit what root can do, not what a kernel bug can do. Put builds on their own nodes with a taint, so a container escape lands on a machine with nothing else on it.
Open egress "so apk and pip work"
A build that can reach the internet can send your source code and
credentials anywhere. Point package managers and base images at a mirror and
allow only the mirror. The offline-builds-no-internet page shows how.
Reusing the pod for the next build
Kaniko writes over its own root filesystem. The README says it "may
overwrite anything already there". One build per pod, restartPolicy: Never,
and the pod is gone after ttlSecondsAfterFinished.
Checklist
- Kaniko runs as uid 0 with
privileged: falseandallowPrivilegeEscalation: false. - Capabilities: drop
ALL, add onlyCHOWN,DAC_OVERRIDE,FOWNER,SETUID,SETGID. seccompProfile: RuntimeDefaultis set on the pod.automountServiceAccountToken: falseon the ServiceAccount and the pod.- The build namespace enforces Pod Security
baseline. - A NetworkPolicy allows egress to DNS and the registry mirror only, and no ingress.
- The push credential can write to one repository only.
- Builds run on a tainted, dedicated node pool.
- Each build is a new pod with CPU, memory, storage and time limits.
- The executor image is built from the maintained fork and pinned by digest.
- Untrusted pull requests build without any push credential.
Kaniko gave you a smaller root. Five capabilities, one credential and one open port make it small enough to hand to a pull request.
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 freeH2 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