Graceful Shutdown: Respecting SIGTERM With Go

TL;DR
Catch 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.

main.go go
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

shell
➜ curl localhost:8080

While 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.

main.go diff
 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.

main.go diff
-	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:

  1. The server moves into a goroutine

    Same trick as the SIGHUP post. ListenAndServe blocks, so we run it on the side and keep main free to wait for a signal.

  2. ErrServerClosed is not an error

    Once Shutdown is called, ListenAndServe returns http.ErrServerClosed right away. That is the happy path, so we skip it instead of calling log.Fatal.

  3. main blocks on ctx.Done()

    Nothing happens until a signal arrives. Calling stop() right after means a second Ctrl+C falls back to Go’s default behavior and kills the process immediately. Handy when a shutdown hangs.

  4. 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, Shutdown returns an error and we bail.

Testing It

Start the server again, fire off a request and hit Ctrl+C while it is still waiting.

shell
# 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!
shell
# 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

shell
➜ 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!