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 the domain and forwards to the backend.
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: one you have
verified, or any unused subdomain of style.dev,
which is free and needs no verification or DNS records. You write no firewall
rule to make this work: the edge reaches the VM over a standing grant every VM
already has, 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.
Publish A Minecraft Server
Set protocol: "minecraft" and the rule is served by the edge’s Minecraft front
on 25565 instead of over HTTPS.
// Players dialing mc.acme.com reach vm-123456's Minecraft server.
await freestyle.tls.rules.create({
action: "allow",
domain: "mc.acme.com",
protocol: "minecraft",
source: { public: true },
destination: { vmId: "vm-123456", port: 25565 },
});
Point the domain’s A and AAAA records at the edge, exactly as for a web domain
(see DNS). Players type mc.acme.com with no port; no SRV
record is needed while the server is on 25565.
The edge reads the server address out of the client’s handshake — the routing
key, the way Host is over HTTP — and splices the session through. It
terminates nothing: a minecraft rule takes no transform, and the name needs
no certificate.
A status ping — the row in a player’s multiplayer list — never starts a
stopped VM. While the VM is stopped the row reads Sleeping — join to start the server; while it is running the player sees the server’s own MOTD, player count
and icon. Joining starts the VM.
Two settings on the guest, because the edge sits between the player and the server:
prevent-proxy-connections=falseinserver.properties, or an online-mode server rejects every login.- Minecraft: Java Edition only. Bedrock Edition is a different protocol over UDP.
Publish A Port With Your Own Certificate
Set protocol: "tcp" and the edge matches the name in the client’s ClientHello
and splices the connection to the VM without terminating it. Your VM answers the
handshake with its own certificate.
// Clients dialing secure.acme.com reach vm-123456 on 8443, TLS end to end.
await freestyle.tls.rules.create({
action: "allow",
domain: "secure.acme.com",
protocol: "tcp",
source: { public: true },
destination: { vmId: "vm-123456", port: 8443 },
});
Point the domain’s A and AAAA records at the edge, exactly as for a web domain
(see DNS). Clients dial 443, the same port an http rule
is served on; the SNI is what selects the rule. Serve the certificate from the
VM — the platform issues none for the name, and a client that offers no SNI
never reaches the rule.
Passthrough is what you reach for when the session is yours to end: mutual TLS your server verifies, a protocol over TLS that is not HTTP, or a certificate you issue yourself. What it gives up is everything the edge would otherwise do:
- No
transform. The edge holds no key to the session. - No HTTP/3. There is no TCP connection to splice.
- Public ingress only —
{ public: true } -> { vmId, port }. - One name, one rule. A name published for passthrough is not also served as
HTTPS; on 443 the more specific rule wins, and an exact
tcprule beats a*.acme.comhttpone.
Publish A Mailbox
Set protocol: "imap" and the edge serves the name on 993, terminates the
TLS, and forwards plain IMAP to your VM.
// Mail clients dialing mail.acme.com on 993 reach dovecot on vm-123456:143.
await freestyle.tls.rules.create({
action: "allow",
domain: "mail.acme.com",
protocol: "imap",
source: { public: true },
destination: { vmId: "vm-123456", port: 143 },
});
Point the domain’s A and AAAA records at the edge (see
DNS). The name is issued a certificate through the same ACME
pipeline an http rule uses, so your VM runs an unencrypted IMAP server on 143
and holds no key material.
Users configure their mail client with the domain as the IMAP server, port 993,
SSL/TLS. Accounts and passwords are your IMAP server’s — the edge reads no part
of the session past the handshake and injects nothing, so an imap rule takes
no transform.
To terminate the TLS yourself, set protocol: "imaps" instead and land the rule
on 993:
await freestyle.tls.rules.create({
action: "allow",
domain: "mail.acme.com",
protocol: "imaps",
source: { public: true },
destination: { vmId: "vm-123456", port: 993 },
});
The edge then matches the SNI and splices, and your server answers the handshake with a certificate you obtain and renew. The platform issues none for the name.
Both are public ingress only, and 993 takes one rule per name: an exact imaps
rule beats a *.acme.com imap one, the way 443 resolves tcp against http.
Publish A Database
Set protocol: "postgres" and the rule is served by the edge’s Postgres front on
5432.
// Clients connecting to db.acme.com reach vm-123456's Postgres on 5432.
await freestyle.tls.rules.create({
action: "allow",
domain: "db.acme.com",
protocol: "postgres",
source: { public: true },
destination: { vmId: "vm-123456", port: 5432 },
});
Point the domain’s A and AAAA records at the edge, exactly as for a web domain
(see DNS). Connection strings use sslmode=require or
stricter:
psql "postgresql://app@db.acme.com/prod?sslmode=verify-full"
The edge terminates TLS at the domain and forwards the session to the VM’s
Postgres port. Your own server runs the startup exchange and authenticates the
client; the edge reads nothing past the handshake, so a postgres ingress rule
takes no transform. The name needs a certificate, so it must be one the
platform can serve — the same rule as an HTTPS domain.
An unencrypted connection (sslmode=disable) is refused: it carries no server
name, and the name is what selects the rule.
Reach A Database With Credentials Injected
A VM connects to a database out on the world; the edge authenticates upstream with credentials the guest never holds and hands back the open session.
// vm-123456 may reach db.vendor.com, connecting as `app` with a password it
// was never given.
await freestyle.tls.rules.create({
action: "allow",
domain: "db.vendor.com",
source: { vmId: "vm-123456" },
destination: { public: true },
transform: [{ postgres: { username: "app", password: "…", database: "prod" } }],
});
protocol is inferred from the transform. In the guest, connect with any user
and no password:
psql "postgresql://db.vendor.com/?sslmode=require"
The edge replaces user — and database when the transform names one — and
completes the origin’s authentication, whether it asks for SCRAM-SHA-256, MD5, or
a cleartext password. password is write-only: sealed at rest and read back
as "***". The origin’s certificate is verified against the public root CAs on
every connection.
A postgres transform belongs on an egress rule: a destination of
public: true or a host. On a rule landing on your own VM it is refused.
A public origin with no port defaults to 5432.
Reach A Domain With A Secret Injected
For Git clone, fetch, and push with credentials held at the edge, see Use Private Git Repositories in a Sandbox.
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 pieces make this work: 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
guest agent installs exact-domain entries in the VM’s /etc/hosts pointing
to the edge. A whole network may open the session too — use
source: { vpcId: "vpc-backend" }.
The installed entries can include both IPv4 and IPv6 addresses. The client must use an address reachable from its own network. Containers and clients that bypass the VM’s hosts file do not automatically follow those entries. See Docker TLS routing for container hostname mappings and an IPv6-enabled network. A client that dials the origin’s IP directly bypasses this steering and receives no injected headers; any direct connection still needs a firewall grant.
Terminating a real domain means presenting a certificate the guest trusts for
that name — the platform CA installed in the VM when egress is configured.
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. Host-alias rules
currently need an existing allowed path to the edge; see the
restricted-egress limitation.
The direct-provider public: true rule above supplies an automatic edge grant.
// 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-…" } }],
});
Configure Client Certificate Trust
On Ubuntu, Freestyle installs its public CA at
/usr/local/share/ca-certificates/freestyle-tls.crt and refreshes
/etc/ssl/certs/ca-certificates.crt. This changes the VM’s system store;
it does not modify every runtime or a Docker container’s filesystem.
The same distinction applies after you install your own proxy or private CA.
Point each client at the bundle containing the required CA. For processes running directly in an Ubuntu VM:
| Client | Environment setting |
|---|---|
| Python Requests | REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt |
| Python HTTPX | SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt |
| Node.js | NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt |
Pass these variables through vm.exec({ command, env: { ... } }), your service
configuration, or the application’s launch command. Restart existing clients
after changing trust; Node reads its additional-CA setting at startup.
Requests normally uses certifi.
Installing a CA in the system store, setting only SSL_CERT_FILE, or mounting
a certificate file without configuring Requests is insufficient. For an
explicit request, pass the bundle with verify:
response = requests.get(url, verify="/etc/ssl/certs/ca-certificates.crt", timeout=30)
For Session.send() with a prepared request,
pass verify explicitly or merge the session’s environment settings. That
flow does not automatically apply REQUESTS_CA_BUNDLE. Likewise, a custom
HTTPX client with trust_env=False needs an explicit SSL configuration instead
of relying on environment variables.
See Node’s CA setting
for clients that supply their own TLS ca option.
Inside Docker, the bundle must exist inside the container, and the
variables must be set on its process. Use the
container CA recipes.
Registry pulls have a separate daemon trust configuration.
Keep verification enabled; a working curl request in the VM does not prove
that Requests or a container trusts the same CA.
Match HTTP Egress Requests
An optional match beside transform selects which HTTP egress requests receive
all of that rule’s transforms:
match: {
method: ["POST", "PUT"],
path: { exact: "/v1/responses" },
},
transform: [
{ headers: { authorization: `Bearer ${providerKey}` } },
{ jsonPatch: [{ op: "add", path: "/model", value: "approved-model" }] },
],
| Field or behavior | Meaning |
|---|---|
match | Conditions apply to the entire transform list. Omitted fields impose no condition; {} matches every request. |
method: string[] | Any listed method matches; an empty array matches none. Method and path conditions must both match. |
| Method spelling | Case-sensitive HTTP tokens per RFC 9110 §9.1; extension methods are accepted. |
path: { exact: string } | Case-sensitive equality. /v1/responses/ differs from /v1/responses. |
| Query strings | /v1/responses?stream=true matches the path /v1/responses per RFC 9112 §3.2.1. Put no query or fragment in exact. |
| Non-matching requests | Forwarded without this rule’s transforms, including credential injection. Matching does not deny origin access. |
The edge compares the original method and raw URI path before reading the body.
It does not percent-decode paths, collapse dot segments, or normalize slashes.
Use the spelling your client sends. Thus GET /v1/models can pass through with
its original body and headers while POST /v1/responses receives both the model
patch and injected credentials. Unmatched requests retain client-supplied headers.
Freestyle currently supports method and exact path only. Unsupported fields are
rejected. match is accepted on HTTP egress rules to a public origin or host;
it does not change domain/source rule selection, choose another destination, or
fall through to another TLS rule. Match conditions are visible on reads, so do
not put secrets in them. Updates replace the full rule: include match to retain
it, or omit it to restore unconditional transforms.
Transform JSON Request Bodies
For complete provider recipes, see OpenAI models and authentication, Anthropic models and authentication, OpenRouter models and authentication, and migrating from OpenShell.
A jsonPatch transform applies standard RFC 6902 JSON Patch
operations to HTTP egress request bodies. Authentication stays in a separate
headers transform, and destination selects the upstream:
await freestyle.tls.rules.create({
action: "allow",
domain: "inference.local",
source: { vmId: "vm-123456" },
destination: { host: "api.openai.com", port: 443 },
match: { method: ["POST"], path: { exact: "/v1/responses" } },
transform: [
{ headers: { authorization: `Bearer ${providerKey}` } },
{ jsonPatch: [{ op: "add", path: "/model", value: "approved-model" }] },
],
});
Run this in your trusted controller. The guest calls
https://inference.local/v1/responses with its usual JSON request. A client SDK
that requires an API key can use a placeholder; the edge injects the real key.
For OpenAI Chat Completions, change the matched path to /v1/chat/completions.
For Anthropic Messages, match /v1/messages, select api.anthropic.com, and
inject x-api-key; the client still supplies required headers such as
anthropic-version. The /model patch itself is the same.
add on an object is an upsert: it creates /model when absent and replaces
it when present. It does not mean “set only if missing.” The six operations use
standard semantics:
| Operation | Behavior |
|---|---|
add | Insert or overwrite an object member; insert an array element, shifting later elements. /- appends to an existing array. |
replace | Replace an existing value, including an array element without shifting. |
remove | Remove an existing value. |
copy | Copy the value at from to path, with add destination semantics. |
move | Remove the value at from, then add it at path. |
test | Require an existing value to equal value; a failed test rejects the request. |
Paths use RFC 6901 JSON Pointer.
For example, /messages/0/content selects the first message’s content, ~1
escapes /, and ~0 escapes ~. The empty path selects the whole document.
Parent objects and arrays must exist. There are no wildcards, automatic parent
creation, conditional defaults, or foreach operations. null is an ordinary
value; use remove to delete a field. Removing the document root or moving from
the root is unsupported; use add or replace with an empty path to replace it.
{ jsonPatch: [
{ op: "add", path: "/model", value: "approved-model" },
{ op: "replace", path: "/messages/0/content", value: "Updated first message" },
{ op: "add", path: "/tools/-", value: extraTool },
] }
Operations run in order. Every operation must succeed before the request is
forwarded. The example requires an existing first message and tools array.
Use one jsonPatch per rule, alongside any header transforms, on HTTP egress
from a VM or VPC to a public origin or host. Patches accept at most 128 operations
and 64 KiB of serialized configuration. Operation value fields are write-only:
sealed at rest and returned as "***". Operation names, path, and from remain
visible; do not put secrets in pointer paths.
Without match, the patch applies to every request on the selected TLS rule.
With match, only matching requests receive the patch and injected headers.
JSON Patch does not provide a provider adapter or complete model-access policy. A /model patch does not constrain
Anthropic fallback models, nested batch entries, or models selected by an uploaded
JSONL file. Fixed nested fields can be addressed explicitly; arbitrary-length
collections need a gateway with richer policy. It does not translate between
OpenAI and Anthropic formats or validate provider-specific schemas.
Requests selected for JSON Patch must be UTF-8 application/json (an optional UTF-8 charset is accepted).
Non-JSON and compressed bodies get 415; WebSocket upgrades and CONNECT get
403. Empty bodies, malformed JSON, request trailers, failed operations, or
complexity/output-limit violations get 400.
The edge bounds input and each intermediate/output document to 32 MiB, with
at most 262,144 JSON values and 64 levels of nesting. Copies also reserve
space for their source before allocation. Oversized incoming bodies get 413.
The body must arrive within 30 seconds (408); a busy edge can return 503.
The upstream must return response headers within five minutes (504). Responses,
including SSE, stream through without JSON rewriting or body buffering. JSON
whitespace and object ordering may change; numeric precision is preserved.
Duplicate object names use their last value and are written upstream once.
Scope the transform with match to let unrelated multipart uploads and
body-less retrieval pass through without patching or credential injection.
Matching multipart, JSONL, and WebSocket requests remain unsupported. Ordinary JSON HTTP inference, including requests asking for
streamed responses, works with this transform.
Use freestyle.tls.rules.update(ruleId, fullRule) to change the patch for later
requests. Supply the entire rule, including actual patch values and header
secrets. A redacted read cannot reconstruct them. Cached rules can take about
five seconds to refresh; updates do not alter requests already in flight.
Editing JSON Patch or matched rules in the dashboard currently requires the
SDK or CLI, preserving write-only values and request conditions.
The CLI accepts the same operation array through --json-patch on tls create
and tls update, composable with --header and the JSON --match option:
freestyle tls create --domain inference.local --from vm=vm-123456 \
--to host=api.openai.com,port=443 \
--header 'authorization=Bearer sk-…' \
--match '{"method":["POST"],"path":{"exact":"/v1/responses"}}' \
--json-patch '[{"op":"add","path":"/model","value":"approved-model"}]'
Transforms keep the normal firewall semantics: omit broad public-egress grants when the guest must use only its named TLS routes. A transform affects its selected route, not other routes the VM can access.
Send A VM Out Through Your Own Proxy
A VM’s outbound sessions leave through a SOCKS5 proxy you supply. The edge authenticates to the provider; the VM never holds the credentials.
// Everything vm-123456 opens leaves through gate.provider.com.
await freestyle.tls.rules.create({
action: "allow",
domain: "*",
source: { vmId: "vm-123456" },
destination: { host: "gate.provider.com", port: 7000 },
transform: [{ socks5: { username: "cust-9", password: "…" } }],
});
protocol is inferred from the transform. The destination is the proxy’s
endpoint, and both host and port are required — { public: true } is
refused.
A catch-all rule sets all_proxy and ALL_PROXY in the guest, in
/etc/environment and in /etc/profile.d/freestyle-socks5-proxy.sh. exec,
PTY and SSH sessions all inherit them:
curl https://api.ipify.org # answers with the provider's address
The endpoint is socks5h://socks5.freestyle.internal:1080. Point a client at it
directly to use it without the environment. Use socks5h, not socks5: the
proxy must receive the hostname, and the hostname selects the rule.
domain on a socks5 rule is an allow-list of destinations, checked against
what the guest asked for in the SOCKS request. * allows any destination and is
accepted here only. A narrower rule allows what it names and nothing else:
// vm-123456 reaches anything under target.com through the proxy.
await freestyle.tls.rules.create({
action: "allow",
domain: "*.target.com",
source: { vmId: "vm-123456" },
destination: { host: "gate.provider.com", port: 7000 },
transform: [{ socks5: { username: "cust-9", password: "…" } }],
});
Only a catch-all rule writes the guest’s proxy environment. A narrower rule leaves it alone; aim a client at the endpoint yourself.
The transform is optional. A provider that allow-lists by source address needs no username or password.
The edge terminates nothing here: it opens the connection through the proxy and copies bytes. Your TLS runs end to end to the origin, no platform CA is installed in the VM, and no certificate is issued.
socks5 is egress only. Its password is write-only: sealed at rest, read back
as "***".
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.
An internal service can also be Postgres. Set protocol: "postgres" and the
source connects to the name on 5432 instead of opening https://the-name:
// vm-web reaches vm-db as "db.internal" on 5432.
await freestyle.tls.rules.create({
action: "allow",
domain: "db.internal",
protocol: "postgres",
source: { vmId: "vm-web" },
destination: { vmId: "vm-db", port: 5432 },
});
An internal service carries no transform, whatever its protocol.
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");
From The CLI
freestyle tls create --domain app.acme.com --from public --to vm=vm-123456,port=8000
freestyle tls create --domain db.acme.com --protocol postgres \
--from public --to vm=vm-123456,port=5432
freestyle tls create --domain db.vendor.com --from vm=vm-123456 --to public \
--pg-user app --pg-password 'hunter2' --pg-database prod
freestyle tls create --domain mc.acme.com --protocol minecraft \
--from public --to vm=vm-123456,port=25565
freestyle tls create --domain mail.acme.com --protocol imap \
--from public --to vm=vm-123456,port=143
freestyle tls create --domain secure.acme.com --protocol tcp \
--from public --to vm=vm-123456,port=8443
freestyle tls create --domain api.openai.com --from vm=vm-123456 --to public \
--header 'authorization=Bearer sk-…'
freestyle tls list --vm vm-123456
freestyle tls update tls-123456 --domain api.openai.com --from vm=vm-123456 --to public \
--header 'authorization=Bearer sk-rotated'
freestyle tls delete tls-123456
Each endpoint is comma-separated key=value pairs (vm, vpc, host,
port), or the bare word public. Repeat --header name=value for each
injected header; as everywhere, the values are write-only. --protocol takes
http (the default), tcp, postgres, minecraft, imap, imaps, or socks5. --pg-user, --pg-password,
and --pg-database build a Postgres transform, and --socks5-user/--socks5-password a proxy one; they are mutually exclusive with
--header, since one rule terminates one protocol.
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.socks5egress is the exception: it is not steered by a name, so wildcard and catch-all destinations work there. socks5is egress only.{ public } -> { vmId }is refused, and so is a destination of{ public: true }— the rule has to name the proxy it leaves through.tcppassthrough is public ingress only. Outbound ({ vmId } -> { public }) and VM-to-VMtcprules are refused: both are brokered by the edge, which has to terminate the session to be the one answering it.imapandimapsare public ingress only, for the same reason, and take notransform: the credential in an IMAP session is your user’s, not one the platform holds on your behalf.- There is no cleartext IMAP door. 993 is the only port the front listens
on; 143 is where an
imaprule lands, inside the VM. - Channel binding upstream is not available. A brokered Postgres session is
two TLS sessions, so
SCRAM-SHA-256-PLUScannot be answered; an origin that offers no plainSCRAM-SHA-256is refused.CancelRequestis not routed. - 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.