How to Build and Push Docker Images in CI Step by Step
Build and push a Docker image from CI with GitHub Actions: log in, tag from git, use the build cache, push only on trusted events and keep credentials safe.

Building an image in CI and pushing it is a ten-line job that is easy to get subtly wrong: the wrong tags, no cache, credentials exposed to untrusted code. This post shows a complete workflow for GitHub Container Registry and the reasoning behind each part. It builds on build caching with BuildKit and applies to other registries with a different login step. Syntax follows the current major versions of the Docker actions.
The workflow
name: image
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=ref,event=branch
type=semver,pattern={{version}}
- uses: docker/build-push-action@v6
with:
context: .
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=maxThe permissions block gives the job's token write access to packages and nothing else; without it, a repository default of read-only makes the push fail with a permission error. On a pull request the image is built but not pushed, which tests the Dockerfile without needing credentials.
Tags
The metadata action produces a list: sha-3c9e7a1 for every commit, the branch name for branch pushes, and 1.4.0 and 1.4 style tags for a v1.4.0 git tag. Deploy by the immutable sha tag or the version, and treat moving tags like main as convenience labels. Never deploy latest from CI to production, since you cannot tell which build it is. The release flow is covered in semantic versioning in CI.
Cache
With type=gha, layers are stored in the GitHub Actions cache, so unchanged steps of the Dockerfile are reused between runs. mode=max caches intermediate layers of multi-stage builds, not just the final one. The cache has a size limit per repository (10 GB), and entries unused for a week are evicted. Dockerfile order still decides the benefit: copy the dependency manifest and install before copying the source, as in Dockerfile best practices.
Safe credentials
Do not run the push on pull_request events from forks: those runs do not get secrets, and workflows triggered with pull_request_target that check out the PR's code and use secrets let a stranger's code read them. The condition github.event_name != 'pull_request' avoids it. For registries other than GHCR, store a token with push scope only for that repository, in repository or environment secrets, and rotate it. Prefer short-lived credentials, as described in OIDC instead of long-lived keys.
Without the actions
echo "$REGISTRY_TOKEN" | docker login ghcr.io -u "$REGISTRY_USER" --password-stdin
docker buildx build --push -t ghcr.io/example/app:"$(git rev-parse --short HEAD)" .The same two commands work in any CI. --password-stdin keeps the token out of the process list. The actions add caching and tag logic that you would otherwise script.
Where this does not apply
Images for several architectures need QEMU or native runners, as in multi-arch images in CI. Scan before you promote an image: scanning in CI. Very small projects can build on the deployment host and skip the registry.
What each step contributes
A reliable image workflow is a short chain of steps, and each has one job. Checking out the code gets the build context. A step to set up Buildx provides the builder that supports caching and multiple platforms. A login step authenticates to the registry, using either the registry's own token or a secret. A metadata step computes the tags and labels from the git reference, so you do not hand-write version strings. The build step builds, and pushes only when the event allows it. Splitting the work this way means a failure points at one stage: a login error is a credentials problem, a build error belongs to the Dockerfile, and a push error is about permissions. The actions maintained by Docker for these jobs are widely used and are best pinned to a full commit SHA, or at least a major version, because a workflow that runs with registry credentials is a valuable target for a compromised action.
Push only from trusted events. A pull request from a fork has no access to your secrets, and a build for it should run without pushing. A condition like push: ${{ github.event_name != 'pull_request' }} builds on every change and pushes only on merges and tags.
Tagging so an image can be traced and rolled back
Tag with something that identifies the source. The commit SHA is the most precise, a version tag from a release is the most readable, and a moving tag such as main or latest is a convenience that tells you nothing about what is inside and changes underneath anyone who pulls it. Deploy by the immutable reference, ideally the digest, and treat the moving tags as pointers for humans. The metadata action can produce several tags in one run, such as the SHA, the branch and the semantic version when the ref is a tag. Add labels with the source repository and the revision using the standard org.opencontainers.image keys, which registries and scanners display. Rolling back is then a matter of redeploying the previous digest, which is only possible if you kept it, so have the workflow print or record the digest it pushed.
Registries have retention rules. Untagged manifests are often cleaned up after some time, so a digest you need for a rollback should have a tag pointing at it, and a cleanup policy should keep the recent releases.
Caching, credentials and the cost of both
Without a cache, every run rebuilds every layer, and a CI runner starts empty. The GitHub-hosted cache backend, type=gha, stores layers between runs, and a registry cache, type=registry, keeps them in an image repository, which also works across different CI systems and local machines. Use mode=max to cache intermediate layers of a multi-stage build and not only the final image. Caches are not free: the GitHub cache has a size limit per repository, and old entries are evicted, so a build that depends on the cache for speed can become slow without any change to the code. Order the Dockerfile so that dependency installation comes before copying the source, which is what makes the cache useful at all. For credentials, prefer the short-lived token provided to the workflow, with packages: write permission, over a stored password, and never print or bake secrets into a layer, since build arguments and environment variables remain in the image history. Use build secrets mounted for one step when a build needs a token.
Pull requests, forks and the permissions block
Set the workflow's permissions explicitly, granting only contents: read and packages: write, since the default token may be broader than the job needs. Pull requests from forks run with a read-only token and no secrets, so they can build the image to prove it compiles and cannot push, which is the safe behavior. Resist the pattern of triggering on pull_request_target to give forks more access, since it runs trusted workflow code with secrets against untrusted pull request content, and has caused real compromises. If you need to publish a preview image for a pull request from inside the repository, do it for branches in the same repository only, under a tag that includes the pull request number, and clean those tags up when the pull request closes.
See Docker's GitHub Actions guide and GitHub's container registry documentation.


