How a Variable in nginx.conf Silently Stripped All My API Paths (and How Claude Code Caught It)
I set up Docker deployment for a FastAPI + React app and every API call returned 404. The routes existed, the image was current — the bug was one word in nginx.conf.
I was containerizing a FastAPI + React app — nginx serving the frontend SPA, with /api/* proxied to the backend FastAPI container. Wrote a multi-stage Dockerfile, wired up a docker-compose stack, deployed it. The app loaded. Every API call came back 404.
The Proxy Setup That Looked Fine
The frontend container is nginx serving a static React build. It needs to proxy /api/* to the backend FastAPI container (backend:8000 in the compose network).
There's a well-known nginx gotcha with Docker: if you write proxy_pass http://backend:8000/api/; as a literal, nginx tries to resolve backend at startup. If the backend container isn't up yet, nginx refuses to start entirely — "host not found in upstream." The standard fix is to use a variable for the upstream host, combined with Docker's embedded resolver:
# frontend/nginx.conf
resolver 127.0.0.11 valid=10s;
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
set $backend_upstream backend:8000;
proxy_pass http://$backend_upstream/api/;
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;
}
}
127.0.0.11 is Docker's embedded DNS. The variable $backend_upstream defers resolution to request time, so nginx starts cleanly even if the backend container isn't ready yet. This is exactly what you find in every "nginx + docker-compose" tutorial.
Nginx started. Backend started — alembic ran migrations, uvicorn came up clean, "Application startup complete." Everything looked right.
Every API Route Came Back 404
The app's frontend loaded fine from /. As soon as anything tried to call /api/admin/courses, 404. Same for every other endpoint. The nginx access log showed them arriving: GET /api/admin/courses HTTP/1.1" 404 22. The 22-byte body is {"detail":"Not Found"} — FastAPI's default 404, not nginx's own error page.
First thought: wrong image, missing route. Claude Code exec'd into the running backend container and listed the registered routes:
docker compose exec backend python -c "
from app.main import app
print([r.path for r in app.routes if 'course' in r.path])
"
Output:
['/api/admin/courses', '/api/admin/courses', '/api/admin/courses/{course_id}', ...]
Route is there. Image is current. So what's happening?
Two Log Lines That Didn't Match
The clue was in comparing the two logs side by side:
nginx access log:
140.207.158.106 - - "GET /api/admin/courses HTTP/1.1" 404 22
uvicorn log (backend):
INFO: 172.19.0.3:44424 - "GET /api/ HTTP/1.1" 404 Not Found
Same request — nginx logged the original path /api/admin/courses, but the backend only received /api/. Everything after /api/ was gone.

The nginx Variable Trap
Here's what's actually happening. When proxy_pass is specified with a literal upstream and a URI component, nginx does prefix replacement: it strips the location-matched part (/api/) from the request URI and replaces it with the proxy_pass URI component. For location /api/ → proxy_pass http://backend:8000/api/, request /api/admin/courses becomes /api/admin/courses on the upstream — the /api/ cancels out and the path is preserved.
But when proxy_pass contains a variable, nginx can't compute that substitution at config-parse time. So it falls back to a different behavior: it uses the URI from the proxy_pass directive literally, ignoring the original request path entirely.
proxy_pass http://$backend_upstream/api/ with $backend_upstream = backend:8000 resolves to http://backend:8000/api/ — and that literal /api/ is what gets sent to the upstream, for every request, regardless of what path the browser actually asked for.
Warning
Using a variable anywhere in proxy_pass (even just for the host) disables nginx's normal URI prefix-replacement behavior — the path in your proxy_pass directive becomes the only path the upstream ever sees.
The Fix
One word: replace the literal /api/ with $request_uri.
location /api/ {
set $backend_upstream backend:8000;
- proxy_pass http://$backend_upstream/api/;
+ proxy_pass http://$backend_upstream$request_uri;
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;
}
$request_uri is the full original request URI — path plus query string — exactly as the browser sent it. Since the backend routes are already prefixed with /api/ (FastAPI registers them as /api/admin/courses, not /admin/courses), passing $request_uri through unchanged is exactly what's needed.
After rebuilding the frontend image and redeploying, GET /api/admin/courses hit the backend as /api/admin/courses and returned data.

FAQ
Why use a variable at all? Can I just use a literal proxy_pass?
If you write proxy_pass http://backend:8000/api/; as a literal in nginx.conf, nginx resolves backend at startup using the system DNS. Inside a docker-compose network that uses Docker's embedded DNS (127.0.0.11), the name backend is only resolvable while the backend container is running and on the same network. If nginx starts first — or if you ever run the frontend container standalone for testing — nginx fails immediately with "host not found in upstream." The variable approach defers resolution to request time, which is more resilient.
Is $request_uri the same as $uri?
Not quite. $request_uri is the raw original URI including query string, unchanged from what the client sent. $uri is the normalized (decoded, ./.. resolved) path without query string — you'd need $uri$is_args$args to get the equivalent. For proxying to an API backend, $request_uri is usually the right choice: it preserves the exact query string and avoids any normalization that might change how the backend parses the request.
Does this only affect nginx inside Docker?
No — this is a general nginx behavior. The variable in proxy_pass disables URI substitution regardless of whether Docker is involved. It just shows up most often in Docker setups because the "use a variable to defer DNS resolution" pattern is the standard workaround for container startup ordering, and the path-stripping side effect isn't mentioned in most tutorials that recommend it.
Bottom Line
The "use a variable in proxy_pass to avoid nginx startup failures with Docker" trick is everywhere — and none of those tutorials mention that it silently disables path forwarding at the same time. The tell is two log lines that show different paths for the same request. The fix is $request_uri. One word, twenty minutes of debugging to find it.