Install The SDK
pnpm add freestyle@latestbun add freestyle@latestnpm install freestyle@latestyarn add freestyle@latest export FREESTYLE_API_KEY="your-api-key"
Create The Network And The Tunnel
import { Freestyle } from "freestyle";
import { writeFile } from "node:fs/promises";
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 tunnel = await freestyle.tunnels.create({
slug: "exit-node",
vpcs: [{ vpcId, exit: true }],
});
const [attachment] = tunnel.attachments;
console.log(attachment.ipv4, attachment.ipv6); // 10.100.0.1 fd20:…::1
await writeFile("exit-node.conf", tunnel.clientConfig, { mode: 0o600 });
Take both addresses. Pinning one family with ipv4 or ipv6 gives the attachment that family only, and the VMs then have no route for the other.
Build The Exit Node
Any Ubuntu machine with a public address. Give it a static one — the address is the point.
gcloud compute addresses create freestyle-exit-ip --region=us-west2
gcloud compute instances create freestyle-exit \
--zone=us-west2-a \
--machine-type=e2-micro \
--image-family=ubuntu-2404-lts-amd64 --image-project=ubuntu-os-cloud \
--address=freestyle-exit-ip \
--can-ip-forward
gcloud compute scp exit-node.conf freestyle-exit:/tmp/exit-node.conf --zone=us-west2-a
gcloud compute ssh freestyle-exit --zone=us-west2-aaws ec2 run-instances --region us-west-1 \
--image-id ami-EXAMPLE --instance-type t3.micro \
--security-group-ids sg-EXAMPLE --key-name my-key \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=freestyle-exit}]'
# A forwarding instance must not have its traffic dropped as spoofed.
aws ec2 modify-instance-attribute --region us-west-1 --instance-id i-EXAMPLE --no-source-dest-check
aws ec2 allocate-address --region us-west-1 --domain vpc
aws ec2 associate-address --region us-west-1 --instance-id i-EXAMPLE --allocation-id eipalloc-EXAMPLE
scp exit-node.conf ubuntu@203.0.113.10:/tmp/exit-node.conf
ssh ubuntu@203.0.113.10 Bring the tunnel up:
sudo apt-get update && sudo apt-get install -y wireguard-tools nftables
sudo install -d -m 700 /etc/wireguard
sudo install -m 600 /tmp/exit-node.conf /etc/wireguard/freestyle.conf
# The gateway has no way to dial your machine, so your machine holds the
# session open. Without this, a VM's first connection has nothing to reach.
echo "PersistentKeepalive = 25" | sudo tee -a /etc/wireguard/freestyle.conf
sudo systemctl enable --now wg-quick@freestyle
sudo wg show
Then forward and masquerade. Replace eth0 with the machine’s public interface:
sudo tee /etc/sysctl.d/99-exit-node.conf > /dev/null <<'EOF'
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1
EOF
sudo sysctl -p /etc/sysctl.d/99-exit-node.conf
sudo tee /etc/nftables.conf > /dev/null <<'EOF'
flush ruleset
table inet exit {
chain postrouting {
type nat hook postrouting priority srcnat;
oifname "eth0" masquerade
}
chain forward {
type filter hook forward priority filter;
# A tunnelled path is smaller than the local link. Clamping keeps large
# responses from being dropped without an ICMP that survives the path.
tcp flags syn tcp option maxseg size set rt mtu
}
}
EOF
sudo systemctl enable --now nftables
Add A VM
One standing rule lets every member of the network — now and later — reach the exit, and nothing else.
await freestyle.firewall.rules.create({
action: "allow",
source: { vpcId },
destination: { tunnelId: tunnel.tunnelId },
});
Then each VM states where its default route goes. Both families, or the family you leave out keeps the default route Freestyle advertised and has nowhere to go.
const viaExit = [
{ cidr: "0.0.0.0/0", via: attachment.ipv4 },
{ cidr: "::/0", via: attachment.ipv6 },
];
const { vm } = await freestyle.vms.create({
slug: "worker-1",
vpcs: [{ vpcId, routes: viaExit }],
firewall: { rules: [] },
});
The second VM is the same two fields, and the hundredth is too. The exit node is not touched, and no key is generated per VM.
Identify VMs At The Exit
Your machine sees each VM’s address inside the network as the source — the address is not rewritten on the way out. 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: viaExit }],
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.
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=203.0.113.10 — your exit node's address
On the exit node, sudo wg show freestyle transfer counts the bytes carried.
Two Exit Nodes
One exit node is one machine to lose. A network may have several exits: attach a second tunnel with exit: true, and give the VMs a route through each.
const second = await freestyle.tunnels.create({
slug: "exit-node-2",
vpcs: [{ vpcId, exit: true }],
});
await freestyle.firewall.rules.create({
action: "allow",
source: { vpcId },
destination: { tunnelId: second.tunnelId },
});
Build the second machine exactly like the first, in another region or another provider.
Routes at the same metric are one multipath route. The guest spreads connections across both exits, and drops one that stops answering:
const exits = [attachment, second.attachments[0]];
const viaBoth = exits.flatMap((exit) => [
{ cidr: "0.0.0.0/0", via: exit.ipv4, metric: 100 },
{ cidr: "::/0", via: exit.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 exits.
net.ipv4.fib_multipath_hash_policy=1
EOF
sysctl -p /etc/sysctl.d/99-multipath.conf`,
});
Give one exit a higher metric instead, and it becomes a cold standby: the lower metric carries everything while it works.
Freestyle stops answering address resolution for an exit whose tunnel has no live session, which is what the guest’s fib_multipath_use_neigh watches. An exit node that is up but has lost its own internet keeps answering and keeps taking traffic — health-check the machines themselves if that matters.
Notes
- A VM’s own rules still bound what it can reach.
firewall: { rules: [] }is the whole internet by way of the exit, and nothing else. Narrow it —destination: { cidr: "203.0.113.0/24" }— and only that survives the trip. - Outbound mail stays closed. Ports 25, 465, and 587 are denied at the platform whatever your rules say, and routing them through your own machine does not change where the traffic leaves Freestyle.
- Deleting a tunnel deletes the rules naming it, which leaves the VMs pointing at an exit they may no longer reach.
- Reserve the exit node’s address. An ephemeral cloud address changes under you, and it is the address you gave a vendor for their allowlist.
- To inspect and modify HTTP and HTTPS on the exit node, see inspecting sandbox traffic with mitmproxy.
- To reach hosts behind your machine rather than the internet in front of it, see routing networks behind your client.