Skip to content

About

s6-linux-init(as stage0) + shepherd(as init)

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

s6-linux-init + Shepherd boot configuration

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

Introduction

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.

The three layers

+--------------------------------------------+
|         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 -I

Shepherd’s graph

Shepherd 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

Add a service (template + worked example)

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:

  1. Add the file (zero-padded NN sorts before 28-boot-stages).
  2. Pick real provides (a list of symbols; not a string, not a list-in-a-list) and the minimal #:requirement.
  3. For daemons: argv list + #:log-file + make-kill-destructor + #:respawn? #t.
  4. For one-shots: use a pure Guile (lambda _ (zero? (system* ...))) or ~run-or-ignored~ + #:one-shot? #t + #:stop (const #t).
  5. Wire it into a stage: edit the #:requirement of interactive or fully-online’s stage in 28-boot-stages if it should be part of one.[fn:addto]
  6. Reload: herd load init.scm. Shepherd restarts only what changed; already-running services are untouched.

Template for daemon(ssh, etc.)

Template for One-Shots (mkdir, swapon, sysctl, etc.):

example: syncthing as a system service ([fn:syncthing]):

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]

Runtime model (what you observe at boot)

  • /etc/shepherd/init.scm loads services.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 #:start throws 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-ready is the barrier behind which gettys wait; fully-online is everything else that is allowed to lag.
  • On shutdown, services run their #:stop thunks 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.

Layout

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

User Shepherd (Graphical Session)

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.

Session Flow

  1. init.scm sets XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS.
  2. 01-runtime-dirs ensures the runtime directory exists with strict 0700 permissions.
  3. 02-dbus, 03-pipewire, and 04-gpg-agent start in parallel (no GUI dependency).
  4. 05-sway starts the compositor (depends on dbus and runtime-dirs).
  5. 06-graphical-session barrier resolves once Sway is running.
  6. ALL GUI services (dunst, portals, swayidle, clipman, etc.) start in parallel.

Installation & Bring Up

# 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 -I

Managing the Session

Because 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

Install & bring up (manual)

# 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 default

Appendix: configuration reference (source of truth)

This 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

System Shepherd entry point

System service definitions

User Shepherd entry point

User Shepherd service definitions

s6-linux-init skel files

Commands

Acknowledge

Footnotes

[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.

About

s6-linux-init(as stage0) + shepherd(as init)

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages