Prerequisites
Before you wire this up, make sure you've got a few things in place. You'll need a GitHub repository with your application code and a working
Dockerfileat the root — or wherever you plan to point the build context. You also need a container registry to push images to. This guide covers Docker Hub and GitHub Container Registry (GHCR), since those are the two I see teams reach for most often.
On the registry side, you'll need credentials. For Docker Hub, that means a username and an access token — not your account password. For GHCR, you can use a GitHub Personal Access Token with
write:packagesscope, or just lean on the built-in
GITHUB_TOKENthat Actions provides automatically. The built-in token is the cleaner path when you're pushing to GHCR, and I'd recommend starting there unless you have a cross-org setup that forces you toward a PAT.
Nail down your branch and tagging strategy before you write a single line of YAML. Knowing which branches trigger builds, whether you're tagging images with git SHAs or semantic versions, and whether you need multi-platform images (ARM64 alongside AMD64) will shape every decision in the workflow file. Get that settled up front — it's much easier than refactoring triggers after the fact.
Step-by-step Setup
Step 1: Store Your Registry Credentials as Secrets
Never hardcode registry credentials in your workflow file. Navigate to your repository on GitHub, go to Settings → Secrets and variables → Actions, and create your secrets there. For Docker Hub, add two:
DOCKERHUB_USERNAMEand
DOCKERHUB_TOKEN. The token should be a Docker Hub access token generated from your account's security settings, scoped to the specific repositories your pipeline needs. If your pipeline only needs to push, don't generate an admin-scoped token.
If you're using GHCR with a PAT instead of the built-in token — common when pushing from one repo into a package registry owned by a different org — add a secret called
CR_PAT. For single-repo setups,
GITHUB_TOKENremoves one credential management headache entirely. Use it.
Step 2: Create the Workflow Directory and File
GitHub Actions workflows live in
.github/workflows/at the repository root. Create that directory if it doesn't already exist. Your workflow file can be named anything ending in
.ymlor
.yaml. I name it something explicit like
docker-build-push.ymlso anyone looking at the Actions tab knows what it does without clicking into it.
Step 3: Define Your Triggers
The
on:block controls when your workflow runs. For a Docker build-and-push pipeline, you almost always want to trigger on pushes to your main branch and on new version tags. A pull request trigger is also worth adding for build-only validation — it catches broken Dockerfiles before they land in main without pushing anything to your registry.
If you're working in a monorepo with multiple services, path filtering is your friend. A change in a frontend directory shouldn't trigger a rebuild of your backend image. The
paths:filter under the push trigger lets you scope builds to the relevant subdirectory, keeping your runner minutes under control.
Step 4: Set Up the Build Job
The job needs a sequence of steps: checkout the code, set up QEMU if you're targeting multiple platforms, set up Docker Buildx, log in to the registry, extract metadata for tags and labels, and finally run the actual build and push. The
docker/build-push-actionhandles the heavy lifting, but it needs the right inputs fed to it.
Docker metadata extraction deserves specific attention. The
docker/metadata-actiongenerates image tags automatically from your git context — branch names, version tags, commit SHAs — and writes OCI-compliant labels without you touching a string. Letting it handle tag generation means you don't end up with fragile inline string manipulation in your YAML. In my experience, that kind of DIY tag logic is where things quietly break on release branches with slashes in the name.
Step 5: Configure Build Caching
Build caching is where you go from a workflow that takes four minutes to one that finishes in under a minute on a warm cache. GitHub Actions supports Buildx cache backends via the
cache-fromand
cache-toinputs. The two most common options are the GitHub Actions cache backend (
type=gha) and registry-based caching (
type=registry). The GHA backend is free, zero-config beyond the two input lines, and works immediately on GitHub-hosted runners. Use it unless you're on self-hosted runners without access to the GitHub cache service.
Set
cache-fromto pull existing layers and
cache-toto write new ones. With
mode=max, every layer in every build stage gets cached — not just the final one. For multi-stage Dockerfiles, this is the setting you want. Without it, you're only caching the last stage and missing most of the benefit.
Full Configuration Example
Here's a complete workflow that covers Docker Hub and GHCR simultaneously, multi-platform builds for AMD64 and ARM64, metadata-driven tagging, PR-safe push logic, and GHA layer caching. Read through it carefully — there are several intentional choices baked in that I'll explain below.
name: Docker Build and Push
on:
push:
branches:
- main
tags:
- 'v*.*.*'
pull_request:
branches:
- main
env:
REGISTRY_DOCKERHUB: docker.io
REGISTRY_GHCR: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_DOCKERHUB }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_GHCR }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY_DOCKERHUB }}/${{ secrets.DOCKERHUB_USERNAME }}/my-app
${{ env.REGISTRY_GHCR }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-,format=short
- name: Build and push Docker image
uses: docker/build-push-action@v5
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=max
A few things worth calling out explicitly. The
if: github.event_name != 'pull_request'condition on both login steps prevents the workflow from attempting registry authentication during PR runs triggered from forks. Fork-based PR runs don't have access to secrets from the target repository — the login step would fail with a credentials error, which is confusing and unhelpful. Skipping login on PRs sidesteps the problem entirely.
The
push: ${{ github.event_name != 'pull_request' }}flag on the build step mirrors that logic. Buildx still runs the full build on PR triggers — you get Dockerfile validation — but nothing gets pushed. This is exactly the behavior you want: catch errors early without polluting your registry with every PR's intermediate builds.
The
permissionsblock is required. The
packages: writepermission is what allows the built-in
GITHUB_TOKENto push to GHCR. Without it, you get a 403 on the push step. It looks like an authentication failure, so you'll probably go re-check your credentials first — I've seen teams spend an hour on this before realizing it's a permissions declaration issue, not a credential issue.
Variant: Single Registry, Simplified Tagging
If you're only pushing to Docker Hub and don't need multi-platform images, you can strip this down considerably. Here's a leaner version for that common case:
name: Docker Build and Push
on:
push:
branches:
- main
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/my-app:latest
${{ secrets.DOCKERHUB_USERNAME }}/my-app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
This version always tags
latestplus the full git SHA. It's predictable and works well for smaller teams that deploy by SHA reference rather than semver. If your deployment tooling expects specific version tags, use the metadata-action version from the full example instead.
Verification Steps
After pushing your workflow file to the repository, head to the Actions tab. You should see the workflow listed. If you pushed to
main, it should already be running or queued. Click into the run to watch the steps execute in real time.
The steps most likely to fail on the first run are the login step (credential misconfiguration) and the build-and-push step (Dockerfile errors or a slow cold cache). A full cache miss on a complex, multi-stage image can take several minutes on the first run — that's completely normal. From the second push onward, layer reuse should cut build time significantly.
Once the workflow completes successfully, verify the image actually landed in your registry. For Docker Hub, check hub.docker.com under your account's repositories and confirm the tags are present. For GHCR, navigate to your GitHub profile or organization, click Packages, and confirm the new image is listed with the tags you expected from the metadata-action configuration.
Don't stop at verifying the push. Pull the image locally and run it. I've seen workflows report a successful push for a corrupted image where the manifest was written but the layers were incomplete. A quick pull and run will tell you immediately whether the image is actually usable.
# Pull and verify from Docker Hub
docker pull infrarunbook-admin/my-app:latest
# Basic sanity check — adjust the flag to match your application
docker run --rm infrarunbook-admin/my-app:latest --version
# Inspect OCI labels written by metadata-action
docker inspect infrarunbook-admin/my-app:latest | grep -A 20 '"Labels"'
Pay attention to the OCI labels in that inspect output. The metadata-action writes
org.opencontainers.image.revisionwith the git SHA and
org.opencontainers.image.createdwith the build timestamp. If those fields are populated correctly, your metadata pipeline is working end-to-end, and you have a clean audit trail baked into every image.
Common Mistakes
Using Your Docker Hub Password Instead of an Access Token
Docker Hub deprecated password-based API authentication. If you store your actual Docker Hub account password as a secret and use it in the login step, it may work today but it'll break without warning when Docker Hub finishes rolling out enforcement. Always use an access token. Generate one at hub.docker.com under Account Settings → Security → New Access Token. Scope it to the minimum permissions your pipeline needs — Read and Write for the target repositories. Don't generate admin-scoped tokens for a pipeline that only pushes.
Adding ARM64 Without QEMU
If you add
linux/arm64to your platforms list without the QEMU setup step, the build fails with an error about the builder not supporting the requested platform. QEMU provides the CPU emulation that lets GitHub's AMD64-based hosted runners build ARM images. The
docker/setup-qemu-actionhandles this, but it has to appear before the Buildx setup step in your workflow. Order matters.
Pushing on Every Pull Request Run
This is surprisingly common in older workflow templates floating around online. The workflow triggers on pull requests and pushes unconditionally, which means unreviewed code lands in your registry on every PR open or update. On Docker Hub's free tier, this burns through your pull rate limit fast. It also creates race conditions when two PRs are open simultaneously and both push a
latesttag. The
push: ${{ github.event_name != 'pull_request' }}pattern prevents this cleanly.
Hardcoding Image Names in Multiple Places
If you write the full image name as a string literal five times across your workflow file, you'll eventually rename the image or move registries and miss one of the occurrences. Define registry URLs and image name patterns in the
env:block at the workflow level, then reference them with
${{ env.VARIABLE_NAME }}. Anything that appears more than once should be a variable.
Skipping the Permissions Block
By default,
GITHUB_TOKENpermissions vary based on repository settings and can be broader than what your workflow actually needs. Declaring explicit permissions in the job documents your intent, limits the token's blast radius if something unexpected runs, and makes security reviews easier. Use
contents: readas the baseline and add only what the job genuinely requires —
packages: writefor GHCR pushes, nothing more.
Not Configuring Cache at All
Skipping cache configuration is the single biggest performance mistake I see in Docker build workflows. The difference between a cold build and a warm cache hit is often three to five minutes on a reasonably complex multi-stage image. On a busy repo with several contributors pushing throughout the day, that time adds up fast. The GHA cache backend costs nothing extra on GitHub-hosted runners. There's genuinely no reason not to configure it — add the two cache lines and move on.
Pinning to Floating Branch References
Using
actions/checkout@v4is reasonable for most teams. Using
actions/checkout@mainis not. If an action maintainer pushes a breaking change — or worse, a compromised dependency — to the default branch, your next build picks it up automatically. Pin to major version tags at minimum. In high-security environments, pin to a specific commit SHA. Major version tags strike a reasonable balance between stability and keeping up with bug fixes from the action maintainers.
That covers the complete setup path from scratch to a working, production-grade build-and-push pipeline. The full configuration example handles multi-platform builds, dual-registry pushes, PR-safe logic, metadata-driven tagging, and layer caching — everything a real workflow needs from day one. Adjust the trigger branches and image name references to match your setup, store your secrets, commit the workflow file, and push. The first run will be slower without warm cache, but from the second merge onward, you'll have fast, reliable Docker image builds running automatically on every change that hits main.
