Skip to content

Base Images

CAPTF publishes one base image per runtime: ghcr.io/captf-io/opentofu-base and ghcr.io/captf-io/terraform-base. Each lays out the runtime half of the image contract (the /captf/runtime binary, a non-root user, the contract labels) so that a module image adds only its module. Every reference module image builds FROM one of them.

Using a base image is recommended, not required: the contract is about the paths and labels in the final image, not how you produce it. See Without a base image for what you then provide yourself.

The images

Image Runtime Sources
ghcr.io/captf-io/opentofu-base OpenTofu (tofu), io.captf.runtime=tofu opentofu-base
ghcr.io/captf-io/terraform-base Terraform (terraform), io.captf.runtime=terraform terraform-base

The two are near-identical; only the runtime binary and its label differ. The runtime versions the bases currently carry are listed in Compatibility.

What the base provides

Path or setting Provided by the base
/captf/runtime Symlink to the runtime binary in /usr/local/bin (tofu or terraform). Both binaries are statically linked and copied from the upstream images.
USER captf, 65532:65532, shell /usr/sbin/nologin, home /tmp. Satisfies the Pod Security restricted profile.
OS Ubuntu 26.04 LTS.
Packages ca-certificates (CA roots for registry and provider downloads), git and openssh-client (modules and providers fetched over git), and the Ubuntu shell for local-exec provisioners.
Entrypoint /captf/runtime, with working directory /captf. The runner replaces the entrypoint in a Job; this one is for running the image by hand.
io.captf.contract v1alpha1
io.captf.runtime tofu or terraform
io.captf.runtime.version The runtime version, for example 1.12.6.

The three io.captf.* labels are inherited unchanged by module images. The base also sets the org.opencontainers.image.source, .revision and .version labels; a module image overrides them.

The base leaves these absent:

  • /captf/module, /captf/providers and the io.captf.role label: the module image adds them.
  • The reserved paths /captf/work, /captf/bin, /captf/config and /var/run/captf/credentials: the Job mounts them. See Fixed paths.

What your module image adds

  • The module at /captf/module and, optionally, the provider mirror at /captf/providers, both owned by 65532:65532.
  • The io.captf.role label.
  • The org.opencontainers.image.source, .revision and .version labels, with .version equal to the tag.
  • On machine images, optionally, the io.captf.capacity and io.captf.node-info labels.

Label values and rules are in OCI labels.

Building a module image

The reference Containerfiles are two-stage builds, one per runtime. Each takes ARG ROLE, and ARG BASE selects the base tag.

Containerfile.opentofu
# Reference module image on the CAPTF OpenTofu base (see
# module-author/base-images.md). Run from your module's root
# directory:
#
#   podman build -f Containerfile.opentofu --build-arg ROLE=machine \
#     --build-arg IMAGE_SOURCE=https://github.com/<org>/<repo> \
#     --build-arg IMAGE_REVISION="$(git rev-parse HEAD)" \
#     --build-arg IMAGE_VERSION=<tag> -t <registry>/<repo>:<tag> .
#
# The image tag is the module version. Lint the module first and the image
# after:
#   tfcapi-lint module . --role machine --strict
#   tfcapi-lint image <registry>/<repo>:<tag> --role machine --strict
#
# Add a .dockerignore (podman reads it too) with at least `.terraform/`,
# `*.tfstate*` and the Containerfile itself, so none of them lands in
# /captf/module.
#
# Pin BASE by digest for reproducible builds; Dependabot bumps the pin:
#   ghcr.io/captf-io/opentofu-base:<version>@sha256:<digest>

ARG BASE=ghcr.io/captf-io/opentofu-base:1.12.6

# Optional but recommended: hermetic provider mirror for every platform the
# image publishes. Needs registry egress at build time; drop this stage (and
# the COPY --from=mirror below) for an image whose `init` downloads
# providers at run time.
FROM ${BASE} AS mirror
# The base runs as 65532; writing /captf/providers needs root in this stage,
# which is not shipped.
USER root
WORKDIR /src
COPY . .
# get: `providers mirror` refuses a module whose nested local modules are not
# installed; get installs them (no providers), in this stage only.
# mkdir: `providers mirror` does not create the target when the module
# requires no providers, and the COPY --from=mirror below needs it.
RUN tofu get \
 && mkdir -p /captf/providers \
 && tofu providers mirror -platform=linux_amd64 -platform=linux_arm64 /captf/providers

