Nginx Reverse Proxy for Local Mock APIs
You need Nginx to do more than blindly forward /api/ to one mock port. Real local setups have to pick between a mock backend and a live staging API per request, re-resolve container hostnames that change IP on restart, forward the right client headers, and inject CORS without doubling it up. This page is a deep, single-file nginx.conf that does all of that using the directives most tutorials skip: map, resolver, and disciplined proxy_set_header blocks.
Context: where a plain proxy_pass falls short
A minimal location /api/ { proxy_pass http://localhost:3000; } works until the moment your setup grows a second dimension. The three problems that force a richer configuration are:
- Backend selection is conditional, not static. You want
/api/v2/paymentsmocked but/api/v2/authproxied to staging, and you want a single header to flip a whole session into mock mode. A pile of near-identicallocationblocks becomes unmaintainable; amapdirective expresses the decision once. - Upstream IPs move. When the mock runs as a Docker container, restarting it can hand out a new IP. Nginx resolves literal upstream addresses exactly once at startup, so after a restart it keeps dialling the dead IP and returns
502. Aresolverplus a variable-basedproxy_passdefers DNS to request time. - Headers need curation. Forwarding
Host,X-Forwarded-*, andConnectioncorrectly — and removing the upstream’s duplicate CORS and HSTS headers — is what separates a proxy that “works on my machine” from one that behaves the same in CI.
This is the specific, deeper counterpart to the generic walkthrough in routing local traffic through a mock API gateway; if you want the same split-routing idea in a declarative gateway instead, see mocking APIs with Kong Gateway.
Solution
1. Declare upstreams with keepalive
Group your backends into named upstream blocks. The keepalive directive pools connections so Nginx does not open a fresh TCP socket per request — important when a mock like WireMock is answering hundreds of test calls a second.
# nginx-mock.conf — run with: nginx -c $(pwd)/nginx-mock.conf
worker_processes 1;
error_log /tmp/nginx-mock-error.log warn;
pid /tmp/nginx-mock.pid;
events { worker_connections 128; }
http {
access_log /tmp/nginx-mock-access.log;
# Local mock backend (WireMock, MSW server, json-server, etc.)
upstream mock_backend {
server 127.0.0.1:8080;
keepalive 32;
}
# Live staging — kept for the paths we do not mock
upstream staging_backend {
server 127.0.0.1:9443; # a local TLS-terminating stub or ssh tunnel to staging
keepalive 16;
}
2. Choose a backend with map
A map builds a variable from another variable. Here $mock_upstream is derived from the request path, and a header override lets a whole session opt into mocking. This replaces a sprawl of conditional location blocks with one lookup table evaluated per request.
# Decide the default backend from the leading path segment.
# The first matching pattern wins; the empty-string default is the fallback.
map $uri $path_backend {
default "staging";
~^/api/v2/payments "mock";
~^/api/v2/catalog "mock";
~^/api/v2/feature "mock";
}
# A request header can force the whole session into mock mode.
# X-Mock-Env: local -> everything routes to the mock backend.
map $http_x_mock_env $mock_upstream {
default $path_backend; # no header -> use the path-based decision
"local" "mock"; # header present -> always mock
}
3. Set the resolver and header hygiene inside the server
The resolver lets Nginx re-resolve hostnames at request time. Assigning the upstream to a set variable and using it in proxy_pass is what actually defers the DNS lookup — a literal upstream name would be cached at startup instead.
server {
listen 8088;
server_name localhost;
# 127.0.0.11 is Docker's embedded DNS; use it when Nginx runs in a container.
# For host-native Nginx, point at your system resolver instead.
resolver 127.0.0.11 ipv6=off valid=10s;
# Shared proxy hygiene applied to every proxied location.
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;
proxy_set_header X-Forwarded-Host $host;
# Strip headers the upstream might set that break local dev.
proxy_hide_header Strict-Transport-Security;
proxy_hide_header Access-Control-Allow-Origin; # we re-add our own below
# Reconnect immediately instead of serving 502 on a stale keepalive socket
# (e.g. after the mock container restarts).
proxy_next_upstream error timeout http_502 http_503;
# ── Single split-routing entry point ───────────────────────────────
location /api/ {
# OPTIONS preflight answered by Nginx, never forwarded.
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always;
add_header Access-Control-Allow-Headers 'Content-Type, Authorization, X-Request-ID, X-Mock-Env' always;
add_header Access-Control-Max-Age 600 always;
add_header Content-Length 0;
return 204;
}
# Pick the concrete upstream host into a variable so the resolver runs
# at request time. Both branches point at named upstream blocks.
set $target "http://mock_backend";
if ($mock_upstream = "staging") {
set $target "http://staging_backend";
}
proxy_pass $target;
# Our single source of CORS truth.
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header X-Gateway "nginx-local-mock" always;
}
# ── Health endpoint, no upstream ───────────────────────────────────
location = /healthz {
default_type text/plain;
return 200 "gateway-ok\n";
}
}
}
Because the mock paths are chosen inside the map, adding a newly-mocked route is a one-line change to the map $uri $path_backend table — no new location block, no reordering. Point your application’s base URL at http://localhost:8088 and, as the network layer abstraction pattern recommends, keep that base URL in a single client module so nothing in the app hardcodes an upstream.
4. Start and reload
# Validate before every start — catches map/regex typos early
nginx -t -c $(pwd)/nginx-mock.conf
nginx -c $(pwd)/nginx-mock.conf # start
nginx -s reload -c $(pwd)/nginx-mock.conf # apply edits, no dropped connections
nginx -s quit -c $(pwd)/nginx-mock.conf # stop cleanly
Verification
One command exercises the path-based decision, the header override, and the staging fallback in sequence:
# Mocked path -> mock_backend
curl -s -o /dev/null -w "catalog -> %header{x-gateway} (%{http_code})\n" \
http://localhost:8088/api/v2/catalog/items
# Non-mocked path -> staging_backend, but header forces mock
curl -s -o /dev/null -w "auth+hdr -> %header{x-gateway} (%{http_code})\n" \
-H "X-Mock-Env: local" http://localhost:8088/api/v2/auth/session
# Non-mocked path, no header -> staging fallback
curl -s -o /dev/null -w "auth -> %{http_code} (falls through to staging)\n" \
http://localhost:8088/api/v2/auth/session
Expected: the first two lines print x-gateway: nginx-local-mock because they hit the mock backend; the third reaches whatever staging returns. Confirm no duplicate CORS header slipped through:
curl -s -D - -o /dev/null http://localhost:8088/api/v2/catalog/items \
| grep -ci "access-control-allow-origin"
# Expected: 1 (exactly one header, not two)
Gotchas and edge cases
-
ifis evaluated per request but is famously sharp-edged. Theif ($mock_upstream = "staging")guard here only assigns a variable, which is one of the few operations that is safe insidelocation-levelifin Nginx. Do not putproxy_set_headeroradd_headerinside anif— they silently fail to inherit. Keep header logic at thelocationorserverlevel and useifsolely toseta variable. -
The resolver
valid=window controls staleness, not correctness. Withvalid=10s, Nginx caches a resolved IP for ten seconds. If a mock container restarts and changes IP, requests can502for up to that window before re-resolution. Lower it tovalid=1sduring heavy container churn, but do not set it to zero — that disables caching and hammers the DNS server on every request. For the container-side of this, see managing mock server lifecycles in Docker. -
mapregexes are case-sensitive and anchored by you.~^/api/v2/paymentsmatches a prefix; without the leading^it would match the substring anywhere in the path, so/api/v2/user/payments-historywould unexpectedly route to the mock. Anchor every pattern and use~*only when you deliberately want case-insensitive matching.
FAQ
Why does Nginx resolve my upstream hostname only once at startup?
When a server address is written directly in an upstream block or a proxy_pass literal, Nginx resolves it a single time at load. To force runtime DNS re-resolution — needed when a Docker container restarts with a new IP — set a resolver directive and pass the hostname through a variable (set $target "http://mock_backend"; proxy_pass $target;), which defers resolution to request time.
How do I route some paths to a mock and proxy the rest to staging?
Use a map directive keyed on the request path or a header to pick a backend variable, then reference that variable in a single proxy_pass. Specific paths in the map table resolve to the mock upstream, while the default entry forwards everything else to the staging upstream. Adding a newly-mocked route becomes a one-line change to the map rather than a new location block — the same longest-match idea as when to use proxy vs inline mocking.
Why do duplicate CORS headers appear on some responses?
Both Nginx and the upstream are adding Access-Control-Allow-Origin. Strip the upstream copy with proxy_hide_header Access-Control-Allow-Origin before adding your own, otherwise the browser sees two values and rejects the response as an invalid CORS configuration.
Related
- Mocking APIs with Kong Gateway — the same split-routing goal in a DB-less declarative gateway with inline stubs
- Environment-Variable-Driven Route Switching — template this nginx.conf so upstream URLs come from env vars per environment
- Routing Local Traffic Through a Mock API Gateway — the introductory Nginx gateway walkthrough this page builds on
← Back to Local API Gateway Routing