Infrastructure automation

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.

Engine: Clojure / Babashka IaC: OpenTofu Config mgmt: Ansible Target OS: Ubuntu 24.04 LTS Hyperscalers: Hetzner · OCI State backend: S3 License: 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 ];

Configuration #

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_*

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"}}
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.

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:

bb walter create ┌─ walter (top-level workflow) ─────────────────────────────────────┐ ::start run-steps step-fns ├─ ::tools-once/tofu args: "render tofu:init tofu:apply:-auto-approve" opts-fn: params/opts-fn ├─ ::tools-walter/ansible args: "render ansible-playbook:main.yml" opts-fn: params/opts-fn └─ ::tools-once/ansible-local args: "render ansible-playbook:main.yml" opts-fn: params/opts-fn ::end └───────────────────────────────────────────────────────────────────┘

Each pipeline step is a keyword + [args opts-fn] vector. opts-fn is the composed function tofu-params ∘ read-bc-pars, which merges:

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

Architecture #

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.

The naming convention is consistent throughout the repo’s sibling projects:

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:

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:

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.

What gets installed on the VM #

The Ansible playbook (main.yml) runs two roles:

System role (root, become: true) #

User role (ubuntu) #

Devbox packages #

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

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

Org bigconfig-ai

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.

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.