Deploying Go Services
Prerequisites: Building CLI tools & cross-compiling
A Go service compiles to one binary, which keeps deployment simple. What remains is making it behave well when it's started, stopped, and upgraded — and automating the checks before it ships.
Graceful shutdown
Orchestrators like Kubernetes, systemd, and Docker stop a process by sending
SIGTERM, then kill it after a grace period. A graceful shutdown stops
accepting new connections and lets in-flight requests finish:
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
var version = "dev" // overridden at build time with -ldflags
func main() {
if err := run(); err != nil {
slog.Error("fatal", "err", err)
os.Exit(1)
}
}
func run() error {
// ctx is cancelled on Ctrl+C (SIGINT) or SIGTERM.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
mux.HandleFunc("GET /version", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(version))
})
srv := &http.Server{
Addr: ":" + envOr("PORT", "8080"),
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
slog.Info("listening", "addr", srv.Addr, "version", version)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
close(errCh)
}()
select {
case err := <-errCh:
return err // failed to start, e.g. port in use
case <-ctx.Done():
slog.Info("shutting down")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
return <-errCh
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
Key points:
ListenAndServereturnshttp.ErrServerClosedimmediately whenShutdownis called; that's expected, not a failure.Shutdownwaits for active requests up to the timeout. Keep the timeout shorter than the orchestrator's grace period (Kubernetes defaults to 30 seconds).- The shutdown context is a fresh
context.Background()— the signal context is already cancelled. - Close other resources (database pool, background workers) after the server has drained.
Configuration comes from environment variables (PORT, DATABASE_URL),
following the twelve-factor convention, so the same binary runs in every
environment.
Version stamping with ldflags
-ldflags "-X" sets a package-level string variable at link time:
go build -ldflags "-X main.version=v1.4.0" -o server .
Since Go 1.18, binaries also embed VCS information automatically (commit,
time, dirty flag) when built inside a Git checkout. Read it with
runtime/debug.ReadBuildInfo(), or from the shell with
go version -m ./server.
A multi-stage Dockerfile
Build in a full Go image, then copy only the binary into a minimal runtime image:
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
# Download modules first so this layer is cached between code changes.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION}" -o /out/server .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]
docker build --build-arg VERSION=v1.4.0 -t myapp:v1.4.0 .
docker run -p 8080:8080 myapp:v1.4.0
The final image is a few megabytes and runs as a non-root user. CGO_ENABLED=0
matters: distroless static and scratch have no C library for a cgo binary
to link against. With FROM scratch you must also copy in CA certificates
(for outbound HTTPS) and timezone data if you need them — distroless includes
both. Use the exec form of ENTRYPOINT (the JSON array) so the process
receives SIGTERM directly.
Continuous integration
Run the same checks on every push. A GitHub Actions example:
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go vet ./...
- run: go test -race -count=1 ./...
- run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
- run: CGO_ENABLED=0 go build -o server .
Many teams add staticcheck or golangci-lint, and check formatting with
test -z "$(gofmt -l .)". The build and push of the Docker image typically
runs only on the main branch or on tags.
Running in production
- Expose a health endpoint (
/healthz) for load balancers and liveness probes; a readiness check can also ping the database. - Log structured JSON with
slog.NewJSONHandler(os.Stdout, nil)so your log platform can index fields. - Set
GOMEMLIMITa little below the container's memory limit. - Since Go 1.25 the runtime sets
GOMAXPROCSfrom container CPU limits; on earlier versions, including 1.23, it uses the host's CPU count, so setGOMAXPROCSexplicitly (or usego.uber.org/automaxprocs) in CPU-limited containers.
Common mistakes
- Treating
http.ErrServerClosedas a crash and exiting non-zero on every deploy. - Using the shell form
ENTRYPOINT /server, which runs under/bin/sh— and distroless has no shell, so it fails to start. - Building with cgo enabled and copying the binary into
scratch, producing a confusing "no such file or directory" at startup. - Skipping
-racein CI because it's slower; that's where it catches bugs. - Baking secrets into the image instead of injecting them at runtime.
Practice
Add a background worker to the service above that logs "tick" every 10
seconds and stops cleanly when the signal context is cancelled. Make run
wait for the worker to finish before returning.
Show solution
// In run(), after creating ctx:
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
t := time.NewTicker(10 * time.Second)
defer t.Stop()
for {
select {
case <-t.C:
slog.Info("tick")
case <-ctx.Done():
slog.Info("worker stopped")
return
}
}
}()
// ... start the server and wait for ctx.Done() as before ...
// After srv.Shutdown succeeds:
wg.Wait()
return <-errCh
The worker shares the signal context, so one SIGTERM stops both the HTTP
server and the worker, and wg.Wait() guarantees it has exited before the
process does.
Where to go from here
You now know the Go language and the standard tools around it: concurrency
and its pitfalls, generics and iterators, the standard library for HTTP,
JSON, and databases, testing, profiling, and shipping a service. What's left
isn't more syntax — it's experience. Build real things: a CLI you'll actually
use, an API with a real database behind it, a worker that processes a queue.
Read good Go code (the standard library itself is an excellent example), use
go vet and the race detector habitually, and let profiles, not guesses,
guide your optimisation.