Walter
Walter provisions a cloud VM (Hetzner Cloud or Oracle OCI), then configures it into a fully-loaded, personalized development environment — Doom Emacs, Devbox, asdf, Tailscale, Docker, Caddy, Redis, and a constellation of dotfiles — with a single command.
Overview #
Walter is a thin orchestration layer over OpenTofu and Ansible written in Clojure. It exists for one purpose: turn the empty cloud account you just opened into a productive Linux workstation in minutes. The workflow is:
- Render OpenTofu templates and provision a VM.
- Render Ansible playbooks against the new VM’s public IP.
- Run a root-level role (system packages, users, Nix, Docker) and a per-user role (Devbox, asdf, dotfiles, Doom Emacs).
- Optionally update local SSH config so the host is reachable by name.
All three steps are composed as a single big-config workflow. You can run the whole thing with bb walter create, or invoke each stage individually for debugging.
Why Clojure?
Workflows are data: each step is a vector of keyword + args. Clojure’s data-first style fits the pipeline.
Why Babashka?
Sub-second start-up. bb tasks feel like make targets but compose like Clojure functions.
Why OpenTofu?
Open-source Terraform fork; identical CLI, no license drama. Same providers (hcloud, oci).
Why Ansible?
Idempotent, agentless, ubiquitous on Ubuntu. Roles are plain YAML; no compiler dance.
Quick start #
Assuming Babashka, OpenTofu and Ansible are already on your PATH (see Prerequisites):
# 1. Clone and enter the repo
git clone https://github.com/amiorin/walter
cd walter
# 2. Drop your secrets into .envrc.private (see .envrc.private.example)
cp .envrc.private.example .envrc.private
$EDITOR .envrc.private
# 3. Authenticate against the hyperscaler
# OCI: oci setup config (writes ~/.oci/config, profile DEFAULT)
# Hetzner: export HCLOUD_TOKEN=... (or set BC_PAR_HCLOUD_TOKEN)
# 4. Provision and configure end-to-end
bb walter create
That last command takes about 10–20 minutes on a fresh box. When it returns, the VM is reachable over Tailscale and Doom Emacs is compiled.
src/clj/io/github/amiorin/walter/ansible.clj and bb.edn is the expected path before first run — see Customization.
Prerequisites #
| Tool | Why | Install hint |
|---|---|---|
Babashka (bb) | Runs bb.edn tasks | brew install borkdude/brew/babashka |
| Clojure CLI | Runs the test alias and REPL | brew install clojure/tools/clojure |
OpenTofu (tofu) | Provisions the VM | brew install opentofu |
| Ansible | Configures the VM | brew install ansible |
| oci-cli (if using OCI) | Reads ~/.oci/config for credentials | brew install oci-cli && oci setup config |
| AWS CLI (for S3 backend) | Auth’d for OpenTofu state in S3 | aws configure |
| devenv (optional) | Reproducible local toolchain via Nix | See devenv.nix in the repo |
| Devbox (target) | Installed automatically on the VM; not needed locally | — |
If you have direnv and devenv, the repo’s .envrc activates the full toolchain on cd. The configured languages and packages are:
;; devenv.nix
languages.clojure.enable = true;
languages.ansible.enable = true;
languages.opentofu.enable = true;
packages = [ pkgs.jet pkgs.hcl2json pkgs.awscli2 pkgs.oci-cli ];
Configuration #
Walter reads its configuration from three sources, in order of override:
- Static defaults —
src/clj/io/github/amiorin/walter/options.clj - Per-invocation overrides —
bb.edn:initblock (bindsbb) - Environment variables — read by
big-configasBC_PAR_*
Environment variables #
Secrets and per-machine values live in .envrc.private (untracked). The example file lists three:
# .envrc.private.example
export BC_PAR_HCLOUD_TOKEN=... # Hetzner Cloud API token
export BC_PAR_DO_TOKEN=... # DigitalOcean (reserved; not used by current providers)
export ATUIN_LOGIN="atuin login -u amiorin -p ... -k '...'" # injected verbatim into the Ansible run
The BC_PAR_ prefix is consumed by big-config and exposed in Clojure as the :io.github.amiorin.walter/… params map. The ATUIN_LOGIN variable is looked up at Ansible run-time inside the user role to seed Atuin shell history sync.
OCI parameters #
The live OCI configuration is the :init block in bb.edn — it overrides the defaults in options.clj:
;; bb.edn :init → bb is the canonical options map
{:big-config.render/profile "walter"
:big-config.workflow/params {:provider-compute "oci"
:oci-config-file-profile "DEFAULT"
:oci-subnet-id "ocid1.subnet.oc1.eu-frankfurt-1.…"
:oci-compartment-id "ocid1.tenancy.oc1.…"
:oci-availability-domain "xTQn:EU-FRANKFURT-1-AD-1"
:oci-display-name "walter"
:oci-shape "VM.Standard.A1.Flex"
:oci-ocpus 3
:oci-memory-in-gbs 18
:oci-boot-volume-size-in-gbs 150
:oci-boot-volume-vpus-per-gb 30
:oci-ssh-authorized-keys "~/.ssh/id_ed25519.pub"
:provider-backend "s3"
:s3-bucket "tf-state-251213589273-eu-west-1"
:s3-region "eu-west-1"
:package "walter"}}
| Key | Meaning | Default |
|---|---|---|
:provider-compute | Which IaC template to render | "oci" (alt: "hcloud") |
:oci-shape | OCI instance shape | VM.Standard.A1.Flex (Ampere ARM, Always Free tier) |
:oci-ocpus | Virtual CPUs | 3 |
:oci-memory-in-gbs | RAM | 18 |
:oci-boot-volume-size-in-gbs | Disk | 150 |
:oci-boot-volume-vpus-per-gb | Volume performance units | 30 (Balanced) |
:provider-backend | OpenTofu state backend | "s3" |
VM.Standard.A1.Flex is ARM64. Every binary downloaded by the Ansible roles (asdf plugins, Devbox packages, Doom Emacs deps) resolves to ARM builds. If you change the shape to an x86 family, expect things like asdf tarballs to need a different arch suffix.
Hetzner parameters #
The Hetzner template is simpler — it expects only BC_PAR_HCLOUD_TOKEN and the SSH key alias already registered in your Hetzner project:
# src/resources/io/github/amiorin/walter/tools/tofu/hcloud/main.tf
resource "hcloud_server" "node1" {
name = "node1-{{ profile }}"
image = "ubuntu-24.04"
server_type = "cx23"
location = "hel1"
ssh_keys = ["32617+amiorin@users.noreply.github.com"]
…
}
To switch hyperscaler, change :provider-compute in the bb.edn :init block from "oci" to "hcloud". The tofu template tree is selected by that key.
Commands #
All commands are Babashka tasks defined in bb.edn. Each one has a starred Clojure variant (e.g. walter*) that parses CLI args and a non-starred core variant invoked from the REPL.
bb walter #
The top-level orchestration. Internally a workflow with two steps wired up: ::start → ::end, where the inner pipeline runs OpenTofu, then Ansible, then ansible-local.
bb walter create # tofu render+init+apply → ansible render+playbook → ansible-local render+playbook
bb walter delete # tofu render+init+destroy (skips ansible)
bb tofu #
Direct access to the OpenTofu stage. Tasks are colon-prefixed; pass them as args:
bb tofu render # render templates to .dist/walter-<hash>/
bb tofu tofu:init # initialize backend & providers
bb tofu lock # tofu providers lock for cross-platform
bb tofu tofu:plan # dry-run changes
bb tofu tofu:apply # prompt-confirmed apply
bb tofu tofu:apply:-auto-approve # used by 'bb walter create'
bb tofu tofu:destroy # teardown
bb tofu tofu:destroy:-auto-approve # used by 'bb walter delete'
You can chain tasks in one invocation; they share a single rendered tree:
bb tofu render tofu:init tofu:plan
bb ansible #
Ansible against the remote host. Reads params output by the OpenTofu stage (public IP, sudoer, uid) and templates them into the inventory.
bb ansible render # render playbooks + roles + inventory.json to .dist/default-<hash>/
bb ansible ansible-playbook:main.yml # run main.yml against admin+users hosts
The rendered tree contains main.yml, ansible.cfg, inventory.json, default.config.yml, and the two roles (root, users).
bb ansible-local #
Same set of playbooks executed on your machine — used today to update your local ~/.ssh/config with a Host entry pointing at the new VM’s Tailscale hostname:
bb ansible-local ansible-playbook:main.yml
After this runs you can ssh walter (or whatever :oci-display-name resolves to) and land on the box over Tailscale.
Tidy & test #
bb tidy # clojure-lsp clean-ns + format
clojure -X:test # cognitect test-runner (test/ dir)
clojure -A:dev # REPL with env/dev/clj on the classpath
The create workflow #
bb walter create resolves to io.github.amiorin.walter.package/walter*, which builds a top-level core/->workflow with the inner create pipeline as the body of ::start:
Each pipeline step is a keyword + [args opts-fn] vector. opts-fn is the composed function tofu-params ∘ read-bc-pars, which merges:
- params from the previous step’s outputs (e.g. the VM’s public IP from the
tofustep), - plus the
BC_PAR_*env vars, - plus the static
bboptions.
This is what lets the Ansible step discover the new VM’s IP without you typing it.
Architecture #
Namespaces #
| Namespace | Responsibility |
|---|---|
walter.package | Top-level walter* workflow. Wires create / delete pipelines and the outer step-fns (print, exit, error). |
walter.tools | The ansible workflow definition: renders the playbook tree, the inventory, packages.yml, repos.yml, ssh-config.yml, default.config.yml. |
walter.ansible | Pure data layer. data-fn produces users/repos/packages/SSH config. render emits YAML/JSON for each Ansible artifact. |
walter.options | Static defaults: the oci map, the s3 backend map, the merged walter opts. bb is an alias for walter. |
walter.params | Tiny composition: opts-fn and walter-opts — the entry points into big-config’s param machinery. |
The naming convention is consistent throughout the repo’s sibling projects:
* (e.g. walter*, ansible*) is the Babashka entry point: it parses *command-line-args*, sets ::bc/env :shell, and forwards to the non-starred core variant. The bare version is what you call from the REPL, supplying opts directly.
big-config workflow engine #
Walter doesn’t reinvent orchestration; it leans on the sibling library big-config. The engine provides:
core/->workflow— build a state machine where each step is a node with a transition table.workflow/->workflow*— build a pipeline: an ordered list of[step-keyword [args opts-fn]]tuples that runs each step in turn.workflow/run-steps— drive the state machine, passing each step through the configured step-fns.step-fns/->exit-step-fn,->print-error-step-fn,print-step-fn— pluggable middleware for logging / exit codes.render/profile— selector for which template tree to render; Walter uses"walter"for OpenTofu and"default"for Ansible.workflow/parse-args— parses Babashka CLI args into the opts map.
The companion library once ships the reusable tofu* and ansible-local* tasks. Walter only owns its own ansible* task because the remote Ansible step needs Walter-specific transforms (packages.yml, repos.yml, ssh-config.yml from data-fn).
Template rendering #
Templates live under src/resources/io/github/amiorin/walter/tools/<tool>. Rendering happens via big-config.render:
;; from walter.tools/ansible
(workflow/prepare
{::workflow/name ::ansible
::render/templates [{:template (keyword->path ::ansible)
:overwrite true
:data-fn a/data-fn
:transform [["." :raw]
[a/render "roles/users/tasks"
{:packages "packages.yml"
:repos "repos.yml"
:ssh-config "ssh-config.yml"}
:raw]
[a/render
{:inventory "inventory.json"
:config "default.config.yml"}
:raw]]}]})
Three transforms run against the source tree:
- Copy everything in
.verbatim — that’s the staticmain.yml,ansible.cfgand the roletasks/filestrees. - Generate
packages.yml,repos.ymlandssh-config.ymlinroles/users/tasks/by callingwalter.ansible/renderwith the corresponding key. - Generate
inventory.jsonanddefault.config.ymlat the tree root.
For the OpenTofu side, the once library does the equivalent, picking the subtree tofu/<provider-compute>/ based on the configured key.
Profiles & .dist/ #
The rendered output goes to .dist/<profile>-<hash>/…. Two profiles are in use:
walter— the OpenTofu profile (configured inoptions.cljas::render/profile "walter").default— the Ansible profile (set inwalter.tools/ansible*).
The hash suffix is content-addressable: identical inputs produce the same directory name, so re-renders are stable and OpenTofu’s working dir doesn’t move between runs unless inputs change.
.dist/ is in .gitignore. Treat it as build output — never hand-edit files there; your changes will be clobbered on the next render.
What gets installed on the VM #
The Ansible playbook (main.yml) runs two roles:
System role (root, become: true) #
- Add a sudoers file for each managed user (
NOPASSWD:ALL). - Install Determinate Nix via the official installer.
- Trust managed users in
/etc/nix/nix.custom.confand restartnix-daemon. - Copy four shell hooks into
/etc/profile.d/:Z50-devbox.sh— sourcesdevbox global shellenvinto every shell.direnv.sh— hooksdirenvinto bash.ssh-agent.sh— symlinks$SSH_AUTH_SOCKto a stable per-user path (sozellijsessions don’t lose agent forwarding).zellij.sh— aliaszeforzellij attach --create $(whoami)@$(hostname).
- Install Docker via the official get.docker.com script (retries 10× with 10s backoff).
- Create the managed user (
ubuntuby default) with UID, home dir, and thedockergroup. - Drop the author’s SSH key into
~ubuntu/.ssh/authorized_keys. - Install asdf v0.18.0 binary to
/usr/local/bin. - Add Tailscale apt repo and install tailscale, fish, build-essential.
- Install Caddy via its stable Debian repo.
- Install Redis via the official Redis repo.
User role (ubuntu) #
- Install Ghostty terminfo (
tic -x). - Install devenv and Devbox via
nix profile add. - Provision the full devbox package set (one task per package, idempotent via
creates:). - Refresh the devbox shell environment.
- Provision asdf languages and tools.
- Clone the configured repos into
~/code/personal/<repo>/main, plus any worktrees. - Install dotfiles by running
bb install -p ubuntuinsidedotfiles-v3/main. - If
ATUIN_LOGINis set, log in to Atuin and runatuin sync. - Clone Doom Emacs pinned at commit
dd72eac…, rundoom sync, pre-compilevterm, and install all the LSP servers Doom expects (Dockerfile, basedpyright, typescript, yaml, astro, mdx). - Generate
~/.ssh/configblocks pointing at the host’s*.afrino-bushi.ts.netTailscale hostname.
Devbox packages #
Each is added with devbox global add --disable-plugin <name>:
fishemacszellijstarshipdirenvghfdfzfatuinjustgitcmakelibtoolsocatzoxidepixiezazipunzipd2clojure-lspbtopclj-kondoripgrep(rg)
asdf languages #
Each plugin is added and the latest version is installed and set as global:
| Plugin | Source | Version |
|---|---|---|
| java | halcyon/asdf-java | latest:temurin-25 |
| duckdb | amiorin/asdf-duckdb | latest |
| hugo | Edditoria/asdf-hugo | latest:extended |
| golang | asdf-community/asdf-golang | latest |
| nodejs | asdf-vm/asdf-nodejs | latest |
| clojure | asdf-community/asdf-clojure | latest |
| babashka | pitch-io/asdf-babashka | latest |
| opentofu | virtualroot/asdf-opentofu | latest |
Cloned repos #
All are cloned via SSH (git@github.com:…) into ~/code/personal/<repo>/main. Worktrees are created as sibling directories.
Org amiorin
dotfiles-v3albertomiorin.com(worktree:albertomiorin)big-containeralice
Org bigconfig-ai
basecamp-oncebig-configonceonce-aionce-bigconfigonce-bigconfig-marketplaceonce-caddy-redirectonce-formswalter
ssh-add -l on your laptop before bb walter create, and that ForwardAgent yes is honoured for the OpenTofu provisioner — see ansible.cfg and ssh-agent.sh.
Customization #
Most personalisation happens in two files:
bb.edn :init block
OCI shape, region, compartment, SSH key, S3 backend, hyperscaler choice (:provider-compute).
walter/ansible.clj data-fn
Users, packages, repos, sudoer, SSH key, Doom Emacs pin, Tailscale hostname domain.
Add a package
Append to the package list in data-fn:
packages (->> ["fish" "emacs" …
"helix"] ; ← new entry, devbox name matches
(mapv (fn [x] [x x]))
(into [["ripgrep" "rg"]])) ; pairs are [devbox-name, expected-binary]
If the package’s binary name differs from the package name (like ripgrep → rg), add a tuple to the into list so the creates: guard is correct.
Add a repo
Pick the right org (amiorin or bigconfig-ai) and add a tuple:
["my-new-repo" ["feature-branch-wt"]] ; second element = worktrees vector
Switch to Hetzner
- Edit
bb.edn:initblock: set:provider-compute "hcloud", remove the OCI keys. - Export
BC_PAR_HCLOUD_TOKEN(or put it in.envrc.private). - Register your SSH public key in the Hetzner project; update
ssh_keysintofu/hcloud/main.tfto match the key’s alias. bb walter create.
Resize the OCI VM
OCI Ampere A1 Flex shapes are billed per OCPU+RAM-hour. To go larger:
:oci-ocpus 4
:oci-memory-in-gbs 24
:oci-boot-volume-size-in-gbs 200
Then bb tofu render tofu:plan to preview, bb tofu tofu:apply to commit. OpenTofu can resize Flex shapes in place — no destroy needed.
Pin a new Doom Emacs commit
The pin lives in the user record, not in a YAML var:
users [{:name main-user
:uid (or uid "1000")
:doomemacs "dd72eac1971616a6ebe81067cca33b14c148cbcd" ; ← bump this
:remove false}]
The user role’s Install doomemacs task reads {{ (users | selectattr(...))first.doomemacs }} with a hard-coded fallback commit.
Remove a user
Add an entry with :remove true. The system role’s Force removal task picks them up — home dir is wiped.
REPL development #
Each source file ends with a comment block containing a (debug tap-values …) form. The pattern is: invoke a workflow in the REPL with ::bc/env :repl instead of :shell, inspect the tapped values, then return.
clojure -A:dev
;; from package.clj
(comment
(debug tap-values
(create [] (merge options/walter
{::bc/env :repl
::tools-once/tofu-opts (workflow/parse-args "render")
::tools-walter/ansible-opts (workflow/parse-args "render")
::tools-once/ansible-local-opts (workflow/parse-args "render")
::run/shell-opts {:err *err* :out *out*}})))
(-> tap-values))
This runs just the render phase of every stage and stashes intermediate state in tap-values for inspection. Handy when debugging a template or data-fn change without spinning up a VM.
To preview just the Ansible data shape:
(require '[io.github.amiorin.walter.ansible :as a])
(a/data-fn {} {})
(a/render :inventory (a/data-fn {} {}))
Project structure #
walter/
├── bb.edn # Babashka tasks & :init opts
├── deps.edn # Clojure deps (big-config, once, cheshire, clj-yaml…)
├── devenv.nix # Local dev environment (Nix)
├── .envrc / .envrc.private.example # direnv hooks & secrets template
├── README.md
├── CLAUDE.md # Guidance for AI coding assistants
│
├── src/
│ ├── clj/io/github/amiorin/walter/
│ │ ├── package.clj # Top-level walter workflow (create / delete)
│ │ ├── tools.clj # Ansible workflow definition + render transforms
│ │ ├── ansible.clj # data-fn: users / repos / packages / SSH / inventory
│ │ ├── options.clj # Static OCI + S3 defaults
│ │ └── params.clj # opts-fn composition
│ │
│ └── resources/io/github/amiorin/walter/tools/
│ ├── tofu/
│ │ ├── oci/main.tf # OCI Ampere VM template
│ │ └── hcloud/main.tf # Hetzner cx23 template
│ └── ansible/
│ ├── ansible.cfg
│ ├── main.yml # admin → root role, users → users role
│ └── roles/
│ ├── root/
│ │ ├── tasks/main.yml # sudoers, nix, docker, asdf, apt, caddy, redis
│ │ ├── tasks/caddy.yml
│ │ ├── tasks/redis.yml
│ │ ├── handlers/main.yml
│ │ └── files/ # Z50-devbox.sh, direnv.sh, ssh-agent.sh, zellij.sh
│ └── users/
│ ├── tasks/main.yml # devbox, asdf, repos, dotfiles, atuin, doom
│ └── files/xterm-ghostty
│
├── env/dev/clj/user.clj # REPL entry namespace
├── test/ # Cognitect test-runner
└── .dist/ # (generated) profile-<hash>/ rendered trees
Troubleshooting #
OpenTofu can’t authenticate to OCI
Check ~/.oci/config exists and has a [DEFAULT] profile (matches :oci-config-file-profile). Test with oci os ns get. If you use the OCI CLI Docker image, you’ll need to bind-mount the config in.
S3 backend errors
The configured bucket (tf-state-251213589273-eu-west-1) is the author’s. You must change it or you’ll see AccessDenied. Update :s3-bucket + :s3-region in bb.edn, and make sure your local AWS creds can write there.
Ansible can’t SSH to the new VM
The OpenTofu template includes a remote-exec provisioner running ls — its job is to block until the SSH port is up. If that step succeeded but Ansible still times out, suspect: (a) wrong sudoer user (OCI is ubuntu, Hetzner Cloud is root); (b) the public IP changed between tofu and ansible — re-render with bb ansible render.
Doom Emacs install hangs / fails
The Install doomemacs task does everything inline: clone, sync, compile vterm, install many npm-based LSP servers. If npm is wedged the task fails late. Re-running the playbook is safe — it’s gated by creates: ~/.emacs.d, so you’ll need to delete ~/.emacs.d on the VM to retry from scratch.
SSH agent forwarding is gone after reconnect
The /etc/profile.d/ssh-agent.sh hook stabilises the socket path. As of commit ecd905a the hook also guards against creating a circular symlink (older versions could re-link the file to itself on a second login).
“Where did the rendered files go?”
.dist/<profile>-<hash>/. Profile names are walter (tofu) and default (ansible). The hash is content-stable.
Tidy / format failing
bb tidy calls clojure-lsp clean-ns then clojure-lsp format. Both need clojure-lsp on your PATH; install via brew install clojure-lsp/brew/clojure-lsp-native.
License #
Copyright © 2024 Alberto Miorin. Distributed under the MIT License.