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

Introduction

hll (pronounced “hell”—short for HomeLab Language) is a small declarative language for describing homelab services. You write a compact .hll file describing a service—its image, the port it exposes, its volumes, environment variables, restart policy—and hllc, the hll compiler, transpiles it into a Docker Compose YAML file with Traefik reverse-proxy labels already attached.

It exists to remove copy-paste. Standing up a new homelab service with Docker Compose and Traefik usually means duplicating a near-identical Compose service block and label set, changing only the image, the port, and the subdomain. hll lets you write just what’s different about a service, and pull in the repeated parts (the Traefik network, the forward-auth middleware, the PUID/PGID pair every LinuxServer.io image wants) from a shared template.

hll is a transpiler, not an interpreter—there’s no evaluation, no runtime, no state. Every .hll file compiles down to plain Compose YAML that you check in, deploy, and read like any other Compose file.

Who this book is for

This is a user guide: it’s for someone writing .hll files to describe their own homelab, not someone modifying the compiler itself. It covers the language’s syntax in plain terms, every built-in field, how templates and imports work, and the hllc command line.

If you’re looking for the formal grammar, desugaring rules, or the internals of the lexer/parser/codegen pipeline, see docs/DESIGN.md in the repository instead—the compiler builds against that implementer-facing spec. This book is a friendlier presentation layer on top of it, covering the same rules with prose and examples instead of Backus-Naur Form (BNF).

A quick taste

service jellyfin {
  image "jellyfin/jellyfin:latest"
  expose 8096
  volume "/mnt/media" -> "/data"
  env PUID = "1000"
  restart unless-stopped
}

hllc build turns that into a ready-to-run docker-compose.yml with a jellyfin service, its image, a bind mount, an environment variable, a restart policy, and the network wiring—all from those six lines.

A reverse proxy handles the hostname that reaches it, rather than Compose, so that isn’t part of the language: it’s labels, and templates write them. Routing covers the set hllc ships for Traefik. The rest of this book walks through how all of that works, starting with Getting Started.

Getting started

This page walks through going from nothing to a running service, using hll the whole way.

Installing hllc

Every merge to main cuts a tagged release with a prebuilt Linux x86-64 hllc binary attached—no Rust toolchain required:

curl -Lo hllc https://github.com/travisboettcher/hl-lang/releases/latest/download/hllc-linux-x86_64
chmod +x hllc
./hllc --version

Put hllc somewhere on your PATH, for example ~/.local/bin, so the rest of this page can just call hllc directly. Pin to a specific release tag instead of latest for anything you intend to keep reproducible, such as CI or a deploy script.

Linux x86-64 is the only platform this project tests or supports—see the main repository’s README. If you’re on a different platform, building from source—also covered in the README—may work, but it’s untested.

Your first service

Create a file called jellyfin.hll:

service jellyfin {
  image "jellyfin/jellyfin:latest"
  expose 8096
  volume "/mnt/media" -> "/data"
  env PUID = "1000"
  restart unless-stopped
}

This declares one service named jellyfin, running the jellyfin/jellyfin:latest image, reachable on container port 8096, with a bind mount, one environment variable, and a restart policy of unless-stopped.

Reaching it from a hostname is a separate matter, and deliberately not part of the language—see Routing once the basics here make sense.

Compile it:

hllc build jellyfin.hll

With no --out, build prints the generated Compose YAML straight to your terminal—a Compose services: block for jellyfin, with image, expose, volumes, environment, restart and networks. Skim it—the mapping from the .hll fields you wrote to the YAML fields it produces should be fairly direct.

The first two lines are a # Generated by hllc comment. Compose ignores it, but it marks the file as compiler output—and hllc refuses to overwrite a file that doesn’t have it, so pointing build at a directory can’t quietly eat a docker-compose.yml you wrote by hand (see the command-line tool page).

To write the result to disk instead of printing it:

hllc build jellyfin.hll --out docker-compose.yml
docker compose -f docker-compose.yml up -d

That’s a complete, deployable service from six lines of hll.

Adding a second service

A single .hll file can declare more than one service, and a real homelab usually wants several. Add a second service to the same file, or start a new one—either works, since hllc build treats one input file as one Compose document that may hold multiple services:

volume uptime-kuma-data {}

service jellyfin {
  image "jellyfin/jellyfin:latest"
  expose 8096
  volume "/mnt/media" -> "/data"
  env PUID = "1000"
  restart unless-stopped
}

service uptime-kuma {
  image "louislam/uptime-kuma:latest"
  expose 3001
  volume uptime-kuma-data -> "/app/data"
  restart unless-stopped
}

uptime-kuma-data names a Docker-managed named volume rather than a host path, so it needs the top-level volume uptime-kuma-data {} declaration at the top of the file—the same way a networks [x] entry needs a network x { ... }. The /mnt/media that jellyfin mounts starts with a /, which makes it a bind mount, and bind mounts need no declaration. See volume for the full rule.

Removing repetition with a template

Both preceding services repeat restart unless-stopped, and a real homelab tends to repeat far more than that across every service—the same proxy network, the same routing labels, the same PUID/PGID pair. That repetition is what template and with are for:

template baseline {
  restart unless-stopped
}

volume uptime-kuma-data {}

service jellyfin {
  with baseline
  image "jellyfin/jellyfin:latest"
  expose 8096
  volume "/mnt/media" -> "/data"
  env PUID = "1000"
}

service uptime-kuma {
  with baseline
  image "louislam/uptime-kuma:latest"
  expose 3001
  volume uptime-kuma-data -> "/app/data"
}

Both services now pick up restart unless-stopped from one place. A template applies only where a with names it, so nothing happens behind your back and a service can opt out by leaving the line off. Templates & Composition covers parameters and the merge rules in full. Imports covers sharing templates like this across every .hll file in your homelab instead of just within one file.

Where to go next

  • Syntax Basics—how a statement, a body, and the primary-value shorthand from the preceding example (expose 8096 instead of writing the full expose { port: 8096 } body) actually work.
  • Built-in Fields—every field hll understands, what it accepts, and its defaults.
  • Routing—getting a hostname to reach one of these services, with the Traefik templates hllc ships.
  • The hllc command-line tool—building a whole directory of services at once, not just one file.

Syntax basics

This page explains how .hll files fit together, in plain terms. If you’ve read Getting Started you’ve already seen most of these shapes in practice—this page names them and explains the rules behind them.

Declarations

A .hll file is a sequence of top-level declarations. There are three kinds:

  • A named declaration—a service, a network, or a volume—gives a type and a name, followed by a body: service jellyfin { ... }.
  • A template declaration starts with the word template: template internal_web(port) { ... }. That word isn’t reserved—see Reserved words below—it just has to come first here.
  • A use declaration imports another file: use "docker.hll" as traefik. See Imports.

Bodies and statements

A body is a { }-delimited list of statements, one per line:

service jellyfin {
  image "jellyfin/jellyfin:latest"
  expose 8096
  restart unless-stopped
}

Every statement is one of two shapes:

  • key: value—an explicit field assignment, for example, restart: unless-stopped.
  • key followed by some shorthand—the common case in practice, covered below.

A “value” itself can be a string ("jellyfin/jellyfin:latest"), a number (8096), a bare word (unless-stopped), a list ([a, b, c]), or another nested statement—bodies nest arbitrarily, which is how a healthcheck { ... } block and with internal_web { port: 8080 } both work: the { ... } after internal_web is itself a body, using the exact same grammar as a service’s own top-level body.

One difference between the two is worth knowing early. A field’s own body separates its statements by newline, like a service body does. An invocation’s argument body also accepts commas, which is what lets with caddy { net: proxy, port: 8096 } fit on one line.

Reserved words

hll has none. Every word that looks like a keyword—template, service, network, image, build, volume, env, restart, expose, labels, with, as, use, raw, defaults, and so on—is an ordinary identifier that only means something because of where it appears and what field it’s assigned to. This is deliberate: it keeps the door open for a field or a template named anything at all, with no list of words you have to avoid.

template was the one exception until recently. Removing it lets the preceding rule hold without a footnote. So this parses, odd as it reads:

template template {
  restart unless-stopped
}

service template {
  image "nginx"
  with template
}

Naming things this way is a bad idea, not a good one. The point is that nothing in the language stops you—which is the same promise every other keyword-shaped word already made.

The primary-value shorthand

Writing expose { port: 8096 } for a type that has one obvious “main” field is more ceremony than the information deserves. Any type with a primary field lets you skip the field name and the braces, and just write the value directly after the type name:

image "jellyfin/jellyfin:latest"
# same as: image { ref: "jellyfin/jellyfin:latest" }

expose 8096
# same as: expose { port: 8096 }

image’s primary field is ref, and expose’s is port. See Built-in Fields for the full list of which type’s primary field is which.

Secondary-field shorthand

A type with several fields lets you skip the full { } body (each field on its own line—see Layout rules below) and instead fuse further fields onto the primary position with a leading comma:

build "./app", dockerfile: "Dockerfile.prod"
# same as:
# build {
#   context: "./app"
#   dockerfile: "Dockerfile.prod"
# }

From there, you can keep adding further key: value fields, each preceded by a comma.

The same shorthand is what makes a with invocation’s arguments read the way they do—with traefik.http { host: "...", port: ... } is one body written on one line, not a special call syntax.

Map-style shorthand

Three types—volume, publish, and env—are conceptually key/value maps rather than named struct fields, and each has its own natural-looking separator instead of a colon:

volume "/mnt/media" -> "/data"     # host path -> container path
volume media -> "/media"           # named volume -> container path
publish 8096 -> 8096               # host port -> container port
env PUID = "1000"                  # key = value

volume is the one map-style field whose key side can be either. A quoted host is a path. An unquoted one is an identifier referring to a named Docker volume, and needs a declaration to refer to.

volume here is also the field that mounts something into a service. A volume at the top level of a file, outside any service body, is a different thing—the declaration of a named Docker volume, whose body is an ordinary struct body. See volume.

Writing any of these more than once in the same body accumulates entries rather than overwriting—a service can have several volume lines, several publish lines, and several env lines. The same is true of networks and depends_on, which are list fields. image and restart, by contrast, are scalar—writing either twice in the same body is a compile error, not a silent overwrite.

Layout rules

