Building and Pushing Container Images in GitHub Actions
Build multi-arch container images with Docker Buildx in GitHub Actions, tag them with metadata-action, and push to a registry with layer caching.
Netbay Infrastructure Team
Netbay Engineering
On this page
Container images are the natural output of a CI pipeline, and GitHub Actions has first-class tooling for building them: docker/build-push-action with Docker Buildx, which supports multi-platform builds and layer caching. This post walks a production-shaped workflow that builds, tags, and pushes an image to a container registry, then shows how to keep it fast and secure.
The pieces you need
To build and push you typically use three actions together: docker/setup-buildx-action to configure Buildx, docker/login-action to authenticate to the registry, and docker/build-push-action to do the build and push. The login action reads registry credentials from secrets. For a full workflow you also need actions/checkout to get the Dockerfile and any build context.
A key automation trick is using metadata for tags: docker/metadata-action generates standard tags (latest, rust-tagged, and semantic versions) from the git ref and commit so you do not hand-write tag logic.
A complete build-and-push workflow
Here is a workflow that runs on push to main and on version tags, builds an amd64 and arm64 image, and pushes both to a registry:
name: Build and Push Image
on:
push:
branches: [main]
tags: ['v*']
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/octo-org/my-app
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha
- 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 }}
cache-from: type=gha
cache-to: type=gha,mode=maxUsing registry credentials and GITHUB_TOKEN
The login action accepts either a dedicated registry account or, for GitHub Container Registry, the automatically provisioned GITHUB_TOKEN. Pushing to ghcr.io with GITHUB_TOKEN requires your package to be attached to the repository and the token scoped with the packages: write permission. For external registries, put the username and password (or access token) in repository secrets and reference them in with blocks — here the password refers to a secret, and the username is derived from the actor via the environment equivalent.
Notice the metadata template uses its own brace syntax ({{version}}) which is independent of GitHub Actions expression interpolation — that is fine because the dollar sign is not involved.
Multi-platform and layer caching
The platforms array lets Buildx build for multiple architectures, which is how you ship an image that runs on both amd64 servers and arm64 boards. Building arm64 on an amd64 runner requires QEMU emulation, which the setup-buildx-action can register automatically. Without emulation or a native runner, the arm64 build will fail.
Layer caching is where speed hides. cache-from/cache-to with type=gha stores build cache in GitHub Actions cache, massively cutting rebuild time when the base layers and most instructions are unchanged. mode=max caches intermediate layers at the cost of more storage; mode=min is lighter. Always feed cache-from with the same scope so parallel workflow runs can share the cache.
The Dockerfile you point buildx at matters too. A multi-stage build keeps the image small and rebuilds fast when only your source changes:
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/server.js"]The deps stage only re-runs when package.json or the lockfile changes, so caching plus this layering keeps the build lean across platforms.
Tagging strategy and image hygiene
Rely on metadata-action for consistent tags: branch refs and SHAs for every commit, semver-tagged versions on release tags, and latest only where you actually want a moving pointer. Avoid tagging every push as latest — it erases history and makes rollback impossible. Push the specific SHA tag and a semantic tag, and let deployments pin to real versions. Also set security scan on the registry so vulnerabilities surface in the push pipeline, and consider an SBOM generation step alongside the image.
A deployment symmetry note
A container image is only as useful as where it deploys. With the image on a registry, your deploy job can pull and run it on a server. Self-hosted runners and the servers you deploy to are usually distinct from the hosted build runners — the pattern is: build and push on GitHub-hosted runners, then a separate job or server pulls the image and runs it.
Takeaway
Use Buildx, login, and build-push actions together with metadata tags and layer caching to ship clean, multi-platform images to a registry. Then a separate job or server pulls and runs it. A container is only useful where it can run — for a simple self-managed pull target, an Ubuntu VPS on Netbay starts in under 60 seconds at netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance