How-to › Operate integrations in production

How to choose between an egress gateway and an in-process library#

Put outbound policy at the network edge or inside the process, and decide with a bypass test: one deny rule, three callers, and a record of who got through.

Audience
Platform team
Level
intermediate
Topic
Control outbound integrations from one place
Verified

The platform team writes a deny rule for a vendor at the gateway. The nightly export still reaches the vendor, because it is a Python job that never heard of the proxy. A second rule goes into the client library, which the shell scripts never load. The two rules have two owners, so when they disagree the on-call engineer cannot say which one decides.

What you get

You will end up with a decision backed by a bypass test: one deny rule per layer, three callers in three runtimes, and a table of which caller each layer stopped. This is for you if outbound policy is about to live in two places.

Short answer

Put the rule where the thing it names is visible. A gateway such as Envoy or Tyk sees every connection that reaches it, as host and port in a tunnel, and as method, path, and headers once it terminates TLS. An in-process library such as station sees the operation and its arguments, and only for callers that use it. Run one bypass test before deciding: one deny rule, three callers, and a record of which were stopped.

You will need

Node 22 or later, Python 3 and curl on the PATH, and an SDK generated by sdkgen for station to bind to. The code directory carries one: the MeterCo SDK that generate.sh built for the page in this series on switching one vendor off, copied unchanged as compiled JavaScript. Verified 2026-10-21 against Node 22.22.0, Python 3.13.16, @voxgig/station 0.2.0, @voxgig/create-sdkgen 0.30.7, and @voxgig/sdkgen 4.34.7. Envoy, Tyk, and Istio did not run here. The gateway is a loopback forward proxy with one host rule, and station runs in solo mode, with no station proxy attached.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Envoy egress gatewayCallers in any language, once a network rule lets only Envoy out, and a team that runs Envoy alreadyA tunnel shows it a host and a port, so a rule on an operation or a tenant needs TLS termination you then own, on top of Envoy’s configuration modelThe rule you need is about what the call does rather than where it goes
Istio egress policyWorkloads already in the mesh, where the sidecar captures traffic whatever the language or the proxy settingsA mesh to run, ALLOW_ANY as the default until you switch to REGISTRY_ONLY, and host-level visibility onlyWorkloads outside Kubernetes, or a team with no mesh
stationCalls leave through sdkgen SDKs in Node, and the rule names an operation, a tenant, or a budgetA library per language in every service, a policy per repository, and a gap wherever a caller skips the SDKCallers in languages station has no port for, or code you do not build
TykKeys, quotas, and analytics per vendor in a gateway you run already, with callers you can point at a listen pathAn API definition per vendor, and a caller holding the vendor’s own URL walks straight past itCallers you cannot repoint, or a rule that must hold on the wire

Voxgig maintains station. It is one of four options here, not the recommendation.

The split is coverage against context. Envoy and Istio work on the wire, so they hold a caller in any language once its traffic reaches them. The Istio sidecar captures that traffic, and a standalone Envoy needs a network rule that lets only Envoy out. In a tunnel either sees a host and a port. station reads the operation, the arguments, and the tenant before a socket opens, but only for a caller that loaded it. Tyk adds keys and quotas for the callers that use its listen path.

Write one deny rule at each layer

A deny rule means one thing at the gateway and another in the process, and the bypass test needs both. At the gateway the rule is a host the forward proxy refuses to carry traffic to. The stand-in answers a plain request with 403 and a tunnel request with a refused CONNECT, and logs what it saw either way:

