Skip to content

Fix: GitHub Actions Docker Build and Push Failing

FixDevs · (Updated: )

Part of:  Docker, DevOps & Infrastructure

Quick Answer

How to fix GitHub Actions Docker build and push errors — registry authentication, image tagging, layer caching, multi-platform builds, and GHCR vs Docker Hub setup.

The Error

A GitHub Actions workflow fails when building or pushing a Docker image:

Error: Username and password required
Error response from daemon: Get "https://ghcr.io/v2/": unauthorized: authentication required

Or the push step fails after a successful build:

denied: requested access to the resource is denied
error parsing HTTP 403 response body: unexpected end of JSON input

Or the image tag is wrong:

invalid argument "myapp:latest" for "-t, --tag" flag:
invalid reference format: repository name must be lowercase

Or caching doesn’t work and every build is slow:

#15 importing cache manifest from ghcr.io/...
#15 ERROR: failed to solve: ...

Why This Happens

Docker build/push in CI requires explicit authentication, correct image naming conventions, and proper permissions. Unlike building on your local machine where docker login persists in ~/.docker/config.json, GitHub Actions runners start clean on every run. There is no saved credential, no cached layer, and no pre-authenticated session. Every piece of configuration that your local workflow assumes must be declared explicitly in the workflow YAML.

The authentication model differs by registry. GitHub Container Registry (GHCR) uses the GITHUB_TOKEN that Actions provides automatically, but that token needs the packages: write permission declared in the workflow’s permissions block. Docker Hub uses a separate access token stored as a repository secret. AWS ECR requires an aws-actions/amazon-ecr-login step that exchanges AWS credentials for a temporary Docker login. Each registry has its own naming convention, visibility defaults, and rate-limiting policy.

Specific causes:

  • Missing registry login stepdocker push requires authentication first. Without docker/login-action, pushes fail with “unauthorized”.
  • Insufficient permissions for GHCR — GitHub Container Registry requires packages: write permission in the workflow, and the token must have the right scope.
  • Wrong image name format — Docker image names must be lowercase. ${{ github.repository }} can include uppercase letters if the repo owner or name has them.
  • Missing GITHUB_TOKEN permissions — in workflows triggered by pull requests from forks, GITHUB_TOKEN has read-only permissions by default.
  • Cache not configured correctlydocker/build-push-action supports BuildKit cache, but the cache source/destination must be configured explicitly.
  • Multi-platform build without buildx — building for linux/amd64 and linux/arm64 requires docker/setup-buildx-action.

How Other Tools Handle This

docker/build-push-action is the most popular approach for building Docker images in GitHub Actions, but it is not the only one. Each alternative makes different trade-offs around security, speed, caching, and registry compatibility.

Manual docker build + docker push skips the official actions entirely. You run docker login, docker build -t image:tag ., and docker push image:tag as shell commands in a run: step. This works but loses BuildKit layer caching (unless you manually configure DOCKER_BUILDKIT=1 and --cache-from/--cache-to), metadata generation, and multi-platform support. The main advantage is transparency: every step is a visible shell command with no action-specific abstraction. Use this approach when debugging action-specific issues or when your Dockerfile is simple enough that caching does not matter.

Kaniko builds container images without a Docker daemon. It runs as a container itself, reads the Dockerfile, and pushes layers directly to the registry. In GitHub Actions, you run Kaniko via docker run gcr.io/kaniko-project/executor:latest. The key difference from docker build is that Kaniko does not require privileged mode or the Docker socket. This makes it safer for untrusted builds (like PR builds from forks) because the build process cannot escape the container. The trade-off is slower build times (Kaniko does not cache as aggressively as BuildKit) and occasional compatibility issues with Dockerfile features like RUN --mount=type=cache.

Buildah is a daemonless, rootless image builder that implements the OCI image spec. In GitHub Actions, you use redhat-actions/buildah-build to build the image and redhat-actions/push-to-registry to push it. Buildah supports Dockerfiles (via buildah bud) and a scripting API (buildah from, buildah run, buildah commit) for programmatic image construction. The scripting API is useful for images that are hard to express in a Dockerfile (e.g., images built from the output of another CI step). Buildah does not require a Docker daemon, which avoids the “Docker-in-Docker” problem on self-hosted runners.

