Egress IP ranges

Administrator·7 minutes to read

Explains what address a bot's computer is seen from when it reaches the open internet, how you get those addresses, and what you can put in your own firewall for it today.

Hosted only. On the home edition a computer leaves from the owner's own address, and there are no shared ranges to ask for.

Shared ranges, the same for every customer

Hosted computers reach the internet from shared static egress IP ranges. The ranges are the same for every customer: every organisation's computers leave from the same addresses, and there are no per-customer addresses. An address on a destination's own firewall allowlist therefore admits every customer's computers, not only yours — use it to let Botseon in, never as the only thing that keeps others out.

Getting the ranges

Any member asks for them, with no approval step: Settings → Computer → Egress IP ranges → Request the ranges. The answer is the list itself — every production range as a CIDR block (/32 for IPv4, /128 for IPv6), ready for a firewall rule. The request is recorded in the organisation's audit log as egress.ranges_requested, naming who asked and how many ranges the answer carried. While no range is allocated (the table below is empty) the answer says the request is recorded and that the operator's team sends the ranges as soon as they exist; those requests are the ones whose audit row says answered: 0.

The list a member is handed and the table below are one list: packages/computer/src/egress-ranges.ts (HOSTED_EGRESS_RANGES) is what the request answers from, and packages/computer/src/egress-ranges-doc.test.ts fails if that list and this table differ.

Published addresses

This is the list to copy into a firewall rule. Each row is one static outbound address pair allocated to a fleet app in one region; a computer in that app and region leaves from one of that region's rows.

AppRegionIPv4IPv6
botseon-computer-prodfra209.71.114.2222a09:8280:e612:1:0:193:ac09:0

The deployment this applies to

Both the production and development fleets run in one region today, quoted from the configuration that actually builds them rather than retyped, so the two cannot drift apart silently:

app = "botseon-computer-prod"
primary_region = "fra"
app = "botseon-computer-dev"
primary_region = "fra"

packages/computer/src/egress-ranges-doc.test.ts reads both files directly and fails if either app's region changes without this page changing with it. It also reads the table above: while the table is empty the page must go on saying there is none, and the moment it has a row the page must stop saying so, every row must name one of the two apps above in the region that app really deploys to, and every address must be a public one — a private, loopback, link-local or documentation-example address in the table fails the build rather than reaching a customer's firewall.

How the ranges are allocated and published

Fly's own static egress IP feature hands an app fixed outbound addresses. It is allocated against the app, not written in either configuration file above; by hand it is:

fly ips allocate-egress --app botseon-computer-prod -r fra

Each allocation is one pair — one IPv4 and one IPv6 address — scoped to one app in one region, and it survives deploys and machine replacement until it is released. Two things follow from how Fly scopes it (checked against Fly's documentation on 2026-10-13, recorded in docs/research/2026-10-13-fly-static-egress-ips.md):

  • An app with machines in more than one region needs at least one pair per region; a machine only uses addresses allocated in its own region.
  • One address serves at most 64 machines in its region. Every computer is its own machine, so a fleet larger than that needs more than one pair, and Fly, not Botseon, picks which of a region's addresses a given computer leaves from — allowlist every row for the app, never one of them.

The operator does not type the pairs in. One command reads what Fly holds for both fleet apps, allocates what is missing and writes the result into both places at once:

FLY_API_TOKEN=... pnpm exec tsx packages/computer/src/cli-egress-ranges.ts            # report only
FLY_API_TOKEN=... pnpm exec tsx packages/computer/src/cli-egress-ranges.ts --allocate --write

With no flag it prints, per app and region, the machines running, the pairs held and the pairs needed — one per region the app deploys to or runs a machine in, and one more for every 64 machines there — and changes nothing. --allocate allocates the missing pairs (each IPv4 address is billed by Fly). --write puts every held pair into the table at the top of this page and into HOSTED_EGRESS_RANGES in packages/computer/src/egress-ranges.ts, and removes this page's paragraph under the table and the section explaining why nothing is allocated yet, in the same change; if every pair is ever released, the same run puts them back. It refuses to write an address that is not public, or a page this file's own test would reject (packages/computer/src/egress-ranges-publish.ts). From the commit that carries its output on, a request is answered with the list at once. Run it again whenever the fleet in a region grows past a multiple of 64 machines.

The operator does not need the token on their own machine either: the egress-ranges workflow (.github/workflows/egress-ranges.yml, run by dispatch and approved under the prod Environment) runs the same command with --write, and with --allocate unless the dispatch's allocate input is turned off, using the production computer's deploy token as FLY_API_TOKEN_BOTSEON_COMPUTER_PROD, and opens the pull request that carries both files. Each app reads its own FLY_API_TOKEN_<APP> first and FLY_API_TOKEN after it; an app with neither is skipped and the rows already published for it are kept, never dropped. The command prints every pair it holds (app, region, IPv4, IPv6) in the run's log and summary before it writes anything, and the job regenerates the repository's land-owned generated files in its own tree before it pushes, so a generated file main has not caught up with cannot refuse the push. A run that allocated but opened no pull request is repaired by dispatching it again: the pair is held, so nothing more is allocated, and the run publishes what Fly holds. The repository does not let GitHub Actions open pull requests, so a run can push its branch and still fail at that step (run 39602370861): the run's summary then names the pushed swarm/egress-ranges-<run id> branch with the link that opens its pull request.

Whether or not they are published, the answer for an organisation that wants to control what Botseon's computers can reach is the control this product offers regardless of which address a packet leaves from — it stays the control after the ranges are published, because a source address admits every customer's computers alike: Settings → Computer → Internet access, where an administrator scopes the allowlist that decides what a computer is allowed to dial out to in the first place (see docs/using/administration/policies.md). There is no source-address editor.

The activity export's addresses

The activity export (/telemetry) does not leave from a bot's computer. The hosted worker sends it to your organisation's OpenTelemetry collector, so a collector behind a firewall allowlists the worker's addresses, not the ranges above. The worker is one app in one region:

app = "botseon-worker-prod"
primary_region = "fra"
Exporter appRegionIPv4IPv6
botseon-worker-prodfra209.71.109.1632a09:8280:e612:1:0:193:ac05:0

Add both addresses to your collector's firewall allowlist. They are the production worker's static egress pair, allocated on 2026-10-20 (fly ips list -a botseon-worker-prod), and the Activity export screen shows the same pair. Only the export leaves from them: a bot's computer leaves from the fleet ranges above. The development worker (botseon-worker-dev) has no static egress pair and is not a customer environment, so nothing is published for it. A further pair is allocated with fly ips allocate-egress --app botseon-worker-prod -r fra (billed, so the operator's step) and added as a row here and to OTLP_EGRESS_ADDRESSES in packages/connectors/src/otlp-egress.ts in the same change.

This table and that constant are one list: packages/connectors/src/otlp-egress-doc.test.ts fails the build when they differ, when a row names another app or region than the configuration above, or when an address is not public. The screen shows the same list.

Hosted only. On the home edition the export leaves from the owner's own machine.

Last verified against build 1d934f7.