<<<

dotfiles

Updated scripts are maintained at github.com/davidemerson/dotfiles.

These dotfiles configure a workstation on Debian Linux, OpenBSD, or macOS from a single POSIX shell script. No Salt, Ansible, or other configuration management tools, just sh provision.sh. The script is idempotent and safe to re-run after pulling updates.

Supported platforms are Debian 13+, OpenBSD 7.8+, and macOS with Homebrew.

how it works

provision.sh detects the OS via uname -s and branches accordingly:

On Linux and OpenBSD it runs as root and prompts for the username to provision (creating the user if needed, putting it in sudo or wheel, and setting bash as the login shell). It also prompts for a hostname and makes sure the hostname resolves in /etc/hosts, which prevents slow lookups. On macOS it runs as your normal user.

os-conditional blocks

Some config files need different content per OS. Rather than maintaining separate files, I use simple markers. From .bashrc:

# @@IF_OPENBSD@@
if [ "$(tty)" = "/dev/ttyC0" ]; then
	startx
fi
# @@END_IF@@
# @@IF_LINUX@@
if [ "$(tty)" = "/dev/tty1" ] && ! systemctl is-active --quiet greetd 2>/dev/null; then
	exec sway
fi
# @@END_IF@@

At deploy time, sed strips blocks for other OSes and removes the marker comments, leaving a clean config file. There is also a @@HOME@@ marker that gets replaced with the target user’s home directory (.issyrc uses it for an absolute font path). This replaces the Jinja2/Salt templating from the earlier version of this project with zero dependencies. .gitconfig uses the same trick to enable delta as the diff pager only on Linux, where it’s packaged.

file routing

Not every dotfile deploys on every OS:

One file is special: .config/workstation.conf is seeded once and never overwritten on re-runs, so each machine keeps its own values.

procedure

linux (debian)

VMWare Workstation settings (if applicable):

Standard Debian installation, selecting Desktop, SSH Server, and Standard System Utilities. Then:

su -
apt update && apt install git
git clone https://github.com/davidemerson/dotfiles.git
cd dotfiles/
sh provision.sh
reboot

After reboot, greetd presents tuigreet on vt7. Logging in runs /usr/local/bin/sway-session, a login shell wrapper so the session inherits the environment from .bashrc, which then launches sway. Mod4+Return opens foot. If greetd isn’t running for some reason, a tty1 console login starts sway on its own, so a broken greeter never locks me out of the desktop.

Beyond the desktop, the Linux run also sets the console font to Berkeley Mono (see below), masks gdm in favor of greetd, sets the timezone to America/New_York, pins NTP servers for systemd-timesyncd with a Cloudflare fallback, installs Sublime Text from the official apt repository, sets Google Chrome as the default browser through update-alternatives and the per-user xdg default, and installs open-vm-tools-desktop when it detects VMware.

openbsd

Standard OpenBSD installation. On smaller disks, ensure /usr/local has at least 2GB (the full install uses about 1.6GB in /usr/local).

pkg_add git
git clone https://github.com/davidemerson/dotfiles.git
cd dotfiles/
sh provision.sh
reboot

After reboot, xenodm presents a login on a solid black background. Logging in runs ~/.xsession, which launches i3. Mod4+Return opens st.

Why xenodm? The VMware SVGA adapter has no DRM/KMS driver on OpenBSD, so Xorg needs aperture access, which means running as root. xenodm provides that without making Xorg setuid. The provisioning script comments out xconsole in Xsetup_0 (otherwise i3 tiles it fullscreen as the only window) and keeps the script executable after editing it. xenodm reads ~/.xsession rather than ~/.xinitrc, so .xinitrc is mirrored to .xsession: the same i3 session launches whether you come in through xenodm or through startx (which .bashrc still runs automatically on a ttyC0 console login).

Beyond the desktop, the script does a fair amount of OpenBSD system configuration:

Why i3 instead of sway on OpenBSD? OpenBSD on VMware uses a framebuffer with no DRM/GPU driver, which means sway (Wayland) can’t create a rendering backend. i3 on X11 works out of the box. The i3 config mirrors the sway config (same keybindings, colors, gaps, workspaces) and adds a few things of its own: thin 2px borders with no title bars, auto-floating for dialogs and pop-ups, and an st scratchpad terminal started at launch.

macos

git clone https://github.com/davidemerson/dotfiles.git ~/dotfiles
cd ~/dotfiles
sh provision.sh

Run as your normal user, not root. The script installs Homebrew if missing, then CLI tools, WezTerm, and the application casks. Since CoreText doesn’t scan ~/.fonts, the font is also copied to ~/Library/Fonts so WezTerm can find it.

