Freestyle Docs

Freestyle / Guides

Route Sandboxes through another Sandbox

Using VPCs to route all VM traffic through a router VM you run on Freestyle, with no tunnel and nothing outside the platform.

The same shape as routing through your own IP proxy, with the exit node on Freestyle instead of a machine you host. No tunnel, no keys, no second provider.

What Changes Without A Tunnel

A VM acting as the exit needs no exit: true and no attachment. A frame addressed to its address inside the network is delivered on that address alone, so pointing a route at it is the whole configuration.

The exit VM’s own outbound address is what the internet sees. Every VM in your account already shares one outbound IPv4 address, so this pattern is for inspecting, filtering and attributing traffic rather than for changing the address it leaves from. To change that, put the exit node outside Freestyle and use the tunnel version.

Create The Network And The Router

Set ipv4 and ipv6 on the router’s VPC attachment to choose its private addresses up front. You can use those same values as route next hops without reading them from the VM creation response. Choose available addresses within the VPC’s cidr and cidrV6; this example sets both ranges explicitly.

import { Freestyle } from "freestyle";

const freestyle = new Freestyle();

const { vpcId } = await freestyle.vpc.create({
  slug: "exitnet",
  // 1,022 usable addresses. Size the network above the number of VMs you run
  // at once: an address is held for as long as the VM is attached.
  cidr: "10.100.0.0/22",
  cidrV6: "fd00:100::/64",
});

const routerNet = { ipv4: "10.100.0.1", ipv6: "fd00:100::1" };

const { vm: router, vmId: routerId } = await freestyle.vms.create({
  slug: "exit",
  vpcs: [{ vpcId, ...routerNet }],
  // The only VM here with public egress, and the only place egress policy is
  // written: everything behind it can reach exactly what these rules allow.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
});

Omit either address to have Freestyle allocate it automatically, then read that address from data.vpcs in the creation response.

Then make it forward. Masquerade is not optional: the switch drops any packet not sourced from the sending port’s own address, so a router that forwards without rewriting the source loses its traffic at its own port.

await router.exec({
  linuxUser: "root",
  timeoutMs: 120_000,
  command: `set -eux
sysctl -w net.ipv4.ip_forward=1
sysctl -w net.ipv6.conf.all.forwarding=1
printf 'net.ipv4.ip_forward=1\\nnet.ipv6.conf.all.forwarding=1\\n' >/etc/sysctl.d/99-router.conf

nft add table inet nat
nft 'add chain inet nat postrouting { type nat hook postrouting priority srcnat; }'
nft add rule inet nat postrouting oifname "eth0" counter masquerade
nft list ruleset > /etc/nftables.conf
systemctl enable --now nftables`,
});

Add A VM

One standing rule lets every member of the network — now and later — reach the router, and nothing else.

await freestyle.firewall.rules.create({
  action: "allow",
  source: { vpcId },
  destination: { vmId: routerId },
});

The rule names the router without narrowing it, which is what makes it usable as a next hop. A rule carrying a port describes conversations with the router and does not make it one.

const viaRouter = [
  { cidr: "0.0.0.0/0", via: routerNet.ipv4 },
  { cidr: "::/0", via: routerNet.ipv6 },
];

const { vm } = await freestyle.vms.create({
  slug: "worker-1",
  vpcs: [{ vpcId, routes: viaRouter }],
  firewall: { rules: [] },
});

firewall: { rules: [] } is the whole configuration on the client side. It has no route to the internet of its own, and the route table is what sends its traffic to the router.

Verify

const check = await vm.exec({
  linuxUser: "root",
  timeoutMs: 60_000,
  command: "wget -qO- --timeout=20 http://1.1.1.1/cdn-cgi/trace | grep '^ip='",
});

console.log(check.stdout); // ip=… — the router's outbound address

Both VMs report the same address, because the account shares one. To prove the traffic really crossed the router, read its counter:

const seen = await router.exec({
  linuxUser: "root",
  command: "nft list chain inet nat postrouting | grep -o 'packets [0-9]*' | head -1",
});

console.log(seen.stdout); // packets 4

Two Routers

Two exit nodes Several VMs sit on one private network. The network has two tunnels, each to a machine you own, and every VM holds a route through both. Either machine reaches the internet, and traffic leaves from that machine's address. VM VM VM Private network 10.100.0.0/22 two tunnels Your server 203.0.113.10 Your server 198.51.100.7 Internet

