# Air gapped

URL: https://antifailure.dev/docs/enterprise/air-gapped

An installation that reaches nothing outside your own network, with the list of every call site it refuses and what is deliberately not covered.

---

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

```sh
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.

## 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

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.

## 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

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

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

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