Configuring a system
A Laft machine is described by /etc/laft/system.stav and the files it includes. This page walks through the configuration that the virtual machine test installs, then shows the other things a configuration can declare.
The file is written in Stav, a typed configuration language with block syntax. This guide explains what Laft does with each setting. For the language itself, its types, functions, imports and merging rules, see the Stav documentation. Every field is listed in the system configuration reference.
A real configuration, explained
This is tests/vm/system.stav from the repository, the configuration of the machine in Try it in QEMU. The installer replaces the value "@REPO_KEY@" with the public key of the test repository and leaves the rest of the file, comments included, as it is.
system {
include "./hardware.stav"
hostname = "laft-vm"
packages = [busybox:static, runit:static, limine, laft]
repo core {
url = "file:///repo"
key = "@REPO_KEY@"
}
users {
# The password is "laft".
user root {
shell = "/usr/bin/sh"
password_hash = "$6$laftvmtest$TYgldH8Nq8IA.rapP6rP8nRPwVxtaogYYeskFhzsWeop1a/jLgduKPbwiJkWJBLTVoytkeuCyuB4NCDPgxLiz."
}
}
# The root filesystem is not checked, so the initramfs needs no e2fsck.
fs "/" { fsck = false }
boot {
cmdline = ["console=ttyS0", "quiet", "laft.debug"]
timeout = 1
microcode = force(off)
}
# busybox stands in for udev: mdev populates /dev from sysfs.
service udevd { enable = false }
service udev-trigger { up = ["/usr/bin/mdev", "-s"] }
# This system has no session tracker.
service turnstiled { enable = false }
service boot-complete { log = console }
service vmtest-marker {
description = "Print a marker line on the console"
exec = ["/usr/bin/sh", "-c", "echo 'laft-vmtest: marker service is running'; exec sleep 2147483647"]
log = console
}
getty { ttys = ["ttyS0"] }
}One system block
Everything goes into a single system block. Its schema lives in Laft, so Stav knows every field, its type and its default, and rejects a misspelled field or a value of the wrong type before Laft does anything.
include "./hardware.stav"
The machine-specific part, its filesystems, CPU microcode and initramfs modules, sits in a separate file that laft hardware generates. The installer ran laft hardware --write, which produced this:
# Generated by `laft hardware`. It belongs to you now: laft never
# overwrites it.
fs "/" {
device = "UUID=c51602a6-26f1-4fc1-9927-427ae8e748be"
fstype = ext4
}
fs "/boot" {
device = "UUID=034E-902B"
fstype = vfat
}
boot {
microcode = amd
initrd { modules = [] }
}include merges the file's blocks into the system block, as if they were written there. A fs block per mountpoint gives the device and filesystem type. The ESP is the vfat filesystem at /boot, which boot.esp names by default.
packages
packages = [busybox:static, runit:static, limine, laft]The packages of the system view. Each entry is a package reference, here with the static variant for busybox and runit. The resolver adds everything these packages depend on. The kernel is not in the list because boot.kernel names it, linux by default, and Laft adds it on its own. Services can add packages too, through their own packages field.
repo core
repo core {
url = "file:///repo"
key = "@REPO_KEY@"
}Where packages come from. key is the minisign public key that every index of the repository must be signed with, through a keys.json that the key signs or directly. URLs can be https://, file://, or http:// when allow_http = true is set. A repository also has mirrors, a channel (current or testing), a priority and enable.
Every system has a core repository from the base part, with a placeholder URL in the reserved .invalid domain, because the official repository does not exist yet. This file replaces its URL and key.
users
users {
user root {
shell = "/usr/bin/sh"
password_hash = "$6$laftvmtest$TYgld..."
}
}user and group blocks declare accounts. root already exists in the base part, so this block only changes its shell, which is busybox sh on this system, and sets its password. password_hash takes a crypt(3) hash, and password_file the path of a file that holds one, read at activation so that the hash need not sit in the configuration. Users also take uid, groups, home, ssh_keys and more. Laft allocates ids for accounts that do not set one and keeps them stable from one generation to the next.
fs "/" { fsck = false }
The fs "/" block from hardware.stav and this one merge into one. Turning the check off means the initramfs needs no e2fsck, which this small system does not have.
boot
boot {
cmdline = ["console=ttyS0", "quiet", "laft.debug"]
timeout = 1
microcode = force(off)
}The kernel command line, the Limine menu timeout in seconds, and which CPU microcode the initramfs carries. laft.debug makes laft-boot print its progress on the console. force(off) is a Stav priority: hardware.stav already sets microcode = amd, and two plain assignments of different values to one field would be a conflict. force wins over a plain value, so the test system carries no microcode on any host. Other boot fields set the boot loader, the kernel package, the number of boot attempts before a fallback (tries) and the initramfs.
Switching off what the base part declares
service udevd { enable = false }
service udev-trigger { up = ["/usr/bin/mdev", "-s"] }
service turnstiled { enable = false }
service boot-complete { log = console }Every system includes a base part with the static accounts, the early boot services, a /tmp on tmpfs, kernel hardening sysctls and the privileged programs. Its values are defaults, so a plain assignment overrides them, and anything with an enable field can be switched off. This system has no udev, so it disables udevd and makes udev-trigger run busybox mdev instead. It disables the session tracker and sends the output of boot-complete to the console, where the test can see it.
A service of its own
service vmtest-marker {
description = "Print a marker line on the console"
exec = ["/usr/bin/sh", "-c", "echo 'laft-vmtest: marker service is running'; exec sleep 2147483647"]
log = console
}A longrun, the default kind, which runit supervises and restarts when it exits. exec is the command as a list of arguments, with an absolute program path. log = console writes its output to the console instead of the default logged, which keeps rotated logs in /var/log/<name>/.
getty
getty { ttys = ["ttyS0"] }The terminals that get a login prompt. Each one becomes a service named getty-<tty>. The default is tty1 and tty2. The virtual machine has only a serial console.
More of what a configuration can say
This configuration uses more of the schema. It passes laft check, which type checks a file on its own without resolving packages.
import "std/services" as services
system {
include "./hardware.stav"
hostname = "atlas"
timezone = "Europe/Oslo"
keymap = "no-latin1"
packages = [opendoas, vim, ripgrep:static]
users {
user ada {
groups = [wheel]
shell = "/usr/bin/bash"
ssh_keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... ada@example"]
}
}
file "/etc/doas.conf" {
text = "permit persist :wheel\n"
mode = 0o400
}
include services.dhcpcd()
include services.sshd(port = 2222)
service backup {
description = "Copy /home to the backup disk every night"
kind = oneshot
up = ["/usr/bin/sh", "/etc/backup.sh"]
after = [mount-fs]
}
kernel { modules = ["wireguard"] }
sysctl { "vm.swappiness" = 10 }
gc { keep_generations = 5 }
fs "/home" {
device = "LABEL=home"
fstype = ext4
}
}Files in /etc
A file block manages one path below /etc. Its content is exactly one of text, source (a file next to the configuration), link (a symlink target), or structured data that Laft renders: json, toml, yaml, ini or env. It also sets mode, owner and group, and copy = true writes a real file instead of a link into /etc/static. enable = false removes a file that a package or the base part would provide.
file "/etc/iwd/main.conf" {
ini = {
General = { EnableNetworkConfiguration = true }
Network = { NameResolvingService = "resolvconf" }
}
}Ready-made services
std/services is a catalog of functions that each return a part of a system. Including one adds the service, the packages it needs and its configuration files. The catalog has sshd, dbus, seatd, elogind, dhcpcd, iwd, chronyd and nftables. Their settings are defaults, so a configuration can still change any of them:
include services.sshd(port = 2222)
service sshd { restart_delay = 5 }Packages behind the catalog
The catalog refers to packages such as openssh, dhcpcd and iwd that the recipe repository does not have yet. A configuration that includes them type checks, but resolving it needs a repository that provides them.
Services in full
A service declares how it runs and what it needs:
service web {
description = "Static file server"
exec = ["/usr/bin/busybox", "httpd", "-f", "-p", "8080", "-h", "/srv/www"]
user = nobody
after = [mount-fs]
ready = check(cmd = ["/usr/bin/wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/"])
restart = on-failure
stop_timeout = 20
limits { nofile = 4096 }
}ready decides when services that wait on this one may start: immediate, notify(fd = 3) for programs that write a line to a descriptor when they are ready, or check(...), a command polled until it succeeds. on_change decides what happens to the running service when a new generation changes it. The reference lists every field.
Views
view dev {
packages = [python@3.12]
users = ["ada"]
}A view is another /usr assembled next to the system's. This one starts from the system's package requests and replaces the python request with a 3.12 version. ada and members of wheel may enter it with laft enter dev -- python3.
Priorities and overrides
When two places set the same value, Stav merging decides. Lists and sets concatenate, maps merge per key, and single values settle by priority. The base part and the catalog use default(...), which any plain assignment beats, and force(...) beats a plain assignment. Two plain assignments of different values are an error that names both places. See the Stav merging rules for the full order.
Checking and inspecting
laft check # type check, validate and resolve packages
laft check ./web.stav # type check one file on its own
laft eval services.sshd # print the evaluated value at a path
laft eval -o toml boot # the same, as TOML or YAML
laft options services # list the options below a path
laft fmt # format every .stav file of the configuration
laft plan # show what laft apply would changelaft options reads only the schema, so it works on any machine, with or without a configuration. A field such as group, whose type is Ref<Group>, names a declared group and has no options of its own:
$ laft options users.accounts
users.accounts Map<Str, User> Users. Block label: the user name.
users.accounts.<name>.uid Option<Int where 0..=65534> Allocated when none.
users.accounts.<name>.group Option<Ref<Group>> The primary group. none: a group with the user's name, created when
users.accounts.<name>.groups Set<Ref<Group>> Supplementary groups.
users.accounts.<name>.description Str The comment field of /etc/passwd.
users.accounts.<name>.home Option<Str where matches(r"/.*")> none: /home/<name>, or /var/empty for system accounts.
users.accounts.<name>.create_home Option<Bool> none: true for regular accounts, false for system accounts.
users.accounts.<name>.shell Option<Str where matches(r"/.*")> none: /usr/bin/bash, or /usr/bin/nologin for system accounts.
users.accounts.<name>.system Bool System accounts take their ids from the system range.
users.accounts.<name>.password_hash Option<Str where matches(r"!|\*|!?\$(y|gy|6|5|2b)\$.+")> A crypt(3) hash, or "!" to lock the account.
users.accounts.<name>.password_file Option<Str where matches(r"/.*")> The absolute path of a file holding the hash, read at activation.
users.accounts.<name>.ssh_keys List<Str> OpenSSH public keys that may log in as the user.Errors carry a code and point at the source:
error[E0802]: `system.services.web.user` refers to `www`, which is not a declared `user`
┌─ /etc/laft/system.stav:8:12
│
8 │ user = www
│ ^^^
│
= note: references to `User` name the blocks of `user`
error[E0802]: the configuration has 1 errorCodes that start with E come from Stav. Laft adds its own checks after evaluation, with codes that start with C, and laft explain <code> prints what each means and how to fix it. They are all listed in Diagnostic codes.
laft reads /etc/laft/system.stav unless --config or $LAFT_CONFIG names another entry file. Imports and includes stay inside the directory of the entry file.