Docs
Connect your Vercel app to an IP-restricted API without moving it
A customer or partner will only accept calls from an IP address they have allowlisted, and your app runs on Vercel. Keep deploying on Vercel. Send the server-side calls that need an address through a dedicated IPv4 address of yours, with a small Node SDK, and check at the destination that it is the address you handed over.
What this is, and is not
A Next.js example and an SDK, tested here against a local fake proxy. Neither has run on a real Vercel deployment against a real node yet; that acceptance run is an owner task. The pilot is best effort with no SLA. Only the requests you send through the SDK use your dedicated address.
- The problem
- Setup in four steps
- Several customers in one deployment
- Verify the address at the destination
- Failover
- What it does not do
- Pricing
The problem
The ask is the same each time: send us the IP address your server calls us from, and we will allowlist it. On Vercel there is no such address to send. Vercel's own guidance is that deployments use dynamic outbound IPs by default.
Vercel sells a fix, Static IPs. When we read that page it listed these terms, so check it before you decide:
- Pro and Enterprise plans.
- $100 per month per project, plus data transfer at regional rates (Vercel, Static IPs, Pricing).
- The addresses are shared across a small group of customers in the same region, and each configured region has its own pair.
- Outbound traffic from Vercel Functions only. It does not cover Routing Middleware, and inbound connections do not use it.
The alternative here is to leave the platform alone and route only the calls that need an address through a proxy node with an address that is yours alone. Your project, your build, your domains and your regions stay on Vercel. The SDK sits in your server code and is the only thing that talks to the node.
Why an SDK and not a proxy environment variable? On Vercel Fluid Compute several invocations share one function instance, and the HTTP client reuses connections. A proxy setting that is global to the process, or a connection pool shared between customers, can send one customer's request out through another customer's address. The SDK gives you a client with one fixed identity: its endpoints and credentials are frozen when it is created, it owns its connection pools, it never changes the process-wide settings, and it has no code path that connects without the proxy.
Setup in four steps
The complete app is the Vercel example in the Penduses repository (Next.js App Router, TypeScript, Node.js runtime, pinned versions). The snippets below are taken from it.
1. Install the SDK
The package, @penduses/egress, is not on npm yet. Build it from the repository and install the tarball into your app, then commit the tarball and your lockfile so Vercel can install it:
cd mvp/sdk/node && npm ci && npm run build
npm pack --pack-destination /path/to/your-app/vendor
cd /path/to/your-app
npm install --save ./vendor/penduses-egress-0.1.0.tgz
It needs Node 20 or newer, runs on the server only, and has one runtime dependency, undici.
2. Allowlist the address and set the variables
Your welcome email has the proxy address, the HTTP port (not the SOCKS5 port), the username and the password. Give your customer the dedicated address and ask them to allowlist it. Then add these in the Vercel project settings, under Environment Variables, for Production and Preview. Mark the password as sensitive and never give a name the NEXT_PUBLIC_ prefix: that ships the value to the browser.
| Variable | Value |
|---|---|
PENDUSES_HOST | The proxy address from your welcome email |
PENDUSES_HTTP_PORT | The HTTP port, which accepts CONNECT only |
PENDUSES_USER, PENDUSES_PASSWORD | Your proxy login |
PENDUSES_EXPECTED_IP | The dedicated address the destination must see. A comma separated list means any of these |
ECHO_URL | A service that answers with the caller's TCP source address. It must be on your Penduses allowlist, see Allowlist |
3. Create one client and use it on the server
Create the client at module scope, so every invocation of a function instance reuses its pooled connections, and only when it is first used, so that next build does not need credentials. These are the options, as the SDK README documents them:
const s = readProxySettings('PENDUSES')
single = createEgress({
endpoints: [{ host: s.host, httpPort: s.httpPort, username: s.username, password: s.password }],
expectedIp: s.expectedIp
})
Then make the calls that need your address with egress.fetch(...), undici's fetch bound to the client. Pass egress.dispatcher instead when you use other undici calls. Node's built-in global fetch is not routed through the client.
The route handler of the example uses verifyIp on ECHO_URL. It runs on the Node.js runtime and is evaluated on every request:
import { describeFailure, getEgress, json } from '../../../lib/egress'
import { readEchoUrl } from '../../../lib/env'
export const runtime = 'nodejs' // never 'edge': the SDK needs Node's net, tls and undici
export const dynamic = 'force-dynamic' // evaluated per request, never at build time
/**
* GET /api/check: asks ECHO_URL, through the dedicated IP, which address it sees.
* 200 { observedIp, expectedIp, ok: true } when it is the expected one; 502 with ok: false otherwise.
*/
export async function GET(): Promise<Response> {
try {
const egress = getEgress()
const verified = await egress.verifyIp(readEchoUrl())
return json({ observedIp: verified.observedIp, expectedIp: verified.expectedIp, ok: verified.ok })
} catch (err) {
const failure = describeFailure(err)
return json(failure.body, failure.status)
}
}
4. Pin the region, deploy, check
Pick the function region next to your node: fra1 (Frankfurt) for the Limburg, Germany (LIM) node, cdg1 (Paris) for the Gravelines, France (GRA) node. The region only changes latency to the node; the destination sees the node's address whichever region runs the function. Vercel's default function region for a new project is Washington, D.C. (iad1), a long way from a European node. Do not use the Edge runtime for these routes.
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"regions": ["fra1"]
}
Deploy as usual, then open /api/check on the deployment. You want "ok": true with observedIp equal to the address you gave your customer. Anything else comes with an error code, listed under Failover.
Several customers in one deployment
When one deployment serves several customers, each with their own dedicated address, keep one client per customer in a registry. The registry returns the same client for the same key every time and never mixes two:
registry ??= createEgressRegistry({ resolve: resolveTenantEgress, maxClients: 8 })
return registry.get(tenantId)
The key must come from server-side context, never from request input. The registry binds a key to an address and to the credentials for it, and it does not authorise anything: whoever chooses the key chooses the egress IP and its login. So a tenant name taken from a URL, a header, a cookie or a body field must not reach registry.get, and must never be used to build a variable name or to look up credentials. The example uses a static map on the server. The URL segment only selects an entry of the map, and the entry supplies the registry key and where its settings live:
const { tenant: name } = await context.params
const tenant = lookupTenant(name)
if (!tenant) {
return json({ ok: false, error: 'UNKNOWN_TENANT', message: 'no such tenant', observedIp: null }, 404)
}
A name outside the map is refused before any client exists for it, and the registry's resolve callback refuses an id the map does not define, as a second guard. In your own app there is no URL segment: the key is the tenant of the verified session, or of a job record you created, and the map is your database.
Verify the address at the destination
verifyIp asks ECHO_URL which address it saw and throws IP_MISMATCH when that is not PENDUSES_EXPECTED_IP. It proves the path from your function to the echo service. It does not prove what your customer's server sees, so check there too:
- Read the real TCP source address. Ask your customer to look at the source address of the connection in their own log or firewall, not at an
X-Forwarded-Forheader or a similar field. A header is added by whatever sits in front of their service, and a caller can set it to anything. The echo service you use forECHO_URLmust also answer with the TCP peer address of the connection, not a header. - Match timestamps. Make a call from the app, then find that request in the destination's log by its time. A matching address on a request you cannot identify proves little.
- Mind the cache. Next.js can cache a response at several levels. A route handler or a Server Component that is rendered statically, or a response served from a data cache, can show you a correct-looking address that was produced earlier, or at build time, and says nothing about the current request. Both routes of the example are
force-dynamic, answerCache-Control: no-store, and use the SDK's ownfetchrather than Next.js's patched one. Keep it that way for anything you use to check the address. - Check every environment. Preview and Production can have separate variables. A preview deployment with the wrong or missing value fails closed; it does not fall back to a direct connection.
Failover
The Pro plan has two dedicated addresses, one in GRA and one in LIM, each with its own login. Give the client both endpoints, primary first, and list both addresses in expectedIp; the destination must allowlist both, see Pro failover:
const egress = createEgress({
endpoints: [
{ host: process.env.PENDUSES_HOST!, httpPort: Number(process.env.PENDUSES_HTTP_PORT), username: process.env.PENDUSES_USER!, password: process.env.PENDUSES_PASSWORD! },
{ host: process.env.PENDUSES_HOST_2!, httpPort: Number(process.env.PENDUSES_HTTP_PORT_2), username: process.env.PENDUSES_USER_2!, password: process.env.PENDUSES_PASSWORD_2! }
],
expectedIp: [process.env.PENDUSES_EXPECTED_IP!, process.env.PENDUSES_EXPECTED_IP_2!]
})
Failover is client-side, in your function. There is no automatic failover on our side, and the pilot has no SLA. The SDK behaves like this:
- Only for new connections. A connection that is already open is never moved. When the primary starts failing, pooled connections keep working until they end; every new tunnel goes to the next endpoint. After
endpointCooldownMsthe primary is tried again. - Only on connect-level failures: connection refused, reset or timed out while setting up the tunnel, the proxy closing the socket or never answering
CONNECT, andCONNECTanswered with 503 or 429 or another unexpected status. At that point nothing of the request has been written to any socket, so handing it to the next endpoint cannot duplicate it, whatever the method or body. - Not a failover trigger:
407(PROXY_AUTH, wrong or revoked credentials) and403(DEST_FORBIDDEN, destination not on the allowlist) are thrown at once, because they are the same on every endpoint and failing over would hide a misconfiguration. A TLS error towards the destination, anAbortSignaland anything unexpected also reach you unchanged. - Never a direct connection. With every endpoint down the request fails. The SDK has no code path that connects without a proxy.
- Never a retry of a request that may have been sent. Once the tunnel is up and the request is being written, a broken tunnel throws
EgressErrorNOT_RETRIED, for every method, including GET and a failure in the middle of the response. The outcome at the destination is unknown (RFC 9110 section 9.2.2); retry in your own code only when you know the operation is idempotent.
After a failover to the standby, the destination sees the standby's address. That is why both addresses belong in expectedIp and on the destination's allowlist. Errors are EgressError instances with a code:
| Code | When | Failed over |
|---|---|---|
PROXY_AUTH | CONNECT answered 407 | no |
DEST_FORBIDDEN | CONNECT answered 403 (not allowlisted) | no |
PROXY_BUSY | CONNECT answered 503 (connection limit) or 429 | yes |
PROXY_UNREACHABLE | refused, reset, timed out, no answer, closed, any other CONNECT status | yes |
ALL_ENDPOINTS_DOWN | more than one endpoint and all of them failed | - |
NOT_RETRIED | the tunnel broke after the request may have been sent | no |
IP_MISMATCH | verifyIp saw another address, none, or a non-2xx answer | - |
What it does not do
- No Edge runtime. The SDK needs Node's
net,tlsand undici. Useexport const runtime = 'nodejs'. Edge functions, Routing Middleware and browsers cannot use it. - No database drivers in v1. Only requests made through
egress.fetchoregress.dispatchergo through the proxy.pg,mysql2, Prisma, Redis clients,axioswith its default adapter,node:httpand the globalfetchleave from the platform's own addresses. Database connections are not part of the v1 static egress product. - No inbound traffic. It does not give you an address to receive connections on. For that, see IP Tunnel.
- No private network or VPC peering. The calls cross the public internet to your destination from your dedicated address. This is not a private network between Vercel and your customer.
- No encryption to the node. The node's HTTP port is plain HTTP, so your proxy login crosses the network in clear text between your function and the node, as the quickstart explains. HTTPS destinations stay encrypted end to end; use HTTPS destinations.
- Only destinations on your allowlist. A destination that is not on it is refused with
DEST_FORBIDDEN. - No retries of requests, no rate limiting, no secret storage. The SDK does not authorise tenant keys either; that stays with your code.
- Mind the file descriptors. A Vercel function instance has 1024 file descriptors shared by every invocation in it. The default pool is 8 connections per destination origin and per endpoint (
maxConnections), at most 64. With many tenants or destinations, lower it.
Pricing
Static egress costs $39 per month for Starter (one dedicated address in one region of your choice) and $79 per month for Pro (one address in each of GRA and LIM, which is what failover needs), excl. VAT. Traffic is fair use. See Pricing for the plans and the pilot terms, and compare with Vercel's own price quoted above for what you actually use.
The pilot is best effort, with no SLA. Questions: support@penduses.com.