Use iron-proxy on a dedicated router VM when workloads in a VPC need controlled outbound access and header injection.
This pattern gives you:
- A client VM with no internet of its own, reaching the world only through the router.
- A router VM that provides NAT and runs iron-proxy.
- HTTPS interception with a local CA so the proxy can inspect and transform requests.
- VM-wide proxy defaults on the client, so standard tooling uses iron-proxy without per-program proxy code.
- Secret injection from router environment variables, such as
FREESTYLE_API_KEYintoAuthorization.
Architecture
- Client VM sends its default route to router
192.168.51.1. - Router VM performs NAT for egress and runs iron-proxy on
:3128. - Client VM installs the iron-proxy CA and proxy defaults once.
- iron-proxy applies domain rules and secret transforms before forwarding upstream.
flowchart LR
C[Client VM\n192.168.51.30\ninternet disabled\nproxy defaults installed] -->|default route + HTTPS proxy| R[Router VM\n192.168.51.1\nNAT + iron-proxy :3128]
R -->|transformed HTTPS| A[api.freestyle.sh]
1. Create A VPC
Start with the SDK, your API key, and the network constants used by both VMs:
import { Freestyle } from "freestyle";
const freestyle = new Freestyle();
const freestyleApiKey = process.env.FREESTYLE_API_KEY;
if (!freestyleApiKey) {
throw new Error("FREESTYLE_API_KEY is required");
}
const latestIronProxyRelease = await fetch(
"https://api.github.com/repos/ironsh/iron-proxy/releases/latest",
).then((response) => response.json() as Promise<{ tag_name: string }>);
const ironProxyVersion = latestIronProxyRelease.tag_name.replace(/^v/, "");
const vpcCidr = "192.168.51.0/24";
const routerIp = "192.168.51.1";
const clientIp = "192.168.51.30";
const proxyPort = 3128;
const { vpcId } = await freestyle.vpc.create({
slug: "ironproxy-example",
cidr: vpcCidr,
// Members reach each other — membership alone is not permission, and the
// client's traffic to the router (proxy and DNS) rides on this rule.
firewall: { rules: [{ action: "allow", source: {}, destination: {} }] },
});
2. Create The Router VM
Create the router VM before configuring it. This VM keeps normal internet access so it can download the iron-proxy binary and forward client egress.
const { vm: routerVm, vmId: routerVmId } = await freestyle.vms.create({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
vpcs: [{ vpcId, ipv4: routerIp }],
});
3. Configure The Router VM
Create the iron-proxy YAML config locally, then write it onto the router VM.
const ironProxyConfig = `dns:
proxy_ip: "${routerIp}"
proxy:
tunnel_listen: ":${proxyPort}"
tls:
mode: "mitm"
ca_cert: "/etc/iron-proxy/ca.crt"
ca_key: "/etc/iron-proxy/ca.key"
transforms:
- name: allowlist
config:
domains:
- "api.freestyle.sh"
- name: secrets
config:
secrets:
- source:
type: env
var: FREESTYLE_API_KEY
inject:
header: "Authorization"
formatter: "Bearer {{ .Value }}"
rules:
- host: "api.freestyle.sh"
log:
level: "info"
`;
Install iron-proxy, generate the router CA, write the config and systemd service, then enable NAT:
await routerVm.exec({
timeoutMs: 120_000,
command: `set -eux
curl -fsSL -o /tmp/iron-proxy.tgz "https://github.com/ironsh/iron-proxy/releases/download/v${ironProxyVersion}/iron-proxy_${ironProxyVersion}_linux_amd64.tar.gz"
tar -xzf /tmp/iron-proxy.tgz -C /tmp
mv /tmp/iron-proxy /usr/local/bin/iron-proxy
chmod +x /usr/local/bin/iron-proxy
rm -f /tmp/iron-proxy.tgz
mkdir -p /etc/iron-proxy
openssl genrsa -out /etc/iron-proxy/ca.key 2048
openssl req -x509 -new -nodes \
-key /etc/iron-proxy/ca.key \
-sha256 -days 365 \
-subj "/CN=iron-proxy CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign" \
-out /etc/iron-proxy/ca.crt
cat >/etc/iron-proxy/config.yaml <<'EOF'
${ironProxyConfig}
EOF
cat >/etc/systemd/system/iron-proxy.service <<'EOF'
[Unit]
Description=iron-proxy
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/iron-proxy -config /etc/iron-proxy/config.yaml
Environment=FREESTYLE_API_KEY=${freestyleApiKey}
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sysctl -w net.ipv4.ip_forward=1
cat >/etc/sysctl.d/99-freestyle-router.conf <<'EOF'
net.ipv4.ip_forward=1
EOF
cat >/etc/nftables.conf <<'EOF'
flush ruleset
table ip freestyle_router {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr ${vpcCidr} oifname "eth0" masquerade
}
}
EOF
systemctl daemon-reload
systemctl enable --now nftables
systemctl enable --now iron-proxy
`,
});
Check that iron-proxy is running before creating clients that depend on it:
const routerStatus = await routerVm.exec("systemctl is-active iron-proxy");
console.log(routerStatus.stdout?.trim()); // active
4. Create The Client VM
Create a client VM with a VPC route that sends its traffic through the router VM. The client gets no public grant of its own: reaching the router is the whole permission, and what leaves from there is the router’s decision.
const { vm: clientVm } = await freestyle.vms.create({
firewall: {
rules: [{ action: "allow", source: {}, destination: { vmId: routerVmId } }],
},
vpcs: [
{
vpcId,
ipv4: clientIp,
routes: [{ cidr: "0.0.0.0/0", via: routerIp }],
},
],
});
The rule names the router without narrowing it, which is what makes the router usable as a next hop. Add a port and it stops being one — see Make Bypass Impossible below.
5. Trust The Proxy CA On The Client
Install the router CA into the client VM’s system trust store. After this, standard HTTPS clients can trust certificates minted by iron-proxy.
const caCertResult = await routerVm.exec("cat /etc/iron-proxy/ca.crt");
const caCert = caCertResult.stdout?.trim() ?? "";
await clientVm.exec({
command: [
"cat >/usr/local/share/ca-certificates/iron-proxy.crt <<'EOF'",
caCert,
"EOF",
"update-ca-certificates",
].join("\n"),
});
6. Configure VM-Wide Proxy Defaults
Set proxy defaults on the client VM so shells, package managers, and future systemd services use iron-proxy by default.
await clientVm.exec({
command: `set -eux
cat >/etc/profile.d/90-ironproxy.sh <<'EOF'
export HTTP_PROXY=http://${routerIp}:${proxyPort}
export HTTPS_PROXY=http://${routerIp}:${proxyPort}
export NO_PROXY=localhost,127.0.0.1,::1,${routerIp},${vpcCidr}
export http_proxy=$HTTP_PROXY
export https_proxy=$HTTPS_PROXY
export no_proxy=$NO_PROXY
EOF
cat >/etc/environment <<'EOF'
HTTP_PROXY=http://${routerIp}:${proxyPort}
HTTPS_PROXY=http://${routerIp}:${proxyPort}
NO_PROXY=localhost,127.0.0.1,::1,${routerIp},${vpcCidr}
http_proxy=http://${routerIp}:${proxyPort}
https_proxy=http://${routerIp}:${proxyPort}
no_proxy=localhost,127.0.0.1,::1,${routerIp},${vpcCidr}
EOF
mkdir -p /etc/systemd/system.conf.d
cat >/etc/systemd/system.conf.d/90-ironproxy.conf <<'EOF'
[Manager]
DefaultEnvironment=HTTP_PROXY=http://${routerIp}:${proxyPort} HTTPS_PROXY=http://${routerIp}:${proxyPort} NO_PROXY=localhost,127.0.0.1,::1,${routerIp},${vpcCidr}
EOF
cat >/etc/apt/apt.conf.d/95proxies <<'EOF'
Acquire::http::Proxy "http://${routerIp}:${proxyPort}";
Acquire::https::Proxy "http://${routerIp}:${proxyPort}";
EOF
git config --system http.proxy http://${routerIp}:${proxyPort} || true
git config --system https.proxy http://${routerIp}:${proxyPort} || true
systemctl daemon-reload
`,
});
If you run Docker in the client VM, add a systemd drop-in so image pulls and builds also use iron-proxy:
mkdir -p /etc/systemd/system/docker.service.d
cat >/etc/systemd/system/docker.service.d/proxy.conf <<'EOF'
[Service]
Environment="HTTP_PROXY=http://192.168.51.1:3128"
Environment="HTTPS_PROXY=http://192.168.51.1:3128"
Environment="NO_PROXY=localhost,127.0.0.1,::1,192.168.51.1,192.168.51.0/24"
EOF
systemctl daemon-reload
systemctl restart docker
Restart existing long-running services after this step so they pick up the new environment.
7. Validate VM-Wide Behavior
Run one request with proxy variables explicitly removed, then one request through a login shell that loads the VM-wide proxy defaults:
const direct = await clientVm.exec({
command: `env -u HTTP_PROXY -u HTTPS_PROXY -u NO_PROXY -u http_proxy -u https_proxy -u no_proxy \
curl -sS -o /tmp/direct.out -w '%{http_code}' https://beta-api.freestyle.sh/v5/vms`,
});
const proxied = await clientVm.exec({
command: `bash -lc "curl -sS -o /tmp/proxied.out -w '%{http_code}' https://beta-api.freestyle.sh/v5/vms"`,
});
console.log(direct.stdout?.trim()); // 401
console.log(proxied.stdout?.trim()); // 200
The direct request still reaches the internet through the router VM’s NAT, but it bypasses iron-proxy because the command explicitly removes proxy settings. Normal shells and proxy-aware tools use iron-proxy by default after the client setup.
Make Bypass Impossible
Proxy variables are a default, not a boundary: a binary that ignores them still egresses through the router’s NAT. To close that, take away the client’s route and let it reach one port.
Create the network without the members-reach-each-other rule:
const { vpcId } = await freestyle.vpc.create({
slug: "ironproxy-example",
cidr: vpcCidr,
firewall: { rules: [] },
});
Then give the client the proxy port and nothing else — no routes, so there is no next hop to forward through:
const { vm: clientVm } = await freestyle.vms.create({
firewall: {
rules: [
{
action: "allow",
source: {},
destination: { vmId: routerVmId, port: proxyPort, protocol: "tcp" },
},
],
},
vpcs: [{ vpcId, ipv4: clientIp }],
});
A rule carrying a port describes conversations with the router; it does not make the router a next hop. The client can open a proxy connection and nothing else, so a program that ignores HTTPS_PROXY gets a refused connection rather than an unproxied one.
Proxy-aware clients hand the hostname to iron-proxy and never resolve it themselves. Anything that does need local name resolution takes one more rule:
{ action: "allow", source: {}, destination: { vmId: routerVmId, port: 53, protocol: "udp" } }
Production Notes
- Keep API keys in a secret manager or boot-time credential flow. Avoid baking long-lived secrets into snapshots.
- Restrict
allowlistand rules to the minimal set of domains and paths you need. - Rotate proxy CA keys and API keys regularly.
- Monitor
iron-proxyservice logs withjournalctl -u iron-proxy. - Environment-based proxy defaults cover standard proxy-aware userland tools. To close the gap for arbitrary binaries, use Make Bypass Impossible.
- To send traffic out through a machine you own rather than a router VM, see routing sandboxes through your IP proxy.
For a full runnable example, see the SDK script in the repository at freestyle-sdk/examples/vpc-ironproxy.ts.