Routing Local Traffic Through a Mock API Gateway

Your application sends requests to https://api.example.com/v1/users and you want every one of those calls to hit a local mock instead — without changing a single line of application source code. Without a local gateway layer in place, developers either hardcode localhost URLs into the app (breaking CI) or run against real APIs (causing flaky, rate-limited tests). This page shows exactly how to drop Nginx in as a transparent local gateway that intercepts traffic and forwards it to any mock server running on your machine.

Why this scenario arises

The problem appears most often in three situations:

  • A frontend app reads its API base URL from REACT_APP_API_URL or VITE_API_URL, and that variable is already set to a production or staging hostname in a shared .env file.
  • A backend microservice calls downstream services via hardcoded http://service-name:8080 DNS names that only resolve inside a production Kubernetes cluster.
  • A mobile or desktop client uses a certificate-pinned HTTPS endpoint that cannot be trivially overridden at the call-site level.

In all three cases, inserting a local reverse proxy between the application and the network resolves the problem at the infrastructure level. The proxy vs inline mocking strategies comparison covers when this approach is preferable to in-process interception; if you need to intercept fetch calls without a separate process, see request interception patterns instead.

Architecture: how traffic flows through the local gateway

The diagram below shows the routing path from your running application through Nginx to either the mock server or (for non-mocked paths) through to a real upstream, along with the DNS and port mapping that makes it work.

Local API gateway traffic flow Application sends requests to localhost:8080. Nginx inspects the path: /api/v1/ routes to the mock server on localhost:3000, while /external/ routes through to the real upstream API. Application localhost:3000 HTTP/S Nginx Gateway localhost:8080 path-based routing CORS injection /api/v1/ Mock Server localhost:3000 /external/ Real Upstream api.example.com mocked path pass-through path

The key insight is that Nginx never touches the application’s process — the app continues calling http://localhost:8080/api/v1/users exactly as configured. Nginx handles path inspection, header injection, and upstream selection entirely at the network layer.

Solution

1. Install Nginx and verify ports

# macOS
brew install nginx

# Debian/Ubuntu
sudo apt-get install -y nginx

# Confirm ports 8080 and 3000 are free
lsof -i :8080 -i :3000

If either port is occupied, terminate the conflicting process with kill -9 <PID> or change the ports in the configuration below.

2. Write the gateway configuration

Save the following as nginx-mock-gateway.conf in your project root. This is a complete, drop-in configuration — no ellipsis placeholders, no manual edits required beyond the port numbers.

# nginx-mock-gateway.conf
# Run with: nginx -c $(pwd)/nginx-mock-gateway.conf

worker_processes 1;
error_log /tmp/nginx-mock-error.log warn;
pid       /tmp/nginx-mock.pid;

events {
  worker_connections 64;
}

http {
  access_log /tmp/nginx-mock-access.log;

  # Mock server upstream
  upstream mock_server {
    server 127.0.0.1:3000;
    keepalive 16;
  }

  server {
    listen 8080;
    server_name localhost;

    # ── Mocked API paths ─────────────────────────────────────────
    location /api/v1/ {
      proxy_pass         http://mock_server/api/v1/;
      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;

      # CORS — allow all origins in local dev
      add_header Access-Control-Allow-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' always;

      # Handle OPTIONS preflight without hitting the mock server
      if ($request_method = OPTIONS) {
        return 204;
      }

      # Strip HSTS so the browser doesn't enforce HTTPS for localhost
      proxy_hide_header Strict-Transport-Security;
    }

    # ── Second API version routed to a separate mock instance ────
    location /api/v2/ {
      proxy_pass         http://127.0.0.1:3001/api/v2/;
      proxy_http_version 1.1;
      proxy_set_header   Connection "";
      proxy_set_header   Host $host;
      add_header Access-Control-Allow-Origin * always;
      proxy_hide_header Strict-Transport-Security;
    }

    # ── Health check endpoint (no upstream) ──────────────────────
    location /healthz {
      return 200 'gateway-ok\n';
      add_header Content-Type text/plain;
    }
  }
}

3. Start the gateway

# Validate config syntax first
nginx -t -c $(pwd)/nginx-mock-gateway.conf

# Start
nginx -c $(pwd)/nginx-mock-gateway.conf

# Reload config without dropping connections (after edits)
nginx -s reload -c $(pwd)/nginx-mock-gateway.conf

# Stop cleanly
nginx -s quit -c $(pwd)/nginx-mock-gateway.conf

Using -c $(pwd)/nginx-mock-gateway.conf keeps the gateway config isolated from any system-wide Nginx installation. The pid and log paths in /tmp/ ensure no root permissions are needed.

4. Point your application at the gateway

Set the API base URL environment variable before starting your dev server:

