# Go + Nginx: Deploy a Go API Behind a Reverse Proxy

- URL: https://karandeepsingh.ca/posts/go-nginx-reverse-proxy/
- Author: Karandeep Singh
- Published: 2026-02-15
- Tags: golang, nginx, linux
- Summary: Deploy a Go HTTP API behind Nginx. Start with a bare server, hit the port 80 permission trap, proxy through Nginx, lose client IPs, fix header forwarding, add rate limiting, and wire up graceful shutdown with systemd.

Every Go tutorial ends the same way: `http.ListenAndServe(":8080", nil)` and "it works!" But in production, your users aren't hitting port 8080. They're hitting port 80 or 443 through Nginx, and there's a gap between "my Go server runs" and "my Go server runs behind Nginx in production."

That gap is full of traps: permission errors, lost client IPs, 502s when your app restarts, and rate limiting that blocks the wrong address. We'll hit every one of these on purpose so you understand the fixes.

![Ethernet cables connected to network switches for reverse proxy networking](https://karandeepsingh.ca/images/posts/go-nginx-reverse-proxy.jpg)

## What We're Building

A JSON API in Go that serves deployment status info, the kind of internal tool every DevOps team builds. We'll put it behind Nginx as a reverse proxy.

**The journey:**
1. Build a bare Go HTTP server
2. Try to bind to port 80 (the permission trap)
3. Add Nginx as a reverse proxy (and get a 502)
4. Fix it, then discover client IPs are all `127.0.0.1`
5. Fix header forwarding with `X-Forwarded-For`
6. Add rate limiting (and hit the `127.0.0.1` problem again)
7. Add graceful shutdown so deploys don't drop connections

## Prerequisites

- Go 1.21+ installed (`go version`)
- Nginx installed (`nginx -v`)
- A Linux server (local VM, EC2 instance, or WSL)
- Root or sudo access (for Nginx and port 80)

## Step 1: Build a Bare Go API

**What:** A minimal JSON API that returns deployment status.

**Why:** This is the baseline: a Go server that works on its own before we add any proxy complexity.

Create your project:

```bash
mkdir go-nginx-api && cd go-nginx-api
go mod init go-nginx-api
```

**`main.go`**
```go
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"
)

type DeployStatus struct {
	Service   string `json:"service"`
	Version   string `json:"version"`
	Status    string `json:"status"`
	Timestamp string `json:"timestamp"`
	ClientIP  string `json:"client_ip"`
}

func main() {
	http.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
		status := DeployStatus{
			Service:   "auth-api",
			Version:   "v1.4.2",
			Status:    "healthy",
			Timestamp: time.Now().Format(time.RFC3339),
			ClientIP:  r.RemoteAddr,
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(status)
	})

	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		fmt.Fprintln(w, "ok")
	})

	log.Println("starting server on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
```

Two endpoints: `/api/status` returns JSON with deployment info and the client's IP address, `/health` returns a simple "ok" for health checks. We include `ClientIP` in the response so we can see what Go thinks the client's address is. This becomes important later.

Run it:

```bash
go run main.go
```

Test it in another terminal:

```bash
curl http://localhost:8080/api/status
```

**Expected output:**
```json
{
  "service": "auth-api",
  "version": "v1.4.2",
  "status": "healthy",
  "timestamp": "2026-02-15T14:30:00Z",
  "client_ip": "127.0.0.1:54321"
}
```

The `client_ip` shows `127.0.0.1` with a random port because you're curling from localhost. On a remote server, this would show the actual client IP. Remember this. It changes when we add Nginx.

## Step 2: Try Port 80 (The Permission Trap)

**What:** Bind the Go server directly to port 80 so users don't need `:8080` in the URL.

**Why:** This is what every beginner tries first. Ports below 1024 are privileged on Linux, and only root can bind to them. Your Go binary running as a normal user will crash.

Change the listen address:

Two lines change, both of them the port:

@@ -35,6 +35,6 @@
 		fmt.Fprintln(w, "ok")
 	})
 
-	log.Println("starting server on :8080")
-	log.Fatal(http.ListenAndServe(":8080", nil))
+	log.Println("starting server on :80")
+	log.Fatal(http.ListenAndServe(":80", nil))
 }
