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
  • mkcert installed and mkcert -install already 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
  • curl available 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:

Local API Gateway Routing Topology Traffic from browser, mobile simulator, CLI, and backend services all flows into a local Traefik gateway. The gateway routes /api/v2/* to a local mock server and /auth/* plus everything else to the live staging API. Clients Browser (fetch / XHR) Mobile simulator CLI / backend service gRPC client Local Gateway (Traefik or Nginx) localhost:443 route table + TLS termination Mock backend WireMock · localhost:8080 /api/v2/* → Live / staging api.staging.internal /auth/* + catch-all → All clients share one routing table — no per-client config changes required

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.


Four ways to split real from mock traffic Four routing predicates: by path prefix, by hostname, by request header, and by a weighted share for gradual cutover. Each is paired with what it is good for and the failure mode it introduces, from a path that collides with a real route to a header that a browser cannot set. By path prefix the simplest split, and the most brittle a new real route under the same prefix goes to the mock By hostname clean separation, works with TLS needs host entries or a wildcard certificate By request header per-request control, ideal for tests a browser navigation cannot set one By weighted share gradual cutover between mock and real non-deterministic — never in a test suite Header-based routing is the only one that lets a single test choose, which is why it is worth the setup.

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

One entry point, two destinations A request reaches the gateway on a single local origin. The gateway evaluates its routing rules and forwards either to the mock service or to the real upstream. The response returns through the gateway, so the application sees one origin regardless of which side answered. The application never learns which side answered — which is the property that makes the split safe to change. Application one local origin no per-backend URLs Gateway evaluates routing rules path, host or header Mock or upstream whichever the rule chose the app cannot tell Response returns via the gateway one origin, always Add a response header naming the chosen backend, or the first routing bug costs an afternoon.

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-by headers or staging-side logs)
  • npm run test:integration runs green in both local and CI environments without mock:up being called manually
  • The Traefik dashboard shows all routers in enabled state with correct upstream resolution
  • Structured gateway logs capture method, path, and upstream_response_time for 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.


The gateway as a seam, not as infrastructure

It helps to think of a local gateway less as a piece of infrastructure and more as a seam — a single point where the decision about which backend answers can be made, changed and observed.

Seen that way, its value is easier to judge. A seam is worth having when the decision it holds needs to change frequently, needs to change per route rather than globally, or needs to be visible when something goes wrong. If a project only ever needs “everything to the mock” or “everything real”, a build-time configuration value is a simpler seam and a gateway is overhead.

The cases where the gateway earns its place all involve partial mocking. A team building against a backend that exists for six endpoints and not for the other four needs both, side by side, addressed identically. A team reproducing a defect that only occurs when one service is slow needs that service mocked and the rest real. A team validating a migration needs the same route served by two implementations depending on a header. None of those are expressible with a single global switch.

There is also a secondary benefit that is easy to underrate: the gateway is the one place where every request is visible. Access logs from a local gateway answer “did the application actually call that endpoint, and what did it get back” without adding logging to the application, without opening DevTools, and identically for a browser, a test runner and a backend service. On a stack with several clients, that single vantage point is often worth more than the routing.

The cost to keep in view is that a seam is another thing that can be misconfigured, and a misconfigured routing rule produces a symptom — wrong data — that looks exactly like an application bug. Naming the answering backend in a response header removes that ambiguity for the price of one line, and it is the single highest-value thing to add to a gateway configuration.

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.


← Back to Tool-Specific Implementation & Setup