export async function gateway({ allow }) {
  const state = { log: [] }
  const verdict = (host) => (allow.includes(host) ? 'allowed' : 'denied')

  const server = createServer((req, res) => {
    let target = null
    try {
      target = new URL(req.url) // a proxy request names its target in absolute form
    } catch {
      // Origin form names no upstream, so there is nothing to decide or relay.
    }
    if (target?.protocol !== 'http:') {
      res.writeHead(400, { 'content-type': 'application/json' })
      return res.end(JSON.stringify({ error: 'not a proxy request' }))
    }
    const v = verdict(target.hostname)
    state.log.push(`${req.method} ${target.host}${target.pathname} ${v}`)
    if (v === 'denied') {
      res.writeHead(403, { 'content-type': 'application/json' })
      return res.end(JSON.stringify({ error: 'denied by egress policy', host: target.hostname }))
    }
    // Upstream, Host names the host the rule checked, never the one the caller wrote.
    const headers = { ...req.headers, host: target.host }
    const up = request(
      { host: target.hostname, port: target.port, path: target.pathname + target.search, method: req.method, headers },
      (ures) => {
        res.writeHead(ures.statusCode, ures.headers)
        ures.pipe(res)
      },
    )
    up.on('error', () => {
      res.writeHead(502)
      res.end()
    })
    req.pipe(up)
  })

  const close = closer(server) // before the handler below, so close() reaches every tunnel
  server.on('connect', (req, socket, head) => {
    const [host, port] = req.url.split(':')
    const v = verdict(host)
    state.log.push(`CONNECT ${host}:${port} ${v}`)
    if (v === 'denied') return socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
    const n = Number(port)
    if (!Number.isInteger(n) || n < 1 || n > 65535) return socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
    const up = connect(n, host, () => {
      socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
      if (head.length) up.write(head)
      up.pipe(socket)
      socket.pipe(up)
    })
    up.on('error', () => socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'))
    socket.on('error', () => up.destroy())
  })

  const port = await listen(server)
  return { port, url: `http://127.0.0.1:${port}`, state, close }
}

The two handlers are the gateway’s whole view. A plain request arrives in absolute form, so the log holds the method and the path. Upstream, Host carries the host the rule checked, as RFC 9112 requires of a proxy, so a caller cannot name a denied vendor behind a shared front end. A CONNECT arrives as host:port, which is all a gateway sees of a TLS connection, and all it sees here of a Node fetch started with NODE_USE_ENV_PROXY=1.1 Envoy’s RBAC filter answers a denied request with 403 at the same point, and an Istio mesh set to REGISTRY_ONLY blocks any host without a service entry.2

In the process, station has no deny rule as such. Its policy holds a hosts allowlist per plugin, and a host missing from it is refused with station_host_allow before any connection opens. The service reads that allowlist from STATION_HOSTS:

// The service: the MeterCo SDK registered under station, the in-process control library.
// VENDOR_BASE is the vendor's URL. STATION_HOSTS, when set, is the hosts allowlist station
// enforces before any connection opens, so a vendor host missing from it is the in-process deny
// rule. The credential comes from METERCO_APIKEY, resolved by station from the SDK's slug.
const base = process.env.VENDOR_BASE
const hosts = process.env.STATION_HOSTS?.split(',')
const policy = hosts ? { hosts } : {}

const station = new Station({
  proxy: 'off',
  config: { station: 1, profiles: { default: { sdk: { meterco: { base, policy } } } } },
})
const sdk = station.connect(MetercoSDK, {})

try {
  const meters = await sdk.Meter().list()
  console.log(`reached the vendor, ${meters.length} meter`)
} catch (err) {
  if (err.code === 'station_host_allow') {
    console.log(`stopped in process: ${err.code}, no connection opened`)
  } else if (/fetch failed/.test(err.message)) {
    // The gateway refused the tunnel. The service sees a network failure, not a policy decision.
    console.log('stopped on the wire: fetch failed')
  } else {
    console.log(`failed: ${err.code ?? err.name}`)
  }
}

station.connect wraps the SDK’s transport, so every operation crosses station once. The hosts check runs there, on the request’s host name, and the catch block can branch on err.code. A wire refusal reaches the same block as fetch failed, a network error with no policy in it, which is why the two outcomes print differently.

Call the vendor from three runtimes

Each caller runs in its own process with exactly the environment the platform would set and nothing inherited. The service is the SDK under station. The shell script is plain curl, which reads http_proxy from the environment, in lowercase only. The scheduled job is Python on http.client, which never reads a proxy variable;3 urllib.request would have.

set -u
curl -s -o /dev/null -w '%{http_code}\n' "$VENDOR_BASE/meters"
import http.client
import os
from urllib.parse import urlsplit

target = urlsplit(os.environ["VENDOR_BASE"])
conn = http.client.HTTPConnection(target.hostname, target.port, timeout=5)
try:
    conn.request("GET", "/meters")
    print(conn.getresponse().status)
except OSError as err:
    print(f"connection failed: {type(err).__name__}")

The test runs twice. The first half puts the rule at the gateway and hands every caller HTTP_PROXY; the second half puts it in station and hands nobody a proxy:

node bypass.mjs
@voxgig/station 0.2.0 in solo mode; the gateway and the vendor are loopback stand-ins

deny rule at the gateway, HTTP_PROXY set for every caller
  service, SDK under station               reached the vendor, 1 meter                                    fetch ignores HTTP_PROXY
  service, with NODE_USE_ENV_PROXY=1       stopped on the wire: fetch failed                              the gateway refused the tunnel
  shell script, curl                       403                                                            the gateway answered 403
  scheduled job, python http.client        200                                                            http.client never reads http_proxy
  the gateway saw: CONNECT 127.0.0.1:39731 denied | GET 127.0.0.1:39731/meters denied
  the vendor received 2 of the 4 calls

deny rule in the process, station hosts policy without the vendor
  service, SDK under station               stopped in process: station_host_allow, no connection opened   refused before a socket opened
  shell script, curl                       200                                                            never loaded the library
  scheduled job, python http.client        200                                                            never loaded the library
  the vendor received 2 of the 3 calls

Read the two counts. The gateway rule stopped only the two callers out of four that had asked to be routed through it. curl asked by reading http_proxy, and the service asked by starting with NODE_USE_ENV_PROXY=1, a flag Node added in 24.0.0 and 22.21.0 and leaves off by default. The same service without the flag reached the vendor, and so did the Python job in both halves. The station rule stopped one caller out of three, with a code and before a socket opened, while the two scripts never loaded it.

Neither layer stopped all three callers. Only something that captures the connection itself does that: an Istio sidecar under REGISTRY_ONLY, or a network rule that lets only the gateway out. The second is the arrangement the egress proxy page in this branch ends on. Record the matrix with the decision, name who may change each rule, and write down which layer decides when the two disagree.

Check it worked

Eight tests pin the matrix and the gateway itself. The gateway stops curl and the flagged service while the Python job walks past, and its log holds host and port for a tunnel and method and path for a plain request. The same service bypasses the gateway without the flag. station stops the service before a socket opens and never sees the scripts, and two policy sources give two error shapes. The last three hold the gateway to the checked host and a 400, and show NO_PROXY taking callers past it.

node --test bypass.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0

# pass 8 is the result to expect. The run spawns a Node, a bash, and a Python process per row, so it takes a second or two.

When it goes wrong

Two rules disagree. One team owns the gateway and another owns the library, and the day comes when one of them allows the vendor and the other refuses it:

node pitfall.mjs
two rules for one vendor
  the gateway allows the vendor and the station policy denies it
    service                        stopped in process: station_host_allow, no connection opened
    shell script, curl             200
  the gateway denies the vendor and the station policy allows it
    service                        stopped on the wire: fetch failed
    shell script, curl             403
  one intent, two error shapes: a code the caller can branch on, and a failed fetch

The service is refused either way, in two shapes. When station refuses, the catch block gets station_host_allow, a code it can branch on and log as policy. When the gateway refuses, the same block gets fetch failed, which looks exactly like the vendor being down. Decide which layer rules before this happens, and make the other one report-only for that vendor, so that one refusal has one shape. Telling a denial from an outage is its own page.

The service walks past the gateway. Node’s fetch and http.request both ignore HTTP_PROXY unless the process started with NODE_USE_ENV_PROXY=1. Set the flag in the service’s environment, or give the SDK a proxy-aware dispatcher such as undici’s EnvHttpProxyAgent. Then add the service to the bypass test, so the gap shows up before an incident does. NO_PROXY reopens the gap for every caller that reads the variables, and the runtimes read one list differently. With NO_PROXY=127.0.0.0/8, curl read the entry as a network and called the vendor directly, while Node still sent the call to the gateway. Run the bypass test with the NO_PROXY your platform sets.

The job walks past both. A caller in a language the library has no port for, written against a low-level client, is stopped by nothing on this page. Move it onto urllib.request or onto the vendor’s SDK under a port of the library, or put it behind the network rule, and run the bypass test from that runtime as well.

When not to do this

Do not adopt both layers without writing down which one decides. Two policy sources with two owners produce two refusals with two shapes, and the first incident is spent working out which one fired.

Do not choose station for enforcement over callers you do not build. A library holds only the callers that load it, and a shell script, a scheduled job, or a service in another language loads none of it. Pair it with a wire rule, or accept the gap in writing.

Do not choose a gateway for a rule about what a call does. A tunnel shows it a host and a port, so a rule on an operation, a tenant, or a spend needs TLS termination at the gateway or a library in the process.

Do not run the bypass test only from the runtime you like. The caller that embarrasses the rule is the one nobody thought to include, and the test costs three processes.

Last verified

Verified 2026-10-21 against Node 22.22.0, Python 3.13.16, @voxgig/station 0.2.0, @voxgig/create-sdkgen 0.30.7, and @voxgig/sdkgen 4.34.7. Every output block is what the command preceding it printed. Envoy, Tyk, and Istio did not run: the gateway is a loopback forward proxy with one host rule, and their behavior is read from the documentation linked. station ran in solo mode against the MeterCo SDK generated for another page in this series, whose generator versions are taken from that page’s generate.sh and frontmatter rather than from a run here.

Footnotes

  1. NODE_USE_ENV_PROXY=1 is documented at Stability 1.1, Active Development, added in 24.0.0 and backported to 22.21.0. The entry describes proxy support as being for reaching external networks through authorized proxies, for example when a firewall requires one. The next sentence says it “is not for hiding traffic or evading network policy” in so many words, which is the kind of sentence a flag acquires after somebody has tried. ↩︎ Back to text

  2. The Istio egress control task says ALLOW_ANY is the default, chosen so that you can “start evaluating Istio quickly, without controlling access to external services” at first. The task’s own advice is to configure every service and then switch to REGISTRY_ONLY to block the rest. The policy exists from the first install, set to permit everything until somebody changes it. ↩︎ Back to text

  3. The Python documentation for http.client says the module “is normally not used directly” and that urllib.request uses it underneath. The proxy handling lives in that upper layer, in a ProxyHandler that urllib installs on its own when it finds http_proxy in the environment. A job written one layer down gets the sockets without the proxy handling. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.