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.

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.

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.


← Back to Local API Gateway Routing