Docker in CI/CD: GitHub Actions and TeamCity Pipelines (2026)
Every Docker CI/CD pipeline has the same three jobs: build the image, test it, push it to a registry. The details that separate fast pipelines from slow ones are layer caching (reuse unchanged layers across runs), parallel stages (lint/test/scan don't depend on each other), multi-platform builds (one manifest serves amd64 and arm64), and smart tagging (SHA for traceability, semver for releases). This phase covers complete GitHub Actions workflows, BuildKit cache backends, multi-platform Buildx, GitLab CI, and service containers for integration testing.
Table of Contents
Basic Build and Push
# .github/workflows/docker.yml — minimal build and push to GHCR
name: Build and Push
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }} # org/repo → ghcr.io/org/repo
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
if: github.event_name != 'pull_request' # Don't push on PRs
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha # GitHub Actions cache
cache-to: type=gha,mode=max # mode=max caches all stages
Layer Caching in CI
# BuildKit supports multiple cache backends for CI.
# Without caching: every run rebuilds from scratch → slow.
# With caching: unchanged layers are reused → fast.
# 1. GitHub Actions Cache (recommended for GitHub Actions)
- uses: docker/build-push-action@v6
with:
cache-from: type=gha
cache-to: type=gha,mode=max
# Stores cache in GitHub's action cache (up to 10GB per repo)
# mode=max: caches ALL stages including intermediate (best hit rate)
# mode=min: caches only final stage (smaller cache, lower hit rate)
# 2. Registry cache (stores cache layers as OCI artifacts in the registry)
- uses: docker/build-push-action@v6
with:
cache-from: type=registry,ref=ghcr.io/org/myapp:buildcache
cache-to: type=registry,ref=ghcr.io/org/myapp:buildcache,mode=max
# Pros: shared across runners, no size limit from GitHub cache
# Cons: extra registry storage cost
# 3. Inline cache (embedded in image — least efficient)
- uses: docker/build-push-action@v6
with:
build-args: BUILDKIT_INLINE_CACHE=1
cache-from: ghcr.io/org/myapp:latest
# Only caches the final stage, no intermediate stages
# 4. Local cache (self-hosted runners only)
- uses: docker/build-push-action@v6
with:
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
# After build: mv /tmp/.buildx-cache-new /tmp/.buildx-cache
# (move to avoid cache corruption on partial writes)
# Cache hit verification — check build output for CACHED steps:
# #7 [builder 3/5] RUN npm ci
# #7 CACHED ← layer reused from cache
Smart Tagging with Metadata Action
# docker/metadata-action generates tags and labels automatically
# based on the Git context (branch, tag, PR, SHA).
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/org/myapp
tags: |
# Tag with semver on git tags (v1.2.3 → :1.2.3, :1.2, :1, :latest)
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Tag with branch name on branch push (main → :main)
type=ref,event=branch
# Tag with PR number on PRs (pr-42 → :pr-42)
type=ref,event=pr
# Always tag with short SHA
type=sha,prefix=,format=short
labels: |
org.opencontainers.image.title=MyApp
org.opencontainers.image.description=My application
org.opencontainers.image.vendor=MyOrg
- name: Build and push
uses: docker/build-push-action@v6
with:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
push: true
# Result on push to main:
# ghcr.io/org/myapp:main
# ghcr.io/org/myapp:a3b4c5d
# Result on git tag v1.2.3:
# ghcr.io/org/myapp:1.2.3
# ghcr.io/org/myapp:1.2
# ghcr.io/org/myapp:1
# ghcr.io/org/myapp:latest
# ghcr.io/org/myapp:a3b4c5d
Multi-Platform Builds
# Build one image manifest that supports both amd64 and arm64.
# Docker automatically pulls the right variant for each host architecture.
# Required for: Apple Silicon dev machines (arm64) + Linux servers (amd64).
- name: Set up QEMU # Emulation for cross-platform builds
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push multi-platform
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: true
tags: ghcr.io/org/myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
# QEMU emulation is slow for large builds — use native runners instead:
# jobs:
# build:
# strategy:
# matrix:
# platform: [linux/amd64, linux/arm64]
# include:
# - platform: linux/amd64
# runner: ubuntu-latest
# - platform: linux/arm64
# runner: ubuntu-24.04-arm # GitHub's ARM runner
#
# Then merge manifests with docker buildx imagetools create:
# docker buildx imagetools create \
# --tag ghcr.io/org/myapp:latest \
# ghcr.io/org/myapp:latest-amd64 \
# ghcr.io/org/myapp:latest-arm64
# Verify multi-platform manifest
docker buildx imagetools inspect ghcr.io/org/myapp:latest
# Manifests:
# linux/amd64 sha256:abc...
# linux/arm64 sha256:def...
Testing with Service Containers
# GitHub Actions service containers: spin up Docker services (DB, Redis, etc.)
# alongside your test job. They're available at localhost on the job runner.
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
env:
DATABASE_URL: postgres://testuser:testpass@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
# Alternative: use docker compose in CI for complex test setups
- name: Start services
run: docker compose -f compose.test.yml up -d --wait
- name: Run integration tests
run: npm run test:integration
- name: Stop services
if: always()
run: docker compose -f compose.test.yml down -v
Parallel Pipeline
# Full pipeline: lint + test in parallel → build → scan → deploy
name: CI/CD
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: npm }
- run: npm ci && npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: npm }
- run: npm ci && npm test
build:
needs: [lint, test] # Only runs if lint AND test pass
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- name: Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: type=sha,format=short
- uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
scan:
needs: build
runs-on: ubuntu-latest
steps:
- uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ needs.build.outputs.image }}
severity: HIGH,CRITICAL
exit-code: 1
deploy:
needs: [build, scan]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # Requires manual approval if configured
steps:
- name: Deploy to production
run: echo "Deploy ${{ needs.build.outputs.image }}"
GitLab CI
# .gitlab-ci.yml — equivalent pipeline for GitLab CI/CD
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
stages:
- test
- build
- scan
- deploy
test:
stage: test
image: node:20-alpine
services:
- postgres:16-alpine
variables:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
DATABASE_URL: postgres://testuser:testpass@postgres/testdb
script:
- npm ci
- npm test
build:
stage: build
image: docker:26
services:
- docker:26-dind # Docker-in-Docker for building images
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker buildx create --use
- docker buildx build
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE:buildcache
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE:buildcache,mode=max
--push
-t $IMAGE
-t $CI_REGISTRY_IMAGE:latest
.
only:
- main
scan:
stage: scan
image:
name: aquasec/trivy:latest
entrypoint: [""]
script:
- trivy image --exit-code 1 --severity HIGH,CRITICAL $IMAGE
only:
- main
CI Performance Tips
# 1. Use BuildKit (default in Docker 23+) — parallel stage execution
# DOCKER_BUILDKIT=1 on older Docker versions
# 2. Cache aggressively — type=gha,mode=max hits on every unchanged layer
# 3. Use --mount=type=cache in Dockerfile for package managers
# (npm, pip, go modules — see Phase 3)
# 4. Parallelize independent jobs (lint, typecheck, unit test, e2e)
# Don't run them sequentially if they don't depend on each other
# 5. Skip build on doc-only changes
on:
push:
paths-ignore:
- '**.md'
- 'docs/**'
# 6. Build only on push to main (not on every PR commit)
# PRs run tests; main builds + pushes the image
# 7. Use smaller runner images — ubuntu-latest is 80GB but you need ~5GB
# Self-hosted runners: use a cached, pre-warmed image
# 8. Avoid docker pull in test jobs — bake test deps into your image
# 9. Layer ordering matters even more in CI (no local cache between runs)
# Ensure deps are copied and installed before source code
# 10. Use reusable workflows to avoid copy-pasting across repos
# .github/workflows/docker-build.yml (in central repo):
on:
workflow_call:
inputs:
image-name:
required: true
type: string
# Other repos call it:
jobs:
build:
uses: org/central/.github/workflows/docker-build.yml@main
with:
image-name: myapp
Next: Phase 11 — Docker Monitoring covers Prometheus metrics, Grafana dashboards, Loki log aggregation, and cAdvisor for container resource monitoring.