# The final stage starts from the base again, so it runs as the base's
# non-root user (captf, 65532:65532).
FROM ${BASE}
ARG ROLE
ARG IMAGE_SOURCE=""
ARG IMAGE_REVISION=""
ARG IMAGE_VERSION=""
COPY --from=mirror --chown=65532:65532 /captf/providers /captf/providers
COPY --chown=65532:65532 . /captf/module
# io.captf.contract, io.captf.runtime and io.captf.runtime.version come from
# the base.
LABEL io.captf.role="${ROLE}" \
      org.opencontainers.image.source="${IMAGE_SOURCE}" \
      org.opencontainers.image.revision="${IMAGE_REVISION}" \
      org.opencontainers.image.version="${IMAGE_VERSION}"
Containerfile.terraform
# Reference module image on the CAPTF Terraform base (see
# module-author/base-images.md). Run from your module's root
# directory:
#
#   podman build -f Containerfile.terraform --build-arg ROLE=cluster \
#     --build-arg IMAGE_SOURCE=https://github.com/<org>/<repo> \
#     --build-arg IMAGE_REVISION="$(git rev-parse HEAD)" \
#     --build-arg IMAGE_VERSION=<tag> -t <registry>/<repo>:<tag> .
#
# The image tag is the module version. Lint the module first and the image
# after:
#   tfcapi-lint module . --role cluster --strict
#   tfcapi-lint image <registry>/<repo>:<tag> --role cluster --strict
#
# Add a .dockerignore (podman reads it too) with at least `.terraform/`,
# `*.tfstate*` and the Containerfile itself, so none of them lands in
# /captf/module.
#
# Pin BASE by digest for reproducible builds; Dependabot bumps the pin:
#   ghcr.io/captf-io/terraform-base:<version>@sha256:<digest>

ARG BASE=ghcr.io/captf-io/terraform-base:1.16.4

# Optional but recommended: hermetic provider mirror for every platform the
# image publishes. Needs registry egress at build time; drop this stage (and
# the COPY --from=mirror below) for an image whose `init` downloads
# providers at run time.
FROM ${BASE} AS mirror
# The base runs as 65532; writing /captf/providers needs root in this stage,
# which is not shipped.
USER root
WORKDIR /src
COPY . .
# get: `providers mirror` refuses a module whose nested local modules are not
# installed; get installs them (no providers), in this stage only.
# mkdir: `providers mirror` does not create the target when the module
# requires no providers, and the COPY --from=mirror below needs it.
RUN terraform get \
 && mkdir -p /captf/providers \
 && terraform providers mirror -platform=linux_amd64 -platform=linux_arm64 /captf/providers

# The final stage starts from the base again, so it runs as the base's
# non-root user (captf, 65532:65532).
FROM ${BASE}
ARG ROLE
ARG IMAGE_SOURCE=""
ARG IMAGE_REVISION=""
ARG IMAGE_VERSION=""
COPY --from=mirror --chown=65532:65532 /captf/providers /captf/providers
COPY --chown=65532:65532 . /captf/module
# io.captf.contract, io.captf.runtime and io.captf.runtime.version come from
# the base.
LABEL io.captf.role="${ROLE}" \
      org.opencontainers.image.source="${IMAGE_SOURCE}" \
      org.opencontainers.image.revision="${IMAGE_REVISION}" \
      org.opencontainers.image.version="${IMAGE_VERSION}"

The same files are available as examples/Containerfile.opentofu and examples/Containerfile.terraform.

The mirror stage runs get and providers mirror for linux/amd64 and linux/arm64 into /captf/providers. It switches to USER root because the base’s captf user cannot create /captf/providers. That stage is not shipped. The final stage starts again from the base, so the image keeps the non-root USER and copies in only the mirror and the module, owned by 65532.

Mirroring providers needs registry egress at build time

Drop the mirror stage (and its COPY --from=mirror) for a non-hermetic image whose init downloads providers at run time.

