Freestyle Docs

Freestyle / Docs

Tunnels

Use WireGuard to connect your computer to Freestyle private networks.

A tunnel is your WireGuard identity on Freestyle. Create one, save the config it gives you, and bring it up with the WireGuard CLI whenever you need in — then attach the private networks you want on the other side and connect to VMs by their private VPC IPs.

The config never changes. Attaching and detaching networks is an API-side routing change: a connected client just starts (or stops) reaching the network, with nothing to re-download and no interface to restart. A tunnel is permanent — it keeps its keys until you delete it, so the file you save today still works next month, whatever you attach in between. Connecting and disconnecting costs nothing on either side.

Terms

  • A tunnel is a lasting network connection your computer can make into Freestyle. What it reaches is controlled by attachments, not by its config.
  • An attachment is one private network on the far side of a tunnel. It gives the tunnel an address inside that network — the address that network’s VMs see.
  • WireGuard is the VPN tool Freestyle uses. It creates an encrypted tunnel between your computer and the networks you attached.
  • A peer is the other side of a WireGuard connection. Your config has exactly one: Freestyle’s gateway, which routes to every attached network.
  • A WireGuard config is the small file wg-quick uses to create the tunnel. It includes a private key, so treat it like a credential.

Install WireGuard Tools

Install the WireGuard CLI on your computer:

brew install wireguard-tools
sudo apt-get update
sudo apt-get install -y wireguard-tools
sudo dnf install -y wireguard-tools

Create A Tunnel

Create a VPC, attach a VM, create a tunnel, attach the VPC to it, and bring the tunnel up with wg-quick:

