Environment-Variable-Driven Route Switching

You maintain one gateway config, but it needs to behave differently everywhere it runs: point at a local mock on your laptop, at a shared mock service in CI, and at real staging in a preview environment. Copy-pasting three near-identical config files rots fast. This page shows how a single MOCK_ENV flag plus a handful of URL variables, rendered through envsubst, produces the correct concrete config for every environment from one template.

Context: why hardcoded upstreams do not scale

A reverse-proxy config with literal upstream URLs — proxy_pass http://127.0.0.1:8080 or a Traefik service url: http://wiremock:8080 — bakes one environment’s assumptions into a file that has to run in several. The moment CI needs a different mock hostname, or a preview environment needs to hit real staging, someone forks the config, and the forks drift. The fix is to treat the config as a template with holes, and fill the holes from the environment.

Two ideas do the work:

  • A MOCK_ENV flag names the intent — mock, live, or hybrid — rather than an implementation. Callers set one variable; the render step maps it to concrete URLs.
  • envsubst templating substitutes ${VAR} placeholders in a .tpl file with values from the shell environment, producing a plain config file the gateway loads with no template awareness of its own.

This keeps the switching logic in one small shell script instead of scattered across the config, and it is the pattern referenced from both the Nginx reverse proxy for local mock APIs page and the local API gateway routing overview. It also composes cleanly with dockerized mock environments, where the same .env files parameterise the Compose stack.

From variable to route A single environment variable is read at gateway start, validated against a closed set of backends, and turned into an upstream address. Every route then points at that resolved address, so switching backends is one variable and a restart rather than an edit to each route. Validating against a closed set at start is what turns a typo into a startup error rather than a silent default. Variable one closed set of values validated at start Resolved upstream mock, local, or staging one decision Routes all point at the resolved value no per-route editing Restart the switch takes effect and is announced in the log A gateway that defaults to the real upstream on an unrecognised value is one typo away from hitting production.

Solution

1. Define the environment contract

Each environment gets a small, committed .env file. The variable names are the contract; only the values differ.

# env/mock.env — local laptop, everything mocked
MOCK_ENV=mock
MOCK_BACKEND_URL=http://127.0.0.1:8080
LIVE_BACKEND_URL=http://127.0.0.1:8080
GATEWAY_PORT=8088
CORS_ORIGIN=http://localhost:5173
# env/ci.env — CI, mock runs as a compose service
MOCK_ENV=mock
MOCK_BACKEND_URL=http://wiremock:8080
LIVE_BACKEND_URL=http://wiremock:8080
GATEWAY_PORT=8088
CORS_ORIGIN=http://localhost:5173
# env/preview.env — preview env, mock for writes, live for reads
MOCK_ENV=hybrid
MOCK_BACKEND_URL=http://wiremock:8080
LIVE_BACKEND_URL=https://api.staging.internal
GATEWAY_PORT=8088
CORS_ORIGIN=https://preview.example.com

2. Write the config template

Author nginx.conf.tpl with ${...} placeholders. Note that Nginx’s own runtime variables ($host, $remote_addr) are written without braces so we can tell envsubst to leave them alone.

# nginx.conf.tpl — rendered to nginx.conf by scripts/render-gateway.sh
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;

  upstream mock_backend { server ${MOCK_BACKEND_HOSTPORT}; keepalive 32; }
  upstream live_backend { server ${LIVE_BACKEND_HOSTPORT}; keepalive 16; }

  server {
    listen ${GATEWAY_PORT};
    server_name localhost;

    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_hide_header Access-Control-Allow-Origin;

    # Writes always go to the primary backend chosen by MOCK_ENV.
    location ~ ^/api/v2/(orders|payments) {
      proxy_pass http://${PRIMARY_UPSTREAM};
      add_header Access-Control-Allow-Origin ${CORS_ORIGIN} always;
      add_header X-Active-Env "${MOCK_ENV}" always;
    }

    # Reads use the secondary backend (same as primary unless hybrid).
    location /api/v2/ {
      proxy_pass http://${SECONDARY_UPSTREAM};
      add_header Access-Control-Allow-Origin ${CORS_ORIGIN} always;
      add_header X-Active-Env "${MOCK_ENV}" always;
    }

    location = /healthz { default_type text/plain; return 200 "gateway-ok\n"; }
  }
}

The template references ${MOCK_BACKEND_HOSTPORT}, ${PRIMARY_UPSTREAM}, and ${SECONDARY_UPSTREAM} — derived values the render script computes from the flag, keeping the branching logic out of the config.

3. Write the render script

The script sources the chosen .env, translates MOCK_ENV into concrete upstreams, and runs envsubst with an explicit whitelist so it never mangles Nginx’s own $host-style variables.

#!/usr/bin/env bash
# scripts/render-gateway.sh <env-name>
# Example: scripts/render-gateway.sh preview
set -euo pipefail

ENV_NAME="${1:?usage: render-gateway.sh <mock|ci|preview>}"
ENV_FILE="env/${ENV_NAME}.env"
[ -f "$ENV_FILE" ] || { echo "No env file: $ENV_FILE" >&2; exit 1; }

# Load the environment contract.
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a

# Strip the scheme so the value fits an nginx `upstream ... server` line.
strip_scheme() { echo "${1#http://}" | sed 's#^https://##; s#/$##'; }
export MOCK_BACKEND_HOSTPORT="$(strip_scheme "$MOCK_BACKEND_URL")"
export LIVE_BACKEND_HOSTPORT="$(strip_scheme "$LIVE_BACKEND_URL")"

# Translate the single MOCK_ENV flag into primary/secondary upstreams.
case "$MOCK_ENV" in
  mock)   PRIMARY_UPSTREAM="mock_backend"; SECONDARY_UPSTREAM="mock_backend" ;;
  live)   PRIMARY_UPSTREAM="live_backend"; SECONDARY_UPSTREAM="live_backend" ;;
  hybrid) PRIMARY_UPSTREAM="mock_backend"; SECONDARY_UPSTREAM="live_backend" ;;
  *) echo "Unknown MOCK_ENV: $MOCK_ENV (want mock|live|hybrid)" >&2; exit 1 ;;
esac
export PRIMARY_UPSTREAM SECONDARY_UPSTREAM

# Whitelist ONLY our placeholders so envsubst leaves $host, $remote_addr, etc. intact.
VARS='${MOCK_BACKEND_HOSTPORT} ${LIVE_BACKEND_HOSTPORT} ${GATEWAY_PORT}'
VARS+=' ${PRIMARY_UPSTREAM} ${SECONDARY_UPSTREAM} ${CORS_ORIGIN} ${MOCK_ENV}'

envsubst "$VARS" < nginx.conf.tpl > nginx.conf
echo "Rendered nginx.conf for MOCK_ENV=$MOCK_ENV (primary=$PRIMARY_UPSTREAM, secondary=$SECONDARY_UPSTREAM)"

# Fail fast if the render produced invalid config.
nginx -t -c "$(pwd)/nginx.conf"

4. Render, start, and wire into tooling

scripts/render-gateway.sh mock
nginx -c "$(pwd)/nginx.conf"

Wire the render step into your dev and CI entry points so the config is always regenerated from the template and never edited by hand:

{
  "scripts": {
    "gw:dev": "scripts/render-gateway.sh mock && nginx -c $(pwd)/nginx.conf",
    "gw:ci": "scripts/render-gateway.sh ci && nginx -c $(pwd)/nginx.conf",
    "gw:reload": "scripts/render-gateway.sh mock && nginx -s reload -c $(pwd)/nginx.conf"
  }
}

The same MOCK_ENV=hybrid idea maps directly onto the Traefik dynamic configuration shown in the overview: render routes.yaml from a routes.yaml.tpl with the identical .env files, since Traefik uses ${...} service URLs too.

