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/providersand theio.captf.rolelabel: the module image adds them.- The reserved paths
/captf/work,/captf/bin,/captf/configand/var/run/captf/credentials: the Job mounts them. See Fixed paths.
What your module image adds¶
- The module at
/captf/moduleand, optionally, the provider mirror at/captf/providers, both owned by65532:65532. - The
io.captf.rolelabel. - The
org.opencontainers.image.source,.revisionand.versionlabels, with.versionequal to the tag. - On machine images, optionally, the
io.captf.capacityandio.captf.node-infolabels.
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.
# 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}"
# 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:
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:
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.