Usage: this is a personal boot configuration for one host. It hard-codes a disk UUID, a user, Unix groups, the Guix store layout, and specific Shepherd/Guix generations. It is not a portable installer. Borrow individual services at your own risk and audit them first.
AI assistance: produced with opencode, model =poolside/laguna-s-2.1= No warranty is provided.
License: all prose and documentation in this repository are CC0 1.0 Universal (no rights reserved). The Scheme, shell, and s6-linux-init scripts are licensed GPL-3.0-or-later. The same license as GNU Shepherd and Guile, keeping derivatives compatible with the rest of the stack. SPDX-License-Identifier: CC0-1.0 OR GPL-3.0-or-later.
Source: you can view in gh:shepherd-void
This is the only documentation this boot stack needs. It describes, end to end,
how a single Void Linux root with a Guix-installed Shepherd 0.10.5 (and a
Guile 3.0.9) comes up, and how to add a new service to it.[fn:why-not]
A machine has one service manager, not three. Here it is #4, with s6 reduced to the smallest thing that makes that possible.
+--------------------------------------------+
| Layer 1: dracut initramfs |
| hardware discovery, storage, mount root |
+--------------------------------------------+
|
|
v
+--------------------------------------------+
| Layer 2: s6-linux-init |
| mount @gnu, @log, mkdir control dirs |
+--------------------------------------------+
|
|
v
+--------------------------------------------+
| Layer 3: GNU Shepherd 0.10.5 |
| root-rw, pseudo-filesystems, cgroups, udev |
+--------------------------------------------+
S6’s only job is Layer 2: bring the store and runtime writable, then hand
control to Shepherd with (exec). Shepherd then runs the whole graph; s6 does
not manage services.
#!/bin/sh -e
# s6 stage 0 -> Shepherd handoff. Regenerate with:
# s6-linux-init-maker -b /etc/s6-linux-init/skel <more options>
DISK_UUID=abcd-...
# --- 1. mount what Shepherd/store need (idempotent) ---
mountpoint -q /gnu || mount -t btrfs -o subvol=@/@gnu,noatime /dev/disk/by-uuid/$DISK_UUID /gnu \
|| mount -t btrfs -o subvol=@gnu,noatime /dev/disk/by-uuid/$DISK_UUID /gnu
mountpoint -q /var/log || mount -t btrfs -o subvol=@log,noatime /dev/disk/by-uuid/$DISK_UUID /var/log
mountpoint -q /var/tmp || mount -t btrfs -o subvol=@tmp,noatime /dev/disk/by-uuid/$DISK_UUID /var/tmp
mountpoint -q /var/cache || mount -t btrfs -o subvol=@cache,noatime /dev/disk/by-uuid/$DISK_UUID /var/cache
# --- 2. exec Shepherd (PID 1) ---
SHEPHERD=/usr/local/bin/shepherd # or /gnu/current-guix-profile/bin/shepherd
[ -x "$SHEPHERD" ] || exec /sbin/agetty --noclear -a root tty1 linux
mkdir -p /run/shepherd
"$SHEPHERD" -c /etc/shepherd/init.scm -s /run/shepherd/socket \
-l /var/log/shepherd.log -IShepherd does not have a “boot script”. It has a dependency graph and a target symbol that means “everything that must be up”; starting that symbol resolves and runs the whole graph in parallel.
The targets live in services.d/28-boot-stages.scm [fn:stages]
;; 28-boot-stages.scm --- named barriers for the boot graph.
(define boot-critical
'(root-rw pseudo-filesystems runtime-directories
static-device-nodes kernel-modules cgroups udev udev-settle
file-systems tmpfs swap sysctl dmesg guix-daemon))
(define boot-ready
'(boot-critical hostname log-files syslog random-seed
console-setup timezone hwclock))
(define interactive
'(boot-ready agetty-tty1 agetty-tty2 agetty-tty3
agetty-tty4 agetty-tty5 agetty-tty6))
(define fully-online
'(interactive loopback dbus polkit elogind seatd acpid
network-manager zram openssh chrony))Boot DAG (what actually gates the gettys):
root-rw
───┬──────┬──────────────┐
pseudo-filesystems cgroups runtime-dirs
(proc sys run dev ┌──cgroup2 (mkdir /run, /run/user
dev-pts dev-shm └──cgroup-v1 runtime-dirs)
securityfs mqueue │
efivarfs) │
udev ─ udev-settle ─────────┤
│ static-device-nodes │
file-systems ─ swap ────────────┤
│ hostname │
log-files ─ syslog ─────────────┤
kernel-modules ─ random-seed ─────┤
│ │
boot-ready ─ agetty-tty1..6 ─┤
│ │
interactive ─ loopback ─ dbus ─────┤
│ polkit │ │
fully-online ─ network-manager ───┘
seatd · acpid · zram · ssh · chrony
Every service is a (service '(provides) #:requirement '(...) #:start ...).
That is the configuration: declare what it is, what it waits on, and what
it does, and never a serial start ladder.
Checklist:
- Add the file (zero-padded
NNsorts before 28-boot-stages). - Pick real provides (a list of symbols; not a string, not a list-in-a-list)
and the minimal
#:requirement. - For daemons: argv list +
#:log-file+make-kill-destructor+#:respawn? #t. - For one-shots: use a pure Guile
(lambda _ (zero? (system* ...)))or~run-or-ignored~+#:one-shot? #t+#:stop (const #t). - Wire it into a stage: edit the
#:requirementofinteractiveorfully-online’sstagein 28-boot-stages if it should be part of one.[fn:addto] - Reload:
herd load init.scm. Shepherd restarts only what changed; already-running services are untouched.
To enable: cp the file into /etc/shepherd/services.d/, add syncthing to
the fully-online stage requirements in 28-boot-stages.scm, then herd load
init.scm. A new syncthing service appears in herd status with no Shepherd
restart.[fn:syncthingurl]
/etc/shepherd/init.scmloadsservices.d/*.scm, then(start-in-the-background '(fully-online)).- Shepherd resolves requirements breadth-first and starts them concurrently
where the graph allows.[fn:par] You see interleaved
Starting service X/Service X has been started.lines in/var/log/shepherd.log. - A service whose
#:startthrows is marked failed; dependents are skipped / retried, logged, and the boot continues on other branches. one bad daemon never hangs the whole boot.[fn:fail] boot-readyis the barrier behind which gettys wait;fully-onlineis everything else that is allowed to lag.- On shutdown, services run their
#:stopthunks in reverse-dependency order (e.g.,seedrng save,hwclock --systohc, cleaning/tmp).
$ herd status # on the running machine
root (running) uptime 00:00:47
udev (running) uptime 00:00:46
syslog (running) uptime 00:00:45
boot-ready (stopped) start it to unblock login
agetty-tty1 (running) uptime 00:00:44
fully-online (stopped) start it to bring the rest
$ tail -f /var/log/shepherd.log # the spinner feeds this
2026-... Starting service udev...
2026-... Service udev has been started.
shepherd/
├── readme.org <- you are here
├── init.scm <- Shepherd config entry point
├── services.d/
│ ├── 00-utilities.scm <- shared helpers (mounted?, read-rc-conf-var)
│ ├── 01-root.scm <- root-rw remount
│ ├── 02-pseudo-fs.scm <- proc/sys/run/dev/+
│ ├── 02-tmpfs.scm <- mount /tmp as tmpfs
│ ├── 03-runtime.scm <- early runtime directories
│ ├── 03-console.scm <- KEYMAP/FONT from /etc/rc.conf
│ ├── 04-cgroups.scm <- unified | hybrid | legacy
│ ├── 05-static-devnodes.scm
│ ├── 06-kernel-modules.scm
│ ├── 07-udev.scm <- udev + udev-settle
│ ├── 08-file-systems.scm <- mount -a
│ ├── 09-hostname.scm
│ ├── 10-sysctl.scm
│ ├── 10-timezone.scm <- /etc/localtime symlink
│ ├── 11-hwclock.scm <- RTC sync on boot/shutdown
│ ├── 12-dmesg.scm
│ ├── 13-random-seed.scm <- seedrng (now saves on shutdown)
│ ├── 14-log-files.scm <- utmp/wtmp + socklog spool
│ ├── 15-syslog.scm <- socklog -> /dev/log
│ ├── 16-guix-daemon.scm
│ ├── 17-getty.scm <- agetty-tty1..6 (--skip-login, --noissue)
│ ├── 18-loopback.scm
│ ├── 19-dbus.scm <- dbus + dbus-runtime dir
│ ├── 20-network.scm <- NetworkManager
│ ├── 21-polkit.scm
│ ├── 22-elogind.scm <- D-Bus-activated, one-shot pin
│ ├── 23-seatd.scm
│ ├── 24-acpid.scm
│ ├── 25-zram.scm <- zram swap (pure Guile sysfs writes)
│ ├── 26-openssh.scm
│ ├── 27-chrony.scm
│ └── 28-boot-stages.scm <- boot-critical -> boot-ready -> interactive -> fully-online
├── s6-linux-init/
│ └── skel/rc.init <- stage-0 handoff (edit here)
├── user-shepherd
│ ├── init.scm
│ └── services.d
│ ├── 00-utilities.scm
│ ├── 01-runtime-dirs.scm
│ ├── 02-dbus.scm
│ ├── 03-pipewire.scm
│ ├── 04-gpg-agent.scm
│ ├── 05-sway.scm
│ ├── 06-graphical-session.scm
│ ├── 07-portals.scm
│ ├── 08-dunst.scm
│ ├── 09-swayidle.scm
│ ├── 10-sway-audio-idle-inhibit.scm
│ ├── 11-wlsunset.scm
│ ├── 12-clipman.scm
│ └── 13-azotebg.scm
While the system Shepherd manages the machine, the User Shepherd manages your
graphical session (Sway, PipeWire, D-Bus, etc.). It runs as your regular user,
supervising Wayland compositors and desktop daemons without needing systemd
--user.
init.scmsetsXDG_RUNTIME_DIRandDBUS_SESSION_BUS_ADDRESS.01-runtime-dirsensures the runtime directory exists with strict0700permissions.02-dbus,03-pipewire, and04-gpg-agentstart in parallel (no GUI dependency).05-swaystarts the compositor (depends ondbusandruntime-dirs).06-graphical-sessionbarrier resolves once Sway is running.- ALL GUI services (
dunst,portals,swayidle,clipman, etc.) start in parallel.
# 1. Copy user Shepherd config and services to your home directory
mkdir -p ~/.config/shepherd/services.d
cp user-shepherd/init.scm ~/.config/shepherd/init.scm
cp user-shepherd/services.d/*.scm ~/.config/shepherd/services.d/
# 2. Ensure you are in the 'seatd' group (required for Sway to access input/DRM)
sudo usermod -aG _seatd $USER
# (Log out and log back in for group changes to take effect)
# 3. Start the user Shepherd daemon
shepherd -s /run/shepherd/$(id -u).socket \
-c ~/.config/shepherd/init.scm -IBecause every component is a first-class Shepherd service, you can manage them individually without restarting the whole session:
# Restart just the compositor (apps using DBus/PipeWire will survive!)
herd restart sway
# Check audio stack status
herd status pipewire wireplumber pipewire-pulse
# View compositor logs
tail -f ~/.local/state/shepherd/sway.log# 1. Shepherd config -> where rc.init expects it
cp services.d/*.scm /etc/shepherd/services.d/
cp init.scm /etc/shepherd/init.scm
# 2. Shepherd binary visible to rc.init (one of:)
ln -s $(guix build shepherd)/bin/shepherd /usr/local/bin/shepherd # Guix
# or: ln -s $(which shepherd) /usr/local/bin/shepherd # Void
# 3. s6 stage 0 from the skel (regenerates current/scripts/rc.init)
s6-linux-init-maker -b /etc/s6-linux-init/skel \
-u shepherd -g shepherd \
-s /run/s6-svscan \
-m /etc/s6-linux-init/monitor
# 4. Boot it (or reboot)
exec /etc/s6-linux-init/current/scripts/rc.init defaultThis appendix embeds the actual Scheme configuration so this readme, carries the
source of truth alongside the prose above. Each file leads with a header comment
describing its role, and services carry a #:documentation string
[fn:why-not] Why not just use Guix System’s =operating-system= /services?
Because owning dracut -> s6 -> Shepherd explicitly makes every failure mode
legible and avoids Guix SD’s implicit service graph. This repo is that explicit graph.
[fn:stages] Shepherd has no runlevel. A stage here is a service whose #:start is (const #t)
i.e. a named barrier whose only purpose is its #:requirement, so the DAG is readable from one file.
See info:(shepherd)Service Types and services.d/28-boot-stages.scm.
[fn:addto] The stage #:requirement list is the “what must be up before X is considered up” contract.
Add your symbol there to make it part of an ordered phase; leave it out and it only starts if something
else pulls it in.
[fn:syncthing] Syncthing is a fine fully-online citizen: network-bound, not login-critical,
restart-on-crash via #:respawn? #t. The example intentionally uses the system instance;
for a desktop session move it to user-shepherd, set HOME and XDG_CONFIG_HOME, and require seatd.
[fn:syncthingurl] syncthing/README · its systemd unit network-online.target + local-fs.target
maps exactly to network-manager + file-systems here.
[fn:par] start-in-the-background (from services.d/init.scm) spawns a fiber that calls
start-in-parallel: each node’s #:requirement starts first, and siblings with no edge
between them start concurrently. Shepherd manual §”Dependency resolution”.
[fn:fail] Failures are per-service. Shepherd logs Exception caught while starting X to
/var/log/shepherd.log, marks the service failed, and continues other branches rather than
aborting the boot. That is intentional: a one-shot random-seed failing must not stop udev.