Skip to content

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 Traefik
import 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 : Frontend
frontend =
Frontend
{ network = "frontend"
, certificateResolver = "letsencrypt"
}
publish : Route -> Workload -> Workload
publish =
Traefik.publish frontend

Traefik.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 : Network
appNetwork =
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 : Workload
database =
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 : Workload
application =
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 : Route
surveyRoute =
route "limesurvey" "survey.example.com" 80

Also 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 : String
databasePassword = "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 Scroll
main =
[ 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 Scroll
planned 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.service

The 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 declarations

Ingress.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