s6-linux-init + Shepherd boot configuration
Warning: 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
1. 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.1
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.
A machine has one service manager, not three. Here it is #4, with s6 reduced to the smallest thing that makes that possible.
guix build shepherd resolves to the store path of Shepherd; the symlink keeps
/usr/local/bin/shepherd stable across profile generations. guix build See the
Guix manual for details on profile generation stability.
2. 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
The generator writes current/scripts/rc.init from skel/rc.init. Edit the
skel copy and re-run s6-linux-init-maker. never patch current directly.
3. 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 2
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.
;; 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
The solid lines are #:requirement edges. Gettys depend on boot-ready, not on
network/D-Bus/desktop, so a slow link or GUI never blanks the login prompt.
udev is eudev from Void (Shepherd just supervises it); device nodes are
pre-seeded by 05-static-devnodes.scm for udev’s very first run. Udev
(ArchWiki) covers the rules format.
4. 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:
- 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.3 The stage#:requirementlist 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. - Reload:
herd load init.scm. Shepherd restarts only what changed; already-running services are untouched.
4.1. Template for daemon(ssh, etc.)
;; NN-name.scm --- one line.
;; Why it exists: <sentence>. Why that dependency list: <edges>.
(define name
(service '(name alias)
#:documentation "Human sentence."
#:requirement '(the-services-it-cannot-run-without)
#:start (make-forkexec-constructor '("name" "args" …)
#:log-file "/var/log/name.log")
#:stop (make-kill-destructor)
#:respawn? #t)) ; one-shot? #t for transient steps
(register-services (list name))
4.2. Template for One-Shots (mkdir, swapon, sysctl, etc.):
;;; NN-name.scm --- one-shot setup task.
(define name
(service '(name)
#:documentation "Human sentence."
#:requirement '(the-services-it-cannot-run-without)
#:start (lambda _
;; Pure Guile: no shell scripts, no make-system-constructor
(zero? (system* "command" "arg1" "arg2")))
#:stop (const #t)
#:one-shot? #t))
(register-services (list name))
4.3. example: syncthing as a system service (4
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.
):
;;; 31-syncthing-example.scm --- Syncthing daemon.
;; Example service for the "how to add a service" guide in readme.org.
;; It is kept under examples/, NOT services.d/, so it is registered only when
;; you deliberately copy it in:
;;
;; cp examples/31-syncthing-example.scm /etc/shepherd/services.d/
;; # then add `syncthing' to the `fully-online' stage in 28-boot-stages.scm
;; herd load init.scm
;;
;; Syncthing is a good fully-online citizen: network-bound, not login-critical,
;; and restart-on-crash via #:respawn?. Its systemd unit ties it to
;; `network-online.target` + `local-fs.target`, which here map to
;; `network-manager` + `file-systems`.
(define syncthing
(service '(syncthing)
#:documentation "Syncthing file synchronizer (system instance)."
#:requirement '(network-manager file-systems)
#:start (make-forkexec-constructor
'("/usr/bin/syncthing" "-no-browser" "-gui-address=0.0.0.0:8384")
#:log-file "/var/log/syncthing.log")
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list 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.5
syncthing/README · its systemd unit network-online.target + local-fs.target
maps exactly to network-manager + file-systems here.
Syncthing ships a systemd unit that does the same network-online + filesystem
dance; this is the manual port. For a per-user GUI session, put the same service
under user-shepherd/services.d/ with GUIX_PROFILE set.
5. Runtime model (what you observe at boot)
/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.6
start-in-the-background(fromservices.d/init.scm) spawns a fiber that callsstart-in-parallel: each node’s#:requirementstarts first, and siblings with no edge between them start concurrently. Shepherd manual §“Dependency resolution”. You see interleavedStarting 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.7 Failures are per-service. Shepherd logsException caught while starting Xto/var/log/shepherd.log, marks the service failed, and continues other branches rather than aborting the boot. That is intentional: a one-shotrandom-seedfailing must not stopudev. 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 also exposes herd = the CLI. =herd help, herd doc <service>, and
[[info:(shepherd)Invoking shepherd][info:(shepherd)]] cover every flag.
6. 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
7. 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.
7.1. Session Flow
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.
7.2. 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
To start User Shepherd automatically on TTY login, add this to your
~/.bash_profile or ~/.profile: if [ -z "$WAYLAND_DISPLAY" ] && [
"$XDG_VTNR" -eq 1 ]; then exec shepherd -I; fi
7.3. 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
8. 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
guix build shepherd resolves to the store path of Shepherd; the symlink keeps
/usr/local/bin/shepherd stable across profile generations. guix build
9. 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
9.1. System Shepherd entry point
(define-module (shepherd config) #:declarative? #f)
(use-modules ((ice-9 ftw) #:select (scandir))
(srfi srfi-1))
;; Where this configuration lives. Default to the standard location; the
;; s6 ~rc.init~ script points Shepherd here via ~-c /etc/shepherd/init.scm~.
(define %services-dir
(or (getenv "SHEPHERD_SERVICES_DIR")
"/etc/shepherd/services.d"))
;; 1. Load every *.scm (alphabetically) from the services directory.
;; Each file registers its own services; ~#:requirement~ may reference a
;; service defined in an earlier-loaded file (dependencies are resolved
;; lazily at start time, so load order only needs shared helpers first --
;; which is why 00-utilities.scm sorts before everything else). The stage
;; targets (boot-ready, interactive, fully-online, ...) live in
;; ~28-boot-stages.scm~.
(define (load-services dir)
(let ((files (scandir dir
(lambda (f)
(and (string-suffix? ".scm" f)
(not (string-prefix? "." f)))))))
;; Guard: warn about old-style (unnumbered) files that shadow numbered
;; ones. Digits sort before letters in ASCII (0x30 < 0x61), so
;; 15-syslog.scm loads BEFORE socklog.scm. The unnumbered file then
;; silently overrides the canonical definition -- this was the root cause
;; of the socklog 127 / respawn-too-fast cascades on the Void host.
(let ((stale (filter (lambda (f)
(not (char-numeric? (string-ref f 0))))
files)))
(when (pair? stale)
(format #t "WARNING: old-style (unnumbered) service files shadow numbered files:\n ~a\nThese load AFTER the numbered files and silently override them.\nRemove them: rm ~a/*.scm (the unnumbered ones)\n\n"
(string-join stale ", ") dir)))
(for-each
(lambda (file)
(load (string-append dir "/" file)))
files)))
(load-services %services-dir)
;; 2. Self-check policy.
;; Shepherd 0.10.x exposes no public "list all services" API from a config
;; file (the registry is channel-based and private), so a static
;; "missing-requirements" scan cannot be done here inline. Instead:
;; * graph integrity (no cycles, no dangling requirements) is verified by
;; the off-line validator (~validate-guile.scm~), run by the author;
;; * at boot, ~start-in-the-background~ resolves the ~fully-online~ graph
;; and Shepherd throws/logs an ~unresolved requirement' (or
;; ~Unregistered service') to /var/log/shepherd.log for any symbol that
;; is required but never provided -- i.e. boot still fails fast and
;; loud, just one layer inside the daemon.
;; This keeps init.scm free of version-coupled internal accessors.
;; 3. Kick off boot by resolving the ~fully-online~ target's graph. Placed at
;; the end of init.scm on purpose: Shepherd evaluates this file once at
;; startup (launched from s6 stage 0) and starts the machine. ~start-in-the
;; background' returns immediately so the control socket is served right
;; away; already-running services are left untouched on a later
;; ~herd load init.scm~. (shepherd/service) exports start-in-the-background,
;; NOT a bare ~start'.
(unless (equal? (getenv "SHEPHERD_NO_AUTOBOOT") "1")
(start-in-the-background '(fully-online)))
9.2. System service definitions
;;; 00-utilities.scm --- shared helpers (loaded first; registers nothing).
;; Because ~scandir~ in init.scm loads every *.scm file, these helpers are
;; available to every service below. They encode the small, repeated pieces
;; (mount-option formatting, idempotent mountpoint checks) so that individual
;; service files read like declarative data, not shell.
(use-modules (srfi srfi-1) ; filter
(shepherd service))
(define (opts->string opts)
"Render ~('nosuid', 'nodev', ...)~ as ~-o nosuid,nodev~.
The empty / ~('default')~ list renders as ''."
(if (or (null? opts) (equal? opts '("defaults")))
""
(string-append " -o " (string-join opts ","))))
(define (mountpoint-q? target)
"Return #t when TARGET is already a mount point."
(zero? (system (string-append "mountpoint -q " target))))
(define (ensure-dir dir)
"Create DIR and parents."
(zero? (system* "mkdir" "-p" dir)))
(define (run-or-ignored command . args)
"Run the primitive COMMAND (an absolute path) with ARGS, treating absence or
a non-zero exit as success -- i.e. the Scheme equivalent of the shell idiom
~(command ... 2>/dev/null || true)' used for best-effort one-shots such as
swapon or seedrng. Returns a truthy value so Shepherd records success."
(catch 'system-error
(lambda () (apply system* command args))
(lambda _ #t)))
(define (mounted? dir)
(catch 'system-error
(lambda ()
(let ((st-dir (stat dir))
(st-parent (stat (dirname dir))))
(not (= (stat:dev st-dir) (stat:dev st-parent)))))
(lambda args
;; If the directory doesn't exist yet, it's not mounted.
#f)))
(define* (pseudo-fs-service name
#:key
source
target
type
(options '())
(requirement '()))
(service
(list name)
#:documentation
(format #f "Mount pseudo-filesystem ~a on ~a~a."
type target
(if (null? options) ""
(format #f " (options: ~{~a~^,~})" options)))
#:requirement requirement
#:start
(lambda _
;; Build the mount command arguments purely as a Scheme list.
(let ((args (append `("-t" ,type)
(if (null? options)
'()
`("-o" ,(string-join options ",")))
`(,source ,target))))
;; Attempt to mount. If it fails, check if it's already mounted.
;; This makes the service perfectly idempotent without shell scripts.
(or (zero? (apply system* "mount" args))
(mounted? target))))
#:stop (const #t)
#:one-shot? #t))
;; A descriptive, non-working target service grouping several requirements.
;; Used purely to give the boot graph readable named layers.
(define* (stage name documentation requirements)
;; If name is already a list, use it; otherwise, wrap the symbol in a list.
(service (if (list? name) name (list name))
#:documentation documentation
#:requirement requirements
#:start (const #t)
#:stop (const #t)
#:one-shot? #t))
(define (read-rc-conf-var var)
"Read a variable from /etc/rc.conf (e.g., KEYMAP='us')."
(catch 'system-error
(lambda ()
(call-with-input-file "/etc/rc.conf"
(lambda (port)
(let loop ((line (read-line port)))
(cond
((eof-object? line) #f)
((string-prefix? (string-append var "=") (string-trim-both line))
(let* ((val (substring line (+ (string-length var) 1)))
(val (string-trim-both val)))
;; Strip surrounding quotes if present
(if (and (>= (string-length val) 2)
(char=? (string-ref val 0) #\")
(char=? (string-ref val (- (string-length val) 1)) #\"))
(substring val 1 (- (string-length val) 1))
val)))
(else (loop (read-line port))))))))
(lambda args #f)))
;;; 01-root.scm --- root filesystem remount.
;; ~root-rw~ is intentionally the earliest service: it has NO requirements.
;; The previous config made it depend on ~hostname~, but root-rw -> hostname
;; is not semantically justified (the hostname is set from /etc which lives
;; on root, and nothing on root-rw depends on the hostname). Instead,
;; pseudo-filesystem services depend on ~root-rw~, expressing the real edge.
(define root-rw
(service '(root-rw root-filesystem)
#:documentation "Remount the root file system read/write."
#:requirement '()
#:start (lambda _
(setenv "LIBMOUNT_FORCE_MOUNT2" "always")
(or (zero? (system* "mount" "-o" "remount,rw" "/"))
#t))
#:stop (const #t) ;; Let the kernel handle ro remount on poweroff
#:one-shot? #t))
(register-services (list root-rw))
;;; 02-pseudo-fs.scm --- independent pseudo-filesystem services.
;; Replaces the old monolithic ~pseudo-filesystems~ service. Each pseudo
;; filesystem is its own service so they mount in parallel and so a service
;; can depend on exactly the one it needs (e.g. agetty needs dev-pts).
;;
;; Dependency graph (all under root-rw, all parallelizable):
;; root-rw
;; ├── proc
;; ├── sys └── securityfs
;; ├── run
;; └── dev ── dev-pts / dev-shm (+ mqueue, efivarfs under sys/dev)
(define pseudo-fs-proc
(pseudo-fs-service 'proc
#:source "proc" #:target "/proc" #:type "proc"
#:options '("nosuid" "nodev" "noexec")
#:requirement '(root-rw)))
(define pseudo-fs-sys
(pseudo-fs-service 'sys
#:source "sysfs" #:target "/sys" #:type "sysfs"
#:options '("nosuid" "nodev" "noexec")
#:requirement '(root-rw)))
(define pseudo-fs-run
(pseudo-fs-service 'run
#:source "run" #:target "/run" #:type "tmpfs"
#:options '("mode=0755" "nosuid" "nodev" "size=32M")
#:requirement '(root-rw)))
(define pseudo-fs-dev
(pseudo-fs-service 'dev
#:source "devtmpfs" #:target "/dev" #:type "devtmpfs"
#:options '("nosuid" "mode=755")
#:requirement '(root-rw)))
(define pseudo-fs-dev-pts
(pseudo-fs-service 'dev-pts
#:source "devpts" #:target "/dev/pts" #:type "devpts"
#:options '("mode=0620" "gid=5" "nosuid" "noexec")
#:requirement '(dev)))
(define pseudo-fs-dev-shm
(pseudo-fs-service 'dev-shm
#:source "shm" #:target "/dev/shm" #:type "tmpfs"
#:options '("mode=1777" "nosuid" "nodev")
#:requirement '(dev)))
(define pseudo-fs-securityfs
(pseudo-fs-service 'securityfs
#:source "securityfs" #:target "/sys/kernel/security" #:type "securityfs"
#:options '("nosuid" "nodev" "noexec")
#:requirement '(sys)))
(define pseudo-fs-mqueue
(service '(mqueue)
#:documentation "Mount /dev/mqueue."
#:requirement '(dev)
#:start (lambda _
(system* "mkdir" "-p" "/dev/mqueue")
(zero? (system* "mount" "-t" "mqueue" "none" "/dev/mqueue")))
#:stop (const #t)
#:one-shot? #t))
(define pseudo-fs-efivarfs
(service '(efivarfs)
#:documentation "Mount efivarfs (UEFI/FAT EFI only)."
#:requirement '(sys)
#:start (lambda _
(if (file-exists? "/sys/firmware/efi/efivars")
(begin
(system* "mkdir" "-p" "/sys/firmware/efi/efivars")
(zero? (system* "mount" "-t" "efivarfs" "efivarfs" "/sys/firmware/efi/efivars")))
#t)) ;; Safe no-op on BIOS
#:stop (const #t)
#:one-shot? #t))
;; Descriptive overlay target used by the boot stages.
(define pseudo-filesystems
(stage 'pseudo-filesystems
"All early pseudo-filesystems are mounted."
'(proc sys run dev dev-pts dev-shm securityfs mqueue efivarfs)))
(register-services
(list pseudo-fs-proc pseudo-fs-sys pseudo-fs-run pseudo-fs-dev
pseudo-fs-dev-pts pseudo-fs-dev-shm pseudo-fs-securityfs
pseudo-fs-mqueue pseudo-fs-efivarfs
pseudo-filesystems))
;;; 06-tmpfs.scm --- mount /tmp as tmpfs.
(define tmpfs-tmp
(service '(tmpfs tmp)
#:documentation "Mount /tmp as a tmpfs filesystem."
#:requirement '(root-rw)
#:start (lambda _
;; Ensure the mount point exists
(system* "mkdir" "-p" "/tmp")
;; Mount with strict security and 50% RAM limit
(and (zero? (system* "mount" "-t" "tmpfs"
"-o" "mode=1777,nosuid,nodev,size=50%"
"tmpfs" "/tmp"))
;; Belt-and-suspenders: enforce permissions
(zero? (system* "chmod" "1777" "/tmp"))))
#:stop (lambda _ (zero? (system* "umount" "/tmp")))
#:one-shot? #t))
(register-services (list tmpfs-tmp))
(define console-setup
(service '(console-setup)
#:documentation "Set console font and keyboard map from /etc/rc.conf."
#:requirement '(dev)
#:start (lambda _
(let ((keymap (read-rc-conf-var "KEYMAP"))
(font (read-rc-conf-var "FONT")))
(when keymap
(system* "loadkeys" "-q" "-u" keymap))
(when font
(system* "setfont" font)))
#t)
#:stop (const #t)
#:one-shot? #t))
(register-services (list console-setup))
;;; 03-runtime.scm --- runtime directory creation (mounts live elsewhere).
;; Mounting and runtime-directory creation are separate responsibilities, so
;; this service only creates the well-known /run and /var/log runtime spots
;; that several daemons depend on. It performs no mount.
(define runtime-directories
(service '(runtime-directories)
#:documentation "Create early runtime directories under /run, /var/log."
#:requirement '(run root-rw)
#:start (lambda _
(apply system* "mkdir" "-p"
'("/run/runit" "/run/udev" "/run/systemd" "/run/user" "/run/lock"
"/run/shepherd" "/var/log/shepherd" "/var/log/socklog"))
#t)
#:stop (const #t)
#:one-shot? #t))
(register-services (list runtime-directories))
;;; 04-cgroups.scm --- cgroup hierarchy mounting.
;; The old setup shipped a large shell block switching on CGROUP_MODE in the
;; middle of a ~while read~ loop over /proc/cgroups. Here the mode decision
;; and the controller enumeration live in Scheme; only each individual ~mount~
;; is a single small shell command guarded by an idempotency check.
(use-modules (srfi srfi-1) ; filter, filter-map
(shepherd service))
;; mkdir-p is NOT exported by (ice-9 ftw) in Guile 3.0.9 from Guix store.
;; Use system* "mkdir" "-p" instead — this is the robust, portable approach.
(define (ensure-dir dir)
(zero? (system* "mkdir" "-p" dir)))
(define %cgroup-mode
;; Mirrors Void's /etc/rc.conf CGROUP_MODE (default: unified; this box).
(or (getenv "CGROUP_MODE") "unified"))
(define (mountpoint-q? target)
(zero? (system (string-append "mountpoint -q " target))))
(define (enabled-cgroup-v1-controllers)
"Return the names of cgroup v1 controllers flagged ~enabled=1~ in
/proc/cgroups. Returns the empty list when the file is absent."
(catch 'done
(lambda ()
(let ((port (false-if-exception (open-input-file "/proc/cgroups"))))
(if port
(let ((lines '()))
(do ((line (get-line port) (get-line port)))
((eof-object? line) (set! lines (reverse lines)))
(set! lines (cons line lines)))
(close-input-port port)
(filter-map
(lambda (line)
(let ((f (filter (lambda (s) (not (string=? s "")))
(string-split line char-whitespace))))
(and (>= (length f) 4)
(string=? (cadddr f) "1")
(car f))))
lines))
(throw 'done '()))))
(lambda _ '())))
(define (mount-node type source target opts)
"Mount a cgroup node, idempotently: skip when already a mount point."
(unless (mountpoint-q? target)
(ensure-dir target)
(system (string-append "mount -t " type
(if (null? opts) ""
(string-append " -o " (string-join opts ",")))
" " source " " target))))
(define (cgroup2-boot)
"Mount the cgroup v2 hierarchy on unified and hybrid modes (no-op legacy)."
(when (not (string=? %cgroup-mode "legacy"))
(mount-node "cgroup2" "cgroup2" "/sys/fs/cgroup" '("nsdelegate"))))
(define (cgroup-v1-boot)
"Mount cgroup v1 tmpfs + per-controller hierarchies (legacy/hybrid only)."
(when (member %cgroup-mode '("legacy" "hybrid"))
(unless (mountpoint-q? "/sys/fs/cgroup")
(ensure-dir "/sys/fs/cgroup")
(system "mount -t tmpfs cgroup_root /sys/fs/cgroup"))
(for-each
(lambda (controller)
(mount-node "cgroup" controller
(string-append "/sys/fs/cgroup/" controller) '()))
(enabled-cgroup-v1-controllers))
;; systemd-tracking cgroup, for nested systemd instances.
(mount-node "cgroup" "none,name=systemd" "/sys/fs/cgroup/systemd" '())))
(define cgroup2
(service '(cgroup2 cgroup-hierarchy)
#:documentation "Mount the cgroup v2 hierarchy at /sys/fs/cgroup."
#:requirement '(sys runtime-directories)
#:start (lambda _ (cgroup2-boot) #t)
#:stop (const #t)
#:one-shot? #t))
(define cgroup-v1
(service '(cgroup-v1)
#:documentation
"Mount cgroup v1 hierarchies (legacy/hybrid only; no-op on unified)."
#:requirement '(sys runtime-directories cgroup2)
#:start (lambda _ (cgroup-v1-boot) #t)
#:stop (const #t)
#:one-shot? #t))
;; Descriptive overlay: the single ~cgroups~ name referenced by boot-ready.
(define cgroups
(stage 'cgroups "cgroup hierarchies are in place." '(cgroup2 cgroup-v1)))
(register-services (list cgroup2 cgroup-v1 cgroups))
;;; 05-static-devnodes.scm --- load modules backing static /dev nodes.
(use-modules (ice-9 popen) ;; open-pipe* close-pipe
(ice-9 rdelim) ;; read-line
(srfi srfi-13) ;; string things
)
(define (load-static-devnodes)
(let ((port (open-pipe* OPEN_READ "kmod" "static-nodes" "-f" "devname")))
(catch 'system-error
(lambda ()
(let loop ((line (read-line port)))
(unless (eof-object? line)
(let ((parts (string-split line #\space)))
(unless (null? parts)
(let ((mod (string-trim-both (car parts))))
(unless (string-null? mod)
(system* "modprobe" "-bq" mod)))))
(loop (read-line port)))))
(lambda args #f))
(catch 'system-error
(lambda () (close-pipe port))
(lambda args #f))))
(define static-device-nodes
(service '(static-device-nodes)
#:documentation "Load device drivers backing the static nodes in devtmpfs."
#:requirement '(runtime-directories)
#:start (lambda _ (load-static-devnodes) #t)
#:stop (const #t)
#:one-shot? #t))
(register-services (list static-device-nodes))
;;; 06-kernel-modules.scm --- load modules from modules-load.d + /etc/modules.
(use-modules (ice-9 rdelim) ;; read-line
(srfi srfi-13) ;; string things
)
(define (load-modules-from-file file)
(when (file-exists? file)
(call-with-input-file file
(lambda (port)
(let loop ((line (read-line port)))
(unless (eof-object? line)
(let ((trimmed (string-trim-both line)))
(unless (or (string-null? trimmed)
(string-prefix? "#" trimmed))
(system* "modprobe" trimmed)))
(loop (read-line port))))))))
(define kernel-modules
(service '(kernel-modules)
#:documentation "Load kernel modules listed by modules-load and /etc/modules."
#:requirement '(proc runtime-directories)
#:start (lambda _
(system* "modules-load")
(load-modules-from-file "/etc/modules")
#t)
#:stop (const #t)
#:one-shot? #t))
(register-services (list kernel-modules))
;;; 07-udev.scm --- device event daemon + initial enumeration.
;; udevd itself is a long-running daemon managed by Shepherd (respawn).
;; Triggering rules and settling the queue are a separate one-shot, so the
;; boot graph can express "udev is running" vs "udev finished the initial
;; device pass".
(define udev
(service '(udev)
#:documentation "udev device event-management daemon (udevd)."
#:requirement '(root-rw dev cgroup2
static-device-nodes kernel-modules runtime-directories)
#:start (make-forkexec-constructor
'("/usr/sbin/udevd" "--debug")
#:log-file "/var/log/udevd.log")
#:stop (make-kill-destructor)
#:respawn? #t))
(define udev-settle
(service '(udev-settle)
#:documentation "Trigger udev rules and wait for the queue to settle."
#:requirement '(udev)
#:start (lambda _
(system* "udevadm" "trigger" "--action=add" "--type=subsystems")
(system* "udevadm" "trigger" "--action=add" "--type=devices")
(zero? (system* "udevadm" "settle")))
#:stop (const #t)
#:one-shot? #t))
(register-services (list udev udev-settle))
;;; 08-file-systems.scm --- mount local filesystems from /etc/fstab.
;; ~mount -a~ is idempotent: already-mounted entries are skipped, so the
;; btrfs subvolumes that s6 stage 0 already mounted (home, /gnu, /var/log...)
;; are left alone. We never hard-fail boot on a single mount error (matches
;; Void's behavior for optional/media devices).
(define file-systems
(service '(file-systems)
#:documentation "Mount all non-network filesystems from /etc/fstab."
;; We depend on tmpfs so /tmp is ready before apps try to use it
#:requirement '(root-rw tmpfs run dev udev-settle)
#:start (lambda _
(zero? (system* "mount" "-a" "-t" "nosysfs,nonfs,nonfs4,nosmbfs,nocifs")))
#:stop (lambda _
(zero? (system* "umount" "-a" "-t" "nosysfs,nonfs,nonfs4,nosmbfs,nocifs")))
#:one-shot? #t))
(register-services (list file-systems))
;;; 09-hostname.scm --- set the kernel hostname from /etc/hostname.
(use-modules (ice-9 rdelim) ;; read-line
(srfi srfi-13) ;; string things
)
(define hostname
(service '(hostname)
#:documentation "Set the system hostname from /etc/hostname."
#:requirement '(root-rw)
#:start (lambda _
(let ((h (catch 'system-error
(lambda ()
(let ((line (call-with-input-file "/etc/hostname" read-line)))
(if (or (eof-object? line) (string-null? (string-trim-both line)))
"machine"
(string-trim-both line))))
(lambda args "machine"))))
(call-with-output-file "/proc/sys/kernel/hostname"
(lambda (port) (display h port)))
(system* "hostname" h))
#t)
#:stop (const #t)
#:one-shot? #t))
(register-services (list hostname))
;; 10-hwclock.scm --- hwclock set to utc
(define hwclock
(service '(hwclock)
#:documentation "Sync hardware clock with system time."
#:requirement '(root-rw)
#:start (lambda _
;; Read hardware clock and set system time.
;; Use --utc if your BIOS is set to UTC (recommended),
;; or --localtime if your BIOS is set to local time.
(system* "hwclock" "--systz" "--utc")
#t)
#:stop (lambda _
;; Write system time to hardware clock on shutdown.
(system* "hwclock" "--systohc" "--utc")
#t)
#:one-shot? #t))
(register-services (list hwclock))
;;; 10-sysctl.scm --- apply sysctl settings.
;; sysctl is a boot-critical concern (networking knobs, fs limits), but it
;; does not gate login, so it is kept out of the ~boot-ready~ barrier and may
;; run in parallel with the TTYs being brought up.
(use-modules (ice-9 ftw) ;; scandir, file-is-directory?
(srfi srfi-13) ;; string things
)
(define sysctl
(service '(sysctl)
#:documentation "Load /etc/sysctl.conf and /etc/sysctl.d/*.conf."
#:requirement '(root-rw sys file-systems)
#:start (lambda _
(define (conf-dir? dir)
(and (false-if-exception (file-exists? dir))
(false-if-exception (file-is-directory? dir))))
(for-each (lambda (dir)
(when (conf-dir? dir)
(for-each (lambda (f)
(system* "sysctl" "-p" (string-append dir "/" f)))
(scandir dir (lambda (f) (string-suffix? ".conf" f))))))
'("/etc/sysctl.d" "/run/sysctl.d" "/usr/lib/sysctl.d"))
(when (false-if-exception (file-exists? "/etc/sysctl.conf"))
(system* "sysctl" "-p" "/etc/sysctl.conf"))
#t)
#:stop (const #t)
#:one-shot? #t))
(register-services (list sysctl))
;;; 10-timezone.scm --- set timezone
(use-modules (ice-9 rdelim) ;; readline
(srfi srfi-13) ;; string
)
(define timezone
(service '(timezone)
#:documentation "Set system timezone from /etc/rc.conf or /etc/timezone."
#:requirement '(root-rw)
#:start (lambda _
(let ((tz (or (read-rc-conf-var "TIMEZONE")
(and (file-exists? "/etc/timezone")
(call-with-input-file "/etc/timezone"
(lambda (p) (string-trim-both (read-line p)))))
"UTC")))
(let ((target (string-append "/usr/share/zoneinfo/" tz)))
(when (file-exists? target)
(false-if-exception (delete-file "/etc/localtime"))
(symlink target "/etc/localtime"))))
#t)
#:stop (const #t)
#:one-shot? #t))
(register-services (list timezone))
;;; 11-swap.scm --- activate swap areas from /etc/fstab.
;; ~swapon~/~swapoff~ are primitive binaries; the only shell-like logic was the
;; ~|| true~ "don't fail boot if there is no swap". That is composition, so it
;; lives in Scheme via ~run-or-ignored~. No ~/bin/sh -c~ needed.
(define (swapon-all)
(run-or-ignored "/usr/sbin/swapon" "-a"))
(define (swapoff-all)
(run-or-ignored "/usr/sbin/swapoff" "-a"))
(define swap
(service '(swap)
#:documentation "Activate swap areas (~swapon -a~)."
#:requirement '(file-systems)
#:start swapon-all
#:stop swapoff-all
#:one-shot? #t))
(register-services (list swap))
;;; 12-dmesg.scm --- snapshot kernel ring buffer into /var/log/dmesg.log.
(use-modules (ice-9 popen) ;; open-pipe* close-pipe
(ice-9 rdelim) ;; read-line
(srfi srfi-13) ;; string things
)
(define dmesg
(service '(dmesg)
#:documentation "Save the kernel ring buffer to /var/log/dmesg.log."
#:requirement '(root-rw file-systems)
#:start (lambda _
(with-output-to-file "/var/log/dmesg.log"
(lambda () (system* "dmesg")))
(let ((restrict? (catch 'system-error
(lambda ()
(let ((port (open-pipe* OPEN_READ "sysctl" "-n" "kernel.dmesg_restrict")))
(let ((val (read-line port)))
(close-pipe port)
(string=? (string-trim-both val) "1"))))
(lambda args #f))))
(let ((mode (if restrict? "0600" "0644")))
(false-if-exception
(system* "chmod" mode "/var/log/dmesg.log"))))
#t)
#:stop (const #t)
#:one-shot? #t))
(register-services (list dmesg))
;;; 13-random-seed.scm --- credit the kernel CSPRNG early.
;; Mirrors Void's 05-misc.sh ~seedrng~. Best-effort: if seedrng is absent or
;; fails, do not block boot. Run purely in Scheme (no shell): the only logic
;; here is "try the primitive and tolerate failure", which is composition --
;; the binary itself is the primitive.
(define (seedrng-thunk)
"Credit the kernel CSPRNG. seedrng exits immediately after seeding."
(run-or-ignored "/usr/sbin/seedrng"))
(define random-seed
(service '(random-seed)
#:documentation "Credit the kernel CSPRNG with seedrng."
#:requirement '(root-rw file-systems)
#:start seedrng-thunk
#:stop (lambda _
(system* "seedrng" "save")
#t)
#:one-shot? #t))
(register-services (list random-seed))
;;; 14-log-files.scm --- utmp/wtmp/btmp/lastlog + socklog spool + dirs.
;; based on runit/core-services/99-cleanup.sh
;; Creates the accounting and log scaffolding. Per-service logs are written by
;; Shepherd to their own ~#:log-file~ (and Shepherd auto-creates the parent
;; dir on first write), so here we only lay out the system-wide log/utmp
;; files and the socklog spool directories with their syslog ~config~ files.
;;
;; socklog reads its per-facility ~config~ using svlogd directives:
;; s<size> rotate at <size> bytes
;; n<count> keep <count> rotated files
;; -* clear the accept list
;; +pat accept messages matching pat
(use-modules (srfi srfi-13) ;; string things
)
(define log-files
(service '(log-files utmp)
#:documentation "Create utmp/wtmp/btmp/lastlog and the socklog spool."
#:requirement '(root-rw file-systems runtime-directories)
#:start (lambda _
;; 1. Create the utmp/wtmp/btmp/lastlog files
(for-each (lambda (args) (apply system* args))
'(("install" "-m0664" "-o" "root" "-g" "utmp" "/dev/null" "/run/utmp")
("install" "-m0664" "-o" "root" "-g" "utmp" "/dev/null" "/var/log/wtmp")
("install" "-m0600" "-o" "root" "-g" "utmp" "/dev/null" "/var/log/btmp")
("install" "-m0600" "-o" "root" "-g" "utmp" "/dev/null" "/var/log/lastlog")))
;; 2. Boot marker in /var/log/wtmp (so `last` shows the boot).
;; Void's `halt -B' idiom is unsafe here: on this host
;; /bin/halt is a `herd` proxy that *reboots* (and now
;; that /usr/local/bin is on PATH at boot, `halt -B`
;; would actually trigger a reboot during boot).
;; Writing a wtmp BOOT_TIME record needs a proper utmp
;; writer (pututxline/utmpdump); until one is wired in,
;; skip it rather than risk a boot-time reboot.
;; (See services.d/29-power.scm for the real shutdown.)
;; 3. socklog spool setup
(system* "mkdir" "-p" "/var/log/socklog")
(system* "chown" "root:socklog" "/var/log/socklog")
(system* "chmod" "2750" "/var/log/socklog")
(for-each (lambda (facility)
(let ((dir (string-append "/var/log/socklog/" facility)))
(system* "mkdir" "-p" dir)
(call-with-output-file (string-append dir "/config")
(lambda (port)
(display "s1000000\nn10\n" port)))
(system* "chown" "root:socklog" dir)
(system* "chmod" "0750" dir)))
'("messages" "daemon" "cron"))
#t)
#:stop (lambda _
;; Shutdown cleanup (replaces Void's 99-cleanup.sh)
(for-each (lambda (args)
(unless (file-exists? (last args))
(apply system* args)))
'(("install" "-m0664" "-o" "root" "-g" "utmp" "/dev/null" "/var/log/wtmp")
("install" "-m0600" "-o" "root" "-g" "utmp" "/dev/null" "/var/log/btmp")
("install" "-m0600" "-o" "root" "-g" "utmp" "/dev/null" "/var/log/lastlog")))
(system* "install" "-dm1777" "/tmp/.X11-unix" "/tmp/.ICE-unix")
(for-each (lambda (f) (false-if-exception (delete-file f)))
'("/etc/nologin" "/forcefsck" "/forcequotacheck" "/fastboot"))
#t)
#:one-shot? #t))
(register-services (list log-files))
;;; 15-syslog.scm --- system logger via Void ~socklog' listening on /dev/log.
;; Void ships no syslog by default; the ~socklog~ package (from the
;; ~socklog-void~ configuration) provides it. socklog runs in the foreground
;; by default (it is meant to be supervised), and writes the syslog messages
;; it receives on /dev/log into /var/log/socklog/<facility>/current. The
;; spool directories and their ~config~ files are created by ~log-files~.
(define syslog
(service '(syslog socklog)
#:documentation "socklog system logger listening on /dev/log."
#:requirement '(log-files runtime-directories)
#:start (make-forkexec-constructor
'("/usr/bin/socklog" "-U" "unix" "/dev/log")
#:log-file "/var/log/socklog/socklog.log")
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list syslog))
;;; 16-guix-daemon.scm --- the Guix build daemon.
;; guix-daemon only needs the store mounted (/gnu, in /etc/fstab via
;; file-systems) and the guixbuild users to exist; it does not need the
;; network. It must NOT be in the boot-critical set: on foreign distros
;; (e.g. Void), the Guix store libraries are in /gnu/store/* and the
;; dynamic loader can resolve them, but a garbage-collected dependency
;; (e.g. libgpg-error) will cause exit 127 which triggers a respawn loop
;; that wastes CPU and clutters the boot log. Since Shepherd itself is the
;; only Guix component needed at boot, guix-daemon is moved to fully-online
;; and made non-respawning so a failure is logged but does not cascade.
(define guix-daemon
(service '(guix-daemon)
#:documentation "Guix build daemon. Not required for Shepherd-only use."
#:requirement '(file-systems)
#:start (make-forkexec-constructor
'("/var/guix/profiles/per-user/root/current-guix/bin/guix-daemon"
"--build-users-group=guixbuild"
"--enable-substitutes=yes")
#:log-file "/var/log/guix-daemon.log")
#:stop (make-kill-destructor)
#:respawn? #f)) ;; Do NOT respawn — a 127 means a missing store dep, not a crash.
(register-services (list guix-daemon))
;;; 17-getty.scm --- virtual terminals on tty1..tty6.
;; Agetty is the interactive tier. Every getty depends on the boot barrier
;; ~boot-ready~ (defined in 28-boot-stages.scm), so a TTY is never offered
;; before the machine is actually ready to log in.
(define (agetty-binary)
"Resolve the agetty executable (Void ships it under /usr/sbin)."
(or (and (file-exists? "/usr/sbin/agetty") "/usr/sbin/agetty")
(and (file-exists? "/sbin/agetty") "/sbin/agetty")
(and (file-exists? "/usr/bin/agetty") "/usr/bin/agetty")
"/usr/sbin/agetty"))
(define* (make-agetty tty #:optional (extra-args '()))
"Return a respawning agetty service for TTY."
(service
(list (string->symbol (string-append "agetty-" tty)))
#:documentation (string-append "Getty on " tty)
#:requirement '(boot-ready)
#:start (make-forkexec-constructor
`(,(agetty-binary)
"--noclear"
"--noissue" ; <-- Don't wait to display /etc/issue
"--local-line" ; <-- Tell it the line is local (no modem/carrier wait)
,tty
"linux"
,@extra-args)
#:log-file (string-append "/var/log/agetty-" tty ".log"))
#:stop (make-kill-destructor)
#:respawn? #t))
(define agetty-tty1 (make-agetty "tty1" '("--autologin" "komeil")))
(define agetty-tty2 (make-agetty "tty2"))
(define agetty-tty3 (make-agetty "tty3"))
(define agetty-tty4 (make-agetty "tty4"))
(define agetty-tty5 (make-agetty "tty5"))
(define agetty-tty6 (make-agetty "tty6"))
(register-services
(list agetty-tty1 agetty-tty2 agetty-tty3 agetty-tty4 agetty-tty5 agetty-tty6))
;;; 18-loopback.scm --- bring up the loopback interface.
;; lo has no real dependencies beyond the ip tool and the dev pseudo-filesystem,
;; so it can come up in parallel with dbus / nm.
(define loopback
(service '(loopback)
#:documentation "Bring the loopback interface up."
#:requirement '(dev file-systems)
#:start (lambda _ (zero? (system* "ip" "link" "set" "up" "dev" "lo")))
#:stop (lambda _ (zero? (system* "ip" "link" "set" "down" "dev" "lo")))
#:one-shot? #t))
(register-services (list loopback))
;;; 19-dbus.scm --- system D-Bus message bus.
;; The runtime directory (/run/dbus, owned by the dbus user, uid/gid 22 on
;; Void) is a runtime-directory concern, so it is a separate one-shot service
;; (~dbus-runtime~) that depends on ~runtime-directories~. The daemon itself
;; is then started from a plain argv list -- no ~/bin/sh -c~ wrapper, and no
;; conditional ~install || install~ chain.
(define dbus-runtime
(service '(dbus-runtime)
#:documentation "Create and chown /run/dbus for the D-Bus system bus (dbus:22)."
#:requirement '(runtime-directories)
#:start (lambda _
(zero? (system* "install" "-d" "-m755" "-g" "22" "-o" "22" "/run/dbus")))
#:stop (const #t)
#:one-shot? #t))
(define dbus
(service '(dbus)
#:documentation "D-Bus system message bus."
#:requirement '(dbus-runtime dev)
#:start (make-forkexec-constructor
'("/usr/sbin/dbus-daemon" "--system" "--nofork" "--nopidfile")
#:log-file "/var/log/dbus.log")
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list dbus-runtime dbus))
;;; 20-network.scm --- NetworkManager (primary) and dhcpcd (fallback).
;; NetworkManager is the primary network stack (required by fully-online).
;; ~dhcpcd~ is a fallback: it is NOT pulled in by the boot graph, so the two
;; never fight over the same interface. If NetworkManager is unavailable or
;; you need it to manage only one link, start dhcpcd manually:
;; herd start dhcpcd
(define network-manager
(service '(network-manager networking)
#:documentation "NetworkManager (primary network stack)."
#:requirement '(dbus udev file-systems loopback)
#:start (make-forkexec-constructor
'("/usr/sbin/NetworkManager" "--no-daemon")
#:log-file "/var/log/NetworkManager.log")
#:stop (make-kill-destructor)
#:respawn? #t))
(define dhcpcd
(service '(dhcpcd)
#:documentation "dhcpcd (fallback; enable manually if NetworkManager is absent)."
#:requirement '(loopback file-systems)
#:start (make-forkexec-constructor
'("/sbin/dhcpcd" "-B" "-M" "-q")
#:log-file "/var/log/dhcpcd.log")
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list network-manager dhcpcd))
;;; 21-polkit.scm --- authority manager.
(define polkit
(service '(polkit)
#:documentation "polkit authority manager."
#:requirement '(dbus)
#:start (make-forkexec-constructor
'("/usr/lib/polkit-1/polkitd" "--no-debug")
#:log-file "/var/log/polkit.log")
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list polkit))
;;; 22-elogind.scm --- systemd-logind replacement for sessions/seats.
;; On Void, elogind is *activated on demand* by the system D-Bus daemon
;; through /usr/share/dbus-1/system-services/org.freedesktop.login1.service
;; (the .service file Exec=es elogind-wrapper). Launching elogind.wrapper
;; from a Shepherd service would race that activation and make elogind
;; reclaim org.freedesktop.login1 -- i.e. exactly the bug this used to carry.
;;
;; So this is a purely declarative place-holder: it pins elogind behind
;; ~dbus' (so it cannot be ordered before the bus) and exports the ~elogind~
;; / ~logind' symbols other services may depend on. The actual daemon is
;; bus-activated, lazily, on the first Login1 call (first graphical or
;; console login handled by PAM).
(define elogind
(service '(elogind logind)
#:documentation
"elogind is D-Bus-activated on Void; this service pins ordering and exports the symbol."
#:requirement '(dbus)
#:start (const #t)
#:stop (const #t)
#:one-shot? #t))
(register-services (list elogind))
;;; 23-seatd.scm --- seat management for Wayland (sway).
;; Void's seatd group is ~_seatd~. seatd must start after udev so device
;; nodes exist, and after /run is writable.
(define seatd
(service '(seatd)
#:documentation "seatd seat-management daemon (for sway/etc.)"
#:requirement '(udev udev-settle run runtime-directories)
#:start (make-forkexec-constructor
'("/usr/sbin/seatd" "-g" "_seatd")
#:log-file "/var/log/seatd.log")
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list seatd))
;;; 24-acpid.scm --- ACPI event daemon.
;; acpid reads events from /proc/acpi/event (needs sysfs + dev). It blocks
;; nothing critical, so it stays in fully-online, parallel to dbus/NM.
(define acpid
(service '(acpid)
#:documentation "ACPI event daemon."
#:requirement '(sys udev)
#:start (make-forkexec-constructor
'("/usr/sbin/acpid" "--foreground")
#:log-file "/var/log/acpid.log")
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list acpid))
;;; 25-zram.scm --- compressed swap on a zram block device.
;; Replaces the old inline shell. The swap activation is a separate one-shot
;; so the boot graph is explicit; zram only needs the kmod (handled by
;; static-device-nodes/kernel-modules) and the block layer.
(define zram
(service '(zram swap-zram)
#:documentation "Set up a zram swap device."
#:requirement '(static-device-nodes)
#:start (lambda _
(system* "modprobe" "zram")
(call-with-output-file "/sys/block/zram0/comp_algorithm"
(lambda (p) (display "lz4\n" p)))
(call-with-output-file "/sys/block/zram0/disksize"
(lambda (p)
(display (* 8 1024 1024 1024) p) ;; 8GB -> B
(newline p)))
(system* "mkswap" "/dev/zram0")
(system* "swapon" "/dev/zram0")
#t)
#:stop (lambda _
(system* "swapoff" "/dev/zram0")
(call-with-output-file "/sys/block/zram0/reset"
(lambda (p) (display "1\n" p)))
#t)
#:one-shot? #t))
(register-services (list zram))
;;; 26-openssh.scm --- OpenSSH server + host-key generation.
(define ssh-keys
(service '(ssh-keys)
#:documentation "Generate SSH host keys on first boot."
#:requirement '(loopback file-systems)
#:start (lambda _
(system* "/usr/bin/ssh-keygen" "-A")
#t)
#:stop (const #t)
#:one-shot? #t))
(define openssh
(service '(openssh sshd ssh)
#:documentation "OpenSSH server."
#:requirement '(ssh-keys loopback file-systems)
#:start (make-forkexec-constructor
'("/usr/sbin/sshd" "-D")
#:log-file "/var/log/sshd.log")
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list ssh-keys openssh))
;;; 27-chrony.scm --- NTP client.
;; chrony only needs /var/lib for its drift file and the network; it must NOT
;; block getty, so it lives in fully-online only.
(define chrony
(service '(chrony ntp)
#:documentation "chrony NTP client/server."
#:requirement '(network-manager file-systems)
#:start (make-forkexec-constructor
'("/usr/sbin/chronyd" "-n" "-f" "/etc/chrony.conf")
#:log-file "/var/log/chrony.log")
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list chrony))
;;; 28-boot-stages.scm --- boot barriers and stage targets.
;; Three descriptive stage targets, plus the ~boot-ready~ barrier that gates the
;; interactive gettys. These targets perform no work themselves: their only job
;; is to express ordering through ~#:requirement~ so that the graph is obvious
;; from reading this file.
;;
;; boot-critical : everything needed to run the store / devices.
;; boot-ready : the subset of the above that login actually waits on.
;; interactive : virtual terminals.
;; fully-online : networking + desktop/session daemons.
;;
;; =agetty= depends on =boot-ready= (NOT on a serial list), so unrelated
;; boot-critical services run in parallel and never block each other.
(use-modules (shepherd service))
;; boot-critical: The absolute minimum to get the kernel, storage, and
;; pseudo-filesystems ready. guix-daemon is NOT here: on a foreign distro
;; (Void) the Guix store libraries live in /gnu/store/* and a
;; garbage-collected dependency causes exit 127. Since Shepherd is the
;; only Guix component needed at boot, guix-daemon was moved to
;; ~fully-online~ and made non-respawning (see 16-guix-daemon.scm).
(define boot-critical
(stage 'boot-critical "Stage 1: core system initialization."
'(root-rw pseudo-filesystems runtime-directories
static-device-nodes kernel-modules cgroups udev udev-settle
file-systems tmpfs swap sysctl dmesg)))
;; boot-ready: The barrier behind which gettys wait.
;; Includes user-facing setup like console, timezone, and hwclock.
(define boot-ready
(stage 'boot-ready "Barrier: root, devices, filesystems, hostname, logging,
and console are ready. Gettys may start once this is up."
'(boot-critical hostname log-files syslog random-seed
console-setup timezone hwclock)))
;; interactive: Things that require a TTY or user session.
(define interactive
(stage 'interactive "Stage 2: local TTYs and user login."
'(boot-ready agetty-tty1 agetty-tty2 agetty-tty3
agetty-tty4 agetty-tty5 agetty-tty6)))
;; fully-online: Network and background daemons.
(define fully-online
(stage 'fully-online "Stage 3: networking, D-Bus, seat/session, SSH, and friends.
These deliberately do NOT gate the gettys."
'(interactive loopback dbus polkit elogind seatd acpid
network-manager zram openssh chrony guix-daemon)))
(register-services (list boot-critical boot-ready interactive fully-online))
;;; 29-power.scm --- reboot and halt targets for Shepherd.
;; Since s6-linux-init-hpr no longer works (Shepherd replaced it as PID 1),
;; we define native Shepherd targets that drive the Magic SysRq keys directly.
;;
;; IMPORTANT ordering: the old implementation ran `umount -a -r` *before*
;; triggering SysRq. That unmounted /run, which deleted the shepherd socket
;; ("Socket deleted; stopping") and killed shepherd, and *then* fired the
;; reboot -- so if the SysRq key never took effect the box was left half-dead
;; with no supervisor. SysRq 'b'/'o' reboot/halt the kernel immediately and
;; skip the unmount, so we trigger it FIRST (after sync+remount-ro via
;; 's'/'u') and let the kernel tear the filesystems down.
(define (sysrq . keys)
"Write each KEY to /proc/sysrq-trigger, silently ignoring failures (e.g.
when SysRq is disabled or unavailable to this build)."
(for-each
(lambda (key)
(false-if-exception
(let ((fd (open-file "/proc/sysrq-trigger" "w")))
(display key fd)
(close-port fd))))
keys))
(define reboot
(service '(reboot)
#:documentation "Reboot the machine via the Magic SysRq sequence."
#:requirement '(root-rw)
#:start (lambda _
(sync)
(sysrq "s" "u" "b") ; sync, remount-ro, reboot
#t)
#:one-shot? #t))
(define halt
(service '(halt poweroff)
#:documentation "Power off / halt the machine via the Magic SysRq sequence."
#:requirement '(root-rw)
#:start (lambda _
(sync)
(sysrq "s" "u" "o") ; sync, remount-ro, poweroff
#t)
#:one-shot? #t))
(register-services (list reboot halt))
9.3. User Shepherd entry point
;;; User Shepherd config (copy to ~/.config/shepherd/init.scm).
;;
;; Run as the regular user with:
;; guix package -i shepherd
;; mkdir -p ~/.config/shepherd
;; cp -r ~/.dotfiles/shepherd/user-shepherd/services.d \
;; ~/.config/shepherd/services.d
;; cp ~/.dotfiles/shepherd/user-shepherd/init.scm \
;; ~/.config/shepherd/init.scm
;; shepherd -s /run/shepherd/$(id -u).socket \
;; -c ~/.config/shepherd/init.scm -I
;;
;; Expects:
;; * a system seatd socket at /run/seatd/socket (the root `seatd` service
;; in Stage 3; this user must be in the `_seatd' group:
;; usermod -aG _seatd $USER)
;; * xbps packages installed (see "Required xbps packages" below)
;; * XDG_RUNTIME_DIR=/run/user/$UID (elogind on the system side creates it
;; via PAM when you log in on an agetty with elogind running).
;;
;; Session flow:
;; Shepherd starts -> loads services.d/*.scm
;; -> runtime-dirs sets up XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS
;; -> dbus, gpg-agent, pipewire start in parallel
;; -> sway starts
;; -> graphical-session barrier becomes ready
;; -> ALL GUI services start in parallel (dunst, swayidle, portals, etc.)
;; -> sway-session barrier resolves
;; `herd restart sway` / `herd restart pipewire` / etc. all work.
(define-module (shepherd config)
#:declarative? #f)
(use-modules (shepherd service)
((ice-9 ftw) #:select (scandir)))
;; Ensure runtime environment is set *before* loading service files, so that
;; base-env() in 00-utilities.scm captures DBUS_SESSION_BUS_ADDRESS.
(let ((rt (or (getenv "XDG_RUNTIME_DIR")
(string-append "/run/user/" (number->string (getuid))))))
(unless (getenv "XDG_RUNTIME_DIR")
(setenv "XDG_RUNTIME_DIR" rt))
(unless (getenv "DBUS_SESSION_BUS_ADDRESS")
(setenv "DBUS_SESSION_BUS_ADDRESS"
(string-append "unix:path=" rt "/bus"))))
(define %services-directory
(or (getenv "SHEPHERD_USER_SERVICES")
(string-append (or (getenv "HOME") "/home/komeil")
"/.config/shepherd/services.d")))
;; 1. Load every *.scm (alphabetically) from the services directory.
;; Each file registers its own services; #:requirement may reference a
;; service defined in an earlier-loaded file (resolved lazily at start
;; time). 00-utilities.scm sorts first so helpers are available.
(for-each
(lambda (file)
(load (string-append %services-directory "/" file)))
(scandir %services-directory
(lambda (f)
(and (string-suffix? ".scm" f)
(not (string-prefix? "." f))))))
;; 2. Top-level barrier: the complete user graphical session.
;; Its #:requirement pulls in every branch of the graph; Shepherd resolves
;; them in parallel where the DAG allows.
(define sway-session
(service '(sway-session desktop graphical-session-user)
#:documentation
"Barrier: the complete user graphical session (dbus, audio,
gpg-agent, sway, and all GUI daemons)."
#:requirement '(dbus pipewire gpg-agent sway graphical-session)
#:start (const #t)
#:stop (const #t)
#:one-shot? #t))
(register-services (list sway-session))
;; 3. Kick off the session.
;; (shepherd service) exports start-in-the-background, not a bare start.
;; Resolves the graph and starts services concurrently where possible.
(start-in-the-background '(sway-session))
9.4. User Shepherd service definitions
;;; 00-utilities.scm --- shared helpers for user Shepherd services.
;; Loaded first (alphabetically); registers no services.
;; Provides binary resolution, env-var construction, and log-file helpers
;; so that individual service files read like declarative data.
(use-modules (srfi srfi-1) ; find, delete-duplicates, remove
(shepherd service))
;; mkdir-p: create DIR and parents. Defined locally to avoid depending on
;; (ice-9 ftw) availability variations across some Guile builds.
(define* (mkdir-p dir #:optional (mode #o755))
(let loop ((parts (string-split dir #\/))
(path ""))
(if (null? parts)
#t
(let* ((next (car parts))
(full (if (string=? path "") next
(string-append path "/" next))))
(unless (or (string=? next "") (file-exists? full))
(catch 'system-error
(lambda () (mkdir full mode))
(lambda args
;; If the error is not EEXIST, re-throw it.
(unless (= (system-error-errno args) EEXIST)
(apply throw args)))))
(loop (cdr parts) full)))))
(define %home
(or (getenv "HOME") "/home/komeil"))
(define %xdg-state
(or (getenv "XDG_STATE_HOME")
(string-append %home "/.local/state")))
(define %shepherd-dir
(string-append %xdg-state "/shepherd"))
(define %xdg-runtime
;; Inherit from the login session; fall back to the standard per-UID path.
;; init.scm sets this via setenv before loading service files.
(or (getenv "XDG_RUNTIME_DIR")
(string-append "/run/user/"
(number->string (getuid)))))
(define %wayland-display
;; Sway's default socket name. If the user has a non-default display,
;; set WAYLAND_DISPLAY in the environment before starting the user Shepherd.
(or (getenv "WAYLAND_DISPLAY") "wayland-0"))
(define %xdg-desktop "sway")
(define %ssh-agent-enabled
;; Set GPG_SSH=yes to enable SSH-agent support in gpg-agent.
(member (getenv "GPG_SSH") '("yes" "1" "true")))
(define nilog-dir
(begin
(mkdir-p %shepherd-dir)
%shepherd-dir))
(define (log-file name)
"Return the log file path for user service NAME."
(string-append nilog-dir "/" name ".log"))
(define* (binary name #:optional (hint ""))
"Resolve an xbps-installed binary by trying common prefixes.
Returns the first existing path, or NAME if none found (so Shepherd
logs a clear 'No such file or directory' error rather than silently
resolving to PATH)."
(let ((candidates
(append
(if (string=? hint "")
'()
(list (string-append hint "/" name)))
(list (string-append "/usr/bin/" name)
(string-append "/usr/sbin/" name)
(string-append "/usr/libexec/" name)
(string-append "/usr/lib/" name)
(string-append "/bin/" name)))))
(or (find file-exists? candidates)
name)))
(define (base-env)
"Minimal environment variables every user process needs: HOME, PATH,
XDG_RUNTIME_DIR, and DBUS_SESSION_BUS_ADDRESS, captured from the current
(Shepherd) process. init.scm sets DBUS_SESSION_BUS_ADDRESS via setenv
before loading services, so all children inherit the session bus address."
(let ((path (or (getenv "PATH") "/usr/local/bin:/usr/bin:/bin"))
(term (or (getenv "TERM") "xterm-256color"))
(dbus (getenv "DBUS_SESSION_BUS_ADDRESS"))
(xdg (getenv "XDG_RUNTIME_DIR")))
(filter identity
(list (string-append "HOME=" %home)
(string-append "PATH=" path)
(and xdg (string-append "XDG_RUNTIME_DIR=" xdg))
(string-append "TERM=" term)
(and dbus (string-append "DBUS_SESSION_BUS_ADDRESS=" dbus))))))
(define (wayland-env)
"Environment for GUI services: base env + WAYLAND_DISPLAY + XDG_CURRENT_DESKTOP.
Every Wayland client needs these to connect to the compositor."
(append (base-env)
(list (string-append "WAYLAND_DISPLAY=" %wayland-display)
(string-append "XDG_CURRENT_DESKTOP=" %xdg-desktop)
(string-append "XDG_SESSION_TYPE=wayland"))))
(define (with-wayland-env proc)
"Run PROC after setting WAYLAND_DISPLAY and XDG_CURRENT_DESKTOP in the
current process environment (used for one-shots that need the Wayland
display but are started via system* rather than forkexec)."
(setenv "WAYLAND_DISPLAY" %wayland-display)
(setenv "XDG_CURRENT_DESKTOP" %xdg-desktop)
(setenv "XDG_SESSION_TYPE" "wayland")
(proc))
;;; 01-runtime-dirs.scm --- user runtime directory + session env vars.
;; Sets XDG_RUNTIME_DIR (created by PAM/elogind at login, but we ensure it
;; exists as a safeguard) and DBUS_SESSION_BUS_ADDRESS so that dbus-daemon
;; and every dbus-dependent child can find the user session bus at a
;; predictable path. This is a no-op when XDG_RUNTIME_DIR is already set
;; correctly (idempotent).
(define runtime-dirs
(service '(runtime-dirs)
#:documentation
"Ensure XDG_RUNTIME_DIR exists and set session env vars on the
Shepherd process so all child services inherit them."
#:requirement '()
#:start
(lambda _
(let ((rt (or (getenv "XDG_RUNTIME_DIR")
(string-append "/run/user/"
(number->string (getuid))))))
(false-if-exception (mkdir-p rt))
(setenv "XDG_RUNTIME_DIR" rt)
(setenv "DBUS_SESSION_BUS_ADDRESS"
(string-append "unix:path=" rt "/bus"))
#t))
#:stop (const #t)
#:one-shot? #t))
(register-services (list runtime-dirs))
;;; 03-pipewire.scm --- PipeWire audio stack.
;; Dependency chain:
;; pipewire -> wireplumber -> pipewire-pulse
;;
;; pipewire is the core daemon; wireplumber is the session manager
;; pipewire-pulse provides PulseAudio wire-protocol compatibility
;; for legacy clients.
;;
;; The audio stack does NOT depend on graphical-session. it works headless
;; and can start before Sway. pipewire runs in the foreground by default
;; (no --no-daemon needed — that flag doesn't exist on Void's pipewire).
(define pipewire
(service '(pipewire)
#:documentation "PipeWire multimedia server (core daemon)."
#:requirement '(dbus)
#:start
(make-forkexec-constructor
(list (binary "pipewire"))
#:environment-variables (base-env)
#:log-file (log-file "pipewire"))
#:stop (make-kill-destructor)
#:respawn? #t))
(define wireplumber
(service '(wireplumber)
#:documentation "PipeWire session manager."
#:requirement '(pipewire)
#:start
(make-forkexec-constructor
(list (binary "wireplumber"))
#:environment-variables (base-env)
#:log-file (log-file "wireplumber"))
#:stop (make-kill-destructor)
#:respawn? #t))
(define pipewire-pulse
(service '(pipewire-pulse pulseaudio)
#:documentation "PipeWire PulseAudio compatibility bridge."
#:requirement '(wireplumber)
#:start
(make-forkexec-constructor
(list (binary "pipewire-pulse"))
#:environment-variables (base-env)
#:log-file (log-file "pipewire-pulse"))
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list pipewire wireplumber pipewire-pulse))
;;; 04-gpg-agent.scm --- GnuPG agent (with optional SSH support).
;; GnuPG 2.2+ on Void: gpg-agent without --supervised or --daemon will
;; fail ("no gpg-agent running in this session"). Using --supervised
;; makes gpg-agent run in the foreground under Shepherd supervision.
;; SSH support is toggled via GPG_SSH at Shepherd start time (see
;; 00-utilities.scm). When enabled, the SSH socket lands at
;; $XDG_RUNTIME_DIR/gnupg/S.gpg-agent.ssh — a path ssh-add and other
;; SSH tools find automatically via the agent's own socket discovery.
(define gpg-agent
(service '(gpg-agent)
#:documentation
"GnuPG agent. With GPG_SSH=yes in the environment, also acts
as an SSH agent (SSH_AUTH_SOCK is set automatically by the agent's socket
discovery)."
#:requirement '(runtime-dirs)
#:start
(make-forkexec-constructor
(append
(if %ssh-agent-enabled
(list (binary "gpg-agent") "--supervised" "--enable-ssh-support")
(list (binary "gpg-agent") "--supervised"))
'("--no-allow-external-cache"))
#:environment-variables (base-env)
#:log-file (log-file "gpg-agent"))
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list gpg-agent))
;;; 05-sway.scm --- Sway Wayland compositor (user, respawned).
;; Sway is started by the user Shepherd directly (not by an external TTY
;; login script), so `herd restart sway` works. It requires only:
;; * seatd socket (system service at /run/seatd/socket)
;; * XDG_RUNTIME_DIR (from runtime-dirs, for the Wayland socket)
;;
;; Sway does NOT require dbus to run. it uses the Wayland protocol directly
;; for output/input management. Portal integration (xdg-desktop-portal) is
;; handled separately by services that depend on BOTH graphical-session and
;; dbus.
(define sway-env
;; Base env plus the seatd socket address Sway needs to talk to the
;; system seat management daemon.
(append (base-env)
(list (string-append "SEATD_SOCKET=/run/seatd/socket"))))
(define sway
(service '(sway wayland)
#:documentation "sway Wayland compositor (user service)."
#:requirement '(runtime-dirs dbus)
#:start
(make-forkexec-constructor
(list (binary "sway"))
#:environment-variables sway-env
#:directory %home
#:log-file (log-file "sway"))
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list sway))
;;; 06-graphical-session.scm --- barrier for the ready graphical session.
;; This is a lightweight stage (barrier) service. It does no work itself;
;; its only purpose is its #:requirement '(sway). All GUI-only daemons
;; depend on this barrier, so they start together once Sway has created the
;; Wayland display, but none of them block Sway startup and they all run
;; in parallel.
(define graphical-session
(service '(graphical-session)
#:documentation
"Barrier: the Wayland compositor (sway) is running and the Wayland
display socket exists. GUI-only daemons depend on this."
#:requirement '(sway)
#:start (const #t)
#:stop (const #t)
#:one-shot? #t))
(register-services (list graphical-session))
;;; 07-portals.scm --- Wayland desktop portals.
;; Two pieces:
;; 1. portals-env (one-shot): publishes WAYLAND_DISPLAY and XDG_CURRENT_DESKTOP
;; to the D-Bus activation environment via dbus-update-activation-environment,
;; so dbus-activated services (the portals) inherit the Wayland session.
;; Requires both graphical-session (sway has started) and dbus (the bus
;; must exist to publish the env).
;; 2. The portals themselves: started explicitly for reliability rather than
;; relying on dbus activation alone.
(define portals-env
(service '(portals-env)
#:documentation
"Publish WAYLAND_DISPLAY and XDG_CURRENT_DESKTOP to the D-Bus
activation environment so dbus-activated services (portals) inherit them."
#:requirement '(graphical-session dbus)
#:start
(make-system-constructor
(string-append
(binary "dbus-update-activation-environment")
" WAYLAND_DISPLAY=" %wayland-display
" XDG_CURRENT_DESKTOP=" %xdg-desktop
" XDG_SESSION_TYPE=wayland"))
#:stop (const #t)
#:one-shot? #t))
(define xdg-desktop-portal
(service '(xdg-desktop-portal)
#:documentation
"xdg-desktop-portal: the host D-Bus interface for desktop portal
requests (file pickers, screencasting, etc.)."
#:requirement '(portals-env)
#:start
(make-forkexec-constructor
(list (binary "xdg-desktop-portal"))
#:environment-variables (wayland-env)
#:directory %home
#:log-file (log-file "xdg-desktop-portal"))
#:stop (make-kill-destructor)
#:respawn? #t))
(define xdg-desktop-portal-wlr
(service '(xdg-desktop-portal-wlr)
#:documentation
"xdg-desktop-portal-wlr: Wayland-specific portal backend
(screencasting, screenshot, idle inhibiting for sway)."
#:requirement '(xdg-desktop-portal)
#:start
(make-forkexec-constructor
(list (binary "xdg-desktop-portal-wlr"))
#:environment-variables (wayland-env)
#:directory %home
#:log-file (log-file "xdg-desktop-portal-wlr"))
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list portals-env xdg-desktop-portal xdg-desktop-portal-wlr))
;;; 08-dunst.scm --- notification daemon (Wayland).
;; dunst is a lightweight notification daemon. It needs a Wayland connection
;; (for notification positioning) and dbus (for the org.freedesktop.Notifications
;; interface). The dbus connection is inherited from the Shepherd env
;; (DBUS_SESSION_BUS_ADDRESS set by runtime-dirs); the Wayland display is
;; passed via #:environment-variables.
(define dunst
(service '(dunst notifications)
#:documentation "dunst notification daemon (Wayland)."
#:requirement '(graphical-session dbus)
#:start
(make-forkexec-constructor
(list (binary "dunst") "-mode wayland")
#:environment-variables (wayland-env)
#:directory %home
#:log-file (log-file "dunst"))
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list dunst))
;;; 09-swayidle.scm --- idle management + swaylock integration.
;; Replaces the existing `swaylock`-named service (which was actually running
;; swayidle). swayidle monitors Sway output activity and triggers commands
;; on inactivity. The config mirrors the Sway config's exec line:
;; timeout 600s -> dpms off
;; resume -> dpms on
;; before-sleep -> swaylock -f -c 000000
;;
;; swaylock (the locker binary) is invoked by swayidle, so it is not a
;; separate service; it is a dependency of the `swaylock` symbol being
;; available in PATH, which it is when the swaylock xbps package is installed.
(define swayidle
(service '(swayidle)
#:documentation
"swayidle: idle management daemon. Locks with swaylock on
inactivity and before sleep."
#:requirement '(graphical-session)
#:start
(make-forkexec-constructor
(list (binary "swayidle") "-w"
"timeout" "600" "swaymsg" "output *" "dpms" "off"
"resume" "swaymsg" "output *" "dpms" "on"
"before-sleep" "swaylock" "-f" "-c" "000000")
#:environment-variables (wayland-env)
#:directory %home
#:log-file (log-file "swayidle"))
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list swayidle))
;;; 10-sway-audio-idle-inhibit.scm --- keep screen from sleeping during audio.
;; Monitors the PulseAudio server (provided by pipewire-pulse) for active
;; playback and inhibits swayidle's idle timer while audio is playing.
;; Requires both the compositor (via graphical-session) and the audio
;; bridge (pipewire-pulse), without either, the daemon has no purpose.
(define sway-audio-idle-inhibit
(service '(sway-audio-idle-inhibit)
#:documentation
"Prevent idle-sleep while audio is playing (connects to Sway IPC
and PulseAudio server)."
#:requirement '(graphical-session pipewire-pulse)
#:start
(make-forkexec-constructor
(list (binary "sway-audio-idle-inhibit"))
#:environment-variables (wayland-env)
#:directory %home
#:log-file (log-file "sway-audio-idle-inhibit"))
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list sway-audio-idle-inhibit))
;;; 11-wlsunset.scm --- day/night gamma adjustment.
;; wlsunset adjusts the screen color temperature using the
;; wlr-gamma-control-unstable-v1 protocol. It needs a Wayland connection
;; and the Sway compositor. Coordinates: lat 38.0792, lon 46.2887
;; (Tabriz, Iran), temperature 6000K (daytime baseline. the daemon handles
;; the day/night ramp; -t 6000 means daytime temperature).
(define wlsunset
(service '(wlsunset)
#:documentation "wlsunset: day/night gamma adjustment for Wayland."
#:requirement '(graphical-session)
#:start
(make-forkexec-constructor
(list (binary "wlsunset")
"-l" "38.0792" "-L" "46.2887" "-t" "6000")
#:environment-variables (wayland-env)
#:directory %home
#:log-file (log-file "wlsunset"))
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list wlsunset))
;;; 12-clipman.scm --- clipboard history daemon (Wayland).
;; Cliphist writer is a self-contained daemon that monitors the Wayland
;; clipboard and stores history in ~/.local/share/cliphist.db.
;; Sway config bindsym stays as a keybinding (not a daemon):
;; bindsym Mod4+Shift+v exec cliphist list | wofi -d -i | xargs -r cliphist decode | wl-copy
(define clipman
(service '(clipman clipboard cliphist)
#:documentation
"cliphist: Wayland clipboard history daemon (replaces clipman)."
#:requirement '(graphical-session)
#:start
(make-forkexec-constructor
(list (binary "cliphist") "writer")
#:environment-variables (wayland-env)
#:directory %home
#:log-file (log-file "clipman"))
#:stop (make-kill-destructor)
#:respawn? #t))
(register-services (list clipman))
;;; 13-azotebg.scm --- wallpaper setter (one-shot).
;; Azotebg is a one-shot: it queries Sway for connected outputs
;; (via swaymsg) and sets a wallpaper for each, then exits.
;; It is a #:one-shot? service that depends on
;; graphical-session (sway must be running for output queries).
(define azotebg
(service '(azotebg wallpaper)
#:documentation
"Set the desktop wallpaper via azotebg (one-shot, runs after sway)."
#:requirement '(graphical-session)
#:start
(make-forkexec-constructor
(list (binary "azotebg"))
#:environment-variables (wayland-env)
#:directory %home
#:log-file (log-file "azotebg"))
#:stop (make-kill-destructor)
#:one-shot? #t))
(register-services (list azotebg))
9.5. s6-linux-init skel files
#!/usr/bin/bash
#
# s6-linux-init stage-0 -> Shepherd handoff.
#
# This file lives in the s6-linux-init *skeleton* (skel/rc.init), not in
# `current/scripts/`. Regenerate with:
# s6-linux-init-maker -b /etc/s6-linux-init/skel ...
# s6-linux-init-maker copies it to current/scripts/rc.init, so edits here
# survive rebuilds.
#
# This script does THREE things and no more:
# 1. mount /gnu, /var/log, /var/tmp (the minimal set Shepherd + the store
# need before it can run), mount a fresh tmpfs on /run, and create the
# runtime tree (/run/shepherd, /var/log/shepherd). /var/cache is NOT
# mounted here (xbbs cache only, @pkg subvol); fstab handles it.
# 2. launch the Shepherd daemon (init.scm then drives the whole graph via
# its dependency requirements).
# 3. tail Shepherd's logfile to the console with a colored spinner.
# Nothing past this point is managed by s6 -- Shepherd owns udev, dbus,
# networking, gettys, sway, etc.
#
# NOTE: s6-linux-init supervises this stage-1 script but does NOT supervise
# the Shepherd daemon launched here -- Shepherd is bg'd and orphaned. If it
# ever exits, the socket disappears and stays gone until the next boot, so
# `herd' reports "no socket". Keep boot steps below from deleting /run.
set -Eeuo pipefail
# /usr/local/bin holds the `herd` and `shepherd` symlinks (into /gnu/store).
# Services launched by Shepherd inherit this PATH; without it any service that
# shells out to `herd` fails at boot (e.g. the old `halt -B' boot-marker).
export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
# Optionally pull in the live Guix profile so daemons/tools upgraded later via
# `guix package` are visible to services launched further down. Non-fatal: the
# /usr/local/bin shim above already guarantees `herd' is resolvable when
# s6-linux-init itself runs. /run/current-guix-profile may not exist yet this
# early (activation creates it); sourcing a missing script is skipped.
if [ -r /etc/profiles/guix.sh ]; then
# shellcheck disable=SC1091
. /etc/profiles/guix.sh 2>/dev/null || \
echo "rc.init: warning: /etc/profiles/guix.sh failed to source" >/dev/console 2>/dev/null || true
fi
rl="${1:-default}"
[ $# -gt 0 ] && shift || true
DISK_UUID=bded8bd1-ce84-47a6-8a90-0d0495f68111
LOGFILE=/var/log/s6-shepherd-boot.log
CONSOLE_LOGLEVEL=4
if grep -q 'loglevel=[7-9]' /proc/cmdline 2>/dev/null; then
CONSOLE_LOGLEVEL=7
fi
# verbose + strict logger: always appends to $LOGFILE, prints color to /dev/console.
# usage: log <level> <msg>
log() {
local level="${1:-info}" msg="$2" color=""
case "$level" in
critical|error) color='\033[1;31m';;
warn) color='\033[1;33m';;
debug) color='\033[0m'; [ "$CONSOLE_LOGLEVEL" -ge 7 ] || return 0;;
info) color='\033[1;32m'; [ "$CONSOLE_LOGLEVEL" -ge 6 ] || return 0;;
*) color='\033[0m';;
esac
printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$level" "$msg" >>"$LOGFILE" 2>/dev/null || true
printf "${color}%s\033[0m\n" "$msg" >/dev/console 2>/dev/null || true
}
# Diagnostic: confirm rc.init is running (bypasses _log which needs /var/log)
echo "rc.init: starting (rl=$rl)" >/dev/console 2>/dev/null || true
log info "rc.init start rl=$rl (PATH=$PATH)"
# 1a. /var/log (persistent, btrfs subvol so crash logs survive reboot)
mkdir -p /var/log
mountpoint -q /var/log 2>/dev/null || \
mount -t btrfs -o subvol=@log,noatime "/dev/disk/by-uuid/$DISK_UUID" /var/log \
|| log warn "/var/log mount failed (using existing)"
# 1b. /var/tmp
mkdir -p /var/tmp
mountpoint -q /var/tmp 2>/dev/null || \
mount -t btrfs -o subvol=@/@tmp,noatime "/dev/disk/by-uuid/$DISK_UUID" /var/tmp \
|| log warn "/var/tmp mount failed (using existing)"
# 1c. /gnu -- the store. No /gnu, no Shepherd; this is the hard gate.
mkdir -p /gnu
if ! mountpoint -q /gnu 2>/dev/null; then
if ! mount -t btrfs -o subvol=@/@gnu,noatime "/dev/disk/by-uuid/$DISK_UUID" /gnu; then
log critical "CRITICAL: /gnu mount failed"
exec /sbin/agetty --noclear -a root tty1 linux
fi
fi
log info "/gnu ok"
# 1d. /run -- fresh tmpfs each boot. This is the key fix for "Socket deleted;
# stopping": /run is recreated empty and deterministic, and we never umount
# it from a service. Idempotent: only mount/mkdir/chmod if not already done.
mkdir -p /run
mountpoint -q /run 2>/dev/null || \
mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs /run \
|| log warn "/run tmpfs mount failed (proceeding with existing /run)"
ln -sfnT /run /var/run 2>/dev/null || true
mkdir -p /run/shepherd
chmod 700 /run/shepherd
# /var/log/shepherd holds per-service logs Shepherd's logger pulls here.
mkdir -p /var/log/shepherd
# Ensure the `socklog` group exists before Shepherd starts its services:
# 14-log-files.scm chowns the /var/log/socklog spool to root:socklog, and
# without this group every boot logs `chown: invalid group: 'root:socklog'`.
getent group socklog >/dev/null 2>&1 || groupadd -r socklog 2>/dev/null || log warn "could not create 'socklog' group"
log info "rc.init stage-0 complete, launching Shepherd"
# 1e. /sbin is not where `herd' lives; add it to the boot PATH.
SHEPHERD=/usr/local/bin/shepherd
[ -x "$SHEPHERD" ] || SHEPHERD=/gnu/current-guix-profile/bin/shepherd
if [ ! -x "$SHEPHERD" ]; then
log critical "CRITICAL: no shepherd binary found"
exec /sbin/agetty --noclear -a root tty1 linux
fi
log info "Using $SHEPHERD"
# 1f. Launch Shepherd once. -I = stay in foreground (we bg it); -s socket,
# -c config, -l daemon log. Idempotent: skip if a socket is already live.
if [ ! -S /run/shepherd/socket ]; then
"$SHEPHERD" -s /run/shepherd/socket -c /etc/shepherd/init.scm \
-l /var/log/shepherd.log -I >/dev/null 2>&1 &
i=0
while [ ! -S /run/shepherd/socket ] && [ "$i" -lt 150 ]; do
sleep 0.1
i=$((i + 1))
done
fi
if [ ! -S /run/shepherd/socket ]; then
log critical "CRITICAL: shepherd socket not created"
log critical "--- /var/log/shepherd.log ---"
tail -n 40 /var/log/shepherd.log >>"$LOGFILE" 2>/dev/null || true
log critical "Falling back to emergency getty"
exec /sbin/agetty --noclear -a root tty1 linux
fi
log info "SUCCESS: shepherd up, config loaded"
# 1g. Mirror Shepherd's lifecycle log to the console with a colored spinner.
(
set -e
current_svc=""
spinner_pid=""
spin() {
local svc="$1" i=0 chars='-\|/'
while true; do
printf '\r\033[0;36m→ Starting %-20s %s\033[0m' \
"$svc" "${chars:$i:1}" >/dev/console 2>/dev/null || true
i=$(( (i + 1) % 4 ))
sleep 0.1
done
}
tail -n 0 -F /var/log/shepherd.log 2>/dev/null |
while IFS= read -r line; do
case "$line" in
*"Starting service "*)
if [ -n "$spinner_pid" ]; then kill "$spinner_pid" 2>/dev/null || true; fi
wait "$spinner_pid" 2>/dev/null || true
current_svc=$(printf '%s' "$line" | sed -n 's/.*Starting service \([^.]*\).*/\1/p')
spin "$current_svc" &
spinner_pid=$!
;;
*"has been started."*)
if [ -n "$spinner_pid" ]; then kill "$spinner_pid" 2>/dev/null || true; fi
wait "$spinner_pid" 2>/dev/null || true
spinner_pid=""
printf '\r\033[1;32m✓ Service %-20s started\033[0m\n' \
"$(printf '%s' "$line" | sed -n 's/.*Service \([^ ]*\) has been started.*/\1/p')" >/dev/console 2>/dev/null || true
;;
*"has been disabled."*|*"failed to start"*|*"Respawning too fast"*|*"is currently disabled."*)
if [ -n "$spinner_pid" ]; then kill "$spinner_pid" 2>/dev/null || true; fi
wait "$spinner_pid" 2>/dev/null || true
spinner_pid=""
printf '\r\033[1;31m✗ Service %-20s failed\033[0m\n' \
"$(printf '%s' "$line" | sed -n 's/.*Service \([^ ]*\).*/\1/p')" >/dev/console 2>/dev/null || true
;;
esac
done
) &
log info "rc.init finished OK"
#!/bin/sh -e
### Things to do before hardware halt/reboot/poweroff.
### Ideally, it should be a single call to the service manager,
### telling it to bring all the services down.
### If your s6-linux-init-maker invocation was made with the -1
### option, messages from rc.shutdown will appear on /dev/console
### as well as be logged by the catch-all logger.
### If your s6-linux-init-maker invocation did NOT include the -1
### option, messages from rc.shutdown will only be logged by the
### catch-all logger and will NOT appear on /dev/console. In order
### to print them to /dev/console instead, you may want to
### uncomment the following line:
# exec >/dev/console 2>&1
### If your services are managed by sysv-rc:
### also remove the K11reboot link from /etc/rc6.d to prevent
### sysv-rc from rebooting prematurely - because sysvinit does
### not properly separate state changes from system init/shutdown.
# exec /etc/init.d/rc 6
### If your services are managed by OpenRC:
### also remove the "killprocs" and "mount-ro" symlinks from
### /etc/runlevels/shutdown - because OpenRC does not properly
### separate the service manager from the shutdown manager either.
# exec /sbin/openrc shutdown
### If your services are managed by s6-rc:
# exec s6-rc -v2 -bDa change
#!/bin/sh -e
### Things to do *right before* the machine gets rebooted or
### powered off, at the very end of the shutdown sequence,
### when all the filesystems are unmounted.
### This is a last resort hook; normally nothing should be
### done here (your rc.shutdown script should have taken care
### of everything) and you should leave this script empty.
### Some distributions, however, may need to perform some
### actions after unmounting the filesystems: typically if
### an additional teardown action is required on a filesystem
### after unmounting it, or if the system needs to be
### pivot_rooted before it can be shut down, etc.
### Those are all exceptional cases. If you don't know for
### certain that you need to do something here, you don't.
#!/bin/sh -e
### This script is called once at boot time by rc.init, and is
### also called by the runleveld service every time the user
### requests a machine state change via telinit.
### Ideally, it should just be a call to the service manager.
test "$#" -gt 0 || { echo 'runlevel: fatal: too few arguments' 1>&2 ; exit 100 ; }
### If your services are managed by sysv-rc:
# exec /etc/init.d/rc "$1"
### If your services are managed by OpenRC:
# exec /sbin/openrc "$1"
### If your services are managed by s6-rc:
# exec s6-rc -v2 -up change "$1"
9.6. Commands
#!/bin/sh
exec /usr/sbin/s6-linux-init -v 1 -m 0022 \
-c /etc/s6-linux-init/current \
-p /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin \
-- "$@"
#!/bin/sh
exec herd start halt
#!/bin/sh
exec herd start halt
#!/bin/sh
exec herd start reboot