Air gapped
An air gapped installation reaches nothing outside your own network. Not the licence server, because there is not one. Not a telemetry endpoint, not a release check, not a model provider, not Docker Hub, and not the third party APIs your application calls.
Turning it on
Section titled “Turning it on”export AF_LICENSE_KEY=...export AF_ORG=acmeexport AF_AIR_GAPPED=1export AF_AIR_GAPPED_ALLOW='registry.example.com:5000,10.4.0.0/16,vault.example.com'af upAF_AIR_GAPPED_ALLOW is your own network, and it is empty by default. Each
entry is a hostname, a hostname and port, an IP address or a CIDR. A bare
hostname permits every port on it; an entry that names a port permits that port
and no other.
A private range is not permitted implicitly. An internal registry on
10.0.0.0/8 is reachable because you named it, not because the range looked
harmless. A flat corporate network would otherwise widen the air gap for
everybody on it, silently.
Loopback and unix sockets are always permitted. The sidecar, a local Postgres and the Docker daemon are addressed there, and an installation that could not reach them could not run at all.
An entry that is not an address stops the binary. https://registry.example.com/v2/
is refused rather than ignored.
What happens without the licence
Section titled “What happens without the licence”AF_AIR_GAPPED set on an installation whose licence does not include
air_gapped does not start. It does not warn and carry on unsealed.
There is no way to unseal a running process. Everywhere else in this product a licence is asked per call, so that a lapse degrades a feature rather than requiring a restart; this one is the opposite, and a licence lapse does not unseal a running installation.
What it refuses
Section titled “What it refuses”Every outbound client in the engine dials through one guard. Sealed, each of these is refused unless the address is in your allow list, and each refusal is recorded with the site that made it.
| What | Where it would have gone |
|---|---|
| the release check | api.github.com, and the release download af update fetches |
| the telemetry exporter | OTEL_EXPORTER_OTLP_ENDPOINT |
| the model key probe | your model provider, from af model test and from the MCP server |
| the code reviewer | your model provider, from the static code review lane in af ci |
| the workflow oracle | the two deployments af oracle compares |
| the identity provider seeding | Clerk, Auth0, WorkOS |
| the control plane client | the control plane |
| the control plane identity discovery | the control plane’s OIDC endpoint |
| the device authorization login | the control plane |
| the load generator | the application under test |
| the S3 golden store | AWS |
| the Azure Blob golden store | Azure |
| the GCS golden store | Google Cloud Storage |
| the Neon control API | console.neon.tech |
| the Supabase management API | api.supabase.com |
| the Database Lab API | your DBLab server |
| the Aurora control API | AWS, to create and branch an Aurora cluster |
| the Xata control API | api.xata.tech |
| the RDS control API | AWS, to snapshot and restore an RDS for PostgreSQL instance |
| the Cloud SQL control API | Google Cloud, to clone and branch a Cloud SQL instance |
| the Azure PostgreSQL control API | Azure, to restore and branch a flexible server |
| the ClickHouse HTTP interface | your ClickHouse server |
| the service readiness probe | the environment, over loopback |
| the webhook delivery | a service in the environment |
| the doctor reachability check | whatever it was asked about |
| the cloud credential path | AWS, GCP, Azure or Vault, for every secret store and every managed database provider |
| the audit stream sink | your syslog receiver, your webhook endpoint, or the object store the audit stream is dropped into |
| the runtime conformance suite | the internet, on purpose, which is why it is here |
| the emulator seeding | the environment’s own sidecar on loopback, to create the cloud resources production declares inside the emulators. Nothing outside this machine |
| the container image pull | the registry the image reference names, which for the sidecar is ghcr.io unless AF_PROXY_IMAGE names your own |
| the container image build | Docker Hub, for the sidecar’s base image |
Three of those are worth naming separately.
Name resolution. af doctor resolves a host without dialing it, and a
resolver query is an outbound packet carrying exactly the name an air gapped
installation was not supposed to be interested in. It does not look like a
connection, which is why it is the one that gets missed. The guard also refuses
a hostname before resolving it, so a refused connection does not put the
name on the wire on its way to being refused.
Container images. A pull happens in the Docker daemon, over a socket the guard never sees, so it is checked against the registry the reference names before the daemon is asked. Both callers look for the image locally first, so an installation that loaded its images from a tarball or an internal registry runs untouched. What is refused is the silent reach for Docker Hub.
The sidecar image. A release publishes it to ghcr.io, and on a machine
that is not air gapped af up fetches it from there before it would compile
anything. Under an air gap neither happens. The image has to be on the machine
already, and when it is not, af up stops with one refusal, at the container
image build, because compiling it would pull its golang:1.25-alpine base
image from Docker Hub. The error names the image and both ways to supply it.
Two ways through, and neither needs the internet:
- Mirror the published image into a registry your allow list names, and set
AF_PROXY_IMAGEto its reference in your registry. The engine fetches that and never falls back to compiling, because falling back would reach Docker Hub on a machine configured not to. - Load the image into the daemon under the name
aflooks for, whichdocker image ls antifailure/proxyshows on any machine that has run it.
Either way the image has to say it is this sidecar. Every sidecar image carries
a dev.antifailure.proxy-sources label naming the digest of the source it was
built from, and an image fetched from anywhere whose label does not match the
source this af carries is refused rather than run, whatever it is called. An
image af compiled carries the label too, so pushing that into your registry
works.
The forwarder that publishes a service’s port on your loopback is this same image started in forward mode, so an environment that publishes ports needs no other image and reaches for nothing more.
Which database you may use
Section titled “Which database you may use”An environment is refused before it is created when its database.provider has
a control plane outside your network.
| Provider | Air gapped |
|---|---|
docker |
permitted, a container on this machine |
dblab |
permitted, a Database Lab Engine you host |
pgurl |
permitted, a connection string you supplied |
neon |
refused, creating a branch means console.neon.tech |
supabase |
refused, creating a branch means api.supabase.com |
The permitted side is the list, not the refused side: a provider added to this product later is refused here until somebody classifies it.
What your application may do
Section titled “What your application may do”The largest outbound path in a preview environment is not the engine, it is the application. Egress rules decide that, and an air gapped installation refuses an environment whose rules would leave your network, before it is created, naming every rule.
| Mode | Air gapped |
|---|---|
block |
permitted, the request is refused inside the environment |
capture |
permitted, the message is recorded and the provider’s success shape returned |
mock |
permitted, answered from a pack in your repository |
allow |
refused, it forwards the request to the real host |
sandbox |
refused, it substitutes a test credential and still forwards to the real host |
synth |
refused, it asks a model provider to invent the response |
The same applies to egress.default, which is the mode every host no rule names
gets. A manifest with default: allow and no rules at all reaches the whole
internet, and it is refused for exactly that.
sandbox is the one people are surprised by. Substituting a test credential
does not stop the connection being made or the request leaving; it changes what
the request carries.
The environment is refused rather than quietly downgraded. An environment
switched from allow to block behind your back would come up green.
What is not covered, and why
Section titled “What is not covered, and why”Four things sit outside the guard:
Building your application’s image. docker build runs in the daemon and in
BuildKit, and what it fetches is a base image and whatever your package manager
resolves. None of that passes through this process. Governing it is the daemon’s
job: build on a machine whose registry mirror and package mirror are internal,
or use build.strategy: image and supply a prebuilt image, which an air gapped
installation usually already does.
The Postgres connection. Connections made by the database drivers go to the
URL you supply, through a driver the guard does not sit on. Two things close the
ordinary way of getting such a URL. The cloud providers’ own control APIs, which
is how a Neon or Supabase branch is created in the first place, are guarded and
refused. And the environment itself is refused before it is created when its
database.provider is one whose control plane is somebody else’s.
The Kubernetes runtime. af talks to whatever cluster your kubeconfig names.
That is your cluster by definition, and the guard does not sit on the client.
The Docker daemon. af talks to the daemon DOCKER_HOST names, which is a
unix socket on the machine by default and is permitted for that reason. Pointing
it at a remote daemon over TCP is a connection the guard does not sit on.
Proving it
Section titled “Proving it”engine/internal/runtime/local carries a test that seals the guard and then
performs a complete lifecycle, bringing an environment up on real Docker,
serving a request through it, and tearing it down. It asserts that the ledger
contains zero refusals, and separately that the ledger contains the readiness
probe, because zero refusals out of zero observations is not a measurement.
engine/pkg/airgap carries a second test that walks the source of both modules
looking for an outbound client that does not go through the guard. It has its
own test that it can say no, pointed at a fixture that reaches the network six
different ways. And a third test compares the table
above against the guard’s own source in both directions, so a site added without
a row here, or a row here naming a refusal that does not happen, is a failure
rather than a slow drift.