docker build && docker push from your laptop works until someone else has to ship the same image, or it has to happen on every merge without you at the keyboard. GitHub Actions runs that build on GitHub’s runners, tags the image from whatever triggered the workflow, and pushes it to a registry your deploy step can pull from. Nothing runs locally.
What it takes to build and push a Docker image from CI
Five pieces have to be in place before a runner can push an image:
actions/checkoutfor the build context: your Dockerfile and your sourcedocker/setup-buildx-actionfor a BuildKit builder, because the runner’s stock Docker Engine can’t export layer cache or build for other platformsdocker/login-actionfor registry credentialsdocker/metadata-actionto turn the git ref into a tag list and OCI labelsdocker/build-push-actionto run the build and the push in one invocation
Docker maintains all five, and they are written to compose: the metadata step’s outputs drop straight into the build step’s inputs. Underneath it is the same docker build and docker push you run by hand, moved onto a runner and wired to git events.
Why the runner needs Buildx before it can build anything
ubuntu-latest ships Docker Engine, and Docker Engine builds images fine with docker build. What it can’t do on its own is export a build cache anywhere other than local disk, or build for a platform other than the runner’s. docker/setup-buildx-action creates a Buildx builder backed by BuildKit and makes it the default, which is what makes cache-to/cache-from and multi-platform builds possible later in this guide.
| |
One step, no inputs needed for the common case. Skip it and build-push-action still runs, using whatever builder the runner already has — the build succeeds, but cache export and multi-arch are off the table.
How to authenticate to GHCR with the built-in token
Every workflow run gets a short-lived GITHUB_TOKEN, scoped to the repository it runs in. For pushing to the GitHub Container Registry (ghcr.io), that token is enough: no personal access token, no secret to create or rotate.
| |
The permissions block matters as much as the login step. Without packages: write, the token GitHub hands the workflow can read but not push, so the login succeeds and the push fails with a 403. On an organization repository there is a second place to look: a workflow permissions setting under Settings → Actions → General can cap tokens at read-only no matter what the workflow file asks for. If the push still fails with the block above in place, check that.
How to tag images from the git ref automatically
Hardcoding a tag in the workflow file means editing YAML every time you cut a release. docker/metadata-action reads the event that triggered the run (which branch, which tag, which PR) and produces the tag list and OCI labels for you.
| |
Push a tag v1.4.0 and the semver rule produces 1.4.0. Push to main and the raw rule adds latest. Every build gets a short-SHA tag regardless of trigger, so a specific commit stays pullable after latest has moved past it. steps.meta.outputs.tags is a newline-separated list of the tags those rules produced for this one event, not the union of all three across every run.
What the full workflow file looks like
Put the pieces together and add docker/build-push-action, which does the docker build and the docker push in a single BuildKit call:
| |
context: . builds from the checked-out repository root using the Dockerfile there; point file: at another path if yours lives elsewhere. What ends up inside the image is still the Dockerfile’s business, from layer ordering to a non-root USER. This workflow only decides when the build runs and where the result goes.
One thing to get right early: if the build itself needs a credential, such as a token for a private npm registry, pass it through the action’s secrets: input rather than a build-arg, which stays visible in the image history. Environment Variables and Secrets in Docker goes through that difference.
How to cache layers between workflow runs
A GitHub-hosted runner starts clean every time. With no external cache, every layer rebuilds on every run, RUN npm ci included. cache-from: type=gha and cache-to: type=gha,mode=max in the step above tell BuildKit to read and write its cache through GitHub’s Actions cache service instead of local disk, so the cache outlives the runner.
mode=max caches every intermediate layer, including layers from earlier stages of a multi-stage Dockerfile. The default, mode=min, keeps only the layers that end up in the final image, which means the build stage of a multi-stage Dockerfile compiles from scratch on every run.
The Actions cache has a size ceiling per repository and evicts old entries LRU. For most application images this never comes up. A repo building several large multi-arch images can hit it, and a registry-backed cache (cache-to: type=registry,ref=ghcr.io/org/app:buildcache) is the way out, since it isn’t capped by the Actions cache quota.
How to build on every push but only push on release
The workflow above pushes on every push to main and every version tag, which is the right default for a project that deploys from main. A more conservative setup builds and tests every pull request without pushing anything, and pushes only after a merge:
| |
The full build still runs on the PR, so a broken Dockerfile fails before merge, while push: false skips the registry write. With cache-to: type=gha in place, that PR build also primes the cache the post-merge push reuses.
What changes if you push to Docker Hub instead
Docker Hub needs a registry argument and different credentials: an access token from Account Settings → Security, not your account password, which Docker Hub no longer accepts for API logins.
| |
Buildx setup, metadata extraction and the build-push step don’t change. The registry is a parameter, not a different pipeline.
What this workflow doesn’t cover
Multi-platform images (linux/amd64 plus linux/arm64 in one manifest) need docker/setup-qemu-action before Buildx and a platforms: input on the build step. QEMU emulation makes an arm64 build on an amd64 runner slow, often several times slower than native. If you build arm64 regularly, GitHub’s arm64-hosted runners remove the emulation entirely.
Self-hosted runners that already have Buildx configured don’t need setup-buildx-action, and a persistent builder there can beat the Actions cache backend by keeping layers on local disk between runs. A monorepo publishing three service images usually moves to a build matrix rather than three copies of this job, which changes how context and images are parameterized but not the steps themselves.
Test the workflow before you tag a release
Push to a branch first, not a version tag. Watch the run in the Actions tab, then check the image under your repository’s Packages page, or pull it directly with docker pull ghcr.io/<owner>/<repo>:<short-sha> using the tag from the run log. Once that pull works, a release is the same git tag v1.0.0 && git push --tags you already run, and the workflow takes it from there.