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:
- Centralized TLS management — one place to manage certificates (especially with Let’s Encrypt automation) across multiple Go services, rather than wiring cert reloading logic into every binary.
- Multiple services, one IP — Nginx can route different domains or paths to different Go binaries running on different local ports.
- Protection from slow clients — Nginx buffers slow or malicious connections so they don’t tie up your Go server’s goroutines unnecessarily.
- Static file serving — offloading static assets to Nginx keeps your Go binary focused purely on application logic.
- Operational familiarity — logging, rate limiting, and access control are often easier to manage consistently at the Nginx layer across a fleet of different backend languages/frameworks.
Requirements
- Go installed on your build machine or CI system (
go versionto confirm), and your application built as a static binary. - Nginx installed on the server (
nginx -v). - Root/sudo access for systemd and Nginx configuration.
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
- Bind your Go application to
127.0.0.1only — never0.0.0.0— when Nginx is meant to be the sole entry point. - Only trust
X-Forwarded-ForandX-Forwarded-Protoheaders when you’re certain the request is genuinely coming through Nginx; never trust them on a directly internet-facing service. - Run the Go binary under a dedicated, unprivileged systemd user, and use systemd’s sandboxing directives (
ProtectSystem,NoNewPrivileges,PrivateTmp) as shown above. - Keep Go itself updated — security patches to the standard library’s
net/httpand TLS packages do occasionally matter, even for compiled binaries, since you need to rebuild with the patched toolchain to pick them up. - Set
client_max_body_sizedeliberately rather than leaving an overly generous default, especially on public-facing upload endpoints.
Performance Tips
- Let Nginx handle static file serving and gzip compression, keeping your Go binary focused purely on application logic.
- Disable
proxy_bufferingspecifically for streaming, SSE, or long-polling endpoints, while leaving it enabled (the default) for normal request/response traffic where buffering actually helps performance. - Go’s own HTTP server is highly concurrent by default (each request runs in its own goroutine), so you generally don’t need aggressive Nginx-side connection tuning purely for the Go app’s sake — but do size
worker_connectionsappropriately if you’re handling a large number of concurrent long-lived connections. - If running multiple Go instances for redundancy or to use multiple CPU cores across processes, use an
upstreamblock for simple round-robin or least-connections load balancing.
Real-World Use Cases
- REST and gRPC-Gateway APIs — Go’s strong concurrency model makes it a common choice for API backends, with Nginx handling TLS and rate limiting in front.
- CLI tool backends and webhooks receivers — small, focused Go services that benefit from Nginx’s IP restriction and logging.
- Real-time services using Server-Sent Events or WebSockets — Go’s goroutine model handles many concurrent long-lived connections efficiently, with Nginx correctly configured to avoid buffering interference.
- Internal microservices — multiple small Go binaries behind a single Nginx instance, each routed by path or subdomain.
- High-throughput data ingestion endpoints — Go’s performance characteristics pair well with Nginx’s efficient connection handling for high request-volume scenarios.
Best Practices
- Bind Go apps to localhost only, treating Nginx as the sole public-facing entry point.
- Always implement forwarded-header parsing in your app (or configure your framework’s trusted proxy settings) — don’t assume
r.RemoteAddrreflects the real client once Nginx is in front. - Run under systemd with
Restart=alwaysand reasonable sandboxing directives. - Offload static files and compression to Nginx.
- Disable proxy buffering specifically where streaming responses are involved, rather than globally.
- Keep your deployment process (build,
scp, restart service) scripted and repeatable rather than manual, since Go’s single-binary deployment model makes this especially easy to automate well.
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.