Skip to content
CloudOps
Newsletter
All guides
AI for NGINX By James Joyner IV · · 11 min read

AI-Assisted NGINX Reverse Proxy for Microservices

Route many backend services behind one NGINX with AI: upstream blocks, proxy_set_header, WebSocket upgrades, and the trailing-slash proxy_pass footgun.

  • #nginx
  • #ai
  • #reverse-proxy
  • #microservices

A teammate pinged me at 11pm last week: the new checkout service was returning 404 for half its routes, and the WebSocket-backed order tracker dropped every connection after 60 seconds. The NGINX config in front of four services had grown organically, and nobody could say with confidence which location block matched what. I pasted the whole thing into an AI assistant, asked it to trace the routing for one specific URL, and within a minute had a clear answer: a trailing slash on one proxy_pass was rewriting the path and eating the API prefix. The fix was two characters. The lesson is the one I keep relearning — AI is excellent at decoding and drafting NGINX config, but you validate with nginx -t and you stay the one who hits reload.

This post walks through building a reverse proxy for a small microservices app the way I actually do it now: let the AI draft the boilerplate, then read every line yourself because the footguns live in the details.

The shape of the problem

You have several services — say an api (HTTP), a web frontend (HTTP), and a realtime service (WebSocket) — each listening on its own port on localhost or inside a container network. You want one public NGINX terminating TLS and routing to all of them. There are two routing strategies, and AI tends to default to whichever you mention first, so be explicit:

  • Host-based: api.example.com, app.example.com each get their own server block. Clean separation, but you manage more DNS and certs.
  • Path-based: everything under example.com, routed by URL prefix (/api/, /ws/). One cert, but the path-rewriting rules are where people get hurt.

I’ll show path-based here because that’s where the trailing-slash trap lives.

Drafting upstreams with AI

Start with upstream blocks. They name your backends so the rest of the config reads cleanly and you can add load balancing later without touching every proxy_pass. Here’s a prompt that gets a good first draft:

Draft NGINX upstream blocks for three local services: an api on 127.0.0.1:8080, a web frontend on 127.0.0.1:3000, and a realtime websocket service on 127.0.0.1:9090. Add keepalive connection reuse to each. Output only the config.

That produces something like this, which I then tighten by hand:

upstream api_backend {
    server 127.0.0.1:8080;
    keepalive 32;
}

upstream web_backend {
    server 127.0.0.1:3000;
    keepalive 16;
}

upstream realtime_backend {
    server 127.0.0.1:9090;
    keepalive 16;
}

keepalive matters: without it, NGINX opens a fresh TCP connection to the backend for every request. To actually use the pooled connections you must also set proxy_http_version 1.1 and clear the Connection header in the location — the AI will forget this unless you ask, so ask.

The WebSocket upgrade map

WebSockets were why the order tracker kept dying. An upgrade request carries Connection: Upgrade and Upgrade: websocket, and NGINX needs to forward both verbatim. The idiomatic pattern is a map at the http level that picks the right Connection value depending on whether the client actually requested an upgrade:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

This is one of those snippets AI reproduces correctly because it’s so standard — but understand what it does. When $http_upgrade is empty (a normal HTTP request), Connection becomes close; when a client requests a WebSocket, it becomes upgrade. You reference $connection_upgrade in the realtime location below.

The server block and the headers that matter

Here’s the full server block. Read the comments — every proxy_set_header is there for a reason, and dropping any one of them causes a real, debuggable production failure.

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/ssl/certs/example.com.pem;
    ssl_certificate_key /etc/ssl/private/example.com.key;

    # Frontend: everything not matched below
    location / {
        proxy_pass http://web_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        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;
    }

    # API: note NO trailing slash on proxy_pass (see below)
    location /api/ {
        proxy_pass http://api_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        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;
    }

    # WebSocket realtime service
    location /ws/ {
        proxy_pass http://realtime_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        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;
        proxy_read_timeout 3600s;
    }
}

What each header buys you:

  • Host $host — passes the original hostname so the backend can do virtual-host routing and build correct absolute URLs. Without it, the backend sees the upstream name and your redirects break.
  • X-Real-IP $remote_addr — the immediate client IP. Your app logs and rate limiters depend on this; otherwise every request looks like it came from 127.0.0.1.
  • X-Forwarded-For $proxy_add_x_forwarded_for — appends the client IP to any existing chain, preserving the full proxy path. Don’t hardcode $remote_addr here; the $proxy_add_* variable does the appending correctly.
  • X-Forwarded-Proto $scheme — tells the backend the original request was HTTPS even though NGINX talks to it over plain HTTP. Skip this and your app generates http:// redirects that cause infinite loops or mixed-content warnings.

For the realtime location, proxy_read_timeout 3600s is what fixes the 60-second disconnect — NGINX’s default read timeout closes idle WebSocket connections. That was the second half of my 11pm bug.

The trailing-slash footgun

This is the one that cost us the 404s, and it’s worth burning into memory. The presence or absence of a trailing slash on proxy_pass changes how NGINX rewrites the path:

# Request: GET /api/users
location /api/ {
    proxy_pass http://api_backend;     # backend receives /api/users
}

location /api/ {
    proxy_pass http://api_backend/;    # backend receives /users  (prefix stripped!)
}

With a trailing slash (or any URI path) on proxy_pass, NGINX strips the matched location prefix and replaces it. Without one, it passes the full original URI through. Neither is wrong — it depends on whether your backend expects to see /api/ or not. The bug is using the version that doesn’t match your backend’s routes. When I ask AI to generate these, I always add: “state explicitly what path the backend will receive for a request to /api/users.” That one sentence turns a silent footgun into a reviewable claim.

This is also exactly the kind of subtle config logic worth a second pass — pairing this with the approach in reviewing NGINX security configuration with AI catches both the routing mistakes and the headers you forgot to lock down.

Validate before you reload

Never reload on faith. The whole point of keeping a human in the loop is this step:

# Check syntax and that referenced files exist
sudo nginx -t

# Expected:
# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
# nginx: configuration file /etc/nginx/nginx.conf test is successful

# Only then, gracefully reload without dropping connections
sudo nginx -s reload

nginx -t catches typos, missing semicolons, and bad cert paths — but it does not catch a wrong trailing slash or a missing X-Forwarded-Proto, because those are syntactically valid. So after reload, I curl the actual routes:

curl -i https://example.com/api/users
curl -i -H "Connection: Upgrade" -H "Upgrade: websocket" https://example.com/ws/

If the backend logs show the path you expected and the WebSocket returns 101 Switching Protocols, you’re done.

Where AI fits, and where it doesn’t

AI drafted my upstreams, reproduced the WebSocket map perfectly, and traced the trailing-slash bug faster than I could have grepping by hand. What it did not do was decide my routing strategy, know which path my backend expects, or take responsibility for the reload. Treat its output as a strong first draft from a knowledgeable colleague who has never seen your actual backend — useful, fast, and occasionally confidently wrong.

If you want more NGINX-specific patterns, the AI for NGINX category collects them, and the reverse-proxy and header-tracing prompts I lean on live in the prompt library. Draft with AI, validate with nginx -t, reload on your own terms.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.