Uplink Docs
Formations & uplink.yaml
Documentation

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]
FieldNotes
accountOptional owner selector: omitted or personal for your personal account, or an organization slug. Every verified member of the owner can use a private service.
portRequired. The local loopback port to forward.
publictrue opens the route to everyone with the URL and temporarily bypasses the gated access settings, including allow_ips.
shareList of email addresses to invite beyond the owning account. Needs a managed edge.
passwordA bearer-token way in, hashed locally. Works on self-hosted key-mode edges too. Mutually exclusive with password_hash.
password_hashThe 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_ipsExact IPs or CIDR ranges allowed to reach the tunnel. Empty or omitted allows any source.
rate_limitBase request limit for the tunnel: requests, window_secs, and optional per: ip (default) or route.
bandwidth_capOptional per-tunnel Mbps limit. Omit it to use the maximum available from the account plan and edge.
edgeRegion/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_domainManaged 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.
firewallRule ids defined under firewall_rules: in the same file.
inspectTraffic 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 actionblock:, 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)
Search across 15 pages.
↑↓ navigate openEsc close