Recipes for common admin tasks on bedrock. Most recipes assume you have an account in wheel (any of the users declared in configuration.nix) and are SSHed in:
ssh <USERNAME>@subduction.sync.inkandswitch.comFor deploying changes from your laptop, see the "Deploying" section in README.md and the "Rebuild and activate" section below.
The repo ships a small command vocabulary — logs:tail, service:restart, health, gens, etc. — that's available in two places with identical names:
| Where you are | How to reach it | Implementation |
|---|---|---|
| Your laptop (in a repo checkout) | nix develop (shell prints the menu on entry) |
Each command SSHs to bedrock and runs the work remotely |
| SSHed into bedrock | Already in your PATH — just run them |
Each command runs locally on the server (no SSH round-trip) |
Same command names everywhere, so muscle memory transfers. Run menu in either context to list what's available.
nix develop # prints the menu on entry
menu # re-print the menu inside the shellThe wrappers SSH to bedrock (per your ~/.ssh/config). Override with BEDROCK_HOST:
export BEDROCK_HOST=expede@subduction.sync.inkandswitch.comThis context adds laptop-only commands the server doesn't have:
| Command | What it does |
|---|---|
deploy |
nixos-rebuild switch … with the right --target-host / --build-host / --sudo |
deploy:dry |
Same, but dry-activate (no commit) |
deploy:test |
test-mode rebuild (reverts on reboot) |
deploy:rollback |
Roll back to the previous generation |
shell |
Interactive SSH session on bedrock |
update |
nix flake update (and :subduction, :nixpkgs variants) |
See nix/commands.nix for the full set.
After deploy, every account on bedrock has the same command vocabulary in their PATH — no shell to enter, no nix develop, no PATH tricks. Just SSH in and run them:
ssh bedrock
$ menu
$ logs:tail
$ service:restartThe on-server commands don't include the deploy:* family (you don't deploy from inside the system being deployed), the shell command (you're already there), or update (flake updates live in the repo on your laptop).
See nix/server-commands.nix for the full set.
| Command | What it does (laptop SSHs; server runs locally) |
|---|---|
menu |
Print this command catalogue |
logs:tail |
Follow Subduction logs live |
logs:since [t] |
Show Subduction logs since t (default: 10 minutes ago) |
logs:errors [t] |
Recent Subduction errors only |
logs:warn [t] |
Recent Subduction warnings + errors |
logs:grep <pat> |
Grep Subduction logs for a pattern |
logs:journal |
Follow the entire systemd journal |
service:status |
systemctl status subduction |
service:start |
Start Subduction |
service:stop |
Stop Subduction |
service:restart |
Restart Subduction |
service:units |
Status of every bedrock-owned unit (Caddy, Loki, Grafana, Alloy, Tailscale, sshd) |
health |
Public HTTPS + service status + local sockets |
health:http |
Public HTTPS endpoint only |
disk:usage |
df -h / |
disk:inodes |
df -i / |
disk:trees |
Count Subduction trees currently hosted |
disk:subduction |
Bytes + inodes under /var/lib/subduction/ |
storage:migrate-trees |
Migrate trees/ from flat to sharded layout (stops Subduction; --dry-run to preview) |
gens |
List system generations with timestamps and current marker |
users |
List human accounts (UID ≥ 1000) |
Everything below explains what those wrappers do under the hood — useful when debugging a wrapper, working off-flake, or doing something the menu doesn't cover.
| You want to… | Run |
|---|---|
| See if Subduction is running | systemctl status subduction |
| Tail logs live | sudo journalctl -o cat -u subduction -f |
| Look at the last 10 min of logs | sudo journalctl -o cat -u subduction --since "10 minutes ago" --no-pager |
| Just errors / warnings | append -p err or -p warning (see Log priority filtering) |
| Restart Subduction | sudo systemctl restart subduction |
| Check disk space | df -h / |
| Check inodes | df -i / |
| Count synced trees | sudo find /var/lib/subduction/trees -mindepth 2 -maxdepth 2 -type d | wc -l |
| CPU / memory snapshot | btop |
| Apply a config change pulled from git | sudo nixos-rebuild switch --flake .#bedrock |
| Bump Subduction to a new version | nix flake update subduction then nixos-rebuild switch (see Updating) |
| Roll back the last deploy | sudo nixos-rebuild switch --rollback |
Note
The unit is subduction, not subduction_cli. There is no subduction_cli systemd unit on this host, so systemctl status subduction_cli will silently return nothing useful.
| Action | Command |
|---|---|
| Status | systemctl status subduction |
| Stop | sudo systemctl stop subduction |
| Start | sudo systemctl start subduction |
| Restart | sudo systemctl restart subduction |
| Reload | not supported — restart instead |
The service is configured in configuration.nix under services.subduction.server. After editing the config, deploy with nixos-rebuild switch rather than restarting manually — the rebuild restarts the unit if its config changed.
Subduction writes only to stdout. systemd captures everything in the journal; there is no /var/log/subduction/ directory.
sudo journalctl -o cat -u subduction -fsudo journalctl -o cat -u subduction --since "10 minutes ago" --no-pager
sudo journalctl -o cat -u subduction --since "2026-05-06 14:00" --until "2026-05-06 15:00" --no-pager
sudo journalctl -o cat -u subduction --since today --no-pager--no-pager is useful when piping into rg / grep / wc (otherwise the output goes to less and breaks the pipeline).
Use -p <level> to filter by syslog severity. Valid levels (high → low):
emerg alert crit err warning notice info debug
sudo journalctl -o cat -u subduction --since "10 minutes ago" -p err # errors only
sudo journalctl -o cat -u subduction --since "10 minutes ago" -p warning # warnings + errorsCaution
The level is err, not error; and warning, not warn. -p error and -p warn will silently match nothing.
When you want to find log lines mentioning a specific tree, peer, etc., grep the textual output:
sudo journalctl -o cat -u subduction --since "1 hour ago" --no-pager | rg -i 'WARN|ERR'
sudo journalctl -o cat -u subduction --since today --no-pager | rg '<tree-id-prefix>'(-u WARN to journalctl is not a level filter — -u selects the systemd unit. The right tool is -p for severity or rg for substrings.)
Set in configuration.nix via the module option:
services.subduction.server.logLevel = "subduction=info";Edit + nixos-rebuild switch to change verbosity. Useful values: error, warn, info (default), debug, trace. trace is very chatty.
Note
Set the level through services.subduction.server.logLevel, not a raw
systemd.services.subduction.serviceConfig.Environment = "RUST_LOG=…". The
module already sets RUST_LOG from logLevel, so an Environment override
in the service block would collide with it.
The server runs with logFormat = "json" (services.subduction.server.logFormat),
so each log line is a JSON object with a level field. Alloy's
loki.process "subduction_level" stage (in configuration.nix)
parses that JSON and promotes level to a Loki stream label, which the Grafana
Log Rate by Level panel filters on ({level=~"(?i)error"}, etc.). If that
panel is empty, check that the server still emits JSON and that the Alloy stage
is present.
State lives under /var/lib/subduction/. Trees use a sharded layout:
trees/{first-4-hex}/{remaining-60-hex}/, where the 4-hex bucket is the
first two bytes of the tree id and the leaf is the rest (concatenate the two
to recover the full 64-hex id).
sudo ls /var/lib/subduction/ # top-level layout
sudo find /var/lib/subduction/trees -mindepth 2 -maxdepth 2 -type d | wc -l # how many trees we host
sudo ls /var/lib/subduction/trees/<prefix>/<rest>/ # contents of one tree
sudo ls /var/lib/subduction/trees/<prefix>/<rest>/commitsTree IDs are 64-hex-character strings. To find one by a known prefix, split it into the 4-char bucket and the remainder:
sudo ls /var/lib/subduction/trees/<first-4-chars>/ # leaves in that bucketNote
This sharded layout was introduced in a Subduction upgrade. Servers that
predate it store trees flat (trees/{64-hex-id}/) and must be migrated with
storage:migrate-trees (which runs scripts/migrate-trees-sharding.sh from
the Subduction source) before the new server can see existing data. See
Updating the server.
Important
/var/lib/subduction/key-seed is the server's signing-key material. Never cat it into terminal scrollback, never copy it off-host without encryption, and never check it into git. It is auto-generated on first boot (see DECISIONS.md, if present).
Subduction creates many small files under trees/. On a small droplet, inodes can run out before bytes do.
df -h / # bytes used / free
df -i / # inodes used / free
sudo du -sh /var/lib/subduction/ # bytes under subduction state
sudo du --inodes -s /var/lib/subduction/ # inodes under subduction state
sudo du --inodes --one-file-system --separate-dirs /var/lib/subduction/If df -i shows IUse% near 100, the server will start failing to create new files even with disk space remaining. Same goes for df -h and bytes.
btop # interactive: q to quit
systemctl --failed # any units in a bad state?
systemctl list-units --type=service --state=running
journalctl -o cat --since "1 hour ago" -p err # errors across all unitsWhen working on the server itself rather than from your laptop:
cd ~/bedrock # or wherever you cloned it
git pull
sudo nixos-rebuild switch --flake .#bedrockCaution
The flake URI is .#bedrock (path ., attribute bedrock). ./nix#bedrock and friends are typos — nixos-rebuild will report a confusing "path does not exist" error.
Other modes:
| Mode | Effect |
|---|---|
switch |
Build, activate now, add to bootloader (most common) |
test |
Build and activate now, don't add to bootloader (reverts on reboot) |
boot |
Add to bootloader but don't activate until next reboot |
If a deploy goes wrong, the previous generation is still in the bootloader: reboot and pick it from the menu, or roll back from a working session with sudo nixos-rebuild switch --rollback.
Two things can be "updated" independently: the flake inputs (Subduction, nixpkgs, etc.) and the NixOS system itself (rebuilding against whatever the inputs currently point at). Most of the time you want both, in order.
From a checkout of this repo (laptop or on the server):
nix flake update # update every input
nix flake update subduction # bump just Subduction
nix flake update nixpkgs unstable # bump just nixpkgs channelsThis rewrites flake.lock. Review the diff before deploying:
git diff flake.lockSubduction tracks the main branch of inkandswitch/subduction; nix flake update subduction picks up whatever the latest commit on that branch is. The exact commit deployed is always recorded in flake.lock (see the subduction.locked.rev field).
To pin to a specific tag instead, edit flake.nix:
subduction.url = "github:inkandswitch/subduction/v0.14.0-nightly.2026-05-07";…then nix flake update subduction to refresh the lock.
Note
Stable releases are published as vX.Y.Z tags; nightlies are vX.Y.Z-nightly.YYYY-MM-DD. The releases page marks one tag as "Latest" — that's the latest stable, not the latest tag overall.
nixos-rebuild switch --flake .#bedrock \
--target-host <USERNAME>@subduction.sync.inkandswitch.com \
--build-host <USERNAME>@subduction.sync.inkandswitch.com \
--sudo--sudo makes the remote privileged steps (writing the system profile, running the activation script) escalate via sudo rather than expecting a root SSH login. Required here because services.openssh.settings.PermitRootLogin = "no"; accounts in wheel have passwordless sudo so this is non-interactive.
Caution
Do not prefix the command with a local sudo. It accomplishes nothing useful (every privileged action happens on the remote) and will break things — root locally has its own empty ~/.ssh/config, so SSH host aliases vanish and key resolution falls back to "try every key in the agent", which trips the server's MaxAuthTries.
The flag spelling is --sudo on NixOS 25.11's nixos-rebuild-ng. Classic Perl nixos-rebuild called the same thing --use-remote-sudo.
--build-host builds the closure on the droplet itself rather than locally. It's required when your laptop can't produce x86_64-linux derivations (e.g. from Apple Silicon).
If your laptop is x86_64-linux, drop --build-host and let local Nix build the closure; nixos-rebuild pushes it over SSH via nix-copy-closure before activating:
nixos-rebuild switch --flake .#bedrock \
--target-host <USERNAME>@subduction.sync.inkandswitch.com \
--sudoTradeoffs vs. --build-host:
| Build location | Pros | Cons |
|---|---|---|
Server (--build-host) |
Same command works on Linux and macOS; no closure upload; no local store growth | Uses droplet CPU/RAM/disk for the build |
Laptop (no --build-host) |
Usually faster on a beefy laptop; doesn't tax the droplet | Closure cached in laptop's /nix/store; closure pushed over the network |
Keep --build-host if you want one command that works verbatim on both platforms.
cd ~/bedrock
git pull # if the lockfile bump was committed
sudo nixos-rebuild switch --flake .#bedrockFor a dry run that builds without activating:
sudo nixos-rebuild build --flake .#bedrockSome Subduction upgrades change the on-disk trees/ layout. The notable one
is the flat → sharded migration: the new server reads only the sharded
layout (trees/{4-hex}/{60-hex}/) and silently ignores legacy flat tree
directories (trees/{64-hex}/). If you are upgrading a server that predates
sharding, its existing trees become invisible (not deleted) until migrated.
The storage:migrate-trees menu command runs scripts/migrate-trees-sharding.sh
from the deployed Subduction source. It is offline (stops the service),
idempotent, and resumable.
Caution
The data directory has no automated backups. Take a DigitalOcean snapshot (or
copy /var/lib/subduction/trees) before migrating.
storage:migrate-trees --dry-run # preview moves, service stays up
storage:migrate-trees # stop → migrate → startPrefer the menu command — it already knows the script path for the exact
Subduction revision this system is built from. If you need to run the script
directly (e.g. with extra flags), find its store path from the generated
command and pass --force only when you have separately confirmed the service
is stopped:
# Inspect the wrapper to see the pinned script path it invokes:
type storage:migrate-trees # or: cat "$(command -v storage:migrate-trees)"Skip this step entirely on a fresh server (empty trees/) or when the upgrade
did not change the layout.
systemctl status subduction
sudo journalctl -o cat -u subduction --since "5 minutes ago" --no-pager -p warning
curl -sI https://subduction.sync.inkandswitch.comExpect HTTP 200/426 from the public endpoint (Subduction will upgrade to WebSocket; a plain curl returning 426 Upgrade Required is healthy). See Health checks for a fuller list.
NixOS keeps each previous system configuration as a numbered generation at /nix/var/nix/profiles/system-N-link. Rolling back is atomic (no half-applied state) and never destructive — old generations stay in the store and bootloader until you explicitly garbage-collect them.
On the server:
sudo nixos-rebuild switch --rollbackFrom your laptop:
nixos-rebuild switch --rollback \
--target-host <USERNAME>@subduction.sync.inkandswitch.com \
--sudoEither form activates the previous generation now and makes it the bootloader default for the next boot.
sudo nix-env --list-generations -p /nix/var/nix/profiles/systemOutput is one line per generation, with (current) marking the active one:
21 2026-05-21 09:55:32 (current)
20 2026-05-12 22:14:01
19 2026-05-08 11:02:55
…
If "the previous one" isn't enough — e.g. you want to skip past two bad deploys — activate a specific generation by its number:
sudo /nix/var/nix/profiles/system-<N>-link/bin/switch-to-configuration <mode><mode> |
Activate now | Update bootloader default |
|---|---|---|
switch |
yes | yes |
boot |
no | yes (next reboot) |
test |
yes | no (reverts on reboot) |
test is the safest way to try out an old generation: if it works, follow with switch; if it doesn't, reboot.
If the bad generation broke networking or sshd, you can't roll back over SSH. Recovery path:
- Open the droplet's "Console" tab in the DO web UI.
- Reboot the droplet (
Power→Power Cycle). - At the GRUB menu, select
NixOS - Configuration <N>for the last known-good generation. - Once it boots and SSH works again, run
sudo nixos-rebuild switch --rollback(or a specific-generationswitch-to-configuration switch) to make the rollback the new default.
Without step 4, the next reboot lands back on the broken generation.
A NixOS generation is just a closure — it doesn't snapshot the filesystem outside the store. Specifically, these survive a rollback unchanged:
| Path | Owned by |
|---|---|
/var/lib/subduction/ |
Subduction (key-seed, tree data, commits) |
/var/lib/loki/ |
Loki (log history, TSDB index) |
/var/lib/prometheus2/ |
Prometheus (metrics TSDB) |
/var/lib/caddy/ |
Caddy (ACME accounts, Let's Encrypt certificates) |
/var/lib/grafana/ |
Grafana (UI-edited dashboards, users, secrets) |
/var/log/journal/ |
systemd journal |
/home/<user>/ |
per-user data |
If a bad deploy already caused service-side mutations (corrupted DB, deleted tree, malformed config written to a service's state dir), rolling back the system closure won't undo those — restore from backup separately.
Caution
If a rolled-back generation pins a different Caddy config, Caddy may re-issue Let's Encrypt certs on activation. LE has rate limits (5 duplicate certs per week per domain). Try not to thrash deploys around Caddy / cert changes.
sudo nix-env --list-generations -p /nix/var/nix/profiles/system | grep current
systemctl status subduction
sudo journalctl -o cat --since "2 minutes ago" -p err --no-pager
curl -sI https://subduction.sync.inkandswitch.com(current) should point to the generation you rolled back to. See Health checks for a fuller post-rollback checklist.
There's no "un-rollback". Once the underlying problem is fixed in the flake, an ordinary nixos-rebuild switch builds the next generation (N+1) and activates it. The "bad" generations stay in the store until garbage-collected.
To pick up the latest nixos-25.11 channel without touching Subduction:
nix flake update nixpkgs
nixos-rebuild switch --flake .#bedrock --target-host … --build-host …Subduction's NixOS module pins its own dependencies via the subduction flake input, so a nixpkgs bump won't move Subduction.
After several deploys, old system closures accumulate in the Nix store:
sudo nix-collect-garbage --delete-older-than 14d
sudo nixos-rebuild switch --flake .#bedrock # refresh the bootloader entriesThe nix.gc settings in nix.nix also schedule periodic GC; manual collection is mainly useful when disk pressure is high now.
| Check | Command |
|---|---|
| HTTPS reachable | curl -sI https://subduction.sync.inkandswitch.com |
| Subduction local socket | curl -sI http://127.0.0.1:8080 |
| Prometheus scraping | curl -s http://127.0.0.1:9092/api/v1/targets | jq |
| Grafana up | curl -sI http://127.0.0.1:3939 |
| Loki up | curl -s http://127.0.0.1:3100/ready |
| Caddy config valid | sudo systemctl status caddy |
| Symptom | Cause / fix |
|---|---|
systemctl status subduction_cli shows nothing |
Wrong unit name. Use subduction. |
journalctl … -p error shows nothing |
Use -p err (and -p warning, not -p warn). |
journalctl … -u WARN |
-u selects the unit. Use -p or pipe through rg WARN. |
nixos-rebuild complains about path |
The flake URI is .#bedrock, not ./nix#bedrock. |
--no-pager keeps getting forgotten |
It's required when piping into rg / grep / wc. |
| Logs cut off after a few hours | Increase --since window: --since "1 day ago", --since today. |
Deploy: Permission denied writing system profile |
Missing --sudo flag (or --use-remote-sudo on classic nixos-rebuild). Don't add local sudo. |
Deploy: Could not resolve hostname … or auth fails |
You ran sudo nixos-rebuild … — root has a different ~/.ssh/config. Run as your user, with --sudo. |
README.md— architecture, deployment from your laptopconfiguration.nix— service definitions- Subduction repo