Two rules govern whitespace and punctuation, and both matter in practice:

  • Different fields go on different lines, not different fields separated by commas. image "x" and restart unless-stopped must each be on their own line inside a service/template/network body. A comma between two unrelated fields (image "x", restart unless-stopped) is a compile error. A comma continues a single field’s own list—it never marks the boundary between two different fields. (volume/ env/raw { } bodies and a with-invocation’s argument body are the exception—see below.)
  • A trailing comma continues a list, but its absence ends it. This applies to bracket lists ([a, b, c]), a bare with-list (with a, b, c), and the preceding secondary-field shorthand. If there’s a next item, the comma before it’s mandatory—bare adjacency with no comma does not imply continuation.
volume syncthing-config {}

service syncthing {
  with internal_web { port: 8384 },
       authenticated,
       linuxserver_app { puid: 1000, pgid: 100 }
  image "lscr.io/linuxserver/syncthing:latest"
  volume syncthing-config -> "/config"
}

A long with list reads better wrapped across multiple lines, one template per line, as long as every line but the last ends with a trailing comma—this parses identically to writing it all on one line.

volume { }/env { }/raw { } bodies, and a with-invocation’s own argument body ({ port: 8080 } from the preceding example), are the exception to the newline rule: they’re key/value maps, not named struct fields, so the compact one-line style ({ puid: 1000, pgid: 100 }) is fine there, comma-separated or one entry per line with no commas at all. What’s still not valid is bare adjacency on one line with neither—{ "a": "/x" "b": "/y" } is a parse error, just written without the comma the preceding struct-body rule would otherwise demand a newline for instead.

Comments and interpolation

A # starts a line comment, running to the end of the line. It’s only recognized between tokens—a # inside a string is just a literal character, not a comment:

# Media server
service jellyfin {
  image "jellyfin/jellyfin:latest"  # pin this before upgrading
}

A string can contain {{name}}, which interpolates the enclosing service’s own name at compile time. This is how a template can generate a per-service hostname without knowing the service’s name in advance:

template internal_web(port) {
  expose $port
  labels {
    "traefik.http.routers.{{name}}.rule": "Host(`{{name}}.internal.example.com`)"
  }
}

Applied inside service syncthing { with internal_web { port: 8384 } }, {{name}} resolves to syncthing, producing syncthing.internal.example.com.

Inside a template, the same form also reaches that template’s own parameters: {{port}} puts the port argument into a string, where $port can only fill a whole value. See Interpolating a parameter. {{name}} always means the service, even in a template that declares a parameter of that name.

A binding with a dot in it reads a field off a network or volume declaration—{{proxy.name}} puts that network’s real Docker name into the string, and proxy.name on its own fills a whole value. See Reading a declaration’s real name.

Numbers and strings

Numbers are integers only—no sign, no decimal point, no exponent (8096, not 8096.0 or -1). Strings are double-quoted, and a backslash escapes the character after it:

EscapeCharacter
\"a double quote
\\a backslash
\na newline
\ta tab
\ra carriage return

Those five are the whole set. A backslash followed by anything else, such as "\q", is a compile error naming the backslash, so an escape the language doesn’t have never turns into the two characters that spell it.

Use them wherever a value needs a quote or a line break of its own—a shell command that quotes its own argument, a JSON blob in an environment variable, or the multi-line entrypoint a raw block passes straight through to Compose:

command "sh -c \"exec nginx -g 'daemon off;'\""
env CONFIG = "{\"log\": \"debug\"}"
raw {
  entrypoint: "echo starting\nexec /app/server"
}

A string still can’t run past the end of its line: \n is how you write a newline, and a line that ends before its closing " is a compile error. So is a string ending in a backslash, such as "C:\"—that backslash escapes the closing quote, which leaves the string unfinished. Write a trailing backslash as \\.

A labels key rejects a newline, a tab and an =, on top of the metacharacter set every string already rejects. None of them belongs in a label key, and each changes which label Docker reads.

A template’s declared parameter carries no type annotation—just a bare name (template linuxserver_app(puid, pgid) { ... }). Instead, composition checks a substituted argument against the field it lands in: a reference-shaped position such as networks or a router’s middleware rejects a bare number, since that position’s own grammar can never hold one directly, and a number-typed position such as expose.port rejects anything that isn’t one, whether the value arrives through a $param or you write it directly—so expose "eight-thousand" fails the same way with a_template { port: "eight-thousand" } would. Every other position accepts any literal kind, exactly as writing it directly would.

Built-in fields

This is a reference for every field hll understands: what it accepts, its default, and what it produces in the generated Compose YAML. See Syntax Basics for the shorthand forms referenced below—primary-value, secondary-field, map-style.

service, network, and volume

service, network, and volume are the three top-level declaration types—each requires a name, such as service jellyfin { ... }, network traefik-net { ... }, or volume syncthing-config { ... }, and a body. A template body accepts exactly the same set of fields as service—see Templates & Composition.

volume names two different things, depending on where you write it. At the top level it declares a named Docker volume, which this section covers. Inside a service or template body it mounts one, the map-kind volume field further down.

network fields

FieldAcceptsDefault
externalbare flag, no valueunset, false
namestringthe network’s own hll identifier

external marks a network as one Docker already manages (for example, one docker compose created for another stack) rather than one this file’s own Compose output should create. name is the real underlying Docker network name, when it differs from the identifier you declared the network under—needed because Compose’s own auto-derived network names depend on the directory you ran docker compose from, which the compiler can’t know:

network traefik-net {
  external
  name: "docker_default"
}

Whatever name ends up as, traefik-net.name reads it back from any value position—a label that has to carry the real Docker name, say. See Reading a declaration’s real name.

volume declaration fields

FieldAcceptsDefault
externalbare flag, no valueunset, false
namestringthe volume’s own hll identifier
driverstringunset, matching Compose’s own default of local
driver_optsmap body, key: valueempty

external and name mean exactly what they mean on a network, down to media.name reading the second one back. The first marks a volume Docker already manages rather than one this file’s own Compose output should create. The second is the real underlying Docker volume name, when it differs from the identifier you declared the volume under. hllc passes driver and driver_opts straight through to Compose:

volume syncthing-config {}

volume media {
  external
  name: "media_store"
}

volume backups {
  driver "local"
  driver_opts {
    type: "nfs"
    o: "addr=192.168.50.10,rw"
    device: ":/exports/backups"
  }
}

Every named volume a service mounts needs one of these declarations—see the volume field for what counts as a named volume and why hllc requires the declaration.

image

Primary field: ref.

FieldAcceptsDefault
refstringrequired—no default
image "jellyfin/jellyfin:latest"

Every service needs an image or a build—either directly or inherited from a template—and hllc build fails when it would emit neither.

That check reads the generated document, not the image field, so a raw entry supplying the key by hand counts:

service foo {
  raw {
    image: "test:latest"
  }
}

build

Primary field: context.

FieldAcceptsDefault
contextstringRequired—a build with no context has nothing to build
dockerfilestringunset—Compose looks for Dockerfile inside the context

Compose’s own build: key, for a service built from a local Dockerfile rather than pulled from a registry:

service vault-git-sync {
  build "./vault-git-sync"
  restart unless-stopped
}
services:
  vault-git-sync:
    build: ./vault-git-sync
    restart: unless-stopped

Name a dockerfile and hllc switches to Compose’s long form, since the short one has nowhere to put it. {{name}} resolves in both halves, the same as in image:

service app {
  build {
    context: "./{{name}}"
    dockerfile: "Dockerfile.prod"
  }
}
services:
  app:
    build:
      context: ./app
      dockerfile: Dockerfile.prod

image and build are independent—set either, or both. Compose reads the pair as build this context, then tag the result as that image.

build deliberately has no args. It’s a map, unlike the two plain strings here, and nothing has needed one yet. raw { build: { ... } } overrides the whole key for a service that does.

expose

Primary field: port—the one field this type has.

FieldAcceptsDefault
portnumberNo default—omitting expose entirely just means Compose gets no expose: entry
expose 8096

expose is Compose’s own expose: key—container-network visibility, reachable from other containers on the same network but never published to the host (for that, see publish). It has nothing to do with which hostname reaches the service.

Routing isn’t a built-in field

expose says the port is reachable inside the Compose network. It says nothing about which hostname reaches it, which is a reverse proxy’s question rather than Compose’s, and hllc no longer answers it: there is no router field, no traefik field, and no expose <port> as "<host>" sugar. Routing goes in labels, and the templates in std:traefik write those labels for you.

The load-balancer port label goes with it—a port template writes it when a router needs one. What survives here is expose itself, doing the one job Compose gives it.

publish

Map-kind. Bare-entry separator: ->, which points from the host port to the container port. hllc checks uniqueness on the container port, the value side, the same convention volume follows for its own host -> container mapping.

publish is Compose’s ports: key, which puts the port on the Docker host where the rest of the local network can reach it. That’s the opposite of expose, Compose’s expose: key, which reaches only other containers on the same network. A service behind a reverse proxy wants expose, plus the labels that route to it (see Routing). A service that takes traffic directly, such as Pi-hole on 53, Syncthing’s sync port, or a game server, wants publish. Setting both is fine and means both things.

service pihole {
  image "pihole/pihole:latest"
  publish 53 -> "53/tcp"
  publish 53 -> "53/udp"
  publish 8081 -> 80
  restart unless-stopped
}
services:
  pihole:
    image: pihole/pihole:latest
    restart: unless-stopped
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "8081:80"

Repeating publish accumulates entries rather than overwriting. A service with several published ports, such as Jellyfin’s 8096 and 8920 or Syncthing’s 8384 and 22000, gets one line each.

Write both sides exactly as you’d write them in Compose’s short syntax. hllc passes both through to the generated host:container string unchanged. A protocol suffix belongs on the container side, quoted so it lexes as one value: publish 53 -> "53/udp" yields "53:53/udp". Quoting the host side works the same way when you need to pin an interface, as in publish "127.0.0.1:8081" -> 80.

Checking uniqueness on the container side rather than the host one is deliberate. Docker itself conflicts on the host port, but the protocol suffix rides on the container half of the mapping, so a host-side check would reject the legal pair in the preceding example. The trade-off is the mirror image: hllc rejects one container port published on two different host ports, 8080 -> 80 and 8081 -> 80, as a duplicate. Reach for raw’s ports: when you genuinely need that.

There’s no single-value shorthand. publish 8096 is an error, not “8096 on both sides.” volume requires both sides of its mapping too, and both fields follow the same rule.

devices

Map-kind. Bare-entry separator: ->, which points from the host device path to the container device path. hllc checks uniqueness on the container path, the value side—the same convention publish follows for its own host -> container mapping, and for the same reason.

service cadvisor {
  image "gcr.io/cadvisor/cadvisor:latest"
  devices "/dev/kmsg" -> "/dev/kmsg"
  privileged
}
services:
  cadvisor:
    image: gcr.io/cadvisor/cadvisor:latest
    privileged: true
    devices:
      - /dev/kmsg:/dev/kmsg