```

The full listing, for reference:

**`main.go` (updated):**
```go
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"
)

type DeployStatus struct {
	Service   string `json:"service"`
	Version   string `json:"version"`
	Status    string `json:"status"`
	Timestamp string `json:"timestamp"`
	ClientIP  string `json:"client_ip"`
}

func main() {
	http.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
		status := DeployStatus{
			Service:   "auth-api",
			Version:   "v1.4.2",
			Status:    "healthy",
			Timestamp: time.Now().Format(time.RFC3339),
			ClientIP:  r.RemoteAddr,
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(status)
	})

	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		fmt.Fprintln(w, "ok")
	})

	log.Println("starting server on :80")
	log.Fatal(http.ListenAndServe(":80", nil))
}
```

Run it:

```text
$ go run main.go
2026/02/15 14:30:00 starting server on :80
2026/02/15 14:30:00 listen tcp :80: bind: permission denied
exit status 1
```

Permission denied. You could fix this with `sudo go run main.go` or `setcap 'cap_net_bind_service=+ep'` on the binary, but both are bad ideas. Running your application as root means a vulnerability in your code gives an attacker root access. Using `setcap` is fragile because you have to reapply it after every build.

The real fix: keep Go on a high port (8080) and put Nginx in front on port 80. Nginx is designed to run as root for port binding and then drop privileges to a `www-data` worker. Your Go app stays unprivileged.

Change it back to `:8080` before continuing:

The revert is the same two lines:

```diff
# main.go
@@ -35,6 +35,6 @@
 		fmt.Fprintln(w, "ok")
 	})
 
-	log.Println("starting server on :80")
-	log.Fatal(http.ListenAndServe(":80", nil))
+	log.Println("starting server on :8080")
+	log.Fatal(http.ListenAndServe(":8080", nil))
 }
```

The full listing, for reference:

**`main.go` (updated):**
```go
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"
)

type DeployStatus struct {
	Service   string `json:"service"`
	Version   string `json:"version"`
	Status    string `json:"status"`
	Timestamp string `json:"timestamp"`
	ClientIP  string `json:"client_ip"`
}

func main() {
	http.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
		status := DeployStatus{
			Service:   "auth-api",
			Version:   "v1.4.2",
			Status:    "healthy",
			Timestamp: time.Now().Format(time.RFC3339),
			ClientIP:  r.RemoteAddr,
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(status)
	})

	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		fmt.Fprintln(w, "ok")
	})

	log.Println("starting server on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
```

## Step 3: Add Nginx as a Reverse Proxy (And Get a 502)

**What:** Configure Nginx to listen on port 80 and forward requests to Go on port 8080.

**Why:** This is the standard production pattern. But if you configure Nginx before starting Go, you'll get a 502 Bad Gateway. Nginx can't connect to a backend that isn't running.

Create the Nginx config:

**`/etc/nginx/sites-available/go-api`**
```nginx
server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://127.0.0.1:8080;
    }
}
```

Enable it and reload Nginx:

```bash
sudo ln -sf /etc/nginx/sites-available/go-api /etc/nginx/sites-enabled/go-api
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
```

Now test it **without** starting the Go server:

```bash
curl http://localhost/api/status
```

**Expected output:**
```html
<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<center>nginx/1.24.0</center>
</body>
</html>
```

502 Bad Gateway. Nginx tried to connect to `127.0.0.1:8080` and nobody was listening. This is the most common Nginx error in production. It means your backend is down, crashed, or hasn't started yet.

Now start the Go server in a second terminal:

```bash
go run main.go
```

And test again:

```bash
curl http://localhost/api/status
```

**Expected output:**
```json
{
  "service": "auth-api",
  "version": "v1.4.2",
  "status": "healthy",
  "timestamp": "2026-02-15T14:35:00Z",
  "client_ip": "127.0.0.1:54322"
}
```

It works! But look at `client_ip`: it's `127.0.0.1`. Even if you curl from a different machine, the IP will always be `127.0.0.1` because Nginx is making the connection to Go, not the actual client. Every request looks like it comes from localhost.

This breaks logging, rate limiting, geolocation, and any feature that depends on knowing who the real client is.

## Step 4: Fix Client IP Forwarding

**What:** Pass the real client IP through Nginx to Go using the `X-Forwarded-For` header.

**Why:** Without this, every request to your Go app looks like it came from `127.0.0.1`. You can't do rate limiting per user, you can't log real IPs, and your abuse detection is blind.

Update the Nginx config:

**`/etc/nginx/sites-available/go-api` (updated):**
```nginx
server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://127.0.0.1:8080;

        # Forward real client info
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

