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/payments mocked but /api/v2/auth proxied to staging, and you want a single header to flip a whole session into mock mode. A pile of near-identical location blocks becomes unmaintainable; a map directive 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. A resolver plus a variable-based proxy_pass defers DNS to request time.
  • Headers need curation. Forwarding Host, X-Forwarded-*, and Connection correctly — 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

  • if is evaluated per request but is famously sharp-edged. The if ($mock_upstream = "staging") guard here only assigns a variable, which is one of the few operations that is safe inside location-level if in Nginx. Do not put proxy_set_header or add_header inside an if — they silently fail to inherit. Keep header logic at the location or server level and use if solely to set a variable.

  • The resolver valid= window controls staleness, not correctness. With valid=10s, Nginx caches a resolved IP for ten seconds. If a mock container restarts and changes IP, requests can 502 for up to that window before re-resolution. Lower it to valid=1s during 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.

  • map regexes are case-sensitive and anchored by you. ~^/api/v2/payments matches a prefix; without the leading ^ it would match the substring anywhere in the path, so /api/v2/user/payments-history would 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.


← Back to Local API Gateway Routing