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 8096instead of writing the fullexpose { port: 8096 }body) actually work. - Built-in Fields—every field
hllunderstands, what it accepts, and its defaults. - Routing—getting a hostname to reach one of these
services, with the Traefik templates
hllcships. - The
hllccommand-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, anetwork, or avolume—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
usedeclaration 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.keyfollowed 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"andrestart unless-stoppedmust 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 awith-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 barewith-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:
| Escape | Character |
|---|---|
\" | a double quote |
\\ | a backslash |
\n | a newline |
\t | a tab |
\r | a 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
| Field | Accepts | Default |
|---|---|---|
external | bare flag, no value | unset, false |
name | string | the 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
| Field | Accepts | Default |
|---|---|---|
external | bare flag, no value | unset, false |
name | string | the volume’s own hll identifier |
driver | string | unset, matching Compose’s own default of local |
driver_opts | map body, key: value | empty |
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.
| Field | Accepts | Default |
|---|---|---|
ref | string | required—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.
| Field | Accepts | Default |
|---|---|---|
context | string | Required—a build with no context has nothing to build |
dockerfile | string | unset—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.
| Field | Accepts | Default |
|---|---|---|
port | number | No 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.
| Field | Accepts | Default |
|---|---|---|
policy | bare word or string | unset, 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.
| Field | Accepts | Default |
|---|---|---|
test | string or bracketed list | unset—no healthcheck defined here, though the image’s own still applies if it has one |
interval | string | unset, matching Compose’s own default |
timeout | string | unset, matching Compose’s own default |
retries | number | unset, matching Compose’s own default |
start_period | string | unset, matching Compose’s own default |
start_interval | string | unset, matching Compose’s own default |
disable | bare flag, no value | unset, 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_onnames a same-file siblingservicethis one depends on—it’s not cross-file, and doesn’t accept a qualifiedalias.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 baredepends_on databasehas ever meant),service_healthy(wait for the target’shealthcheckto report healthy), orservice_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: ... }.hllcemits 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 withservice_started, since the mapping form requires every entry to name one.service_healthyis only meaningful when the target service actually has a healthcheck to become healthy against—buthllcdoesn’t warn when the target’s.hllbody has nohealthcheckfield, because that’s not evidence the condition is meaningless: a Docker image can bake its ownHEALTHCHECKinto its Dockerfile, invisible to anything an.hllfile declares. -
networksreferences a top-levelnetworkdeclared in the same program—see the preceding section. If exactly one referenced network isexternal, its real name also drives thetraefik.docker.network=label, but more than oneexternalnetwork on the same service is a compile error, since it’s ambiguous which network Traefik should target.hllcbuilds the generatednetworks:section from these references, so anetworkno service names never reaches the output. That one is a warning on stderr rather than an error—see Warnings.defaultis the one network name every program gets for free, with or without a matching declaration:networks [default]compiles even when nothing in the file declaresnetwork default { ... }, resolving to the same implicit default networkdocker composeitself creates for a project.hllcadds nothing to the top-levelnetworks:section for it in that case—Compose already knows aboutdefault, so there’s nothing forhllcto declare.Two or more
servicedeclarations 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 todefaultin addition to whatever it names explicitly—nonetworks [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 forhllcto add. Auto-attachment is idempotent—a service that writesnetworks [default]itself still ends up with onedefaultentry, not two—and, when explicit, always sorts last in that service’snetworks:list.Attaching every service unconditionally is where
hllcparts company withdocker composeitself, which hands a service the default network only when that service lists no networks of its own. The difference is deliberate:defaultalready 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: itsexternal/namesettings apply exactly as they would to any other named network, including feeding thetraefik.docker.network=label when it’sexternal, and it still emits its own top-levelnetworks:entry. The implicit, undeclareddefaultis only a fallback for when no such declaration exists. -
dnssets Compose’s own per-servicedns:key—a resolver override. Use it, for example, when a network has a local name server. -
env_filesets Compose’s ownenv_file:key—one or more paths to load environment variables from. It’s a plain generic Compose key likedns, not homelab-specific itself, even though most real entries point at a gitignored, per-homelab.envfile. The generatedenv_file:value is always a list: a singleenv_file "one.env"still emits a one-elementenv_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, nothllc—write it exactly asdocker composewould expect it. When two files set the same variable, Compose lets the later file win, so order matters here the same way it matters fordns’s resolver priority. Reach forenvinstead when a value belongs directly in the.hllfile 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
| Field | Accepts | Default |
|---|---|---|
privileged | bare flag, no value | unset, 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"
| Accepts | Default |
|---|---|
| string | not 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"]
| Accepts | Default |
|---|---|
| string or bracketed list | unset—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"]
| Accepts | Default |
|---|---|
| string or bracketed list | unset—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:$portin the preceding example reachesexpose.port, one of the fields Built-in Fields documents as taking anumber, sowith internal_web { port: "8384" }is a compile error even though nothing here declaredport: Number. A reference-shaped field likenetworksrejects a bare number the same way—see Parameterizing references below—while every other field takes whatever literal kind its argument happens to be.$portinside 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 namedname, but{{name}}goes on meaning the service andhllcsays so. Reach that parameter as$nameinstead.
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 value | reads |
|---|---|
proxy.name | a network/volume this program declares |
net.proxy.name | one an imported file declares |
$net.name | whichever 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 value | reads |
|---|---|
media.name | the volume’s real Docker name |
media.driver | the 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. Writingnetworks [$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
$paramtakes 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:
with-listed templates, left to right- 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 butdnsandenv_fileconcatenate by distinct name: naming the same network in a template and again in the service’s own body means what naming it once means, sohllcdrops the repeat rather than emitting it twice. The first occurrence is the one kept, so the surviving order is still eachwithtarget left to right, then the body’s own entries.dnsandenv_fileare the exception and keep every entry, duplicates included, because their order is observable—resolver priority fordns, Compose’s own last-file-wins precedence forenv_file. -
depends_onlooks 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 explicitcondition: 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 plaindepends_on [db]always has. Only when two explicit templates’ conditions genuinely differ is it the sameMapKeyCollisioncompile error two templates setting the sameenvkey to two different values would raise. -
Map fields (
volume,env,labels,raw) merge key-by-key (or value-by-value forvolume, 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’sdepends_onkeys 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 twoenventries sharing a key always are, whatever those two entries’ values happen to be. -
Scalar fields (
image,restart,expose’sport) error on collision among explicit templates only, per the preceding rule. -
healthcheckis 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 buttestis a scalar and collides likeexpose.portdoes, andtestcollides the same way even though its value isn’t a plain string or number—see below.healthcheck.testandhealthcheck.disablecollide the same way a scalar sub-field does, even though neither is a plainLiteral:testcarries Compose’s own shell-string-or-exec-list shape, anddisableis a bare-presence flag whose only “value” is that it’s present at all. Two explicit templates each settingtest(or each settingdisable) still collide, exactly as two explicit templates each settingexpose.portdo. -
commandmerges the same wayhealthcheck.testdoes, not the waycontainer_namedoes: its shell-string-or-exec-list shape isn’t a plainLiteraleither, so it collides between two explicit templates by the same rule rather than riding the plain-scalar machineryimage/restart/container_nameuse. Unlikehealthcheck.test,commandsits directly on the service body rather than inside a struct field of its own, so there’s no sub-field independence to it—settingcommandat all is the whole collision, the same as settingcontainer_nameis. -
entrypointmerges exactly the waycommanddoes, 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 setsentrypointand a template that setscommandmerge cleanly, and the service gets both. -
labelsmerges 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
labelsentry 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
restartfrominternal_web - an
exposeblock built frominternal_web’sportparameter, and its routing labels with their{{name}}-interpolated host - a middlewares entry contributed by both
internal_webandauthenticated, joined into one label because each wrote a list - two
enventries fromlinuxserver_app - its own
imageandvolume, 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:
| Template | Writes |
|---|---|
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 invokedhllcfrom.alias.namethen qualifies any reference that would otherwise be a bare identifier: anetworks [...]entry (networks [traefik.traefik-net]), a named-volume mount’s host side, awithinvocation’s target (with common.internal_web { ... }), or an argument to one (with traefik.docker_network { net: net.traefik-net }).dns,env_fileanddepends_ondon’t support a qualified form. None has a coherent cross-file meaning:depends_onnames a same-file sibling service, anddnsandenv_filename an IP address and a path on disk—each is just text passed through verbatim. Onlynetworksand a named-volume mount’s host side resolve a qualifier, since only they name something another.hllfile 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
.hllfiles directly inside it → flat mode,--outrequired.hllcskips files that declare no service. - No
.hllfiles 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.hllfiles, 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 theline:colsequence. -
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 theusegraph, 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 conflictBoth 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
servicein an imported file, sincehllcbuilds only the entry file’s services—see Imports - a
template defaultsno service applies with awith, sincedefaultsis no longer applied implicitly—see Templates & Composition - a top-level
networkno service references, sincehllcbuilds thenetworks: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.hllfiles. - The repository README—installing
a released
hllcbinary, building from source, running the test suite, and cutting releases. CONTRIBUTING.md—the PR workflow, if you’d like to contribute tohllitself.
Found a gap in this book, or something that’s out of date with the compiler’s actual behavior? Open an issue.