import { Freestyle } from "freestyle";
import { spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";

const freestyle = new Freestyle();
const { vpcId } = await freestyle.vpc.create({
  slug: "workspace-network",
  cidr: "10.100.0.0/24",
});

const { vmId } = await freestyle.vms.create({
  // Required: a VM reaches nothing it has not been allowed to.
  firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
  networks: [{ vpc: vpcId, ipv4: "10.100.0.10" }],
});

// One-time setup, one call: create the tunnel with the network already
// attached. The config embeds the private key (returned once, never stored)
// and is fixed for the tunnel's life — this is the only file you will ever
// need for this tunnel.
const tunnel = await freestyle.tunnels.create({
  name: "my-laptop",
  vpcs: [{ vpc: vpcId, ipv4: "10.100.0.2" }],
});
await writeFile("freestyle.conf", tunnel.clientConfig, { mode: 0o600 });

await new Promise<void>((resolve, reject) => {
  const child = spawn("sudo", ["wg-quick", "up", "./freestyle.conf"], {
    stdio: "inherit",
  });
  child.once("error", reject);
  child.once("exit", (code) =>
    code === 0 ? resolve() : reject(new Error(`wg-quick up failed with ${code}`)),
  );
});

console.log(`Created VM ${vmId} in VPC ${vpcId}`);
console.log("Try: ping -c 3 10.100.0.10");

Run the script:

export FREESTYLE_API_KEY="your-api-key"
bun run tunnel.ts

Your computer now routes the tunnel’s ranges (by default 10.0.0.0/8 and fd00::/8) through WireGuard; normal internet traffic keeps using your regular network.

Verify that WireGuard has a peer and has sent traffic:

sudo wg show

Connect to a VM by its VPC IP:

ping 10.100.0.10

Take the tunnel down when you are done. The tunnel stays; only your local connection ends:

sudo wg-quick down ./freestyle.conf

One Tunnel, Many Networks

Name several networks at create (vpcs is a list, all-or-nothing), or attach another one later — a connected client simply starts reaching it, no new config, no restart:

await freestyle.tunnels.attachVpc(tunnel.id, "staging-network");
// ping something in staging-network right away

Detach and it stops, releasing the tunnel’s address in that network. The rest of the tunnel carries on:

await freestyle.tunnels.detachVpc(tunnel.id, "staging-network");

Two rules keep this unambiguous:

  • Attached networks must not overlap. The gateway routes by destination address, so two networks sharing address space on one tunnel would shadow each other. (Networks may share address space on the platform — they just can’t share a tunnel.) Overlapping attachments are refused with a conflict.
  • A network must fall inside the tunnel’s routes. Your client’s AllowedIPs are fixed at create (default 10.0.0.0/8 and fd00::/8), so a network outside them would be silently unreachable — attaching one is refused instead. Pass routes at create if your networks use other ranges:
const tunnel = await freestyle.tunnels.create({
  name: "ci",
  routes: ["192.168.0.0/16", "fd00::/8"],
});

Careful with routes like 192.168.0.0/16 or 172.16.0.0/12 on a laptop: wg-quick installs a route for each entry, and those ranges are where home LANs and local Docker bridges usually live.

Route Networks Behind Your Client

A tunnel client does not have to be an endpoint — it can be a router. Run the client on a machine that fronts other hosts (a cloud VM in front of its VPC, an office gateway box), grant the ranges behind it with remoteCidrs at attach, and your Freestyle network can reach those hosts individually — and they can reach back, with their real addresses in both directions.

The zero-config way is to carve the range out of your network’s own CIDR. Make the network’s CIDR wide enough to cover both sides — say 10.0.0.0/9 for your VMs with the remote site at 10.128.0.0/20 — and grant the remote part:

await freestyle.tunnels.attachVpc(tunnel.id, "prod-network", {
  remoteCidrs: ["10.128.0.0/20"], // inside the network's CIDR: just works
});

A carved-out range behaves like a remote subnet of your network. VMs reach it with no routes and no configuration — the whole CIDR is already on-link from their view — and the platform keeps the range clean: it must be free when you grant it, and no VM can ever be allocated (or pin) an address inside it afterwards.

A range outside the network’s CIDR works too; your VMs just need to be told where to send it. Pass a route with the VM’s network — the attachment’s address is the next hop — and the platform installs it in the guest for you (also live, via the update-networks call):

networks: [{ vpc: "prod-network",
             routes: [{ cidr: "192.168.50.0/24", via: attachment.address }] }]

Either way, two pieces of plumbing remain on your side of the tunnel:

  1. Forwarding on the client machine. It routes packets between the tunnel and its own network: sysctl -w net.ipv4.ip_forward=1 (and check its cloud firewall / source-dest checks allow forwarding).
  2. A return route on the remote side. Hosts behind the client need to route your network’s CIDRs back through the client machine (a route table entry in your cloud VPC, or NAT on the client if you only need one-way reachability).

Remote ranges never appear in your WireGuard config — the client’s own LAN routes already cover them — so the config stays fixed, as always. Two rules keep the routing unambiguous: a range must lie fully inside the network’s CIDR or fully outside it (straddling the boundary is refused), and the ranges granted to one network must be disjoint across its tunnels (two different networks are free to route the same range). Conflicts are refused at attach.

Reaching A Sleeping VM

You do not need to start a VM before connecting to it. Traffic arriving over a tunnel for a VM that is paused or stopped starts that VM, the same as traffic from another VM in the VPC does. The first connection attempt is what wakes it, so give it a moment and retry — ping will start answering once the VM is up.

Use A System Config Path

For repeated use, install the config under /etc/wireguard and refer to it by name:

sudo mkdir -p /etc/wireguard
sudo install -m 600 freestyle.conf /etc/wireguard/freestyle.conf
sudo wg-quick up freestyle
sudo wg show freestyle
sudo wg-quick down freestyle

Your Address Inside A Network

Each attachment reserves the tunnel an address inside that network — pass ipv4 when attaching to pin it, or leave it out to be assigned one. That address is what the network’s VMs see as you: the gateway rewrites your traffic to it on the way in, and back on the way out, so it always fits the network’s own subnet.

The address comes from the same pool VM NICs draw from, so the two can never collide: asking for an address a VM already holds is a conflict, and once a tunnel holds one, no VM can be given it. Pinning it is what lets you write it into firewall rules, allowlists, or an /etc/hosts on your VMs and have it keep working.

Your config’s own Address lines are different: they are fixed platform-side addresses that only exist between you and the gateway. No VPC ever sees them.

What The Config Contains

tunnels.create() returns a standard WireGuard config in clientConfig, complete and final:

[Interface]
PrivateKey = generated-client-private-key
Address = 100.64.0.1/32
Address = fd7a:7570:6c6b::1/128
MTU = 1200

[Peer]
PublicKey = freestyle-server-public-key
AllowedIPs = 10.0.0.0/8, fd00::/8
Endpoint = vpn-endpoint.example.com:51820

One peer, whatever you attach: the gateway tells your networks apart by destination address, not by peers. AllowedIPs is the tunnel’s routes, so only those ranges go through the tunnel.

Bring Your Own Key

Pass clientPublicKey and Freestyle never sees a private key at all. The returned config leaves PrivateKey blank for you to fill in from your own keystore:

const tunnel = await freestyle.tunnels.create({
  name: "ci-runner",
  clientPublicKey: myPublicKey,
});

Rotate Or Delete

The private key is returned once, at create, and is never stored — so there is no way to read it back. If you lose the config, or need to revoke one that leaked, rotate the tunnel’s keys. It keeps its id, its routes, and every attachment; the old keys stop working and the response carries a fresh, complete config:

const rotated = await freestyle.tunnels.rotateKey(tunnel.id);
await writeFile("freestyle.conf", rotated.clientConfig, { mode: 0o600 });

Deleting is the only thing that ends a tunnel. It detaches every network, releases the addresses for reuse, and any client still holding the config loses access:

await freestyle.tunnels.delete(tunnel.id);
esc