Graceful Shutdown: Respecting SIGTERM With Go
SIGTERM with signal.NotifyContext, call http.Server.Shutdown with a deadline, and in-flight requests finish instead of getting dropped on every deploy.A while back I wrote about respecting SIGHUP so you can reload config without restarting a process. This is the other half of that story. Sometimes you do need to stop the process: a deploy, a node getting drained, an autoscaler deciding it has too many of you. How your application behaves in those few seconds is the difference between a boring deploy and a pile of 502s.
When something wants your process to stop politely, it sends SIGTERM. Kubernetes does this, systemd does this, docker stop does this. If you ignore it, they wait a bit and then send SIGKILL, which you can’t catch. Anything in flight is gone.
In this post we will build a small web server that hears SIGTERM, stops taking new requests, lets the current ones finish and then exits cleanly. Let’s dive into it.
Setup
Same as last time, a single main.go with one endpoint. This time the endpoint is slow on purpose so we have something “in flight” to protect.
package main
import (
"log"
"net/http"
"time"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(5 * time.Second)
w.Write([]byte("Hello, World!\n"))
})
log.Println("starting up....")
log.Fatal(http.ListenAndServe(":8080", nil))
}Start it with go run main.go, and in a second terminal kick off a request
➜ curl localhost:8080While that request is waiting, hit Ctrl+C on the server. The curl dies with Empty reply from server. That is exactly what your users see when you deploy without handling shutdown. Not great.
Catching The Signal
Since Go 1.16 there is a helper in os/signal that is perfect for this, signal.NotifyContext. It hands you a context.Context that gets cancelled when one of the signals you list arrives. No channels to wire up by hand this time.
import (
+ "context"
"log"
"net/http"
+ "os"
+ "os/signal"
+ "syscall"
"time"
)
func main() {
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(5 * time.Second)
w.Write([]byte("Hello, World!\n"))
})
We listen for os.Interrupt too, which is what Ctrl+C sends, so you can test this locally without hunting for process IDs.
Shutting Down The Server
Here is the problem. http.ListenAndServe is a package-level helper and it gives us nothing to call when we want to stop. We need our own http.Server so we can call Shutdown on it.
Shutdown does exactly what we want: it closes the listeners so no new connections come in, then waits for active requests to finish. It takes a context, which is how we put a ceiling on how long we are willing to wait.
- log.Println("starting up....")
- log.Fatal(http.ListenAndServe(":8080", nil))
+ srv := &http.Server{Addr: ":8080"}
+
+ go func() {
+ log.Println("starting up....")
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatal(err)
+ }
+ }()
+
+ <-ctx.Done()
+ stop()
+ log.Println("shutting down, finishing in-flight requests....")
+
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(shutdownCtx); err != nil {
+ log.Fatalf("forced shutdown: %v", err)
+ }
+ log.Println("bye!")
}
Wait, what just happened? A few things:
- The server moves into a goroutine
Same trick as the
SIGHUPpost.ListenAndServeblocks, so we run it on the side and keepmainfree to wait for a signal. - ErrServerClosed is not an error
Once
Shutdownis called,ListenAndServereturnshttp.ErrServerClosedright away. That is the happy path, so we skip it instead of callinglog.Fatal. - main blocks on ctx.Done()
Nothing happens until a signal arrives. Calling
stop()right after means a secondCtrl+Cfalls back to Go’s default behavior and kills the process immediately. Handy when a shutdown hangs. - Shutdown gets a fresh context with a deadline
Not
ctx, which is already cancelled. We give in-flight requests 10 seconds. If they aren’t done by then,Shutdownreturns an error and we bail.
Testing It
Start the server again, fire off a request and hit Ctrl+C while it is still waiting.
# in terminal one
➜ go run main.go
2026/10/09 09:12:01 starting up....
^C2026/10/09 09:12:03 shutting down, finishing in-flight requests....
2026/10/09 09:12:06 bye!# in terminal two
➜ curl localhost:8080
Hello, World!The request finished! The server waited for it, then exited. Try a new curl after you hit Ctrl+C and you will get Connection refused, which is what we want. New traffic goes somewhere else, old traffic gets to finish.
To prove it works with the real signal, find the process like last time and send SIGTERM directly
➜ kill -SIGTERM <pid>Picking The Timeout
I like to set the shutdown timeout a little shorter than the grace period. That way the application gives up on its own terms and logs about it, instead of disappearing mid-sentence.
Conclusion
That’s it! Between this and SIGHUP you now have the two signals I care most about when running things below the application layer. One reloads without a restart, the other restarts without dropping anything. If I was going to keep going, I would close database connections and flush any buffered logs or metrics after Shutdown returns. Happy hacking!