Skip to content
Trilha
Chapters

Cookbook

Docker

A static binary in a distroless image, the assets already inside it, the variables it needs, and a health probe the orchestrator can use.

A Trilha app is one binary with the pages compiled in and, if you used //go:embed, the static files too. That makes the image small enough that the interesting part is what you leave out.

The Dockerfile#

FROM golang:1.22 AS build
WORKDIR /src
# Dependencies first: this layer is cached until go.mod changes.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The generated file is committed, but generating it again in the build is
# how you find out someone forgot to run trilha gen.
RUN go run ./cmd/trilha gen
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
# The port is documentation; the platform decides what it publishes.
EXPOSE 3000
USER nonroot
ENTRYPOINT ["/app"]

Two lines carry the weight. CGO_ENABLED=0 makes a static binary, which is what lets the second stage be distroless/static — no shell, no package manager, nothing to exploit that is not your code. nonroot means a bug in your app is a bug running as uid 65532.

The address#

trilha.ConfigFromEnv already reads PORT and ADDR, so the platform that hands over a port is obeyed with no code — the default is :3000, and :3000 means every interface, which is what a container needs. The most common broken image is the one that was told to bind 127.0.0.1: inside the container that is the container itself, and nothing outside reaches it.

The variables#

VariableWhat it is
TRILHA_ENVprod — turns off the dev reload, the error page with source, the verbose log
TRILHA_SECRETthe key for cookies and CSRF; at least 32 bytes, from the platform's secret store
PORT or ADDRwhere to listen; :3000 by default
DATABASE_URLyour pool's DSN
TRILHA_BASE_PATHonly when the app is not at the root of the domain

A secret baked into the image is a secret in the registry, and in every layer cache that ever pulled it. Rotating one is TRILHA_SECRET_PREVIOUS with the old value for a deploy or two, so sessions signed with the old key keep working while they expire.

Compose#

services:
  app:
    build: .
    environment:
      TRILHA_ENV: prod
      DATABASE_URL: postgres://app:app@db:5432/app?sslmode=disable
    env_file: [.env]           # TRILHA_SECRET lives here, not in this file
    ports: ["8080:3000"]
    depends_on:
      db: { condition: service_healthy }
    healthcheck:
      test: ["CMD", "/app", "-health"]
      interval: 10s
  db:
    image: postgres:16-alpine
    environment: { POSTGRES_PASSWORD: app, POSTGRES_USER: app, POSTGRES_DB: app }
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
    volumes: [pgdata:/var/lib/postgresql/data]
volumes:
  pgdata:

A distroless image has no curl and no shell, so the health check cannot be a shell command against a URL. Two ways out: a -health flag in your own binary that requests /_trilha/health/ready and exits with the status, or the orchestrator's own probe — which is what Kubernetes does, and it does not need anything inside the image:

livenessProbe:
  httpGet: { path: /_trilha/health/live, port: 3000 }
readinessProbe:
  httpGet: { path: /_trilha/health/ready, port: 3000 }
  periodSeconds: 5

live says the process is up; ready says it can serve, and it is the one that goes red when the database is gone. Wiring them the other way round is how a container that lost its database gets restarted forever instead of being taken out of the pool.