Skip to content

Type to search pages.

View .md

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.

Terminal window
export AF_LICENSE_KEY=...
export AF_ORG=acme
export AF_AIR_GAPPED=1
export AF_AIR_GAPPED_ALLOW='registry.example.com:5000,10.4.0.0/16,vault.example.com'
af up

AF_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.

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.

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_IMAGE to 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 af looks for, which docker image ls antifailure/proxy shows 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.

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.

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.

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.

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.