Publishing Images to Docker Hub
Once your GitHub Actions workflow can build a Docker image, the next step in a real pipeline is getting that image somewhere other machines can pull it from. Docker Hub is the default public registry for Docker images, and it remains the most common target for small teams and open-source projects. This lesson covers how to authenticate a workflow against Docker Hub without exposing long-lived credentials, how to tag images so consumers know exactly what they are running, and how to structure the job so that only trusted, tested code is ever published.
Overview / How it works
Docker Hub organizes images into repositories, for example yourdockerhubuser/sample-api. Each push to a repository creates or updates a tag, such as latest, 1.4.0, or a short git commit SHA. Tags are mutable pointers — the same tag name can point to different image content over time — while a digest, a sha256 hash of the image manifest, is immutable and always resolves to the exact bytes that were pushed.
Publishing from GitHub Actions means the workflow authenticates to Docker Hub, builds the image (usually with Docker Buildx so you can target multiple CPU architectures), and pushes the result. Three actions do almost all of this work: docker/login-action, docker/build-push-action, and, for anything beyond a single fixed tag, docker/metadata-action. None of these actions talk to the GitHub API, so the workflow’s permissions: block has nothing to do with Docker Hub authentication — that is controlled entirely by the secrets you configure.
Syntax or workflow structure
A publishing job typically follows this shape:
- Check out the repository so the Dockerfile and build context are present.
- Authenticate with
docker/login-action, supplying a username and an access token stored as encrypted secrets, never an account password. - Optionally derive tags and OCI labels with
docker/metadata-action, driven by the git ref that triggered the run. - Build and push with
docker/build-push-action, which wraps Buildx and only pushes whenpush: trueis set.
Restrict the trigger to events you control — a push to a protected branch or a version tag — rather than pull_request, so untrusted contributions never run in a job that holds registry credentials.
Examples
Example 1: a minimal publish job. This workflow logs in and pushes a single fixed tag whenever main is updated.
name: Publish to Docker Hub
on:
push:
branches: [main]
permissions:
contents: read
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- 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@v6
with:
context: .
push: true
tags: yourdockerhubuser/sample-api:latest
Expected behavior: every push to main builds the image and pushes yourdockerhubuser/sample-api:latest. If the login step or the build fails, the job stops and nothing reaches Docker Hub — there is no partial publish.
Example 2: tagging with metadata-action. A single moving latest tag does not tell you which commit or release produced an image. Adding docker/metadata-action generates a consistent set of tags from the triggering ref.
name: Publish to Docker Hub
on:
push:
branches: [main]
tags: ['v*.*.*']
permissions:
contents: read
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: yourdockerhubuser/sample-api
tags: |
type=sha,format=short
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
Expected behavior: a push to main produces both a short-SHA tag (for example sample-api:a1b2c3d) and sample-api:latest. Pushing a tag like v1.4.0 produces sample-api:1.4.0 without touching latest, because is_default_branch is false for a tag build.
Example 3: production-grade publish with an environment gate and a digest. This version adds multi-architecture support, a protected environment for human approval, and captures the immutable digest for downstream deploys.
name: Publish to Docker Hub
on:
push:
branches: [main]
tags: ['v*.*.*']
permissions:
contents: read
jobs:
build-and-push:
runs-on: ubuntu-latest
environment: dockerhub-production
steps:
- name: Check out code
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: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: yourdockerhubuser/sample-api
tags: |
type=sha,format=short
type=semver,pattern={{version}}
- name: Build and push
id: push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Record immutable digest
run: echo "Published digest ${{ steps.push.outputs.digest }}"
Expected behavior: the job pauses until a required reviewer approves the dockerhub-production environment, then builds a multi-arch manifest covering amd64 and arm64, and finally prints a digest such as sha256:9f1c.... Downstream deploy steps should reference that digest instead of a tag.
Step by step
- Create a scoped Docker Hub access token rather than reusing your account password: Account Settings → Security → New Access Token, granting Read & Write, and name it after the repository or workflow that will use it.
- Store the username and token as GitHub secrets, for example
DOCKERHUB_USERNAMEandDOCKERHUB_TOKEN, under repository or environment secrets. - Set
permissions: contents: readexplicitly, since this job only needs to read the repository’s own code, not write to it. - Add
docker/login-actionreferencing those two secrets. - Decide your tagging scheme up front — short SHA on every build, semantic version on release tags,
latestonly from the default branch — and encode it indocker/metadata-action. - Capture the
digestoutput fromdocker/build-push-actionand use it, not a tag, anywhere a deploy step or manifest needs to reference a specific image. - Optionally gate the job behind a GitHub environment with required reviewers so a human approves before an image reaches Docker Hub.
Common Mistakes
Mistake 1: using your Docker Hub account password as the login credential. A password grants full account access — billing, organization membership, every repository — and cannot be scoped or revoked without changing your login everywhere it is used. Generate an access token scoped to Read & Write instead, store it as DOCKERHUB_TOKEN, and revoke or rotate it independently of your password.
Mistake 2: publishing on the pull_request trigger. The example below runs on every pull request, including ones from forks, and pushes on push: true.
name: Bad example - do not use
on:
pull_request:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: yourdockerhubuser/sample-api:latest
If secrets are reachable in that context — which happens with pull_request_target, or if repository settings expose secrets to fork workflows — a contributor could modify the Dockerfile or build scripts in their pull request to exfiltrate the Docker Hub token or publish a malicious image under your namespace. Only publish on push to a protected branch or on tag creation; for pull request validation, build with push: false so the image is tested but never leaves the runner.
Mistake 3: deploying by referencing the latest tag. latest is just another mutable tag; a later push, even a failed or unrelated one, can change what it points to between the moment you tested an image and the moment you deploy it, breaking reproducibility and making rollbacks unreliable. Capture steps.push.outputs.digest from docker/build-push-action and deploy the digest-pinned reference instead, for example yourdockerhubuser/sample-api@sha256:....
Best Practices
- Use scoped Docker Hub access tokens named per workflow, and rotate or revoke them without touching the account password.
- Pin third-party actions like
docker/login-actionanddocker/build-push-actionto a release tag, or to a commit SHA for maximum supply-chain safety; a tag can be moved to point at different code, a SHA cannot. - Set an explicit minimal
permissions:block (contents: read) rather than relying on the repository default, which may be broader than this job needs. - Never trigger a publish job from
pull_requestevents on forks; build-only on pull requests, publish only on push or tag events against trusted branches. - Tag consistently: a short commit SHA on every build for traceability, semantic version tags on releases, and
latestonly from the default branch. - Prefer digests over tags anywhere an image is pulled for deployment.
- Use Buildx to produce multi-architecture manifests if your consumers run on more than one CPU architecture.
- Gate production publishing behind a GitHub environment with required reviewers for an extra human checkpoint.
- Watch Docker Hub’s pull rate limits for anonymous and free-tier pulls, and authenticate pulls in downstream systems if you hit them.
Practice Exercises
- Create a Docker Hub access token scoped to a single test repository, store it as a secret, and write a workflow that logs in and pushes a minimal image only on push to main.
- Extend that workflow with
docker/metadata-actionso every push produces both a short-SHA tag and, only on the default branch, alatesttag. - Add a step that prints the pushed image’s digest, then pull the image on your own machine by digest instead of by tag and confirm it matches what the workflow built.
- Take a workflow that currently runs on
pull_requestand rewrite its triggers so pull requests only build withpush: false, while publishing happens solely on push to main; note why that change matters.
Summary
Publishing to Docker Hub from GitHub Actions comes down to three decisions: how you authenticate (a scoped access token, never a password), how you tag (a deliberate scheme, not just latest), and when you publish (only after trusted, tested code merges, never from an untrusted pull request). docker/login-action, docker/metadata-action, and docker/build-push-action provide the building blocks; explicit permissions, environment protection, and digest-based references turn a working pipeline into one you can deploy from with confidence.
