Setting an endpoint's scope to Public puts it on the internet under a name of its own, with a certificate, and with no proxy in the path.

Unlike the other two scopes, public exposure has prerequisites, and all of them belong to the zone — the deployment as a whole. Somebody with zone permissions sets them up once, and every organization in the zone benefits.

The public path, end to end

clientstorefront.acme.example.comwildcard certificate,issued over ACME1. looks up the nameDNSanswers 203.0.113.42. connects to that addressthe host holding the claim203.0.113.43. forwards the flow to a nodea storefront nodeanswers on port 80804. reply leaves thenode directly —direct server return

Step 2 is the part worth noticing. A public name resolves to one pool address, never to node addresses. One host at a time holds the claim on it; when that host fails the claim moves within seconds, and the record never changes.

What has to be true first

Open Zone, then the DNS tab. Three things live there, and a zone operator fills them in once.

Public base domain. The root every public name hangs off: an exposed service is {service}.{org}.{base}, so storefront in organization acme under example.com is storefront.acme.example.com. It must be two or more DNS labels — orc.example.com — and it must be delegated to this deployment. Leaving the field empty keeps the current value; submitting a single - clears it, which turns public naming off for the whole zone and rejects new public exposures.

Record sink — who actually holds the public records, plus that backend's credentials. Today provider-api is the one that publishes, through a Cloudflare API token with DNS edit permission and the 32-character zone id of the zone holding your base domain. The other sinks are selectable but not written yet: choosing one still derives the records, which are then logged and never served, so no public name resolves and no certificate can be issued.

ACME directory and contact. Where organization wildcard certificates are ordered from; ACME is the protocol they are issued and renewed over. Leave the directory empty for Let's Encrypt production. While you are proving out a new deployment, point it at the staging directory (https://acme-staging-v02.api.letsencrypt.org/directory): its rate limits are far more forgiving, and a botched delegation will otherwise burn a week of your quota. The contact email is optional and worth setting — it is the only channel that reaches you if renewal stops working.

Use Test connection before you rely on any of it. It probes public naming, the Cloudflare zone, and the ACME directory, and tells you which one is not ready.

Declaring address plans

A public name needs an address, and an address has to come from a block your upstream routes to your hosts. Only a zone operator can know that, so only a zone operator declares it: ZonePublic AddressesDeclare a plan. It takes the ManageZones permission.

