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

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",
});

const { vm: router, vmId: routerId, data } = await freestyle.vms.create({
  slug: "exit",
  vpcs: [{ vpcId, ipv4: "10.100.0.1" }],
  // 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 } }] },
});

const [routerNet] = data.vpcs;

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:

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

const [network] = data.vpcs;
await store.set(network.ipv4, vmId);
await store.set(network.ipv6, vmId);

Addresses are released on delete and handed to a later VM, so clear the entry when you delete the VM. To keep an address for the life of a workload instead, pin it at create with ipv4.

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