Writing Services in hll
Last post I covered why I wrote a compiler for my homelab, which was mostly 33 Compose files that had drifted apart from each other over three years. This one is about what you actually write in hll. I’ll leave the field-by-field details to the user guide and just walk through the shapes I use most.
The shape of a service
Every service is a declaration - a type, a name, and a body. Here’s BookStack, where all my long-form homelab documentation lives:
service bookstack {
image "lscr.io/linuxserver/bookstack:latest"
expose 80
env PUID = "1000"
env PGID = "1000"
restart unless-stopped
}
Feed that to hllc build and here’s what comes back:
# Generated by hllc—do not edit.
# Edit the .hll source and re-run `hllc build` instead.
services:
bookstack:
image: lscr.io/linuxserver/bookstack:latest
restart: unless-stopped
environment:
- PUID=1000
- PGID=1000
expose:
- 80
Not one word in that input is a keyword. service, image, expose and the rest are ordinary identifiers that get looked up in a table while my file is being parsed (which means I can name a network expose if I want to, and next post I do).
Templates, and the problem they fix
Templates are the reason I built this thing, and they’re where last post’s complaint gets answered. Half my Compose files set container_name under a rule I can no longer reconstruct; a third carry dns overrides I have forgotten the reasons for. Those aren’t 33 decisions - they’re one decision, re-typed from memory 33 times, drifting a little each time.
So you write it once. Here’s Paperless, and I’d watch the first line:
use "std:traefik" as traefik
network proxy {
external
name: "docker_default"
}
template linuxserver_app(puid, pgid) {
env PUID = $puid
env PGID = $pgid
restart unless-stopped
networks [proxy]
}
service paperless {
with linuxserver_app { puid: 1000, pgid: 1000 },
traefik.http { host: "paper.techdebtor.io", port: 8000 },
traefik.docker_network { net: proxy }
image "paperlessngx/paperless-ngx:latest"
}
Parameters are declared bare (no types to write - the field a value lands in does the checking) and referenced with a $. Changing my mind about a convention is now one edit instead of 33 reviews.
Here are the labels that come out of that, with the rest of the service left off:
labels:
- traefik.http.routers.paperless.rule=Host(`paper.techdebtor.io`)
- traefik.http.services.paperless.loadbalancer.server.port=8000
- traefik.docker.network=docker_default
The part I didn’t see coming
Look at that first line again. traefik.http is a template. Not a keyword, not a field, not a thing the compiler knows about - a template, invoked through the same with as the one I wrote myself two paragraphs ago. std:traefik ships inside the hllc binary, so there’s no file to vendor and no path to get right - but that’s a delivery convenience and nothing more. My compiler has never heard of Traefik.
That gets a whole post later on (it wasn’t always true, and the getting there is the strangest thing that’s happened to this project). What matters here is the test it came out of: would this make sense on a homelab with completely different infrastructure? A router field only means something if you happen to run Traefik, so it’s none of the language’s business. Neither is Authentik, or my domain, or the PUID that every LinuxServer.io image asks for. If I move to Caddy tomorrow that’s a file I write, not a compiler I fork.
The docker_network line is my favorite small piece of this. It reads the network’s real Docker name out of the declaration rather than making me repeat it - and it’s the exact label I once misspelled as traefiki.
Two templates, one label
Middlewares are where the design earns its keep. Both of these write the same label key:
template internal_only {
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 internal_only, authenticated
}
Apply both and they combine rather than fight - here’s the one label they produce between them:
- traefik.http.routers.syncthing.middlewares=local-ipwhitelist@file,forwardAuth-authentik@file
The brackets are doing that. A single value says the key holds one thing, so two templates setting it are two answers to a one-answer question and hllc refuses the pair (I checked - it does). A list says the key holds several, so several places contributing is the entire point. Which leaves authenticated as a standalone unit I can mix into anything, and that turned out to be the property I actually wanted.
The escape hatch
There are Compose keys hll has no field for, and there always will be, so raw passes whatever you give it straight through. Here’s Jellyfin getting at the iGPU - devices is a real field so I write it normally, and group_add isn’t, so it goes in raw.
service jellyfin {
image "jellyfin/jellyfin:latest"
expose 8096
devices "/dev/dri" -> "/dev/dri"
raw {
group_add: ["video"]
}
}
One sharp edge, found the hard way, which is where this blog gets most of its material. raw { labels: ... } replaces the computed labels instead of adding to them, so a service with routing loses all of it without a word:
use "std:traefik" as traefik
service jellyfin {
image "jellyfin/jellyfin:latest"
with traefik.http { host: "media.techdebtor.io", port: 8096 }
raw {
labels: ["com.example.owner=me"]
}
}
Both routing labels are gone from the output, and the only one left is the one I wrote by hand. It warns about that now:
rawlabels.hll:7:5: warning: `raw { labels: ... }` replaces service `jellyfin`'s
computed labels rather than adding to them, so every entry its `labels` blocks and
the templates it applies would have produced is dropped — write the extra labels in
a `labels { ... }` block instead, or reproduce the ones you still need in this list
Writing that warning took me less time than working the behavior out a second time would have (the second time is always somehow worse than the first).
Next
Next post goes inside the compiler - the token stream, the parser, and the table that makes all of this one function instead of thirty. If you’d rather read real documentation than wait around for me, the user guide covers every field, and the design doc has the formal grammar behind it.
This post was written with the help of Claude, which also wrote a good deal of the compiler it’s about.