CI jobs, AI coding agents, and sandboxed containers can make arbitrary outbound requests. A compromised dependency, a prompt injection, or a malicious build step can exfiltrate secrets, phone home, or open a reverse shell. Most teams have zero visibility into what's leaving their workloads, let alone any way to stop it.
iron-proxy is a MITM egress proxy with a built-in DNS server that sits between your untrusted workload and the internet. It enforces default-deny at the network boundary, so the workload can only reach domains you explicitly allow. Real secrets never enter the sandbox. Workloads use proxy tokens, and iron-proxy swaps in real credentials at egress, meaning a compromised workload can exfiltrate a token that's worthless outside the proxy.
Single binary. Single YAML config.
- Default-deny egress. Every outbound request is blocked unless the destination matches your allowlist. List your domains and CIDRs, everything else gets a 403.
- Boundary-level secret injection. Workloads send proxy tokens; iron-proxy replaces them with real secrets before the request leaves. If the sandbox is compromised, the attacker gets tokens that are useless outside the proxy.
- Per-request audit trail. Every request logged as structured JSON with the full transform pipeline result: which secrets were swapped, which rules matched, what got blocked and why.
- Streaming-aware. WebSocket upgrades and Server-Sent Events are proxied natively. No special configuration for agent workloads that hold long-lived connections.
Built for CI pipelines, GitHub Actions, AI agents (Claude Code, Cursor, Codex), and any environment where you run code you don't fully trust.
Docker images are available on Docker Hub and pre-built binaries for Linux/macOS (amd64/arm64) are on GitHub Releases.
Or build from source:
go build -o iron-proxy ./cmd/iron-proxycd examples/docker-compose
docker compose upThis starts iron-proxy and a demo client that fires five requests through the proxy. Check the logs to see allowed, blocked, and secret-rewritten requests:
docker compose logs proxyEvery request produces a structured JSON audit entry:
{
"host": "httpbin.org",
"method": "GET",
"path": "/headers",
"action": "allow",
"status_code": 200,
"duration_ms": 142,
"request_transforms": [
{ "name": "allowlist", "action": "continue" },
{
"name": "secrets",
"action": "continue",
"annotations": { "swapped": [{ "secret": "OPENAI_API_KEY", "locations": ["header:Authorization"] }] }
}
]
}Rejected requests include a rejected_by field and log at WARN level. See
Audit log format for the full schema.
iron-proxy terminates TLS by generating leaf certificates on the fly, signed by a CA you provide. Client containers must trust this CA.
mkdir -p certs
openssl genrsa -out certs/ca.key 4096
openssl req -x509 -new -nodes \
-key certs/ca.key \
-sha256 -days 3650 \
-subj "/CN=iron-proxy CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign" \
-out certs/ca.crtiron-proxy needs a fixed IP so containers can point their DNS at it:
docker network create --subnet=172.20.0.0/24 iron-proxyCreate an env file with your secrets (keep this out of version control):
echo "OPENAI_API_KEY=sk-real-key" > .envdocker run -d --name iron-proxy \
--network iron-proxy --ip 172.20.0.2 \
-v $(pwd)/proxy.yaml:/etc/iron-proxy/proxy.yaml:ro \
-v $(pwd)/certs/ca.crt:/etc/iron-proxy/ca.crt:ro \
-v $(pwd)/certs/ca.key:/etc/iron-proxy/ca.key:ro \
--env-file .env \
ironsh/iron-proxy:latest -config /etc/iron-proxy/proxy.yamlThe simplest approach is DNS-based routing: point the container's DNS at iron-proxy and all hostname lookups resolve to the proxy IP, routing traffic through it automatically:
docker run --rm \
--network iron-proxy \
--dns 172.20.0.2 \
-v $(pwd)/certs/ca.crt:/certs/ca.crt:ro \
curlimages/curl --cacert /certs/ca.crt https://httpbin.org/getFor stronger enforcement, layer nftables rules to block non-proxy egress, or use TPROXY for kernel-level interception. See Routing traffic to the proxy for details on each approach.
| iron-proxy | Squid | mitmproxy | Envoy | |
|---|---|---|---|---|
| Default-deny egress | Built-in | Requires complex ACL config | Requires custom scripting | Requires RBAC/filter configuration |
| Secret injection | Built-in | No | No | No |
| Structured audit logging | Built-in, per-transform traces | Basic access logs | Plugin-based | Configurable access logs |
| Setup complexity | Single binary + YAML | Extensive config language | Python scripting | Complex YAML or control plane |
iron-proxy is purpose-built for one job: controlling and auditing egress from untrusted workloads. Squid can do default-deny but requires significant ACL configuration and has no concept of secret injection. mitmproxy is a great debugging tool but isn't designed for production enforcement. Envoy is a general-purpose proxy that can be configured to do parts of this, but it's far more complexity than the problem requires.
iron-proxy runs a DNS server and an HTTP/HTTPS proxy. Point your container's DNS at iron-proxy and all hostname lookups resolve to the proxy IP, routing traffic through it automatically. The proxy terminates TLS (generating leaf certs on the fly from a CA you provide), runs the request through an ordered transform pipeline, forwards it upstream, and runs the response back through the pipeline.
Container → DNS lookup → iron-proxy IP → TLS termination → transforms → upstream
Transforms run in order. Built-in transforms:
| Transform | What it does |
|---|---|
allowlist |
Permits requests to matching domains/CIDRs; rejects everything else (403). |
secrets |
Scans headers, query params, and optionally body for proxy tokens and swaps in real secrets from environment variables. |
iron-proxy takes a single flag: -config path/to/config.yaml. Here's the
full shape (see iron-proxy.example.yaml for a
copy-pasteable starting point):
dns:
listen: ":53"
proxy_ip: "10.16.0.1" # IP where iron-proxy is running (required)
passthrough: # Domains forwarded to OS resolver
- "*.internal.corp"
- "metadata.google.internal"
records: # Static DNS records (highest precedence)
- name: "internal.example.com"
type: A
value: "10.0.0.5"
proxy:
http_listen: ":80"
https_listen: ":443"
max_request_body_bytes: 1048576 # 1 MiB (default)
max_response_body_bytes: 0 # uncapped (default)
tls:
ca_cert: "/etc/iron-proxy/ca.crt" # Required
ca_key: "/etc/iron-proxy/ca.key" # Required
cert_cache_size: 1000 # LRU cache for generated leaf certs
leaf_cert_expiry_hours: 72
transforms:
- name: allowlist
config:
domains:
- "api.openai.com"
- "*.anthropic.com"
cidrs:
- "10.0.0.0/8"
- name: secrets
config:
source: env
secrets:
- var: OPENAI_API_KEY # Env var holding the real secret
proxy_value: "proxy-token-123" # Token the sandbox sends
match_headers: ["Authorization"]
match_body: false
hosts:
- name: "api.openai.com"
log:
level: "info" # debug, info, warn, errorEverything resolves to proxy_ip by default, which is what routes traffic
through the proxy. Exceptions:
passthrough: glob patterns forwarded to the OS resolver (e.g.,*.internal.corp). Traffic to these hosts bypasses the proxy entirely.records: static A or CNAME records. Highest precedence.
Default-deny. Requests must match at least one domain glob or CIDR to proceed.
Unmatched requests get a 403 Forbidden.
Domain patterns use glob matching: *.example.com matches any subdomain and
example.com itself.
Warn mode: Set warn: true to observe what the allowlist would block without
actually enforcing it. Requests that would be rejected are allowed through but
annotated with "action": "warn" in the transform trace. This is useful for
rolling out new allowlist rules or auditing existing traffic before switching
to enforcement.
The sandbox never holds real credentials. Instead:
- Set real secrets as environment variables on the iron-proxy container.
- Give the sandbox a proxy token (e.g.,
proxy-openai-abc123). - Configure the
secretstransform to map proxy tokens to env vars.
iron-proxy scans outbound requests and replaces proxy tokens with the real values before forwarding upstream. You control where it looks:
match_headers: list of header names to scan. Empty list = all headers.match_body: scan the request body (buffered up tomax_request_body_bytes).hosts: restrict swapping to specific domains or CIDRs.
Query parameters are always scanned.
Transforms that inspect or forward request/response bodies (secrets body matching, gRPC transforms) operate on buffered bodies. Two global settings control the maximum buffer sizes:
max_request_body_bytes(default:1048576/ 1 MiB): caps how much of the request body is buffered for transforms. Data beyond this limit is truncated from the transform's perspective but still forwarded to upstream.max_response_body_bytes(default:0/ uncapped): caps how much of the response body is buffered. Set to0to buffer the full response, which is the right default for most workloads (e.g., npm packages, model weights).
Bodies are buffered incrementally as transforms read them, and automatically rewound between pipeline stages. If a transform doesn't read the body, no buffering occurs and the body streams through untouched.
iron-proxy generates leaf certificates on the fly, signed by the CA you provide.
The client container must trust this CA (add it to the system trust store or pass
it via --cacert). Certs are cached in an LRU cache keyed by SNI hostname.
There are three approaches, with increasing enforcement.
Point the container's DNS at iron-proxy. All lookups resolve to the proxy IP, so HTTP/HTTPS traffic flows through it naturally. This is what the Docker Compose example uses:
services:
client:
dns:
- 172.20.0.2 # iron-proxy IPEasy to set up but easy to bypass: the workload can hardcode IPs or use its own DNS resolver to skip the proxy entirely.
Layer an nftables firewall on top of DNS routing. DNS still steers traffic to the proxy, but nftables ensures the workload can't talk to anything else, even with hardcoded IPs.
The examples/nftables directory has a working setup.
The client container loads firewall rules on startup before running any
application traffic:
nftables.conf allows traffic to the proxy, drops everything else:
table ip iron {
chain output {
type filter hook output priority 0; policy drop;
# allow loopback
oif lo accept
# allow traffic to the proxy itself (DNS + HTTP/HTTPS)
ip daddr 172.20.0.2 tcp dport { 80, 443 } accept
ip daddr 172.20.0.2 udp dport 53 accept
# allow established/related (return traffic)
ct state established,related accept
# log and drop everything else
log prefix "iron-proxy-drop: " drop
}
}
docker-compose.yml: the client image is built with nftables
pre-installed. The entrypoint loads the rules, then runs the demo.
CAP_NET_ADMIN is required to load the rules:
services:
proxy:
# ... same as DNS example ...
networks:
demo:
ipv4_address: 172.20.0.2
client:
build:
context: .
dockerfile: Dockerfile.client # alpine + curl + nftables
dns:
- 172.20.0.2
cap_add:
- NET_ADMIN
volumes:
- ./nftables.conf:/etc/nftables.conf:ro
- certs:/certs:ro
networks:
demo:
ipv4_address: 172.20.0.4In a production setup you'd load the rules in an entrypoint wrapper and then
exec your actual process as a non-root user without CAP_NET_ADMIN.
For environments where you can't control the workload's DNS at all, nftables TPROXY can redirect traffic at the kernel level without any cooperation from the workload. This intercepts packets in the PREROUTING chain and hands them directly to iron-proxy:
table ip iron {
chain prerouting {
type filter hook prerouting priority mangle; policy accept;
# redirect HTTP/HTTPS to iron-proxy via TPROXY
tcp dport 80 tproxy to 172.20.0.2:80 meta mark set 1 accept
tcp dport 443 tproxy to 172.20.0.2:443 meta mark set 1 accept
}
chain output {
type route hook output priority mangle; policy accept;
# mark locally-originated packets for policy routing
tcp dport { 80, 443 } meta mark set 1
}
}
This requires ip rule and ip route setup to route marked packets to a
local socket, plus iron-proxy must bind with IP_TRANSPARENT. This is more
complex to set up but provides the strongest guarantee that traffic can't
bypass the proxy. TPROXY operates below DNS, so it catches hardcoded IPs,
custom resolvers, and anything else the workload might try.
The examples/docker-compose directory contains a
working setup. The key pieces:
docker-compose.yml: proxy and client on a shared bridge network. Real secrets are set as env vars on the proxy container only:
services:
proxy:
build:
context: ../..
dockerfile: examples/docker-compose/Dockerfile
environment:
- OPENAI_API_KEY=sk-real-openai-key-do-not-share
- INTERNAL_TOKEN=real-internal-secret-value
volumes:
- certs:/certs
networks:
demo:
ipv4_address: 172.20.0.2
client:
image: alpine:latest
dns:
- 172.20.0.2 # Point DNS at the proxy
volumes:
- certs:/certs:ro
networks:
demo:
ipv4_address: 172.20.0.4proxy.yaml allowlists httpbin.org and icanhazip.com, swaps two
secrets:
transforms:
- name: allowlist
config:
domains:
- "httpbin.org"
- "icanhazip.com"
cidrs:
- "172.20.0.0/24"
- name: secrets
config:
source: env
secrets:
- var: OPENAI_API_KEY
proxy_value: "proxy-openai-abc123"
match_headers: ["Authorization"]
hosts:
- name: "httpbin.org"
- var: INTERNAL_TOKEN
proxy_value: "proxy-internal-tok"
match_headers: [] # scan all headers
hosts:
- name: "httpbin.org"The client script sends five requests to demonstrate each behavior:
# 1. Allowed request
curl https://httpbin.org/get
# 2. Blocked request (not in allowlist)
curl https://example.com/
# 3. Secret swap: proxy token replaced with real key in Authorization header
curl -H "Authorization: Bearer proxy-openai-abc123" https://httpbin.org/headers
# 4. Secret swap: proxy token in custom header
curl -H "X-Internal: proxy-internal-tok" https://httpbin.org/headers
# 5. Secret swap: proxy token in query parameter
curl "https://httpbin.org/get?token=proxy-openai-abc123&q=hello"Every proxied request produces a structured JSON log entry:
{
"host": "httpbin.org",
"method": "GET",
"path": "/headers",
"action": "allow",
"status_code": 200,
"duration_ms": 142,
"request_transforms": [
{
"name": "allowlist",
"action": "continue"
},
{
"name": "secrets",
"action": "continue",
"annotations": {
"swapped": [{ "secret": "OPENAI_API_KEY", "locations": ["header:Authorization"] }]
}
}
],
"response_transforms": []
}Rejected requests include a rejected_by field and log at WARN level.
Need Vault/KMS secret backends, a Kubernetes operator, or centralized policy management? iron.sh builds on iron-proxy with enterprise features for teams running this at scale.