Three places to put the switch Three locations. In the gateway configuration it applies to every client but needs a restart. In a request header it is per-request but a browser navigation cannot set one. In a hostname it works everywhere including navigations but needs host entries or DNS. Location Granularity Cost gateway configuration the whole environment a restart to change request header per request, ideal for tests browsers cannot set it on a navigation hostname per client, works for navigations host entries or a wildcard certificate Most stacks want two: a configuration default plus a header override for tests that need to differ.

Verification

Render two environments and confirm the active backend changes without touching the template. The X-Active-Env header echoes the flag that produced the running config:

# Mock everywhere
scripts/render-gateway.sh mock && nginx -s reload -c "$(pwd)/nginx.conf"
curl -s -o /dev/null -w "reads=%header{x-active-env}\n" http://localhost:8088/api/v2/catalog
# Expected: reads=mock

# Hybrid: writes mocked, reads live
scripts/render-gateway.sh preview && nginx -s reload -c "$(pwd)/nginx.conf"
curl -s -D - -o /dev/null http://localhost:8088/api/v2/orders  | grep -i x-active-env
curl -s -D - -o /dev/null http://localhost:8088/api/v2/catalog | grep -i x-active-env
# Both print: X-Active-Env: hybrid  (orders -> mock upstream, catalog -> live upstream)

Confirm envsubst preserved Nginx’s own variables — a blanked $host is the classic templating failure:

grep -c 'proxy_set_header Host \$host;' nginx.conf
# Expected: 1   (the literal $host survived; it was NOT substituted to empty)

Gotchas and edge cases

  • Unquoted envsubst substitutes everything. Calling envsubst < tpl > out with no argument replaces every $NAME token it recognises from the current environment, silently emptying Nginx runtime variables like $host and $proxy_add_x_forwarded_for. Always pass the explicit '${VAR1} ${VAR2}' whitelist as the script does — this is the single most common way env-driven gateway configs break.

  • A stale rendered file outranks your template. If nginx.conf is committed and someone edits it directly, the next render silently overwrites their change, or worse, a forgotten render leaves an old file in place. Add the rendered output (nginx.conf, routes.yaml) to .gitignore and commit only the .tpl and .env files, so the template is unambiguously the source of truth.

  • MOCK_ENV=live still needs a reachable upstream. Switching a preview environment to live only works if the gateway can actually reach staging — DNS, VPN, and TLS trust all have to line up. When a live render returns 502, verify connectivity independently before blaming the switch, and keep a mock fallback documented so a broken staging dependency does not block local work. For coordinating that fallback with container startup order, see managing mock server lifecycles in Docker.


FAQ

Why does envsubst blank out my Nginx variables like $host?

By default envsubst replaces every $NAME token, including Nginx runtime variables such as $host and $remote_addr, which are empty in the shell environment. Pass an explicit whitelist of variable names — envsubst '${GATEWAY_PORT} ${CORS_ORIGIN} ...' — so it only substitutes your placeholders and leaves Nginx’s own variables untouched.

How does a single MOCK_ENV flag flip every route at once?

The render script reads MOCK_ENV and selects which upstream each placeholder resolves to before the gateway starts. When MOCK_ENV=mock the primary and secondary upstreams both point at the local mock; MOCK_ENV=live points both at staging; MOCK_ENV=hybrid splits them. The gateway itself only ever sees concrete URLs, so there is no conditional logic in the config.

Should I commit the rendered config or only the template?

Commit only the template and the per-environment .env files. The rendered output (nginx.conf, routes.yaml) is a build artifact — add it to .gitignore so a stale rendered file can never silently override the template that is the real source of truth.


Three properties the switch must hold Three safety properties. It must fail closed on an unrecognised value, it must announce the active backend somewhere visible, and it must be impossible for a test run to reach a real backend by accident. Each is paired with the mechanism that provides it. Fail closed an unknown value is a startup error never a default to the real upstream Announce the choice a startup log line and a response header or ten minutes go on establishing it Unreachable by accident block the real upstream in test profiles a green build against production is the worst outcome The third property is the one that has to be enforced by configuration rather than by convention.

← Back to Local API Gateway Routing