Docker in CI/CD cheatsheet.

GitHub Actions: build + push

name: Build
on:
  push:
    branches: [main]
    tags: ["v*"]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      
      - uses: docker/setup-buildx-action@v3
      
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      
      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=ref,event=branch
            type=ref,event=tag
            type=sha
            type=raw,value=latest,enable={{is_default_branch}}
      
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64,linux/arm64

docker/metadata-action

Generates tags from event:

  • branch push → branch-name.
  • tag push → v1.2.3.
  • always → sha-abc1234.
  • main branch → latest.

Build args / secrets

- uses: docker/build-push-action@v6
  with:
    build-args: |
      VERSION=${{ github.sha }}
    secrets: |
      "npm_token=${{ secrets.NPM_TOKEN }}"

Cache strategies

  • type=gha — GitHub Actions cache (fastest on GitHub).
  • type=registry,ref=... — push cache as image (works anywhere).
  • type=inline — cache embedded in image.
  • type=local,src=https://blog.rajpoot.dev/tmp/cache,dest=/tmp/cache — local files.
cache-from: |
  type=registry,ref=ghcr.io/me/myapp:buildcache
cache-to: |
  type=registry,ref=ghcr.io/me/myapp:buildcache,mode=max

Multi-arch builds

- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    platforms: linux/amd64,linux/arm64

QEMU is slow. For speed: native runners per arch + manifest merge.

Native ARM runner

strategy:
  matrix:
    include:
      - runner: ubuntu-latest
        platform: linux/amd64
      - runner: ubuntu-latest-arm
        platform: linux/arm64

runs-on: ${{ matrix.runner }}
steps:
  - uses: docker/build-push-action@v6
    with:
      platforms: ${{ matrix.platform }}
      outputs: type=image,push-by-digest=true,name-canonical=true,push=true

Then merge digests in a follow-up job.

Run tests in Docker

- run: docker compose up -d db redis
- run: docker compose exec -T web pytest
- run: docker compose down -v

Or:

- run: docker build -t myapp:test .
- run: docker run --rm myapp:test pytest

Service containers (GH Actions)

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env: { POSTGRES_PASSWORD: x }
        ports: [5432:5432]
        options: --health-cmd pg_isready

Available at localhost:5432.

Deploy: SSH + docker compose

- uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.HOST }}
    username: deploy
    key: ${{ secrets.SSH_KEY }}
    script: |
      cd /srv/app
      docker compose pull
      docker compose up -d
      docker image prune -f

Deploy: docker context

- run: |
    echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
    chmod 600 ~/.ssh/id_ed25519
    docker context create remote --docker host=ssh://deploy@server
    docker --context remote compose up -d

Image scanning in CI

- uses: aquasecurity/trivy-action@master
  with:
    image-ref: ghcr.io/me/myapp:${{ github.sha }}
    exit-code: "1"
    severity: "CRITICAL,HIGH"
    ignore-unfixed: true

Or docker scout:

- run: |
    docker scout cves --exit-code --only-severity critical,high \
      ghcr.io/me/myapp:${{ github.sha }}

GitLab CI

build:
  image: docker:latest
  services:
    - docker:dind
  variables:
    DOCKER_DRIVER: overlay2
    DOCKER_TLS_CERTDIR: ""
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker buildx build --push -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .

Tagging strategy

# Per-commit: ghcr.io/me/myapp:sha-abc1234
# Per-branch: ghcr.io/me/myapp:main, :feature-x
# Per-tag:    ghcr.io/me/myapp:v1.2.3
# Latest:     ghcr.io/me/myapp:latest

Pin specific SHA in prod, latest only for dev convenience.

Dependent jobs (use built image)

jobs:
  build:
    outputs:
      image: ${{ steps.meta.outputs.tags }}
  
  test:
    needs: build
    runs-on: ubuntu-latest
    container: ${{ needs.build.outputs.image }}
    steps:
      - run: pytest

Common mistakes

  • No cache → 10min builds.
  • Pulling latest on deploy without version tracking.
  • Forgetting permissions: packages: write for GHCR.
  • Building everything every push — use path filters.
  • Scanning fails CI but vulns not actionable — set ignore-unfixed.

Read this next

If you want my full CI/CD docker workflow, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .