Taproot
What holds when the surface turns.
Dotfiles, containers, and the configs that make a machine mine.
What is this?
The single deep root beneath everything I work on. Personal infrastructure across every machine I tend, from the development container I write code in to the Alpine host that runs in the distance.
This is not a framework. It’s a living configuration. It grows when something changes and stays quiet when nothing needs to.
Structure
taproot/
├── dotfiles/ the soil: bash, git, neovim, tmux
├── containers/
│ └── webdev/
│ ├── Dockerfile the vessel: Ubuntu 24.04 dev image
│ ├── bootstrap.ps1 one-shot host setup (Windows)
│ └── scripts/ copied to ~/scripts/ in the container
│ ├── restic-backup.sh manual restic snapshot to B2
│ ├── restic-restore.sh pull latest snapshot from B2
│ ├── restic-status.sh last snapshot per host across repos
│ ├── code-sync.sh pull existing repos + clone new ones from GitHub
│ └── server-health-check.sh ssh into alpine and run its health check
└── hosts/
└── alpine/
├── quickstart.sh provision a fresh server
├── etc/apk/ package repositories
├── etc/periodic/daily/ restic autobackup, apk autoupgrade
├── root/ health check, restore.sh
└── srv/
├── projects.conf the manifest, every project and repo
├── caddy/Caddyfile the single gate, reverse proxy
└── bootstrap.sh clone all repos into a fresh code directory
The projects it tends
Everything deployed lives in hosts/alpine/srv/projects.conf, one line per
project: name|github_repo|branch|has_data_dir|runs_migrations. The Caddyfile,
post-receive hooks, and bootstrap script all grow from that single file. Every
container listens on 8000 internally; Caddy reaches each one by container name on
the shared bythewood-edge network, so there is no per-project port to track.
| Project | What it is |
|---|---|
analytics | Self-hosted website analytics (Rust axum, SQLite) |
status | Uptime monitor & status page (Rust axum, SQLite) |
finance | Self-hosted market watcher (Rust axum, SQLite) |
blog.bythewood.me | Personal blog (Rust axum, markdown files) |
darkfurrow.com | Seasonal almanac (Rust axum) |
repos | Minimal git repo browser (Rust axum) |
isaacbythewood.com | Personal portfolio (Next.js) |
timelite | Local-only time tracker (Next.js) |
The container
An Ubuntu 24.04 development workstation with everything already in the ground:
Python (uv), Node, Bun, Docker CLI, neovim, tmux, Claude, and Playwright
Chromium (for the Claude playwright MCP). Kept alive with sleep infinity.
Bootstrap on a fresh Windows host
Prereqs: Docker Desktop installed and running, an SSH key at
$HOME\.ssh\home_key (and .pub) added to GitHub. Nothing else.
irm https://raw.githubusercontent.com/overshard/taproot/main/containers/webdev/bootstrap.ps1 -OutFile bootstrap.ps1
powershell -ExecutionPolicy Bypass -File .\bootstrap.ps1 laptop
-ExecutionPolicy Bypass is needed because PowerShell blocks scripts pulled
from the internet by default; the flag scopes to that one invocation, no
persistent system change. Use desktop or laptop as the first arg to tag
this machine’s restic snapshots. Re-run any time; every step is idempotent.
Bootstrap creates the four bythewood-* volumes, clones taproot into
bythewood-code via a throwaway helper container (so the host filesystem stays
clean), builds the image using docker.sock and the volume-resident taproot,
runs the container, copies your host SSH key into the volume, and prompts for
restic credentials. Pass -Force to pull the latest taproot, rebuild the
image, and recreate the container; pass -Restore to also pull data from B2.
Then connect:
docker exec -it bythewood-webdev tmux # TUI workflow
Helper scripts inside the container
All in ~/scripts/ and on PATH:
| Command | What it does |
|---|---|
restic-backup | Manual restic backup to B2; snapshot tagged with $RESTIC_HOST |
restic-restore | Pull latest snapshot from B2; existing data archived first |
restic-status | Last snapshot per host across both restic repos, plus repo size |
code-sync | git fetch && git pull --ff-only for every repo under ~/code/, then clones any non-archived non-fork repos owned by overshard on GitHub that aren’t local yet |
server-health-check | SSH into alpine and run its /root/server-health-check.sh. Override target with $ALPINE_HOST |
The dotfiles
Minimal by intention. I respect defaults and only override what earns it.
Everything in dotfiles/ is baked into the container at build time via
COPY (bash, git, tmux, neovim).
The host
Alpine Linux. Firewall, daily restic backups to Backblaze B2, and quiet daily
maintenance. The bare repos and post-receive hooks are generated from
projects.conf (the Caddyfile is hand-maintained) so the server can be rebuilt
from this repo alone.
Provision a fresh server:
scp -r hosts/alpine/ root@your-server:/root/alpine
ssh root@your-server "cd /root/alpine && sh quickstart.sh"
Bootstrap a fresh code directory with all repos and server remotes:
cd ~/code
sh taproot/hosts/alpine/srv/bootstrap.sh
Backups
Both the webdev container and the alpine host back up to a single Backblaze B2
bucket (overshard-backups) using restic, one repo per kind:
| Repository | What’s in it |
|---|---|
b2:overshard-backups:webdev | Per-machine snapshots from desktop and laptop (~/.claude, ~/code, ~/.ssh). Each snapshot tagged with $RESTIC_HOST (desktop or laptop); retention applies per-machine. |
b2:overshard-backups:alpine | Daily snapshots from the production server (/srv/git, /srv/docker, /srv/data). |
Retention: 7 daily, 4 weekly, 6 monthly per host, pruned after each backup. Restic passwords and B2 application keys live in 1Password.
Webdev credentials
Placed automatically by bootstrap.ps1 (it prompts for them and writes into
the bythewood-restic volume). The b2-env file ends up looking like:
export B2_ACCOUNT_ID="<keyID>"
export B2_ACCOUNT_KEY="<applicationKey>"
export RESTIC_HOST="desktop" # or "laptop"
Optional: drop the alpine repo password at ~/.restic/alpine-password
(prompted for during bootstrap) so restic-status can report on the alpine repo too.
Alpine credentials
Placed by hand after quickstart.sh runs (the same paste-from-1Password
pattern), at /root/.restic/password and /root/.restic/b2-env. The alpine
b2-env should also have RESTIC_HOST="alpine".
Daily flow
restic-backup # take a snapshot from this machine
restic-status # check fleet health (both repos, every host) from anywhere
code-sync # pull every repo under ~/code/ + clone any new ones from GitHub
Restore
Existing data is moved aside to ~/before-restore-<UTC-ISO>/ (webdev) or
/root/before-restore-<UTC-ISO>/srv/ (alpine) before restic writes the
snapshot back:
restic-restore # webdev (from inside the container)
ssh root@server /root/restore.sh --up # alpine; --up auto-restarts containers
Philosophy
- Keep defaults until they fail you.
- One repo, one root, everything grows from here.
- If it’s not worth tending, remove it.
License
BSD 2-Clause. See LICENSE.md.