Setting the Scene
Many teams reach for Docker‑in‑Docker (DinD) when they need to build container images inside a CI job. The idea feels simple: spin up a Docker daemon inside the build container, run docker build, and push the result. This article walks through a minimal DinD setup with GitHub Actions, then examines why the approach quickly becomes a liability.
Step 1 – Create a Dockerfile for the Builder Image
The builder image must contain a Docker client and a daemon that can be started on demand. Below is a trimmed‑down Dockerfile that follows the official Docker “dind” pattern but adds the GitHub Actions runner entrypoint.
FROM docker:23.0-dind
# Install dependencies needed for most CI steps
RUN apk add --no-cache \
git \
curl \
bash \
openssh-client
# Add a non‑root user that matches the host UID/GID
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN addgroup -g $GROUP_ID builder \
&& adduser -D -u $USER_ID -G builder builder
USER builder
WORKDIR /home/builder
# Optional: pre‑pull common base images to speed up builds
# RUN docker pull alpine:latest
Save this as Dockerfile.dind. The image can be built locally and pushed to a private registry for reuse across pipelines.
Step 2 – Define the GitHub Actions Workflow
The following .github/workflows/ci.yml demonstrates a job that runs the builder image as a service, starts the inner Docker daemon, and builds a sample application.
name: CI with DinD
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu‑latest
services:
dind:
image: ghcr.io/yourorg/dind‑builder:latest
options: --privileged
ports:
- 2375:2375 # expose the daemon to the job container
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Wait for Docker daemon
run: |
for i in {1..10}; do
nc -z localhost 2375 && break
echo "Waiting for Docker daemon..."
sleep 1
done
- name: Build image inside DinD
env:
DOCKER_HOST: tcp://localhost:2375
run: |
docker version
docker build -t ghcr.io/yourorg/sample-app:${{ github.sha }} .
docker push ghcr.io/yourorg/sample-app:${{ github.sha }}
The key flags are --privileged (required for DinD) and the environment variable DOCKER_HOST that points the client to the inner daemon.
Step 3 – Run a Local Test
Before committing, verify the pipeline locally with act or by reproducing the steps in a Docker Compose file:
version: "3.8"
services:
dind:
image: yourorg/dind-builder:latest
privileged: true
ports:
- "2375:2375"
ci:
image: ubuntu:22.04
depends_on:
- dind
environment:
DOCKER_HOST: tcp://dind:2375
command: |
bash -c "
apt-get update && apt-get install -y curl docker.io &&
docker version &&
docker build -t sample-app . &&
echo 'Build succeeded'
"
At this point the pipeline works. Images are built, tagged, and pushed without leaving the CI environment.
Why This Pattern Is Problematic
The simplicity of DinD hides three categories of risk that become evident as the pipeline scales.
1. Privilege Escalation Surface
Running a container with --privileged grants it virtually all host capabilities: access to device nodes, kernel modules, and the host network stack. If an attacker can inject malicious code into the build (for example, via a compromised dependency), they can break out of the DinD container and affect the runner host. This defeats the isolation that CI systems are supposed to provide.
2. Resource Contention and Unpredictable Performance
The inner Docker daemon competes with the outer job container for CPU and memory. On shared runners, this double‑layered virtualization can cause OOM kills and erratic build times. Monitoring becomes difficult because metrics are collected at the outer level while the inner daemon’s consumption remains hidden.
3. Complex Cleanup and Orphaned Artifacts
When a job aborts, the inner daemon may leave dangling images, networks, or volumes that persist in the host Docker engine. Over time these orphans consume disk space and can interfere with subsequent builds. Cleaning them requires explicit docker system prune steps that are easy to forget.
Alternative Approaches
Most of the motivations for DinD can be addressed with safer patterns:
- BuildKit with Remote Caching: Use Docker’s BuildKit frontend and push build caches to a registry. This avoids a nested daemon while still enabling fast incremental builds.
- Kaniko or img: These tools build images inside an unprivileged container by mounting the build context and writing layers directly to the registry.
- GitHub Actions “docker/build-push-action”: The official action runs a Docker daemon in the same container but isolates it using the
docker/setup-qemu-actionanddocker/setup-buildx-actionhelpers, removing the need for--privileged.
Below is a short snippet showing how to replace DinD with the official Build‑Push action:
steps:
- uses: actions/checkout@v3
- uses: docker/setup-buildx-action@v2
- uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ghcr.io/yourorg/sample-app:${{ github.sha }}
Security and Best Practices
If you must keep DinD for legacy reasons, apply the following safeguards:
- Run the DinD service on a dedicated runner VM that does not host other workloads.
- Limit the daemon’s exposure by binding it to a Unix socket inside the container rather than a TCP port.
- Enable Docker daemon hardening:
--icc=false,--userland-proxy=false, and a customdaemon.jsonthat disables experimental features. - Add a post‑step that aggressively prunes the inner daemon:
docker system prune -af --volumes. - Audit all build dependencies with a Software Bill of Materials (SBOM) generator before they reach the DinD environment.
“Running Docker inside Docker is a shortcut that often trades short‑term convenience for long‑term instability.”
Conclusion
DinD offers an easy entry point for container image builds inside CI, but the hidden privilege escalation surface, resource contention, and cleanup complexity make it unsuitable for production pipelines. Modern alternatives like BuildKit, Kaniko, or the official Docker Build‑Push action provide comparable speed without the security compromises. Transitioning away from DinD early saves engineering time and reduces risk as your CI system scales.
Review your existing pipelines, replace DinD where possible, and enforce the safeguards listed above if you must keep the pattern temporarily. The payoff is a cleaner, faster, and more secure build process.