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 amain.versionof 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 answerstools ...,pre-install, andpre-deletewhen 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 versions — 1.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
- Helm Charts - The shared operator chart that deploys any module image
- Registry Publishing - Get the image into a module index
- Versioning - Version strategy