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_targetand 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-argsinstead 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
RUNinstruction 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
.dockerignorefile,docker builduploads everything undercontextto the builder, including.git, local dependency directories, and any stray.envfile — any of which a carelessCOPY .instruction can then bake straight into a layer. The fix is a.dockerignorelisting 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: readat the workflow level and addpackages: writeonly 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’sdigestoutput 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 plainpull_requestfor a public repository without first restrictingpushto trusted events, as shown in Example 3. - Pass build-time secrets through BuildKit’s
secretsinput andRUN --mount=type=secret, never throughbuild-argsorENV, which persist in the image’s history. - Maintain a
.dockerignorefile so the build context sent to the builder contains only what the Dockerfile actually needs. - Pin actions like
docker/build-push-actionto 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
- Take Example 1 and rewrite it to use
docker/setup-buildx-actionanddocker/build-push-actioninstead of rawdocker buildanddocker push, then explain what capability you gained beyond shorter YAML. - Add a
.dockerignorefile 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. - 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
mainand confirm the same job now logs in and pushes. - Take the vulnerable
pull_request_targetsnippet 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. - Introduce a build-time credential your Dockerfile currently needs as an
ARG, and convert it to a BuildKit secret mount, then confirm withdocker historyon 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.
