A docker build runs your RUN steps, and every dependency they fetch, with unrestricted network
access. Buildcage puts that behind an allowlist: each RUN step can reach only the destinations you
name, and anything else is blocked and reported.
- Your Dockerfile doesn't change, BuildKit isn't patched, and the CA and variables Buildcage adds to a step are taken back out before its layer is committed, apart from the exceptions in CA trust and compatibility.
- Run once in
auditmode and the report writes the allowlist for you, ready to review and paste back into the workflow. - A rule can name an HTTP method and a URL, inside TLS as well, so a build can fetch a package from a registry without being able to publish one to it.
- It all runs inside your GitHub Actions job, with no agent to install and no hosted service in the build's traffic path.
See buildcage.github.io for what it does and why. To isolate a
workflow run: step rather than a Docker build, use
buildcage/isolated-run.
- Requirements
- Usage
- Engines
- Inputs
- Report action
- How it works
- CA trust and compatibility
- Scope
- Limitations
- FAQ
- GitHub's native egress firewall
- Documentation
The builder is a container on the runner itself, so this action needs a Linux runner with a working Docker installation:
- GitHub-hosted
ubuntu-latest, the versionedubuntu-*images, and their-armvariants- Lightweight images such as
ubuntu-slimare not supported: they ship a Docker client with no daemon
- Self-hosted
- Docker Engine 25.0 or later, with Compose v2.20.2 or later
- A host using cgroup v2
- For the
inspectengine, a Docker data root that can hold an overlayfs upper directory: not itself on overlayfs (Docker-in-Docker needs a volume for/var/lib/docker) and not on XFS formatted withftype=0
A runner that falls short fails while the builder starts, before any RUN step runs.
Buildcage starts a BuildKit builder in your job. Point Docker Buildx at it as a remote driver and
build as usual. Run once in audit mode to collect what the build reaches, then
switch to restrict. The examples below use the default inspect engine; Engines
covers the choice of engine.
- name: Start Buildcage in audit mode uses: buildcage/docker@1a8142a6fe143c76982405411cf41652cb247d54 # v4.4.3 with: proxy-mode: audit # Log every destination, enforce no rules - name: Set up Docker Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 with: driver: remote endpoint: docker-container://buildcage - name: Build uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . - name: Show Buildcage report if: always() uses: buildcage/docker/report@1a8142a6fe143c76982405411cf41652cb247d54 # v4.4.3
The report action writes every destination the build contacted to the Job Summary:
Its Switch to restrict mode section holds the allowlist, already written out from what the build
actually did. A request whose method, host or path contains rule syntax, such as *, is listed
below the allowlist instead of being added to it, since a rule copied from it would permit more
than was sent.
Paste that allowlist into the setup step and switch the mode:
- name: Start Buildcage in restrict mode uses: buildcage/docker@1a8142a6fe143c76982405411cf41652cb247d54 # v4.4.3 with: proxy-mode: restrict allowed-url-rules: | GET http://deb.debian.org/** GET https://registry.npmjs.org/** POST https://registry.npmjs.org/-/npm/v1/security/advisories/bulk
Each rule names the methods it permits, so this one lets npm fetch packages without letting it
publish any: a POST to the same host is blocked, as is every host not listed.
A blocked connection fails the job at the report step, so a build that starts reaching somewhere new doesn't pass unnoticed.
Each pair runs a build in audit, then in restrict with the rules the audit reported, plus one request those rules block so the report shows a block:
proxy-engine sets how much of each connection Buildcage reads, and so what a rule can match.
inspect is the default; universal has to be set explicitly.
inspect terminates TLS and re-signs it with a CA generated for each builder container. Rules
match on method and URL, so GET|HEAD https://registry.npmjs.org/** allows a fetch while blocking a
publish on the same host, and the report names every request with its URL. The build has to trust
that CA: Buildcage adds it to the system store, to the CA-trust variables, to a JVM already in the
base image, and to Chromium's NSS database, so most toolchains need nothing extra (see CA trust and
compatibility).
universal reads only the SNI. Rules match on host and port only, such as registry.npmjs.org:443.
The report shows host and port, and the build's own TLS is left untouched.
Start with inspect. When a tool pins a certificate or ships a trust store Buildcage cannot inject
into, its host cannot be re-signed: switch the whole build to universal, or pass that host alone
through with allowed-tls-rules.
Every engine intercepts at the network level, so a tool that ignores HTTP_PROXY is covered too.
Only traffic from RUN steps is intercepted. What buildkitd fetches for itself is not: see
Limitations.
Every input is optional. The sections below cover the rule inputs you write by hand. The full list, with defaults and the engines each input applies to, is in Reference, and the grammar those rules are written in is in Rule syntax.
The builder is named buildcage unless builder-name says otherwise, and the Buildx endpoint has
to match whatever it is named. The setup action's builder-name output holds the name it used.
The inputs can also live in a YAML file next to the Dockerfile, named by config-file. See
Config file.
A # starts a comment at the start of a line or after whitespace, so a group of rules can carry a
heading or a rule can carry its reason. It works the same in every rule input.
proxy-mode: audit logs every destination the build reaches and enforces none of the rules. It
still blocks what no rule could allow, such as a malformed request or a name that resolves to an
internal address like cloud metadata or the runner itself, and under inspect an origin whose
certificate fails verification.
restrict, the default, allows only what the rules match and blocks and logs everything else. Start
with audit when you first adopt Buildcage or when a dependency changes, and keep restrict for
everyday builds.
If you forget a domain the build needs, restrict blocks it and the report step fails with the
destination named, which is why it is worth running audit first.
audit also lets through a connection made straight to an address. Under universal that includes
cloud metadata (169.254.169.254) and the runner's own addresses; inspect still blocks those
unless a rule names the address as its host.
allowed-url-rules is the one to reach for. Each line is a method list, a space, and a URL pattern.
* stays inside one domain label or path segment, ** crosses dots and slashes, and a rule with no
path allows any path on that host:
allowed-url-rules: | # apt GET http://deb.debian.org/** # npm: fetch packages, and the audit endpoint it posts to GET https://registry.npmjs.org/** POST https://registry.npmjs.org/-/npm/v1/security/advisories/bulk # pip: one registry, two domains GET https://pypi.org/simple/** GET https://files.pythonhosted.org/packages/** # a private registry is an ordinary host GET|HEAD https://registry.internal.example.com:8443/**
allowed-tls-rules passes a TLS destination through undecrypted, judged on its SNI and port. It is
for TLS that isn't HTTPS, and for hosts whose clients cannot accept the inspect CA, such as a tool
that pins its certificate:
allowed-tls-rules: | db.example.com:5432 repo.maven.apache.org:443
allowed-ip-rules covers connections made straight to an address, which never go through DNS. A
rule may be an address, a CIDR block or a wildcard, on every engine:
allowed-ip-rules: | 192.168.1.10:443 10.0.0.0/8:443
universal never decrypts, so rules name a host and a port. allowed-https-rules and
allowed-http-rules split by scheme, and allowed-ip-rules works as it does under inspect:
allowed-https-rules: | # npm and maven registry.npmjs.org:443 repo.maven.apache.org:443 *.internal.example.com:443 # every host one level under internal.example.com allowed-http-rules: | deb.debian.org:80 allowed-ip-rules: | 192.168.1.10:443
A noisy dependency, or a domain you are deliberately keeping off the allowlist to confirm it stays
blocked, belongs in known-blocked-rules. Their rows move to the report's collapsed Known Blocked
Hosts and do not fail the job; the destination stays unreachable:
known-blocked-rules: | telemetry.example.com
A name that was only looked up, never connected to, has no port. A bare telemetry.example.com
(or telemetry.example.com:*) covers it; telemetry.example.com:443 does not, since no port was
involved.
One rule per line, and on inspect a line can also be a URL rule (a method and a URL, the
allowed-url-rules syntax) to acknowledge a single endpoint on a host whose other traffic is
allowed, such as a telemetry POST to an API you otherwise use:
known-blocked-rules: | POST https://api.example.com/telemetry
Any other blocked request to that host still fails the step. See Blocked rules for the full syntax.
buildcage/docker/report reads the builder's communication log and writes the Job Summary. Add it
with if: always() so a failing build still reports:
- name: Show Buildcage report if: always() uses: buildcage/docker/report@1a8142a6fe143c76982405411cf41652cb247d54 # v4.4.3
When anything needs attention, an alert under the report's title gives the counts, such as how many connections were blocked or failed. The report then lists, in this order:
- what was blocked unexpectedly, and any connection that failed, each with its reason
- the allowed hosts
- what
known-blocked-rulesmatched, collapsed under Known Blocked Hosts - in
auditmode, the Switch to restrict mode allowlist - under
inspect, Communication details: the URL of every request, allowed or blocked, with credential query parameters replaced (see Credentials in a URL)
How the alert and the tables are made up is in Report details.
In restrict mode the step fails when a blocked connection is found, and with it the workflow. Pass
fail-on-blocked: false to report without failing, or list what you expect to stay blocked in the
setup action's known-blocked-rules. In audit mode a blocked connection or an incomplete report
does not fail the step. A proxy or resolver that stopped answering, or a log the report could not
fetch, fails it in either mode. The
action's inputs are in
Reference.
upload-traffic-artifact: true uploads the whole timeline as a traffic.json, one row per request
and per name lookup, with the method, URL, status, size and the address it resolved to. It is
uploaded even when the build fails. Under universal it omits the method, URL, status and resolved
address that only inspect sees. The fields are listed in
Reference.
BuildKit itself is unpatched. Buildcage starts it with a network configuration that puts every
RUN step on its own network, and wraps its runtime so the build CA is mounted into a step for as
long as that step runs. Name lookups and traffic from there reach the resolver and the proxy in the
builder container, at the network level, so a tool that ignores the proxy environment variables is
covered as well. The figure is the inspect engine; universal follows the same path without
terminating TLS, and so needs no CA.
Buildx needs driver: remote because the builder is a second BuildKit. Multi-stage builds, caching
and the image that comes out are unaffected, and nothing Buildcage does changes the build definition
BuildKit runs (its LLB) or a cache key.
Security Details has the architecture of each engine, with a diagram of what runs where and what it decides.
proxy-engine: inspect terminates TLS and re-signs it with a CA generated for each builder
container, whose private key never leaves that container
(details), so the build has to trust that CA. As each
RUN step starts, Buildcage adds it where the common toolchains look:
- The system CA store, including the distribution's anchor directory, so a step that installs
ca-certificatespartway through keeps trusting it. - The variables Node, Deno, OpenSSL, Python
requestsand pip read:NODE_EXTRA_CA_CERTS,DENO_CERT,SSL_CERT_FILE,REQUESTS_CA_BUNDLEandPIP_CERT, andCURL_CA_BUNDLEin a step with no system CA store. If the base image or the Dockerfile already sets one of them, the CA is appended to the file it names instead of the variable being pointed elsewhere.GIT_SSL_CAINFO,npm_config_cafile,AWS_CA_BUNDLE,CARGO_HTTP_CAINFOandBUNDLE_SSL_CA_CERTare handled only that way: appended to when already set, and otherwise left unset. - The keystore of a JVM already in the base image, at
$JAVA_HOMEand behind the firstjavaonPATH, somvn,gradleandjavareach the proxy withoutproxy-engine: universal. - The NSS database in
$HOMEthat Chromium reads, including thechrome-headless-shellthat Puppeteer, Playwright and Remotion download.
All of it is taken back out before the layer is committed, so neither the CA nor the variables are
left in the image. Two kinds of copy are the exception: one inside something Buildcage cannot read,
such as a compressed archive, and one it found but could not remove, which
fail-on-ca-residue: false lets through with a warning. How each one is done is in
CA trust variables, and what this cannot cover is in
Limitations, below.
Buildcage controls where your build can connect, not what code it runs. A malicious package delivered through an allowed domain still runs. Treat it as one layer among your other measures: if something gets past them, it still cannot reach a destination outside the allowlist.
An allowlist also cannot stop anything leaving through a service you had to allow anyway. It stops traffic to destinations that are not on the list, such as infrastructure an attacker set up; see Threat model.
An allowlist generated from an audit run already blocks every host the audit did not record, apart
from what the allowed-tls-rules and allowed-ip-rules it carries over from the audit run's own
inputs admit.
Whether to go further depends on what the build has access to:
Hardening is what to look at when it holds credentials, personal
data, or source you do not publish. For the full threat model, see
Security Details.
- buildkitd's own traffic is outside the allowlist.
FROM,ADD <url>, git contexts and the frontend image a# syntax=directive names are fetched by buildkitd itself rather than by aRUNstep, and no rule applies to them. See What buildkitd fetches itself. - Traffic an
allowed-tls-rulesentry passes is not decrypted. The SNI and the port are checked, and the proxy resolves that name itself, so the connection reaches the host the rule named, but nothing inside the TLS session is seen. - Traffic an
allowed-ip-rulesentry passes is not inspected at all, and doesn't require TLS either: once anip:portpair is allowed, any TCP-based protocol can use that path. Prefer a domain rule wherever the destination has a stable name. universalnever sees the method or the path. They travel inside TLS, so neither is enforced and neither reaches the report or the traffic artifact. A request fronted behind an allowed SNI is invisible to it as well, whileinspectmatches on the realHostand blocks it. See Domain fronting.- An allowlisted name that resolves to cloud metadata, to loopback, or to an address the runner
itself holds is blocked, so a compromised name cannot turn the proxy into a route back into the
runner. A mirror or registry running on the runner is therefore not reachable by name: allow it
with
allowed-ip-rules, which skips that guard except for the proxy's own port. See A name may not resolve inward. - Under
inspect, the generated allowlist writes URL rules only from the requests the proxy read.allowed-tls-rulesandallowed-ip-rulesare copied over unchanged from the audit run's own inputs, since the traffic they pass through is never decrypted. - Under
universal, the generated allowlist writes a host rule for the SNI orHostof each connection made through a name, and an IP rule for each address reached directly. - Buildcage produces no SLSA provenance. The traffic artifact is an observation record with no content digest.
- UDP is dropped, so QUIC and HTTP/3 either fall back to TCP or fail. The one exception is port 53 on the gateway, which is the proxy's own address and where the resolver listens. ICMP is dropped too.
- IPv6 is not used anywhere. The rule syntax refuses an IPv6 address, forwarded IPv6 is dropped, and the proxy reaches allowed names over IPv4 only, so an allowed name with AAAA records and no A record never resolves and no rule can clear it.
The resolver has no upstream, so it returns nothing for a discovery record: SRV, TXT, TLSA and
URI queries come back empty, and the build connects to the name a rule allowed rather than to one
a nameserver picked for it. A client that treats SRV as an optional discovery layer falls back to
the host name itself, so the host a rule names is the host the build reaches.
What this breaks is a client with no fallback, where the record is the only way it can find the
service at all. A mongodb+srv:// connection string is the one to expect: use mongodb:// with the
shard hostnames written out and allowlist those instead. Active Directory and Kerberos discovery
have the same shape.
Under inspect, a lookup for a _service._proto.<host> name is reported as discovery when the
rules allow that host, and is not counted as blocked. A service name under any other host is
reported as blocked; see
Blocked service names.
-
A tool that pins a specific certificate, or ships a bundled trust store it never lets the system update, still needs
proxy-engine: universalor anallowed-tls-rulespassthrough:inspectre-signs the connection, and a pinned or bundled store will not accept the new certificate. -
A client that insists on HTTP/2 through ALPN, as gRPC clients do (grpc-go since 1.67, for one), fails to connect:
inspectanswers no ALPN. Pass its host through withallowed-tls-rules, which judges the SNI and port only. A client that falls back to HTTP/1.1, such as curl, works. -
Only two JDKs get the CA in their keystore: the one at
$JAVA_HOMEand the one behind the firstjavaonPATH. Even those are left alone when the keystore is sealed with a password other than the JDK default; such a JDK needsproxy-engine: universalor anallowed-tls-rulespassthrough. These JDKs do not trust the CA: one further alongPATH, a Gradle toolchain under~/.gradle/jdks, or one the step itself downloads (ansdk install, an unpacked tarball). Fetch such a JDK in an earlierRUNstep, then useENVto put itsbinfirst onPATHor pointJAVA_HOMEat it:RUN tar xzf jdk-21.tar.gz -C /opt/java && /opt/java/jdk-21/bin/java -jar fetch.jar # fails RUN tar xzf jdk-21.tar.gz -C /opt/java ENV PATH=/opt/java/jdk-21/bin:$PATH RUN java -jar fetch.jar # fine
-
Chromium trusts the CA only through the NSS database under the
HOMEthe step starts with. Chromium started with anotherHOME(HOME=/tmp chromium) or as another user (su,gosu,sudo -u) does not trust it: switch users withUSERand leaveHOMEalone within the step. A Chromium before M146 reads only~/.pki/nssdb. Buildcage adds the CA by giving a copy of the database'spkcs11.txtan extra entry that points at a store holding only the CA. A database the step's user cannot write gets no such entry, and the step warns; useproxy-engine: universalfor Chromium there. See Chromium. -
A Firefox carrying Mozilla's own root list, such as Playwright's or the one Selenium drives, reads a per-profile database and does not trust the CA; use
proxy-engine: universalor anallowed-tls-rulespassthrough for it. -
Every
RUNstep has the NSS database's directory as a mount point, created if the home has none, sorm -rf ~/.pki,git clone <url> .into the home andrm -rf ~/.[!.]*fail; use aWORKDIRoutside the home. A step that changes the CA's own trust withcertutil -Mfails the build. -
A
RUNstep that copies the system CA bundle into a binary or an uncompressed archive (go:embed,include_str!,tar cf) fails: the copy carries the build's CA, which cannot be cut out of a binary without corrupting it. Copy the bundle in an earlierRUNstep instead:RUN cp /etc/ssl/certs/ca-certificates.crt ./certs/ && go build # fails RUN cp /etc/ssl/certs/ca-certificates.crt ./certs/ RUN go build # fine
-
A copy of the CA left in a step's layer is removed. One that cannot be, such as in a JKS keystore sealed with a password other than
changeit, fails the build (fail-on-ca-residue: falsemakes it a warning). One that cannot be read, such as in a compressed archive, is not found and stays in the image. See CA residue. -
auditterminates TLS as well. It drops the rules, not the interception, so a tool that cannot accept the CA fails inauditjust as it would inrestrict. -
An image with no system CA store (
scratch, distroless, ordebian:*-slimbeforeca-certificatesis installed) still gets every variable set, but pointed at a file trusting only the proxy's own CA. That is enough for ordinary HTTPS, sinceinspectre-signs all of it with that CA, but not for anallowed-tls-rulesorallowed-ip-rulespassthrough, which presents its own real certificate. The decision is made once, from the rootfs as the step begins, so installingca-certificatespartway through a step doesn't help a passthrough made later in the same step:RUN apt-get install -y ca-certificates && \ curl https://internal.example.com/pkg.tgz -o pkg.tgz # still fails: CURL_CA_BUNDLE was already # fixed to the proxy-CA-only fallback RUN apt-get install -y ca-certificates RUN curl https://internal.example.com/pkg.tgz -o pkg.tgz # this step starts with a store, so # CURL_CA_BUNDLE points at it instead
-
A task runner that starts each command with a filtered environment drops the CA variables before the command sees them. vite-plus does this for a cached
vp runtask, which keepsNODE_OPTIONSbut notNODE_EXTRA_CA_CERTS, so Node inside it fails withSELF_SIGNED_CERT_IN_CHAIN. In an image with a system CA store, which holds the CA already, have Node read that store. As anARGthe flag reaches every laterRUNin the stage without staying in the image, unless anENV NODE_OPTIONSoverrides it:ARG NODE_OPTIONS=--use-system-ca # Node 22.15+ (23.9+ on 23.x); older Node refuses to start RUN vp run build
Otherwise pass the variable through on the task itself, as
untrackedEnvrather thanenv, which would put its value in the cache key. A task withcache: falsekeeps the whole environment.// vite.config.ts build: { command: "vp build", untrackedEnv: ["NODE_EXTRA_CA_CERTS"] },
-
The directories holding a bundle or keystore the CA is added to are mounted over while the step runs. Removing or renaming one fails, while what is inside it behaves normally:
RUN rm -rf /etc/ssl/certs # fails: the directory is a mount point RUN rm -rf /etc/ssl/certs/* # fine
A JDK with a keystore of its own has its
lib/securitymounted, so take it offPATHandJAVA_HOMEwithENVbefore a step removes it. See Mounted directories.
A host only ever gets looked up, never connected to. How do I write a rule for it?
The report gives it a row with DNS as the rule kind and no port. If you want it to stay
unreachable without failing the job, put the name in known-blocked-rules, which is the one input
where a host rule may omit the port. If the build actually needs it, write an ordinary host or URL
rule and the lookup is reported as allowed.
Why not use BuildKit's built-in --proxy-network?
BuildKit's exec network proxy injects HTTP_PROXY/HTTPS_PROXY into each RUN step and can record
what it fetched as SLSA provenance, which Buildcage does not do. Two limits kept it from being the
enforcement mechanism. Its source policy matches a request's host, port and URL path but has no
notion of an HTTP method, so it cannot allow a fetch from a registry while blocking a publish to the
same host, which inspect does by terminating TLS. And because it is an explicit proxy, what it
enforces and records reaches only tools that honor the proxy variables; a tool that ignores them is
not covered. Buildcage enforces at the network level instead, so it also covers those tools.
Can I trust the action itself?
You can check what it runs instead of taking it on trust. The source is public, and the action runs only what was built from it: each release is immutable, its image is built from the release commit and signed, and the action verifies that signature before it runs. Changing what Buildcage does means changing its public source first. See Image provenance verification.
GitHub is building an egress firewall directly into Actions runners
(technical preview as of
September 2026): opt a job into a firewall-enabled runner image and its traffic is inspected outside
the runner VM, in log or enforce mode, from a single .github/egress-firewall.yaml in the
repository. Because it sits outside the VM, a workflow that gains root inside the runner cannot
switch it off. Firewall-enabled images are GitHub-hosted and Linux only.
One policy for the whole run is one allowlist for every step in it: the destinations
actions/checkout, the caches and the setup actions need stay open to the build as well. Buildcage
writes a separate allowlist for the build you don't trust, so a docker build gets the hosts that
build needs and nothing else, and a rule there can name a method and a URL rather than only a host.
The two can be used together: GitHub's firewall as an outer limit the job cannot turn off, and
Buildcage as a narrower allowlist for the build.
Buildcage also runs on any Linux runner with Docker, self-hosted included, rather than on a firewall-enabled runner image.
| Doc | What's in it |
|---|---|
| Reference | Every input, the rule syntax in full, and the report's own output |
| Security Details | Architecture and threat model for every engine, attack resistance |
| Development Guide | Working on Buildcage itself: setup, local runs, tests and the rules |
Issues and pull requests are welcome at github.com/buildcage/docker.
Knowing that this project is useful to others gives me the motivation to keep working on it. If you find Buildcage useful, please consider giving it a star on GitHub.
This software is provided "as is", without warranty of any kind, express or implied. The authors and contributors are not liable for any damages, losses, or security incidents arising from the use of this software. Use at your own risk.
The Buildcage source code is licensed under the MIT License. See LICENSE file for details.
The Docker image includes third-party components under their own licenses (GPL, Apache 2.0, ISC, etc.). See THIRD_PARTY_LICENSES for the full list. The image carries that file, this repository's LICENSE and the license texts it points to under /opt/buildcage/.
The Actions bundle their npm dependencies (MIT, Apache 2.0, ISC) into the committed dist/ files. See THIRD_PARTY_LICENSES_NPM for their license texts.



