Building Docker Images Without Docker Daemon on Kubernetes: A Practical Kaniko Guide for Secure CI/CD

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

The Security Hole That Keeps Kubernetes Admins Up at Night

When you first set up a CI/CD pipeline on Kubernetes, the quickest way to build Docker images is to mount the Docker socket from the host node into your build pod. It works immediately, everyone does it, and then three months later your security team flags it as a critical vulnerability.

Mounting /var/run/docker.sock into a container gives that container full control over the Docker daemon running on the host node. Any process inside that build pod can pull images, spawn privileged containers, escape to the host filesystem, and effectively own the node. For a CI/CD system that runs arbitrary build scripts — sometimes from contributors you don’t fully trust — this is a serious exposure.

I ran into this exact problem while setting up a CI/CD pipeline for a Kubernetes cluster at a previous company. The team had been mounting the Docker socket for months, and nobody had questioned it until an internal audit flagged it. The fix wasn’t just a config change — it required rethinking how image builds work in a Kubernetes context. Once an audit catches it, the remediation is straightforward. You’d just rather not get there the hard way.

Why the Docker Daemon Clashes with Kubernetes Security

Docker’s traditional architecture relies on a central daemon (dockerd) that runs as root. When you run docker build, the CLI communicates with this daemon over a Unix socket, and the daemon does the actual heavy lifting. This design made sense when Docker was a local dev tool, but it clashes fundamentally with what Kubernetes expects:

  • The daemon requires root privileges to manage namespaces, cgroups, and filesystem layers
  • Pods with socket access bypass Kubernetes RBAC entirely — they’re talking directly to the node’s daemon
  • Multi-tenant clusters where different teams share nodes become impossible to isolate properly
  • Pod Security Standards at the “restricted” level explicitly block hostPath mounts of socket files

The root cause isn’t bad configuration — it’s that the Docker daemon was never designed for the multi-tenant, least-privilege environment that Kubernetes expects.

Three Approaches to Daemon-Free Builds (And Why Two of Them Fall Short)

Docker-in-Docker (DinD)

Docker-in-Docker runs a full Docker daemon inside the build container. It avoids mounting the host socket — but the inner daemon still needs --privileged mode to function. That grants the same host access you were trying to eliminate.

There’s a performance cost too: the inner daemon has no persistent storage between jobs, so every build starts cold. Add the I/O overhead of running overlay2 inside overlay2, and builds can take 2-3x longer than necessary. For security-conscious environments, you’ve traded one problem for a slightly different one.

Buildah

Buildah from Red Hat is a solid rootless image builder that works without any daemon. It integrates well with Podman-based workflows. The catch for Kubernetes: rootless Buildah still requires user namespaces on the host. Some managed Kubernetes offerings — EKS with hardened node AMIs, certain GKE configurations — disable these by default. It’s also a harder sell if your team has years of Docker muscle memory to unlearn.

Kaniko (The Right Tool for Kubernetes)

Kaniko, built by Google, was designed specifically to build container images inside Kubernetes without requiring any elevated privileges. It runs entirely in userspace, executes each Dockerfile command without needing a daemon, and pushes the result directly to a registry. No privileged mode, no socket mounts, no root required on the host. For Kubernetes CI/CD specifically, nothing else comes close.

Setting Up Kaniko: A Complete Working Example

Prerequisites

  • A running Kubernetes cluster (1.19+)
  • kubectl configured with cluster access
  • A container registry (Docker Hub, GCR, ECR, or a private registry)
  • Your application Dockerfile

Step 1: Create a Registry Secret

Kaniko needs credentials to push the built image. Create a Kubernetes secret with your registry credentials:

# For Docker Hub
kubectl create secret docker-registry regcred \
  --docker-server=https://index.docker.io/v1/ \
  --docker-username=YOUR_USERNAME \
  --docker-password=YOUR_PASSWORD \
  --docker-email=YOUR_EMAIL \
  -n ci-build

# For Google Container Registry (using a service account JSON key)
kubectl create secret generic gcr-credentials \
  --from-file=config.json=/path/to/service-account-key.json \
  -n ci-build

