Skip to content

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 : Frontend
frontend =
Frontend
{ network = "frontend"
, certificateResolver = "letsencrypt"
}
FieldIs
networkthe 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
certificateResolverthe 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 : Route
surveyRoute =
route "limesurvey" "survey.example.com" 80
theSameRoute : Route
theSameRoute =
Route
{ name = "limesurvey"
, host = "survey.example.com"
, port = 80
}
FieldIs
namethe Traefik router and service name. It appears inside every label key, so it must be unique among the routes on one front door
hostthe hostname the router matches, as Host(`host`)
portthe 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 : Ingress
frontDoor =
ingress "ops@example.com" frontend
FunctionSignatureDoes
ingressString -> Frontend -> Ingressbuilds a front door from the ACME contact address and the frontend, with defaultImage, TlsChallenge, and no environment files
withChallengeChallenge -> Ingress -> Ingressreplaces the ACME challenge
withEnvironmentFilesList String -> Ingress -> Ingressreplaces the list of files systemd reads the container’s environment from
defaultImageImagedocker.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 : Ingress
onTraefik34 =
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 }:

ConstructorProves the domain byNeeds
TlsChallengeanswering a challenge on port 443the domain already resolving to this host before the workload is deployed
DnsChallengewriting a TXT record through a DNS provider’s APIthe 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 : Ingress
overCloudflare =
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 : Workload
survey =
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 : Workload
published =
publish frontend surveyRoute survey

Every 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:

FunctionSignatureReturns
routeLabelsFrontend -> Route -> List Labelthe seven labels that make Traefik route a container
frontendNetworkFrontend -> NetworkExistingNetwork on the frontend’s name

The seven labels, for a Route named r on host h at port p:

LabelValue
traefik.enabletrue — the opt-in, because the generated config sets exposedByDefault: false
traefik.docker.networkthe frontend’s network name
traefik.http.services.r.loadbalancer.server.portp
traefik.http.routers.r.ruleHost(`h`)
traefik.http.routers.r.entrypointswebsecure
traefik.http.routers.r.tlstrue
traefik.http.routers.r.tls.certresolverthe 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.

FunctionSignatureReturns
ingressGlyphsIngress -> List Glyphevery glyph the front door needs
ingressUnitIngress -> Scrolla 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 Scroll
main =
[ scroll
{ name = "manta"
, groups =
[ ingressUnit frontDoor
, scroll { name = "limesurvey", glyphs = workloadGlyphs published }
]
}
]

Lowering — an Ingress to the four glyphs

ingressGlyphs produces, in order:

  1. aptPackage { name = "podman" } and systemdService { unit = "podman.socket" } — the API socket Traefik reads container labels from.
  2. directory /etc/traefik and file /etc/traefik/traefik.yml — the static configuration: the web entry point on :80 redirecting to websecure on :443, the Podman provider pointed at unix:///run/podman/podman.sock with exposedByDefault: false and the frontend network, and the ACME resolver with its email, its /letsencrypt/acme.json storage, and the challenge lines.
  3. the glyphs of the front-door Workload itself — a container named traefik publishing 80:80 and 443:443, on a ManagedNetwork for the frontend, mounting /etc/traefik and /run/podman read-only from the host and the named volume traefik-acme at /letsencrypt, Restart=always, and expose = Public, which is where the nftables base and the public-traefik-80 and public-traefik-443 drop-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 Scroll
planned 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