ko is purpose-built for Go applications. It compiles a Go binary, layers it onto a distroless base image, and pushes the result to a registry — all in one command (ko build ./cmd/myapp). There is no Dockerfile. In GitHub Actions, you install ko via ko-build/[email protected] and run ko build in a run: step. ko is faster than docker build for Go because it skips the Dockerfile parsing, layer computation, and multi-stage build overhead. The limitation is that ko only works for Go. If your application includes non-Go assets (static files, config), you need to embed them in the Go binary or use a separate approach.

GHCR vs Docker Hub vs ECR/GCR authentication differs in significant ways. GHCR uses GITHUB_TOKEN with packages: write scope, which means no additional secrets are needed for repositories owned by the same account. Docker Hub requires a separate access token stored as a secret, and free accounts are subject to pull rate limits (100 pulls per 6 hours for anonymous, 200 for authenticated). AWS ECR requires aws-actions/configure-aws-credentials followed by aws-actions/amazon-ecr-login, which exchanges IAM credentials for a temporary 12-hour Docker login token. GCR (Google) uses google-github-actions/auth with a service account key or Workload Identity Federation. The choice of registry affects not just the login step but also image naming (ghcr.io/owner/repo vs 123456789.dkr.ecr.region.amazonaws.com/repo vs docker.io/user/repo), retention policies, and vulnerability scanning capabilities.

Fix 1: Complete Working Workflow (GHCR)

A minimal, working workflow for building and pushing to GitHub Container Registry:

name: Build and Push Docker Image

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write    # Required for GHCR push

    steps:
      - name: Checkout
        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: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=sha,prefix=sha-

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}  # Don't push on PRs
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

The docker/metadata-action handles image naming and tagging automatically — it generates tags like main, pr-42, 1.2.3, and sha-abc1234 based on the event.

Fix 2: Complete Workflow for Docker Hub

For Docker Hub instead of GHCR:

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 }}   # Use Access Token, not password

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ secrets.DOCKERHUB_USERNAME }}/myapp

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/myapp:buildcache
          cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/myapp:buildcache,mode=max

Set up Docker Hub secrets:

  1. Go to Repository Settings -> Secrets and variables -> Actions
  2. Add DOCKERHUB_USERNAME — your Docker Hub username
  3. Add DOCKERHUB_TOKEN — create an access token at Docker Hub -> Account Settings -> Security

Use an Access Token, not your password. Access tokens can be scoped to read/write and revoked independently of your account password.

Fix 3: Fix Image Name Case and Format

Docker image names must be lowercase. ${{ github.repository }} includes the owner name, which might be mixed case:

# WRONG — might be "MyOrg/MyApp" which is invalid
tags: ghcr.io/${{ github.repository }}:latest

# CORRECT — convert to lowercase
env:
  IMAGE_NAME: ${{ github.repository }}

steps:
  - name: Build and push
    uses: docker/build-push-action@v5
    with:
      tags: ghcr.io/${{ env.IMAGE_NAME }}:latest

Better approach — use metadata-action which handles this automatically:

- name: Extract metadata
  id: meta
  uses: docker/metadata-action@v5
  with:
    images: ghcr.io/${{ github.repository }}
    # metadata-action lowercases the image name automatically

Or use a step to normalize the name:

- name: Set image name
  id: image
  run: echo "name=$(echo 'ghcr.io/${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    tags: ${{ steps.image.outputs.name }}:latest

Fix 4: Fix Permissions for Fork Pull Requests

When a pull request comes from a fork, GITHUB_TOKEN has read-only permissions. This prevents pushing to GHCR. Use the pull_request_target event for fork PRs, or only push on branch pushes:

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          # Only push when triggered by a push event (not PRs from forks)
          push: ${{ github.event_name == 'push' }}
          tags: ...

For PRs, build but don’t push — this validates the Dockerfile without requiring write access:

- name: Build (no push) for PR validation
  if: github.event_name == 'pull_request'
  uses: docker/build-push-action@v5
  with:
    context: .
    push: false
    tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}

