How Do I Build and Push in One GitHub Actions Step?
Set push to true on the build and push action and feed it tags computed earlier in the same job, so BuildKit exports each layer straight to the registry.
Use a build and push action with push: true and hand it the tags a metadata step computed earlier in the same job, so BuildKit exports each layer to the registry as the build finishes it and the image is never produced twice. Splitting the work into a build step and a later docker push step means the image has to land somewhere in between, and that somewhere is the runner's local image store, which costs a full export of every layer before a single byte moves toward the registry (docker/build-push-action, checked on 2026-08-13).
Answer
A one-step publish is three steps in the job, only one of which touches the image.
| Step | Action | What it produces |
|---|---|---|
| Compute tags | docker/metadata-action | steps.meta.outputs.tags and labels from the ref, the SHA, and the event (docker/metadata-action) |
| Authenticate | docker/login-action | a registry credential in the Docker config for the push destination (docker/login-action) |
| Build and push | docker/build-push-action with push: true | the built image, exported straight to the registry by the image exporter (Docker image and registry exporters) |
The order matters because the build step is the one that uploads. Tags have to exist before it runs, and the credential has to exist before it runs, or the export fails at the end of a build you already paid for.
name: publish-image
on:
push:
branches: [main]
tags: ["v*"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
publish:
runs-on: warp-ubuntu-latest-x64-8x
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,format=long
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Report what was published
run: echo "published ${{ steps.build.outputs.digest }}"secrets.GITHUB_TOKEN is minted per job and needs the packages: write permission declared above to push to GitHub Container Registry (GitHub automatic token authentication, checked on 2026-08-13). The digest output of the build step is the address of what was pushed, which is what a deploy job downstream should consume rather than a mutable tag.
Detail
What the two-step shape actually does
A build step that sets neither push nor load writes its result into the BuildKit cache and nowhere else, so a following docker push app:latest reports that no such image exists. The fix people reach for is load: true, and that is where the time goes: the image exporter serializes every layer of the finished image into the runner's Docker image store, and only then does docker push read those layers back and upload them. The same bytes are written to disk, read from disk, and sent over the network, against a build where push: true streams each layer to the registry as BuildKit finishes it.
load carries a second constraint. It supports one platform, so a build that lists linux/amd64,linux/arm64 and sets load: true fails outright, while push: true writes an index covering both platforms (Docker image and registry exporters, checked on 2026-08-13).
What the extra minutes cost
The export and the re-upload are runner minutes at the runner's rate. Assume the pair adds two minutes to each build and the workflow runs 600 times a month; substitute your own numbers by reading the step durations in the job log. Rates come from the WarpBuild cloud runners documentation and the pricing page, checked on 2026-08-13.
| Runner label | vCPU | Per minute | 1,200 extra minutes per month |
|---|---|---|---|
warp-ubuntu-latest-x64-4x | 4 | $0.008 | $9.60 |
warp-ubuntu-latest-x64-8x | 8 | $0.016 | $19.20 |
warp-ubuntu-latest-x64-16x | 16 | $0.032 | $38.40 |
warp-ubuntu-latest-arm64-8x | 8 | $0.012 | $14.40 |
The same arithmetic applies whichever platform publishes your images.
There is a second meter when a remote builder is involved. Docker builders are billed per session, measured from when the builder action starts until the job completes, per the Docker builders documentation. Minutes the job spends loading and re-uploading an image are minutes the builder session stays open, so a two-step publish is billed on the runner and on the builder at the same time. Builder rates below come from the same documentation and the pricing page, checked on 2026-08-13.
| Builder profile | Architectures | Per minute | 1,200 extra minutes per month |
|---|---|---|---|
| 16 vCPU, 32GB RAM, 100GB disk | amd64, arm64, multi | $0.06 | $72.00 |
| 32 vCPU, 64GB RAM, 200GB disk | amd64, arm64, multi | $0.12 | $144.00 |
| 64 vCPU, 128GB RAM, 200GB disk | amd64, arm64, multi | $0.24 | $288.00 |
The remote builder variant
On a remote Docker builder profile the build runs on a dedicated builder VM rather than on the runner, and push: true keeps the layers on that side of the wire: the builder holds the layers it just produced and exports them to the registry itself, so the runner sends the build context up and receives a digest back. Setting load: true in that arrangement reverses the flow and drags the finished image across the connection into the runner's image store before anything reaches the registry.
The workflow change is the action name plus a profile:
- id: build
uses: Warpbuilds/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
profile-name: "app-images-x64"api-key is required only when the job runs somewhere other than a WarpBuild runner. cache-from and cache-to come out at the same time, because a builder profile keeps its layer cache on local disk and reuses it on the next build with no export or import step (Docker builders documentation, and the caching documentation for the job-level cache action). The Docker builds on GitHub Actions solution page covers profile sizing and the full before and after workflow.
Related Questions
Why does a separate docker push step fail with an image not found?
Because a build that sets neither push nor load leaves the result in the BuildKit cache and never writes it into the local image store, so the docker CLI has no such tag to push. The two ways out are load: true, which exports the whole image into the daemon first, and push: true, which sends the layers to the registry from the build itself. The Docker builds on GitHub Actions solution page walks through the exporter behavior in a full workflow.
Can I keep a local copy of the image after pushing it?
Yes, by setting both push: true and load: true, and the load half costs a full export of every layer into the runner's image store. When the local copy is only needed for a smoke test, pull the tag you just pushed or reference the digest output of the build step instead. On a builder profile the local copy is the expensive option, because the image has to travel back to the runner: see remote Docker builder profiles and rates.
Does one step work for a multi-platform image?
Yes for push and no for load. The image exporter writes an index covering every platform you list, while load supports a single platform only, so a multi-platform build that also sets load will fail. Drop load and read the digest output when a later job needs to reference the image (Docker image and registry exporters, checked on 2026-08-13).
How do I push the same image to two registries in one step?
Log in to both registries in separate login steps, then pass both fully qualified tags to the same build and push step. BuildKit builds once and exports the same layers to each destination, so the second registry costs an upload rather than a rebuild. Publishing one image to several registries covers the tag list and the credential handling, and authenticating to a private registry covers the login step when the destination is not GitHub Container Registry.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.