heartwood every commit a ring

taproot

main

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
│   └── host/                       host-side configs (Zed, Windows ssh-config)
├── containers/
│   └── webdev/
│       ├── Dockerfile              the vessel — Ubuntu 24.04 dev image
│       ├── bootstrap.ps1           one-shot host setup (Windows)
│       ├── entrypoint.sh           starts sshd, then exec's CMD
│       └── 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/caddy/              the single gate — Caddyfile
        ├── etc/docker/             daemon configuration
        ├── etc/periodic/           daily backups and upgrades
        ├── root/                   health checks, restore.sh
        └── srv/
            ├── projects.conf       the manifest — every project, port, repo
            └── bootstrap.sh        clone all repos into a fresh code directory

The projects it tends

Everything deployed lives in hosts/alpine/srv/projects.conf. The Caddyfile, port map, and post-receive hooks all grow from that single file.

ProjectPortWhat it is
analytics8000Self-hosted website analytics (Django, SQLite)
blog.bythewood.me8100Personal blog (Flask, markdown files)
timelite8200Local-only time tracker (Next.js, IndexedDB)
isaacbythewood.com8300Personal portfolio (Next.js)
status8400Uptime monitor & status page (Django, SQLite)
darkfurrow.com8500Seasonal almanac (Flask)

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/master/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
ssh -p 2222 dev@localhost                   # editor remote-dev (Zed, VS Code, JetBrains)

Helper scripts inside the container

All in ~/scripts/ and on PATH:

CommandWhat it does
restic-backupManual restic backup to B2; snapshot tagged with $RESTIC_HOST
restic-restorePull latest snapshot from B2; existing data archived first
restic-statusLast snapshot per host across both restic repos, plus repo size
code-syncgit 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-checkSSH 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. Two flavors:

  • dotfiles/ baked into the container at build time via COPY (bash, git, tmux, neovim).
  • dotfiles/host/ copied by hand on a fresh Windows machine. Bootstrap doesn’t manage these to avoid trampling other entries you have:
    • dotfiles/host/zed-settings.json -> %APPDATA%\Zed\settings.json
    • dotfiles/host/ssh-config -> ~\.ssh\config (merge with existing entries)

The host

Alpine Linux. Firewall, daily restic backups to Backblaze B2, and quiet daily maintenance. The Caddyfile, port assignments, and post-receive hooks are all generated from projects.conf 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:

RepositoryWhat’s in it
b2:overshard-backups:webdevPer-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:alpineDaily 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.