- name: Build and push on main
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:latest

Fix 5: Speed Up Builds with Layer Caching

Without caching, every build downloads base images and reinstalls dependencies from scratch. Configure BuildKit cache:

GitHub Actions cache (recommended — free, integrated):

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

Registry cache (useful for sharing cache between workflows):

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
    cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max

Optimize your Dockerfile for caching — put frequently-changing layers last:

# GOOD — dependencies cached separately from source code
FROM node:20-alpine
WORKDIR /app

# 1. Copy dependency manifests (rarely changes)
COPY package*.json ./
RUN npm ci --only=production    # Cached unless package.json changes

# 2. Copy source (changes often)
COPY . .
RUN npm run build

CMD ["node", "dist/server.js"]

Pro Tip: The mode=max option in cache-to exports all layers, not just the final stage’s layers. This is essential for multi-stage Dockerfiles where intermediate stages contain the dependency installation. Without mode=max, only the final stage is cached, and the dependency install runs from scratch every time.

Fix 6: Multi-Platform Builds

Build for both linux/amd64 (x86 servers) and linux/arm64 (AWS Graviton, Apple Silicon):

- name: Set up QEMU
  uses: docker/setup-qemu-action@v3

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

- name: Build and push multi-platform
  uses: docker/build-push-action@v5
  with:
    context: .
    platforms: linux/amd64,linux/arm64
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

Note: Multi-platform builds are significantly slower than single-platform because QEMU emulates non-native architectures. Use matrix strategy for faster parallel builds:

jobs:
  build:
    strategy:
      matrix:
        platform: [linux/amd64, linux/arm64]
    runs-on: ubuntu-latest
    steps:
      - name: Build
        uses: docker/build-push-action@v5
        with:
          platforms: ${{ matrix.platform }}
          # ...

Fix 7: Pass Build Args and Secrets

Pass environment-specific values to the Docker build without hardcoding them:

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    build-args: |
      APP_VERSION=${{ github.sha }}
      BUILD_DATE=${{ github.run_started_at }}
      NODE_ENV=production

For secrets that shouldn’t appear in image layers, use BuildKit secrets:

- name: Build with secret
  uses: docker/build-push-action@v5
  with:
    context: .
    secrets: |
      NPM_TOKEN=${{ secrets.NPM_TOKEN }}
# Dockerfile — access secret during build only
RUN --mount=type=secret,id=NPM_TOKEN \
    NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) \
    npm ci
# Secret is NOT stored in the image layer

Still Not Working?

Check the workflow permissions section. GHCR requires packages: write explicitly. A missing or misconfigured permissions block defaults to read-only:

jobs:
  build:
    permissions:
      contents: read
      packages: write   # Must be here for GHCR

Verify the image is public or the package visibility matches. New packages created in GHCR are private by default. If downstream systems try to pull without authentication, they’ll get 403. Set visibility in GitHub -> Packages -> your package -> Package settings -> Change visibility.

Check if the secret is masked in logs. GitHub Actions masks secret values in logs, replacing them with ***. If you see *** where the username or token should appear in a command, the secret is set but may have an incorrect value.

Test the login step in isolation:

- name: Test GHCR login
  run: |
    echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io \
      -u ${{ github.actor }} \
      --password-stdin
    echo "Login successful"

Check if actions/checkout is missing or incomplete:

# Without checkout, the build context is empty — no Dockerfile found
- name: Checkout
  uses: actions/checkout@v4
  # If you need Git history (for version tagging), add:
  # with:
  #   fetch-depth: 0

A missing actions/checkout step is a surprisingly common oversight, especially in workflows copied from examples that assume a different trigger event.

Verify the Dockerfile path if it is not in the repo root:

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    context: ./services/api
    file: ./services/api/Dockerfile
    # 'context' sets the build context directory
    # 'file' specifies the Dockerfile path relative to the repo root

For related CI/CD issues, see Fix: GitHub Actions Permission Denied, Fix: AWS ECR Authentication Failed, Fix: Docker Build Arg Not Available, and Fix: GitHub Actions Process Completed with Exit Code 1.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles