Freestyle Docs

Freestyle / Docs

Firewall

Allow network traffic between VMs, private networks, address ranges, and the public Internet.

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

FieldMatches
vmIdOne VM, by its id or your slug for it
vpcIdEverything on one private network, by id or slug
tunnelIdWhatever is on the far side of one tunnel, by id
cidrAn address range, IPv4 or IPv6
publicAny publicly routable address. Only true
portA 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:

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:

OutcomeMeaning
allowedByPlatformFreestyle delivered it — a mapped domain, SSH. No rule involved
allowedByRuleA rule matched; ruleId says which
deniedNo 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:

  • action is required; a rule that omits it is refused before anything else is checked.
  • Unknown fields are rejected rather than ignored — a typo’d porrt: 22 that silently became “every port” is the wrong failure.
  • public: false is refused; omit the field instead.
  • public: true cannot be combined with vmId, vpcId, or tunnelId.
  • Both endpoints may be bare only on vpc.create, where it means “members reach each other”. On vms.create it would be a VM talking to itself.
  • port requires protocol, and icmp accepts no port.
  • A CIDR must be canonical: 10.0.0.1/24 is a typo for 10.0.0.0/24.
esc