Skip to content
AgileLogics
9 min read

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:

  • ListenAndServe returns http.ErrServerClosed immediately when Shutdown is called; that's expected, not a failure.
  • Shutdown waits 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 GOMEMLIMIT a little below the container's memory limit.
  • Since Go 1.25 the runtime sets GOMAXPROCS from container CPU limits; on earlier versions, including 1.23, it uses the host's CPU count, so set GOMAXPROCS explicitly (or use go.uber.org/automaxprocs) in CPU-limited containers.

Common mistakes

  • Treating http.ErrServerClosed as 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 -race in 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.