# Graceful Shutdown: Respecting SIGTERM With Go

> Catch SIGTERM with signal.NotifyContext and let http.Server.Shutdown finish in-flight requests before your Go service exits.

- Author: Ross Edman
- Published: 2026-10-09
- Tags: go
- URL: https://rossedman.io/blog/computers/graceful-shutdown-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`](https://rossedman.io/blog/computers/hot-reload-sighup-with-go/) 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.

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

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

```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:

**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.**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`.**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.**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

> **Your timeout has to fit inside theirs.** Whatever sends `SIGTERM` is only going to wait so long before it sends `SIGKILL`. In Kubernetes that is `terminationGracePeriodSeconds`, which defaults to 30 seconds. If your shutdown timeout is longer than that, you never get to finish.
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!
