Local API Gateway Routing
This page covers exactly one thing: running a reverse-proxy gateway on your local machine that intercepts outbound API traffic, routes versioned paths to mock backends, and falls back to live or staging services for everything else. It does not cover browser-scoped interceptors or in-process mocking.
Prerequisites
- Docker Desktop (or Docker Engine + Compose plugin) — v24 or later
-
mkcertinstalled andmkcert -installalready run so your system trusts a local CA - A running mock backend on a known local port — for example, a WireMock standalone instance on
localhost:8080 - Basic familiarity with proxy vs inline mocking strategies — the gateway is a proxy-style approach
-
curlavailable for verification steps
Why a local gateway?
Request interception patterns like MSW work inside the JavaScript runtime: they capture fetch and XMLHttpRequest calls from browsers and Node.js processes. A local gateway sits below the runtime at the TCP layer, making it the right choice when traffic comes from clients that never touch the JS stack — native mobile apps running on a simulator pointed at your dev machine, backend microservices calling each other, CLI tools, or gRPC clients using generated stubs.
The trade-off is setup cost versus coverage breadth. A gateway adds a configuration layer you must maintain, but once it is in place every client on your machine shares the same routing table with no per-client changes.
The diagram below shows the routing topology this page builds:
Phase 1 — Core setup: Traefik as a local gateway
Traefik is the simplest starting point because it reads route rules from a YAML file at runtime and reloads without a restart. The Docker Compose definition below runs the gateway alongside WireMock.
# docker-compose.yml
services:
gateway:
image: traefik:v3.0
command:
- "--api.insecure=true"
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--log.level=INFO"
ports:
- "80:80"
- "443:443"
- "8081:8080" # Traefik dashboard
volumes:
- "./traefik/dynamic:/etc/traefik/dynamic:ro"
- "./certs:/certs:ro"
networks:
- mocknet
wiremock:
image: wiremock/wiremock:3.5.4
volumes:
- "./wiremock/mappings:/home/wiremock/mappings:ro"
- "./wiremock/__files:/home/wiremock/__files:ro"
networks:
- mocknet
networks:
mocknet:
driver: bridge
The dynamic configuration file controls where each path goes:
# traefik/dynamic/routes.yaml
http:
routers:
# Highest-priority rule: header-flagged mock traffic
mock-env-header:
rule: "Header(`X-Mock-Env`, `local`)"
service: mock-backend
priority: 200
tls: {}
# Route versioned API path to WireMock
mock-api-v2:
rule: "PathPrefix(`/api/v2`)"
service: mock-backend
priority: 100
middlewares:
- strip-api-prefix
tls: {}
# Pass auth to live staging — never mock credentials
live-auth:
rule: "PathPrefix(`/auth`)"
service: staging-proxy
priority: 90
tls: {}
# Catch-all: forward anything not matched above
fallback-staging:
rule: "PathPrefix(`/`)"
service: staging-proxy
priority: 10
tls: {}
services:
mock-backend:
loadBalancer:
servers:
- url: "http://wiremock:8080"
staging-proxy:
loadBalancer:
servers:
- url: "https://api.staging.internal"
passHostHeader: false
middlewares:
strip-api-prefix:
stripPrefix:
prefixes:
- "/api/v2"
tls:
certificates:
- certFile: "/certs/local.dev.crt"
keyFile: "/certs/local.dev.key"
Generate the certificate with mkcert:
# Run once — mkcert -install must already have been run
mkcert -cert-file certs/local.dev.crt -key-file certs/local.dev.key \
"local.dev" "*.local.dev" "localhost" "127.0.0.1"
Add local.dev to /etc/hosts:
echo "127.0.0.1 local.dev api.local.dev" | sudo tee -a /etc/hosts
Phase 2 — Configuration and wiring
Environment-variable-driven route switching
Hard-coding upstream URLs in routes.yaml prevents reuse across CI and production-preview environments. Externalise URLs through environment variables and use a template step:
# .env.local
MOCK_BACKEND_URL=http://wiremock:8080
STAGING_URL=https://api.staging.internal
MOCK_ENV_NAME=local
# scripts/render-traefik-config.sh
#!/usr/bin/env bash
set -euo pipefail
source .env.local
envsubst < traefik/dynamic/routes.yaml.tpl > traefik/dynamic/routes.yaml
echo "Route table written."
# traefik/dynamic/routes.yaml.tpl (template — variables substituted at startup)
http:
services:
mock-backend:
loadBalancer:
servers:
- url: "${MOCK_BACKEND_URL}"
staging-proxy:
loadBalancer:
servers:
- url: "${STAGING_URL}"
Header-based environment switching
The X-Mock-Env: local header lets individual requests opt into mock routing without touching the URL. This is valuable when a single test session needs to mix mocked and live responses:
# Request routed to WireMock
curl -H "X-Mock-Env: local" https://local.dev/api/payments
# Same path, no header — falls through to staging
curl https://local.dev/api/payments
Your WireMock standalone configuration must include mappings for the stripped paths (/payments, not /api/v2/payments) because the strip-api-prefix middleware removes the prefix before forwarding.
gRPC routing with Traefik
Traefik v3 treats gRPC as a first-class protocol. Add an IngressRoute with the h2c scheme to forward protobuf traffic to a mock gRPC server:
# traefik/dynamic/grpc-routes.yaml
http:
routers:
grpc-mock:
rule: "PathPrefix(`/com.example.PaymentService`)"
service: grpc-mock-backend
priority: 150
tls: {}
services:
grpc-mock-backend:
loadBalancer:
servers:
- url: "h2c://grpc-mock-server:50051"
Phase 3 — Integration with the mock stack and CI
Connecting to the broader mock lifecycle
The gateway is a long-running process that must start before any test suite and shut down cleanly afterwards. Add lifecycle scripts to package.json so the mock stack spins up and tears down with your dev and CI commands:
{
"scripts": {
"mock:up": "docker compose up -d gateway wiremock && scripts/wait-for-gateway.sh",
"mock:down": "docker compose down --remove-orphans",
"dev": "npm run mock:up && vite",
"test:integration": "npm run mock:up && vitest run --reporter=verbose; npm run mock:down"
}
}
#!/usr/bin/env bash
# scripts/wait-for-gateway.sh
set -euo pipefail
echo "Waiting for gateway..."
until curl -sk https://local.dev/health > /dev/null 2>&1; do
sleep 1
done
echo "Gateway ready."
CI pipeline (GitHub Actions)
# .github/workflows/integration.yml
name: Integration tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install mkcert
run: |
sudo apt-get install -y libnss3-tools
wget -q https://dl.filippo.io/mkcert/latest?for=linux/amd64 -O mkcert
chmod +x mkcert && sudo mv mkcert /usr/local/bin/
mkcert -install
- name: Generate local certs
run: |
mkdir -p certs
mkcert -cert-file certs/local.dev.crt -key-file certs/local.dev.key \
local.dev "*.local.dev" localhost 127.0.0.1
- name: Add hosts entry
run: echo "127.0.0.1 local.dev api.local.dev" | sudo tee -a /etc/hosts
- name: Start mock stack
run: docker compose up -d gateway wiremock
- name: Wait for gateway
run: bash scripts/wait-for-gateway.sh
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- name: Run integration tests
run: npm run test:integration
- name: Tear down
if: always()
run: docker compose down --remove-orphans
For dockerized mock environments that need the gateway to persist across multiple test suites in one CI job, use a service container rather than a step-level compose invocation — this avoids repeated startup overhead.
Verification steps
Run these commands after npm run mock:up to confirm the gateway is routing correctly.
- HTTPS reachability:
curl -sv https://local.dev/health 2>&1 | grep "< HTTP" # Expected: < HTTP/2 200 - Mock path routing (
/api/v2/*→ WireMock):curl -s https://local.dev/api/v2/users | jq . # Expected: WireMock response body per your mapping - Header-based routing to mock:
curl -s -H "X-Mock-Env: local" https://local.dev/api/payments | jq . # Expected: WireMock response - Live passthrough (
/auth/*→ staging):curl -sv https://local.dev/auth/token 2>&1 | grep "x-served-by" # Expected: staging upstream header - Traefik dashboard accessible:
curl -s http://localhost:8081/api/overview | jq .http.routers.total # Expected: integer matching your router count - No routing loops (check logs):
docker compose logs gateway | grep -i "loop\|cycle" # Expected: no output
Troubleshooting
curl: (60) SSL certificate problem: unable to get local issuer certificate
The system trust store does not include the mkcert CA. Run mkcert -install again after installing mkcert (it must be run as the same user that runs curl). On Linux, also run update-ca-certificates. Inside a Docker container, copy the CA root with:
docker exec gateway sh -c \
"cp /certs/rootCA.pem /usr/local/share/ca-certificates/mkcert-root.crt && update-ca-certificates"
404 page not found from WireMock when the path looks correct
The strip-api-prefix middleware removes /api/v2 before forwarding. Your WireMock mapping must match the stripped path. If WireMock receives GET /users but your mapping registers GET /api/v2/users, it will 404. Confirm the forwarded path with:
docker compose logs wiremock | grep "Request received"
Traefik router shows status: disabled in dashboard
Traefik disables routers that reference an undefined service. Check that every service: name in routes.yaml has a matching entry under services:. Names are case-sensitive.
ERR_TOO_MANY_REDIRECTS in browser
The HTTP-to-HTTPS redirect entrypoint is forwarding to itself. Ensure that entrypoints.web.http.redirections.entrypoint.to is set to websecure (port 443), not web (port 80), and that your application is requesting https:// URLs when talking to the gateway.
Mock response arrives but without CORS headers
Traefik does not automatically inject CORS headers. Add a middleware:
# traefik/dynamic/routes.yaml — add to middlewares section
cors-headers:
headers:
accessControlAllowOriginList:
- "http://localhost:5173"
- "https://local.dev"
accessControlAllowHeaders:
- "Content-Type"
- "Authorization"
- "X-Mock-Env"
accessControlAllowMethods:
- "GET"
- "POST"
- "PUT"
- "DELETE"
- "OPTIONS"
accessControlMaxAge: 300
Then add cors-headers to the middlewares list on each affected router.
When to advance
The gateway is correctly implemented when:
- Every path in your OpenAPI spec that maps to a mock returns the expected WireMock response via
https://local.dev - Auth endpoints reliably reach the staging upstream (confirm via
x-served-byheaders or staging-side logs) npm run test:integrationruns green in both local and CI environments withoutmock:upbeing called manually- The Traefik dashboard shows all routers in
enabledstate with correct upstream resolution - Structured gateway logs capture
method,path, andupstream_response_timefor every request, giving you an audit trail for debugging contract drift
Once these signals are met, you are ready to add concrete how-to pages for individual gateway tools — for example, Nginx reverse proxy configuration or Kong plugin-based routing — and to connect the gateway to dynamic response shaping so WireMock returns scenario-specific payloads based on request context injected at the gateway layer.
FAQ
When should I use a local gateway instead of MSW or a browser interceptor?
Use a local gateway when you need to intercept traffic from clients that bypass the JavaScript network stack: native mobile apps, CLI tools, backend services, or gRPC clients. MSW handler configuration excels at browser and Node.js fetch/XHR interception but cannot see raw TCP connections from other runtimes. The two approaches are complementary — a team with a React frontend and a mobile app often runs MSW for browser tests and a local gateway for mobile simulator tests against the same WireMock mapping directory.
Can the gateway route some paths to real staging endpoints while mocking others?
Yes. Split-routing is one of the primary use cases. Define higher-priority rules for paths you want to mock (e.g. /api/v2/payments) and a lower-priority catch-all that proxies everything else to your staging URL. Traefik applies longest-prefix matching so rules never overlap ambiguously. This is the same selective-mock pattern described in when to use proxy vs inline mocking.
How do I make the gateway trust my local TLS certificate?
Generate a certificate with mkcert and run mkcert -install to add its CA to your system and browser trust stores. Reference the generated .crt and .key files in your proxy TLS configuration. For Docker-based gateways, mount the certificate files into the container and set the CA bundle path via environment variables. The certs/ directory created in Phase 1 handles this for both Traefik and browser clients that inherit the system trust store.
Does adding a gateway slow down local development iteration?
Traefik supports live configuration reloading — updating routes.yaml takes effect in under a second with no container restart (the --providers.file.watch=true flag enables this). Nginx requires nginx -s reload, which is near-instant. Neither tool adds measurable latency to proxied requests on localhost; the TCP round-trip within the loopback interface is sub-millisecond. The startup time of the Docker containers (typically 2–4 seconds for Traefik + WireMock together) is the only meaningful overhead, and the wait-for-gateway.sh script absorbs it automatically.
Related
- Routing Local Traffic Through a Mock API Gateway — step-by-step walkthrough for a specific production-parity Nginx configuration
- WireMock Standalone Configuration — setting up the mock backend this gateway routes to
- Dockerized Mock Environments — running the full mock stack in Docker Compose for CI
- Proxy vs Inline Mocking Strategies — architectural context for choosing between gateway and in-process approaches
- Mock Lifecycle Management — startup, teardown, and state-reset patterns for any mock infrastructure
← Back to Tool-Specific Implementation & Setup