devices is Compose’s own devices: key, exposing a host device inside the container—cadvisor’s classic use case, reading host /proc and control-group device metrics. It’s a plain generic Compose key like dns, not homelab-specific itself even though a real entry always is. hllc never validates or rewrites a device path—write it exactly as docker compose would expect it.

Repeating devices accumulates entries rather than overwriting, exactly like publish.

Write both sides exactly as you’d write them in Compose’s short syntax, HOST:CONTAINER[:CGROUP_PERMISSIONS]. hllc passes both through to the generated host:container string unchanged. An optional control-group permissions suffix belongs on the container side, quoted so it lexes as one value: devices "/dev/sda" -> "/dev/xvda:rwm" yields "/dev/sda:/dev/xvda:rwm".

Checking uniqueness on the container side rather than the host one is deliberate, and the reasoning transfers directly from publish—Docker’s real conflict is on the host side, but the optional permissions suffix rides on the container half of the mapping, so a host-side check would reject the legitimate case of one host device mapped to two container paths, each with its own permissions. The trade-off is the mirror image: hllc rejects one container path fed by two different host devices, "/dev/sda" -> "/dev/xvda" and "/dev/sdb" -> "/dev/xvda", as a duplicate. Reach for raw’s devices: when you genuinely need that.

There’s no single-value shorthand, matching publish/volume: devices "/dev/kmsg" is an error, not “the same path on both sides.”

volume

Map-kind. Bare-entry separator: ->, which points from the host path or volume name to the container path. hllc checks uniqueness on the container path, the value side—Docker itself refuses two mounts at the same container path but allows the same host path mounted more than once.

volume syncthing-config {}

service syncthing {
  image "lscr.io/linuxserver/syncthing:latest"
  volume "/mnt/media" -> "/data"       # bind mount
  volume syncthing-config -> "/config" # named volume
}

Repeating volume accumulates entries rather than overwriting.

The two entries in that example differ in one visible way, and it’s the only thing hllc goes by: quoting. A quoted host side is a path on the machine Compose runs on, whatever the path looks like. An unquoted one is an identifier naming a named Docker volume, exactly as an entry in a networks [x] list names a network. So volume "media" -> "/data" mounts a host path called media, while volume media -> "/data" mounts the volume declared as volume media { ... }.

Only the unquoted form takes an alias.name qualifier, since only a reference names something an .hll file declares. See Imports for importing a volume across files.

Every named volume needs a matching top-level volume declaration—in the file that mounts it, or in a file it imports—exactly as a networks [x] entry needs a matching top-level network declaration. Reference one you never declared and hllc reports a compile error:

syncthing.hll:6:10: service `syncthing` references undeclared volume `snycthing-config`

That error catches a typo or an accidental collision. Before hllc asked for the declaration, snycthing-config quietly became a second, empty volume, and two services that happened to write the same string looked exactly like two services deliberately sharing one. Now each file states the sharing outright, with both services naming the one declaration—and a misspelling has nothing to resolve to:

volume shared-media {}

service jellyfin {
  image "jellyfin/jellyfin:latest"
  volume shared-media -> "/data"
}

service sonarr {
  image "lscr.io/linuxserver/sonarr:latest"
  volume shared-media -> "/media"
}

Bind mounts need no declaration at all. They name a host path rather than something Docker manages, and Docker itself asks for no pre-declaration either. hllc passes a quoted host side through to Compose as written, so ./jellyfin, ../shared, and /mnt/media all behave the way Compose’s own short syntax says they do.

hllc gives every referenced named volume an entry in the Compose document’s top-level volumes: section, carrying whatever external, name, driver, and driver_opts its declaration set. A volume you declare but never mount produces no entry, exactly as a network declaration no service names produces none—though only the network case raises a warning today.

Either kind of entry may add a trailing { read_only } body, appending Compose short syntax’s :ro mode suffix:

volume "/" -> "/rootfs" { read_only }
volume media -> "/data" { read_only }

produces:

volumes:
  - /:/rootfs:ro
  - media:/data:ro

read_only is bare presence only, matching network’s own external flag—there’s no read_only: true/read_only: false form, and no way to write :rw explicitly, since that’s already Compose’s own default for an entry with no mode suffix at all. It works the same way whether the host side is a bind-mount path or a named-volume reference, and inside volume’s canonical multi-entry body a flagged entry and an unflagged one can sit side by side:

volume {
  "/" -> "/rootfs" { read_only }
  "/data" -> "/data"
}

hll covers only :ro today, not Compose’s other short-syntax mount options (:z, :Z, tmpfs sizing)—see the design doc’s volume section for why this design picked a bare flag over a general mode string.

env

Map-kind. Bare-entry separator: =, key equals value. hllc checks uniqueness on the key—two env entries can’t set the same variable.

env PUID = "1000"
env PGID = "100"

Repeating env accumulates entries.

labels

Map-kind. Bare-entry separator: :, so the short form and the canonical form are the same thing. hllc checks uniqueness on the key, like env.

labels holds the service’s Docker labels. Entries arrive from every template the service applies as well as from its own body, and hllc adds them rather than letting one set replace another, which is what lets templates carry a service’s routing and still leaves room for a label of your own:

use "std:traefik" as traefik

network traefik-net {
  external
  name: "docker_default"
}

service web {
  image "nginx"
  networks [traefik-net]

  with
    traefik.http { host: "web.example.com", port: 8123 },
    traefik.http_entrypoints { router: "{{name}}", entrypoints: ["web-secure"] }

  labels {
    "traefik.http.routers.web.tls.domains[0].main": "internal.example.com"
    "com.example.owner": "platform-team"
  }
}
labels:
- traefik.http.routers.web.rule=Host(`web.example.com`)
- traefik.http.services.web.loadbalancer.server.port=8123
- traefik.http.routers.web.entrypoints=web-secure
- traefik.http.routers.web.tls.domains[0].main=internal.example.com
- com.example.owner=platform-team

Entries land in tier order: each with target left to right, then the service’s own body last. Nothing a template wrote moves to make room, so a service that applies no template emits exactly what it wrote.

This is where routing labels go, either written by hand or by the templates in std:traefik, and where a label no template covers goes—the standard example is a per-router list of TLS Subject Alternative Name (SAN) domains. It’s also what raw { labels: ... } can’t do: raw replaces the whole list, so one extra line costs you every other label unless you retype them all.

Quoting

Traefik’s label keys carry dots and brackets, and neither can appear in a bare word, so a key like traefik.http.routers.web.tls.domains[0].main needs quotes. That’s the one ergonomic cost of the map shape, and it buys the duplicate-key check below—which a list of "key=value" strings, closer though it reads to Traefik’s own documentation, has no way to perform.

Docker reads a label as key=value, splitting at the first =, so a key containing one would name a different label than the one written. hllc rejects that rather than emitting it.

A value goes through as written

hllc checks the key and leaves the value alone. That’s deliberate. A Traefik rule is mostly backticks, parentheses and ||, so a guard strict enough to be worth having would reject the labels this field exists to write:

service web {
  image "nginx"
  labels {
    "traefik.http.routers.web.rule": "Host(`web.example.com`) || Host(`www.example.com`)"
  }
}
labels:
- traefik.http.routers.web.rule=Host(`web.example.com`) || Host(`www.example.com`)

