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: writefor 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 .