Formations & uplink.yaml
A formation is a uplink.yaml file that declares everything a device serves:
services, their access and firewall settings, per-service edges, and managed
custom-domain assignments. One command serves the whole thing.
uplink init # write a commented starter uplink.yaml here
uplink up # serve everything in ./uplink.yaml (in the background)
uplink up -f stack.yaml # use a specific file
uplink reload # re-read the file and re-declare the saved formation
uplink down # stop everything served on this device
uplink down -f stack.yaml # stop only that file's services
uplink up reads the config, syncs inline rules into the local registries shared with
the desktop app, saves the formation, and asks the local agent to serve it. Services on
different edges are handled by the serving supervisor. reload re-reads and
re-declares; down clears and stops.
A complete example
edge: ny.uplink.computer # managed-region default for all services
firewall_rules:
no-dotenv:
block: { path: /.env }
services:
web:
port: 3000
public: true # anyone with the URL
custom_domain: app.example.com
dashboard:
account: acme # organization slug; omitted means personal
port: 5000
share: [contractor@ext.com] # organization members, plus this person
api:
account: acme
port: 8080
password: ${API_TOKEN} # bearer token, hashed locally before it leaves your machine
allow_ips: [203.0.113.0/24]
rate_limit: { requests: 100, window_secs: 60, per: ip }
firewall: [no-dotenv]
The parser denies unknown fields. ${ENV} references are expanded before YAML
parsing; an unset variable is a hard error. Top-level keys are schema, edge,
firewall_rules, and services. schema: is an optional version marker (defaults
to the current schema); a config written for a newer schema errors with a clear
“upgrade uplink” message instead of a misleading unknown-field error. edge: accepts
a single public domain or a list — see
Regions & edge selection.
Services
Each services: key is the app name. port is required. account defaults to your
personal account; use account: personal to spell that explicitly, or an
organization’s stable slug for organization ownership. A service is private by
default (owning-account members only); the other access keys below are additive
— each adds a way in, and they combine.
services:
api:
account: acme # every organization member gets access
port: 8080
share: [contractor@example.com] # invite additional people
password: ${API_TOKEN} # …and/or a bearer token
edge: ldn.uplink.computer
custom_domain: api.example.com
allow_ips: [203.0.113.0/24]
rate_limit: { requests: 100, window_secs: 60, per: ip }
firewall: [no-dotenv]
| Field | Notes |
|---|---|
account | Optional owner selector: omitted or personal for your personal account, or an organization slug. Every verified member of the owner can use a private service. |
port | Required. The local loopback port to forward. |
public | true opens the route to everyone with the URL and temporarily bypasses the gated access settings, including allow_ips. |
share | List of email addresses to invite beyond the owning account. Needs a managed edge. |
password | A bearer-token way in, hashed locally. Works on self-hosted key-mode edges too. Mutually exclusive with password_hash. |
password_hash | The pre-hashed alternative to password: a SHA-256 hash (64 hex chars), for when the plaintext must not appear in the file. Mutually exclusive with password. |
allow_ips | Exact IPs or CIDR ranges allowed to reach the tunnel. Empty or omitted allows any source. |
rate_limit | Base request limit for the tunnel: requests, window_secs, and optional per: ip (default) or route. |
bandwidth_cap | Optional per-tunnel Mbps limit. Omit it to use the maximum available from the account plan and edge. |
edge | Region/edge pin for this service — a public domain or a list. Precedence: per-service edge:, then the top-level edge:, then UPLINK_EDGE; a service that names none keeps the edges it already runs on (reload never re-homes it), and only a genuinely new service is probed onto the nearest region. There is no saved default edge. |
custom_domain | Managed edges only: assigns an existing verified account alias to this service’s generated host. A standalone edge uses an operator-owned YAML alias policy instead. |
firewall | Rule ids defined under firewall_rules: in the same file. |
inspect | Traffic capture for this service: off (default), metadata, or full. See Observability. |
Generated YAML uses the same canonical form: it omits account for personal
ownership, writes an organization’s stable slug instead of its internal account ID,
and omits bandwidth_cap when the saved value is simply the current managed-plan
maximum. A genuine lower bandwidth override remains explicit.
Owning-account membership, share, and password stack on one route. allow_ips
further restricts those ways in. public: true is the only exclusive choice. See
Access Control for the semantics and which keys need a
managed edge.
Restrict access by IP
allow_ips adds a source-network restriction to the service’s access settings:
services:
admin:
account: acme
port: 3000
allow_ips:
- 203.0.113.0/24
- 2001:db8:1234::/48
The source must match before the owning-account/invite/password policy decides who may
enter.
Once an allowlist is configured, an unknown source address fails closed. In the app,
choose Restrict to IPs in the Access panel; like the other gated-access options,
it is unavailable while Public access is enabled. public: true temporarily bypasses
the saved allowlist; turning Public access off restores it.
Firewall settings
Each service can also set a first-class base request limit:
services:
api:
port: 8080
rate_limit:
requests: 100
window_secs: 60
per: ip
The base rate_limit covers the whole tunnel. per: ip gives each source its own
budget; per: route shares one budget across the tunnel.
Use reusable rules when an HTTP request-head block needs conditions, monitor mode, or reuse across services. IP access and request budgets stay on the service itself.
Reusable firewall rules
Rules under firewall_rules: are reusable request-head rules attached to
services via the service firewall: list, or to every service with
all_services: true. Each rule names exactly one action — block:,
rate_limit:, challenge:, or redirect:. monitor: true records matches without
enforcing (block/rate_limit only) — useful for tuning a rule before you
turn it on. Referenced rule ids must be defined in the same file; unknown ids
are not attached. Rules apply in the order they are defined — for a bot-check
challenge: the first matching rule wins, so definition order is precedence
(the desktop’s firewall page reorders the same list).
Block rules
firewall_rules:
no-dotenv:
block: { path: /.env } # substring (op: contains)
no-trace:
block: { method: TRACE, op: equals }
scanner:
block: { header: User-Agent, value: sqlmap }
no-lab-net:
block: { source_ip: 203.0.113.0/24 } # network peer (op: in_cidr)
office-only:
block: { source_ip: 198.51.100.0/24, op: not_in_cidr } # allowlist: outside ⇒ blocked
A block names exactly one target — path, query, header, method, or
source_ip — with an optional op: (defaults to contains; a source_ip
defaults to in_cidr). path, query, method, and source_ip carry their
pattern inline; for headers, put the name in header and the pattern in value.
A source_ip is an exact IP or a CIDR, matched against the connection’s source
address as the edge resolves it (the socket peer, or the trusted-proxy hop when
an edge is deployed behind one) — never a header the client can set.
Operators (op:): contains (default), equals, starts_with, present / absent
(headers only), in_cidr / not_in_cidr (source IPs only). not_in_cidr is the
allowlist primitive: it matches sources outside the network (an unattributable
source also matches, so an allowlist fails closed); list several allowed ranges
with all_of:. The service’s first-class allow_ips and rate_limit fields
remain the whole-route conveniences.
Rate-limit rules
firewall_rules:
api-budget:
rate_limit:
match: { path: /api, op: starts_with }
requests: 100
window_secs: 60
per: ip # or `route` for one shared budget
The budget is charged only against requests whose head matches match: (the same
grammar as block:), with its own counter per rule — in series with the service’s
whole-route rate_limit. Over-budget requests receive 429.
Bot-check override rules
firewall_rules:
gate-admin:
challenge:
match: { path: /admin, op: starts_with }
level: strict
open-webhooks:
challenge:
match: { path: /webhooks, op: starts_with }
level: off # exempt — the target must carry its own auth
A challenge: overrides the service’s managed_challenge: level for
matching requests — raising it, lowering it, or turning it off (an exemption).
level: defaults to standard. Non-matching requests keep the service-level
setting.
Redirect rules
firewall_rules:
move-old:
redirect:
match: { path: /old, op: starts_with }
to: /new # a local absolute path or an https:// URL
permanent: true # 301; omit for the default temporary 302
Matching requests are answered with the redirect instead of forwarded. The redirect is applied after access control, so on a private service only admitted visitors ever see it, and after any rate limits, so redirected traffic still spends its budgets.
Multiple conditions. Instead of a single target, a block: can list conditions
under all_of: (every condition must match) or any_of: (any may match):
firewall_rules:
bot-or-admin:
block:
any_of:
- header: User-Agent
value: bot
- path: /wp-admin
op: equals
monitor: true
This is 1:1 with the desktop app’s rule builder — the same rules round-trip through either surface.
Custom domains
On a managed edge, custom_domain is an account-alias assignment, not the tunnel
identity. uplink up and uplink reload keep the generated Uplink host as the
canonical route for analytics, quota, filters, and tunnel detail links, then assign
the verified alias to that host.
If the alias is missing or still waiting on DNS, the canonical tunnel still serves and the CLI prints the next step. If the alias is already assigned to another generated host, or belongs to a different edge than the service, the command fails before rewriting local serving state. See Custom Domains.
Do not set custom_domain for a standalone key-mode edge. Its operator maps the
generated host in the edge-side YAML policy described under
Self-hosted aliases.
Managing a running formation
uplink status # your login + every served app
uplink stop api # stop one app
uplink stop --all # stop all
uplink logs api # local access log for one app
uplink agent status # inspect the local agent (socket, logs, protocol, snapshot)