Skip to content

Fix: GitHub Actions Docker Build and Push Failing

FixDevs ·

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:

  • 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.

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"]

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"

For related CI/CD issues, see Fix: GitHub Actions Permission Denied and Fix: AWS ECR Authentication Failed.

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