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

Sponsored

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 .dockerignore to speed things up and avoid leaking secrets.
  • Dockerfile — the recipe that turns the context into an image, layer by layer. Each RUN, COPY, and EXPOSE creates 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:

  1. Check out the code — the CI runner clones your repository into a fresh workspace.
  2. 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:dind for GitLab or the docker service in GitHub Actions).
  3. Build the image — the docker build command reads your Dockerfile and the build context, executing each instruction to produce a set of layers.
  4. Tag the image — you assign a human-readable version (like app:1.2.3 or app:sha-abc123) so you can track exactly which code produced this image.
  5. Push to a registry — authentication (a token or secret) is passed securely to docker push, uploading the layers to your chosen registry.
  6. 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 latest and 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 build directly on a runner with Docker installed — simpler but slower; no layer caching across runs.
  • Using docker/build-push-action with cache-from and cache-to for BuildKit caching — the recommended modern approach on GitHub Actions.
  • Using docker:dind in 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 latest to 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 in denied: requested access errors.
  • Tagging with latest only, 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 latest base 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

  1. Use docker build --cache-from and --cache-to with BuildKit to share caches between CI runs.
  2. For GitLab CI, use the docker:dind service to run a Docker daemon inside a job.
  3. Leverage a Makefile with a build target to wrap docker build and docker push commands 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 latest and a unique identifier (like the commit SHA) for traceable rollbacks.
  • Use secure registry authentication via secrets or GITHUB_TOKEN and 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.