FieldWhat it means
NameThe plan's identity, 1–63 DNS-label characters. It is also the tie-breaker in selection, so name plans deliberately.
BlockA CIDR prefix with no host bits set — 2001:db8:1::/64, 203.0.113.0/29. The address family follows from the prefix; there is no family field.
Scopezone-shared (any organization's public exposures may draw from it) or one specific organization (a block that organization supplied or paid for).
Routed hostsThe node ids the block is routed to, separated by spaces or commas. This is the statement of physical fact — the platform believes it and never verifies it with your router.
DSROn (recommended) means your network tolerates node hosts sourcing the plan's addresses; the flag is your consent to that, and strict per-host reverse-path filtering is the reason to withhold it. Leave it on — the NAT funnel meant to serve dsr-off plans is designed but not yet realized on hosts, so a plan with the flag off currently delivers nothing.

Blocks may not overlap: an address belongs to exactly one plan. Declaring a plan whose block overlaps an existing one is refused, naming the plan it collides with. You can change the routed hosts and the DSR flag of a live plan later with Save routing. Retire stops new addresses being drawn from a block, and is refused while the plan still backs a pool address — the message says how many.

There is no "routed versus on-link" setting: both are the same declaration, and which one you have shapes how you wire your side, not what you type here. See Front-door patterns.

Exposing a service

With the zone ready, the exposure itself is one control in the request composer.

  1. Open the request composer and add the app to the pool as usual.
  2. In the app's Exposure section, find the row for the endpoint you want on the internet.
  3. Set that row's scope to Public.
  4. Optionally set a Service name. It defaults to the pool's name, and it is the label that ends up in DNS.
  5. Submit the request.

The same thing in the request document:

apps:
  - name: postgres
    endpoints:
      pg:
        expose: public
        name: maindb

Service names must be unique among your organization's active public exposures. A collision is refused when you submit, and the error names the pool holding the name. Because the name is request configuration rather than topology, it survives a pool rename and follows the service if you move it to a different pool — the new request simply carries the same name. A released name rests in quarantine for a while before another project can take it, so that stale bookmarks cannot land on a stranger; the project that released it can re-claim it immediately, which is what makes moving a service possible.

How an address is chosen

The first public exposure on a pool draws that pool's public address — one per address family — at submission time. From then on the pool keeps it: a later reconfiguration only draws a family the pool is missing, so the address behind a live service never moves because you edited its request.

Selection is automatic. Neither you nor the organization picks a plan:

  1. A plan is eligible if the hosts it is routed to overlap the pool's hosts (where nodes sit, and where they may be placed) and its scope admits the pool's organization.
  2. Among the eligible plans of a family, organization-dedicated outranks zone-shared.
  3. A remaining tie breaks on the lexicographically least plan name.

Failure is loud and specific, and never a quiet fallback to another plan, another family, or another delivery mode:

  • With nothing eligible, the request is refused with no public address plan is eligible for this pool, listing each declared plan and the criterion it failed — no routed host in common, or dedicated to another organization.
  • With the selected plan full, the request is refused naming the plan and its capacity: public address plan "edge-v4" (203.0.113.0/29) is out of addresses: 0 of 6 usable remain — widen the block or declare another plan. That is the practical IPv4 case, and the message tells you exactly which block to widen.
  • With no base domain configured, public exposure needs a public base domain on the zone; none is configured.

Public exposure is also refused when the app carrying it does not resolve in the project — an unpublished app, or a pinned version that does not exist. An app that cannot be resolved contributes no endpoints, so the exposure would otherwise be dropped in silence.

Removing the last public exposure from a pool releases its addresses and its public names; the addresses rest in the standard reuse quarantine, oldest-released-first.

The records that appear

For a service named storefront in organization acme under base domain example.com, serving an endpoint called http:

RecordContents
storefront.acme.example.comAAAA and/or A — the pool address, one per family
_acme-challenge.acme.example.comTXT, only while a certificate order is open

Public exposure publishes the pool's plain name only — there are no public slot names. Each exposed port forwards to its serving set, decided by the endpoint's own serve mode (Authoring apps): primary sends every flow to the slot-1 node, spread picks among the nodes currently passing that endpoint's readiness probe by a consistent hash, so a connection stays on the node it started on. While the set is empty — no node passing, or a primary endpoint whose slot-1 node is unready — the port refuses connections rather than serving them from a node the app has not vouched for. A port with nothing behind it is therefore a refused connection, not a name that stops resolving.

Two properties are worth internalising:

  • A public name answers the pool address or it answers no address at all. A pool that has drawn none publishes nothing. It never falls back to nodes' own addresses — that fallback would put overlay addresses in front of the internet, and preventing it is why the pool address exists.
  • The zone is converged onto derived state. Records are computed from the exposures and written to the sink; a record you add by hand beside them is surplus that the next pass deletes. Even the ACME challenge is part of that desired state rather than a write placed next to it.

Records carry TTLs of at most 30 seconds and are health-fed. What health gates is whether the name answers at all: the record is the pool address either way, so a spread name withdraws only once no node serves it, and a primary name withdraws as soon as the slot-1 node goes unready — every port it names would refuse anyway. If your sink's provider refuses sub-minute TTLs it clamps them to its own minimum, and failover through that backend is correspondingly slower.

Certificates

The organization's first public exposure triggers issuance of one wildcard per organization, *.acme.example.com — one certificate covering every name under the organization's label — over ACME DNS-01, with the challenge records flowing through the same record sink as everything else. Renewal is automatic, scheduled from the certificate's own validity window. Issuance is leader-gated, never starts against a sink that publishes nothing, and re-arms failures on a backoff that honours any wait the certificate authority states, so a broken delegation costs you time rather than your rate-limit budget.

The wildcard is delivered to your app through the tls.cert and tls.key parameters. Direct delivery is app-terminated — there is no platform proxy to terminate TLS for you, so a publicly exposed endpoint must speak HTTPS itself: declare its protocol as https and serve TLS with the delivered wildcard (see Authoring apps).

A plaintext http endpoint is rejected when you submit, not accepted and quietly served in the clear:

apps.grafana.endpoints.web.expose: endpoint "web" is plaintext http; public delivery is
direct (no TLS terminator) — declare protocol "https" and serve TLS with the delivered
certificate, or expose it at project/org scope

https, tcp, and udp are all publishable — the rule is about the missing terminator, not about HTTP. If the app genuinely cannot serve TLS, keep it at project or organization scope, where traffic stays on the overlay.

The wildcard is the only certificate shape issued without an explicit opt-in, and that is a privacy guarantee rather than an implementation shortcut. Certificate Transparency logs are public, append-only and searchable; because nothing below the organization label is ever requested, they reveal your organization's existence and nothing about its projects, pools, or services. A per-service certificate is available as an opt-in, and it knowingly publishes that one name to CT.

Losing the last public exposure stops renewal and revokes nothing. The certificate ages out, and re-exposing a service later costs neither a new issuance nor a new transparency-log entry.

Claim hosts and DSR, briefly

One host at a time holds the claim on a pool address: it receives that address's ingress, answering NDP or ARP for it where the block is on-link, and simply accepting it where the block is routed to it. When the claiming host fails, the control plane reassigns the claim within seconds — the DNS record never changes, and clients reconnect to the same name and port. A pool with no placed node holds its address unclaimed, which is not the same as unallocated: the name keeps pointing where it pointed, and a returning host resumes it.

The claiming host forwards packets to the serving node unrewritten — the slot-1 node for a primary endpoint, or one picked from the spread set by a consistent hash of the flow, so a connection stays put and a membership change remaps only its share. Where any public endpoint serves primary, the claim is placed on the slot-1 node's own host wherever the plan routes there, so that traffic takes no extra hop. Every node of the pool holds the pool address on a non-advertising loopback — every node, not only the healthy ones, so that a recovering node does not drop the first connections forwarded to it. The reply leaves through the node's own uplink, sourced from the pool address: ingress funnels through one host, egress spreads across all of them, and your app sees the real client address. Where DSR is off, or a node's OS cannot hold the loopback, the design's fallback is a NAT funnel at the claiming host — same names and ports, at the cost of the real client address. That fallback is not implemented yet: today such an address is reported as unrealized rather than served, and the host log says so.

Plan your address families deliberately

IPv6 is what makes this cheap: providers routinely route a /64 per server for free, so a v6 plan usually costs nothing. IPv4 is rented, small, and the family that runs out.

The reality that catches people: a service with only a v6 address is unreachable from a v4-only client, and a large share of the internet's coffee shops, corporate networks and mobile carriers are still exactly that. Nothing in the platform hides this — a family with no eligible plan is simply not delivered as long as the other one is, and a pool with only a v6 plan will publish only AAAA. If a service must be reachable by anyone, declare both families and let the pool draw a dual-stack pair. If it is for your own machines or your own network, v6-only is a perfectly good decision made on purpose.