# .env.local (Vite / Next.js / Create React App)
VITE_API_BASE_URL=http://localhost:8080
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
REACT_APP_API_BASE_URL=http://localhost:8080

If your application uses a centralised HTTP client (Axios instance, fetch wrapper, or an OpenAPI-generated client), confirm the base URL is read from the environment rather than hardcoded:

// src/lib/apiClient.ts
import axios from "axios";

export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080",
  headers: { "Content-Type": "application/json" },
});

This is the only application-level change required. The network layer abstraction pattern explains how centralising the base URL in one place prevents environment leakage across the codebase.

5. Enable TLS for localhost (optional)

Some OAuth 2.0 libraries, Service Workers, and payment SDKs refuse to operate over plain HTTP even on localhost. Use mkcert to generate a locally-trusted certificate:

# Install mkcert and inject its CA root
brew install mkcert          # macOS; see mkcert docs for Linux
mkcert -install

# Generate a cert for localhost
mkcert -key-file /tmp/localhost-key.pem -cert-file /tmp/localhost.pem localhost 127.0.0.1

# Verify the files exist
ls -lh /tmp/localhost*.pem

Add an HTTPS server block to nginx-mock-gateway.conf inside the http {} section:

server {
  listen 8443 ssl;
  server_name localhost;

  ssl_certificate     /tmp/localhost.pem;
  ssl_certificate_key /tmp/localhost-key.pem;
  ssl_protocols       TLSv1.2 TLSv1.3;

  location /api/v1/ {
    proxy_pass       http://mock_server/api/v1/;
    proxy_set_header Host $host;
    add_header Access-Control-Allow-Origin * always;
    proxy_hide_header Strict-Transport-Security;
  }
}

Update your environment variable to use https://localhost:8443 and restart Nginx.

Verification

Run this single command — it exercises the path-matching, header injection, and mock server response in one shot:

curl -s -o /dev/null -w "%{http_code} | X-Powered-By: %header{x-powered-by}\n" \
  http://localhost:8080/api/v1/users

Expected output (WireMock as the mock server):

200 | X-Powered-By: WireMock

Also verify the health check endpoint to confirm Nginx itself is healthy independently of the mock server:

curl http://localhost:8080/healthz
# → gateway-ok

If curl returns 000 (connection refused), Nginx did not start — check /tmp/nginx-mock-error.log. If it returns 502 Bad Gateway, Nginx is running but cannot reach the mock server on port 3000.

Gotchas and edge cases

  • proxy_pass trailing slash matters. proxy_pass http://mock_server/api/v1/ rewrites /api/v1/users to /api/v1/users on the upstream. Omitting the trailing slash (proxy_pass http://mock_server) passes the full original URI, including /api/v1/, which doubles the path prefix if your mock server also has a /api/v1/ prefix in its own routing. Match the trailing slash to your mock server’s URL scheme.

  • Environment variable not picked up at runtime. Node-based dev servers (Vite, Next.js) read .env.local only at startup. If you add or change VITE_API_BASE_URL after the dev server is already running, you must restart it. Changes to .env (without the .local suffix) may also require a full node_modules/.cache purge to take effect.

  • Mock server restart drops keepalive connections. The keepalive 16 directive in the upstream block makes Nginx cache connections to the mock server. If the mock server restarts (e.g. WireMock hot-reload, nodemon restart), Nginx will receive 502 errors on the cached sockets until they time out. Add proxy_next_upstream error timeout to your location block to make Nginx reconnect immediately rather than serving errors. For WireMock-specific lifecycle management, see managing mock server lifecycles in Docker.


FAQ

Why does the browser still call the production API even though Nginx is running?

The browser makes requests to whatever URL is embedded in the JavaScript bundle. If your build tool substituted https://api.example.com into the bundle before you set the environment variable, a full rebuild is needed — not just a Nginx restart. Run grep -r "api.example.com" dist/ to confirm whether the production URL was baked in.

How do I route different API versions to different mock servers?

Add separate location blocks inside the same server {} directive — /api/v1/ forwarding to 127.0.0.1:3000 and /api/v2/ forwarding to 127.0.0.1:3001. The configuration in Step 2 above already includes this pattern. Start two independent mock server processes on those ports and Nginx handles the version split transparently.

Does this approach work with WebSocket connections?

Yes, with one addition. WebSocket upgrades require Upgrade and Connection headers to be passed through:

location /ws/ {
  proxy_pass          http://mock_server/ws/;
  proxy_http_version  1.1;
  proxy_set_header    Upgrade    $http_upgrade;
  proxy_set_header    Connection "upgrade";
}

Without these headers, Nginx closes the connection after the initial HTTP handshake and the WebSocket client receives a 101 Switching Protocols failure.


← Back to Local API Gateway Routing