Domains and ingress
A service's domains: put it on the web. isb serve runs
Caddy as the HTTP(S) edge, routes each hostname
(and path) to the service's healthy replicas, and gets certificates from
Let's Encrypt, so a deploy is all it takes to serve https://shop.example.com.
Per org, the hostnames go out through the server's public listeners, or
through the org's own Cloudflare Tunnel when the server has no public ports.
services:
web:
image: docker:traefik/whoami
deploy: {replicas: 2, update_config: {order: start-first}}
domains:
- {host: shop.example.com, port: 80, www_redirect: true}
- {host: shop.example.com, path: /api, port: 3000, strip_prefix: true}
- {host: auto, port: 80}
$ sudo isb host setup --public-ingress # once: ports 80/443
$ isb serve --ingress-http :80 --ingress-https :443 --acme-email ops@example.com
$ isb stack deploy shop
...
web: https://shop.example.com/ (serving, cert issued, 2 upstreams)
web: https://web-shop-default.203-0-113-7.sslip.io/ (serving, cert issued, 2 upstreams)
Apps take the same list (the Domains tab in the web UI, or --domain on
isb app create; Deploy apps). isb up ignores domains.
A workspace's published ports can have hostnames too, under the same rules
(Ports): they
show in isb ingress as stack workspace:<name>, service port-<port>.
domains:#
| Field | Default | Meaning |
|---|---|---|
host |
(required) | A hostname; *.example.com where the org allows wildcards; auto for a generated name. |
path |
/ |
Path prefix: /api matches /api and /api/.... |
port |
(required without redirect) |
The replicas' port. |
https |
true |
HTTPS with a certificate, plain HTTP redirected to it (308). false serves plain HTTP. |
redirect |
Answer with a 308 to this URL instead of proxying. A URL without a path keeps the request's path and query (https://example.com); one with a path is used as is. |
|
strip_prefix |
false |
Remove path before passing the request on. |
www_redirect |
false |
Also serve www.<host>, redirecting to host. |
Hostnames are DNS names (no IP addresses, no .isb, .incus or
localhost). A host's longest matching path wins; concrete hosts win over
wildcards. A wildcard covers one label: *.example.com serves
a.example.com, not a.b.example.com.
Without an ingress (isb serve with none of --ingress-http,
--ingress-https or --ingress-tunnels) domains are saved but not served.
The web UI says so wherever domains are listed or edited, with auto shown as
"auto (not served: no ingress)", and app_create and app_update answer with
a warning when the app has domains. app_get reports ingress_enabled.
Domains are not part of a service's revision: adding or changing them reroutes without replacing an instance.
Generated names#
host: auto becomes <service>-<stack>-<org>.<a-b-c-d>.sslip.io, which
sslip.io resolves to a.b.c.d: a working HTTPS URL with
no DNS set up. The address is --ingress-public-ip, or the address the
host's default route leaves from when that is a public one (true on most
cloud servers, false behind NAT; then pass it). A first label longer than 63
characters is cut and ends in a hash of the whole. Generated names are outside
every org's allowlist: they are always allowed.
Routing and rollouts#
Each domain's upstreams are the service's replicas in rotation: healthy
ones (or, without a healthcheck, running ones), the same set the load
balancer and service names use. Every change in that set, every deploy and
every removal regenerates Caddy's whole config and loads it through its
admin API (POST /load). Caddy swaps it gracefully: requests in flight on
the old config finish (up to 10 s).
During a rollout a replica leaves rotation first; the controller waits until
the config without it is loaded and Caddy reports no requests in flight to
it (at most 10 s), and only then stops it. Between replicas Caddy spreads
requests least-connections first, retries a replica that refuses a
connection on another (twice, within 5 s), and sets a replica that failed
aside for 10 s. A service with no replica in rotation answers 503 with
Retry-After: 5.
Measured on a test host: a 2-replica start-first redeploy under about 25
requests a second over HTTPS, 1188 requests, none failed.
Backends see X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host;
the Host header is the visitor's.
Certificates#
Caddy gets a certificate for every HTTPS hostname as soon as it is routed,
answering ACME's HTTP-01 challenge on the HTTP listener and TLS-ALPN on the
HTTPS one; both must be reachable from the internet on 80 and 443. It renews
them itself. --acme-ca picks the CA:
--acme-ca |
|
|---|---|
letsencrypt (default) |
Let's Encrypt production |
letsencrypt-staging |
Let's Encrypt staging: untrusted certificates, generous rate limits; use it first |
internal |
Caddy's own CA (its root is <state>/ingress/caddy/pki/authorities/local/root.crt); for tests and private networks. It is never added to the host's trust stores. |
an https:// URL |
another ACME directory |
--acme-email is the ACME account's contact (expiry notices). Certificates
and the ACME account live in <state>/ingress/caddy, so a restart reuses
them.
A wildcard host needs a DNS-01 challenge, which this ingress does not do: on
the public listeners it is routed but gets no certificate (cert: unsupported), unless --acme-ca internal. Put wildcards behind the
Cloudflare Tunnel provider, where Cloudflare
holds the certificate.
Each domain's state shows in isb stack ps and stack_status (domains),
and in isb ingress:
cert |
|
|---|---|
pending |
being obtained |
issued |
in use (seen in Caddy's log, or found in its storage) |
failed |
the CA refused or the challenge failed; message says why, and Caddy retries with backoff |
unsupported |
a wildcard on an ACME CA |
cloudflare |
a tunnel org's HTTPS domain: TLS ends at Cloudflare |
none |
plain HTTP, or no HTTPS listener |
state |
|
|---|---|
serving |
routed, with replicas in rotation |
no-replicas |
routed; answers 503 until a replica is in rotation |
redirect |
a redirect domain |
conflict |
another stack holds the name (message) |
refused |
outside the org's allowlist, a host: auto without a public address, unreadable org settings |
off |
needs a listener the server does not have (an HTTPS domain without --ingress-https) |
Certificates issued and failed, and new conflicts and refusals, are events
on the service (the events tool, /api/v1/events), and cert.issued /
cert.failed can go to a notification channel.
Who may serve a name#
- Allowlist. A platform admin limits an org's names with
isb org create ORG --allow-domain example.com(Orgs). Without a list any concrete name is allowed and no wildcard; a wildcard host needs a*.suffixentry. - First claim wins. A name one org serves is refused to every other org,
whatever the paths, and a wildcard counts for every name it covers. Within
an org, a host and path belong to one service. Claims are kept in
<state>/ingress/claims.jsonwith the time they were granted, so the outcome does not depend on the order stacks load in; ties between new claims go by org, stack and service name. A deploy that would lose a claim is refused (domain conflict: shop.example.com is already served by another org, never naming it); a claim that loses at runtime shows asconflict. When the holder stops serving the name, the next claimant gets it.
The edge process#
- Caddy release 2.11.6, downloaded on first use from GitHub into
<state>/ingress/bin/caddy-2.11.6and checked against the SHA-512 compiled into isb (amd64, arm64).--caddy-binruns another binary instead. - It is a child of
isb serve: restarted with backoff (1 s doubling to 30 s) when it exits, stopped (SIGTERM, then SIGKILL after 15 s) when the daemon stops, and killed by the kernel if the daemon dies without stopping it. A new daemon starts a new Caddy rather than adopting one; certificates survive in storage. So while the daemon restarts the domains are down for a few seconds, as published ports are. - Its admin API is a unix socket in a 0700 directory
(
<state>/ingress/run/admin.sock, or under$XDG_RUNTIME_DIR/isb/when the state path is too long for a socket), never TCP. - Its log (JSON) goes through the daemon: warnings, errors and certificate events reach the journal.
- Ports below 1024 need privileges:
sudo isb host setup --public-ingresslets ordinary users bind from 80 up (net.ipv4.ip_unprivileged_port_start) and opens 80 and 443 in ufw (Host setup). Or run with higher ports behind something else.
Cloudflare Tunnel provider#
An org that cannot (or should not) use the server's public ports gets its
names through its own Cloudflare
Tunnel.
It needs no inbound port at all, and it is how an org in a dedicated
VM serves its domains. Any org can use it, the
default org included.
isb org create acme --ingress cloudflare-tunnel --allow-domain acme.com
isb secret create cloudflare-tunnel-token --org acme < token.txt # a remotely-managed tunnel's token
isb secret create cloudflare-api-token --org acme < api.txt # optional: isb manages rules and DNS
isb serve --ingress-tunnels ... # or with public listeners too
- cloudflared runs inside the org, as the stack
isb-tunnel(servicecloudflared, imagecloudflare/cloudflared2026.9.3 pinned by digest, token delivered asTUNNEL_TOKEN), unprivileged, on the org's bridge and behind the org's ACL. It never runs on the host: a remotely-managed tunnel's rules are whatever its Cloudflare account says, and on the host they could point at the host's own loopback services and sockets. Inside the org they reach what the org may reach.isb-tunnelis reserved; isb deploys it, rolls it when the token changes, and removes it when the org leaves the provider. - Caddy listens on the org's bridge address,
http://<gateway>:8480(--ingress-tunnel-port), with only that org's routes: the origin its tunnel's rules point at.isb ingressprints it. Other orgs cannot reach it (their ACLs reject private ranges, and the host firewall ruleisb host setupadds only lets bridges in). An HTTPS domain redirects visitors who came in over plain HTTP (cloudflared'sX-Forwarded-Proto: http); TLS ends at Cloudflare. - Without an API token, configure the tunnel in the Cloudflare dashboard:
a public hostname per domain, service
http://<gateway>:8480. - With
cloudflare-api-token(permissions: Account, Cloudflare Tunnel, Edit; Zone, DNS, Edit), isb replaces the tunnel's ingress rules with one per hostname to the origin (and a final 404), and keeps a proxied CNAME<host> -> <tunnel id>.cfargotunnel.comper hostname, commentedmanaged by isb ingress. It changes a record only if it made it, and deletes only its own records whose hostname went away; a record it did not make is reported, not touched. The account comes from the tunnel token (or--cloudflare-account); the zone is--cloudflare-zone, or looked up per hostname. It syncs when the org's hostnames change and every 10 minutes. - Wildcards work here (Cloudflare holds the certificate), subject to the org's allowlist.
This is the ingress for the org's apps. To reach isb serve itself (the web
UI, MCP and API) from outside, see Reach isb serve
remotely.
Commands and flags#
isb ingress [--json] the ingress_status tool: edge, routes, certificates, conflicts, tunnels
isb stack ps NAME per service, each domain: URL, state, certificate, upstreams
Flags of isb serve (environment in brackets): --ingress-http ADDR
(ISB_INGRESS_HTTP), --ingress-https ADDR (ISB_INGRESS_HTTPS),
--ingress-tunnels (ISB_INGRESS_TUNNELS), --ingress-tunnel-port
(ISB_INGRESS_TUNNEL_PORT, 8480), --ingress-public-ip
(ISB_INGRESS_PUBLIC_IP), --acme-ca (ISB_ACME_CA), --acme-email
(ISB_ACME_EMAIL), --caddy-bin (ISB_CADDY_BIN). The ingress is on when
any of the first three is given; ADDR is IP:PORT, and :443 means every
address. All flags: Configuration.
Limits#
- One Caddy config for the whole server: a listener that cannot bind (a tunnel
org's bridge without an address, a port taken) fails the load, and the
previous config keeps serving;
isb ingressshows the error. - No DNS-01 challenge, so no wildcard certificates from an ACME CA.
- No custom certificates, basic auth or regex redirects.
isb upignoresdomains.