A firewall rule states an intent: traffic matching this source may reach this destination. Nothing else — no chains, no ordering, no priorities, no interfaces. You describe what should be allowed; Freestyle decides how.
Only allow rules exist today, and every rule says so: action is required, not
defaulted. The day deny arrives, no rule written before it should silently
acquire an action it never stated — and “what does this rule do” is the one
question a firewall rule must never answer by omission.
Create A Rule
import { Freestyle } from "freestyle";
const freestyle = new Freestyle();
// One VM to another, on TCP 8080.
await freestyle.firewall.rules.create({
action: "allow",
source: { vmId: "vm-123456" },
destination: { vmId: "vm-654321", port: 8080, protocol: "tcp" },
});
Both ends are matchers. A matcher says what the traffic is, and optionally narrows it to a port and protocol.
Matchers
| Field | Matches |
|---|---|
vmId | One VM, by its id or your slug for it |
vpcId | Everything on one private network, by id or slug |
tunnelId | Whatever is on the far side of one tunnel, by id |
cidr | An address range, IPv4 or IPv6 |
public | Any publicly routable address. Only true |
port | A single port, 1–65535. Requires protocol |
protocol | "tcp", "udp", or "icmp" |
Fields inside one matcher intersect. { vpcId: "vpc-db", port: 5432, protocol: "tcp" } means “traffic in that network, on 5432, over TCP” — all
three at once, not any of them.
The public Internet is always public: true. An empty matcher never means the
Internet; inside a VM create it means the VM being
created, and everywhere else it is an error.
// Anything in one private network to anything in another, on Postgres.
await freestyle.firewall.rules.create({
action: "allow",
source: { vpcId: "vpc-frontend" },
destination: { vpcId: "vpc-backend", port: 5432, protocol: "tcp" },
});
// A VM out to the public Internet, on HTTPS.
await freestyle.firewall.rules.create({
action: "allow",
source: { vmId: "vm-123456" },
destination: { public: true, port: 443, protocol: "tcp" },
});
// The public Internet in to a VM, on HTTPS.
await freestyle.firewall.rules.create({
action: "allow",
source: { public: true },
destination: { vmId: "vm-123456", port: 443, protocol: "tcp" },
});
// Ping from one office range only.
await freestyle.firewall.rules.create({
action: "allow",
source: { cidr: "203.0.113.0/24" },
destination: { vmId: "vm-123456", protocol: "icmp" },
});
A port always needs a protocol. Freestyle will not guess: opening both TCP
and UDP because you wrote one number would allow more than you asked for.
What A Matcher Covers
Matchers select traffic two different ways, and a rule can use either.
vmId, vpcId, and tunnelId select by identity. { vmId: "vm-123456" } matches that
VM however it is addressed — over a private network it is attached to, or over
its public IPv6 — so one rule covers both:
// Covers vm-123456 reaching vm-654321 on 8080/tcp, whether the packets go over
// a shared VPC or over vm-654321's public address.
await freestyle.firewall.rules.create({
action: "allow",
source: { vmId: "vm-123456" },
destination: { vmId: "vm-654321", port: 8080, protocol: "tcp" },
});
That is what makes a rule about your machines rather than about your topology: attaching a VM to a VPC later, or giving it a public IPv4, does not silently change which rules apply to it.
cidr and public select by address. public: true is every publicly
routable address, with no regard for who owns it. Another Freestyle VM reached
at its public address is reached over the public Internet, and one public
rule governs it exactly as it governs any other host out there:
// vm-123456 may reach anything on 443/tcp — including your own other VMs, at
// their public addresses.
await freestyle.firewall.rules.create({
action: "allow",
source: { vmId: "vm-123456" },
destination: { public: true, port: 443, protocol: "tcp" },
});
The two kinds overlap, and that is fine: allow rules union, so traffic permitted by an identity rule and by an address rule is simply permitted.
The consequence worth knowing: an outbound public: true rule is genuinely
broad. If some traffic must stay off the public path, put those VMs on a
private network and describe the traffic with vpcId or vmId — no public
rule reaches a private address.
// Everything on one private network to everything on another. Nothing about
// this is reachable from a `public: true` rule.
await freestyle.firewall.rules.create({
action: "allow",
source: { vpcId: "vpc-frontend" },
destination: { vpcId: "vpc-backend", port: 5432, protocol: "tcp" },
});
Use cidr when you want to talk about addresses rather than machines — a
partner’s range, an office, a public block.
One combination is refused: public: true together with vmId, vpcId, or
tunnelId. Since one selects by address and the other by identity, together
they could only mean “that VM, but only when reached over its public address” —
a path distinction this API does not make. Use one or the other.
Tunnels
You do not need a rule to let an attached tunnel reach the network it is
attached to. Attaching allocates the tunnel an address out of the same pool
your VM NICs draw from, so on that network the peer is a member exactly as a VM
is — and { vpcId: … } matches every member. If your network has “members
reach each other”, your tunnel is already covered.
What { tunnelId: … } is for is the rest: naming that particular tunnel
among several, and admitting the ranges behind it. Traffic from an
attachment’s remoteCidrs — your office LAN, say — arrives with a source
address in the remote range rather than the attachment’s, so it is not a member
and does need a rule:
// Whatever dials this tunnel may SSH to one VM.
await freestyle.firewall.rules.create({
action: "allow",
source: { tunnelId: "tun-123456" },
destination: { vmId: "vm-123456", port: 22, protocol: "tcp" },
});
// ...and everything on a private network may reach a service behind it.
await freestyle.firewall.rules.create({
action: "allow",
source: { vpcId: "vpc-backend" },
destination: { tunnelId: "tun-123456", port: 443, protocol: "tcp" },
});
Tunnels have no slug, so a rule names one by id. Tunnels created before they
became their own resource — most of them — carry a wg-… id rather than
tun-…; either works, and freestyle tunnel list shows you which is which.
This is the reason a tunnel is a selector rather than a CIDR you copy out of its config: the peer’s addresses are the peer’s business, and rotating or re-routing them does not mean rewriting the rules that admit it.
Deleting a tunnel deletes the rules naming it, exactly as for a VM or a network — the peer no longer has a way in, so a rule still admitting it would name nothing.
Every Path Is Filtered
Rules govern traffic on a private network exactly as they govern traffic to and from the Internet. Two VMs on the same network are two parties like any other: sharing a network puts them within reach of each other, and a rule is what decides whether they may actually talk. Membership is not permission.
// Without this, VMs on vpc-app do not reach each other at all.
await freestyle.firewall.rules.create({
action: "allow",
source: { vpcId: "vpc-app" },
destination: { vpcId: "vpc-app" },
});
That is the one rule most networks want, and it is why vpc.create takes a
firewall block — you state it as you create
the network rather than discovering later that nothing moves. The same applies
to what arrives over a tunnel or from a hardware server: it reaches
a VM if a rule admits it.
What is never filtered is the plumbing that makes a network work at all — ARP, IPv6 neighbour discovery, DHCP. No rule can describe those, and a VM that cannot resolve its neighbour’s address has lost the network rather than been protected by it.
Freestyle’s Own Traffic Always Works
Traffic Freestyle itself delivers to your VM is allowed whatever your rules say, and no rule can block it. That covers:
- a mapped domain reaching the port you mapped, and
- an SSH session through
ssh vm-…@ssh.freestyle.sh.
You never write a rule to make either work, and deleting every rule you have cannot take a mapped domain or your SSH access offline.
The reason is in how that traffic arrives. Both proxies resolve your VM’s address and dial it themselves, so your VM never sees the client — it sees a connection from a Freestyle-operated address. A rule that had to admit that would be a rule naming our infrastructure: addresses you do not control, cannot discover, and which move when we redeploy.
The authorization already happened somewhere better. The domain serves because you mapped it; the SSH session opened because your credential worked. Both proxies refuse anything without a mapping or a credential, so by the time a connection reaches your VM it is already something you asked for.
public: true ingress rules are for everything else — a port you expose
directly, at an address rather than a name.
Checking A Rule Set
firewall.evaluate answers whether a connection would be allowed, without
changing anything. Use it to check a rule set before you rely on it:
// A mapped domain, on a VM with no rules at all.
await freestyle.firewall.evaluate({
source: { platform: true },
destination: { vmId: "vm-123456", port: 3000 },
protocol: "tcp",
});
// => { outcome: "allowedByPlatform", side: "source" }
// The same port, reached directly from the Internet.
await freestyle.firewall.evaluate({
source: { public: true, address: "203.0.113.7" },
destination: { vmId: "vm-123456", port: 3000 },
protocol: "tcp",
});
// => { outcome: "denied" } — that needs a rule
outcome is one of:
| Outcome | Meaning |
|---|---|
allowedByPlatform | Freestyle delivered it — a mapped domain, SSH. No rule involved |
allowedByRule | A rule matched; ruleId says which |
denied | No rule describes this traffic |
Describe each end with what is true about it, not with a pattern: a VM party
carries vpcIds for the networks it is on, so rules naming a network match it.
Rules Declared With A VM
firewall is required on vms.create. A VM gets nothing implicitly — no
outbound Internet, no inbound — so a VM that needs either says so at create.
The most common block is one rule:
const { vm } = await freestyle.vms.create({
firewall: {
rules: [
// This VM can reach the Internet. Without it, it cannot — no package
// installs, no outbound API calls.
{ action: "allow", source: {}, destination: { public: true } },
],
},
});
{ rules: [] } is a legitimate answer: a VM reachable only through a mapped
domain or SSH, which is a sensible thing to want and worth saying out loud
rather than arriving at by omission.
Rules can be declared in vms.create, where the new VM has no id yet. Inside
the firewall block, an endpoint with no identity means the VM being
created:
const { vm, firewallRules } = await freestyle.vms.create({
firewall: {
rules: [
// Inbound HTTPS from anywhere.
{ action: "allow", source: { public: true }, destination: { port: 443, protocol: "tcp" } },
// Inbound Postgres from a private network.
{ action: "allow", source: { vpcId: "vpc-backend" }, destination: { port: 5432, protocol: "tcp" } },
// Outbound HTTPS to anywhere.
{ action: "allow", source: {}, destination: { public: true, port: 443, protocol: "tcp" } },
],
},
});
console.log(firewallRules.map((rule) => rule.id));
Exactly one end of each rule may be left bare. Leaving both bare would describe
the VM talking to itself; leaving neither bare would describe traffic that has
nothing to do with the new VM, so declare that rule with
firewall.rules.create instead.
The create returns the rules it made, with the ids you need to delete one later. If any declared rule is invalid, the whole create fails and no VM is made.
Rules Declared With A Network
vpc.create takes the same block, where a bare endpoint means the network
being created. A network is the one thing allowed to name itself on both
ends, because “everything here can reach everything else here” is the most
common thing anyone wants to say about one:
const { vpcId, firewallRules } = await freestyle.vpc.create({
slug: "backend",
firewall: {
rules: [
// Members reach each other.
{ action: "allow", source: {}, destination: {} },
// Members reach the Internet.
{ action: "allow", source: {}, destination: { public: true } },
],
},
});
The dashboard’s Create VPC dialog offers these as checkboxes, with “members can reach each other” and “members can reach the Internet” on by default and inbound from the Internet off.
Lifecycle
A rule that names a VM, a private network, or a tunnel cannot outlive it. Delete the VM and the rules referencing it go too, so you never end up with rules pointing at machines that no longer exist. Every rule reports what it depends on:
const rule = await freestyle.firewall.rules.get("fw-…");
console.log(rule.dependencies);
// [{ kind: "vm", id: "vm-123456" }]
The dependency runs one way. Deleting a rule never touches the VMs or networks it named.
List And Delete
// Every rule in your account, newest first.
const { rules } = await freestyle.firewall.rules.list();
// The rules that apply to one VM: those naming it, plus those naming a
// private network it is attached to.
const { rules: applied } = await freestyle.vms.ref("vm-123456").firewallRules();
// Or the rules naming one network or one tunnel.
await freestyle.firewall.rules.list({ vpcId: "vpc-backend" });
await freestyle.firewall.rules.list({ tunnelId: "tun-123456" });
await freestyle.firewall.rules.delete("fw-…");
From The CLI
freestyle firewall create --from public --to vm=vm-123456,port=443,proto=tcp
freestyle firewall create --from tunnel=tun-123456 --to vm=vm-123456,port=22,proto=tcp
freestyle firewall list --vm vm-123456
freestyle firewall list --tunnel tun-123456
freestyle firewall delete fw-…
Each endpoint is comma-separated key=value pairs (vm, vpc, tunnel,
cidr, port, proto), or the bare word public.
Validation
Rules are validated in the SDK before the request and again by the API, and both name the field at fault:
actionis required; a rule that omits it is refused before anything else is checked.- Unknown fields are rejected rather than ignored — a typo’d
porrt: 22that silently became “every port” is the wrong failure. public: falseis refused; omit the field instead.public: truecannot be combined withvmId,vpcId, ortunnelId.- Both endpoints may be bare only on
vpc.create, where it means “members reach each other”. Onvms.createit would be a VM talking to itself. portrequiresprotocol, andicmpaccepts no port.- A CIDR must be canonical:
10.0.0.1/24is a typo for10.0.0.0/24.