How to Set Up Nginx for a Go Application

How to Set Up Nginx for a Go Application

How to Set Up Nginx for a Go Application

Go’s standard library makes it trivial to spin up a fast, production-capable HTTP server with nothing but net/http. So it’s fair to ask why you’d bother putting Nginx in front of it at all. In practice, I still do this for almost every Go web app I deploy, and in this guide I’ll explain exactly why, then walk through the full setup — from running your Go binary as a proper service to configuring Nginx as a reverse proxy with TLS, static file offloading, and the header handling your Go app needs to see real client information.

Why Put Nginx in Front of a Go App

Go’s HTTP server is genuinely fast and can handle TLS directly with just a few lines of code. But there are still solid reasons to run Nginx in front of it in production:

Requirements

Building and Deploying Your Go Binary

Go compiles to a single static binary, which makes deployment refreshingly simple compared to runtimes that need an interpreter or separate framework installed on the server.

Build for Linux (even if you’re developing on macOS or Windows, cross-compilation is built in):

GOOS=linux GOARCH=amd64 go build -o myapp ./cmd/server

Copy it to your server:

scp ./myapp user@yourserver:/opt/myapp/myapp

Make sure it’s executable:

chmod +x /opt/myapp/myapp

Test it runs directly before wiring up Nginx:

cd /opt/myapp
./myapp

By convention, most Go web apps read a PORT environment variable or have it hardcoded — check your app’s startup code or flags. For this guide, I’ll assume it listens on 127.0.0.1:8080.

Running Your Go App as a systemd Service

Same principle as any other backend service — you want it running persistently, restarting on crash, and starting automatically on boot.

sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=My Go Application
After=network.target

[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/myapp
Restart=always
RestartSec=5
Environment=PORT=8080
Environment=GIN_MODE=release
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

# Basic hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/myapp/data

[Install]
WantedBy=multi-user.target

I included a few systemd hardening options (NoNewPrivileges, PrivateTmp, ProtectSystem) since Go binaries are frequently deployed this way in minimal server environments, and it costs nothing to add basic sandboxing. Adjust ReadWritePaths to match whatever directory your app actually needs to write to (logs, uploaded files, a local database file, etc.) — ProtectSystem=strict makes the rest of the filesystem read-only to the process.

Create a dedicated user if you haven’t already:

sudo useradd -r -s /usr/sbin/nologin appuser
sudo chown -R appuser:appuser /opt/myapp

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
sudo systemctl status myapp

Watch logs:

sudo journalctl -u myapp -f

Configuring Nginx as the Reverse Proxy

Basic proxy configuration:

server {
    listen 80;
    server_name example.com;

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

        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;
    }
}

Reading Forwarded Headers Correctly in Go

Just like any language, your Go app needs to be told to trust and parse the X-Forwarded-For and X-Forwarded-Proto headers rather than relying on r.RemoteAddr, which — once Nginx is in the picture — will always just report Nginx’s own address (typically 127.0.0.1), not the real client.

If you’re using the standard library directly, a small middleware handles this:

package main

import (
	"net"
	"net/http"
	"strings"
)

func realIPMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
			// X-Forwarded-For can be a comma-separated list;
			// the original client is the first entry.
			ip := strings.TrimSpace(strings.Split(xff, ",")[0])
			r.RemoteAddr = net.JoinHostPort(ip, "0")
		}
		next.ServeHTTP(w, r)
	})
}

Important caveat: only trust X-Forwarded-For when the request is actually coming from Nginx (i.e., your Go app should bind to 127.0.0.1 only, so it’s unreachable directly — see below). If your Go app were exposed directly to the internet, blindly trusting this header would let anyone spoof their apparent IP.

If you’re using a framework like Gin, it has this built in:

router := gin.Default()
router.SetTrustedProxies([]string{"127.0.0.1"})
// router.Context.ClientIP() now correctly reflects the real client

