The Makefile that actually runs my NixOS host
A NixOS switch that restarts your display manager will kill itself halfway through if you started it from a terminal inside that session. greetd goes down, your compositor goes with it, your terminal is a child of the compositor, and nixos-rebuild catches the same signal everything else did. You get a partially activated system and a login screen, if you’re lucky.
I solved that once, badly, by remembering to run rebuilds from a TTY. Then I solved it properly and put it in a Makefile, which is the actual argument for keeping an ops layer in your nix-config. A solved problem you have to remember to apply is still an unsolved problem.
Here’s what’s in the file after a year of that.
The target that justifies the whole file
## switch-detached: Switch as a detached system unit (survives session manager/greetd teardowns)
switch-detached:
sudo systemd-run --collect --unit=nixos-switch \
--setenv=TMPDIR=$(REBUILD_TMPDIR) \
--setenv=SUDO_UID=$$(id -u) \
--setenv=PATH=/run/current-system/sw/bin:/run/wrappers/bin \
nixos-rebuild switch --flake $(FLAKE_DIR)/#$(HOST)
@echo ""
@echo "++ Switch running detached as system unit 'nixos-switch'."
@echo "++ Follow: journalctl -u nixos-switch -f"
@echo "++ Status: systemctl status nixos-switch"
systemd-run starts the rebuild as a transient system unit instead of a child of your shell. It’s outside the session cgroup, so when greetd tears the session down the switch keeps going. --collect garbage-collects the unit once it exits, so you don’t accumulate failed units you have to systemctl reset-failed later.
The three --setenv lines are the part I’d never retype correctly. A transient system unit doesn’t inherit your environment, which means TMPDIR is gone, SUDO_UID is gone, and PATH is whatever systemd decides it is. Activation scripts that want to know which user invoked the rebuild read SUDO_UID. Without it they get root or nothing.
That’s also why FLAKE_DIR is an absolute path:
FLAKE_DIR := /persist/home/lowcache/.nix-config
The unit doesn’t run in my working directory, so .#volnix means nothing to it. On a tmpfs root the /persist path is the real one and $HOME is a bind mount over it, so I point at the source rather than the view.
Then you run make switch-detached, close the laptop lid if you want, and read journalctl -u nixos-switch -f afterward. None of that is hard. All of it is impossible to remember at 2am.
The variable that has to be on every rebuild
REBUILD_TMPDIR ?= $(HOME)/Storage/tmp
On a machine with a RAM-backed root, /tmp is RAM. A kernel build or a large closure copy will fill it and you get ENOSPC partway through something expensive. The fix is one environment variable pointed at real disk.
The problem isn’t knowing that. It’s that TMPDIR has to be set on switch, build, test, dry-activate, boot, and the detached unit, and forgetting it on exactly one of them is how you find out. In the Makefile it’s declared once and referenced six times:
## build: Build system configuration without switching
build:
TMPDIR=$(REBUILD_TMPDIR) nixos-rebuild build --flake .#$(HOST)
## test: Temporarily switch to configuration (no boot entry)
test:
sudo TMPDIR=$(REBUILD_TMPDIR) nixos-rebuild test --flake .#$(HOST)
This is the whole value proposition in one variable. A shell alias could do any one of these. It can’t guarantee all six agree.
?= and :=, doing different jobs on purpose
The distinction matters here in a way it usually doesn’t in tutorials.
HOST ?= volnix
REBUILD_TMPDIR ?= $(HOME)/Storage/tmp
FLAKE_DIR := /persist/home/lowcache/.nix-config
HOST is ?= because I want make switch HOST=other-box to work without editing anything. Same for REBUILD_TMPDIR, which someone cloning this repo will want to point somewhere else. FLAKE_DIR is := because the detached unit cannot work with a different value, and letting the environment override it would produce a confusing failure rather than an obvious one.
The rule I’ve settled on: ?= for anything a caller might reasonably want to change, := for anything that would break if they did.
A Make variable holding an entire nix invocation
This one I like more than I expected to.
MKDOCS := nix shell --impure --expr 'let f = builtins.getFlake (toString ./.); pkgs = f.inputs.nixpkgs.legacyPackages.x86_64-linux; in pkgs.python313.withPackages (ps: [ ps.mkdocs-material ])' -c mkdocs
The wiki builds with MkDocs Material, which I don’t want installed system-wide for a tool I run maybe twice a week. nix shell gives it to me ephemerally. The --expr reads nixpkgs out of my own flake’s inputs via builtins.getFlake, so the docs build against the same pinned nixpkgs as the system does, not whatever my ambient channel happens to be.
Because it’s a variable, it composes:
## docs-serve: Live-preview the docs site locally (http://127.0.0.1:8000)
docs-serve:
$(MKDOCS) serve
## docs-build: Build the static site strictly to ./site
docs-build:
$(MKDOCS) build --strict
## docs-deploy: Build and rsync ./site to remote documentation host
docs-deploy: docs-build
rsync --delete -rv ./site/ $(DOCS_REMOTE):/$(DOCS_PROJECT)
Three targets, one definition of what “mkdocs” means. If I bump python313 to python314 it happens in one place. A scripts/ directory would have that expression pasted three times, and one of them would drift.
Commands you are never going to memorize
Some things belong behind a target purely because the real invocation is unmemorable. git subtree is the canonical example:
## dots-push: Publish dots/ history to remote
dots-push:
git subtree push --prefix=$(DOTS_PREFIX) $(DOTS_REMOTE) $(DOTS_BRANCH)
## dots-pull: Merge changes from remote back into dots/
dots-pull:
git subtree pull --prefix=$(DOTS_PREFIX) $(DOTS_REMOTE) $(DOTS_BRANCH)
I’ve been using subtrees for years and I still look up the flag order every time. Now I don’t.
The sops targets are the same idea with a correctness angle attached:
## sops-edit: Decrypt and edit SOPS secrets file
sops-edit:
SOPS_AGE_KEY_FILE=$(SOPS_AGE_KEY_FILE) sops $(SOPS_FILE)
## sops-rekey: Re-encrypt secrets across public host keys listed in .sops.yaml
sops-rekey:
SOPS_AGE_KEY_FILE=$(SOPS_AGE_KEY_FILE) sops updatekeys $(SOPS_FILE)
SOPS_AGE_KEY_FILE threaded through consistently means I can’t decrypt with the wrong key by forgetting to export something in a fresh shell. On a system that wipes $HOME on reboot, “I exported it earlier” is not a thing that stays true.
There’s also dots-split, which uses a Make feature most people write around:
dots-split:
@echo "Regenerating '$(DOTS_SPLIT_BRANCH)' projection of $(DOTS_PREFIX)/ ..."
-@git branch -D $(DOTS_SPLIT_BRANCH) >/dev/null 2>&1 || true
git subtree split --prefix=$(DOTS_PREFIX) -b $(DOTS_SPLIT_BRANCH)
The - prefix on a recipe line tells Make to keep going when that command fails. Deleting a branch that doesn’t exist yet should not abort the target. It’s the Make-native version of || true, and I’ve written both on the same line here, which is belt and suspenders but costs nothing.
Recursive make, used for something small
## git: Automated procedure to commit changes and sync with remote
git:
@run() { $(MAKE) --no-print-directory push && $(MAKE) --no-print-directory comm && $(MAKE) --no-print-directory push; }; \
if ssh-add -l >/dev/null 2>&1; then \
run; \
else \
eval "$$(ssh-agent -s)" >/dev/null 2>&1; \
trap 'kill "$$SSH_AGENT_PID" 2>/dev/null' EXIT; \
echo "++ Starting ssh-agent (passphrase required once)..."; \
ssh-add ~/.ssh/id_ed25519 || exit 1; \
run; \
fi && echo "++ Git Repo Updated."
Push, commit, push. The leading push flushes anything I committed earlier and forgot to send, so the second one isn’t racing a remote that moved.
The ssh-agent handling is the reason this is a target and not an alias. If an agent’s already loaded, use it. Otherwise start a throwaway one, trap its PID on EXIT so it dies with the recipe, and unlock the key once for all three subcommands instead of three separate passphrase prompts.
Two details that took me a while to get right. $(MAKE) rather than a literal make, so the sub-invocations inherit the parent’s flags and job server. And --no-print-directory, without which every recursive call prints an “Entering directory” banner that buries the actual output.
comm uses read -p to prompt for a message, which means make git deliberately can’t run unattended. That’s the intent. Anything that commits my system config without me typing something is a thing I want to not exist.
What this file doesn’t do
Twenty-eight targets and exactly one prerequisite edge: docs-deploy: docs-build. That’s it. The DAG engine that Make supposedly brings to the table is doing close to nothing here.
I think that’s fine, and I’d rather say it than pretend otherwise. What I’m actually getting is a discoverable namespace with consistent environment handling. make help parses the ## comments out of the file itself and prints every operation the repo supports:
## help: Display available targets and descriptions
help:
@echo "Vol NixOS Helper Makefile"
@echo ""
@sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':'
That’s the feature I use most, and it’s four lines of sed. If I ever grow real build dependencies between these targets, Make is already there to express them. Until then it’s a very good command table that happens to be in git and happens to work on a fresh clone.
The other thing it doesn’t do is run on someone else’s machine unmodified. FLAKE_DIR has my username in it. MKDOCS pins x86_64-linux. Fork it, don’t clone it.
The whole file
# Vol NixOS Makefile
# Unifies system rebuilds, MicroVM guest management, SOPS secrets, and maintenance tasks.
# --- Configuration & Environment Defaults ---
HOST ?= volnix
REBUILD_TMPDIR ?= $(HOME)/Storage/tmp
FLAKE_DIR := /persist/home/lowcache/.nix-config
# --- Dotfiles Subtree Config ---
DOTS_PREFIX ?= dots
DOTS_REMOTE ?= dotfiles
DOTS_BRANCH ?= main
DOTS_SPLIT_BRANCH ?= dots-history
# --- SOPS / Secret Management ---
SOPS_FILE ?= secrets/secrets.yaml
SOPS_AGE_KEY_FILE ?= ~/.config/sops/age/keys.txt
# --- Documentation site (MkDocs Material) ---
DOCS_PROJECT ?= wiki
DOCS_REMOTE ?= pgs.sh
# Ephemeral MkDocs Material environment from the flake's own pinned nixpkgs
MKDOCS := nix shell --impure --expr 'let f = builtins.getFlake (toString ./.); pkgs = f.inputs.nixpkgs.legacyPackages.x86_64-linux; in pkgs.python313.withPackages (ps: [ ps.mkdocs-material ])' -c mkdocs
# --- All Targets Declared PHONY ---
.PHONY: help switch switch-detached build test dry-activate boot \
run-netgate run-tailscale \
sops-edit sops-rekey sops-view \
check fmt update update-nixpkgs trash \
git comm push \
dots-log dots-split dots-remote dots-push dots-pull \
docs-serve docs-build docs-deploy
.DEFAULT_GOAL := help
## help: Display available targets and descriptions
help:
@echo "Vol NixOS Helper Makefile"
@echo ""
@sed -n 's/^##//p' $(MAKEFILE_LIST) | column -t -s ':'
# ==============================================================================
# System Operations
# ==============================================================================
## switch: Rebuild and switch system live (Default HOST: volnix)
switch:
sudo TMPDIR=$(REBUILD_TMPDIR) nixos-rebuild switch --flake .#$(HOST) --option fallback true
## switch-detached: Switch as a detached system unit (survives session manager/greetd teardowns)
switch-detached:
sudo systemd-run --collect --unit=nixos-switch \
--setenv=TMPDIR=$(REBUILD_TMPDIR) \
--setenv=SUDO_UID=$$(id -u) \
--setenv=PATH=/run/current-system/sw/bin:/run/wrappers/bin \
nixos-rebuild switch --flake $(FLAKE_DIR)/#$(HOST)
@echo ""
@echo "++ Switch running detached as system unit 'nixos-switch'."
@echo "++ Follow: journalctl -u nixos-switch -f"
@echo "++ Status: systemctl status nixos-switch"
## build: Build system configuration without switching
build:
TMPDIR=$(REBUILD_TMPDIR) nixos-rebuild build --flake .#$(HOST)
## test: Temporarily switch to configuration (no boot entry)
test:
sudo TMPDIR=$(REBUILD_TMPDIR) nixos-rebuild test --flake .#$(HOST)
## dry-activate: See what service transitions will happen
dry-activate:
sudo TMPDIR=$(REBUILD_TMPDIR) nixos-rebuild dry-activate --flake .#$(HOST)
## boot: Stage the rebuild for the next boot
boot:
sudo TMPDIR=$(REBUILD_TMPDIR) nixos-rebuild boot --flake .#$(HOST)
# ==============================================================================
# MicroVM Guest Operations
# ==============================================================================
## run-netgate: Start the Tor net-gate MicroVM runner
run-netgate:
nix run .#net-gate
## run-tailscale: Start the Tailscale-vm MicroVM runner
run-tailscale:
nix run .#tailscale-vm
# ==============================================================================
# Secrets Management (SOPS / Age)
# ==============================================================================
## sops-edit: Decrypt and edit SOPS secrets file
sops-edit:
SOPS_AGE_KEY_FILE=$(SOPS_AGE_KEY_FILE) sops $(SOPS_FILE)
## sops-rekey: Re-encrypt secrets across public host keys listed in .sops.yaml
sops-rekey:
SOPS_AGE_KEY_FILE=$(SOPS_AGE_KEY_FILE) sops updatekeys $(SOPS_FILE)
## sops-view: Print decrypted secrets without opening an editor
sops-view:
SOPS_AGE_KEY_FILE=$(SOPS_AGE_KEY_FILE) sops -d $(SOPS_FILE)
# ==============================================================================
# Flake & Code Maintenance
# ==============================================================================
## check: Check flake lock and schema validity
check:
nix flake check
@echo "==Dead Code & Antipattern Checks=="
@echo "==Running Deadnix=="
@deadnix --fail .
@echo "==Running Statix Check=="
@statix check .
## fmt: Auto-format all Nix expressions
fmt:
nix fmt
@echo "==Statix Fix=="
@statix fix .
## update: Update all flake inputs
update:
nix flake update
## update-nixpkgs: Update only the nixpkgs input
update-nixpkgs:
nix flake update nixpkgs
## trash: Delete system profile generations older than 7 days and clean store
trash:
@echo "++ Deleting system profile generations older than 7 days..."
sudo nix-env --profile /nix/var/nix/profiles/system --delete-generations 7d
@echo "++ Running Nix store garbage collection..."
nix-store --gc
# ==============================================================================
# Git Operations
# ==============================================================================
## git: Automated procedure to commit changes and sync with remote
git:
@run() { $(MAKE) --no-print-directory push && $(MAKE) --no-print-directory comm && $(MAKE) --no-print-directory push; }; \
if ssh-add -l >/dev/null 2>&1; then \
run; \
else \
eval "$$(ssh-agent -s)" >/dev/null 2>&1; \
trap 'kill "$$SSH_AGENT_PID" 2>/dev/null' EXIT; \
echo "++ Starting ssh-agent (passphrase required once)..."; \
ssh-add ~/.ssh/id_ed25519 || exit 1; \
run; \
fi && echo "++ Git Repo Updated."
## comm: Scan repo, stage changes, and prompt for commit message
comm:
@echo "++ Scanning Repo..."; \
git add .; \
echo "++ Staging Commit..."; \
if read -p "++ Enter Commit Message: " cm && [ -n "$$cm" ]; then \
echo ""; \
git commit -m "$$cm" || true; \
else \
echo "++ ERROR: Commit Message Invalid."; \
echo "++ Aborting..."; \
exit 1; \
fi
## push: Push local commits to remote using ssh-agent
push:
@echo "++ Pushing to Remote..."; \
if ssh-add -l >/dev/null 2>&1; then \
git push; \
else \
echo "++ No usable ssh-agent found; starting one (passphrase required)..."; \
eval "$$(ssh-agent -s)" >/dev/null 2>&1; \
ssh-add ~/.ssh/id_ed25519 && git push; rc=$$?; \
kill "$$SSH_AGENT_PID" 2>/dev/null; \
exit $$rc; \
fi
# ==============================================================================
# Dotfiles Subtree Operations
# ==============================================================================
## dots-log: Show git log scoped to dots/ prefix
dots-log:
git log --oneline -- $(DOTS_PREFIX)
## dots-split: Regenerate the projection branch of dots/
dots-split:
@echo "Regenerating '$(DOTS_SPLIT_BRANCH)' projection of $(DOTS_PREFIX)/ ..."
-@git branch -D $(DOTS_SPLIT_BRANCH) >/dev/null 2>&1 || true
git subtree split --prefix=$(DOTS_PREFIX) -b $(DOTS_SPLIT_BRANCH)
## dots-remote: Add standalone dotfiles remote (Usage: make dots-remote URL=<url>)
dots-remote:
@test -n "$(URL)" || { echo "Usage: make dots-remote URL=<git-url>"; exit 1; }
git remote add $(DOTS_REMOTE) "$(URL)"
@echo "Added remote '$(DOTS_REMOTE)' -> $(URL)"
## dots-push: Publish dots/ history to remote
dots-push:
git subtree push --prefix=$(DOTS_PREFIX) $(DOTS_REMOTE) $(DOTS_BRANCH)
## dots-pull: Merge changes from remote back into dots/
dots-pull:
git subtree pull --prefix=$(DOTS_PREFIX) $(DOTS_REMOTE) $(DOTS_BRANCH)
# ==============================================================================
# Documentation Wiki (MkDocs Material -> pgs.sh)
# ==============================================================================
## docs-serve: Live-preview the docs site locally (http://127.0.0.1:8000)
docs-serve:
$(MKDOCS) serve
## docs-build: Build the static site strictly to ./site
docs-build:
$(MKDOCS) build --strict
## docs-deploy: Build and rsync ./site to remote documentation host
docs-deploy: docs-build
rsync --delete -rv ./site/ $(DOCS_REMOTE):/$(DOCS_PROJECT)
Config: lowcache/volnixos · The companion post covers Make itself, and why it beats a scripts/ directory.
Written from the machine it describes. More field notes: the archive · RSS.
Working on something in this territory? I take on scoped engagements.