Reload Nginx:

```bash
sudo nginx -t && sudo systemctl reload nginx
```

Now update Go to read the forwarded IP:

The change is two new imports, a realIP helper, and one field assignment:

```diff
# main.go
@@ -4,7 +4,9 @@
 	"encoding/json"
 	"fmt"
 	"log"
+	"net"
 	"net/http"
+	"strings"
 	"time"
 )
 
@@ -16,6 +18,26 @@
 	ClientIP  string `json:"client_ip"`
 }
 
+// realIP extracts the client's real IP from proxy headers.
+// It trusts X-Real-IP and X-Forwarded-For because Nginx sets them.
+func realIP(r *http.Request) string {
+	// X-Real-IP is set by Nginx to the direct client
+	if ip := r.Header.Get("X-Real-IP"); ip != "" {
+		return ip
+	}
+
+	// X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2
+	// The first one is the original client
+	if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
+		parts := strings.Split(forwarded, ",")
+		return strings.TrimSpace(parts[0])
+	}
+
+	// Fallback to direct connection IP (strips the port)
+	ip, _, _ := net.SplitHostPort(r.RemoteAddr)
+	return ip
+}
+
 func main() {
 	http.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
 		status := DeployStatus{
@@ -23,7 +45,7 @@
 			Version:   "v1.4.2",
 			Status:    "healthy",
 			Timestamp: time.Now().Format(time.RFC3339),
-			ClientIP:  r.RemoteAddr,
+			ClientIP:  realIP(r),
 		}
 
 		w.Header().Set("Content-Type", "application/json")
```

The full listing, for reference:

**`main.go` (updated):**
```go
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net"
	"net/http"
	"strings"
	"time"
)

type DeployStatus struct {
	Service   string `json:"service"`
	Version   string `json:"version"`
	Status    string `json:"status"`
	Timestamp string `json:"timestamp"`
	ClientIP  string `json:"client_ip"`
}

// realIP extracts the client's real IP from proxy headers.
// It trusts X-Real-IP and X-Forwarded-For because Nginx sets them.
func realIP(r *http.Request) string {
	// X-Real-IP is set by Nginx to the direct client
	if ip := r.Header.Get("X-Real-IP"); ip != "" {
		return ip
	}

	// X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2
	// The first one is the original client
	if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
		parts := strings.Split(forwarded, ",")
		return strings.TrimSpace(parts[0])
	}

	// Fallback to direct connection IP (strips the port)
	ip, _, _ := net.SplitHostPort(r.RemoteAddr)
	return ip
}

func main() {
	http.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
		status := DeployStatus{
			Service:   "auth-api",
			Version:   "v1.4.2",
			Status:    "healthy",
			Timestamp: time.Now().Format(time.RFC3339),
			ClientIP:  realIP(r),
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(status)
	})

	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		fmt.Fprintln(w, "ok")
	})

	log.Println("starting server on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
```

The `realIP` function checks `X-Real-IP` first (set by Nginx to the direct client), falls back to `X-Forwarded-For` (which can chain through multiple proxies, so take the first one), and finally falls back to `RemoteAddr` if neither header exists.

Restart Go and test:

```bash
go run main.go
```

```bash
curl http://localhost/api/status
```

**Expected output:**
```json
{
  "service": "auth-api",
  "version": "v1.4.2",
  "status": "healthy",
  "timestamp": "2026-02-15T14:40:00Z",
  "client_ip": "127.0.0.1"
}
```