Echo has similar middleware available via middleware.ProxyHeaders or by reading echo.Context.RealIP(), which respects X-Forwarded-For and X-Real-IP out of the box.

Binding Your Go App to Localhost Only

Make sure your Go app listens only on 127.0.0.1, not 0.0.0.0, so it can’t be reached directly, bypassing Nginx entirely:

http.ListenAndServe("127.0.0.1:8080", router)

This is a small but important detail — if your app listens on all interfaces and your firewall isn’t locked down correctly, someone could hit port 8080 directly and skip your Nginx-layer protections (rate limiting, IP restriction, TLS) entirely.

Serving Static Files Directly Through Nginx

If your Go app embeds and serves its own static assets (common with embed.FS in modern Go), you can still let Nginx handle them more efficiently for production traffic by pointing directly at the same files on disk:

server {
    listen 80;
    server_name example.com;

    location /static/ {
        alias /opt/myapp/static/;
        expires 30d;
        add_header Cache-Control "public";
    }

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        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;
    }
}

Complete Example Configuration

A fuller production setup with HTTPS, static file offloading, gzip, and support for streaming/long-lived connections (common in Go apps using Server-Sent Events or long-polling):

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    gzip on;
    gzip_types text/css application/javascript application/json;
    gzip_min_length 1024;

    client_max_body_size 10M;

    location /static/ {
        alias /opt/myapp/static/;
        expires 30d;
        add_header Cache-Control "public";
    }

    location /events/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        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;

        # Important for Server-Sent Events: disable buffering
        # so events reach the client immediately.
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        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;
    }
}

Testing Your Configuration

Validate and reload Nginx:

sudo nginx -t
sudo systemctl reload nginx

Check the Go app directly first:

curl -I http://127.0.0.1:8080

Then through Nginx:

curl -I https://example.com

Verify forwarded headers are correctly received by adding a temporary debug route in your Go app:

mux.HandleFunc("/debug-headers", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "RemoteAddr: %s\nX-Forwarded-For: %s\nX-Forwarded-Proto: %s\n",
		r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Forwarded-Proto"))
})
curl https://example.com/debug-headers

You should see your real public IP in X-Forwarded-For, and https in X-Forwarded-Proto.

Troubleshooting Common Issues

502 Bad Gateway. Confirm the Go binary is actually running (systemctl status myapp) and listening on the port Nginx is configured to proxy to. Check journalctl -u myapp for a panic or bind error — a common cause is the port already being in use by a leftover process from a previous deployment.

Go app crashes and doesn’t restart. Confirm Restart=always is set in your systemd unit, and check RestartSec isn’t so aggressive it’s causing a restart loop that systemd eventually gives up on (systemctl status will show “start-limit-hit” in that case — increase StartLimitIntervalSec or fix the underlying crash).

Real client IP shows as 127.0.0.1 in application logs. Your app isn’t parsing X-Forwarded-For, or you’re using a framework’s built-in IP detection without configuring trusted proxies. Revisit the middleware/framework configuration section above.

Server-Sent Events or streaming responses feel delayed or arrive in bursts. This is Nginx’s default response buffering interfering with real-time delivery. Add proxy_buffering off; to that specific location block, as shown in the example above.

413 Request Entity Too Large. Increase client_max_body_size to match your application’s actual upload requirements.

Systemd hardening options prevent the app from writing files it needs. If you see permission errors despite correct file ownership, double check ReadWritePaths includes every directory your app actually writes to, since ProtectSystem=strict locks down everything else.

Security Considerations

Performance Tips

Real-World Use Cases

Best Practices

Go and Nginx are a genuinely low-friction combination — the binary deployment model keeps the application side simple, and Nginx fills in exactly the operational concerns (TLS, static files, buffering, multiple services on one host) that you’d otherwise have to build into every Go binary yourself. Once it’s set up once, replicating the pattern for additional services is mostly copy, paste, and adjust the port number.

Exit mobile version