Building Optimized Container Images with Buildah in GitHub Actions

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:

For minimal layers you have two levers:

  1. Commit once. In the from/run/commit flow, do all your mutations first and call buildah commit a single time at the end.
  2. Squash on commit. buildah commit --squash collapses 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

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.

Buildah Containers GitHub Actions CI/CD Docker