How-to › Operate integrations in production

How to give a vendor a stable egress IP to allowlist#

Pin calls to a vendor that allowlists source addresses behind one reserved address, and prove it from inside a worker with an address echo service.

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

Read first: Route outbound API calls through an egress proxy

Calls to the vendor fail in production with a 403 that names no credential, although the same code passed in staging. Their support desk asks for your IP address, a question your platform cannot answer. The workers scale out across three zones, each instance leaves the network from whatever public address it was handed at boot, and the address you registered belonged to a staging box.

What you get

You will end up with one reserved address that every vendor call leaves from, a client that refuses any other route, and a check that asks an echo service what it saw. This is for you if a vendor admits traffic by source address.

Short answer

Route every call to the vendor through one egress that owns a reserved address. That egress can be a NAT gateway with an Elastic IP, a Squid or Envoy proxy bound to a fixed outgoing address, or a Cloudflare dedicated egress IP. Register that one address with the vendor. Then ask an address echo service, from inside a worker and through the same route, which address it saw, and fail the deploy when it is not the registered one.

You will need

Node 22 or later, a vendor that admits traffic by source address, and a network path you can route. Verified 2026-10-21 against Node 22.22.0. Nothing here needs a cloud account: the egress, the vendor, and the echo service are loopback stand-ins. The addresses 127.0.0.42 and 127.0.1.1 to 127.0.1.4 stand in for public ones, because Linux routes all of 127.0.0.0/8 to the loopback interface. MongoDB Atlas allows client connections only from entries in a project’s IP access list,1 and Snowflake network policies allow or deny a request by its origin.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
AWS NAT gateway with an Elastic IPWorkloads inside a VPC that need one public address with no process of yours in the pathAn hourly charge plus a charge per gigabyte, one gateway per Availability Zone to survive a zone failure, and so one address to register per zoneYou need to see or change the request, or the workload runs outside a VPC
Cloudflare Gateway egress policiesDevices and networks already on Cloudflare One, and a vendor that wants one address for people and servers alikeDedicated egress addresses are an account add-on arranged with your account team, and every workload’s traffic has to reach Gateway first, through a client, a PAC file, or a TunnelThe traffic comes from cloud workloads with no Cloudflare on-ramp
Envoy with upstream_bind_configEnvoy is already your egress proxy and you want the pinned address and the per-vendor policy in one processA bind address per cluster in a configuration model with its own vocabulary, and a proxy you patch, scale, and keep up yourselfNobody on the team runs Envoy
Squid with tcp_outgoing_addressA forward proxy you run already, on a host that holds a reserved address, with one rule per ACLA host to patch and monitor, and a second host plus a second registered address before it stops being a single point of failureYour traffic is HTTPS you cannot decrypt and you need more than a host rule

The NAT gateway and the Cloudflare option buy the address as a service, and the two proxies make it a host you run. A NAT gateway bills by the hour per zone and by the gigabyte,2 and it works at the packet level. A method or path rule still needs the proxy from the previous page in this series. Squid and Envoy give you the rule and the address in one process, in exchange for patching, scaling, and a second host before the address stops being a single point of failure.

Pin the address where the connection opens

The address a vendor sees is the source address of the TCP connection that reaches it, so whatever opens that connection decides it. AWS puts the decision in the gateway: a public NAT gateway must have an Elastic IP associated at creation, and that address is the one the vendor sees. Squid puts it in a directive, tcp_outgoing_address 203.0.113.10, and Envoy puts it in a cluster’s upstream_bind_config. The stand-in on this page does the same job with one option of http.request:

/**
 * The pinned egress: a forward proxy that opens every upstream connection from one reserved
 * address. A NAT gateway with an Elastic IP, Squid's tcp_outgoing_address, and Envoy's
 * upstream_bind_config all do this job. Here it is a few lines of node:http, so the page can
 * show what the vendor sees without an account at a cloud provider.
 */
export async function pinnedEgress({ address }) {
  const state = { relayed: 0 }
  const server = createServer((req, res) => {
    let target = null
    try {
      target = new URL(req.url) // absolute form, which is what a client sends to a proxy
    } catch {
      // Origin form, such as a load balancer's health check, names no upstream to relay to.
    }
    if (target?.protocol !== 'http:') return json(res, 400, { error: 'not a proxy request' })
    state.relayed++
    const up = request(
      {
        host: target.hostname,
        port: target.port,
        path: target.pathname + target.search,
        method: req.method,
        headers: req.headers,
        localAddress: address,
      },
      (ures) => {
        res.writeHead(ures.statusCode, ures.headers)
        ures.pipe(res)
      },
    )
    up.on('error', () => json(res, 502, { error: 'the egress could not reach the vendor' }))
    req.pipe(up)
  })
  const port = await listen(server)
  return { url: `http://127.0.0.1:${port}`, address, state, close: closer(server) }
}

