Buildah is a daemonless tool for building OCI and Docker container images. Unlike Docker, it has no long-running daemon and works entirely on a per-command basis: you create a working container, mutate it with buildah run and buildah copy, and only when you are satisfied do you commit it into an image. That makes it a great fit for CI, and the best part is that on GitHub Actions you do not need to install anything: buildah is preinstalled on GitHub's ubuntu-24.04 runner image, so you can build and push images entirely with shell commands, no third-party actions required.
This post walks through how buildah works, how to use it directly in GitHub Actions, and how to structure your build steps for minimal layers and optimized images. The examples come from a real project, the opencode-server repository, which builds hardened multi-arch OpenCode server container images this way.
How Buildah Works
Buildah's command set maps almost one-to-one onto Dockerfile instructions:
buildah from → FROM
buildah run → RUN
buildah copy → COPY
buildah config → ENV / CMD / ENTRYPOINT / LABEL / USER / ...
buildah commit → (implicit final step)
The key command is buildah run. It executes a process inside a containerized environment, a mount namespace over the working container's filesystem, so every file the command writes lands in the image. Nothing is written to the final image until you call buildah commit.
An important difference from Docker: a working container accumulates changes in a single writable layer, and buildah run does not snapshot a new layer per invocation. Layers are only created when you buildah commit (or build from a Dockerfile with buildah bud, where every RUN/COPY step becomes a layer). So with the manual from/run/commit flow, the number of buildah run calls does not affect your layer count — you get one new layer per commit.
Why Layer Count Matters
Every buildah commit produces a new layer, and every RUN/COPY step in a buildah bud Dockerfile build adds one. Layers are how image registries and runtimes share and cache data, but more layers means:
- More storage on the registry and on every pull.
- Larger, slower image pulls: each layer must be fetched and decompressed.
- More filesystem metadata to walk at container start.
For minimal layers you have two levers:
- Commit once. In the
from/run/commitflow, do all your mutations first and callbuildah commita single time at the end. - Squash on commit.
buildah commit --squashcollapses the entire image into a single layer.
Real Example: opencode-server
The opencode-server repo builds hardened container images for the OpenCode server. It uses buildah directly from GitHub Actions, with per-variant scripts (build-alpine.sh, build-debian.sh) that share a common build routine.
Creating the working container
CTR=$(buildah from "docker.io/${BASE_IMAGE}")
buildah from pulls the base image and gives you a container handle ($CTR). Nothing is running yet, it is just an image whose layers are mounted for mutation.
Mutating inside a containerized environment
The variant scripts install packages with a single buildah run call. The cleanup (rm -rf /var/lib/apt/lists/*) is added to remove any cached package indexes to further reduce the final image size.
install_packages() {
local ctr="$1"
buildah run "${ctr}" -- sh -c "apt-get update && \
apt-get install -y --no-install-recommends \
build-essential ca-certificates curl git jq openssh-client pkg-config python3 ripgrep tini unzip xz-utils zip && \
rm -rf /var/lib/apt/lists/*"
}
The Alpine variant is equivalent but more minimal:
install_packages() {
local ctr="$1"
buildah run "${ctr}" -- apk add --no-cache \
bash build-base ca-certificates curl git jq openssh-client pkgconf python3 ripgrep tini unzip xz zip
}
apk add --no-cache skips the package index cache entirely, and --no-install-recommends on apt avoids pulling in unneeded dependencies. Both are easy wins for smaller images.
Files and binaries are copied into the working container. Because buildah copy supports --chown and --chmod (and creates missing parent directories), you can set ownership directly on copy:
buildah copy --chown opencode:opencode "${CTR}" "${BINARY}" /usr/local/bin/opencode
buildah copy --chown opencode:opencode "${CTR}" opencode.jsonc "${OPENCODE_CONFIG_DIR}/opencode.jsonc"
Note that --chown accepts a name only if the user/group exists in the image's /etc/passwd and /etc/group; otherwise pass numeric IDs (--chown 10001:10001), which always work. --chmod takes a numeric mode (e.g. --chmod 0755).
Then image metadata is set with buildah config — user, working directory, exposed port, labels, healthcheck, and finally the entrypoint:
buildah config --user opencode "${CTR}"
buildah config --workingdir "${OPENCODE_HOME_DIR}" "${CTR}"
buildah config --port "4096" "${CTR}"
buildah config --volume "${OPENCODE_HOME_DIR}" "${CTR}"
buildah config --healthcheck "CMD curl -fsS http://localhost:4096/global/health || exit 1" "${CTR}"
buildah config --label "org.opencontainers.image.source=https://github.com/aardbol/opencode-server" "${CTR}"
buildah config --entrypoint '["tini", "--", "opencode"]' "${CTR}"
buildah config --cmd '["serve"]' "${CTR}"
Before committing, the image is verified by running the tools inside the container environment:
buildah run "${CTR}" -- tini --version >/dev/null || exit 1
buildah run "${CTR}" -- opencode --version >/dev/null || exit 1
Committing and cleaning up
buildah commit --format docker "${CTR}" "${IMAGE}:${IMAGE_TAG}"
buildah rm "${CTR}"
The container is committed as an image and removed, leaving a clean state for the next build.
Buildah in GitHub Actions
Because buildah ships on the runner, the entire build is just a run: step. The opencode-server workflow builds a matrix of variants and architectures in parallel:
strategy:
fail-fast: false
matrix:
include:
- variant: alpine
arch: amd64
runner: ubuntu-24.04
- variant: alpine
arch: arm64
runner: ubuntu-24.04-arm
- variant: debian
arch: amd64
runner: ubuntu-24.04
- variant: debian
arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v7
- name: Build ${{ matrix.arch }} image
env:
OPENCODE_VERSION: ${{ needs.detect.outputs.version }}
TARGETARCH: ${{ matrix.arch }}
IMAGE_TAG: ${{ steps.tags.outputs.image_tag }}
run: ./build-${{ matrix.variant }}.sh
- name: Push ${{ matrix.arch }} image
run: |
buildah push "${IMAGE}:${{ steps.tags.outputs.image_tag }}" \
"docker://${IMAGE}:${{ steps.tags.outputs.image_tag }}"
No setup step, no daemon, no extra action - just the script. The native ubuntu-24.04-arm runner makes true multi-arch builds straightforward.
Pushing to a Registry
buildah push writes the image to a container registry. Combined with buildah login it handles authentication without any special tooling:
buildah login -u "${REGISTRY_USER}" -p "${REGISTRY_PASSWORD}" ghcr.io
buildah push --format docker "${IMAGE}:${IMAGE_TAG}" "docker://ghcr.io/your-org/your-image:${IMAGE_TAG}"
For multi-arch images, push each architecture to its own tag and combine them into a manifest list with buildah manifest or use a dedicated action if you prefer not to script it.
Tips for Optimized, Minimal Images
- Clean up package caches before committing.
rm -rf /var/lib/apt/lists/*andapk add --no-cachekeep caches out of the committed layer, which is what actually shrinks the image. - Commit once, at the end. Every
buildah commitadds a layer, so avoid committing intermediate states in the manual workflow. - Use
--squashon commit to force a single layer when you want the absolute minimum:buildah commit --squash "${CTR}" "${IMAGE}:${TAG}". - Prefer
apk add --no-cacheandapt-get --no-install-recommendsto skip caches and unneeded dependencies. - Verify inside the container with
buildah runbefore committing. It catches broken installs at build time, not at deploy time. - Set ownership on copy, not with a separate
chownrun.buildah copy --chown/--chmodhandles it in one step and creates parent directories automatically. This is just for efficiency sake as it does not add a separate layer. - Set immutable metadata last (
buildah configadds no filesystem layer), so you can bump labels without re-creating layers. - Pin base images (e.g.
alpine:3.23,debian:13.6-slim) and build in CI on a schedule to pick up security updates automatically.
Wrapping Up
Buildah gives you a daemonless, scriptable way to build container images that fits naturally into GitHub Actions. Because it is preinstalled on the runner, there is nothing to install and no dependency on third-party actions, just shell scripts that buildah from, mutate with buildah run, and buildah commit.
By committing once at the end, cleaning up package caches inside the container, and squashing when it matters, you keep layer counts and image sizes down. The opencode-server repository is a working reference implementation: hardened, multi-arch, minimal images built entirely with buildah in GitHub Actions.