Run two, spread across machines, and give the VMs a route through each. Anti-affinity keeps them off the same host — two routers on one machine is one machine.

const routers = await Promise.all(["10.100.0.1", "10.100.0.2"].map((ipv4, index) =>
  freestyle.vms.create({
    slug: `exit-${index + 1}`,
    metadata: { role: "exit", net: "exitnet" },
    placement: {
      antiAffinity: [
        { topology: "node", selector: { matchLabels: { role: "exit", net: "exitnet" } } },
      ],
    },
    vpcs: [{ vpcId, ipv4 }],
    firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
  }),
));

for (const { vmId } of routers) {
  await freestyle.firewall.rules.create({
    action: "allow",
    source: { vpcId },
    destination: { vmId },
  });
}

Routes at the same metric are one multipath route. The guest spreads connections across both routers, and drops one that stops answering:

const viaBoth = routers.flatMap(({ data }) => {
  const [net] = data.vpcs;
  return [
    { cidr: "0.0.0.0/0", via: net.ipv4, metric: 100 },
    { cidr: "::/0", via: net.ipv6, metric: 100 },
  ];
});

const { vm: worker } = await freestyle.vms.create({
  slug: "worker-2",
  vpcs: [{ vpcId, routes: viaBoth }],
  firewall: { rules: [] },
});

await worker.exec({
  linuxUser: "root",
  command: `set -eux
tee /etc/sysctl.d/99-multipath.conf <<'EOF'
# Do not select a next hop whose neighbour has stopped answering.
net.ipv4.fib_multipath_use_neigh=1
# Spread by connection rather than by address pair, so traffic to one
# destination still uses both routers.
net.ipv4.fib_multipath_hash_policy=1
EOF
sysctl -p /etc/sysctl.d/99-multipath.conf`,
});

Give one router a higher metric instead, and it becomes a cold standby: the lower metric carries everything while it works.

A router that is up but has lost its own egress keeps answering and keeps taking traffic. Health-check the routers themselves if that matters.

Identify VMs At The Router

The router sees each VM’s address inside the network as the source — the address is not rewritten on the way in. Nothing carries the VM’s id with it, so record the mapping when you create the VM. Preselect available ipv4 and ipv6 addresses within the VPC’s cidr and cidrV6 to make each worker easy to identify:

const workerNet = { ipv4: "10.100.0.10", ipv6: "fd00:100::10" };

const { vmId } = await freestyle.vms.create({
  vpcs: [{ vpcId, ...workerNet, routes: viaRouter }],
  firewall: { rules: [] },
});

console.log({ vpcId, vmId, ...workerNet });

Persist that mapping in your application and match it against the source IP of traffic arriving at the router, before outbound masquerade replaces it. Omit either address to have Freestyle allocate it automatically, then read that address from data.vpcs in the creation response.

Choose different worker addresses for every attached VM. Reusing either workerNet.ipv4 or workerNet.ipv6 in the same VPC makes the second creation fail with HTTP 409 Conflict; the first VM keeps its address. The router’s own addresses are reserved too. Workers can share viaRouter because those next-hop addresses identify the router. See address conflicts.

Keep mappings scoped to the VPC and update them when an attachment changes or a VM is deleted. Addresses can be reused, so retain historical mappings alongside saved traffic logs. To reuse an address across replacement VMs, explicitly request it with ipv4 or ipv6 after the previous attachment has released it.

For decoded HTTP and HTTPS flows with a source-IP filter, see identifying the VM behind a mitmproxy flow.

Notes

  • The reachable set is the intersection of both rule sets. A client’s own rules bound what it may send, and the router’s bound what leaves. Narrowing the router narrows everything behind it at once.
  • Outbound mail stays closed. Ports 25, 465, and 587 are denied at the platform whatever your rules say, and routing them through a VM of your own does not change where the traffic leaves Freestyle.
  • A router is a VM. It counts against your quota, it is billed like any other, and pausing or deleting it takes the network’s egress with it.
  • To run an HTTP proxy on the router rather than plain NAT — domain allow-lists, header injection, request inspection — see running Iron Proxy on a router VM.
esc