The cost is that nothing checks what the value means. hllc once parsed rule syntax and could see that a stray backtick closed a Host( call early, so it refused a host containing one:

4:11: `router.host` must not contain '`' — it would change the meaning of the generated Traefik label

That check went with the grammar. Routing is a string now, so the same value compiles and reaches Traefik intact—as a rule matching every host rather than one:

labels {
  "traefik.http.routers.web.rule": "Host(`ok.example.com`) || HostRegexp(`{any:.+}`)"
}

So a value you assemble from somewhere else—a template parameter, most of all—is yours to vet. The check hllc drops here applies at compile time to text sitting in your own .hll source, which is what makes leaving it out defensible: a bad value breaks your own homelab rather than opening it to a stranger.

A key written twice is an error

Two entries claiming one key is a compile error naming both, exactly as for env:

labels {
  "com.example.owner": "platform-team"
  "com.example.owner": "someone-else"
}
5:5: duplicate `labels` entry: key "com.example.owner" already set at 4:5

A key two entries resolve to is an error

The preceding check compares keys as written, which isn’t the same as comparing the keys that reach Compose: {{name}} resolves later, so two entries spelled differently in source can still land on one key. hllc catches that too, and the message names both sides:

service web {
  image "nginx"
  expose 8123
  labels {
    "traefik.http.routers.{{name}}.rule": "Host(`web.example.com`)"
    "traefik.http.routers.web.rule": "Host(`elsewhere.example.com`)"
  }
}
6:5: label "traefik.http.routers.web.rule" is already set by this service's own `labels` at 5:5 — two entries spelled differently can still resolve to one key once `{{name}}` is substituted, so one of them would silently do nothing

Neither spelling wins, deliberately. Whichever one lost would be a line you wrote that quietly does nothing. Refusing is the only outcome where every line either takes effect or gets a diagnostic.

Merging across templates

A single-valued entry merges exactly the way env does. A template’s entries reach the service, the service’s own body wins over a template that set the same key, and two with-listed templates setting one key is a MapKeyCollision—see Templates & Composition.

{{name}} resolves in both halves of an entry, so "com.example.{{name}}.owner": "{{name}}-team" works the way it does under env.

A list value composes instead of colliding

Write a bracketed list and the entry means something different when two places set it: the values join rather than conflict.

template internal_web(port) {
  expose $port
  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"
  with internal_web { port: 8384 }, authenticated
}
labels:
- traefik.http.routers.syncthing.middlewares=local-ipwhitelist@file,forwardAuth-authentik@file

The list renders comma-joined, the same separator a list argument interpolates with. Entries dedupe, so naming one twice across two templates gets you one. Your service body adds to what its templates supplied rather than replacing it.

That difference between the two shapes is the point of having both. A single value says the key holds one thing, so two templates setting it are two answers to one question and the collision is right. A list says the key holds several, so several places contributing is the whole idea. Writing one key as a list in one place and a single value in another is an error—the two disagree about which kind of thing the key holds:

10:12: `labels` key "com.example.tags" is a single value here but a list at 6:12 — a list composes across templates and a single value doesn't, so the two say different things about what this key holds

restart

Primary field: policy.

FieldAcceptsDefault
policybare word or stringunset, matching Compose’s own default of no automatic restart
restart unless-stopped

Writing image or restart more than once in the same body is a compile error, since both are scalar fields, not repeatable—unlike volume/publish/env/middleware/depends_on.

healthcheck

No primary field—unlike image’s ref or expose’s own port, no one sub-field stands in for the whole healthcheck, so healthcheck { ... } requires the braced body. healthcheck "..." doesn’t parse.

FieldAcceptsDefault
teststring or bracketed listunset—no healthcheck defined here, though the image’s own still applies if it has one
intervalstringunset, matching Compose’s own default
timeoutstringunset, matching Compose’s own default
retriesnumberunset, matching Compose’s own default
start_periodstringunset, matching Compose’s own default
start_intervalstringunset, matching Compose’s own default
disablebare flag, no valueunset, false
healthcheck {
  test: "pg_isready -U miniflux"
  interval: "10s"
  timeout: "5s"
  retries: 3
  start_period: "30s"
  start_interval: "5s"
}

test accepts either a bare string—Compose’s shell form, run through the container’s own shell (a bare string is shorthand for CMD-SHELL <string>)—or a bracketed list—Compose’s exec form, run directly with no shell involved. hllc carries whichever form you write straight through to the generated test: key, rather than normalizing one into the other:

service miniflux-db {
  image "postgres:15"
  healthcheck {
    test: ["CMD", "pg_isready", "-U", "miniflux"]
    interval: "10s"
    start_period: "30s"
  }
}
services:
  miniflux-db:
    image: postgres:15
    healthcheck:
      test:
        - CMD
        - pg_isready
        - -U
        - miniflux
      interval: 10s
      start_period: 30s

hllc carries interval/timeout/start_period/start_interval/ retries through exactly as written—it doesn’t parse or validate Compose’s duration syntax ("10s", "1m30s") or check that retries is a sane, non-negative count. A mistake there is docker compose config’s to catch, not hllc’s.

disable sets Compose’s own disable: true, which turns the healthcheck off entirely—including one the image itself defines:

healthcheck {
  disable
}

Writing healthcheck more than once in the same body is a compile error, same as image/restart/expose—it’s a struct-kind field, not repeatable.

depends_on, networks, dns, env_file

All four are plain list fields directly on service/template, not nested struct types, so there’s no primary-field shorthand to learn for them. Write a bare identifier or string, a bracketed list, or repeat the field:

depends_on database
depends_on cache                   # repeating accumulates
depends_on [database { condition: service_healthy }]

networks [traefik-net]

dns ["192.168.50.182"]

env_file "miniflux.env"
env_file ["miniflux.env", "common.env"]

A middleware list looks like it belongs to this group but isn’t a field at all: a middleware reaches Traefik as a label on one specific router, so it’s written in labels—see Routing.

  • depends_on names a same-file sibling service this one depends on—it’s not cross-file, and doesn’t accept a qualified alias.name. Each entry may optionally add a { condition: ... } body naming one of Compose’s own three readiness conditions—service_started (the default: wait only for the target container to start, which is all a bare depends_on database has ever meant), service_healthy (wait for the target’s healthcheck to report healthy), or service_completed_successfully (wait for the target to exit zero—typically a one-shot init/migration container). Anything else is a compile error naming all three. A bare entry and a conditioned one can sit side by side in the same list:

    depends_on [cache, database { condition: service_healthy }]
    

    Compose has two mutually exclusive shapes for depends_on: and never mixes them in one document: a plain list of names, or a mapping of name to { condition: ... }. hllc emits the plain list as long as no entry in the field carries a condition, and switches the whole field to the mapping form once any entry does. A sibling entry with no explicit condition is then filled in with service_started, since the mapping form requires every entry to name one.

    service_healthy is only meaningful when the target service actually has a healthcheck to become healthy against—but hllc doesn’t warn when the target’s .hll body has no healthcheck field, because that’s not evidence the condition is meaningless: a Docker image can bake its own HEALTHCHECK into its Dockerfile, invisible to anything an .hll file declares.

  • networks references a top-level network declared in the same program—see the preceding section. If exactly one referenced network is external, its real name also drives the traefik.docker.network= label, but more than one external network on the same service is a compile error, since it’s ambiguous which network Traefik should target. hllc builds the generated networks: section from these references, so a network no service names never reaches the output. That one is a warning on stderr rather than an error—see Warnings.

    default is the one network name every program gets for free, with or without a matching declaration: networks [default] compiles even when nothing in the file declares network default { ... }, resolving to the same implicit default network docker compose itself creates for a project. hllc adds nothing to the top-level networks: section for it in that case—Compose already knows about default, so there’s nothing for hllc to declare.

    Two or more service declarations in one file are, by construction, one Compose stack meant to talk to each other, so every service in such a file is implicitly attached to default in addition to whatever it names explicitly—no networks [default] required. A single-service file gets no such auto-attachment. Compose’s own implicit default network already covers a lone service for free, so there’s nothing for hllc to add. Auto-attachment is idempotent—a service that writes networks [default] itself still ends up with one default entry, not two—and, when explicit, always sorts last in that service’s networks: list.

    Attaching every service unconditionally is where hllc parts company with docker compose itself, which hands a service the default network only when that service lists no networks of its own. The difference is deliberate: default already carries the traffic between the services in one file, so a stack can lean on it and declare no private network for that job—one declaration fewer than the hand-written Compose it replaces, rather than a shortfall next to it.

    An explicit network default { ... } declaration still wins: its external/name settings apply exactly as they would to any other named network, including feeding the traefik.docker.network= label when it’s external, and it still emits its own top-level networks: entry. The implicit, undeclared default is only a fallback for when no such declaration exists.

  • dns sets Compose’s own per-service dns: key—a resolver override. Use it, for example, when a network has a local name server.

  • env_file sets Compose’s own env_file: key—one or more paths to load environment variables from. It’s a plain generic Compose key like dns, not homelab-specific itself, even though most real entries point at a gitignored, per-homelab .env file. The generated env_file: value is always a list: a single env_file "one.env" still emits a one-element env_file: list, so the generated shape doesn’t depend on how many paths you wrote. Compose itself resolves each path relative to the Compose file, not hllc—write it exactly as docker compose would expect it. When two files set the same variable, Compose lets the later file win, so order matters here the same way it matters for dns’s resolver priority. Reach for env instead when a value belongs directly in the .hll file rather than in an external file.

All five accumulate across repeated writes within one body. Across template composition (see Templates & Composition), middleware/networks/ dns/env_file also just accumulate—there’s no collision to check since list fields can only ever grow. depends_on merges keyed on the service name instead, and the service’s own body always wins over a template’s entry for the same dependency—but two with-listed templates naming the same service is not automatically a compile error: as the preceding discussion of depends_on covers, it’s only one when their conditions actually disagree, exactly like two templates setting the same env key to two different values would collide. Two templates that both say depends_on [database]—or that spell out the same condition on both—are giving the same answer twice, not two different ones, so they still collapse to a single entry exactly as they always have.

privileged

Another plain generic Compose key, directly on service/template:

privileged
FieldAcceptsDefault
privilegedbare flag, no valueunset, false

privileged gives the container extended host privileges—Compose’s own privileged: key. Bare-presence only, matching network’s own external field: there’s no privileged: false form to write, since absence already means false.

cadvisor is the service that motivated this field and devices together: it needs privileged and a devices mount to read host /proc/cgroups, previously written through raw before these two fields existed.

container_name

A plain scalar field directly on service/template, not a nested struct type:

container_name "uptime-kuma"
AcceptsDefault
stringnot set—Compose’s own per-project name applies

Only emitted when set explicitly. Compose’s own default container naming, scoped per project, is what most people want. An explicit container_name forces one specific name everywhere it’s deployed, so it’s an opt-in override you use for a stable hostname or an external reference, not something every service should get by default. Defaulting it to the service’s own name reliably collides across independent stacks that happen to share a service name (db, broker, and so on), and Compose refuses to start the second container with the same name.

command

A plain scalar-or-list field directly on service/template, not a nested struct type, sharing its grammar with healthcheck’s test sub-field—a bare string, Compose’s shell form, or a bracketed list, Compose’s exec form:

command "npm start"
command ["--housekeeping_interval=30s", "--docker_only=true"]
AcceptsDefault
string or bracketed listunset—the image’s own CMD/entrypoint applies

command overrides the arguments Compose passes to the image’s entrypoint, exactly like Compose’s own command: key. hllc carries whichever form you write straight through to the generated command: key, rather than normalizing one into the other—the same rule healthcheck’s test follows, and for the same reason: the shell form runs through the container’s own shell, while the exec form runs directly with no shell involved, so the two aren’t interchangeable. A comma inside one quoted list item is data, not a list separator:

service cadvisor {
  image "gcr.io/cadvisor/cadvisor:latest"
  command [
    "--housekeeping_interval=30s",
    "--docker_only=true",
    "--enable_metrics=cpu,memory,network"
  ]
}
services:
  cadvisor:
    image: gcr.io/cadvisor/cadvisor:latest
    command:
      - --housekeeping_interval=30s
      - --docker_only=true
      - --enable_metrics=cpu,memory,network

Writing command more than once in the same body is a compile error, same as image/restart/container_name—it’s single-occurrence, not repeatable.

entrypoint

A plain scalar-or-list field directly on service/template, sharing its grammar with the preceding command—a bare string, Compose’s shell form, or a bracketed list, Compose’s exec form:

entrypoint "/bin/sh -c 'do-a-thing'"
entrypoint ["/bin/sh", "-c", "do-a-thing"]
AcceptsDefault
string or bracketed listunset—the image’s own ENTRYPOINT applies

entrypoint overrides the image’s ENTRYPOINT, exactly like Compose’s own entrypoint: key. hllc carries whichever form you write straight through to the generated entrypoint: key, rather than normalizing one into the other—the same rule command follows, and for the same reason: the shell form runs through the container’s own shell, while the exec form runs directly with no shell involved.

entrypoint and command are two different keys

They’re separate Compose keys and they override separate halves of what the image declares. entrypoint replaces the image’s ENTRYPOINT, the program the container runs. command replaces its CMD, the arguments that program gets. Docker joins them: the container runs the entrypoint with the command appended. Set either one, both, or neither—setting one says nothing about the other:

service backup {
  image "alpine:3"
  entrypoint ["/usr/local/bin/backup.sh"]
  command "--target=/data"
}
services:
  backup:
    image: alpine:3
    entrypoint:
      - /usr/local/bin/backup.sh
    command: --target=/data

Writing entrypoint more than once in the same body is a compile error, same as command—it’s single-occurrence, not repeatable. And a service that overrides entrypoint from a with-listed template replaces the inherited value outright rather than appending to it, since the value is one whole argument vector.

raw

Map-kind, schema-free: hllc accepts unknown keys as-is rather than checking them against a fixed field list, and their values pass straight through to the generated YAML. Its job is the long tail the language doesn’t model with a field of its own: real Compose keys that come up rarely enough, or are specific enough to one deployment, that a dedicated field isn’t worth it. cadvisor’s security_opt is one:

raw {
  security_opt: ["seccomp=unconfined"]
}

Each raw entry becomes a sibling top-level key on the generated Compose service block (security_opt: [...]), exactly as written—there’s no validation, so docker compose itself is the first thing to reject a misspelled key or a value Compose doesn’t understand.

A raw value’s lists and maps may nest up to 128 levels deep. Past that, hllc reports an error rather than following the nesting further. Real Compose structures nest a handful of levels, so this only ever comes up for generated or pathological input.

hllc checks uniqueness on the key, the same convention env uses: two explicit with-listed templates setting the same raw key is a compile error, not a silent override.

A key repeated within one body is a compile error too—one raw { } block, or two in the same service, since two blocks accumulate into one map:

raw { user: "1000", user: "2000" }
3:23: duplicate `raw` entry: key "user" already set at 3:9

That’s the same diagnostic a repeated env key raises, and it names both occurrences so you can see which value you were about to lose.

What counts as a duplicate

A duplicate key is a key repeated inside one mapping, which is what it means in YAML too. raw values nest, so a raw body is a tree of mappings rather than one flat list of keys, and hllc checks each mapping on its own:

raw {
  logging: { driver: "json-file" }
  "x-backup": { driver: "restic" }
}

Both nested maps hold a driver, and that’s fine—they’re two separate mappings, so neither one repeats anything. A nested map may likewise reuse a key its enclosing mapping already uses. Only writing one key twice in the same { } is an error.

raw wins over a built-in field of the same name

A raw key may name a field hll already has: image, container_name, command, entrypoint, privileged, restart, healthcheck, environment, env_file, volumes, networks, dns, devices, ports, expose, depends_on, or labels. When it does, the raw value is what’s emitted, and hllc drops the built-in one—the key appears exactly once:

image "nginx"
raw {
  image: "nginx:1.27-alpine"   # this is the image that's emitted
}

This is what makes raw a durable escape hatch, and it isn’t hypothetical. Files that wrote raw { ports: [...] } before publish existed still compile to exactly the same output now that it does, so gaining a built-in field is never a breaking change for files that were working around its absence. The same holds for whichever Compose key gets a field next, so reaching for raw today costs nothing later.

Note that raw’s value replaces the built-in one. It never merges with it.

labels is an aggregate, and replacing it costs more than it looks

labels deserves its own warning label, because the key hllc emits isn’t one field from an author’s point of view. Its entries arrive from every template the service applies as well as from its own labels block, and since templates carry a service’s routing (std:traefik), that usually means all of the service’s routing is in there.

So raw { labels: [...] } doesn’t replace one block. It replaces everything every contributor produced, all at once:

raw {
  labels: ["only.this=1"]   # every other label is dropped
}

The routing rule, the entry points, the load-balancer port—every entry any template or block wrote—vanishes from that service, leaving only.this=1 as the entire labels: list hllc emits. Overriding labels therefore means writing every one of those lines by hand, and keeping them in step with the .hll file from then on.

hllc says so rather than letting it happen quietly. A service that has labels and names labels in its raw block gets a warning:

8:5: warning: `raw { labels: ... }` replaces service `web`'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

It’s a warning, not an error. Hand-writing the whole label list is a legitimate thing to do—it’s exactly what raw is for when a label needs a shape labels can’t yet write—so the build still succeeds and the generated document stays exactly as it was.

For adding a label rather than replacing every one, reach for the labels field instead. It adds to the set, checks for duplicate keys, and refuses two entries that resolve to one key instead of quietly dropping one. raw { labels: ... } stays the escape hatch for the case where you really do want to write the entire list by hand—and it overrides a labels field too, since it replaces the emitted key rather than any one contributor to it.

A service with no labels at all—none of its own and none from a template—has nothing for the raw list to replace, and says nothing.

Overriding a service’s volumes: or networks: key doesn’t retract the top-level volumes:/networks: declarations that volume and networks produced—those stay, so a raw replacement naming the same named volume or network still resolves.

with

Not really a “field” you set directly so much as the mechanism for pulling a template’s fields onto a service—see Templates & Composition for with in full.

Templates & composition

A template is a named, reusable partial service—a block of fields that gets merged onto a real service via with, rather than a service in its own right. This is hll’s answer to the copy-paste every homelab accumulates: the shared Traefik network, the forward-auth middleware, the PUID/PGID pair every LinuxServer.io image wants, all written once and pulled in wherever they’re needed.

Declaring a template

A template accepts exactly the same fields as a service body (see Built-in Fields):

template internal_web(port) {
  networks [traefik-net]
  restart unless-stopped
  expose $port
  labels {
    "traefik.http.routers.{{name}}.rule": "Host(`{{name}}.internal.example.com`)"
    "traefik.http.routers.{{name}}.entrypoints": "web-secure"
    "traefik.http.routers.{{name}}.middlewares": ["local-ipwhitelist@file"]
  }
}
  • (port) declares the template’s parameter list—just bare names, with no type annotation to write. Composition checks a substituted argument against the field it lands in instead: $port in the preceding example reaches expose.port, one of the fields Built-in Fields documents as taking a number, so with internal_web { port: "8384" } is a compile error even though nothing here declared port: Number. A reference-shaped field like networks rejects a bare number the same way—see Parameterizing references below—while every other field takes whatever literal kind its argument happens to be.
  • $port inside the body refers to that declared parameter—the $ sigil serves exactly this purpose, and works only inside a template’s own body. It fills a whole value. To put a parameter inside a string, see Interpolating a parameter further down this page.
  • {{name}} interpolates the calling service’s own name at compile time—see Syntax Basics.

A template with no parameters just omits the parameter list:

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

Both templates write the same labels key, and because each writes a list the two concatenate in tier order rather than colliding—see Merge order and collisions below.

Applying a template with with

with merges one or more templates onto a service:

volume syncthing-config {}

service syncthing {
  with internal_web { port: 8384 }, authenticated
  image "lscr.io/linuxserver/syncthing:latest"
  volume syncthing-config -> "/config"
}

Each item in a with list is a template name, followed by a { arg: value, ... } argument body if the template takes parameters (a zero-parameter template like authenticated needs no body—bare authenticated is enough). A template must always be fully applied at each call—you can’t partially apply it or curry it.

A template’s own body can itself with other templates, so templates can layer on each other—up to 64 levels of nesting, past which hllc reports an error instead of following the chain further. That’s a bound on with depth, not on how many templates a single with list may name.

A template may also forward its own parameters into the templates it applies:

template linuxserver_app(puid, pgid) {
  env PUID = $puid
  env PGID = $pgid
}

template linuxserver_web(puid, pgid, port) {
  with linuxserver_app { puid: $puid, pgid: $pgid }
  expose $port
  labels {
    "traefik.http.routers.{{name}}.entrypoints": "web-secure"
  }
}

Parameterizing references

$param isn’t limited to plain values like the preceding $port—you can write it anywhere you write a reference too, so a template can parameterize which network it attaches to, which middleware it names, or which entry point it routes through, not just the values on its other fields:

network proxy {
  name: "real-proxy"
}

template attached_to(net) {
  networks [$net]
}

service app {
  image "nginx:alpine"
  with attached_to { net: "proxy" }
}

Composition checks the substituted argument against networks’ own grammar before it goes anywhere near name resolution: with attached_to { net: 1000 } is a compile error, since a bare number can never appear in a reference-shaped position even written directly. Past that, the argument still has to name something real: with attached_to { net: "ghost" } fails with the same UnknownNetwork error networks [ghost] written directly would, since resolving a network by name happens after composition binds the parameter, not before.

Interpolating a parameter into a string

$param fills a whole value. When what you need is a parameter in the middle of one—a hostname inside a routing rule, a name inside a dotted label key—write {{param}} instead, the same interpolation form {{name}} uses:

template traefik_http(host, port) {
  labels {
    "traefik.http.routers.{{name}}.rule": "Host(`{{host}}`)"
    "traefik.http.services.{{name}}.loadbalancer.server.port": $port
  }
}

service jellyfin {
  image "jellyfin/jellyfin"
  with traefik_http { host: "media.example.com", port: 8096 }
}
labels:
- traefik.http.routers.jellyfin.rule=Host(`media.example.com`)
- traefik.http.services.jellyfin.loadbalancer.server.port=8096

Both bindings appear in that template and they resolve at different times, which is worth knowing when one of them goes wrong:

  • {{param}} resolves as the template merges onto a service, against that invocation’s arguments.
  • {{name}} resolves later, against the service the result lands on. A template may declare a parameter named name, but {{name}} goes on meaning the service and hllc says so. Reach that parameter as $name instead.

A binding naming neither raises unknown interpolation {{hsot}}, so a typo stops the build rather than reaching the output.

An argument can be anything with a text form—a string, a number, a bare identifier, or another parameter forwarded from the enclosing template. A list or a nested map has no text to splice into a string, so passing one to a {{param}} fails to compile, even though the same argument still fills a whole slot that accepts a list.

$param inside a string does nothing

The $ sigil never reaches inside string content:

template traefik_http(host) {
  labels {
    # Wrong: emits the five characters `$host`, not the argument.
    "traefik.http.routers.{{name}}.rule": "Host(`$host`)"
  }
}

That compiles, and writes rule=Host(`$host`) into the generated file—a router matching a host literally named $host, which nothing ever requests. hllc warns when a string inside a template holds a $ naming one of that template’s own parameters. The fix is the preceding {{host}} spelling.

The warning stays deliberately narrow. A $ in any other string is ordinary content: command and env values carry $HOME through to a shell, and Compose reads its own ${VAR} interpolation out of the generated file once hllc has written it. The warning skips both.

Passing a list

A list argument does one of two things, depending on where the parameter sits.

Interpolated into a string, it joins with commas. That’s the shape of a Docker label holding several entries:

template middlewares(router, chain) {
  labels {
    "traefik.http.routers.{{router}}.middlewares": "{{chain}}"
  }
}

service web {
  image "nginx"
  with middlewares {
    router: "{{name}}",
    chain: ["auth@file", "compress@file"]
  }
}
labels:
- traefik.http.routers.web.middlewares=auth@file,compress@file

Any item with a text form counts—a quoted string, a number, a bare identifier, a forwarded parameter, a decl.name field access—so [1, "two", three] renders 1,two,three. The comma is the only separator there is. A template that needs a different one takes the joined string as an ordinary parameter instead.

In a list-shaped field, it splices. The items land where the parameter stood:

network a { }
network b { }
network c { }

template attach(nets) {
  networks [a, $nets, c]
}

service web {
  image "nginx"
  with attach { nets: [b] }
}
networks:
- a
- b
- c

networks $nets and networks [$nets] mean the same thing as each other, since a bare list field and a one-element bracket list parse alike. The same goes for the other list fields alongside it—dns, env_file and depends_on. A depends_on entry carries a condition as well as a name, and every item spliced through that entry takes it:

service db { image "postgres" }
service cache { image "redis" }

template waits_for(deps) {
  depends_on [$deps { condition: service_healthy }]
}

service web {
  image "nginx"
  with waits_for { deps: [db, cache] }
}
depends_on:
  db:
    condition: service_healthy
  cache:
    condition: service_healthy

An empty list means what it says in both places: no characters when joined, no elements when spliced.

Two things a list can’t do. It can’t nest—[a, [b]] is an error rather than a flattening, because [a, b] already spells the flat list and one source shouldn’t have two spellings. And it can’t fill a slot that holds a single value, so container_name $xs is an error however many items xs holds:

container_name $xs
2:18: argument `xs` for template `t` must be a scalar value (a list/map can't fill a single-value field)

An interpolated value lands as written

{{host}} puts the argument into the string as it stands. Nothing inspects it on the way, so a template that renders a Traefik rule from a parameter trusts whoever calls it:

template traefik_http(host) {
  labels {
    "traefik.http.routers.{{name}}.rule": "Host(`{{host}}`)"
  }
}

Pass ok.example.com`) || HostRegexp(`{any:.+} as host and the rule matches every host instead of one. hllc can’t catch that, because it no longer knows the label holds a rule—see a value goes through as written. A template that splices a parameter into a value with a syntax of its own owns that syntax.

Reading a declaration’s real name

A network or volume answers to two names: the identifier your .hll files refer to it by, and the real Docker name—the name: override when the declaration sets one, the identifier otherwise. A label that has to name a real Docker network needs the second. Write .name after the declaration to read it:

network proxy {
  external
  name: "docker_default"
}

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

service jellyfin {
  image "jellyfin/jellyfin"
  with caddy { net: proxy, port: 8096 }
}
networks:
- proxy
expose:
- 8096
labels:
- caddy.network=docker_default
- caddy.upstream=jellyfin:8096

One parameter serves both positions, and each takes what it needs: networks [$net] attaches the network, so it wants the identifier, while the label hands the network’s name to the proxy that reads it, so it wants docker_default. Passing the identifier to both is the trap this replaces—it compiles, and the label reads caddy.network=proxy, which matches nothing. Drop the name: override and the two spellings agree, so the mistake survives a small test and breaks on exactly the files that need it: an external network another Compose project created almost always carries a name:.

Three spellings read the same field:

written in a valuereads
proxy.namea network/volume this program declares
net.proxy.nameone an imported file declares
$net.namewhichever declaration the invocation binds net to

The last row is what makes the parameter worth having, and it takes an imported declaration as readily as a same-file one: with caddy { net: shared.proxy } binds the declaration shared.hll declares, and $net.name reads docker_default off it just the same. See Passing an imported declaration to a template.

A field is readable when it holds a value. name holds one on both kinds, and a volume’s driver holds one too:

written in a valuereads
media.namethe volume’s real Docker name
media.driverthe driver it names, when it sets one

The fields that hold nothing say so rather than pretending not to exist. external is a bare-presence flag and driver_opts is a map, so neither fills a value, and hllc names the field and why. Ask for a field the kind hasn’t got at all and it lists the ones it has. Ask for one the declaration leaves unset—a volume with no driver—and it refuses rather than handing back an empty string, since Docker picks the default and no text spells the default it picks.

The access also goes inside string content, as a dotted binding alongside the {{param}} form:

labels { "caddy.upstream": "http://{{net.name}}:8096" }

Two rules keep this unambiguous, and both are worth knowing before you hit them:

  • Value positions only. networks [...], dns, env_file, depends_on, and a named-volume mount’s host side all name a declaration, and a . there already qualifies that name by an import alias. Writing networks [$net.name] is an error that says so.
  • At most three parts. Two name a declaration and a field, three name an alias, a declaration and a field, and $param takes exactly one field. A fourth part has no reading left.

A with-invocation’s argument is the one value position where two parts may instead name an imported declaration, since a parameter is the one value that can go on to be a reference. A base naming one of this program’s own declarations still reads as a field access there, so nothing you already write changes meaning. Only a base naming nothing local takes the import-alias reading.

A worked set of templates: routing

Routing is the biggest thing templates do in a real homelab, and it’s entirely templates—hllc bundles a module of them, std:traefik, and has no routing built in at all. It’s a good read once this page makes sense, because it exercises every feature here at once: parameters, interpolation into a string, list arguments, reading a declaration’s name, and list-valued labels composing across tiers.

Routing covers it.

Every template needs a with

A template reaches a service only through that service’s own with. No template name is special to the compiler, defaults included:

template defaults {
  restart unless-stopped
}

service jellyfin {
  with defaults
  image "jellyfin/jellyfin:latest"
  expose 8096
}

hllc used to apply a template named exactly defaults to every service in its file, with no with needed. That’s gone. It only ever worked within one file—having no invocation left no alias for a cross-file lookup to go through—so the case it looked like it saved you from, sharing one baseline across your whole homelab, was the one case it couldn’t serve. Writing with defaults costs a line per service and works everywhere, imported files included.

A template defaults that no service applies is a warning rather than a silent no-op, since a file written against the old behavior would otherwise stop picking those fields up without saying so.

Merge order and collisions

When a service ends up with fields from more than one source—its own body and one or more with-listed templates—they merge in a fixed priority order, lowest to highest:

  1. with-listed templates, left to right
  2. the service’s own body—always wins over everything

A collision between two with-listed templates on the same scalar or map field is a compile error—if two templates you listed both try to set image, or both set the same env key, hllc won’t guess which one you meant. Note that setting the field in the service’s own body does not break the tie: the template tier merges to completion before hllc applies the body, so it reports the collision first, and the body never gets a chance to win. The two real remedies are to drop one of the templates from the with list, or to refactor the contested field out of one of them. The service’s own body is exempt from this check, because it always silently wins over whatever survives the template tier.

Different field kinds merge differently:

  • List fields (middleware, networks, dns, env_file) concatenate—no collision is possible, since there’s nothing to overwrite. All but dns and env_file concatenate by distinct name: naming the same network in a template and again in the service’s own body means what naming it once means, so hllc drops the repeat rather than emitting it twice. The first occurrence is the one kept, so the surviving order is still each with target left to right, then the body’s own entries. dns and env_file are the exception and keep every entry, duplicates included, because their order is observable—resolver priority for dns, Compose’s own last-file-wins precedence for env_file.

  • depends_on looks like a list field—depends_on [db]—but merges like the map fields just below it, keyed on the referenced service’s own name, so the service’s own body always wins over a template’s entry for the same dependency. Unlike the true map fields, though, naming the same service twice isn’t automatically a collision: two entries agree when their conditions match—including a bare entry and an explicit condition: service_started, which mean the same thing to Compose—and two templates that agree are giving the same answer twice, not two different ones, so they still collapse to a single entry exactly as a plain depends_on [db] always has. Only when two explicit templates’ conditions genuinely differ is it the same MapKeyCollision compile error two templates setting the same env key to two different values would raise.

  • Map fields (volume, env, labels, raw) merge key-by-key (or value-by-value for volume, since its uniqueness check is on the container-path side)—a genuine collision on the same key, regardless of whether the two values happen to agree, is the preceding compile error case. The preceding entry’s depends_on keys like a map field too, but its collision check also looks at the value: two entries that agree aren’t a real collision the way two env entries sharing a key always are, whatever those two entries’ values happen to be.

  • Scalar fields (image, restart, expose’s port) error on collision among explicit templates only, per the preceding rule.

  • healthcheck is the built-in struct field with more than one sub-field, and it merges per sub-field independently rather than as one indivisible unit—the same key-by-key reasoning as a map field, applied to a struct’s named fields instead of a map’s keys. Each sub-field then follows its own kind’s rule: every sub-field but test is a scalar and collides like expose.port does, and test collides the same way even though its value isn’t a plain string or number—see below.

    healthcheck.test and healthcheck.disable collide the same way a scalar sub-field does, even though neither is a plain Literal: test carries Compose’s own shell-string-or-exec-list shape, and disable is a bare-presence flag whose only “value” is that it’s present at all. Two explicit templates each setting test (or each setting disable) still collide, exactly as two explicit templates each setting expose.port do.

  • command merges the same way healthcheck.test does, not the way container_name does: its shell-string-or-exec-list shape isn’t a plain Literal either, so it collides between two explicit templates by the same rule rather than riding the plain-scalar machinery image/restart/container_name use. Unlike healthcheck.test, command sits directly on the service body rather than inside a struct field of its own, so there’s no sub-field independence to it—setting command at all is the whole collision, the same as setting container_name is.

  • entrypoint merges exactly the way command does, and for the same reasons—a service’s own value replaces an inherited one, and two explicit templates that each set it collide. The two are separate Compose keys, though, so they don’t collide with each other: a template that sets entrypoint and a template that sets command merge cleanly, and the service gets both.

  • labels merges key by key like any map field, but the shape of a value decides what a second contributor means. A single value says the key holds one thing, so two explicit templates setting it collide. A list says the key holds several, so they concatenate in tier order, dropping a repeat—which is what lets a template add one middleware to whatever it’s mixed into. One key written as a list in one place and a single value in another is its own error, since the two disagree about what the key holds.

    A shared middleware is exactly what a list-valued labels entry is for: name it once in a template and every service composing that template gets it, with each service free to add its own on top—see A list value composes instead of colliding.

A service’s own body still wins over a template for a single-valued entry, so it can override one routing label while inheriting the rest:

service it-tools {
  with internal_web { port: 8080 }
  image "corentinth/it-tools:latest"
  # overrides just the rule—the entrypoints and middlewares entries
  # still come from internal_web
  labels {
    "traefik.http.routers.{{name}}.rule": "Host(`tools.internal.example.com`)"
  }
}

A complete example

Putting it together—a network, a named volume, three templates, and a service that composes all three:

network traefik-net {
  external
  name: "docker_default"
}

volume syncthing-config {}

template internal_web(port) {
  networks [traefik-net]
  restart unless-stopped
  expose $port
  labels {
    "traefik.http.routers.{{name}}.rule": "Host(`{{name}}.internal.example.com`)"
    "traefik.http.routers.{{name}}.entrypoints": "web-secure"
    "traefik.http.routers.{{name}}.middlewares": ["local-ipwhitelist@file"]
  }
}

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

template linuxserver_app(puid, pgid) {
  env PUID = $puid
  env PGID = $pgid
}

service syncthing {
  with internal_web { port: 8384 }, authenticated, linuxserver_app { puid: 1000, pgid: 100 }
  image "lscr.io/linuxserver/syncthing:latest"
  volume syncthing-config -> "/config"
}

syncthing ends up with:

  • a network reference and restart from internal_web
  • an expose block built from internal_web’s port parameter, and its routing labels with their {{name}}-interpolated host
  • a middlewares entry contributed by both internal_web and authenticated, joined into one label because each wrote a list
  • two env entries from linuxserver_app
  • its own image and volume, which no template sets

Once these templates start getting reused across more than one .hll file, the next step is pulling them into a shared file and use-ing them—see Imports.

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.

Imports

Real templates and networks should span every service file in a homelab instead of getting copy-pasted into each one. use imports another .hll file under a local alias, so its top-level templates and networks become available, qualified by that alias.

Basic usage

use "docker.hll" as traefik
  • use’s path is always a quoted string, resolved relative to the importing file’s own location—never the entry file’s location or the directory you invoked hllc from.
  • alias.name then qualifies any reference that would otherwise be a bare identifier: a networks [...] entry (networks [traefik.traefik-net]), a named-volume mount’s host side, a with invocation’s target (with common.internal_web { ... }), or an argument to one (with traefik.docker_network { net: net.traefik-net }).
  • dns, env_file and depends_on don’t support a qualified form. None has a coherent cross-file meaning: depends_on names a same-file sibling service, and dns and env_file name an IP address and a path on disk—each is just text passed through verbatim. Only networks and a named-volume mount’s host side resolve a qualifier, since only they name something another .hll file actually declares.

Splitting a homelab across files

The templates from the previous page’s example split across three files, use-connected instead of copy-pasted into every service:

# network.hll
network traefik-net {
  external
  name: "docker_default"
}
# templates.hll
use "network.hll" as net

template internal_web(port) {
  networks [net.traefik-net]
  restart unless-stopped
  expose $port
  labels {
    "traefik.http.routers.{{name}}.rule": "Host(`{{name}}.internal.example.com`)"
    "traefik.http.routers.{{name}}.entrypoints": "web-secure"
    "traefik.http.routers.{{name}}.middlewares": ["local-ipwhitelist@file"]
  }
}

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

template linuxserver_app(puid, pgid) {
  env PUID = $puid
  env PGID = $pgid
}
# syncthing.hll
use "templates.hll" as common

volume syncthing-config {}

service syncthing {
  with common.internal_web { port: 8384 }, common.authenticated, common.linuxserver_app { puid: 1000, pgid: 100 }
  image "lscr.io/linuxserver/syncthing:latest"
  volume syncthing-config -> "/config"
}

Compiling syncthing.hll with hllc build produces byte-identical output to writing all three declarations in one file—use is purely an organizational tool, not a different composition mechanism.

A named volume is a reference, not a string, so it imports the same way a network does. The preceding example declares volume syncthing-config {} in the entry file and mounts it by its bare name, which resolves against that file’s own declarations. Move the declaration into a shared file and the mount picks up the alias:

# storage.hll
volume media {
  external
  name: "media_store"
}
# jellyfin.hll
use "storage.hll" as storage

service jellyfin {
  image "jellyfin/jellyfin:latest"
  volume storage.media -> "/data"
}

The imported declaration’s own options—external, name, driver, driver_opts—come with it into the generated volumes: section. Only the unquoted form is a reference: a quoted host side, such as volume "/mnt/media" -> "/data", is a bind-mount path, which names something on the host rather than anything an .hll file declares, so it takes no alias.

Two rules that matter for multi-file layouts

Templates are lexically scoped, not dynamically scoped. A template’s own references always resolve against the file that declared it, not whichever file happens to call it. In the preceding example, internal_web’s networks [net.traefik-net] resolves against templates.hll’s own use "network.hll" as net—even though it’s syncthing.hll that actually invokes internal_web via with. syncthing.hll never itself needs to use "network.hll" for this to work.

Imports aren’t transitive. use-ing a file only makes that file’s own top-level declarations available under your alias—not anything it in turn uses. In the preceding example, syncthing.hll uses templates.hll, and templates.hll uses network.hll, but syncthing.hll can’t write net.traefik-net itself—only templates.hll’s own template bodies can reach network.hll’s declarations, via the preceding lexical-scoping rule. If syncthing.hll needed to reference traefik-net directly instead of through a template, it would need its own use "network.hll" as net.

Together, these two rules mean: a template file needs use declarations for whatever it references, and a service file needs use declarations only for what it references directly—importing a template doesn’t also import that template’s own imports.

Reading an imported declaration’s name

Add a third segment and the same alias reads a field off what it names rather than referencing it—net.traefik-net.name is the real Docker name of that imported network, docker_default in the preceding example. See Reading a declaration’s real name for the field itself. Two things about it are specific to imports:

# proxy.hll
network proxy {
  external
  name: "docker_default"
}
# caddy.hll
use "proxy.hll" as net

service jellyfin {
  image "jellyfin/jellyfin"
  labels {
    "caddy.network": net.proxy.name
  }
}

Reading a name imports nothing. The generated document here carries no networks: section at all: networks [net.proxy] is what pulls a declaration across an import, and reading its name yields a plain string. That also keeps it clear of the bare-name collision the next section describes, since no second declaration comes over to collide.

The alias resolves in the file holding the access. Inside a template, net.proxy.name reads the net of the file that declared the template—the same lexical-scoping rule as any other reference, and it covers a with-invocation’s arguments too, since those are values the calling file wrote.

Passing an imported declaration to a template

A template parameter takes an imported declaration the same way it takes a same-file one: alias.name, with no third segment, names the declaration itself, and the template reads whatever it needs off it. That’s what makes std:traefik’s docker_network usable from a shared templates file, where the network it labels lives in a third file:

# network.hll
network traefik-net {
  external
  name: "docker_default"
}
# web.hll
use "network.hll" as net
use "std:traefik" as traefik

service web {
  image "nginx"
  networks [net.traefik-net]
  with traefik.docker_network { net: net.traefik-net }
}
labels:
- traefik.docker.network=docker_default

docker_network’s body reads "{{net.name}}", and it resolves against web.hll’s own net alias—the file that wrote the argument—not the standard library file the template lives in. The lexical-scoping rule again, and it’s what lets the same argument travel one hop further, from a shared templates.hll into a service file that never imports network.hll at all.

The two things a template can do with the declaration you hand it are what a declaration is for: attach it (networks [$net]) or read a field off it ("{{net.name}}", $net.name). Splicing it into an ordinary value—an env value, an image—is an error naming the argument, since a declaration has no text form a plain field could take. Pass net.traefik-net.name when the field is what you meant.

A local declaration always wins the two-segment spelling. If the file also declares network net { ... }, then net.traefik-net reads the field traefik-net off that network—and says it has no such field—rather than reaching through the alias. Rename one of the two if they collide.

Two networks, or two volumes, can’t share one bare name

An imported network keeps its own bare name in the generated Compose—net.traefik-net becomes the traefik-net key under networks:. So a file that pulls in an imported network while also declaring one of its own by the same name is asking for two different networks under one key, and hllc rejects it:

use "network.hll" as net

# error: `net.proxy` collides with another network named `proxy`
network proxy {
  name: "local_real_name"
}

service web {
  image "nginx"
  networks [net.proxy]
}

Rename one of the two and the ambiguity goes away. The same applies to two imported networks sharing a bare name—use-ing both a.hll and b.hll is fine, and referencing a.proxy and b.proxy from the same file is what’s rejected.

Note this only triggers when a qualified reference actually pulls the imported network in. Two files each declaring their own network proxy is perfectly normal, and stays legal for as long as nothing reaches across the import to name the other one.

Named volumes follow the same rule, word for word, because an imported volume likewise keeps its own bare name as its key under volumes:. Mounting storage.media in a file that also declares its own volume media { ... } is the same ambiguity, and hllc reports it the same way:

jellyfin.hll:6:10: `storage.media` collides with another volume named `media` already in scope — volumes are resolved by their bare name, so the two can't be told apart; rename one of them

Sharing a set of baseline fields

Every template is shareable, because naming a template in a with is the one way any template reaches a service. So a baseline several service files should agree on lives in one imported file, and each service applies it:

# common.hll
template baseline {
  restart unless-stopped
}
# syncthing.hll
use "common.hll" as common

service syncthing {
  with common.baseline
  image "lscr.io/linuxserver/syncthing:latest"
}

hllc used to apply a template named exactly defaults on its own, and that one template was the one use could never share: with no invocation, there was no alias for a cross-file lookup to go through, so an imported defaults reached nothing and warned. That special case no longer exists. defaults is an ordinary name now, and with common.defaults reaches an imported one exactly as with common.baseline does—see Templates & Composition.

Only the entry file contributes services

use shares declarations—templates and networks—not services. Only the file you point hllc at contributes service blocks to the output. hllc parses a service in an imported file, so its syntax and duplicate names still get checked, and then drops it, since nothing can reference a service across files in the first place.

That’s another warning rather than an error, because the imported file is usually still doing its real job as a template library:

common.hll:6:9: warning: service `db` is declared in an imported file and is not compiled — only the entry file's services are built

If you meant to build that service, point hllc at its own file, or, in a directory build, give it a directory of its own—see The hllc command-line tool. If you meant to share it, what you want is a template, applied with with.

Modules bundled with the compiler

hllc can carry .hll modules inside its own binary. A use path that starts with std: names one of those instead of a file beside yours:

use "std:traefik" as traefik

That path resolves against the compiler’s own modules rather than against your tree. This compiler bundles one, std:traefik, whose templates write a service’s Traefik router labels—see Routing. Ask for a name it doesn’t carry and the diagnostic says what it does:

service.hll:1:5: unknown standard library module "std:caddy" — this compiler bundles: std:traefik

The prefix belongs to the compiler, which is the other thing to know about it. A file of your own named std:something.hll no longer answers to use "std:something.hll", and reaching it takes an explicit relative spelling, use "./std:something.hll".

A bundled module behaves like any other import. An alias qualifies its declarations the same way, its templates resolve against the module that declared them, and its own imports stay its own. The one thing it never does: come from anywhere but the binary. No search path, no environment variable, no directory in your home, nothing fetched over a network. A bundled module and the compiler that reads it ship as one artifact and move together, which also means a compiler upgrade can change what one of them generates.

The hllc command-line tool

hllc is the hll compiler’s command-line binary. It has four subcommands, each taking a single positional path argument:

hllc build <file.hll or directory> [--out <path>] [--force]
hllc check <file.hll or directory>
hllc parse <file.hll>
hllc tokens <file.hll>

Each one runs the compiler to a different depth. tokens stops after the lexer, parse after the parser, and build and check run all of it—differing only in where each one puts the document it generates.

hllc on its own prints this list and exits non-zero: it compiles nothing until you name one of the four.

build

Runs the full pipeline—parse, resolve use imports, resolve template/with composition, generate Compose YAML—and either prints the result or writes it to disk. This is the one you’ll use in practice.

Single file

hllc build jellyfin.hll                            # prints YAML to stdout
hllc build jellyfin.hll --out docker-compose.yml   # writes to a path
hllc build jellyfin.hll --out dist/                # writes dist/docker-compose.yml

One input file always produces one output document, though it may hold multiple service declarations—see Getting Started. build fully resolves any use graph the file participates in, so building syncthing.hll from the Imports example produces the same output whether its templates live in the same file or across three use-connected ones.

If --out names an existing directory, hllc writes docker-compose.yml inside it—the same convention directory mode uses, so dist/—the natural first guess—works whether the input is a single file or a whole directory.

Directory: flat mode

Point build at a directory instead of a file, and hllc treats every .hll file directly inside it as its own independent entry point, each with its own use graph:

services/
  jellyfin.hll
  syncthing.hll
  uptime-kuma.hll
hllc build services/ --out dist/

--out is required in this mode—with potentially many files’ worth of output, there’s no single meaningful default location. Each file’s stem becomes its own output directory: dist/jellyfin/docker-compose.yml, dist/syncthing/docker-compose.yml, and so on.

hllc skips, rather than builds, a file that declares no service—one holding only template/network declarations meant to be used by others: building it would produce a Compose document with nothing in it, since codegen only ever emits what a service actually references.

Directory: co-located mode

hllc chooses this mode automatically when the target directory holds no .hll files directly—the layout a real homelab tends to use in practice, keeping each service’s .hll source next to its other files (.env, bind-mounted config), often alongside a shared library of templates and networks used by every service:

homelab/
  shared/
    network.hll
    templates.hll
  services/
    jellyfin/
      jellyfin.hll
      .env
    syncthing/
      syncthing.hll
hllc build homelab/

hllc recurses through the tree looking for service directories—a directory holding exactly one .hll file that declares a service— however many levels down they sit. A directory that isn’t one itself (like homelab/ or services/ in the preceding listing, which hold no .hll files of their own) is recursed into. hllc recognizes a library directory (like shared/, which holds .hll files but none of them declare a service) as such and skips it, rather than treating it as a malformed service directory. Only service declarations count for this—a file that uses a shared library of templates is still a service directory in its own right.

With no --out, each service directory’s .hll file builds in place, right back into that same directory: services/jellyfin/docker-compose.yml, services/syncthing/docker-compose.yml. An explicit --out <dir> still remaps the whole tree, the same way flat mode’s does, but preserving each service directory’s path relative to the build root instead of flattening by name: <out>/services/jellyfin/docker-compose.yml.

A directory containing more than one .hll file that declares a service is a hard error—it’s ambiguous which one’s output belongs directly in that directory, so hllc won’t guess. A directory can freely mix one service file with any number of library files, though—only the count of service-declaring files matters.

Generated files, and what hllc won’t overwrite

Every document build produces—printed or written—starts with a header marking it as generated:

# Generated by hllc—do not edit.
# Edit the .hll source and re-run `hllc build` instead.
services:
  jellyfin:
    ...

It’s an ordinary YAML comment, so docker compose ignores it, and it makes generated files self-identifying in a repo, in a diff, and in review.

hllc also reads that header back. Before writing any output file it checks what’s already there:

  • Nothing there, or a file carrying the header (hllc’s own earlier output) → written, as always. Rebuilding never needs a flag.
  • A file without the header—a hand-written docker-compose.yml, or anything else—→ refused, with an error, and the build exits non-zero without touching it.
  • A symlink → refused as well, whatever it points at. Replacing the link is up to you, since only you can see its target.

This matters most in co-located mode, which scans for its output paths instead of taking them as input: converting a repo one service at a time means running hllc build . over directories whose docker-compose.yml files are still hand-written, and those are exactly the files hllc must not clobber.

Pass --force when you do want an unmarked file replaced—typically the one-time conversion of a service you’ve just rewritten in .hll:

hllc build services/jellyfin/jellyfin.hll --out services/jellyfin/docker-compose.yml --force

--force skips only the header check. The symlink refusal stands regardless.

Which directory mode applies

hllc inspects the target directory once and picks a mode:

  • Any .hll files directly inside it → flat mode, --out required. hllc skips files that declare no service.
  • No .hll files directly inside it → co-located mode, recursing into subdirectories to find service directories at any depth. A directory found along the way that declares no service (whether it holds no .hll files, or only library ones) is recursed into rather than treated as a service directory.
  • No service directory found anywhere in the tree → builds nothing, successfully, but prints a line saying so—a directory build that quietly does nothing is easy to mistake for one that worked.

check

Runs everything build runs and writes nothing. It’s the CI gate: exit 0 means every entry point it found compiles, and a non-zero exit means one didn’t, with the diagnostic on stderr.

hllc check jellyfin.hll
hllc check services/
hllc check homelab/

A run that passes prints nothing at all—there’s no document to show and no path to report. Both directory shapes work exactly as they do under build, walked the same way and choosing the same mode, so a whole tree checks in one command. Flat mode needs no --out here: build requires one because it has however many files’ worth of output to place, and check places none.

check writes nothing anywhere—which matters most in co-located mode, where build with no --out writes each document back into the service directory it found. Nothing appears in the tree you checked, and nothing needs cleaning up afterwards.

Warnings still print, and still don’t fail the run, exactly as under build.

parse

Parses one file and pretty-prints its Abstract Syntax Tree (AST), without resolving use imports or with composition and without generating any output. Useful for understanding how a particular shorthand desugars:

hllc parse jellyfin.hll

It’s a debugging aid, and its output is often thousands of lines, so it’s usually worth a pager: hllc parse jellyfin.hll | less.

tokens

Runs the lexer over one file and prints its token stream, one token per line as line:col kind lexeme—for debugging the lexer itself, not something you’d reach for day to day:

hllc tokens jellyfin.hll

Exit codes

hllc exits non-zero on any lex/parse/link/Compose/codegen error, printing a diagnostic to stderr—hllc check is safe to use directly as a CI gate before docker compose up. An invocation hllc can’t make sense of—no subcommand, an unknown one, a missing path, a flag on a subcommand that doesn’t take it—exits 2 without touching any file.

How much of a location the diagnostic carries depends on the stage that raised it:

  • Lex errors print path:line:col: message.

  • Parse errors print path: line:col: message—the path, then a space, then the position, so the path isn’t part of the line:col sequence.

  • Link errors about a file as a whole (an import that won’t load, a duplicate alias) name that file, which may be an imported one rather than the file you passed on the command line.

  • Compose and codegen errors print path:line:col: message, and every position they mention carries its own path. A composed service’s fields can come from any file in the use graph, so an error about two of them routinely straddles two files:

    t2.hll:2:11: field `restart.policy` set by both template `x` (at t1.hll:2:11) and template `y`—explicit templates must not conflict
    

    Both positions here are line 2, column 11—in different files. The path on each is what distinguishes them, and it points at the file the field was really written in, which may be an imported one you never opened.

Warnings

Not everything hllc has to say is fatal. Some of what you can write is legal, deliberately dropped, and still worth hearing about, since a declaration that compiles to nothing at all looks exactly like one you forgot to write. Those are warnings. hllc prints each one to stderr in the same path:line:col: shape as an error, with a warning: marker after the location, and it changes neither the exit code nor the output. A build that raises only warnings still writes its files and still exits 0, so a warning can’t break a CI gate:

shared/common.hll:1:10: warning: template `defaults` is not applied to anything — it is an ordinary template now, no longer applied implicitly to every service; add `with defaults` to each service that wants it
shared/common.hll:6:9: warning: service `db` is declared in an imported file and is not compiled — only the entry file's services are built
jellyfin.hll:2:9: warning: network `unused` is declared but no service references it, so it is not emitted — add it to a service's `networks [...]` list, or remove the declaration

There are three of them today, one per construct that a stage drops on purpose:

  • a service in an imported file, since hllc builds only the entry file’s services—see Imports
  • a template defaults no service applies with a with, since defaults is no longer applied implicitly—see Templates & Composition
  • a top-level network no service references, since hllc builds the networks: section from services’ references

No flag silences them yet. When one is telling you about something you meant, the fix is to write it in a way that drops nothing, and the warning text names that fix in each case.

Routing used to add a fourth of these—a router with nothing to match was a hard error rather than a warning. Routing is templates now, so hllc has no view on whether a set of labels describes a working router. See Routing.

Further reading

This book covers hll from a user’s point of view—enough to write and compile real .hll files for your own homelab. A few things live elsewhere:

  • docs/DESIGN.md—the formal spec: the lexical/syntactic grammar in Backus-Naur Form (BNF), the desugaring rules, and the full built-in schema table. The lexer, parser, and codegen build against this source of truth, which is also the right place to check when this book’s prose leaves an edge case ambiguous.
  • Each crate’s own rustdoc (crates/hl-lexer/src/lib.rs, crates/hl-parser/src/lib.rs, crates/hl-linker/src/lib.rs, crates/hl-codegen/src/lib.rs)—implementation details: token and Abstract Syntax Tree (AST) shapes, span semantics, error types. Relevant if you’re modifying the compiler itself rather than writing .hll files.
  • The repository README—installing a released hllc binary, building from source, running the test suite, and cutting releases.
  • CONTRIBUTING.md—the PR workflow, if you’d like to contribute to hll itself.

Found a gap in this book, or something that’s out of date with the compiler’s actual behavior? Open an issue.