localAddress binds the upstream socket to the reserved address before it connects. Everything else in the function is an ordinary forward proxy: read the absolute-form URL, answer 400 to a request without one, relay the rest, copy the answer back. Without that 400, a load balancer health check would crash the egress. The address is the only thing the vendor learns about your side, which is what makes it something they can allowlist.

Register only that address with the vendor. Every other entry in their allowlist is a path around the egress, and the list grows by one each time somebody registers a laptop to get a demo working.

Refuse the direct route in the client

The route is a deployment setting, and settings go missing. A service, a scheduled job, or a scaled-out worker that starts without EGRESS_PROXY would call the vendor from its own address and collect the 403 from the opening paragraph. The client refuses first:

/** Thrown when a worker would call a vendor directly, from an address the vendor has never heard of. */
export class EgressNotConfigured extends Error {
  constructor(url) {
    super(`refusing to send to ${new URL(url).host} without an egress route: set EGRESS_PROXY`)
    this.name = 'EgressNotConfigured'
    this.code = 'egress_not_configured'
  }
}

/**
 * Send one request through the configured egress. `egress` is the proxy URL every production
 * worker is given as EGRESS_PROXY. Without it the call is refused rather than sent directly,
 * because a direct call leaves from whatever address this worker happens to hold today, and the
 * vendor registered the egress address, not that one. `allowDirect` is for the one call that
 * must find out what a direct call looks like: the check below. This sends plain HTTP, which is
 * what the loopback stand-ins speak; for an HTTPS vendor, put the same refusal in front of your
 * runtime's proxy support, which tunnels with CONNECT from the same egress address.
 */
export async function send(url, { egress = process.env.EGRESS_PROXY, method = 'GET', agent, allowDirect = false } = {}) {
  if (!egress && !allowDirect) throw new EgressNotConfigured(url)
  const target = new URL(url)
  const options = egress
    ? { ...hostPort(egress), path: target.href, method, headers: { host: target.host }, agent }
    : { ...hostPort(target), path: target.pathname + target.search, method, agent }
  return new Promise((resolve, reject) => {
    const req = request(options, (res) => {
      let text = ''
      res.on('data', (chunk) => (text += chunk))
      res.on('end', () => {
        try {
          const isJson = /json/.test(res.headers['content-type'] ?? '')
          resolve({ status: res.statusCode, body: isJson ? JSON.parse(text) : text })
        } catch (err) {
          reject(err)
        }
      })
    })
    req.on('error', reject)
    req.end()
  })
}

send reads EGRESS_PROXY and sends the request to that proxy in absolute form, with the vendor’s host in the Host header. Without the variable it throws egress_not_configured before opening a socket. A misconfigured worker then fails on its first call with a code your alerting can name, rather than with a vendor 403 that looks like a credential problem.

Prove it from inside a worker

The check asks an address echo service which address it saw. It asks from inside the workload, through the same route the vendor calls take, and compares the answer with the registered address:

/**
 * Ask an address echo service what address this worker's calls arrive from, the same way the
 * vendor calls go, and compare it with the address registered with the vendor. The answer is plain
 * text, as from checkip.amazonaws.com, or JSON with an `ip` field, as from api.ipify.org with
 * ?format=json. Run it from inside the workload, not from a laptop: the laptop's address is not
 * the one in question. It refuses to run without the registered address, and only a 200 from the
 * echo service counts as an answer, so a check that learned nothing never passes.
 */
export async function checkEgress({ echoUrl, expected, egress = process.env.EGRESS_PROXY, agent }) {
  if (typeof expected !== 'string' || !expected) throw new TypeError('checkEgress needs the registered address')
  const { status, body } = await send(echoUrl, { egress, agent, allowDirect: !egress })
  const seen = status === 200 ? (typeof body === 'string' ? body.trim() : body?.ip) || null : null
  return { seen, expected, ok: seen === expected }
}

Two public services answer it: checkip.amazonaws.com returns the address and a line break, and api.ipify.org returns text or JSON.3 The demo runs four workers, each holding an address of its own, through the pinned egress, then runs the check from one of them.

node demo.mjs
registered with billco: 127.0.0.42, the address of the pinned egress
four production workers, each holding a fresh address, all routed through the egress
  worker 127.0.1.1  200  billco saw 127.0.0.42
  worker 127.0.1.2  200  billco saw 127.0.0.42
  worker 127.0.1.3  200  billco saw 127.0.0.42
  worker 127.0.1.4  200  billco saw 127.0.0.42
distinct addresses billco saw: 1
echo service, asked from worker 127.0.1.3 through the egress: 127.0.0.42
registered with billco: 127.0.0.42, match: true

The vendor saw one address for four workers, and the echo service reports the same one. Run checkEgress where the workload runs: as a deploy job inside the production network, with the production EGRESS_PROXY, failing the rollout on ok: false. From a laptop it reports the laptop’s address, which is how the wrong address got registered.

