Three years ago I wrote about managing containers with Docker Compose and pointing Traefik at them, and both of those posts are still more or less how I do things. This is the first of a few posts about what happened when I finally got tired of doing it all by hand.

Here’s what neither of those posts mentions. By the time you’re standing up your fifty-somethingth service, the process looks like this: you copy the last docker-compose.yml you wrote and change the image, the port, the subdomain, the volume path. You delete the bits that don’t apply. You keep the Traefik labels, but rename the router in all three of them. Then docker compose up -d, and hope. Somewhere along the way it stopped being configuration and started being transcription, and I’ve done it by hand, from memory, thirty-odd times.

The audit I should have done ages ago

Before I wrote a single line of code, I read all 33 of my docker-compose.yml files start to finish. Or rather, Claude did - I have never once wanted to do that, and it turns out I still don’t have to. I got two different problems back, and the second one bothers me a lot more than the first.

First, the things that are just broken. Three of them, all live, all doing nothing - and two of them doing nothing silently:

  • treafik.http.routers.status-pages.priority=1. I misspelled traefik. Traefik only reads labels under the traefik. prefix, so I had written a label nothing would ever read, and nothing anywhere warned me.
  • traefiki.docker.network=docker_default. Same class of typo, different service. Two different misspellings of the same word (I’d love to claim there’s a system).
  • traefik.docker.network=docker_local. Spelled right, but I pointed it at a network that doesn’t exist anywhere in the repo. It works today purely by luck: hass only sits on one network, so Traefik never actually needs the hint I’m failing to give it.

Those had been sitting in my config for who knows how long, and nothing ever told me about any of them. YAML will happily hold a key that means nothing whatsoever!

Second, the same job done five different ways. This is the one that actually bothers me.

Copy-paste doesn’t just spread mistakes, it spreads whichever version I happened to copy from that day - so my fleet has layers, and I can very nearly date a file by which conventions it uses. About half still carry the deprecated top-level version: '3.x' key. About half set container_name, with no rule I can reconstruct. Roughly a third set custom dns overrides (I know why a couple of them do; the rest are a mystery to me). One still has a links: directive, which Compose made redundant years ago (vikunja, I’m looking at you!). Two publish ports directly for no reason I can find, which looks an awful lot like debugging I forgot to undo.

And then the one that isn’t cosmetic. Some of my services declare depends_on and wait only for the dependency’s container to start, while others properly wait for its healthcheck to pass. Gitea, Wallabag and AdventureLog are in the first group; n8n, Sharry, Wanderer, Authentik and Miniflux are in the second. Same intent, two different behaviors, and a startup race sitting in the first group that I have so far only been lucky enough to avoid.

Every one of those is a decision I made once, correctly, and then never carried over to my other 32 files. The right way lives in my head, and I re-type it from scratch every time.

So I wrote a compiler

So what did I actually want here? To describe what’s different about a service, and let something else generate the parts that are always the same. That turned into a small language called hll - HomeLab Language, pronounced exactly how it looks - and a compiler, hllc, that turns it into Compose YAML with the Traefik labels already attached.

Here’s an entire service:

use "std:traefik" as traefik

service jellyfin {
  image "jellyfin/jellyfin:latest"
  volume "/mnt/media" -> "/data"
  env PUID = "1000"
  restart unless-stopped
  with traefik.http { host: "media.techdebtor.io", port: 8096 }
}

And here’s what hllc build makes of it, verbatim:

# Generated by hllc—do not edit.
# Edit the .hll source and re-run `hllc build` instead.
services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    restart: unless-stopped
    environment:
    - PUID=1000
    volumes:
    - /mnt/media:/data
    expose:
    - 8096
    labels:
    - traefik.http.routers.jellyfin.rule=Host(`media.techdebtor.io`)
    - traefik.http.services.jellyfin.loadbalancer.server.port=8096

The best part of that output is what isn’t in the input. I never typed a label. Both of those keys fall out of that one with line, and label keys are exactly where all three of my bugs were.

That std:traefik is worth a second look, because it isn’t part of the language I wrote. It’s a set of templates that ship inside the compiler, and my compiler knows how to write a label and nothing about what one means. I did not expect that distinction to matter as much as it did, and it gets a post of its own later.

I didn’t build anything clever here, either - hllc reads a file and writes a Compose file, and that’s about it. What comes out the other end is ordinary YAML that I can read, check into git, and run without hllc being anywhere nearby, which was the one thing I refused to compromise on!

For the divergence problem there are templates. A template is a named bag of fields, and a service picks up the ones it lists in with - so restart unless-stopped gets written once instead of retyped across 33 files, and when I change my mind about a convention I change it in the one place.

Diagnostics

Misspell a field name and you get told about it:

typo.hll: 4:3: unknown field "treafik" on `service`  if `treafik` is a
Compose key with no `hll` field yet, pass it through with `raw { treafik: ... }`

Point a service at a network I never declared - the same shape as the third bug from my audit - and it doesn’t get to be lucky:

hass.hll:3:13: service `hass` references undeclared network `docker_local`

Both of those exit non-zero, which is the part I actually care about - I can drop hllc check into CI and have it stop me before I deploy something silly.

What it doesn’t do

hll has a raw escape hatch for Compose keys the language doesn’t model yet, plus a labels block for Docker labels it can’t spell on its own. Both check structure - duplicate keys, collisions with a label the compiler already generated - but neither has any opinion about whether a key actually means anything. I know this because I fed it the exact treafik...priority typo from my audit, inside a labels block, and it compiled clean and passed the typo straight through. So the escape hatch is exactly as careful as I am, which based on the audit is not very :)

depends_on is in there, but it won’t fix my startup race for me. Both forms compile: a bare depends_on [db] comes out the other end meaning what it has always meant in Compose, which is “wait for the container to start”, and health-gating is something I write out in full as depends_on [db { condition: service_healthy }]. The compiler checks that I spelled the condition right. It won’t decide I wanted one. (I went back and forth on whether it should read the dependency’s healthcheck and upgrade the bare form for me without being asked. Ask me again in six months.)

Still on my plate:

  • an explain command, so I can ask where a given value in the generated output actually came from;
  • getting it running somewhere other than Linux x86-64; and finally,
  • shrinking the list of Compose keys that still need raw.

Where this series is going

The next few posts get into the parts I most enjoyed building:

  • what the language looks like to write;
  • what’s inside the compiler, which turned out to be far more approachable than I’d assumed;
  • how I designed it for Claude to write;
  • the month I spent removing a feature, and what that turned up; and finally,
  • what building the whole thing with an agent actually looked like.

One last note: it’s all up at github.com/travisboettcher/hl-lang if you want to poke at it, or just see how questionable my Rust is. It hasn’t reached 1.0 yet, and so far it has only ever been tested against a homelab of exactly one person.


This post was written with the help of Claude, which also wrote a good deal of the compiler it’s about.