sshd hardening

On Linux and OpenBSD, the script sets sshd to key-only auth (PasswordAuthentication no, KbdInteractiveAuthentication no). The candidate config is validated with sshd -t before it replaces the real one, so a bad edit can never lock you out. macOS keeps its own Remote Login settings.

manual steps after provisioning

  1. Place the SSH key at ~/.ssh/id_d_nnix.pem (plus matching .pub). Both .ssh/config and .gitconfig reference that path, for auth and for commit signing. If you use a different key, edit .gitconfig, .ssh/config, and .config/git/allowed_signers in the repo first. If the private key has no sibling .pub, generate one with ssh-keygen -y, so workstation can tell when the key is already loaded.
  2. Fill in ~/.config/workstation.conf if you want the workstation command (see below).
  3. Sign in to 1Password and enable its browser integration in Chrome. Chrome is allow-listed by default, so there’s nothing else to configure.

what gets installed

ComponentLinuxOpenBSDmacOS
Window ManagerSway (Wayland)i3 (X11)
Greetergreetd + tuigreetxenodm
Terminalfootst (patched)WezTerm
Status Barwaybari3bar + i3status
Launcherwofidmenu (patched)
Lockswaylocki3lock via lock script
Notificationsmakodunst
Volumepamixer + wobsndioctl via volnotify
Clipboardwl-clipboard + cliphistclipmenu
Privilegesudodoassudo
BrowserGoogle ChromeChromium
Password manager1Password + CLI1Password + CLI
MailFastmailFastmail
Notes / TasksJoplin, TodoistJoplin, Todoist
MediaVLC, AudacityVLC, AudacityVLC, Audacity
MeetingsZoomZoom
Git GUIGitHub Desktop (community)GitHub Desktop
NetworkingZeroTier
AI CLIClaude CodeClaude Code
Editorissy (default), micro, nano, Sublime Textissy (default), nanoissy (default), micro, nano
Shellbashbashzsh
Multiplexertmux, herdrtmux (base)tmux, herdr
Remote shellmoshmoshmosh
Fetchpfetch + sysinfopfetch + sysinfopfetch + sysinfo
Firmware / ECCfwupd, rasdaemon
Smart cardpcscd + libccid + opensc
Toolshtop, btop, nmap, screen, lsd, ethtoolhtop, btop, nmap, screen, lsdhtop, btop, nmap, lsd
FontBerkeley MonoBerkeley MonoBerkeley Mono

pfetch isn’t packaged on Debian or OpenBSD, so the script fetches the upstream script directly to /usr/local/bin. herdr ships prebuilt binaries only: Homebrew on macOS, a GitHub release binary on Linux, and no OpenBSD builds, so it’s skipped there. Claude Code installs per-user to ~/.local/bin/claude on Linux and macOS, and is skipped on OpenBSD.

desktop applications

On Linux the apps come from wherever upstream actually ships them, which is not consistent:

macOS gets the same set as official casks, including the genuine GitHub Desktop. OpenBSD gets VLC and Audacity from packages; none of the rest have OpenBSD builds, so they’re skipped.

built from source

Three things are compiled rather than installed from packages:

issy

issy is my text editor, and it’s the default EDITOR on all three platforms. The script builds it from the latest source with Zig. issy needs Zig 0.15.x (0.16 breaks it), so the script verifies any zig on PATH and otherwise installs a pinned one: zig@0.15 via Homebrew, pkg_add zig on OpenBSD, or the official 0.15.2 tarball on Linux. Re-runs compare the installed commit (from issy --version) against upstream HEAD and rebuild only when upstream is newer. If Homebrew already manages issy on macOS, brew keeps ownership and the script just runs brew upgrade. Configured via ~/.issyrc, which points at the Berkeley Mono font file for PDF printing.

st and dmenu (openbsd)

The stock st and dmenu packages stay installed as fallbacks (and for terminfo), but the binaries are replaced with builds from st-flexipatch and dmenu-flexipatch, pinned to specific commits, with config.h and patches.h kept in this repo.

st patches: clipboard (selection auto-copies to the system CLIPBOARD), keyboard-select (mouseless copy), scrollback with mouse wheel, anysize, bold-is-not-bright, boxdraw. dmenu patches: fuzzy match with highlighting, case-insensitive, centered, line-height padding, border.

The builds are stamped with the flexipatch commit plus the OS release, and rebuild when the pinned commit changes, when uname -r changes (a sysupgrade breaks old binaries), when the binary fails ldd, or when the on-disk binary isn’t ours (checked by looking for Berkeley Mono in strings output, because pkg_add -u reinstalls the stock package over the patched one).

the console font (linux)

