Docker · Lesson 10 of 11

Build, Push and Deploy with CI

Automate Docker builds with GitHub Actions: build multi-platform images, tag by version and commit SHA, cache layers, scan, push to GHCR and deploy.

  • Advanced
  • 18 min read
  • 4 objectives

Before this lessonLesson 9: Container Security Best Practices

What you will learn

  • Write a GitHub Actions workflow that builds and pushes an image
  • Tag images with commit SHAs and semantic versions
  • Use registry caching and multi-platform builds
  • Deploy the new image to a server safely

Your Progress

0 of 11 lessons 0%

  • Lessons0 / 11
  • Completed0
  • Est. time left~ 3 hours

Create a free account to keep your progress on every device.

Tip: pressing Next marks this lesson complete automatically.

Building images on your laptop and pushing them by hand works for a side project, but it does not scale. Someone forgets to pull the latest code, builds on an arm64 Mac for an amd64 server, or pushes an image with uncommitted changes. Continuous integration (CI) fixes this: every push to your repository triggers a clean, repeatable build on a fresh machine, and every image in the registry can be traced back to an exact commit.

This lesson uses GitHub Actions and GitHub Container Registry, because they need no extra accounts. The same ideas apply to GitLab CI, CircleCI or Buildkite.

The pipeline at a glance

  • Trigger: a push to main, a version tag like v1.4.2, or a pull request.
  • Build: run docker buildx build with caching, for the platforms you deploy to.
  • Test and scan: run the test suite and a vulnerability scan against the built image.
  • Push: upload to the registry with meaningful tags (only for main and tags, not pull requests).
  • Deploy: tell the server to pull the new image and restart.

A complete build-and-push workflow

Save this as .github/workflows/docker.yml:

name: docker

on:
  push:
    branches: [main]
    tags: ["v*.*.*"]
  pull_request:

permissions:
  contents: read
  packages: write

env:
  IMAGE: ghcr.io/${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=sha
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}

      - uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

A few details worth understanding:

  • GITHUB_TOKEN is created automatically for each run. The packages: write permission lets it push to GHCR, so you store no long-lived password.
  • setup-qemu-action plus platforms builds amd64 and arm64 images in one run and publishes them under one multi-platform tag.
  • Pull requests build (to catch broken Dockerfiles) but do not push.
  • cache-from/cache-to: type=gha stores BuildKit layer cache in GitHub's cache, so unchanged dependency layers are not rebuilt on every run.

Tagging strategy

The metadata action turns git events into tags. For a push of the git tag v1.4.2 on commit 3f9c2ab, the run log shows:

Output
Docker tags
  ghcr.io/stackcone/api:sha-3f9c2ab
  ghcr.io/stackcone/api:1.4.2
  ghcr.io/stackcone/api:1.4
Docker labels
  org.opencontainers.image.created=2026-09-20T12:31:07.412Z
  org.opencontainers.image.revision=3f9c2ab8d1e04c7a9b6f2e5d8c1a0b3e4f5a6b7c
  org.opencontainers.image.source=https://github.com/stackcone/api
  org.opencontainers.image.version=1.4.2

The sha- tag is immutable and exact: perfect for deploying and for rollbacks. The semver tags are friendly for humans. The OCI labels mean anyone can run docker inspect on the image and find the exact commit it came from.

Adding tests and a scan

To test the exact image you will ship, build it once, load it into the runner's local Docker, run checks, then push. Insert these steps before the push step (and set the push step to reuse the cache so it is fast):

      - name: Build for testing
        uses: docker/build-push-action@v6
        with:
          context: .
          load: true
          tags: api:test
          cache-from: type=gha

      - name: Run tests inside the image
        run: docker run --rm api:test pytest -q

      - name: Scan for vulnerabilities
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: api:test
          severity: CRITICAL,HIGH
          exit-code: "1"
          ignore-unfixed: true
Output
Run docker run --rm api:test pytest -q
..........................................                            [100%]
42 passed in 3.87s

Deploying to a server

The simplest real-world deploy is a single VM running Docker Compose. The compose file on the server references the image by a variable, so deploying means changing one value and pulling:

# compose.prod.yaml on the server
services:
  api:
    image: ghcr.io/stackcone/api:${API_TAG}
    restart: unless-stopped
    env_file: .env.prod
    ports:
      - "127.0.0.1:8000:8000"
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
      interval: 10s
      retries: 3

A deploy job at the end of the workflow connects over SSH and runs:

  deploy:
    needs: build
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: deploy
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /srv/stackcone
            export API_TAG=sha-${GITHUB_SHA::7}
            docker compose -f compose.prod.yaml pull api
            docker compose -f compose.prod.yaml up -d --wait api
            docker image prune -f
Output
[+] Pulling 1/1
 ✔ api Pulled                                                           4.2s
[+] Running 1/1
 ✔ Container stackcone-api-1  Healthy                                 12.6s
Total reclaimed space: 212.4MB

--wait makes Compose block until the health check passes and fail the job otherwise, so a broken release shows up as a red CI run instead of a silent outage. Rolling back is the same command with the previous sha- tag. The environment: production line lets you require a manual approval in GitHub before this job runs.

Managed platforms (AWS ECS, Google Cloud Run, Fly.io, Azure Container Apps) replace the SSH step with a single CLI call that points the service at the new image tag, and they handle rolling restarts for you. For many services across many machines, you move to Kubernetes, which is the topic of the next lesson.

Recap

  • CI builds every image from a clean checkout, so every image traces back to one commit.
  • The official docker/* GitHub Actions handle login, tagging, multi-platform builds and layer caching.
  • Tag with the commit SHA for deploys and rollbacks, and with semver for humans; avoid deploying branch tags.
  • Test and scan the exact image you are about to push, and fail the pipeline on critical findings.
  • Deploy by pulling a specific tag and waiting for health checks; rolling back is redeploying the previous tag.
# Write your solution here

Finished reading? Mark this lesson complete to track your progress.

Up next · Lesson 11From Compose to KubernetesLearn when to move from Docker Compose to Kubernetes and translate a Compose service into Deployments, Services, ConfigMaps and Secrets with kubectl.