Build a Docker Image in CI
Learn to build a Docker image in CI with this hands-on tutorial. Step-by-step instructions, troubleshooting tips, and what to study next.
Focus: build a docker image in ci
You’ve mastered pipelines, artifacts, and approvals — but every deployment still depends on that fragile moment when someone builds an image on their laptop and prays it works in production. That’s the pain this lesson kills: build a Docker image in CI means you stop trusting local builds and start producing verifiable, reproducible container images directly from your pipeline. By the end, you’ll own the entire image lifecycle — from Dockerfile to registry — inside your CI system, and you’ll be ready to push that image to production with confidence.
The problem this lesson solves
Manually building Docker images is a silent productivity killer. Every developer with a slightly different laptop, OS, or dependency version produces a slightly different image — and those differences explode when the image finally lands on a production server. You’ve seen it: code that runs fine locally, then crashes in staging with a cryptic missing-library error.
Beyond reproducibility, manual builds create a security and governance gap. Without a CI step to build your Docker image, you have no audit trail of who built what, with which code, or when. You also can’t enforce scanning for vulnerabilities or applying minimal base images — because no one is holding the build accountable. Finally, manual builds are simply slow: you wait for a local build, tag it, and hope the registry stays in sync. CI removes all that friction.
Pro tip: The moment you introduce a CI build step, your Docker image becomes a first-class artifact — versioned, verifiable, and ready for automated deployment. That’s exactly what the next lessons in this track will build on.
Core concept / mental model
Think of building a Docker image in CI as a photocopier chain: your source code is the original document, the Dockerfile is the settings (contrast, paper size), and the CI pipeline is the photocopier itself. The output — the image — is a faithful copy that doesn’t depend on who pressed the button.
To make that analogy concrete, you need three pieces:
- Build context — the files and folders sent to the Docker daemon. Usually your repository, but you can trim it with a
.dockerignoreto speed things up and avoid leaking secrets. - Dockerfile — the recipe that turns the context into an image, layer by layer. Each
RUN,COPY, andEXPOSEcreates a new layer. - Registry — the destination where your image gets stored and versioned, like Docker Hub or GitHub Container Registry (GHCR).
The mental model has a simple loop: CI checks out code → builds an image via the Dockerfile → pushes that image to a registry → deployment pulls that exact image later. This loop is the backbone of modern CI/CD — everything else (scans, tests, promotions) hangs off it.
How it works step by step
Building a Docker image in CI works like this, in sequence:
- Check out the code — the CI runner clones your repository into a fresh workspace.
- Prepare the Docker environment — you either use a runner with Docker pre-installed or start a service container that provides the Docker daemon (for example, using
docker:dindfor GitLab or thedockerservice in GitHub Actions). - Build the image — the
docker buildcommand reads your Dockerfile and the build context, executing each instruction to produce a set of layers. - Tag the image — you assign a human-readable version (like
app:1.2.3orapp:sha-abc123) so you can track exactly which code produced this image. - Push to a registry — authentication (a token or secret) is passed securely to
docker push, uploading the layers to your chosen registry. - Use the artifact — downstream jobs pull that exact image for testing, deployment, or further analysis.
Each step is a cause-and-effect chain: a fresh workspace ensures reproducibility, a precise tag ensures traceability, and a secure push prevents unauthorized image tampering.
Hands-on walkthrough
Now let’s build a Docker image in CI using GitHub Actions — the focus of this track. First, create a minimal Python application and its Dockerfile.
Example 1: A minimal Flask app with a Dockerfile
# app.py
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello():
return "Hello from CI!"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Example 2: The GitHub Actions workflow that builds and pushes the image
# .github/workflows/build-image.yml
name: Build Docker Image
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # needed for GHCR push
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ghcr.io/${{ github.repository }}:latest
Expected output: After a push to main, the workflow runs and ends with a step like ... exporting manifest ... and a confirmation that the image was pushed to ghcr.io/your-name/your-repo:latest.
Example 3: Building with a manual tag for release branches
# For release tags, add a version tag
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository }}:${{ github.sha }}
Pro tip: Always tag with both
latestand the commit SHA. That way, you can roll back to a specific code revision without hunting through docker history.
Compare options / when to choose what
Choosing how to build in CI depends on your registry and your need for performance. Here’s a comparison:
| Option | Best for | Pros | Cons |
|---|---|---|---|
| GitHub Container Registry (GHCR) | GitHub Actions users | Native auth via GITHUB_TOKEN, no extra secrets |
Less familiar to some teams |
| Docker Hub | Public images, simple setups | Familiar, easy to migrate | Separate account + PAT secrets; rate limits on free tier |
| Self-hosted registry (e.g., Harbor) | Enterprise, air‑gapped | Full control, compliance | More ops overhead, custom integration |
If you’re already on GitHub, GHCR is the least friction — the token is injected automatically. If you’re in a multi-cloud environment or need compliance, a self-hosted registry gives you control but requires more configuration. For quick experiments, Docker Hub is fine, but beware of pull-rate limits.
Variations you might encounter
- Using
docker builddirectly on a runner with Docker installed — simpler but slower; no layer caching across runs. - Using
docker/build-push-actionwithcache-fromandcache-tofor BuildKit caching — the recommended modern approach on GitHub Actions. - Using
docker:dindin GitLab or other CI systems — it runs a separate Docker daemon in a container, useful when the runner isn’t Docker-native.
Troubleshooting & edge cases
Even with a clean pipeline, you’ll hit a few classic walls. Here’s how to climb them.
Error: Cannot connect to the Docker daemon
This usually means the runner doesn’t have the Docker CLI properly configured. Fix:
- On GitHub Actions, use the docker/setup-buildx-action which also initializes the daemon connection.
- On self-hosted runners, ensure the docker service is running and the runner user has permission (add it to the docker group).
Error: denied: requested access to the resource is denied
Your push is failing due to authentication. Check:
- The token has write:packages scope (for GHCR) or if you’re using a PAT, it’s correctly stored as a secret.
- You’re logging in with the correct registry and username.
Build succeeds locally but fails in CI
- Compare the
.dockerignore— maybe you’re copying unintended files (like.env) that break the build. - Pin the base image to a specific digest instead of
latestto avoid the upstream changing between local and CI. - Increase the CI timeout — some layers (like
pip install) are slow on fresh runners.
Tagging with ${{ github.repository }} is lowercased automatically?
Yes — registry tags must be lowercase. Use ${{ github.repository_owner }} and the repo name, and normalize strings with | lower in YAML to avoid surprises.
Build takes too long
Enable layer caching:
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
cache-from: type=gha
cache-to: type=gha,mode=max
This caches layers between runs, drastically speeding up subsequent builds.
What you learned & what's next
You now understand how to build a Docker image in CI — from a fresh checkout to a tagged push in a registry — and you’ve practiced with a real GitHub Actions workflow. More importantly, you’ve internalized the core principle: your pipeline, not your laptop, is now the single source of truth for your container image.
That principle directly supports the lesson’s two learning objectives: you can explain the core idea behind building an image in CI (reproducibility via pipeline), and you’ve completed a practical exercise that produces a real image artifact.
Your next step in the track is to deploy that image to a Kubernetes cluster — or if you’re following a different path, you’ll learn how to promote that image across environments using approvals and promotions. You’re now armed with the key tool that every later stage will consume.
Final reminder: Always version your images, store secrets securely, and keep your builds deterministic. CI will reward you with consistency — and production will thank you.
Practice recap
Create a new GitHub repository with the minimal Flask app and Dockerfile from this lesson. Write a GitHub Actions workflow that builds the image and pushes it to GHCR on every push to main. Then modify the workflow to add a second job that runs docker run on the built image to verify it returns a 200 response — this connects your build step to a practical validation before you move on to deployments.
Common mistakes
- Forgetting to authenticate with the registry before
docker push— results indenied: requested accesserrors. - Tagging with
latestonly, which makes rollbacks impossible; always include a unique SHA tag. - Building without
.dockerignore, which sends huge or sensitive files (like.env) to the Docker daemon, slowing builds and leaking secrets. - Using
latestbase images in the Dockerfile — non-deterministic; pin to a digest or exact version. - Assuming the runner has Docker installed; on self-hosted runners you must install Docker or use a service like
docker:dind.
Variations
- Use
docker build --cache-fromand--cache-towith BuildKit to share caches between CI runs. - For GitLab CI, use the
docker:dindservice to run a Docker daemon inside a job. - Leverage a
Makefilewith abuildtarget to wrapdocker buildanddocker pushcommands consistently across local and CI.
Real-world use cases
- A web app team automatically builds and pushes a new image on every merge to main, then deploys that same image to staging.
- A fintech startup uses CI to build a hardened, multi‑stage Docker image, scans it for CVEs, and only promotes it to production if the scan passes.
- An open‑source project publishes pre‑built images to GHCR for each release tag, so users can pull stable versions without compiling from source.
Key takeaways
- Build a Docker image in CI turns your pipeline into the single source of truth for container artifacts.
- A fresh workspace and a pinned Dockerfile ensure reproducible builds — no more 'works on my machine'.
- Tag images with both
latestand a unique identifier (like the commit SHA) for traceable rollbacks. - Use secure registry authentication via secrets or
GITHUB_TOKENand never embed credentials in the Dockerfile. - Enable layer caching in CI to keep build times short on every push.
- After the build, the image becomes a versioned artifact ready for testing, scanning, and deployment in later pipeline stages.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.