DevOps
Deploying a Go app with Docker: a small, safe image and a Compose file
Build a tiny multi-stage Docker image for a Go service, run it as a non-root user, add health checks, use Compose with PostgreSQL, and decide when Kubernetes is worth it.
By Raktim Ranjit · Published · 3 min read
Short answer: build the binary in one Docker stage, copy only the binary into a minimal final image, run it as a non-root user and add a health check. The result is often 10 to 30 MB with few packages to attack. Run it with Compose next to PostgreSQL. You only need Kubernetes when you outgrow one server.
What does the Dockerfile look like?
# build stage
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
# final stage
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]CGO_ENABLED=0produces a static binary with no C library dependency, so it runs on a minimal image.- Copying
go.modandgo.sumfirst lets Docker cache downloaded modules, so rebuilds after code changes are fast. -ldflags="-s -w"strips debug info and shrinks the binary.- A distroless
staticimage has no shell and no package manager. There is little for an attacker to use if they get in. Thenonroottag runs as an unprivileged user.
If you need a shell for debugging, use alpine as the final stage instead and accept a slightly larger image. If you need CA certificates or timezone data, distroless static includes them.
How do you add a health check?
Distroless has no curl. Add a small subcommand to your binary, for example app healthcheck, that requests /healthz and exits 0 or 1, then call it in Compose.
services:
api:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "/app", "healthcheck"]
interval: 10s
timeout: 3s
retries: 5
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
volumes:
db_data:The hardening options come from is Docker safe for production. The general file syntax is explained in Docker Compose in practice.
How do you handle configuration?
- Read settings from environment variables, and fail at startup if a required one is missing. A clear message beats a mysterious runtime error.
- Do not bake secrets into the image.
- Log to standard output in a structured format.
log/slogin the standard library does this.
How do you shut down cleanly?
Containers receive SIGTERM on stop. Handle it so in-flight requests finish.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() { _ = srv.ListenAndServe() }()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)How do migrations fit in?
Run migrations as a separate step with a different database role than the application's runtime role, so the running service cannot alter the schema. This is the two-role design from row-level security is not your only tenant boundary.
When do you need Kubernetes?
Not for one service and a database on one machine. Look at Kubernetes for beginners when you need multiple hosts, rolling updates across replicas, or automatic scaling. The same image works in both.
References
Author
Raktim Ranjit is a software engineer and the founder of NodeDR Infotech. He builds and maintains the software described here.