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",
cidrV6: "fd00:100::/64",
});
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 fd00:100::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. Set ipv4 and ipv6 on the VM’s VPC attachment to choose its private addresses up front, then store those known values with the returned vmId without reading addresses from the creation response. Choose available addresses within the VPC’s cidr and cidrV6:
const workerNet = { ipv4: "10.100.0.10", ipv6: "fd00:100::10" };
const { vmId } = await freestyle.vms.create({
vpcs: [{ vpcId, ...workerNet, routes: viaExit }],
firewall: { rules: [] },
});
console.log({ vpcId, vmId, ...workerNet });
Persist that mapping in your application and match it against the source IP of traffic arriving on the freestyle tunnel interface, before outbound masquerade replaces it. Omit either address on the VM attachment 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 tunnel attachment’s own addresses are reserved too. Workers can share viaExit because those next-hop addresses identify the exit node. 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.
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.