The Linux console runs Berkeley Mono too. scripts/build-console-font.sh rasterizes bmv.otf into a ~16x32 PSF bitmap, which lands in /usr/share/consolefonts and gets set through console-setup’s FONT=. The greeter’s vt7 isn’t covered by console-setup, so a greetd.service drop-in runs setfont there as well. tuigreet’s password mask is rather than the default , because Berkeley Mono has no glyph for U+203B. tuigreet also has no flag to suppress its window title, so scripts/patch-tuigreet-title.sh handles that, re-applied automatically after upgrades via an APT hook.

keybindings

Consistent across sway (Linux) and i3 (OpenBSD):

KeyAction
Mod4 + ReturnTerminal (foot / st)
Mod4 + dLauncher (wofi / dmenu)
Mod4 + bBrowser (Chrome / Chromium)
Mod4 + zLock screen
Mod4 + cClipboard history (cliphist / clipmenu)
Mod4 + Shift+qKill window
Mod4 + wKill window (alias)
Mod4 + Shift+eExit (with confirmation)
Mod4 + Shift+sShutdown (with confirmation)
Mod4 + j/k/i/lFocus left/down/up/right
Mod4 + Shift+j/k/i/lMove window
Mod4 + h/vSplit horizontal/vertical
Mod4 + 1-0Switch workspace
Mod4 + Shift+1-0Move to workspace
Mod4 + Shift+spaceToggle floating
Mod4 + m/nVolume up/down
Mod4 + rEnter resize mode
Mod4 + Shift+cReload config
Mod4 + Shift+rReload (sway) / restart (i3)

Linux adds the application bindings and Wayland screenshots:

KeyAction
Mod4 + tTodoist
Mod4 + eFastmail
Mod4 + p1Password quick access
Mod4 + Shift+p1Password main window
Mod4 + Shift+z1Password lock
Print / Shift+PrintScreenshot, full screen / select region

OpenBSD adds a few of its own:

KeyAction
Mod4 + Shift+mMute toggle
Mod4 + `Scratchpad terminal
Print or Mod4 + pScreenshot, full screen
Mod4 + Print or Mod4 + Shift+pScreenshot, select region

helper scripts

Deployed to ~/.local/bin:

On Linux, sway handles the equivalents natively: swayidle locks with swaylock at 15 minutes and again before sleep, powers outputs off at 30, and volume goes through pamixer with a wob overlay bar. The waybar volume module sits right of memory: scroll to change, left-click opens pavucontrol to pick an output device, right-click mutes.

clipboard and ssh-agent

Two small things that make the desktop behave like the terminal does.

On sway, wl-paste --primary --watch wl-copy mirrors the primary selection into the clipboard, so selecting text anywhere makes it pasteable with Ctrl+Shift+V, matching how patched st behaves on OpenBSD. wl-paste --watch cliphist store keeps a history, so a selection that clobbers something you copied earlier is recoverable; Mod4+c opens the history in a wofi picker.

On Linux and OpenBSD, .bashrc starts one shared per-user ssh-agent bound to a fixed socket in $XDG_RUNTIME_DIR and reuses it across every shell and the WM session it launches. Without it, a fresh sway or i3 session has no agent at all, and both workstation and SSH commit signing have nowhere to load the key. macOS uses the launchd agent instead. workstation will start the same shared agent itself if it can’t reach one.

maintenance and hardening (linux)

Beyond Debian’s stock fstrim, logrotate, and fwupd-refresh timers, configure_maintenance adds:

configure_hardening adds an nftables host firewall (default-deny inbound, allowing loopback, established/related, ICMP, SSH, mosh on UDP 60000-61000, and ZeroTier on UDP 9993, with outbound open), a conservative sysctl drop-in (kptr_restrict, dmesg_restrict, yama.ptrace_scope, no ICMP redirects or source routing, syncookies, fs.protected_*), zram compressed swap (zstd, half of RAM, so memory spikes stay off the NVMe), and systemd-oomd for graceful behavior under memory pressure.

fwupd is installed but the script never flashes firmware. That’s deliberate and stays out of band: fwupdmgr refresh && fwupdmgr update, by hand, when I mean it. rasdaemon is enabled to log ECC/MCE events, which is useful wherever the kernel EDAC layer sees a memory controller and a no-op otherwise.

shell environment

.bashrc (Linux/OpenBSD) and .zshrc (macOS) implement the same two-line prompt: user@host @ cwd on the first line, a blue >>> on the input line, and a right-aligned status badge (navy with a timestamp on success, inverted light gray with the exit code on failure). bash paints the badge onto the first line with cursor positioning; zsh uses RPROMPT, so it sits beside the input line instead.

Aliases in both:

alias ls='lsd -laF'
alias ll='lsd -laF'
alias la='lsd -la'
alias top='btop'

Both set EDITOR=issy, 20k lines of deduplicated history, and colored man pages via LESS_TERMCAP (light blue headings, navy standout). On Linux there’s also fzf (Ctrl-R for history, Ctrl-T for files) and fd, aliased from fdfind. Interactive logins print a minimal fetch: hostname header, pfetch (os, shell, uptime, memory), then sysinfo’s cpu, disk, and epoch lines. It’s suppressed inside tmux to avoid per-pane spam.

.bash_profile sources .profile (for PATH on OpenBSD) and then .bashrc.

theming

Everything uses one palette: grayscale plus navy #1f2d4d and light blue #8fb6e0/#bcd6f2, on black.

git

.gitconfig signs commits and tags with the SSH key (gpg.format = ssh), verified against ~/.config/git/allowed_signers, and carries a set of defaults I want everywhere: main as the initial branch, fast-forward-only pulls, autoSetupRemote on push, pruning fetches, autostash on rebase, zdiff3 conflict style, and histogram diffs with move detection. On Linux it also uses delta as the pager and interactive diff filter. .ssh/config carries the GitHub identity, with UseKeychain on macOS.

repository structure

provision.sh              # the whole show (POSIX sh)
validate.sh               # checks all expected files exist
Makefile                  # make validate / provision / backup
DEPENDENCIES.md           # platform and package inventory
README.md, LICENSE
.gitignore                # excludes backups, SSH keys, session junk
scripts/
├── healthcheck               # weekly health check (Linux, → /usr/local/bin)
├── build-console-font.sh     # rasterizes bmv.otf into a console PSF
├── BerkeleyMonoNNIX.psf.gz   # the resulting console font
└── patch-tuigreet-title.sh   # drops tuigreet's window title
st/config.h, patches.h    # patched st build config (OpenBSD)
dmenu/config.h, patches.h # patched dmenu build config (OpenBSD)
dotfiles/
├── .bashrc, .bash_profile   # bash: prompt, aliases, ssh-agent, sway/startx autostart
├── .zshrc                    # zsh: same prompt and aliases (macOS)
├── .xinitrc                  # xrdb, cursor, caps→escape, key repeat, dbus + i3
├── .Xresources               # crisp Xft rendering, plan9 cursor
├── .gitconfig                # identity, SSH commit signing, defaults, delta
├── .ssh/config               # GitHub host entry
├── .tmux.conf                # tmux behavior + palette
├── .wezterm.lua              # WezTerm: font, palette, panes (macOS)
├── .issyrc                   # issy editor settings
├── .fonts/bmv.otf            # Berkeley Mono Variable NNIX
├── .icons/plan9/             # plan9 cursor theme
├── .local/bin/               # workstation, lock, shot, volnotify, sysinfo
└── .config/
    ├── sway/, waybar/, foot/, swaylock/, wofi/, mako/  # Linux desktop
    ├── i3/, i3status/, dunst/                          # OpenBSD desktop
    ├── fontconfig/fonts.conf                     # monospace = Berkeley Mono
    ├── gtk-3.0/, gtk-4.0/                        # dark theme + cursor
    ├── btop/                                     # nnix theme
    ├── micro/, sublime-text-3/                   # other editor settings
    ├── git/allowed_signers                       # SSH signature verification
    └── workstation.conf                          # per-machine, seeded once

make validate runs validate.sh, which checks that every expected file is present. make backup copies the existing dotfiles (.config, the shell rc files, .gitconfig, .ssh, .wezterm.lua, .xinitrc, .tmux.conf, .issyrc, .Xresources, .local) into a timestamped backups/ directory before you overwrite them.

notes

re-applying updates

cd /path/to/dotfiles
git pull
sh provision.sh

All operations are idempotent. Package managers skip installed packages, from-source builds skip when their stamps match, and files are overwritten with the current version (except workstation.conf).

after an openbsd sysupgrade

OpenBSD has no cross-release binary compatibility, so from-source binaries (st, dmenu, issy) stop loading after a sysupgrade. Run pkg_add -u first, then re-run provision.sh. Order matters: pkg_add -u reinstalls the stock st/dmenu over the patched builds, and the provision run detects that and rebuilds them.

vmware svga emulation

On Linux, sway launches with WLR_NO_HARDWARE_CURSORS=1 in .bashrc to work around VMware SVGA limitations. Remove this on bare metal.

windows lock command

If running VMWare on a Windows host, disable Win+L via registry:

HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\DisableLockWorkstation set to 1.

additional references

home | about | github | mastodon

XXIIVV webring

built
epoch
1785266485