VPCs let Freestyle VMs communicate over private addresses. Use them for databases, internal services, worker pools, or any group of VMs that should not depend on public domains.
Freestyle VPCs are dual-stack. Every VPC gets an IPv6 /64 and an IPv4 /24,
and members take an address from each — so a service that binds 0.0.0.0, or
a container on a v4-only bridge, is reachable from the rest of the network
without anyone configuring anything.
Create A VPC
import { Freestyle } from "freestyle";
const freestyle = new Freestyle();
const { vpc, vpcId, data } = await freestyle.vpc.create({
slug: "workspace-network",
cidr: "10.40.0.0/24",
cidrV6: "fd00:40::/64",
// Membership is not permission: without this rule, members of the network
// do not reach each other at all. See the firewall page.
firewall: { rules: [{ action: "allow", source: {}, destination: {} }] },
});
console.log(vpcId, data.cidr, data.cidrV6);
Both CIDRs are optional: omit cidr and Freestyle derives a /24 out of
10.0.0.0/8, omit cidrV6 and it derives a unique-local /64. Name cidr
yourself when the network has to reach an existing estate over a tunnel and
must not overlap it, or when it needs more than 254 IPv4 members.
The firewall block declares rules with the network,
where a bare endpoint means the network being created — “members reach each
other” is the rule nearly every network wants, and nothing on the network
moves without it.
Attach VMs
A VM can join at most one VPC. Declare the attachment when the VM is created —
pass the VPC ID or your slug for it — or change it later with the
update-networks API (PUT /v5/vms/{vmIdOrSlug}/networks), which attaches,
re-addresses, or detaches declaratively whether the VM is stopped, running, or
paused:
const apiNet = { ipv4: "10.40.0.10", ipv6: "fd00:40::10" };
const { vm: apiVm, vmId: apiVmId } = await freestyle.vms.create({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
slug: "api",
vpcs: [{ vpcId, ...apiNet }],
});
const { vm: workerVm, data: worker } = await freestyle.vms.create({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
slug: "worker",
vpcs: [{ vpcId, ipv4: true }],
});
console.log({ vpcId, vmId: apiVmId, ...apiNet });
console.log(worker.vpcs[0]?.ipv4, worker.vpcs[0]?.ipv6);
Both families are allocated by default, so ipv4/ipv6 only matter when you
want to pin an address or opt out. Pass a string to pin one, true to
allocate explicitly, or false to make the member single-family.
Preselecting addresses also makes it easier to identify which VM sent traffic
to a VPC service, router, or proxy. Choose available addresses within cidr
and cidrV6, save them with the returned vmId, and match the connection’s
private source IP to that mapping. For automatically allocated addresses,
read data.vpcs from the VM creation response instead.
Scope the mapping to the VPC and update it when an attachment changes or a VM is deleted. Addresses can be reused, so retain historical mappings alongside saved traffic logs. To reuse a preselected address for a replacement VM, request it again after the previous attachment has released it. See identifying VMs at an exit node and filtering mitmproxy flows by source IP.
Install an in-guest static route when another VPC member is acting as a router:
const { vm } = await freestyle.vms.create({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
vpcs: [{
vpcId,
ipv4: "10.40.0.20",
routes: [{ cidr: "172.20.0.0/16", via: "10.40.0.1" }],
}],
});
Address Conflicts
Each IPv4 and IPv6 address can belong to only one attachment in a VPC. VMs, router VMs, and tunnel attachments share that address pool. Two VMs cannot share either address, even if their other address differs.
If a VM already holds 10.40.0.10, creating another VM with
ipv4: "10.40.0.10" in the same VPC fails with HTTP 409 Conflict. The SDK
throws FreestyleApiError with status: 409 and a message saying the address
is already reserved. The first VM keeps its address. A conflicting ipv6
request fails the same way, and Freestyle does not silently choose another
address for an explicit request. Concurrent requests cannot both reserve the
same address.
Choose a different available address for each VM, or omit ipv4 and ipv6
to let Freestyle allocate free addresses. An attached VM holds its addresses
while stopped or paused too; reuse them only after the previous attachment
has released them. Private address reservations are scoped to the VPC, so
separate VPCs can reuse the same address ranges.
The addresses in routes[].via identify a router or exit node. Multiple
workers using the same exit should share those next-hop addresses while
keeping their own ipv4 and ipv6 addresses distinct. The
mitmproxy guide
shows two workers configured this way.
Test Private Connectivity
Bind destination services to 0.0.0.0 for IPv4, or :: for both address
families, then connect using the address in VmData.vpcs — worker was
allocated its address rather than pinning one, so read it back rather than
guessing it:
const workerIp = worker.vpcs[0]?.ipv4;
await workerVm.exec("nohup python3 -m http.server 8080 --bind 0.0.0.0 >/tmp/http.log 2>&1 &");
const result = await apiVm.exec(`curl http://${workerIp}:8080`);
console.log(result.stdout);
This works because the network was created with the members-reach-each-other
rule above; without it, the curl would time out.
Connect From Your Computer
Create a persistent WireGuard peer with tunnels, then use the returned config whenever your computer needs access to the VPC.
Manage VPCs
const { vpcs } = await freestyle.vpc.list();
const current = await freestyle.vpc.get("workspace-network");
await vpc.update({ slug: "private-workers" });
await vpc.delete();
Delete or move every attached VM, and detach every tunnel, before deleting a VPC.