Skip to content

Build & CI

The Ultron Infra node is arm64, so every image is built linux/arm64. The build cross-compiles a static binary, drops it into a distroless runtime, and (if the app has migrations) bundles the migrate CLI alongside it so the same image can run schema migrations. The result is pushed to GHCR.

flowchart LR
  src([<app> repo]) --> bx[buildx<br/>linux/arm64]
  bx --> b1[stage 1: build<br/>cross-compile static binary]
  b1 --> b2[stage 2: distroless<br/>binary + migrate + migrations]
  b2 --> push[/ghcr.io/owner/<app>:sha-XXXX/]
  1. Add the Dockerfile to the app repo — multi-stage, distroless, migrate bundled.
  2. Add the .dockerignore to keep secrets and build output out of the context.
  3. Add the GitHub Actions workflow that builds linux/arm64 and pushes to GHCR.
  4. Push to main, then grab the sha-<short> tag for the next page.

Multi-stage: build on the runner’s native arch, cross-compile to the target. CGO off yields a static binary that runs on distroless/static. Drop the migrate download and the migrations copy if <app> has no DB.

# syntax=docker/dockerfile:1
# ---- build ----------------------------------------------------------------
# Build on native arch, cross-compile to target.
FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS build
ARG TARGETOS=linux
ARG TARGETARCH=arm64
ARG VERSION=dev
ARG MIGRATE_VERSION=v4.19.1
WORKDIR /src
# Dependencies first, for layer caching.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO off → static binary that runs on distroless/static.
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
-o /out/<app> ./cmd/api
# golang-migrate CLI, bundled so the migrations Job can run it.
RUN curl -fsSL "https://github.com/golang-migrate/migrate/releases/download/${MIGRATE_VERSION}/migrate.${TARGETOS}-${TARGETARCH}.tar.gz" \
| tar -xz -C /out migrate
# ---- runtime --------------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/<app> /app/<app>
COPY --from=build /out/migrate /app/migrate
COPY --from=build /src/migrations /app/migrations
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app/<app>"]

Notes:

  • $BUILDPLATFORM + TARGETARCH let buildx build on an x86 runner and emit arm64. Setting TARGETARCH=arm64 as the default keeps a plain docker build arm64-correct too.
  • VERSION is injected at link time (-X main.version=...) from a CI-derived git describe.
  • distroless :nonroot has no shell or package manager — the smallest viable attack surface. The container runs as nonroot:nonroot.

Keep secrets, VCS, and build output out of the build context:

# Build output
bin/
tmp/
<app>
*.exe
*.test
# Environment / secrets
.env
.env.local
.env.*.local
# VCS & CI (not needed in the build context)
.git/
.github/
# Docs & specs
*.md
openapi.yaml
# IDE / editor / OS
.idea/
.vscode/
*.swp
*.swo
.DS_Store
._*
# Logs / coverage
*.log
coverage.*
*.out
*.prof

Builds linux/arm64 with buildx, logs in with the workflow’s GITHUB_TOKEN, and tags the image sha-<short> (plus latest on the default branch and the semver on a v* tag). The sha- tag is what you pin in rollout.yaml.

name: build-image
on:
push:
branches: [ main ]
tags: [ 'v*' ]
env:
IMAGE: ghcr.io/<owner>/<app>
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for `git describe`
- name: Derive version
id: ver
run: echo "version=$(git describe --tags --always --dirty)" >> "$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: Image metadata (tags + labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
- name: Build and push (linux/arm64)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.ver.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max

type=sha yields a tag like sha-ed927c2. Grab it from the Actions run (or the GHCR package page) and use it in the next step.

Image in GHCR → GitOps & deploy to point Argo CD at it.