Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Syntax basics

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

Declarations

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

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

Bodies and statements

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

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

Every statement is one of two shapes:

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

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

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

Reserved words

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

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

template template {
  restart unless-stopped
}

service template {
  image "nginx"
  with template
}

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

The primary-value shorthand

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

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

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

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

Secondary-field shorthand

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

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

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

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

Map-style shorthand

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

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

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

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

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

Layout rules

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

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

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

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

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

Comments and interpolation

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

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

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

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

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

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

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

Numbers and strings

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

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

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

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

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

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

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

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