Expose a service to the internet
This guide walks you from “service running internally” to “publicly reachable on https://api.example.com” using Rune’s built-in ingress controller and ACME orchestrator. It assumes you already have a single-node cluster running (see Quick start) on a host with a public IP.
If you’re starting from scratch on a fresh VM, skip to the DigitalOcean deploy guide which covers the host-side setup as well.
What you need
Section titled “What you need”- A
runednode reachable from the public internet on:80and:443. - The node started with
--node-role=edge(this enables the ingress controller and the ACME orchestrator on that node). Tip: if you’re on a laptop using--dev-mode, edge is enabled automatically — no separate flag needed. - A DNS A record pointing your hostname at the node’s public IP. ACME’s HTTP-01 challenge requires this — the certificate authority will hit
http://<host>/.well-known/acme-challenge/<token>and expect to land on your node. - An
acme.emailconfigured. Let’s Encrypt rejects accounts without a contact address.
Step 1 — start runed as an edge node
Section titled “Step 1 — start runed as an edge node”The minimum extra flags on top of a normal startup:
runed \ --data-dir=/var/lib/rune \ --node-role=edge \Or, equivalently, in /etc/rune/runefile.toml:
data_dir = "/var/lib/rune"
[node]role = "edge"
[acme]# directory = "" # defaults to Let's Encrypt productionRestart runed. You’ll see two new lines in the log:
Ingress + ACME enabled (edge node) http=:80 https=:443acme orchestrator startedIf the ports fail to bind (permission denied), either run runed as root, give the binary the cap_net_bind_service capability, or run in --dev-mode for laptop testing.
Step 2 — add expose: to your service
Section titled “Step 2 — add expose: to your service”service: name: api namespace: default image: ghcr.io/example/api:1.4.0 scale: 2 ports: - { name: http, port: 8080 } expose: host: api.example.com port: http tls: auto: true # use ACME (default Let's Encrypt prod)Apply it:
rune cast api.yamlThat’s the entire developer-facing change. The orchestrator publishes endpoints, the edge node adds the route to its ingress router, and the ACME orchestrator queues a certificate request.
Step 3 — watch the certificate land
Section titled “Step 3 — watch the certificate land”$ rune get ingressesNAMESPACE SERVICE HOST TLS CERT EXPIRESdefault api api.example.com acme pending -
# … 10–30 seconds later …
$ rune get ingressesNAMESPACE SERVICE HOST TLS CERT EXPIRESdefault api api.example.com acme ready 89dFor more detail (including the last error if a request failed):
rune get ingress api -n default -o yamlThe orchestrator retries on failure with exponential backoff, capped at the acme_renewal_window (default: 30 days before expiry). Existing certificates keep serving traffic while a renewal is in flight, so a temporary issuance error never causes a public-facing outage.
Step 4 — verify
Section titled “Step 4 — verify”curl -I https://api.example.com/healthz# HTTP/2 200# server: rune-ingressServer: rune-ingress confirms the request went through Rune’s edge proxy. The TLS handshake will use the freshly issued certificate.
Manual TLS (BYO certificate)
Section titled “Manual TLS (BYO certificate)”If you manage certificates out of band — wildcard certs from your CA, Cloudflare Origin Certs, mTLS rigs, etc. — set tls.mode: manual and reference a Secret containing the cert and key:
service: name: api expose: host: api.example.com port: http tls: mode: manual secret: api-tls # Secret must contain `tls.crt` and `tls.key`tls.secret accepts three shapes — same convention as other typed refs in Rune:
| Shape | Resolves to |
|---|---|
api-tls | service’s own namespace |
shared/cf-origin | cross-namespace shorthand |
secret:cf-origin.shared.rune | FQDN secret ref |
The cross-namespace forms are the right pick for a single wildcard cert (or a Cloudflare Origin Cert) you want to share across services in many namespaces — park it once in shared, reference it everywhere.
Create the secret as a normal Rune secret containing tls.crt and tls.key:
secret: name: api-tls namespace: default data: tls.crt: | -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE----- tls.key: | -----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY-----rune cast api-tls.yamlRotate by updating the secret — the ingress controller observes the new Secret.Version on its next reconcile tick (default 2 s) and pushes the new cert into the loader. No service restart needed.
Common gotchas
Section titled “Common gotchas”- DNS not propagated yet. The ACME provider must reach your node. If
dig +short api.example.comdoesn’t return your edge node’s IP from a public DNS server, the challenge will fail. Wait, then the orchestrator will retry on its own. - Port 80 blocked upstream. HTTP-01 needs port 80 specifically. Cloud firewalls and security groups must allow inbound 80 and 443. (If only 443 is allowed, switch to manual TLS.)
acme.directoryleft at default in CI. Tests should point at a Pebble URL — Let’s Encrypt’s production endpoint has rate limits that you will hit.- Multiple edge nodes. Phase 1 ships single-node ingress. Multi-node leader election lands with Phase 2; for now run one edge node and front it with your cloud’s L4 load balancer if you need HA.
Reference
Section titled “Reference”rune get ingress— list, inspect.networking.md— what’s happening behind the scenes.runefile.md— everyacme.*andingress.*knob.