Docker Builds in GitHub Actions

So far your workflows have built and tested application code directly on the runner’s filesystem. Many applications ship as container images instead: a Dockerfile packages the application and its runtime into one artifact that behaves the same on a laptop, in CI, and in production. This lesson covers how to build that image safely inside a GitHub Actions job and push it to a registry — the mechanical foundation the rest of this section builds on for multi-platform builds, tag and digest strategy, and registry-specific publishing.

Overview / How it works

GitHub-hosted runners come with the Docker Engine and the Buildx CLI plugin preinstalled, so a job can build an image the same way you would locally. Each run starts on a fresh, ephemeral runner with no build cache from previous runs unless you explicitly wire one up, which is why a naive build can feel slow compared to your own machine — caching strategy gets its own lesson next in this section.

It matters precisely what building and pushing an image accomplishes. Compiling application code and running its test suite is continuous integration: verifying that a change is correct. Packaging that verified change into a versioned, immutable container image and publishing it to a registry is continuous delivery — it produces an artifact that is ready to run, but nothing has actually run it yet. Continuous deployment is the separate step, covered later in this course, of automatically taking that published image and putting it into a running environment. A workflow that only builds and pushes an image, even on every commit to main, is practicing continuous delivery, not continuous deployment.

Docker Buildx is the builder you want driving this, rather than the legacy builder. It uses BuildKit, which resolves independent build stages in parallel, supports pluggable cache backends, and can target multiple CPU architectures from a single invocation. You configure it with a dedicated action rather than a raw docker build call, which also gives you structured outputs, such as the exact digest of the image that was pushed, that later steps and jobs can consume.

Syntax or workflow structure

A minimal image-building job needs four ingredients: a Dockerfile in the repository, a checkout step, a way to authenticate to the destination registry, and a build step. In GitHub Actions that maps to actions/checkout, docker/setup-buildx-action, docker/login-action, and docker/build-push-action, all maintained by Docker and GitHub and pinned to a stable major version.

Two things need to be explicit rather than assumed. First, permissions: the job needs contents: read to check out the repository and, if it pushes to GitHub Container Registry, packages: write so the automatic GITHUB_TOKEN can publish there. Neither scope should be granted anywhere it isn’t used. Second, the registry credential itself: docker/login-action takes a registry, a username, and a password input, and for GHCR that password is almost always secrets.GITHUB_TOKEN rather than a separate personal token, since the built-in token already has exactly the scope you granted it in the permissions block.

The build-push-action itself exposes the inputs that matter for a first working pipeline:

Input Purpose
context Directory sent to the builder as the build context; usually .
file Path to the Dockerfile, if it isn’t ./Dockerfile
push Whether to publish the result to the logged-in registry, versus building only
tags One or more registry/repository:tag references to publish
build-args Non-secret values passed into ARG instructions at build time
secrets Values exposed to RUN --mount=type=secret without being baked into a layer

Platform targeting and cache backends are also inputs to this same action, but they deserve their own lesson; here the focus is a single-platform build that pushes reliably and safely.

Examples

Example 1: building and pushing with plain Docker commands

You can build and push without the dedicated build action at all, calling docker build and docker push directly once you are authenticated:

name: Build and Push (manual docker commands)

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

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

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build image
        run: docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .

      - name: Push image
        run: docker push ghcr.io/${{ github.repository }}:${{ github.sha }}

On a push to main, this checks out the repository, authenticates to GHCR using the run’s own GITHUB_TOKEN, builds an image tagged with the commit SHA, and pushes it. It works, but every additional tag means another duplicated command, there is no structured way to capture the resulting digest for a later job, and you get none of BuildKit’s parallel stage resolution or cache backends without extra manual flags.

Example 2: the same result with Buildx and build-push-action

Replacing the manual build and push steps with the dedicated action gets you BuildKit, multiple tags in one input, and a usable output:

name: Build and Push (Buildx build-push-action)

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

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

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}

      - name: Show image digest
        run: echo "Pushed image digest: ${{ steps.build.outputs.digest }}"

This pushes both a floating :latest tag and an immutable-per-commit :sha tag from one tags input, and the build step’s digest output now holds the exact content hash of what was pushed — something a raw docker push call does not hand back to you without a separate inspect step.

Example 3: separating untrusted validation from trusted publishing

A Dockerfile can also be validated on pull requests, including ones opened from forks, without ever logging in to a registry or exposing a push credential to that event:

name: Build, Validate, and Publish

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        if: github.event_name == 'push'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build image (push only on main)
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name == 'push' }}
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

  deploy:
    needs: build
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
    steps:
      - name: Deploy pinned image
        run: ./deploy.sh ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}

On a pull request, the login step is skipped entirely because its if condition is false, and push evaluates to false, so the job only confirms the Dockerfile still builds — no credential is ever loaded for that event, which matters just as much for a fork-opened pull request as for one opened by a maintainer. On a push to main, the same job logs in, pushes the image, and hands its digest to a deploy job gated by a protected production environment, so the artifact a reviewer approves is provably the one that was just built.

Step by step

For the combined workflow in Example 3, a pull request event first triggers the build job. Actions checks out the pull request’s code, configures Buildx, evaluates the login step’s if condition as false and skips it, then runs the build step with push resolved to false — the image is built locally on the runner and discarded when the job ends.

