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.

Clojure / Babashka OpenTofu Ansible Ubuntu 24.04 LTS Hetzner · OCI S3 backend MIT

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:

  1. Render OpenTofu templates and provision a VM.
  2. Render Ansible playbooks against the new VM’s public IP.
  3. Run a root-level role (system packages, users, Nix, Docker) and a per-user role (Devbox, asdf, dotfiles, Doom Emacs).
  4. 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.

Note Walter is configured for the author’s personal setup out of the box (his SSH key, his repos, his OCI tenancy). Forking and editing src/clj/io/github/amiorin/walter/ansible.clj and bb.edn is the expected path before first run — see Customization.

Prerequisites

ToolWhyInstall hint
Babashka (bb)Runs bb.edn tasksbrew install borkdude/brew/babashka
Clojure CLIRuns the test alias and REPLbrew install clojure/tools/clojure
OpenTofu (tofu)Provisions the VMbrew install opentofu
AnsibleConfigures the VMbrew install ansible
oci-cli (if using OCI)Reads ~/.oci/config for credentialsbrew install oci-cli && oci setup config
AWS CLI (for S3 backend)Auth’d for OpenTofu state in S3aws configure
devenv (optional)Reproducible local toolchain via NixSee 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 ];

Environment variables

Walter reads its configuration from three sources, in order of override:

  1. Static defaultssrc/clj/io/github/amiorin/walter/options.clj
  2. Per-invocation overridesbb.edn :init block (binds bb)
  3. Environment variables — read by big-config as BC_PAR_*

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"}}
KeyMeaningDefault
:provider-computeWhich IaC template to render"oci" (alt: "hcloud")
:oci-shapeOCI instance shapeVM.Standard.A1.Flex (Ampere ARM, Always Free tier)
:oci-ocpusVirtual CPUs3
:oci-memory-in-gbsRAM18
:oci-boot-volume-size-in-gbsDisk150
:oci-boot-volume-vpus-per-gbVolume performance units30 (Balanced)
:provider-backendOpenTofu state backend"s3"
Warn The default shape 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.

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)

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 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:

1
tofu
provision VM
2
ansible
remote configure
3
ansible-local
~/.ssh/config
;; package.clj — the inner pipeline of ::start
[::tools-once/tofu          ["render tofu:init tofu:apply:-auto-approve" params/opts-fn]]
[::tools-walter/ansible     ["render ansible-playbook:main.yml"      params/opts-fn]]
[::tools-once/ansible-local ["render ansible-playbook:main.yml"      params/opts-fn]]

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 tofu step),
  • plus the BC_PAR_* env vars,
  • plus the static bb options.

This is what lets the Ansible step discover the new VM’s IP without you typing it.

Namespaces

NamespaceResponsibility
walter.packageTop-level walter* workflow. Wires create / delete pipelines and the outer step-fns (print, exit, error).
walter.toolsThe ansible workflow definition: renders the playbook tree, the inventory, packages.yml, repos.yml, ssh-config.yml, default.config.yml.
walter.ansiblePure data layer. data-fn produces users/repos/packages/SSH config. render emits YAML/JSON for each Ansible artifact.
walter.optionsStatic defaults: the oci map, the s3 backend map, the merged walter opts. bb is an alias for walter.
walter.paramsTiny composition: opts-fn and walter-opts — the entry points into big-config’s param machinery.
Convention A function ending in * (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:

  1. Copy everything in . verbatim — that’s the static main.yml, ansible.cfg and the role tasks/files trees.
  2. Generate packages.yml, repos.yml and ssh-config.yml in roles/users/tasks/ by calling walter.ansible/render with the corresponding key.
  3. Generate inventory.json and default.config.yml at 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 in options.clj as ::render/profile "walter").
  • default — the Ansible profile (set in walter.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.

Note .dist/ is in .gitignore. Treat it as build output — never hand-edit files there; your changes will be clobbered on the next render.

System role (root, become: true)

The Ansible playbook (main.yml) runs two roles. The system role does the privileged work:

  • 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.conf and restart nix-daemon.
  • Copy four shell hooks into /etc/profile.d/:
    • Z50-devbox.sh — sources devbox global shellenv into every shell.
    • direnv.sh — hooks direnv into bash.
    • ssh-agent.sh — symlinks $SSH_AUTH_SOCK to a stable per-user path (so zellij sessions don’t lose agent forwarding).
    • zellij.sh — alias ze for zellij attach --create $(whoami)@$(hostname).
  • Install Docker via the official get.docker.com script (retries 10× with 10s backoff).
  • Create the managed user (ubuntu by default) with UID, home dir, and the docker group.
  • 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 ubuntu inside dotfiles-v3/main.
  • If ATUIN_LOGIN is set, log in to Atuin and run atuin sync.
  • Clone Doom Emacs pinned at commit dd72eac…, run doom sync, pre-compile vterm, and install all the LSP servers Doom expects (Dockerfile, basedpyright, typescript, yaml, astro, mdx).
  • Generate ~/.ssh/config blocks pointing at the host’s *.afrino-bushi.ts.net Tailscale hostname.

Devbox packages

Each is added with devbox global add --disable-plugin <name>:

  • fish
  • emacs
  • zellij
  • starship
  • direnv
  • gh
  • fd
  • fzf
  • atuin
  • just
  • git
  • cmake
  • libtool
  • socat
  • zoxide
  • pixi
  • eza
  • zip
  • unzip
  • d2
  • clojure-lsp
  • btop
  • clj-kondo
  • ripgrep (rg)

asdf languages

Each plugin is added and the latest version is installed and set as global:

PluginSourceVersion
javahalcyon/asdf-javalatest:temurin-25
duckdbamiorin/asdf-duckdblatest
hugoEdditoria/asdf-hugolatest:extended
golangasdf-community/asdf-golanglatest
nodejsasdf-vm/asdf-nodejslatest
clojureasdf-community/asdf-clojurelatest
babashkapitch-io/asdf-babashkalatest
opentofuvirtualroot/asdf-opentofulatest

Cloned repos

All are cloned via SSH (git@github.com:…) into ~/code/personal/<repo>/main. Worktrees are created as sibling directories.

Org amiorin

  • dotfiles-v3
  • albertomiorin.com (worktree: albertomiorin)
  • big-container
  • alice

Org bigconfig-ai

  • basecamp-once
  • big-config
  • once
  • once-ai
  • once-bigconfig
  • once-bigconfig-marketplace
  • once-caddy-redirect
  • once-forms
  • walter
Requires SSH agent The clone step runs over SSH and needs your local key forwarded into the VM. Confirm 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 ripgreprg), 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

  1. Edit bb.edn :init block: set :provider-compute "hcloud", remove the OCI keys.
  2. Export BC_PAR_HCLOUD_TOKEN (or put it in .envrc.private).
  3. Register your SSH public key in the Hetzner project; update ssh_keys in tofu/hcloud/main.tf to match the key’s alias.
  4. 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.

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.

REPL workflow

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

License

Copyright © 2026 Alberto Miorin. Distributed under the MIT License.


Single-page manual generated from the repo. For the source of truth, read bb.edn, src/clj/io/github/amiorin/walter/, and src/resources/io/github/amiorin/walter/tools/.