Check it worked

Seven tests pin the page’s claims. Four routed workers are one address to the vendor, and the same four are four addresses with three refusals when they call directly. The check matches only on the registered address and throws without one. A worker with no egress configured refuses to call at all, and the egress answers 400 to a request it cannot relay.

node --test egress.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0

# pass 7 with # fail 0 is the result to expect. A failure in the second test means the workers’ own addresses reached the vendor, so the route was bypassed.

When it goes wrong

The call that passed in staging fails once traffic spreads. Staging ran one worker, somebody registered the address that worker happened to hold, and production spread the same code over four workers with addresses of their own:

node pitfall.mjs
it worked in staging
  one staging worker, 127.0.1.1, and that address registered with billco
    worker 127.0.1.1  200
  production: traffic spreads over four workers, no egress route, same registration
    worker 127.0.1.1  200
    worker 127.0.1.2  403  source address not registered, billco saw 127.0.1.2
    worker 127.0.1.3  403  source address not registered, billco saw 127.0.1.3
    worker 127.0.1.4  403  source address not registered, billco saw 127.0.1.4
  3 of 4 calls refused, each from a worker that did not exist in staging

The fix is the page: route the workers through the egress and register the egress, never a worker. Serverless reaches the same wall sooner. A Lambda function attached to a VPC reaches the internet through a NAT gateway. The same guide says that attaching a function to a VPC does not by itself give it internet access or a public IP. A function left in the Lambda-managed network calls out from addresses the service chooses.

Half the fleet is refused, by zone. AWS recommends a NAT gateway in each Availability Zone, which means one address per zone. The second half of the same run shows what happens when only one of them was registered:

node pitfall.mjs
one egress per zone, one address registered
  zone a leaves from 127.0.0.42, registered; zone b leaves from 127.0.0.43, not registered
    worker 127.0.1.1 in zone a  200
    worker 127.0.1.2 in zone b  403  source address not registered, billco saw 127.0.0.43
    worker 127.0.1.3 in zone a  200
    worker 127.0.1.4 in zone b  403  source address not registered, billco saw 127.0.0.43
  half the fleet refused, by zone
a worker started without EGRESS_PROXY
    EgressNotConfigured: refusing to send to 127.0.0.1:42531 without an egress route: set EGRESS_PROXY

Register every zone’s address before that zone takes traffic, and run the check from every zone. Cloudflare’s dedicated egress addresses arrive at least two to an account, in two cities, and traffic that matches no egress policy takes the closest one. The same rule applies there: register both and write the catch-all policy.

Every call fails with egress_not_configured after a deploy. A service shipped without EGRESS_PROXY, and the last two lines of that output are what it prints. Set the variable, and treat the refusal as the client doing its job.

When not to do this

Do not build this for a vendor that has not asked for it. A reserved address is one more thing to keep in step between your network and their console, and a NAT gateway bills by the hour whether or not a byte crosses it.

Do not treat the address as authentication. A source address says which network a call left from, not which service made it, or for which customer. Keep the key, keep the per-vendor policy from the previous page, and expect the vendor to want both.

Do not route all egress through one address because one vendor wants it. The egress is a single point of failure and a single queue, and every vendor that never asked for a fixed address then shares both. Route only the vendors that need it.

Do not register an address you saw from a laptop, a bastion, or a CI runner. The address that matters is the one the workload’s calls leave from, so ask from there.

Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it printed. No NAT gateway, Squid, Envoy, or Cloudflare Gateway ran: the egress, the vendor, and the echo service are loopback stand-ins, and the fixed addresses are loopback addresses the processes bound to. The vendor and cloud behaviors are read from the documentation linked.

Footnotes

  1. Atlas’s instructions begin with “Find Your Public IP Address” and tell you to learn “the public IP address that you want to grant access to” before you add an entry. The page is written for a person at a browser, and the address a person finds is the browser’s. It works for exactly as long as the connections come from that chair. ↩︎ Back to text

  2. A NAT gateway is billed for each hour it is provisioned and available, plus a charge per gigabyte processed. Since February 2024 the address is billed as well, at $0.005 per public IPv4 address per hour, “whether attached to a service or not.” NAT itself was published as a stopgap. RFC 1631, from May 1994, concludes that NAT “may be a good short term solution to the address depletion and scaling problems” because it needs few changes. The same section adds that its negative characteristics may make it inappropriate even as a short term solution. RFC 3022 replaced it in January 2001 with the word “Traditional” added to the title. The short term solution is in its fourth decade and is sold by the hour. ↩︎ Back to text

  3. api.ipify.org says it has no request limit and logs no visitor information. Its entire output is the address the request came from, which is the one piece of visitor information it cannot avoid reading. checkip.amazonaws.com answers the same question with the address followed by a newline, in plain text. ↩︎ 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.