A TLS rule governs a named session: domain, from source, to
destination. Where a firewall rule is about packets
between addresses — layer 3/4 — a TLS rule is about a session addressed to a
name — layer 7. The two are deliberately separate APIs with one shared grammar,
so a reader who knows the firewall reads this by reflex: source says who may
open the session, destination says where it lands, and both ends carry the
firewall’s identity/address vocabulary with a domain on top.
A TLS rule only ever grants — it never blocks. This is the load-bearing difference from the firewall, and it runs the other way. The firewall is the deny-by-default packet layer; a TLS rule is a grant layered on top of it that adds a named, brokered path through the platform edge and takes nothing away. There is no such thing as a TLS rule that denies a domain. “Allow only these domains” is not a thing you say here — it is a thing you get, by saying nothing at the IP layer (no firewall egress rule, so no raw-IP Internet) and then naming the domains you want through TLS rules.
Publish A Domain To A VM
The open Internet dials a name; the session lands on a VM’s port. The edge terminates HTTPS at your verified domain and forwards to the backend — the successor to domain mappings, expressed as a rule.
import { Freestyle } from "freestyle";
const freestyle = new Freestyle();
// Anyone dialing app.acme.com reaches vm-123456 on port 8000.
await freestyle.tls.rules.create({
action: "allow",
domain: "app.acme.com",
source: { public: true },
destination: { vmId: "vm-123456", port: 8000 },
});
domain is a hostname you control — an exact name like app.acme.com, or a
single-label wildcard like *.acme.com. The edge presents a certificate for it,
so it must be a name the platform can serve. You write no firewall rule to make
this work: the edge reaches the VM over the same standing grant that lets a
mapped domain in, and a TLS ingress rule mints no firewall rule of its own.
You do not set protocol — the edge serves HTTP, and that is the default.
Reach A Domain With A Secret Injected
A VM opens a session to a domain out on the world; the edge terminates it,
injects a header the guest never held, and re-originates to the real origin. The
destination is { public: true } — the domain’s own public origin, wherever
the world’s DNS says the name lives — never a blank matcher.
// vm-123456 may reach api.openai.com, and the edge adds an Authorization
// header the guest itself was never given.
await freestyle.tls.rules.create({
action: "allow",
domain: "api.openai.com",
source: { vmId: "vm-123456" },
destination: { public: true },
transform: [{ headers: { authorization: "Bearer sk-…" } }],
});
This is the point of a transform: it does something the guest could not do
itself. A compromised guest cannot exfiltrate a key it was never handed, because
the key lives only at the edge. Header values are write-only — sealed at
rest and read back as "***" (header names survive a read, so you can still
audit which headers a rule sets).
Two grants make this reach: the firewall admits the guest’s packets to the edge
(so a VM with no raw-IP Internet still reaches its granted domains), and the
platform resolver answers the granted domain with the edge’s address (so the
session goes to the edge). A whole network may open the session too — use
source: { vpcId: "vpc-backend" }.
The domain resolves to the edge in both address families, so it does not matter whether your client asks for A or AAAA records — a runtime pinned to IPv4 is brokered like any other. What is not brokered is a client that skips name resolution altogether and dials the origin’s IP directly: there is no name in that request to steer, so it reaches the origin as itself, without the rule’s injected headers.
Terminating a real domain means presenting a certificate the guest trusts for
that name — the platform CA baked into the base images. A guest that pins
certificates will refuse; for those, the honest path is an alias the guest
knowingly dials, pinned with a host on the destination:
// The guest dials vendor-alias.internal; the edge originates to the real host.
await freestyle.tls.rules.create({
action: "allow",
domain: "vendor-alias.internal",
source: { vmId: "vm-123456" },
destination: { host: "api.vendor.com", port: 443 },
transform: [{ headers: { authorization: "Bearer sk-…" } }],
});
Connect One VM To Another By Name
A rule whose destination names another VM is an internal service — the same
edge-brokered path as a public domain, pointed inward. The source opens
https://the-name; the platform edge terminates it, presenting a certificate the
guest trusts, and forwards to the target VM’s port. You address the service as
plain HTTPS and the edge maps it to whatever port the backend listens on.
// vm-web reaches vm-db's admin UI as "db.internal": https://db.internal
// terminates at the edge and forwards to vm-db:8000.
await freestyle.tls.rules.create({
action: "allow",
domain: "db.internal",
source: { vmId: "vm-web" },
destination: { vmId: "vm-db", port: 8000 },
});
Why the edge, and not a direct VM-to-VM hop? The certificate has to be resolved outside the guest. A VM could only present a platform-trusted certificate for the name by holding the platform’s private key — which no guest ever does — so the edge is the one place the name can terminate. Two grants make it reach: the firewall admits the source’s packets to the edge, and the standing platform grant already lets the edge reach the target VM. Both are grants, not gates — a VM that already had a path is unaffected.
Because the edge terminates it, an internal service is HTTP-only for now and carries no transform — the same limits as ingress and egress. A raw TCP or Postgres port between two VMs would need an edge that splices bytes without terminating; see what’s not built yet.
Only exact names steer this way — the guest’s /etc/hosts has no wildcards. A
*.suffix or catch-all internal name needs the platform resolver.
Source And Destination
The two ends use the firewall’s vocabulary, but they are not symmetric — the asymmetries fall out of what each side means.
| Field | On a source | On a destination |
|---|---|---|
vmId | The VM that may open the session | The VM the session lands on (needs port) |
vpcId | Every member of a network may open it | Not allowed — a landing is one place |
public | Every publicly routable client | The domain’s own public origin |
host | Not allowed — a source is who, not where | Pin the landing to a host (needs port) |
port | Not allowed — a source carries no landing | The port the session lands on |
A source is a pure identity — who opens the session, authenticated by its
switch port and anti-spoofed address, never by the name it dials (the name a
client offers proves nothing about the client). A destination is a landing —
exactly one of vmId, host, or public: true, never a whole network, because
“which member does this land on” has no answer.
public: true never comes from a forgotten field. A blank matcher does not mean
“the Internet” here for the same reason it never does in the firewall: the most
dangerous statement you can write must not be the one you get by omission. Every
rule states both ends.
Declaring Rules With A VM
vms.create takes an inline tls block, where an endpoint with no identity
means the VM being created — ingress to it on a bare destination, egress from it
on a bare source. Exactly one end may be left bare:
const { vm } = await freestyle.vms.create({
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
tls: {
rules: [
// Inbound HTTPS to this VM on 8000.
{ action: "allow", domain: "app.acme.com", source: { public: true }, destination: { port: 8000 } },
// This VM out to a vendor, with a key injected.
{
action: "allow",
domain: "api.openai.com",
source: {},
destination: { public: true },
transform: [{ headers: { authorization: "Bearer sk-…" } }],
},
],
},
});
Leaving both ends bare would describe the VM talking to itself; leaving neither
bare would describe a rule with nothing to do with the new VM — declare that one
with tls.rules.create instead. If any inline rule is invalid, the whole create
fails and no VM is made.
Rotating A Secret
Rules are replaceable in place — the one way this API diverges from the
firewall’s create/delete immutability. A transform carries a rotating secret, and
delete-then-create would drop traffic or trip your account’s rule limit mid-swap.
update keeps the rule’s id and creation time and replaces everything else:
// Rotate the injected key without taking the path down.
await freestyle.tls.rules.update("tls-123456", {
action: "allow",
domain: "api.openai.com",
source: { vmId: "vm-123456" },
destination: { public: true },
transform: [{ headers: { authorization: "Bearer sk-rotated" } }],
});
Because secrets never read back, an update always states the full transform —
there is no “keep the old value”. A read carries redacted: true when it has
hidden a secret, so you know the shape you see is not the whole story.
List, Get, And Delete
// Every rule in your account, newest first.
const { rules } = await freestyle.tls.rules.list();
// The rules that apply to one VM: those naming it, plus those naming a
// private network it is on.
const { rules: applied } = await freestyle.tls.rules.list({ vmId: "vm-123456" });
// Or the rules naming one network.
await freestyle.tls.rules.list({ vpcId: "vpc-backend" });
const rule = await freestyle.tls.rules.get("tls-123456");
await freestyle.tls.rules.delete("tls-123456");
Lifecycle
A rule cannot outlive what it names. Delete a VM or a private network and the rules referencing it go too — the same cascade the firewall uses — so you never end up with a rule pointing at a machine that no longer exists. Every rule reports what it depends on:
const rule = await freestyle.tls.rules.get("tls-123456");
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.
What’s Not Built Yet
The model describes more than the edge serves today. These shapes validate in the type system but are refused (or unrouted) until the platform catches up:
- Only exact names steer. Wildcard (
*.acme.com) and catch-all (*) egress, and wildcard internal names, need the platform resolver — the guest’s/etc/hostssteering has no wildcards. An exact ingress*.acme.comdoes serve; the catch-all never does, since no certificate covers every name. tcppassthrough is not available. Every shape is served as HTTP over the edge; omitprotocolor set it to"http". An opaquetcprule — including a raw TCP or Postgres port between two VMs — is refused, not stored as a rule that would silently never serve, because splicing bytes without terminating needs an edge that does not exist yet.postgrescredential injection is not available. Thepostgrestransform is designed but not built — today only header injection over HTTP runs.- Transforms on internal services are not exposed. The edge terminates a VM-to-VM rule and could inject, but the API refuses a transform there for now.
Everything on this page outside this section is shipped and serving.