A push to main triggers the same job differently: the login step’s condition is now true, so it authenticates to GHCR before the build step runs with push resolved to true, publishing the image and recording its digest as a job output. Because deploy declares needs: build and an if restricted to push events, it only starts after build succeeds on a push, and it references the image strictly by the digest that build reported, not by re-deriving a tag.

Common Mistakes

  • Building fork pull requests with pull_request_target and a push credential. This trigger runs with the base repository’s permissions and secrets even though the pull request’s own head content is what gets checked out and built:
    on:
      pull_request_target:
    
    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
            with:
              ref: ${{ github.event.pull_request.head.sha }}
          - uses: docker/login-action@v3
            with:
              registry: ghcr.io
              username: ${{ github.actor }}
              password: ${{ secrets.GITHUB_TOKEN }}
          - uses: docker/build-push-action@v5
            with:
              push: true
              tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.pull_request.number }}

    Anything an attacker puts in that fork’s Dockerfile, or in a script the Dockerfile invokes, now runs with access to the registry push credential. The fix is to trigger PR builds on plain pull_request, which runs with a read-only token and no repository secrets, and to build without pushing, as in Example 3; if you genuinely need to publish PR images for testing, do it in a separate job that a maintainer manually approves.

  • Passing a secret through build-args instead of a BuildKit secret mount. A build argument is recorded in the image’s build history, so anyone who can pull the image can read it back out even though it never appeared in the workflow log:
    # Dockerfile (bad — token is baked into a layer)
    ARG NPM_TOKEN
    RUN npm config set //registry.npmjs.org/:_authToken=${NPM_TOKEN} && npm ci
    
    # workflow step (bad — passes the token as a build-arg)
          - uses: docker/build-push-action@v5
            with:
              push: true
              build-args: |
                NPM_TOKEN=${{ secrets.NPM_TOKEN }}

    The fix is a BuildKit secret mount, which exposes the value only to the one RUN instruction that asks for it and never persists it in a layer:

    # Dockerfile (fixed — secret is mounted, not baked in)
    RUN --mount=type=secret,id=npm_token \
        npm config set //registry.npmjs.org/:_authToken=$(cat /run/secrets/npm_token) && npm ci
    
    # workflow step (fixed — secret passed through BuildKit, not build-args)
          - uses: docker/build-push-action@v5
            with:
              push: true
              secrets: |
                npm_token=${{ secrets.NPM_TOKEN }}
  • Sending an oversized or sensitive build context. Without a .dockerignore file, docker build uploads everything under context to the builder, including .git, local dependency directories, and any stray .env file — any of which a careless COPY . instruction can then bake straight into a layer. The fix is a .dockerignore listing at minimum .git, dependency and build output directories, and any local environment files, kept in sync with what the Dockerfile actually needs.

Best Practices

  • Set permissions: contents: read at the workflow level and add packages: write only on the job that logs in and pushes, so a compromised build step elsewhere in the workflow cannot publish anything.
  • Tag every image with something immutable, such as the commit SHA, in addition to any floating tag like :latest, and prefer the action’s digest output over either tag when a later job needs to reference the exact image that was built.
  • Never let a job that logs in to a registry or holds a push credential run on pull_request_target, or on plain pull_request for a public repository without first restricting push to trusted events, as shown in Example 3.
  • Pass build-time secrets through BuildKit’s secrets input and RUN --mount=type=secret, never through build-args or ENV, which persist in the image’s history.
  • Maintain a .dockerignore file so the build context sent to the builder contains only what the Dockerfile actually needs.
  • Pin actions like docker/build-push-action to a known major version, and consider pinning to a commit SHA for any workflow with registry-push or deployment access; a SHA pin cannot be silently repointed by the action’s maintainer, at the cost of updating it by hand.
  • Treat every workflow here as a template: registry names, image paths, deploy scripts, and environment names must be replaced with your own infrastructure’s values, never with hard-coded hosts or credentials.

Practice Exercises

  1. Take Example 1 and rewrite it to use docker/setup-buildx-action and docker/build-push-action instead of raw docker build and docker push, then explain what capability you gained beyond shorter YAML.
  2. Add a .dockerignore file to a sample project that currently has none, and describe what specifically would have ended up in the build context, and potentially in a layer, without it.
  3. Starting from Example 3, open a pull request from a separate branch and confirm in the job log that the login step is skipped and no image is pushed; then merge to main and confirm the same job now logs in and pushes.
  4. Take the vulnerable pull_request_target snippet from Common Mistakes and rewrite it so pull requests, including ones from forks, can still validate that the Dockerfile builds, without ever exposing a registry credential to that event.
  5. Introduce a build-time credential your Dockerfile currently needs as an ARG, and convert it to a BuildKit secret mount, then confirm with docker history on the built image that the value no longer appears in any layer.

Summary

Building a Docker image in GitHub Actions means checking out the repository, setting up Buildx, authenticating to a registry with a minimally scoped credential, and running the build through docker/build-push-action rather than raw CLI calls, which gives you BuildKit and a usable digest output. Publishing that image is continuous delivery, not continuous deployment: it produces a versioned, deployable artifact, and nothing runs it until a later, separately gated step does. Keep permissions scoped to the job that actually needs to push, never expose a push credential to pull_request_target or an untrusted fork event, keep build-time secrets out of build-args and layer history by using BuildKit secret mounts, and treat every path, host, and credential in these examples as a placeholder you must replace with your own infrastructure’s values.