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.

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.