GitHub Actions: Docker Build & Publish Workflow
When to Use This Skill
Use this pattern when you need a CI workflow that:
- Pushes Docker images to GHCR (and optionally a second registry)
- Tags images with a datetime+sha slug for traceability and rollback
- Applies
latestonly on the default branch (not feature branches or tags) - Supports manual triggering with a push toggle (useful for dry-run builds)
- Uses GHA layer cache to speed up repeat builds
- Validates Docker builds on pull requests (every PR) with optional push to GHCR via
publish-dockerlabel
The Complete Workflow
name: Build and Publish Docker Image
on:
push:
branches:
- main
tags:
- "v*"
pull_request:
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
inputs:
push:
description: "Push image to registries"
required: false
default: false
type: boolean
env:
GHCR_IMAGE: ghcr.io/${{ github.repository }}
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
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: Log in to external registry
if: vars.EXTERNAL_REGISTRY_URL != ''
uses: docker/login-action@v3
with:
registry: ${{ vars.EXTERNAL_REGISTRY_URL }}
username: ${{ secrets.EXTERNAL_REGISTRY_USERNAME }}
password: ${{ secrets.EXTERNAL_REGISTRY_PASSWORD }}
- name: Get commit info
id: commit
run: |
echo "datetime=$(git log -1 --format=%cd --date=format:'%Y%m%d%H%M' HEAD)" >> ${GITHUB_OUTPUT}
echo "sha=$(git rev-parse --short HEAD)" >> ${GITHUB_OUTPUT}
- name: Build image list
id: images
env:
GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
EXTERNAL_REGISTRY_URL: ${{ vars.EXTERNAL_REGISTRY_URL }}
EXTERNAL_REGISTRY_IMAGE: ${{ vars.EXTERNAL_REGISTRY_IMAGE }}
run: |
IMAGES="${GHCR_IMAGE}"
if [[ -n "${EXTERNAL_REGISTRY_URL}" ]]; then
IMAGE_NAME="${EXTERNAL_REGISTRY_IMAGE:-my-app}"
IMAGES="${IMAGES}"$'\n'"${EXTERNAL_REGISTRY_URL}/${IMAGE_NAME}"
fi
{
echo 'list<<EOF'
echo "${IMAGES}"
echo 'EOF'
} >> "${GITHUB_OUTPUT}"
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.images.outputs.list }}
tags: |
type=ref,event=pr
type=raw,value=${{ steps.commit.outputs.datetime }}_${{ steps.commit.outputs.sha }},enable=${{ github.event_name != 'pull_request' }}
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: >-
${{
github.event_name == 'push' ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'publish-docker')) ||
(github.event_name == 'workflow_dispatch' && inputs.push)
}}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
Key Design Decisions
Tags Generated
| Tag pattern | Example | When applied |
|---|---|---|
| pr-N | pr-42 | PR events only — human-readable, identifies the PR |
| datetime_sha | 202603151430_a1b2c3d | push and tag events — chronological + traceable |
| Branch name | main | branch push events (not tag push events) — human-readable current ref |
| latest | latest | Only on the default branch (main) |
| Tag ref | v1.2.3 | Only when pushing a v* git tag |
The datetime_sha format is important: it lets you sort images chronologically in the registry UI and trace back to an exact commit without needing semver versioning.
Why metadata-action for latest instead of shell conditionals?
docker/metadata-action handles the enable={{is_default_branch}} expression natively — latest is emitted only when github.ref matches the default branch.
The naive alternatives both fail:
❌ || '' (blank line in tags block)
tags: |
ghcr.io/org/app:${{ steps.tag.outputs.version }}
${{ github.event_name == 'push' && 'ghcr.io/org/app:latest' || '' }}
A blank line is passed as an empty tag reference, causing invalid reference format errors in docker/build-push-action (version-dependent but unreliable).
❌ || null
${{ github.event_name == 'push' && 'ghcr.io/org/app:latest' || null }}
GHA expressions have no true null type. Depending on context, null coerces to the literal string "null", which the action attempts to push as a tag named null.
metadata-action avoids both pitfalls — the enable= option suppresses a tag entry entirely, producing no output line at all.
PR builds: build always, push only with publish-docker label
Every pull_request event (opened, pushed to, reopened, labeled) triggers the
workflow and builds the Docker image. The build serves as a validation check — it
fails fast if the Dockerfile is broken, even without pushing anything.
The image is only pushed when the publish-docker label is present on the PR at
the time the run starts. On PR builds, type=ref,event=branch does not fire — docker/metadata-action filters tags by event type, so event=branch only activates on push events to branches. PR builds emit only the pr-N tag (plus no datetime_sha, which is suppressed by its enable= guard).
This is checked via:
contains(github.event.pull_request.labels.*.name, 'publish-docker')
This expression reads the label set from the webhook payload at run time. If the label was already on the PR before a new commit was pushed, that commit's build also pushes.
Fork PR limitation: On pull_request events from forked repositories, GitHub
restricts GITHUB_TOKEN to read-only regardless of the permissions: declaration.
The publish-docker label check passes but the GHCR push fails with a permission
error. This is expected GHA behavior. The publish-on-label feature works only for
PRs from branches within the same repository. If the repository is public, document
this restriction for contributors so they know why labeling a fork PR will not push.
Dual-registry support (optional)
The external registry is entirely opt-in via repository variables and secrets:
vars.EXTERNAL_REGISTRY_URL— e.g.,registry.example.comordocker.iovars.EXTERNAL_REGISTRY_IMAGE— image name on that registry (defaults tomy-app)secrets.EXTERNAL_REGISTRY_USERNAME/EXTERNAL_REGISTRY_PASSWORD
If EXTERNAL_REGISTRY_URL is not set, the login step is skipped and only GHCR is used.
GHA cache with mode=max
cache-from: type=gha
cache-to: type=gha,mode=max
mode=max caches all intermediate layers, not just the final image. This is more storage-intensive but dramatically speeds up builds when dependencies or base images haven't changed.
workflow_dispatch dry-run
The push expression in the Build and push step covers three event types:
push: >-
${{
github.event_name == 'push' ||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'publish-docker')) ||
(github.event_name == 'workflow_dispatch' && inputs.push)
}}
- On
push/ tag events: always pushes - On
pull_requestevents: pushes only when thepublish-dockerlabel is present (see the PR builds section above) - On manual dispatch:
pushdefaults tofalse(safe default — won't accidentally publish on a click). Set totrueexplicitly to push.
Security: env: block for context variables in run: scripts
GitHub's security hardening guide
warns against interpolating ${{ context }} values directly inside run: shell scripts.
Even seemingly safe values like github.sha can be used for script injection if an attacker
controls a value that ends up in the context.
The Build image list step follows this pattern — all GHA context values are passed through
the env: block, never interpolated directly into the shell:
- name: Build image list
env:
GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
EXTERNAL_REGISTRY_URL: ${{ vars.EXTERNAL_REGISTRY_URL }}
EXTERNAL_REGISTRY_IMAGE: ${{ vars.EXTERNAL_REGISTRY_IMAGE }}
run: |
IMAGES="${GHCR_IMAGE}" # safe: reads from env var, not ${{ }}
...
The Get commit info step uses only git commands with no GHA context variables, so
no env: block is needed there.
Permissions
permissions:
contents: read
packages: write
packages: write is required to push to GHCR using GITHUB_TOKEN. Always scope permissions to the minimum needed.
Setup Checklist
- [ ] Place file at
.github/workflows/docker-publish.yml - [ ] Replace
my-appin theBuild image liststep with your actual image name (or setEXTERNAL_REGISTRY_IMAGEvariable) - [ ] Ensure a
Dockerfileexists at the repo root (or setcontext:appropriately) - [ ] To enable image push on PRs, create a
publish-dockerlabel in the GitHub repository (Settings → Labels) — PRs without this label build but do not push - [ ] If using an external registry, add these to the GitHub repo settings:
- Variable:
EXTERNAL_REGISTRY_URL - Variable:
EXTERNAL_REGISTRY_IMAGE(optional, defaults tomy-app) - Secret:
EXTERNAL_REGISTRY_USERNAME - Secret:
EXTERNAL_REGISTRY_PASSWORD
- Variable:
- [ ] GHCR authentication uses
GITHUB_TOKENautomatically — no extra secrets needed
Customization Points
Multi-platform builds — replace platforms: linux/amd64 with:
platforms: linux/amd64,linux/arm64
Note: multi-platform builds cannot use GHA cache in mode=max for all layers; consider type=registry cache instead.
Build args — add to the Build and push step:
build-args: |
APP_VERSION=${{ steps.commit.outputs.datetime }}_${{ steps.commit.outputs.sha }}
Different default branch — enable={{is_default_branch}} reads from the repository's actual default branch setting, so renaming main → master or anything else requires no change.
Timeout — timeout-minutes: 30 is a reasonable default. Adjust based on your build time.