Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Routing

Nothing on this page is part of the language.

hll has no router field, no matcher grammar, no idea what a hostname is for. Routing is Docker labels, labels are a built-in field, and the labels a reverse proxy wants come from templates like any others. This page covers one set of templates the compiler happens to ship.

That’s the point rather than an omission. The test every built-in has to pass is would this make sense on a homelab with completely different infrastructure?—and a router block only means something if you run Traefik. Earlier versions of hllc did answer that question in the grammar, and walking it back is what makes hll a language about Compose services rather than a language about one person’s reverse proxy. What the compiler stopped knowing covers the loss. The gain: Caddy, nginx, or a proxy that doesn’t exist yet is a file you write, not a compiler you fork.

Getting the templates

std:traefik ships inside hllc. There is no file to vendor and no path to get right—it resolves through the same use an ordinary import does, so Imports applies to it unchanged:

use "std:traefik" as traefik

service web {
  image "nginx"
  with traefik.http { host: "web.example.com", port: 8080 }
}
services:
  web:
    image: nginx
    expose:
    - 8080
    labels:
    - traefik.http.routers.web.rule=Host(`web.example.com`)
    - traefik.http.services.web.loadbalancer.server.port=8080

The alias is yours to pick. traefik reads well and this page uses it, but nothing depends on the name.

The common case

http is the shape most services want: one router, one hostname, one port. It writes the expose too, which is what keeps the port written once instead of twice.

http_named is the same shape for one of several routers on a service, building the <service>-<name> router id from the name you pass.

Both composites write the service’s expose, though, which means you can apply one of them per service: two would each set expose.port and collide. A service with two routers drops to the primitives below, where the service writes its own expose once:

use "std:traefik" as traefik

service web {
  image "nginx"
  expose 8080
  with
    traefik.http_rule { router: "{{name}}-public", rule: "Host(`web.example.com`)" },
    traefik.http_rule { router: "{{name}}-admin", rule: "Host(`admin.example.com`)" },
    traefik.port { port: 8080 }
}
services:
  web:
    image: nginx
    expose:
    - 8080
    labels:
    - traefik.http.routers.web-public.rule=Host(`web.example.com`)
    - traefik.http.routers.web-admin.rule=Host(`admin.example.com`)
    - traefik.http.services.web.loadbalancer.server.port=8080

Naming one template twice in a with list is how you say “two of these.” No template needs a plural form.

One template per label

Under the composites is a flat set of templates, one for each label a router can carry:

TemplateWrites
http_rule(router, rule)the router’s rule
http_entrypoints(router, entrypoints)its entrypoints
http_middlewares(router, middlewares)its middlewares
http_priority(router, priority)its priority
http_service(router, port)a Traefik service of its own, and the pointer to it
tcp_*the same five, one segment over, for TCP routers
port(port)the load-balancer target every router falls back to
docker_network(net)traefik.docker.network, from a network declaration
disable()traefik.enable=false, and nothing else

One per label rather than one router template with optional fields, because a labels block emits every key it lists and the language has no way to omit one. A single template taking every option would write an empty entrypoints= for a router that has none. So each label a router may or may not carry is its own template, and a caller lists the ones it wants:

use "std:traefik" as traefik

service web {
  image "nginx"
  expose 8080
  with
    traefik.http_rule { router: "{{name}}", rule: "Host(`web.example.com`)" },
    traefik.http_entrypoints { router: "{{name}}", entrypoints: ["web-secure", "web"] },
    traefik.http_priority { router: "{{name}}", priority: 42 },
    traefik.port { port: 8080 }
}
services:
  web:
    image: nginx
    expose:
    - 8080
    labels:
    - traefik.http.routers.web.rule=Host(`web.example.com`)
    - traefik.http.routers.web.entrypoints=web-secure,web
    - traefik.http.routers.web.priority=42
    - traefik.http.services.web.loadbalancer.server.port=8080

router is the full router id, not a name the template decorates. Pass "{{name}}" for the one unnamed router a service has, "{{name}}-api" for a named one—interpolation resolves inside an argument, so you never type the service’s own name out.

A list argument joins with commas, which is what entrypoints and middlewares want. See Passing a list.

Rules are strings

rule takes Traefik’s rule syntax as text, backticks and all:

with traefik.http_rule {
  router: "{{name}}"
  rule: "Host(`web.example.com`) && !PathPrefix(`/admin`)"
}

hllc doesn’t parse it. It checks that the value is a well-formed string, substitutes any {{...}} in it, and writes it out—which is the whole of what it does to any label value. See A value goes through as written.

