Gracefully shutting down a server might seem like a small detail, but in real-world production systems it’s critical. I recently built a small Go HTTP server and decided to get this part right, and learned a lot in the process.
This post is for Go developers who have shipped an HTTP service but haven’t fully thought through what actually happens when the process stops.
Scope
Handling SIGINT and SIGTERM in Go
Wiring OS signals into context cancellation
Gracefully shutting down an HTTP server
This post focuses on single-process Go services. Distributed shutdown coordination, orchestration systems, and multi-node rollouts are intentionally out of scope.
Why Graceful Shutdown Matters
The Naive Approach
A typical Go server often looks like this, blocking the main goroutine until the process is killed:
log.Fatal(http.ListenAndServe(":8080", handler))When someone presses Ctrl+C, the process exits immediately. Active connections are dropped and any in-flight work like database writes or file operations may be lost. Fine for experiments, not acceptable for production.
What I Wanted to Achieve
Start the server in a background goroutine
Listen for SIGINT and SIGTERM
Stop accepting new requests immediately on shutdown
Let active requests finish with a timeout
Clean up background resources safely
Signals and Context Cancellation
Go’s context package pairs naturally with os/signal. signal.NotifyContext gives you a context that is cancelled when the process receives a shutdown signal, plus a cleanup function that must be deferred to avoid leaking signal handlers.
shutdownCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()The Final Implementation
// ServeWithGracefulShutdown starts the HTTP server and ensures
// graceful shutdown when a termination signal is received.
func ServeWithGracefulShutdown(ctx context.Context, srv *HTTPServer) {
shutdownCtx, cleanupSignalHandler :=
signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cleanupSignalHandler()
srv.StartAsync()
<-shutdownCtx.Done()
log.Println("Shutdown signal received. Preparing to stop the server...")
gracefulShutdownCtx, cancel :=
context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.GracefullyStop(gracefulShutdownCtx); err != nil {
log.Fatalf("Failed to gracefully shut down server: %v", err)
}
log.Println("Server shutdown complete.")
}Step-by-Step Breakdown
Create a signal-aware context that cancels on SIGINT and SIGTERM
Start the HTTP server in a background goroutine
Block until the shutdown context is cancelled
Wrap shutdown in a context.WithTimeout
Call http.Server.Shutdown to stop accepting new requests
Gotchas and Things That Surprised Me
Forgetting to defer the cleanup function from NotifyContext
Long-running handlers must respect request contexts or shutdown will hang
Using context.Background() inside handlers bypasses shutdown cancellation
Key Takeaways
Graceful shutdown is about coordination, not just stopping a server.
Signals should funnel into a single cancellation path.
http.Server.Shutdown stops new requests and drains existing ones.
Timeouts protect you from hanging shutdowns.