#+title: Boot Setup & Low-Level Configuration on Slackware #+author: Ian Keane #+date: 2026-08-08 #+options: toc:t num:nil * Overview This is a writeup of how I have my Slackware laptop configured at the low level — boot services, input devices, audio, remote filesystems, and window manager. The goal throughout has been to keep everything in =~/.dotfiles= as static files, managed by a pair of symlink scripts (=link.sh= for user configs, =link-root.sh= for system configs), and to avoid =systemd= or any init system that requires service descriptors to express dependencies. I run Slackware at runlevel 3 (multi-user, no X). I log in on a TTY and start =stumpwm= or =kde= manually via =startx=, which means my X session is entirely optional and can be restarted or switched without rebooting. Several services — keyboard remapping, VPN, a network filesystem, and a music daemon — start at boot via =rc.local=, before any user session begins. * The Boot Sequence Slackware uses BSD-style =rc.d= shell scripts. There's no dependency tracking, no parallelism, no =After== directives. Everything in =/etc/rc.d/rc.local= runs sequentially, in order, as root. My =rc.local= looks like this: #+begin_src bash #!/bin/bash # Log all boot output to file while still showing on console exec > >(tee /var/log/rc.local.log) 2>&1 if [ -x /etc/rc.d/rc.kanata ]; then /etc/rc.d/rc.kanata start fi if [ -x /etc/rc.d/rc.tailscale ]; then /etc/rc.d/rc.tailscale start fi if [ -x /etc/rc.d/rc.autofs ]; then /etc/rc.d/rc.autofs start fi if [ -x /etc/rc.d/rc.mpd ]; then /etc/rc.d/rc.mpd start fi #+end_src The =exec > >(tee ...)= line redirects all subsequent stdout and stderr to both the console and =/var/log/rc.local.log=, which is invaluable for debugging boot failures without having to attach a serial console or dig through kernel logs. The services start in dependency order: kanata first (it only needs the kernel input device), then tailscale (needs the network), then autofs (needs tailscale to reach the NFS server), then MPD (needs autofs to have mounted the music library). * Keyboard Remapping with Kanata I use [[https://github.com/jtroo/kanata][kanata]] for keyboard remapping. It operates at the kernel input layer via =evdev=, entirely independent of X — which means my remaps work in TTYs, in =tmux= over SSH, everywhere. The rc script at =~/.dotfiles/config/kanata/rc.kanata= is symlinked to =/etc/rc.d/rc.kanata= by =link-root.sh=: #+begin_src bash #!/bin/bash KANATA_BIN="/home/green/.cargo/bin/kanata" KANATA_CFG="/home/green/.config/kanata/kanata.kbd" PIDFILE="/var/run/kanata.pid" case "$1" in start) echo "Starting kanata..." # Clear caps lock state before grabbing device DISPLAY=:0 XAUTHORITY=/home/green/.Xauthority xset led off 2>/dev/null || true sleep 0.2 $KANATA_BIN --cfg $KANATA_CFG & echo $! > $PIDFILE ;; stop) kill $(cat $PIDFILE) && rm -f $PIDFILE ;; restart) $0 stop; sleep 1; $0 start ;; esac #+end_src Kanata is installed from source via cargo. Because it's a Rust binary in =~/.cargo/bin=, the full path is hardcoded in the rc script — no =PATH= games at boot. * Tailscale VPN [[https://tailscale.com][Tailscale]] provides a WireGuard-based mesh VPN. I use it primarily to reach my home server (=tower=) from anywhere without port forwarding or a fixed IP. =tailscaled= is the userspace daemon; I installed the binary directly from a release tarball into =/usr/sbin/= and =/usr/bin/=. The rc script at =~/.dotfiles/config/tailscale/rc.tailscale=: #+begin_src bash #!/bin/bash TAILSCALED=/usr/sbin/tailscaled STATEDIR=/var/lib/tailscale PIDFILE=/var/run/tailscaled.pid start() { if [ -f "$PIDFILE" ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then echo "tailscaled is already running"; return fi echo "Starting tailscaled..." mkdir -p /var/run/tailscale $TAILSCALED --state=$STATEDIR/tailscaled.state \ --socket=/var/run/tailscale/tailscaled.sock \ --port=41641 \ &>/var/log/tailscaled.log & echo $! > $PIDFILE echo "tailscaled started (pid $(cat $PIDFILE))" } stop() { [ -f "$PIDFILE" ] && kill "$(cat $PIDFILE)" 2>/dev/null && rm -f "$PIDFILE" } case "$1" in start) start ;; stop) stop ;; restart) stop; sleep 1; start ;; status) if [ -f "$PIDFILE" ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then echo "tailscaled is running (pid $(cat $PIDFILE))" else echo "tailscaled is not running" fi ;; esac #+end_src ** DNS Caveat Tailscale's MagicDNS (which would let you =ssh tower= instead of =ssh 100.73.64.64=) relies on the system DNS resolver being configured to use Tailscale's nameserver. On Slackware without =systemd-resolved=, this doesn't happen automatically, and =tailscaled= exits with status 2 when it tries to set it up. Compounding this, my router's DHCP server advertises =dump.town= as a domain search suffix. So when I type =tower=, the resolver tries =tower.dump.town= first — which exists and resolves to the public IP of my server, not its Tailscale IP. The short-term fix is to use the Tailscale IP directly (=100.73.64.64=) anywhere I need to reach =tower=. I also added this to =/etc/dhcpcd.conf= to suppress the rogue domain search from DHCP: #+begin_src conf nooption domain_name, domain_search #+end_src Getting MagicDNS working properly on Slackware is a longer project involving either patching =/etc/resolv.conf= from a hook or running a local resolver like =dnsmasq=. * Network Filesystem via autofs My music library lives on =tower= and is exported over NFS. Rather than mount it in =/etc/fstab= (which would block boot if the VPN wasn't up yet), I use [[https://www.kernel.org/doc/Documentation/filesystems/autofs.txt][autofs]] to mount it on demand. autofs works by presenting a directory that triggers an NFS mount the first time it's accessed. If the mount fails, the directory just looks empty. This is much cleaner than retry loops or =nofail= in fstab. =~/.dotfiles/config/autofs/auto.master=: #+begin_src conf /mnt /etc/auto.mnt --timeout=300 /misc /etc/auto.misc /net -hosts #+end_src =~/.dotfiles/config/autofs/auto.mnt=: #+begin_src conf media -fstype=nfs,nolock,soft,timeo=30 100.73.64.64:/mnt/user/media #+end_src This makes =/mnt/media= appear as a directory at all times. The NFS share from =tower= is mounted into it the first time something accesses the path, and unmounted after 300 seconds of inactivity. Key NFS options: - =nolock= — required because =rpc.statd= is not running on this Slackware install - =soft= — NFS operations time out instead of hanging forever if the server is unreachable - =timeo=30= — 3-second timeout (value is in tenths of a second) * Music Player Daemon MPD runs as my user, started at boot by root, before any X session. It serves music from the NFS share over a local socket that =ncmpcpp= connects to. The tricky part: MPD exits immediately if its =music_directory= is inaccessible. It does not retry. So the rc script needs to ensure the NFS share is actually mounted before starting MPD. =~/.dotfiles/config/mpd/rc.mpd=: #+begin_src bash #!/bin/bash MPD=/usr/bin/mpd CONF=/home/green/.config/mpd/mpd.conf PIDFILE=/var/run/mpd/mpd.pid start() { [ -f "$PIDFILE" ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null && return echo "Starting mpd..." mkdir -p /var/run/mpd chown green:users /var/run/mpd # XDG_RUNTIME_DIR is normally created by pam_systemd on login. # Since we start before login, we create it manually. mkdir -p /run/user/1000 chown green:users /run/user/1000 chmod 700 /run/user/1000 # Wait for Tailscale to have connectivity to tower echo "Waiting for Tailscale connectivity to tower..." for i in $(seq 1 30); do tailscale ping --c 1 100.73.64.64 &>/dev/null && break sleep 1 done # Trigger autofs mount and wait for it to succeed echo "Waiting for /mnt/media/Music..." for i in $(seq 1 15); do ls /mnt/media/Music &>/dev/null && break sleep 1 done if ! ls /mnt/media/Music &>/dev/null; then echo "WARNING: /mnt/media/Music not accessible, starting MPD anyway" fi sudo -u green XDG_RUNTIME_DIR=/run/user/1000 $MPD $CONF echo "mpd started" } #+end_src The two-phase wait is important: first we ping tower via Tailscale (to ensure the VPN peer is reachable), then we =ls= the music directory (to trigger the autofs mount and confirm it succeeded). In practice both loops complete in 1-2 seconds once the network is up. MPD's config at =~/.dotfiles/config/mpd/mpd.conf= uses PulseAudio for output. Since I'm also running PipeWire with =pipewire-pulse=, this just works — PulseAudio clients connect to PipeWire's compatibility layer. #+begin_src conf music_directory "/mnt/media/Music" playlist_directory "/mnt/media/Music/playlists" db_file "~/.config/mpd/database" pid_file "/var/run/mpd/mpd.pid" user "green" bind_to_address "localhost" port "6600" audio_output { type "pulse" name "PulseAudio Output" } #+end_src * X Session: Runlevel 3 + startx I boot to runlevel 3 and log in on a TTY. X is started manually with =startx=, which reads =~/.dotfiles/config/xinitrc= (symlinked to =~/.xinitrc=): #+begin_src bash #!/bin/sh [ -f ~/.profile ] && . ~/.profile # XDG_RUNTIME_DIR not set by systemd at runlevel 3 mkdir -p /run/user/$(id -u) chmod 700 /run/user/$(id -u) # Start PipeWire audio stack pipewire & pipewire-pulse & wireplumber & exec stumpwm #+end_src PipeWire starts here rather than at boot because it's a user service that needs =XDG_RUNTIME_DIR= set correctly. It runs within the X session lifetime. I also keep KDE Plasma installed and can switch to it by changing =exec stumpwm= to =exec startplasma-x11=. Since I'm at runlevel 3, there's no display manager involved — it's just which process I exec at the end of =xinitrc=. * Input: libinput Touchpad My touchpad is driven by =libinput= rather than the legacy Synaptics driver. =libinput= supports =DisableWhileTyping= natively via the X server, without needing a separate =syndaemon= process: =~/.dotfiles/config/xorg.conf.d/70-touchpad.conf=: #+begin_src conf Section "InputClass" Identifier "touchpad" MatchIsTouchpad "on" Driver "libinput" Option "DisableWhileTyping" "true" Option "Tapping" "on" Option "NaturalScrolling" "false" EndSection #+end_src This is symlinked to =/etc/X11/xorg.conf.d/70-touchpad.conf= by =link-root.sh=. The Synaptics driver is still installed but doesn't match the touchpad because the =libinput= rule takes precedence (the =70-= prefix puts it after the default =10-= synaptics rules). * WiFi Power Management NetworkManager's default behavior is to enable power saving on WiFi, which causes packet loss and latency spikes. One drop config file fixes it: =~/.dotfiles/config/NetworkManager/wifi-powersave-off.conf=: #+begin_src conf [connection] wifi.powersave = 2 #+end_src Value =2= means "disable power saving". This is symlinked to =/etc/NetworkManager/conf.d/wifi-powersave-off.conf=. * StumpWM: Window Manager Configuration StumpWM is a tiling window manager written and configured in Common Lisp. It runs on SBCL and exposes the full language for configuration — no DSL, no limitations. ** Floating Terminal Scratchpads The most interesting thing I've set up in StumpWM is a floating scratchpad system. The goal: press a keybinding, get a floating terminal running a specific program, positioned at a fixed location on screen. Press the binding again (or focus another window), and it goes away. The key insight was =define-frame-preference= with =:float= as the frame number: #+begin_src lisp (define-frame-preference "Default" (:float t t :class "alsamixer-scratch") (:float t t :class "ncmpcpp-scratch")) #+end_src When StumpWM adds a new window to a group, it consults =frame-preference= rules to decide where to place it. =:float= as the frame argument causes the window to be floated /during/ =group-add-window=, before any tiling logic runs. This is the only reliable way to float a window from config — by the time =*new-window-hook*= fires, the window has already been tiled. We use the WM_CLASS property to identify windows. =wezterm --class my-class= sets both fields of WM_CLASS to =my-class=, so the rule matches reliably. The full scratchpad system in =~/.dotfiles/config/stumpwm/config.lisp=: #+begin_src lisp (defvar *float-term-rules* '()) (defun register-float-term (class command x y width height &key persistent) "Register a floating terminal. Add a matching define-frame-preference rule too. If PERSISTENT is t, smart-kill will hide the window instead of killing it." (setf *float-term-rules* (cons (list class command x y width height :persistent persistent) (remove class *float-term-rules* :key #'car :test #'string=)))) ;; Position floats after they appear (define-frame-preference handles the float, ;; the hook handles the geometry) (add-hook *new-window-hook* (lambda (win) (let ((rule (assoc (window-class win) *float-term-rules* :test #'string=))) (when rule (stumpwm::float-window-move-resize win :x (nth 2 rule) :y (nth 3 rule) :width (nth 4 rule) :height (nth 5 rule)))))) (defun spawn-or-focus (class command) "Focus existing window with CLASS, or spawn COMMAND if none exists." (let ((win (find-if (lambda (w) (string= (window-class w) class)) (screen-windows (current-screen))))) (cond ((null win) (run-shell-command command)) ((eq win (current-window)) nil) (t (focus-window win))))) (defcommand smart-kill () () "Kill window, or hide it if it's a persistent float." (let* ((win (current-window)) (class (when win (window-class win))) (rule (when class (assoc class *float-term-rules* :test #'string=)))) (if (and rule (getf (nthcdr 6 rule) :persistent)) (hide-window win) (kill-window-or-frame)))) ;; Registrations (register-float-term "alsamixer-scratch" "wezterm start --class alsamixer-scratch -- alsamixer" 628 450 800 400) (register-float-term "ncmpcpp-scratch" "wezterm start --class ncmpcpp-scratch -- ncmpcpp" 628 300 900 500 :persistent t) #+end_src =ncmpcpp= is registered as =:persistent t=, so =s-q= (=smart-kill=) hides it rather than closing it — preserving the MPD connection. =alsamixer= is not persistent, so =s-q= just quits it. =s-F= (=select-floating-window=) lets you recover a hidden float. ** Keybindings StumpWM supports both a prefix-key map (=C-t= by default) and direct top-level bindings via =*top-map*=. I prefer Super-key bindings for common actions: | Binding | Action | |-----------+---------------------------------| | =s-d= | rofi run launcher | | =s-w= | rofi window picker | | =s-hjkl= | focus frame (vim directions) | | =s-q= | smart-kill | | =s-f= | toggle fullscreen | | =s-F= | select floating window | | =s-1..4= | switch group | | =s--= | vertical split | | =s-\= | horizontal split | | =s-c a= | alsamixer float | | =s-m p= | ncmpcpp float | | =s-m v= | pick video with mpv | | =s-m d= | download URL from clipboard | Brightness is via a sub-map: =s-c b 1= through =s-c b 0= set 10%-100%. ** Gaps =swm-gaps= module provides inner and outer gap support. I have keybindings to switch between gap presets (=s-W g 0/1/2/3= for none/small/medium/large). * Dotfiles Management All of the above is stored in =~/.dotfiles= and managed by two scripts: - =link.sh= — user-level symlinks (run as self) - =link-root.sh= — system-level symlinks (run as root) Both scripts use a =backup_and_link= function that moves any existing file to =.bak= before creating the symlink, so they're safe to run on a system that already has config files in place. =link-root.sh= also idempotently appends service invocations to =/etc/rc.local= if they're not already present, and =chmod +x='s the rc scripts it symlinks. The advantage of this approach over Nix or stow: it's plain shell, readable, debuggable, and doesn't require any tooling to be installed first. The disadvantage: no atomicity, no rollback. For a single personal machine that's a fine tradeoff.