InfraRunBook
    Back to articles

    GitHub Actions Docker Build and Push Workflow

    CI/CD
    Published: Apr 20, 2026
    Updated: Apr 20, 2026

    A senior engineer's guide to building and pushing Docker images with GitHub Actions, covering multi-platform builds, registry authentication, layer caching, and the mistakes that silently break production pipelines.

    GitHub Actions Docker Build and Push Workflow

    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

    Dockerfile
    at 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:packages
    scope, or just lean on the built-in
    GITHUB_TOKEN
    that 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_USERNAME
    and
    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_TOKEN
    removes 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
    .yml
    or
    .yaml
    . I name it something explicit like
    docker-build-push.yml
    so 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-action
    handles the heavy lifting, but it needs the right inputs fed to it.

    Docker metadata extraction deserves specific attention. The

    docker/metadata-action
    generates 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-from
    and
    cache-to
    inputs. 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-from
    to pull existing layers and
    cache-to
    to 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

    permissions
    block is required. The
    packages: write
    permission is what allows the built-in
    GITHUB_TOKEN
    to 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

    latest
    plus 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.revision
    with the git SHA and
    org.opencontainers.image.created
    with 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/arm64
    to 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-action
    handles 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

    latest
    tag. 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_TOKEN
    permissions 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: read
    as the baseline and add only what the job genuinely requires —
    packages: write
    for 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@v4
    is reasonable for most teams. Using
    actions/checkout@main
    is 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.

    Frequently Asked Questions

    Why does my GitHub Actions Docker push fail with a 403 error on GHCR even though my token looks correct?

    The most common cause is a missing permissions declaration in your job. The built-in GITHUB_TOKEN needs explicit 'packages: write' permission to push to GitHub Container Registry. Add a permissions block to your job with 'contents: read' and 'packages: write', then re-run the workflow. This is separate from your credentials — the token itself is valid, but it's being issued without the packages scope unless you declare it.

    Should I use GITHUB_TOKEN or a Personal Access Token for pushing to GHCR?

    Use GITHUB_TOKEN when you're pushing to a registry in the same repository or organization that owns the workflow. It's automatically available, requires no secret management, and is scoped to the run. Use a PAT when your workflow needs to push to a GHCR namespace owned by a different organization or user. Store the PAT as a repository secret and reference it in your login step.

    How do I stop GitHub Actions from pushing Docker images on pull request runs?

    Add a conditional to your push input: 'push: ${{ github.event_name != 'pull_request' }}'. This tells Buildx to build the image but skip the push step when the trigger is a pull_request event. You should also add the same condition to your registry login steps, since fork-based PRs don't have access to your repository secrets and the login will fail if you attempt it.

    Why is my GitHub Actions Docker build slow even though I configured caching?

    The first run after adding cache configuration always hits a cold cache — there's nothing stored yet. Subsequent runs should be significantly faster as layers accumulate. If builds remain slow after the first run, check that your Dockerfile layer ordering is optimized: put infrequently changing steps like dependency installs before frequently changing steps like copying application code. Also confirm you're using 'mode=max' on the cache-to setting, which caches all stages of a multi-stage build rather than just the final one.

    How do I build Docker images for both AMD64 and ARM64 in GitHub Actions?

    You need three things: the 'docker/setup-qemu-action' step to enable CPU emulation, the 'docker/setup-buildx-action' step to configure the multi-platform builder, and the 'platforms: linux/amd64,linux/arm64' input on your build-push-action step. QEMU must come before Buildx setup in your step ordering. Note that multi-platform builds with emulation are slower than native builds — if build time is critical and you have ARM runners available, consider using a matrix strategy with native runners instead.

    Related Articles