From localhost the IP is still `127.0.0.1`. That's correct, it's your real IP. But now if you curl from a different machine, you'll see the actual remote IP instead of `127.0.0.1`. The difference is that Go is now reading the real IP from the header instead of the TCP connection.

There's a security subtlety worth understanding here. What if a malicious client sends a fake `X-Forwarded-For` header directly to Nginx? They could try to spoof their IP. Nginx's `$proxy_add_x_forwarded_for` appends to the existing header instead of replacing it, so if a client sends `X-Forwarded-For: 1.2.3.4`, the header becomes `1.2.3.4, REAL_IP`. The reason the spoof doesn't land is that our `realIP` function checks `X-Real-IP` first, and Nginx sets `X-Real-IP $remote_addr` — the genuine TCP-level client IP that the client can't forge. So the rule is: trust the value Nginx derives from `$remote_addr`, never the raw `X-Forwarded-For` the client supplied.

For defense in depth, it's still worth having Nginx overwrite `X-Forwarded-For` instead of appending, so any code that reads that header (now or later) also sees a trustworthy value:

**`/etc/nginx/sites-available/go-api` (updated):**
```nginx
server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://127.0.0.1:8080;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;   # overwrite, don't append
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

Changed `$proxy_add_x_forwarded_for` to `$remote_addr`. Now Nginx sets the header to the direct client's IP regardless of what the incoming request says. This is the right choice when Nginx is the only proxy in your stack. If you have multiple trusted proxies (like a CDN in front of Nginx), you'd keep `$proxy_add_x_forwarded_for` and adjust the trust logic.

```bash
sudo nginx -t && sudo systemctl reload nginx
```

## Step 5: Add Rate Limiting (And Hit the 127.0.0.1 Problem Again)

**What:** Add rate limiting in Nginx to prevent abuse.

**Why:** Without rate limiting, a single client can hammer your API with thousands of requests per second. Nginx can throttle this before requests even reach Go, saving your app from overload. But the default rate limiting key is `$remote_addr`, and if you're not careful, it limits the wrong thing.

First, let's do it the naive way:

**`/etc/nginx/sites-available/go-api` (updated, broken version):**
```nginx
limit_req_zone $remote_addr zone=api_limit:10m rate=5r/s;

