An app behind Traefik
An application and its database run as two containers on a private network. One
hostname answers the internet over HTTPS and reaches the application across that
network; nothing else on the host is reachable at all. golem ships both halves —
Quadlet builds the containers,
Traefik builds the front door that terminates TLS and
routes the hostname to one of them.
The program is sites/website/examples/front-door-app.emet, a survey
application backed by MariaDB. Each section below is a slice of it.
import Traefik exposing (Frontend(..), ingress, ingressUnit)import Traefikimport Routing exposing (Route, route)import Quadlet exposing ( image , env , Network(..) , Restart(..) , Access(..) , Relabel(..) , fromVolume , Expose(..) , Workload(..) , workloadGlyphs )Name the frontend once
Two things have to agree for routing to work: the network the front door and the
application share, and the name of the certificate resolver. A Frontend holds
both, so they are written once and passed around:
frontend : Frontendfrontend = Frontend { network = "frontend" , certificateResolver = "letsencrypt" }
publish : Route -> Workload -> Workloadpublish = Traefik.publish frontendTraefik.publish takes the frontend, a route, and a workload. Applying it to
the frontend alone leaves a two-argument function — publish route workload —
and every call site in the program is then free of the frontend entirely. That
partial application is the whole reason the local name is worth defining.
A private network for the two containers
appNetwork : NetworkappNetwork = ManagedNetwork "limesurvey"ManagedNetwork means golem writes the .network quadlet that creates it. The
database and the application both join it, which is how the application reaches
the database by container name.
The database
database : Workloaddatabase = Workload { name = "mysql" , image = image "docker.io/library" "mariadb" "11.4" , env = [ env "MARIADB_ROOT_PASSWORD" databasePassword , env "MARIADB_DATABASE" "limesurvey" ] , envFiles = [] , labels = [] , ports = [] , networks = [ appNetwork ] , volumes = [ fromVolume "limesurvey-db-data" "/var/lib/mysql" ReadWrite Private ] , restart = Always , expose = Unexposed }ports = [] and expose = Unexposed keep the database off the host entirely:
it publishes no port, the firewall opens none, and it is reachable only from
containers on limesurvey. Its data lives in a named volume, so the .volume
quadlet that declares it is written for you.
The application, and the route to it
application : Workloadapplication = Workload { name = "limesurvey" , image = image "docker.io" "adamzammit/limesurvey" "7.0.7" , env = [ env "LIMESURVEY_DB_HOST" "mysql" , env "LIMESURVEY_DB_NAME" "limesurvey" , env "LIMESURVEY_DB_PASSWORD" databasePassword ] , envFiles = [] , labels = [] , ports = [] , networks = [ appNetwork ] , volumes = [] , restart = Always , expose = Unexposed }
surveyRoute : RoutesurveyRoute = route "limesurvey" "survey.example.com" 80Also Unexposed, also publishing no ports: Traefik reaches it across the
frontend network on the container’s own port 80, which is what the Route
records. LIMESURVEY_DB_HOST is the database’s container name, resolved by the
aardvark-dns resolver that ManagedNetwork installs.
The route is defined next to the application rather than next to the front
door. Routing does not depend on Traefik, so a module that describes an
application can say which hostname and port it wants without knowing what will
serve it.
The password
databasePassword : StringdatabasePassword = "development-only"A literal keeps this page compiling without a fleet key. In a real program that
line is Secretspec.get "LIMESURVEY_DB_PASSWORD", resolved on your machine at
compile time from a key declared in secretspec.toml and sealed into the
manifest — see Secretspec and
Trust model. It flows into both containers from one
binding, so the database’s root password and the application’s client password
cannot drift apart.
The fleet
main : List Scrollmain = [ scroll { name = "manta" , groups = [ ingressUnit (ingress "ops@example.com" frontend) , scroll { name = "mysql" , glyphs = workloadGlyphs database } , scroll { name = "limesurvey" , glyphs = workloadGlyphs (publish surveyRoute application) } ] } ]Three leaf units under one host. Each is a failure-isolation boundary: if the database unit fails, the application unit still runs, and neither rolls back the other.
The order is deliberate, and it is a reading order: the front door owns the
frontend .network file that the published application’s Network= line
refers to, so it comes first, and the database comes before the application it
backs. It is not an enact order. Leaf units drain a bounded worker pool
concurrently and source order holds only among one unit’s own glyphs (ADR
0034 §3), so what the tree buys here is isolation and legibility rather than
sequencing.
What it lowers to
31 glyphs, from
cargo run -q -p emet -- build sites/website/examples/front-door-app.emet --text:
main : List Scrollplanned scrolls (1): scroll `manta` (31 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-db-data.volume` (mode 0644) * ensure file `/etc/containers/systemd/mysql.container` (mode 0600) * enable + start systemd unit `mysql.service` * 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.serviceThe last eleven glyphs — everything below the two public-traefik-* drop-ins —
are the database and the application, and not one of them is a firewall rule.
Those two drop-ins are the only ports this host opens.
limesurvey.network appears twice because both containers declare the same
network; identical glyphs share a content id and are enacted once (ADR 0034),
as does the repeated podman package.
The same program, split across modules
examples/limesurvey/ in the golem repository is this program written as a
fleet would write it — three files, each owning one concern:
examples/limesurvey/ Ingress.emet the front door Limesurvey.emet the application and its database main.emet the wiring, and the secrets secretspec.toml the secret declarationsIngress.emet holds the Frontend and exports two names: publish, the
partially-applied router, and units, the front door’s unit as a one-element
List Scroll. It is the only file in the fleet that mentions Traefik.
Limesurvey.emet describes the application. It imports Quadlet and Routing
and not Traefik — it exports route and workload separately and lets
its caller decide whether to publish them. Its units function takes the
published workload as an argument, so the application module never learns what
happened to it.
Its constructors take two records: a defaults record and a required one.
Limesurvey.defaults.survey carries the image, container name, port, and mount
paths; the second argument carries the administrator’s email address and the
password, which have no defaults. A caller who forgets the password gets a
compile error at that call site, not a container that starts with an empty one.
main.emet is the only file that resolves secrets and the only file that knows
both halves exist. It calls Limesurvey.route and Limesurvey.workload, hands
the result to Ingress.publish, and concatenates Ingress.units with
Limesurvey.units into the host scroll’s groups — the same three-unit shape
as the single-file program above.
Compiling it needs the fleet key and a secretspec provider —
emetc build examples/limesurvey/main.emet --secret-key <FILE>. See the
CLI reference.
Where to next
- Every
TraefikandRoutingtype: The Traefik library - Every
Quadlettype: The Quadlet library - A fleet’s own helper layer on the same libraries: A service abstraction