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 likev1.4.2, or a pull request. - Build: run
docker buildx buildwith 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=maxA few details worth understanding:
GITHUB_TOKENis created automatically for each run. Thepackages: writepermission lets it push to GHCR, so you store no long-lived password.setup-qemu-actionplusplatformsbuilds 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=ghastores 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:
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: trueRun 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: 3A 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[+] 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.