Composing middlewares

A middlewares entry takes a list, and the shape is load-bearing. A list-valued labels entry concatenates across template tiers instead of colliding, so a template that adds one middleware to whatever it’s mixed into is expressible as its own unit:

use "std:traefik" as traefik

template internal {
  labels { "traefik.http.routers.{{name}}.middlewares": ["local-ipwhitelist@file"] }
}

template authenticated {
  labels { "traefik.http.routers.{{name}}.middlewares": ["forwardAuth-authentik@file"] }
}

service syncthing {
  image "lscr.io/linuxserver/syncthing:latest"
  with traefik.http { host: "syncthing.example.com", port: 8384 }, internal, authenticated
}
services:
  syncthing:
    image: lscr.io/linuxserver/syncthing:latest
    expose:
    - 8384
    labels:
    - traefik.http.routers.syncthing.rule=Host(`syncthing.example.com`)
    - traefik.http.services.syncthing.loadbalancer.server.port=8384
    - traefik.http.routers.syncthing.middlewares=local-ipwhitelist@file,forwardAuth-authentik@file

Written as single values those two templates would be two answers to a one-answer question, and hllc would refuse the pair. See A list value composes instead of colliding for the rule itself, which is about lists rather than about routing.

The Docker network label

Traefik needs to know which network to reach a multi-homed container on. docker_network writes it, and reads the network’s real Docker name—the name: override when the declaration sets one, the identifier otherwise—rather than making you repeat it:

use "std:traefik" as traefik

network proxy {
  external
  name: "docker_default"
}

service web {
  image "nginx"
  networks [proxy]
  with traefik.http { host: "web.example.com", port: 8080 },
       traefik.docker_network { net: proxy }
}
services:
  web:
    image: nginx
    networks:
    - proxy
    expose:
    - 8080
    labels:
    - traefik.http.routers.web.rule=Host(`web.example.com`)
    - traefik.http.services.web.loadbalancer.server.port=8080
    - traefik.docker.network=docker_default
networks:
  proxy:
    name: docker_default
    external: true

{{net.name}} inside the template is a field access, which is a general facility: a declaration holds values and the language can read them. Nothing here is special-cased for Traefik.

Keeping Traefik off a service

disable writes traefik.enable=false and nothing else:

use "std:traefik" as traefik

service db {
  image "postgres:15"
  with traefik.disable
}
services:
  db:
    image: postgres:15
    labels:
    - traefik.enable=false

A zero-parameter template needs no argument body, so with traefik.disable is the whole invocation.

What the compiler stopped knowing

Worth saying plainly, because this is a real trade rather than a free win.

When routing was a built-in, hllc understood what a rule meant. It parsed the matcher expression, checked matcher names, checked argument counts, and refused a path_prefix beside a rule that already said where to route. A typo in PathPrefx was a compile error.

None of that survives. A label value is a string, and a misspelled matcher inside one is a string with a typo in it—hllc writes it out and Traefik declines to match anything. The checks you keep are the ones that belong to the language rather than to Traefik: a duplicate label key, two entries that resolve to one key, an unknown interpolation, an unsubstituted parameter, a template invoked with the wrong arguments.

Adding a Traefik rule validator back would mean the compiler tracking a third party’s syntax across its releases, which is the coupling this page exists to undo. The check moved to where someone maintains it: Traefik’s own startup, which reports a rule it can’t parse.

Writing your own

There is nothing privileged about std:traefik. It’s a .hll file of ordinary templates that happens to travel inside the compiler, and a Caddy or nginx equivalent is the same file in your own repo:

network proxy {
  external
  name: "docker_default"
}

template caddy(net, host, port) {
  expose $port
  networks [$net]
  labels {
    "caddy": "{{host}}"
    "caddy.reverse_proxy": "{{name}}:{{port}}"
    "caddy.network": "{{net.name}}"
  }
}

service jellyfin {
  image "jellyfin/jellyfin"
  with caddy { net: proxy, host: "media.example.com", port: 8096 }
}
services:
  jellyfin:
    image: jellyfin/jellyfin
    networks:
    - proxy
    expose:
    - 8096
    labels:
    - caddy=media.example.com
    - caddy.reverse_proxy=jellyfin:8096
    - caddy.network=docker_default
networks:
  proxy:
    name: docker_default
    external: true

Same fields, same composition rules, same interpolation. The only thing std:traefik has that this doesn’t is a shorter use line.