Palbase
Sign inGet started

Backend SDK

Outbound Network

Your backend reaches the outside world through two independent fences, and both are worth knowing before you call a third-party API. Your Environment may open outbound TCP on port 443 to public addresses only — no other port, and nothing on a private range. Inside that, the runtime replaces globalThis.fetch with an allowlist of hostnames that lives on the stack rather than in your source tree: palbase egress add <host> writes it, and the next palbase push stamps it into the artifact, which makes egress the one setting that waits for a deploy. A call to a host the allowlist does not cover throws inside your handler, immediately and by name — it is not a timeout you have to diagnose.

Quick example

palbase egress add api.stripe.com
# ▸ todoapp/main
# api.stripe.com allowed — effective on the next deploy

palbase push
# … the deploy stamps the current allowlist into the artifact

palbase egress list
# ▸ todoapp/main
# hosts this stack allows:
#   api.stripe.com
import { Controller, Get, Secrets, PalError, z } from "@palbase/backend";

const Balance = z.object({ available: z.number() });
type Balance = z.infer<typeof Balance>;

@Controller("/billing")
export class BillingController {
  @Get("/balance")
  async balance(): Promise<Balance> {
    const key = await Secrets.get("STRIPE_KEY");
    if (!key) throw new PalError(500, "not_configured", "STRIPE_KEY is not set for this Environment");

    // Allowed, because api.stripe.com is on the list this artifact carries.
    const res = await fetch("https://api.stripe.com/v1/balance", {
      headers: { Authorization: `Bearer ${key}` },
    });
    const body = (await res.json()) as { available: [{ amount: number }] };
    return { available: body.available[0].amount };
  }
}

The two fences

The network. Outbound traffic is fenced below your code: DNS and TCP 443 to public addresses are permitted, and private ranges (10.0.0.0/8, 172.16.0.0/12, 169.254.0.0/16) are excluded. Nothing else gets out. A call to http:// on port 80, an SMTP connection on 25, a database on 5432 or an instance-metadata address on 169.254.169.254 does not fail the allowlist check — it fails at the network, whatever your code thinks it is allowed to do.

The runtime. Before your bundle is imported, the runtime installs the fence over globalThis.fetch. Every fetch your handler, job, webhook or hook makes goes through it, and so does every fetch inside a library you import.

Warning: The allowlist is enforced on fetch and nothing else. A dependency that opens a socket with node:net, or speaks HTTP through node:http, is not checked against it. Such a call is still bounded by the network policy above — 443, public addresses — but it is not covered by anything you can see in palbase egress list.

Managing the allowlist

The list is stored on the Environment the moment you write it, at /v1/management/egress, exactly like every other stack setting. What is different is when it takes effect: the deploy reads the stack's current fence and stamps it into the artifact manifest, because the runtime has to have the allowlist before it imports your bundle — a fence the bundle could supply is not a fence.

palbase egress add api.stripe.com      # stored now, enforced after the next push
palbase egress remove api.stripe.com   # same
palbase egress list                    # what the stack currently holds

add on a host already allowed prints <host> is already allowed and writes nothing. remove on a host that was never allowed prints <host> was not allowed and writes nothing — a no-op, not an error. palbase egress has no --json flag, unlike flags list and storage list. See Stack Settings.

Note: palbase egress list shows the stack's list, which is not necessarily the list the running artifact is enforcing. Between an add and the next push, those two differ by design.

For automation, the same store is a two-field document:

curl -X PUT "$PALBASE_URL/v1/management/egress" \
  -H "apikey: $PALBASE_SERVICE_ROLE_KEY" -H "content-type: application/json" \
  -d '{"hosts":["api.stripe.com","*.example.com"],"timeout_ms":60000}'

A PUT replaces the fence rather than merging into it: an allowlist assembled from fragments is one nobody can read off a single screen, and being readable in one place is what makes an allowlist reviewable at all.

What a host may look like

An entry is a bare hostname. palbase egress add trims the argument and lowercases it, then applies the full rule set locally — the point being that you learn the rule when you type it rather than from a failed deploy:

Refused by palbase egress addWhy
https://api.example.com, api.example.com/v1, api.example.com:8443hostname only — no scheme, port or path
anything containing *, ?, #, @ or a spacenot a hostname
93.184.216.34, 2606:2800::1IP literals are not hosts
localhost, anything ending .svc, .cluster.local, .internal, .localhostinternal names
example, example.123a single label, or a top-level label with no letter in it
a name with non-ASCII charactersuse the punycode form

The stack's own PUT is looser: it refuses an empty entry, a URL, a path and whitespace, and accepts everything else. So a curl can store an entry the CLI would have caught — which is the trade you make when you write the fence directly.

At run time the match is exact, with one expansion: a pattern that begins with *. matches any host that ends with the rest of it, so *.stripe.com covers api.stripe.com and files.stripe.com. Matching is on the hostname alone — the port and the path are not consulted, so allowing api.example.com allows every path on it.

