Graceful shutdown for Go HTTP services
Stop accepting new requests, finish the ones in flight, then close the database: a small main.go pattern for containers and deploys.
3 min read
On this page
What happens during a deploy
Every deploy, scale-down, or container restart sends your process a signal, usually SIGTERM. A Go
service that does not handle it simply dies: requests in progress are cut off, clients see
connection resets, and a half-finished database write may be rolled back or, worse, partially
applied by a later retry.
Graceful shutdown means three steps, in this order:
- Stop accepting new connections.
- Let in-flight requests finish, with a deadline.
- Close shared resources such as the database pool.
The standard library already has everything needed.
The pattern
package main
import (
"context"
"database/sql"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
// ctx is cancelled on Ctrl+C (SIGINT) or SIGTERM from Docker/Kubernetes.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx); err != nil {
slog.Error("server stopped with error", "err", err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
return err
}
defer db.Close() // 3. runs last, after the server has drained
srv := &http.Server{
Addr: ":8080",
Handler: routes(db),
ReadHeaderTimeout: 5 * time.Second,
}
serverErr := make(chan error, 1)
go func() {
slog.Info("listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
close(serverErr)
}()
select {
case err := <-serverErr:
return err // failed to start, e.g. port already in use
case <-ctx.Done():
slog.Info("shutdown signal received")
}
// 1 + 2. Stop accepting connections and wait for active requests, at most 20 seconds.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}A few details are easy to miss:
ListenAndServereturnshttp.ErrServerClosedas soon asShutdownis called. That is the normal path, not an error.- The shutdown context is created from
context.Background(), not fromctx.ctxis already cancelled at that point; using it would give the server zero time to drain. defer db.Close()runs afterShutdownreturns, so handlers never see a closed pool.
Let handlers notice too
Shutdown waits for handlers but does not interrupt them. Long handlers should respect the
request context, which is cancelled when the client disconnects:
func listPolicies(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(),
`SELECT number, status FROM policies ORDER BY updated_at DESC LIMIT 50`)
if err != nil {
http.Error(w, "could not load policies", http.StatusInternalServerError)
return
}
defer rows.Close()
// ... encode rows as JSON
}
}Using QueryContext(r.Context(), ...) instead of Query(...) means an abandoned request also stops
its query.
Match the platform's grace period
Your timeout must be shorter than the time the platform waits before killing the process:
- Docker
docker stopwaits 10 seconds by default (--time/stop_grace_periodchanges it). - Kubernetes waits
terminationGracePeriodSeconds, 30 seconds by default.
A 20-second shutdown timeout fits Kubernetes, but not Docker's default. Set them deliberately.
Make sure the signal reaches Go
In a container, the signal goes to PID 1. If your Dockerfile uses the shell form, the shell is PID 1
and may not forward SIGTERM:
# Shell form: /bin/sh is PID 1, your app may never see SIGTERM.
CMD ./server
# Exec form: the Go binary is PID 1 and receives the signal directly.
CMD ["./server"]Readiness during shutdown
Behind a load balancer, there is a short window where traffic is still routed to a pod that has
started shutting down. A common addition is a readiness endpoint that starts returning 503 as soon
as the signal arrives, followed by a brief pause before calling Shutdown, so the load balancer
stops sending new requests first.
Summary
- Use
signal.NotifyContextto turnSIGTERMinto context cancellation. - Call
srv.Shutdownwith a fresh timeout context; treathttp.ErrServerClosedas success. - Close the database after the server has drained.
- Use request contexts in handlers, exec-form
CMD, and a timeout shorter than the platform's.