Building Modules

A module builds into a single container image. That image is the whole artifact — there is no separate publish step, chart packaging, or platform upload.

The Dockerfile

The template ships the canonical Dockerfile: Go builder, distroless runtime, module binary as the entrypoint running run:

# Build on the NATIVE runner arch (BUILDPLATFORM) and let Go cross-compile to
# the target arch — fast, no QEMU emulation in the build stage.
FROM --platform=$BUILDPLATFORM golang:1.25 AS builder
ARG TARGETOS
ARG TARGETARCH
ARG VERSION
WORKDIR /manager
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
    go build -ldflags="-X github.com/tiny-systems/module/cli.versionID=${VERSION}" \
    -o /bin/manager ./cmd

FROM gcr.io/distroless/static:nonroot
COPY --from=builder /bin/manager /manager
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 65532:65532
CMD ["/manager", "run"]

Key points:

  • The version is injected into the SDK's variable, github.com/tiny-systems/module/cli.versionID — not a main.version of your own.
  • The runtime image is gcr.io/distroless/static:nonroot: no shell, non-root UID 65532. CA certificates are copied from the builder so HTTPS works.
  • CMD ["/manager", "run"] — the same binary also answers tools ..., pre-install, and pre-delete when the chart or a release script invokes it with a different command.

Local Builds

# Local test build
docker build -t my-module:dev --build-arg VERSION=0.0.1 .

# Multi-platform build and push
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --build-arg VERSION=1.0.0 \
  -t ghcr.io/my-org/my-module:1.0.0 \
  --push .

Image tags are bare versions1.0.0, not v1.0.0. The run command refuses a v-prefixed version, and the module index references bare tags.

CI Build (GitHub Actions)

The template's .github/workflows/release.yml builds and pushes on every semver tag. The tag carries the v (v1.0.0); the image tag has it stripped:

name: Publish module image to GHCR

on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'

permissions:
  contents: read
  packages: write

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Image tag is the version without the leading 'v' (v0.5.24 -> 0.5.24),
      # matching what the module index references.
      - name: Derive version
        id: v
        run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build & push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ steps.v.outputs.version }}
            ghcr.io/${{ github.repository }}:latest
          build-args: |
            VERSION=${{ steps.v.outputs.version }}

GHCR packages default to private on first push — make the package public once (repo → Packages → settings → visibility) or no cluster can pull it anonymously.

Validating a Built Image

The image is introspectable through the embedded CLI:

# List components the image registers (and check error-port conformance)
docker run --rm --entrypoint /manager ghcr.io/my-org/my-module:1.0.0 \
  tools components-info

# Generate the RBAC values overlay for the module index
docker run --rm --entrypoint /manager ghcr.io/my-org/my-module:1.0.0 \
  tools rbac-values

# Check platforms
docker manifest inspect ghcr.io/my-org/my-module:1.0.0

# Scan for vulnerabilities
trivy image ghcr.io/my-org/my-module:1.0.0

Next Steps