The Traefik library
A host that answers a domain over HTTPS runs one reverse proxy in front of its
containers: a single process holding ports 80 and 443, terminating TLS, and
forwarding each hostname to the container that serves it. Traefik (in
lib/Traefik.emet) models that front door as a typed Emet value, and Routing
(lib/Routing.emet) models one hostname’s path to one container port. Both
resolve from the library search path named by the repo-root emet.json, and
both lower through Quadlet onto
the four glyphs.
Traefik learns what to route by reading container labels over the Podman API
socket, so the library works on two sides at once: it builds the front-door
container, and it stamps the matching labels onto each container you publish
through it. Naming the Frontend once is what keeps those two sides agreeing.
import Traefik exposing ( Frontend(..) , Challenge(..) , Ingress(..) , ingress , ingressUnit , publish , withChallenge , withEnvironmentFiles )import Routing exposing (Route(..), route)import Quadlet exposing ( image , env , Network(..) , Restart(..) , Expose(..) , Workload(..) , workloadGlyphs )Frontend — the two names every route shares
frontend : Frontendfrontend = Frontend { network = "frontend" , certificateResolver = "letsencrypt" }| Field | Is |
|---|---|
network | the Podman network the front door and every published container join. It is the provider’s network in the generated traefik.yml, the traefik.docker.network label on each published container, and the Network= line on both |
certificateResolver | the name of the ACME resolver. It names a block in the generated traefik.yml and is the value of each router’s tls.certresolver label |
Both are free-form strings whose only requirement is that the front door and the
published workloads use the same ones — which is why you build one Frontend
and pass it around rather than spelling the names at each call site.
Route — one hostname to one container port
Routing is the whole vocabulary for “this name reaches that port”. route is
the constructor function; the record it builds names its three fields, and
Route(..) is exported, so either spelling is available:
surveyRoute : RoutesurveyRoute = route "limesurvey" "survey.example.com" 80
theSameRoute : RoutetheSameRoute = Route { name = "limesurvey" , host = "survey.example.com" , port = 80 }| Field | Is |
|---|---|
name | the Traefik router and service name. It appears inside every label key, so it must be unique among the routes on one front door |
host | the hostname the router matches, as Host(`host`) |
port | the port inside the container that Traefik forwards to |
route : String -> String -> Int -> Route. port is the container’s own port,
not a published one: a routed container does not publish ports to the host at
all, because Traefik reaches it across the shared network.
Routing carries no dependency on Traefik, so a module that describes an
application can export its Route without importing the front door — see
An app behind Traefik.
Ingress — the front door itself
An Ingress carries an Image, the ACME contact address, the Frontend, a
Challenge, and a list of environment files. ingress builds one from the two
that have no sensible default and fills the rest:
frontDoor : IngressfrontDoor = ingress "ops@example.com" frontend| Function | Signature | Does |
|---|---|---|
ingress | String -> Frontend -> Ingress | builds a front door from the ACME contact address and the frontend, with defaultImage, TlsChallenge, and no environment files |
withChallenge | Challenge -> Ingress -> Ingress | replaces the ACME challenge |
withEnvironmentFiles | List String -> Ingress -> Ingress | replaces the list of files systemd reads the container’s environment from |
defaultImage | Image | docker.io/library/traefik:v3.3, the image ingress starts from |
acmeEmail is the address Let’s Encrypt sends expiry notices to. It reaches the
host as the email line of the ACME resolver in /etc/traefik/traefik.yml.
There is no withImage setter. To run a different Traefik version, build the
record directly — Ingress(..) is exported, and the five fields are the ones
ingress fills:
onTraefik34 : IngressonTraefik34 = Ingress { image = image "docker.io/library" "traefik" "v3.4" , acmeEmail = "ops@example.com" , frontend = frontend , challenge = TlsChallenge , envFiles = [] }Challenge — how Let’s Encrypt proves the domain is yours
A Challenge is TlsChallenge, which carries nothing, or DnsChallenge, which
carries { provider : String, delayBeforeCheck : String }:
| Constructor | Proves the domain by | Needs |
|---|---|---|
TlsChallenge | answering a challenge on port 443 | the domain already resolving to this host before the workload is deployed |
DnsChallenge | writing a TXT record through a DNS provider’s API | the provider’s API credentials in the container’s environment |
provider is a Traefik DNS provider code (cloudflare, route53, …) and
delayBeforeCheck is the wait before Traefik polls for the record, as a
duration string ("0", "30s").
overCloudflare : IngressoverCloudflare = withEnvironmentFiles [ "/etc/traefik/cloudflare.env" ] (withChallenge (DnsChallenge { provider = "cloudflare", delayBeforeCheck = "0" }) (ingress "ops@example.com" frontend))The credentials go in a file the unit names and golem never writes.
withEnvironmentFiles adds EnvironmentFile= lines to the container unit, so
the token is placed on the host out of band and never enters the manifest —
the same arrangement /etc/golem/token uses. Compare env, which ships its
value (sealed) to every host the scroll targets; see
Trust model.
publish — a workload, routed
publish frontend route workload returns the same workload with the front
door’s labels and network added:
survey : Workloadsurvey = Workload { name = "limesurvey" , image = image "docker.io" "adamzammit/limesurvey" "7.0.7" , env = [ env "LIMESURVEY_DB_HOST" "mysql" ] , envFiles = [] , labels = [] , ports = [] , networks = [ ManagedNetwork "limesurvey" ] , volumes = [] , restart = Always , expose = Unexposed }published : Workloadpublished = publish frontend surveyRoute surveyEvery other field is carried through unchanged, ports and expose included.
A routed workload normally sets ports = [] and expose = Unexposed: Traefik
reaches it across the frontend network on the container’s own port, so nothing
needs publishing to the host and the firewall opens nothing on its behalf. The
only ports open to the internet are the front door’s 80 and 443.
publish is the composition of the two exports underneath it, either of which
you can call directly:
| Function | Signature | Returns |
|---|---|---|
routeLabels | Frontend -> Route -> List Label | the seven labels that make Traefik route a container |
frontendNetwork | Frontend -> Network | ExistingNetwork on the frontend’s name |
The seven labels, for a Route named r on host h at port p:
| Label | Value |
|---|---|
traefik.enable | true — the opt-in, because the generated config sets exposedByDefault: false |
traefik.docker.network | the frontend’s network name |
traefik.http.services.r.loadbalancer.server.port | p |
traefik.http.routers.r.rule | Host(`h`) |
traefik.http.routers.r.entrypoints | websecure |
traefik.http.routers.r.tls | true |
traefik.http.routers.r.tls.certresolver | the frontend’s certificate resolver name |
Because frontendNetwork returns ExistingNetwork, a published workload emits
a Network= line and no .network unit of its own. The front door owns
that file, which is what makes its position in the scroll matter.
Placing the front door in a fleet
ingressUnit is the ready-made leaf unit; ingressGlyphs is the same glyph
list without one, for a scroll you assemble yourself.
| Function | Signature | Returns |
|---|---|---|
ingressGlyphs | Ingress -> List Glyph | every glyph the front door needs |
ingressUnit | Ingress -> Scroll | a leaf unit named traefik, carrying those glyphs and notifies = [ "golem-nftables.service" ] |
A scroll built on ingressGlyphs owns that notify itself — without it the two
firewall drop-ins are written but never loaded.
main : List Scrollmain = [ scroll { name = "manta" , groups = [ ingressUnit frontDoor , scroll { name = "limesurvey", glyphs = workloadGlyphs published } ] } ]Lowering — an Ingress to the four glyphs
ingressGlyphs produces, in order:
aptPackage { name = "podman" }andsystemdService { unit = "podman.socket" }— the API socket Traefik reads container labels from.directory /etc/traefikandfile /etc/traefik/traefik.yml— the static configuration: thewebentry point on:80redirecting towebsecureon:443, the Podman provider pointed atunix:///run/podman/podman.sockwithexposedByDefault: falseand the frontend network, and the ACME resolver with its email, its/letsencrypt/acme.jsonstorage, and the challenge lines.- the glyphs of the front-door
Workloaditself — a container namedtraefikpublishing80:80and443:443, on aManagedNetworkfor the frontend, mounting/etc/traefikand/run/podmanread-only from the host and the named volumetraefik-acmeat/letsencrypt,Restart=always, andexpose = Public, which is where the nftables base and thepublic-traefik-80andpublic-traefik-443drop-ins come from.
The container and the unit are both named traefik, so one host carries one
front door. Several hostnames go through it as several Routes.
The whole program above — front door plus one published workload — is 25
glyphs. From
cargo run -q -p emet -- build sites/website/examples/traefik-front-door.emet --text:
main : List Scrollplanned scrolls (1): scroll `manta` (25 glyphs): * ensure apt package `podman` installed * enable + start systemd unit `podman.socket` * ensure directory `/etc/traefik` (mode 0755) * ensure file `/etc/traefik/traefik.yml` (mode 0644) * ensure apt package `podman` installed * ensure directory `/etc/traefik` (mode 0755) * ensure directory `/run/podman` (mode 0755) * ensure apt package `aardvark-dns` installed * ensure file `/etc/containers/systemd/frontend.network` (mode 0644) * ensure file `/etc/containers/systemd/traefik-acme.volume` (mode 0644) * ensure file `/etc/containers/systemd/traefik.container` (mode 0600) * enable + start systemd unit `traefik.service` * ensure apt package `nftables` installed * ensure directory `/etc/nftables.d` (mode 0755) * ensure file `/etc/golem-nftables.conf` (mode 0755) * ensure file `/etc/nftables.d/00-base.nft` (mode 0644) * ensure file `/etc/systemd/system/golem-nftables.service` (mode 0644) * enable + start systemd unit `golem-nftables.service` * ensure file `/etc/nftables.d/public-traefik-80.nft` (mode 0644) * ensure file `/etc/nftables.d/public-traefik-443.nft` (mode 0644) * ensure apt package `podman` installed * ensure apt package `aardvark-dns` installed * ensure file `/etc/containers/systemd/limesurvey.network` (mode 0644) * ensure file `/etc/containers/systemd/limesurvey.container` (mode 0600) * enable + start systemd unit `limesurvey.service` ↻ manta/traefik notifies golem-nftables.service/etc/traefik appears twice — once as the configuration directory, once as the
container’s read-only mount source — and podman three times, once for the API
socket and once for each of the two containers. Repeated identical glyphs share
a content id and are enacted once (ADR 0034), so the duplication costs nothing
on the host.
The certificate is not in that list. traefik-acme is a named volume Traefik
writes acme.json into at runtime; golem writes the volume unit, and the
certificate lifecycle stays inside the container.
Where to next
- Two containers and a front door as one program: An app behind Traefik
Workload,Network,Label, and the rest of whatpublishoperates on: The Quadlet library- What all of it lowers to: The four glyphs