Warning: The subdomain form the CLI accepts and the subdomain form the runtime understands are not the same one. palbase egress add documents a leading dot (.example.com) and refuses *, while the runtime's matcher expands only a leading *. — so a .example.com entry matches no hostname at all, and the calls it was meant to allow are denied. Read from both sides of the wire in the source, not fired at a live stack. Until it is fixed, add each subdomain you actually call by its full name, or write a *.example.com entry with a direct PUT /v1/management/egress, which does not apply the CLI's wildcard check.

No allowlist means unrestricted

This is the part to be exact about, because "empty" and "unset" behave the same way and neither of them means "deny".

The deploy stamps an egress declaration into the artifact only when the stack's list is non-empty (or a timeout is set). With no declaration the runtime does not install a fence at all, leaves fetch alone, and says so at boot:

[runtime] egress: UNRESTRICTED — no allowlist is set for this stack (`palbase egress add <host>`)

With a declaration it says the other thing, and names the exemptions:

[runtime] egress fence: 2 host(s) allowed
[runtime] egress: platform hosts exempt — 127.0.0.1

Denying on an absent declaration would break every backend that predates the fence, so undeclared stays open and announces itself — a fence nobody was told about is worse than no fence.

Warning: Removing the last host does not mean "call nothing" — it means unrestricted again. An emptied list produces an artifact with no declaration, and the runtime reads that as allow. The Management API's own field comment describes an explicitly empty list as the value "call nothing", and the store does keep [] distinct from unset, but nothing downstream acts on the distinction. There is no way to deny everything today. Note also that palbase egress list prints no outbound hosts allowed (the backend cannot make external calls) for an empty list — that sentence describes an intent, not the behaviour you will get.

What a denied call looks like

The fence throws inside your handler, before anything leaves the process:

egress denied: api.example.com is not in this backend's declared allowlist. Allow it with `palbase egress add api.example.com`, then push.

The remedy is in the message because it is the only one: the allowlist lives on the stack. defineEgress is still exported from @palbase/backend and still validates its input where you write it — and nothing reads the result, so a declaration in your repository allows nothing. See Stack Settings.

Because it is a thrown Error rather than an HttpError, an unhandled denial surfaces to the caller as a 500 internal_error. Catch it where you can say something better:

import { Injectable, PalError } from "@palbase/backend";

@Injectable()
export class DeliveryService {
  async deliver(host: string): Promise<{ delivered: boolean }> {
    try {
      const res = await fetch(`https://${host}/hook`);
      return { delivered: res.ok };
    } catch (e) {
      throw new PalError(502, "upstream_unreachable", (e as Error).message);
    }
  }
}

The platform's own hosts are exempt

Three surfaces are always reachable regardless of the allowlist: the module surface your backend calls (MODULE_BASE_URL), the JWKS it verifies tokens against (AUTH_JWKS_URL), and the artifact store it reloads from. On the cloud all three are the same local address — http://127.0.0.1:8080, the platform-services process beside your backend — so the exemption is usually a single host, and none of that traffic is fenced either. They are internal traffic rather than egress, and they are exempted by hostname rather than by holding a captured fetch: the module clients resolve globalThis.fetch at call time, so a captured reference would never have reached them.

That exemption is not decoration. Before it existed, the first deploy that declared an allowlist took the backend down: every request answered 500 with egress denied: palsvc is not in this backend's declared allowlist, and the artifact reload loop stopped with it.

Calling another Palbase backend

A backend can call any <ref>.palbase.studio address, including its own. Treat it like any other host: add it with palbase egress add <ref>.palbase.studio if the stack has an allowlist, then push.

Note: Until 2026-09-16 such a call could be dropped silently below your code. A backend saw neither egress denied nor a refused connection, only a fetch that never resolved until its own timeout fired. If you still see that shape on a *.palbase.studio host, it is not your allowlist. Report it with the two refs involved.

Timeouts, and two limits that are not enforced

The stored fence carries an optional timeout_ms beside hosts. When it is set and greater than zero, the runtime aborts an outbound fetch that has not resolved within it, via an AbortController on the call's signal. When it is absent — which is every Environment nobody has written one to — the runtime applies no per-call ceiling of its own, and an outbound call is bounded only by the invocation around it: the request's own lifetime, or a job's @Job({ timeout }).

Nothing in the CLI writes timeout_ms. palbase egress add and remove preserve a value that is already stored but cannot set one, so the only way to set a ceiling today is the raw PUT shown above. The Management API accepts any non-negative integer.

Note: Two numbers appear in the SDK and in older documentation and are not enforced anywhere on the current runtime. defineEgress exports EGRESS_TIMEOUT_MIN_MS (1 000), EGRESS_TIMEOUT_MAX_MS (300 000) and EGRESS_TIMEOUT_DEFAULT_MS (30 000) and validates against them at author time — but nothing reads a config/egress.ts, so no deploy ever sees those bounds and the 30 000 ms default is applied by nothing. The same doc comment says response bodies are buffered whole with a 5 MB cap; that described the retired isolate runtime. The fence in the current runtime passes the Response straight through, so a streaming body streams and nothing caps its size.

  • Stack Settingspalbase egress, and the rest of the settings that live on the stack
  • Secrets — the credential you send to an allowed host
  • Deploying — the push that stamps the allowlist into the artifact
  • Errors — turning a denial into a response your caller can read
  • Overview — the services you can reach without leaving Palbase