Step 2: Create the Kaniko Build Job

Here’s a Kubernetes Job manifest that builds an image from a Git repository and pushes it to Docker Hub:

apiVersion: batch/v1
kind: Job
metadata:
  name: kaniko-build
  namespace: ci-build
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: kaniko
          image: gcr.io/kaniko-project/executor:latest
          args:
            - "--dockerfile=Dockerfile"
            - "--context=git://github.com/yourorg/yourrepo.git#refs/heads/main"
            - "--destination=yourdockerhubuser/yourapp:latest"
            - "--cache=true"
            - "--cache-repo=yourdockerhubuser/yourapp-cache"
          volumeMounts:
            - name: docker-config
              mountPath: /kaniko/.docker/
      volumes:
        - name: docker-config
          secret:
            secretName: regcred
            items:
              - key: .dockerconfigjson
                path: config.json

Notice there’s no privileged: true, no hostPath mount, and no security context exceptions. This job runs cleanly on nodes with restricted Pod Security Standards.

Step 3: Building from a Local Workspace Context

If your Dockerfile and source code arrive via a PersistentVolumeClaim or a mounted workspace volume, adjust the context argument accordingly:

args:
  - "--dockerfile=/workspace/Dockerfile"
  - "--context=dir:///workspace"
  - "--destination=yourdockerhubuser/yourapp:$(BUILD_TAG)"

Step 4: GitLab CI Integration

For GitLab CI using the Kubernetes executor:

build-image:
  stage: build
  image:
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  script:
    - mkdir -p /kaniko/.docker
    - echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64)\"}}}" > /kaniko/.docker/config.json
    - /kaniko/executor
        --context "${CI_PROJECT_DIR}"
        --dockerfile "${CI_PROJECT_DIR}/Dockerfile"
        --destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
        --cache=true
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

The debug image variant includes a busybox shell, which GitLab Runner requires when it overrides the container entrypoint to execute the script block.

Step 5: GitHub Actions with Kaniko

name: Build and Push

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Build with Kaniko
        uses: aevea/action-kaniko@master
        with:
          image: yourorg/yourapp
          tag: ${{ github.sha }}
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
          cache: true
          cache_registry: yourorg/yourapp-cache

Common Issues You’ll Hit in Practice

Build Context Too Large

If Kaniko slurps in your entire repo including node_modules or build artifacts, add a .dockerignore file — Kaniko respects it the same way Docker does:

node_modules/
.git/
*.log
dist/
.env

Layer Cache Not Working

Unlike Docker’s local layer store, Kaniko caches to a remote registry. That means you need to point it at a cache repository via --cache-repo. Without it, every build starts completely cold. Also check that the registry credentials you created have push access to that cache repo — silent mid-build failures happen when they don’t.

RUN Commands Failing with Permission Errors

If your Dockerfile has commands that need root (like apt-get install), they work fine in Kaniko — it runs those commands as root within its own isolated namespace, without needing root on the host. If you’re still seeing errors, check whether your base image requires real kernel capabilities. NET_ADMIN, SYS_PTRACE, and similar flags are beyond what Kaniko can provide.

Verifying That Builds Are Actually Rootless

After deploying Kaniko jobs, confirm they’re running without privilege escalation:

# Check the security context of a running Kaniko pod
kubectl get pod kaniko-build-xxxxx -n ci-build \
  -o jsonpath='{.spec.containers[0].securityContext}'

# Should return empty or show allowPrivilegeEscalation: false
# If you see: privileged: true — something is wrong

You can enforce this at the namespace level with a Pod Security standard label, so no future job can accidentally regress back to privileged mode:

kubectl label namespace ci-build \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/warn=restricted

With this label applied, Kubernetes rejects any pod in that namespace that tries to run privileged or mount host sockets — including if someone accidentally reverts to DinD. It’s a safety net that costs nothing to add.

The transition from socket-mounted Docker builds to Kaniko took our team about a day. The result: a pipeline security auditors were happy to sign off on, builds that ran identically on shared multi-tenant clusters and dedicated build namespaces, and no scrambling when the next audit came around. That tradeoff is worth making early.

Share: