Automating Build, Test, and Deploy Pipelines with Containers
Reproducible environments from commit to production -- eliminating "works on my machine" from every stage of your pipeline.
Code → Build → Test → Scan → Push → Deploy
Containers solve the core CI/CD challenge: consistent, reproducible environments at every stage.
Every major CI platform -- GitHub Actions, GitLab CI, Jenkins, CircleCI -- has first-class Docker support.
Two approaches to running Docker commands inside a CI container:
docker:dind service image# GitLab CI example
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2376
/var/run/docker.sock# Docker run with socket mount
docker run -v /var/run/docker.sock:\
/var/run/docker.sock mybuilder
Rule of thumb: use DinD for untrusted workloads, socket binding for trusted internal CI.
GitHub Actions runners come with Docker pre-installed. Build, test, and push in a single workflow.
# .github/workflows/docker.yml
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
Key actions: docker/login-action, docker/build-push-action, docker/setup-buildx-action
GitLab CI has deep Docker integration via the docker executor and DinD services.
# .gitlab-ci.yml
stages: [build, test, deploy]
build:
stage: build
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
test:
stage: test
image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
script:
- npm test
Built-in variables: $CI_REGISTRY_IMAGE, $CI_COMMIT_SHA, $CI_REGISTRY are provided automatically.
Jenkins can spin up ephemeral Docker containers as build agents -- no permanent agent infrastructure needed.
// Jenkinsfile (Declarative)
pipeline {
agent {
docker {
image 'node:20-alpine'
args '-v $HOME/.npm:/root/.npm' // cache mount
}
}
stages {
stage('Install') { steps { sh 'npm ci' } }
stage('Test') { steps { sh 'npm test' } }
stage('Build Image') {
agent any
steps {
script {
def img = docker.build("myapp:${env.BUILD_NUMBER}")
docker.withRegistry('https://registry.example.com', 'creds') {
img.push()
img.push('latest')
}
}
}
}
}
}
Docker Pipeline plugin provides the docker.build() and docker.withRegistry() DSL.
Best practices for reliable, fast image builds in CI pipelines:
DOCKER_BUILDKIT=1.git/, node_modules/# Multi-stage Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/server.js"]
Good tagging makes images traceable, rollbackable, and auditable.
| Strategy | Format | When to Use | Example |
|---|---|---|---|
| Git SHA | :abc1234 | Every CI build -- unique, traceable | myapp:a1b2c3d |
| Semver | :v1.2.3 | Releases -- human-readable versions | myapp:v2.1.0 |
| Branch | :main | Mutable tag for latest on branch | myapp:develop |
| latest | :latest | Convenience -- avoid in prod | myapp:latest |
| Date+SHA | :20260414-abc123 | Sortable + traceable | myapp:20260414-a1b2c3d |
# Multi-tag in CI
SHA=$(git rev-parse --short HEAD)
VERSION=$(cat VERSION)
docker build -t myapp:$SHA -t myapp:v$VERSION -t myapp:latest .
Never deploy :latest to production. Always use an immutable tag for traceability.
CI runners are ephemeral -- without caching, every build starts from scratch. Caching slashes build times by 50-80%.
- uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=max
Uses GitHub's native cache backend. Up to 10 GB per repo.
- uses: docker/build-push-action@v5
with:
cache-from: |
type=registry,ref=ghcr.io/org/app:cache
cache-to: |
type=registry,ref=ghcr.io/org/app:cache
Cache stored in registry. Shared across runners.
docker build --build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from myapp:latest -t myapp:new .
package.json before source codeRun your test suite inside the same image you'll deploy -- guaranteeing environment parity.
# Test stage in multi-stage build
FROM node:20-alpine AS test
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm test
FROM node:20-alpine AS production
COPY --from=test /app/dist ./dist
Build fails if tests fail -- image never gets created.
# GitHub Actions
steps:
- run: docker build -t myapp .
- run: |
docker run -d --name app -p 3000:3000 myapp
sleep 5
curl -f http://localhost:3000/health
docker run --network host \
postman/newman run tests.json
Smoke tests and API tests against the built image.
Extract test results: docker cp container:/app/coverage ./coverage to upload artifacts.
Spin up your entire stack -- app, database, cache, message queue -- for true integration tests.
# docker-compose.test.yml
services:
app:
build: .
depends_on:
db: { condition: service_healthy }
redis: { condition: service_started }
environment:
DATABASE_URL: postgres://test:test@db:5432/testdb
REDIS_URL: redis://redis:6379
db:
image: postgres:16-alpine
environment: { POSTGRES_DB: testdb, POSTGRES_USER: test, POSTGRES_PASSWORD: test }
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test"]
interval: 2s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
# In CI pipeline
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from app
--exit-code-from app returns the test container's exit code so CI fails on test failure.
Where your images live between build and deploy:
| Registry | Provider | Auth Method | Best For |
|---|---|---|---|
| GHCR | GitHub | GITHUB_TOKEN | GitHub-native workflows |
| ECR | AWS | IAM / OIDC | AWS deployments (ECS, EKS) |
| ACR | Azure | Service principal | Azure deployments (AKS, ACI) |
| GAR | Google Cloud | Workload identity | GCP deployments (GKE, Cloud Run) |
| Docker Hub | Docker Inc | Access token | Open-source, public images |
# ECR login (AWS)
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin 123456789.dkr.ecr.us-east-1.amazonaws.com
# GHCR login
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
Use OIDC federation where possible -- no long-lived secrets to rotate.
Shift security left: scan images before they reach production.
# Trivy in GitHub Actions
- uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1' # Fail pipeline on findings
Gate deployments: block images with CRITICAL vulnerabilities from reaching production.
How you roll out new container versions determines risk and downtime:
Risk: Low
Risk: Very Low
Risk: Lowest
Git as the single source of truth: declare desired state in a repo, let controllers reconcile.
git revert# ArgoCD Application
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: myapp
spec:
source:
repoURL: https://github.com/org/k8s-manifests
path: overlays/production
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
A complete pipeline from commit to production:
# Complete GitHub Actions Pipeline
name: CI/CD
on: { push: { branches: [main] }, pull_request: {} }
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker run --rm -v $PWD:/app -w /app golangci/golangci-lint golangci-lint run
build-and-test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with: { load: true, tags: "myapp:test", cache-from: "type=gha", cache-to: "type=gha,mode=max" }
- run: docker compose -f docker-compose.test.yml up --exit-code-from app
scan:
needs: build-and-test
runs-on: ubuntu-latest
steps:
- uses: aquasecurity/trivy-action@master
with: { image-ref: "myapp:test", severity: "CRITICAL", exit-code: "1" }
push:
if: github.ref == 'refs/heads/main'
needs: scan
runs-on: ubuntu-latest
steps:
- uses: docker/login-action@v3
with: { registry: ghcr.io, username: "${{ github.actor }}", password: "${{ secrets.GITHUB_TOKEN }}" }
- uses: docker/build-push-action@v5
with: { push: true, tags: "ghcr.io/${{ github.repository }}:${{ github.sha }}" }
Fast pipelines keep developers productive. Target: < 10 minutes from push to deploy-ready.
DOCKER_BUILDKIT=1)--mount=type=cache for package managers-alpine or -slim base imagesdocker image inspect --format to audit# BuildKit cache mount for npm
RUN --mount=type=cache,target=/root/.npm \
npm ci --production
# BuildKit cache mount for apt
RUN --mount=type=cache,target=/var/cache/apt \
apt-get update && apt-get install -y curl
Docker transforms CI/CD from fragile scripts into reproducible, portable pipelines.