server {
    listen 80;
    server_name _;

    location / {
        limit_req zone=api_limit burst=10 nodelay;

        proxy_pass http://127.0.0.1:8080;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

This config creates a rate limit zone called `api_limit`: 5 requests per second per IP, with a burst of 10. The zone uses 10MB of shared memory, enough for about 160,000 unique IPs.

Reload and hammer it:

```bash
sudo nginx -t && sudo systemctl reload nginx
```

```text
$ for i in $(seq 1 20); do
>   curl -s -o /dev/null -w "%{http_code} " http://localhost/api/status
> done
$ echo
200 200 200 200 200 200 200 200 200 200 200 503 503 503 503 503 503 503 503 503
```

About 11 succeed (the burst of 10 plus the one request being processed), then you get 503s. The exact count can vary by a request or two depending on timing — the key point is that roughly `burst + 1` get through before the limit kicks in. Because this loop fires all 20 requests in well under a second, almost no tokens are replenished (at 5r/s, that's one every 200ms), so you don't get the full rate's worth of extra successes. Rate limiting works. But here's the problem: this limits by `$remote_addr`, which is the IP of whoever connects to Nginx directly. On a single server, that's fine. But if you put a load balancer or CDN in front of Nginx, `$remote_addr` becomes the load balancer's IP, not the client's. All clients share one rate limit and hit the cap almost instantly.

The fix is to rate limit by the real client IP:

**`/etc/nginx/sites-available/go-api` (updated, fixed version):**
```nginx
# Use a map to get the real client IP for rate limiting
map $http_x_forwarded_for $rate_limit_key {
    default        $remote_addr;
    "~^(?P<ip>[^,]+)" $ip;
}

limit_req_zone $rate_limit_key zone=api_limit:10m rate=5r/s;

server {
    listen 80;
    server_name _;

    location / {
        limit_req zone=api_limit burst=10 nodelay;

        proxy_pass http://127.0.0.1:8080;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Custom error page for rate limiting
    error_page 503 @rate_limited;
    location @rate_limited {
        default_type application/json;
        return 429 '{"error": "rate limit exceeded", "retry_after": "1s"}';
    }
}
```

The `map` block extracts the first IP from `X-Forwarded-For` (if it exists) and uses it as the rate limit key. If there's no `X-Forwarded-For`, it falls back to `$remote_addr`. We also changed the 503 error to return a JSON 429 with a retry hint, which is much friendlier for API consumers.

```bash
sudo nginx -t && sudo systemctl reload nginx
```

Test the rate limit error:

```text
$ for i in $(seq 1 20); do
>   code=$(curl -s -o /tmp/resp -w "%{http_code}" http://localhost/api/status)
>   if [ "$code" != "200" ]; then
>     echo "Request $i: $code $(cat /tmp/resp)"
>     break
>   fi
> done
Request 12: 429 {"error": "rate limit exceeded", "retry_after": "1s"}
```

Now clients get a clear JSON error when they're rate limited, and the limiting is per real client IP even behind a proxy.

## Step 6: Handle Upstream Failures

**What:** Configure Nginx to handle Go app crashes gracefully instead of showing ugly 502 errors.

**Why:** During deploys, your Go app stops and restarts. Without proper Nginx config, users see 502 Bad Gateway for a few seconds. We can do better: serve a proper error response and add health checking.

Update the Nginx config:

**`/etc/nginx/sites-available/go-api` (updated):**
```nginx
map $http_x_forwarded_for $rate_limit_key {
    default        $remote_addr;
    "~^(?P<ip>[^,]+)" $ip;
}

limit_req_zone $rate_limit_key zone=api_limit:10m rate=5r/s;

upstream go_backend {
    server 127.0.0.1:8080 max_fails=3 fail_timeout=30s;
}

server {
    listen 80;
    server_name _;

    location / {
        limit_req zone=api_limit burst=10 nodelay;

        proxy_pass http://go_backend;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Timeouts — don't wait forever for a stuck backend
        proxy_connect_timeout 5s;
        proxy_send_timeout 10s;
        proxy_read_timeout 30s;

        # Buffer settings
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }

    # Health check endpoint — bypass rate limiting
    location /health {
        limit_req off;
        proxy_pass http://go_backend;
        proxy_set_header Host $host;
    }

    error_page 502 @backend_down;
    location @backend_down {
        default_type application/json;
        return 502 '{"error": "service temporarily unavailable", "retry_after": "5s"}';
    }

    error_page 503 @rate_limited;
    location @rate_limited {
        default_type application/json;
        return 429 '{"error": "rate limit exceeded", "retry_after": "1s"}';
    }
}
```

The `upstream` block with `max_fails=3 fail_timeout=30s` tells Nginx: if the backend fails 3 times, stop sending requests to it for 30 seconds before trying again. This prevents Nginx from hammering a crashed backend.

`proxy_connect_timeout 5s` means Nginx waits only 5 seconds to establish a connection. Without this, Nginx uses its default of 60 seconds, during which the client hangs with no response.

The `/health` endpoint bypasses rate limiting so your monitoring and load balancers can always check the service status.

```bash
sudo nginx -t && sudo systemctl reload nginx
```

Stop Go and test:

```bash
curl http://localhost/api/status
```

**Expected output:**
```json
{"error": "service temporarily unavailable", "retry_after": "5s"}
```

A clean JSON error instead of an HTML 502 page. Much better for API consumers.

## Step 7: Graceful Shutdown in Go

**What:** Make the Go server finish in-flight requests before stopping, so deploys don't drop connections.

**Why:** When you kill a Go server with `kill` or stop the systemd service, `http.ListenAndServe` terminates immediately. Any requests being processed get dropped mid-response. The client gets a broken connection. Combined with Nginx, this means users see 502s during every deploy.

Replace your entire `main.go`:

**`main.go` (updated):**
```go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"
)

type DeployStatus struct {
	Service   string `json:"service"`
	Version   string `json:"version"`
	Status    string `json:"status"`
	Timestamp string `json:"timestamp"`
	ClientIP  string `json:"client_ip"`
}

func realIP(r *http.Request) string {
	if ip := r.Header.Get("X-Real-IP"); ip != "" {
		return ip
	}

	if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
		parts := strings.Split(forwarded, ",")
		return strings.TrimSpace(parts[0])
	}

	ip, _, _ := net.SplitHostPort(r.RemoteAddr)
	return ip
}

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
		status := DeployStatus{
			Service:   "auth-api",
			Version:   "v1.4.2",
			Status:    "healthy",
			Timestamp: time.Now().Format(time.RFC3339),
			ClientIP:  realIP(r),
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(status)
	})

	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		fmt.Fprintln(w, "ok")
	})

	server := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 30 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	// Start server in a goroutine
	go func() {
		log.Println("starting server on :8080")
		if err := server.ListenAndServe(); err != http.ErrServerClosed {
			log.Fatal("server error:", err)
		}
	}()

	// Wait for SIGINT or SIGTERM
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	sig := <-quit
	log.Printf("received %s, shutting down...", sig)

	// Give in-flight requests 15 seconds to finish
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	if err := server.Shutdown(ctx); err != nil {
		log.Printf("forced shutdown: %v", err)
	}

	log.Println("server stopped")
}
```

The key change is `server.Shutdown(ctx)`. Instead of killing the server instantly, it:

1. Stops accepting new connections
2. Waits for in-flight requests to finish (up to 15 seconds)
3. Then exits cleanly

We also switched from `http.HandleFunc` (default mux) to an explicit `http.NewServeMux` and `http.Server`. This gives us control over timeouts. `ReadTimeout` prevents slowloris attacks where a client sends headers very slowly to tie up your server, `WriteTimeout` prevents a stuck handler from holding a connection forever, and `IdleTimeout` closes keep-alive connections that have been idle too long.

Run it:

```bash
go run main.go
```

In another terminal, start a slow request and then kill the server:

```bash
# Terminal 1: Start the server
go run main.go

# Terminal 2: Hit the server while sending SIGTERM
curl http://localhost:8080/api/status &
kill -SIGTERM $(pgrep -f "go-nginx-api")
```

**Expected output (Terminal 1):**
```
2026/02/15 14:50:00 starting server on :8080
2026/02/15 14:50:05 received terminated, shutting down...
2026/02/15 14:50:05 server stopped
```

The server waits for the curl request to complete before exiting. No dropped connections.

## Step 8: Wire It Up With systemd

**What:** Run the Go API as a systemd service so it starts on boot, restarts on crash, and integrates with Nginx.

**Why:** Running `go run main.go` in a terminal is fine for development. In production, you need the process managed properly: auto-restart on crash, start on boot, and proper logging to journald.

Build the binary first:

```bash
go build -o go-nginx-api main.go
sudo cp go-nginx-api /usr/local/bin/
```

Create the systemd service:

**`/etc/systemd/system/go-nginx-api.service`**
```ini
[Unit]
Description=Go Nginx API
After=network.target
Wants=nginx.service

[Service]
Type=simple
User=www-data
Group=www-data
ExecStart=/usr/local/bin/go-nginx-api
Restart=on-failure
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=20

# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target
```

Key settings:

- `User=www-data`: runs as the same unprivileged user as Nginx workers, not root
- `KillSignal=SIGTERM`: sends SIGTERM on stop, which triggers our graceful shutdown
- `TimeoutStopSec=20`: waits 20 seconds for graceful shutdown before force-killing (longer than our 15-second context timeout in Go)
- `Restart=on-failure` with `RestartSec=5`: if Go crashes, wait 5 seconds and restart
- Security directives (`NoNewPrivileges`, `ProtectSystem`, `ProtectHome`) lock down what the process can do

Start it:

```bash
sudo systemctl daemon-reload
sudo systemctl enable go-nginx-api
sudo systemctl start go-nginx-api
```

Verify:

```text
$ sudo systemctl status go-nginx-api
$ curl http://localhost/api/status
● go-nginx-api.service - Go Nginx API
     Loaded: loaded (/etc/systemd/system/go-nginx-api.service; enabled)
     Active: active (running) since Sat 2026-02-15 14:55:00 UTC
   Main PID: 12345 (go-nginx-api)
```

```json
{
  "service": "auth-api",
  "version": "v1.4.2",
  "status": "healthy",
  "timestamp": "2026-02-15T14:55:30Z",
  "client_ip": "203.0.113.42"
}
```

Now try killing it:

```bash
sudo systemctl stop go-nginx-api
sudo systemctl status go-nginx-api
```

The service stops cleanly via SIGTERM, Go finishes in-flight requests, then exits. And if you `start` it again, everything comes back. Nginx detects the backend is healthy and starts proxying.

## Deploy Workflow

With everything wired up, deploying a new version looks like this:

```bash
# Build new binary
go build -o go-nginx-api main.go

# Copy and restart (brief downtime — a few seconds)
sudo cp go-nginx-api /usr/local/bin/
sudo systemctl restart go-nginx-api
```

Nginx handles the brief gap by showing the "service temporarily unavailable" JSON response for the 1-2 seconds between stop and start. For zero-downtime deploys, you'd run two instances on different ports and use Nginx upstream with multiple backends.

## What We Built

Starting from a bare Go HTTP server, we incrementally built:

1. **Basic JSON API:** the Go "hello world" for HTTP
2. **The port 80 trap:** why running as root is wrong and reverse proxies are right
3. **Nginx reverse proxy:** forward traffic from port 80 to Go on 8080
4. **Client IP forwarding:** `X-Real-IP` and `X-Forwarded-For` headers with spoofing protection
5. **Rate limiting:** per-client throttling with JSON error responses
6. **Upstream failure handling:** clean errors when the backend is down, with health check bypass
7. **Graceful shutdown:** finish in-flight requests before stopping
8. **systemd integration:** auto-restart, boot start, security hardening

Every Go API in production uses this stack. The details change (TLS termination, multiple upstreams, WebSocket proxying), but the core pattern is always the same: Go on a high port, Nginx on port 80/443, proxy headers forwarded, graceful shutdown on SIGTERM.

## Cheat Sheet

Copy-paste reference for Go behind Nginx.

**Minimal Nginx reverse proxy:**
```nginx
server {
    listen 80;
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

**Read real client IP in Go:**
```go
func realIP(r *http.Request) string {
    if ip := r.Header.Get("X-Real-IP"); ip != "" {
        return ip
    }
    ip, _, _ := net.SplitHostPort(r.RemoteAddr)
    return ip
}
```

**Nginx rate limiting:**
```nginx
limit_req_zone $remote_addr zone=api:10m rate=5r/s;
location / {
    limit_req zone=api burst=10 nodelay;
    proxy_pass http://127.0.0.1:8080;
}
```

**Go graceful shutdown:**
```go
server := &http.Server{Addr: ":8080", Handler: mux}
go func() { server.ListenAndServe() }()

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
server.Shutdown(ctx)
```

**systemd service essentials:**
```ini
[Service]
ExecStart=/usr/local/bin/your-binary
KillSignal=SIGTERM
TimeoutStopSec=20
Restart=on-failure
User=www-data
```

**Key rules to remember:**
- Never bind Go to port 80 directly. Use Nginx as the reverse proxy
- Always set `proxy_set_header X-Real-IP $remote_addr`. Without it, every request looks like `127.0.0.1`
- Use `$remote_addr` instead of `$proxy_add_x_forwarded_for` when Nginx is the only proxy. This prevents IP spoofing
- Set `proxy_connect_timeout` lower than the default 60s. 5s is usually enough for local backends
- Rate limit by `$remote_addr` for simple setups, by `X-Forwarded-For` when behind a load balancer
- `server.Shutdown()` finishes in-flight requests. Match `TimeoutStopSec` in systemd to be longer than Go's shutdown timeout
- Bypass rate limiting on `/health` so monitoring always works
- `ReadTimeout`, `WriteTimeout`, and `IdleTimeout` on the Go server prevent resource exhaustion

**Question:** What's your Go + Nginx setup look like in production? Running multiple upstream backends, using TLS termination, or something else entirely?
