Production Guides
Dockerizing GoFr Services
Summary
Two production-ready ways to ship GoFr in a container: a multi-stage build that compiles a static, CGO-disabled binary inside the image, or a copy-binary variant that lifts a CI-built binary into a minimal runtime. Both target gcr.io/distroless/static-debian12:nonroot, expose HTTP_PORT (8000) and METRICS_PORT (2121), read configuration from env vars, and rely on Kubernetes liveness/readiness probes (the /.well-known/alive and /.well-known/health endpoints GoFr registers) — Dockerfile HEALTHCHECK does not work cleanly on distroless.
When to use this guide
Use this guide when you have a GoFr service running locally with go run and need to package it for a registry, CI, or Kubernetes. The output is a small (typically under 20 MB), non-root image that does not ship a shell or package manager — keeping the attack surface small for production.
For Kubernetes manifests that consume this image, see Deploying to Kubernetes.
Project layout
A typical containerized GoFr project looks like this:
my-service/
├── main.go
├── go.mod
├── go.sum
├── configs/
│ └── .env
├── Dockerfile
├── .dockerignore
└── docker-compose.yml
GoFr loads configs/.env automatically when present, but in containers you should prefer real environment variables — that is what Kubernetes ConfigMaps and Secrets inject.
Choose your variant
Two production-ready paths. Pick based on where you want compilation to happen.
| Variant | When to prefer |
|---|---|
| Multi-stage build | You want a single docker build to produce a release-grade image. Build context lives entirely in-repo. |
| Copy pre-built binary | Your CI already produces a reproducible binary (e.g., signed/attested by SLSA, GoReleaser, etc.). The image build is a thin wrapper around that artifact, so it's faster and the build context is tiny. |
Variant A: Multi-stage Dockerfile
Save this as Dockerfile at the repo root:
# syntax=docker/dockerfile:1.7
ARG GO_VERSION=1.25
ARG APP_VERSION=dev
ARG GIT_COMMIT=unknown
# ---------- builder ----------
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /src
# Cache module downloads in their own layer.
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Copy source after deps so source edits don't bust the dep cache.
COPY . .
ARG APP_VERSION
ARG GIT_COMMIT
ARG TARGETOS
ARG TARGETARCH
# CGO=0 + -trimpath gives a static, reproducible binary.
# TARGETOS/TARGETARCH come from BuildKit so the same Dockerfile builds for
# linux/amd64 and linux/arm64 unchanged.
RUN --mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
go build \
-trimpath \
-ldflags="-s -w -X main.version=${APP_VERSION} -X main.commit=${GIT_COMMIT}" \
-o /out/app ./
# ---------- runtime ----------
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /out/app /app/app
USER nonroot:nonroot
EXPOSE 8000 2121
# distroless/static has no shell and no wget/curl, so a Dockerfile HEALTHCHECK
# is impractical here. On Kubernetes, use the Deployment's livenessProbe and
# readinessProbe (httpGet on /.well-known/alive and /.well-known/health) — see
# the Deploying to Kubernetes guide.
ENTRYPOINT ["/app/app"]
A few things worth calling out:
CGO_ENABLED=0produces a fully statically-linked binary with no dependency onlibcor a dynamic linker at runtime — required becausedistroless/static-debian12:nonrootships only the binary, CA certs,/etc/passwd, tzdata, and a non-root user. There is nolibc(glibc, musl, anything), no shell, no package manager.TARGETOS/TARGETARCHARGs let one Dockerfile build forlinux/amd64andlinux/arm64viadocker buildx build --platform=linux/amd64,linux/arm64 …— useful when developing on Apple Silicon and deploying to amd64 nodes (or vice versa).-X main.version=…ldflags only inject values if yourmainpackage declares matching variables. Addvar (version, commit string)near the top ofmain.goif you wantgofr.Logger().Info(version, commit)to surface the build's git SHA.USER nonrootruns as UID 65532; combined with a read-only root filesystem in Kubernetes this satisfies most pod-security baselines.- No bundled
configs/: env vars come from the platform (compose, K8s ConfigMap/Secret, cloud SSM/Secrets Manager). Do notCOPY configs/into the runtime image — it tends to drift, and a populated.envis a secret. Bake only platform-independent defaults into your binary. - Healthchecks rely on
/.well-known/alive(process up) and/.well-known/health(datasources reachable) that GoFr registers automatically. There is nohealthchecksubcommand on the GoFr binary, anddistroless/statichas no shell orwget/curlto call the endpoint, so a DockerfileHEALTHCHECKdirective does not work cleanly on this base. On Kubernetes, use the Deployment'slivenessProbe/readinessProbeinstead (see the Deploying to Kubernetes guide).
Variant B: Copy a pre-built binary
If your CI already produces a release-grade Go binary — reproducible flags, SLSA provenance, signed by cosign, whatever your supply chain looks like — you don't need a Go toolchain inside the image. Lift the binary in.
Build the binary in CI:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags='-s -w' -o ./bin/app ./
Then this is the entire Dockerfile:
# syntax=docker/dockerfile:1.7
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
# `./bin/app` is the binary your CI produced one step earlier.
COPY ./bin/app /app/app
USER nonroot:nonroot
EXPOSE 8000 2121
ENTRYPOINT ["/app/app"]
Why this is sometimes preferable:
- Faster image builds: no Go toolchain, no module download, no compile step. The image build is a single
COPY. - Smaller build context:
docker buildonly needs./bin/appand the Dockerfile. Use a tight.dockerignore(or build with a custom context) so source isn't shipped to the daemon. - Decoupled supply chain: the binary and its provenance are signed once in CI and the image build never touches source. This matches SLSA Level 3+ patterns.
When NOT to use this variant:
- You want a single
docker buildto be the only entry-point for a fresh checkout. Variant A is more self-contained. - You're shipping arch-specific binaries from the same Dockerfile. Variant A's
TARGETARCHflow is cleaner.
.dockerignore
Without this, COPY . . pulls in .git, local secrets, and build artifacts:
.git
.gitignore
.dockerignore
Dockerfile
docker-compose.yml
*.md
**/*_test.go
bin/
dist/
configs/.env.local
.env
.env.*
Building and tagging
docker build \
--build-arg APP_VERSION=$(git describe --tags --always) \
--build-arg GIT_COMMIT=$(git rev-parse --short HEAD) \
-t my-org/my-service:$(git rev-parse --short HEAD) \
-t my-org/my-service:latest \
.
Always tag with a commit SHA in addition to (or instead of) latest. Kubernetes RollingUpdate only rolls when the image reference actually changes, and latest is mutable.
docker-compose for local development
For local dev you usually want the service plus a few datasources. This compose file matches GoFr's default ports (HTTP 8000, metrics 2121):
services:
app:
build: .
ports:
- "8000:8000"
- "2121:2121"
environment:
APP_NAME: my-service
HTTP_PORT: "8000"
METRICS_PORT: "2121"
LOG_LEVEL: DEBUG
REDIS_HOST: redis
REDIS_PORT: "6379"
DB_HOST: postgres
DB_PORT: "5432"
DB_USER: gofr
DB_PASSWORD: gofr
DB_NAME: gofr
DB_DIALECT: postgres
PUBSUB_BACKEND: KAFKA
PUBSUB_BROKER: kafka:9092
depends_on:
- redis
- postgres
- kafka
redis:
image: redis:7-alpine
ports: ["6379:6379"]
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: gofr
POSTGRES_PASSWORD: gofr
POSTGRES_DB: gofr
ports: ["5432:5432"]
kafka:
image: bitnami/kafka:3.7
environment:
KAFKA_CFG_NODE_ID: "0"
KAFKA_CFG_PROCESS_ROLES: controller,broker
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: "0@kafka:9093"
KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
ports: ["9092:9092"]
The exact env var names for each datasource (Mongo, Cassandra, etc.) are documented under Injecting Databases Drivers.
Production tips
- Image size: with
distroless/static, a typical GoFr binary lands at 15–25 MB compressed. If you see hundreds of MB, you forgotCGO_ENABLED=0or copied build artifacts. - Read-only root FS: in Kubernetes, set
readOnlyRootFilesystem: trueand mount anemptyDirif the service writes temp files. - Don't bake secrets: never
COPYa populated.envinto the runtime image. Inject via Kubernetes Secrets instead. - Pin the Go version: the
ARG GO_VERSIONlets CI build the same image deterministically. - Build cache: Variant A's Dockerfile already includes the
--mount=type=cache,target=/go/pkg/modcache mount on bothgo mod downloadandgo build; just use BuildKit (default indocker buildx, or setDOCKER_BUILDKIT=1) to keep the module cache warm between CI runs.
Verification
A hello-world GoFr service (no datasources) needs no env injection:
docker build -t my-service:dev .
docker run --rm -p 8000:8000 -p 2121:2121 my-service:dev
# In another shell:
curl -s http://localhost:8000/.well-known/alive
# {"data":{"status":"UP"}}
curl -s http://localhost:2121/metrics | head
# # HELP app_http_response ...
# # TYPE app_http_response histogram
A real service with datasources needs env vars. Use --env-file:
cat > .env.dev <<'EOF'
APP_NAME=my-service
HTTP_PORT=8000
METRICS_PORT=2121
LOG_LEVEL=DEBUG
REDIS_HOST=host.docker.internal
REDIS_PORT=6379
DB_HOST=host.docker.internal
DB_PORT=5432
DB_USER=gofr
DB_PASSWORD=gofr
DB_NAME=gofr
DB_DIALECT=postgres
EOF
docker run --rm -p 8000:8000 -p 2121:2121 --env-file .env.dev my-service:dev
# Same curl checks as above.
# Inspect image size and layers:
docker image inspect my-service:dev --format '{{.Size}}'
docker history my-service:dev