Add a .dockerignore next to the Containerfile with at least .terraform/, *.tfstate* and the Containerfile itself, so a local init directory, state or the build file never lands in /captf/module. Podman reads .dockerignore as well.

Lint the module, build, then lint the image (tfcapi-lint):

tfcapi-lint module . --role cluster --strict
podman build -f Containerfile.opentofu --build-arg ROLE=cluster \
  --build-arg IMAGE_SOURCE=https://github.com/<org>/<repo> \
  --build-arg IMAGE_REVISION="$(git rev-parse HEAD)" \
  --build-arg IMAGE_VERSION=<tag> -t <registry>/<repo>:<tag> .
tfcapi-lint image <registry>/<repo>:<tag> --role cluster --strict

One Dockerfile for every role

A repository that ships several roles can use one Dockerfile with the stages mirror, module, then one final stage per role named cluster, machine and machinepool. --build-arg ROLE=<role> picks the module directory, and --target must be the same role. The aws-modules Dockerfile is the reference; its mirror stage copies ${ROLE}/ and a per-role lock file, and its final stages are:

FROM module AS cluster

FROM module AS machinepool

FROM module AS machine
ARG MACHINE_CAPACITY
ARG MACHINE_ARCH
LABEL io.captf.capacity="${MACHINE_CAPACITY}" \
      io.captf.node-info="{\"architecture\":\"${MACHINE_ARCH}\",\"operatingSystem\":\"linux\"}"

The machine labels come from build arguments with a fixed architecture, MACHINE_ARCH, never TARGETARCH, so every platform of a multi-arch build gets identical labels. Build it with:

podman build -f Dockerfile.opentofu --build-arg ROLE=machine \
  --build-arg MACHINE_CAPACITY='{"cpu":"2","memory":"8Gi"}' \
  --build-arg MACHINE_ARCH=amd64 \
  --target machine -t aws-machine:opentofu .

Tags and pinning

Tag Moves Meaning
<version>, for example 1.12.6 yes The newest build for that runtime release, rebuilt weekly.
<major.minor>, for example 1.12 yes The newest build of the newest patch release of that minor.
<version>-YYYYMMDD no That day’s build.
latest yes The newest build.

Pin the base in a module image by tag and digest, for example opentofu-base:1.12.6@sha256:<digest>: the tag documents the version, and the digest fixes the content. To look up a digest:

skopeo inspect --format '{{.Digest}}' docker://ghcr.io/captf-io/opentofu-base:1.12.6

Dependabot’s docker ecosystem bumps a pin of this form. The module repositories use this stanza:

version: 2
updates:
  - package-ecosystem: docker
    directory: /
    schedule:
      interval: weekly
    commit-message:
      prefix: deps

Updates and security fixes

The base is rebuilt every Monday at 05:17 UTC, without the build cache and with apt-get upgrade, to pick up Ubuntu security fixes. A digest pin does not receive those fixes until you bump it to a newer digest and rebuild your module image, so keep Dependabot (or an equivalent) enabled for the FROM line.

Runtime upgrades come from Dependabot on the AS runtime FROM line in the base repository’s Dockerfile; the base build fails if the installed binary does not match the version in that tag. Each push to GHCR carries an SBOM and provenance attestations (mode=max).

To read the labels of a published image:

skopeo inspect docker://ghcr.io/captf-io/opentofu-base:1.12.6 | jq .Labels

Platforms

The base images are multi-arch (linux/amd64, linux/arm64). Ubuntu 26.04 targets the baseline x86-64 instruction set, so the image runs on any amd64 node. A custom final stage on a RHEL 10-family image (such as Rocky Linux 10) requires x86-64-v3, which older amd64 nodes lack.

Without a base image

A hand-rolled image must provide every fixed path, a non-root USER, and all the labels in OCI labels, including io.captf.contract, io.captf.runtime and io.captf.runtime.version, which the base would otherwise supply. /captf/runtime must be the tofu or terraform binary or a symlink to it.

A distroless/static:nonroot final stage works if the module needs no other tool, because tofu is statically linked. It has no git, no ssh and no shell, so a module that fetches over git, or runs a local-exec provisioner, fails on it.