Containers don't just run in production—they power the entire delivery pipeline. From reproducible build environments to daemonless image builds in Kubernetes, containers are the backbone of modern CI/CD.
1. Why Containers in CI/CD?
Before containers, CI environments were fragile snowflakes—shared Jenkins agents with conflicting tool versions, "works on CI but not locally" bugs, and hours spent debugging environment drift. Containers eliminate this entire category of problems:
- Reproducible build environments — Every job runs in a defined image with pinned tool versions
- No "works on CI but not locally" — Same image locally and in CI; identical results
- Fast spin-up — Containers start in seconds vs. minutes for VMs
- Isolation between jobs — Parallel jobs can't pollute each other's filesystem or processes
- Ephemeral and clean-slate — Every run starts fresh; no accumulated state or leftover artifacts
As an architect, think of CI containers as disposable compute units. They enforce the constraint that your build process must be fully declarative—if it can't run in a fresh container, it has hidden dependencies.
2. Containers AS the CI Environment
Each CI job runs inside a container. The container image defines the complete toolchain available to that job:
GitHub Actions
jobs:
build:
runs-on: ubuntu-latest
container:
image: node:20-alpine
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
The container: image: node:20-alpine line means "run every step of this job inside a node:20-alpine container." The runner pulls the image, starts the container, and executes your steps inside it.
GitLab CI
test:
image: node:20
stage: test
script:
- npm ci
- npm test
Jenkins (Declarative Pipeline)
pipeline {
agent {
docker { image 'node:20' }
}
stages {
stage('Test') {
steps {
sh 'npm ci && npm test'
}
}
}
}
In all three systems, the pattern is the same: declare an image, get a pristine environment with exactly the tools you need.
3. Building Images in CI
Beyond using containers as the CI environment, CI pipelines also build container images as artifacts. The common pattern:
- Build —
docker buildcreates the image - Test — Run the test suite inside the freshly built image
- Scan — Security vulnerability scan (Trivy, Snyk, Grype)
- Push — Push to registry on success
- Deploy — Update the running service with the new image
GitHub Actions Workflow: Build, Test, Push
name: Build and Push
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=ref,event=branch
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run tests against built image
run: |
docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${GITHUB_SHA::7} npm test
4. Docker-in-Docker (DinD) vs Socket Mounting
When your CI job itself runs in a container, how do you run docker build inside it? Two approaches:
Docker-in-Docker (DinD)
A privileged container runs its own Docker daemon (dockerd) inside:
# GitLab CI example with DinD
build:
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker build -t myapp .
- ✅ Full isolation — inner Docker has its own storage
- ✅ Clean layer cache per job (or shared via volumes)
- ❌ Requires
--privilegedmode (security concern) - ❌ No layer cache sharing between jobs (unless configured)
Socket Mounting
Bind-mount the host's Docker socket into the CI container:
# Mount the host Docker socket
docker run -v /var/run/docker.sock:/var/run/docker.sock \
docker:24 docker build -t myapp .
- ✅ Shares layer cache with host (fast builds)
- ✅ No privileged mode needed on the inner container
- ❌ Jobs can see/kill each other's containers (no isolation)
- ❌ Container breakout risk — socket access = root on host
| Concern | DinD | Socket Mount |
|---|---|---|
| Isolation | Strong | None |
| Cache sharing | Hard | Automatic |
| Security | Needs --privileged | Host root access |
| Best for | Multi-tenant CI | Trusted single-tenant |
5. Kaniko & Daemonless Builds
In Kubernetes-based CI (Tekton, GitHub Actions on ARC, GitLab K8s runners), you often cannot run a Docker daemon. The solution: daemonless image builders.
Kaniko
Kaniko builds container images from a Dockerfile entirely in userspace—no daemon, no root, no privileged mode:
# Kubernetes pod spec for Kaniko
apiVersion: v1
kind: Pod
spec:
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:latest
args:
- "--dockerfile=Dockerfile"
- "--context=git://github.com/org/repo.git"
- "--destination=ghcr.io/org/repo:latest"
volumeMounts:
- name: docker-config
mountPath: /kaniko/.docker
volumes:
- name: docker-config
secret:
secretName: registry-credentials
How it works: Kaniko reads the Dockerfile, executes each instruction in a snapshot filesystem, computes layer diffs, and pushes the resulting image directly to a registry—no dockerd involved.
Buildah — OCI image builder from Red Hat, daemonless, integrates with Podman. Podman — Drop-in Docker replacement, rootless by default. Tekton — Kubernetes-native CI/CD framework where each pipeline step is a container in a pod. These tools are increasingly common in enterprise CI where Docker daemon access is restricted.
6. Caching in CI
Without caching, every CI run rebuilds every layer from scratch. With proper caching, only changed layers rebuild—cutting build times from 10+ minutes to under 60 seconds.
Caching Strategies
| Strategy | How It Works | Best For |
|---|---|---|
--cache-from |
Pull a previous image from registry, use its layers as cache | Any CI system |
| BuildKit inline cache | Embed cache metadata in pushed image (--build-arg BUILDKIT_INLINE_CACHE=1) |
Simple setups |
| GitHub Actions cache | cache-from: type=gha in build-push-action |
GitHub-hosted runners |
| Local cache mount | --mount=type=cache,target=/root/.cache in Dockerfile RUN |
Package manager caches (npm, pip, go) |
Example: Registry Cache
# Pull previous image for cache, build with cache-from
docker pull ghcr.io/org/myapp:latest || true
docker build \
--cache-from ghcr.io/org/myapp:latest \
--tag ghcr.io/org/myapp:$SHA \
--tag ghcr.io/org/myapp:latest \
.
docker push ghcr.io/org/myapp:$SHA
docker push ghcr.io/org/myapp:latest
Example: Dockerfile with Cache Mounts
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline
COPY . .
RUN npm run build
7. The Full Pipeline
Here's the complete flow from code push to production deployment:
Industry Callout
Netflix uses Titus (their container platform) to run CI/CD at massive scale. Every microservice team pushes independently; the platform handles image builds, security scanning, canary deployments, and automatic rollbacks. They deploy thousands of container changes per day across hundreds of microservices.
Spotify built their CI/CD on top of Kubernetes with custom controllers. Each squad owns their pipeline definition. Builds run in ephemeral pods, images are scanned with inline policies, and promotion to production is gated by automated integration tests running in containers.
The key pattern: standardized container-based pipelines let thousands of engineers ship independently without stepping on each other.
Interactive Quizzes
Quiz 1: DinD vs Socket Mount
Your CI runs on a shared Kubernetes cluster with multiple teams. You need to build Docker images but are concerned about one team's CI job interfering with another's containers. Which approach is more appropriate?
Quiz 2: Why Containers in CI?
A team reports that tests pass on a developer's machine and in CI, but fail in staging. Which CI improvement would BEST prevent environment-related discrepancies?
Quiz 3: Caching Strategy
Your Docker build in CI takes 8 minutes, mostly spent on npm install (which rarely changes). Your CI uses GitHub Actions. What's the most effective caching approach?
Hands-On Task
Create a GitHub Actions workflow that builds a Docker image and pushes it to GitHub Container Registry (GHCR).
Requirements:
- Trigger on push to
mainbranch - Log in to GHCR using
GITHUB_TOKEN - Build the image with a tag based on the Git SHA
- Run tests inside the built image before pushing
- Push to
ghcr.io/<your-username>/<repo>:<sha> - Enable BuildKit layer caching
Starter template:
# .github/workflows/build.yml
name: Build and Push to GHCR
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
# TODO: Checkout code
# TODO: Login to GHCR
# TODO: Build image with SHA tag
# TODO: Run tests in the built image
# TODO: Push image (only if tests pass)
Validation: Push to a real repo with a Dockerfile and confirm the image appears in the repo's Packages tab on GitHub.
Key Takeaways
- Containers as CI environments give you reproducible, isolated, ephemeral build agents with zero drift
- The image IS the artifact — build once, test that image, deploy that same image everywhere
- DinD vs socket mounting is a security/isolation trade-off; prefer DinD for multi-tenant, socket mount for single-tenant trusted environments
- Kaniko and Buildah enable image builds without a Docker daemon—essential for Kubernetes-native CI
- Layer caching (registry cache, GHA cache, cache mounts) can reduce build times by 80%+
- Quality gates (test, scan, size check) in the pipeline prevent bad images from reaching production
- The full pipeline: push → build → test → scan → push → deploy staging → promote production