Building Container Images in CI Without Docker: A Practical Guide to Kaniko
If your GitLab CI pipeline builds Docker images, there is a good chance it runs in privileged mode. That means every job in that runner can escape the container, access the host kernel, mount filesystems, and basically own the machine. On shared runners, this is not a theoretical risk — it is an open door.
Kaniko fixes this. It builds OCI-compliant container images entirely in userspace, with no Docker daemon and no privileged containers. Here is how to set it up properly.
The Problem with Docker-in-Docker
The standard approach to building images in CI looks like this:
build:
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker build -t myapp:latest .
- docker push myapp:latest
Simple. Also dangerous. The docker:dind service requires privileged: true on the runner. That flag disables almost every container isolation mechanism Linux provides — namespaces, seccomp, AppArmor, the lot.
In a dedicated runner you control, this might be acceptable. On shared infrastructure — whether that is gitlab.com shared runners, a multi-tenant Kubernetes cluster, or runners shared across teams — privileged mode is a security nightmare. A malicious or compromised job can break out of the container and access everything on the host.
We covered the broader implications in our security hardening post — privileged containers are one of the first things to eliminate.
graph LR
A[Dockerfile + Context] --> B[Kaniko Container]
B -->|Build layers| C[OCI Image]
C --> D[Push to Registry]
D --> E[GitOps Update]
What Kaniko Is
Kaniko is an open-source tool from Google that builds container images from a Dockerfile, entirely in userspace. No Docker daemon. No socket mounting. No privileges.
It runs as a regular container, parses your Dockerfile, executes each instruction by extracting filesystem layers, and pushes the result to a registry. The executor binary does everything — there is no client-server architecture.
The key insight: building an image does not actually require a container runtime. You just need to unpack base images, apply filesystem changes, and pack them back into layers. Kaniko does exactly that.
GitLab CI Setup
Here is a production-ready .gitlab-ci.yml for building and pushing with Kaniko:
stages:
- build
build-image:
stage: build
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(printf '%s:%s' "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64)\"}}}" > /kaniko/.docker/config.json
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${IMAGE_TAG}"
--destination "${IMAGE_LATEST}"
--cache=true
--cache-repo="${CI_REGISTRY_IMAGE}/cache"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
A few things to note:
- We use the
-debugimage variant because it includes a shell. The non-debug image has no shell at all, which means GitLab CI cannot execute thescriptblock without it. - The
entrypoint: [""]override is required — Kaniko’s default entrypoint is the executor itself. - Authentication is handled by writing a Docker config JSON to
/kaniko/.docker/config.json. GitLab’s predefined CI variables (CI_REGISTRY_USER,CI_REGISTRY_PASSWORD) handle credentials automatically.
Pushing to Multiple Registries
Need to push the same image to GitLab Container Registry, Docker Hub, and a private registry? Add all credentials to the config and use multiple --destination flags:
script:
- mkdir -p /kaniko/.docker
- |
cat > /kaniko/.docker/config.json <<EOF
{
"auths": {
"${CI_REGISTRY}": {
"auth": "$(printf '%s:%s' "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64)"
},
"https://index.docker.io/v1/": {
"auth": "$(printf '%s:%s' "${DOCKERHUB_USER}" "${DOCKERHUB_TOKEN}" | base64)"
},
"registry.internal.example.com": {
"auth": "$(printf '%s:%s' "${PRIVATE_REG_USER}" "${PRIVATE_REG_PASS}" | base64)"
}
}
}
EOF
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
--destination "dockerhubuser/myapp:${CI_COMMIT_SHORT_SHA}"
--destination "registry.internal.example.com/myapp:${CI_COMMIT_SHORT_SHA}"
--cache=true
--cache-repo="${CI_REGISTRY_IMAGE}/cache"
Store DOCKERHUB_TOKEN and private registry credentials as masked CI/CD variables. Never hardcode them.
Caching and Multi-Stage Builds
Kaniko supports layer caching via a registry. When you pass --cache=true and --cache-repo, it pushes individual layers to a dedicated cache repository. On subsequent builds, it pulls cached layers instead of rebuilding them.
This works well with multi-stage builds. Consider this Dockerfile:
# Stage 1: dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
# Stage 2: build
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Stage 3: runtime
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
Kaniko caches each stage independently. If your package.json has not changed, the deps stage is pulled from cache entirely. The key is layer ordering — put things that change less frequently earlier in the Dockerfile.
Performance Tips
Order layers by change frequency. OS packages and dependency installs should come before application code. Kaniko’s cache is layer-based — one changed layer invalidates everything after it.
Warm the cache on main. Run a full build on your default branch so that feature branch builds can pull cached layers. The rules block in the example above achieves this.
Use --snapshot-mode=redo. The default snapshot mode compares file metadata to detect changes. The redo mode is faster for builds with many small files but uses more memory. Test both for your workload.
Use --compressed-caching=false if your build layers are large. Decompressing cached layers during the build adds time. Disabling compressed caching trades storage space for build speed.
- /kaniko/executor
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination "${IMAGE_TAG}"
--cache=true
--cache-repo="${CI_REGISTRY_IMAGE}/cache"
--snapshot-mode=redo
--compressed-caching=false
When NOT to Use Kaniko
Kaniko is a CI tool. Do not use it for local development.
On your workstation, Docker (or Podman) is the right choice. You have a daemon running already, you want fast iterative builds, and you are not sharing a runner with untrusted workloads. Kaniko’s cold-start overhead and lack of local caching make it slower for the edit-build-test loop.
Use Docker locally. Use Kaniko in CI. Keep it simple.
Where This Fits in Your Pipeline
Kaniko is one piece of a larger automated delivery system. In a GitOps workflow, the image build is triggered by a commit, and the resulting image tag is written back to a deployment manifest that a tool like ArgoCD or Flux reconciles. We walked through this full flow in our GitOps pipeline post — Kaniko slots in as the build step.
The chain looks like this:
- Developer pushes code
- GitLab CI triggers Kaniko to build and push the image
- A subsequent pipeline stage updates the image tag in the GitOps repository
- ArgoCD detects the change and rolls out the new version
No manual deployments. No privileged containers. Full audit trail through git.
Wrapping Up
Privileged Docker-in-Docker is the path of least resistance, and it works right up until it does not. Kaniko removes the security liability without adding much complexity. The setup is a single CI job and a Docker config file.
If you are running shared CI infrastructure and still relying on DinD, switching to Kaniko is one of the highest-value, lowest-effort security improvements you can make.
Need help securing your CI/CD pipelines or building a GitOps workflow from scratch? That is exactly what we do at robto. Reach out — we understand it.