neurons firing from a keyboard

thoughts about devops, technology, and faster business from a random guy from dallas.

Using Lima and Bash dotfiles to go fast with Claude Code

Reading Time: Approximately 5 minutes.
View this post on GitHub.

I’ve been using Lima for years to run Docker and Podman on the Mac. This setup has given me a lot of control over how I set up my VMs that the “Desktop” tools often obscure. I also like being able to store my VMs in my eternally-growing dotfiles repo.

Though I started messing with Claude Code last year, between not being happy with how purely vibe-coding my Status tool was going and, as usual, Life Getting In The Way™, I ended up scrapping it.

Claude Code got a lot better since then, so during my annual week-long coding sabbatical this year, I thought to give it another try, but, instead, using it as a pair-programming partner instead of a “do it for me” easy button.

It’s well known that running Claude with --dangerously-skip-permissions is the least annoying way of using it (unless you like being asked to confirm every little thing it does, and Claude LOVES doing lots of little things!). It’s also well known that doing this on your own computer is an express train to pain.

“Docker and dotfiles to the rescue, I thought.”

My first attempt at doing this (which I, of course, forgot to commit; yay, me!) looked something like this pseudocode:

# .bash_ai_specific
claude() {
    { _build_claude_dockerfile &&
      _create_claude_docker_volume &&
      _sync_workdir_to_volume &&
      _start_claude_container } || return 1
      _run_claude
      rc=$?
      _review_changes
      _sync_workdir_from_volume
      _rm_claude_container
      return "$rc"
}

I decided to copy the contents of my workdir to and from Claude’s container volume so that I could review changes in a safe location before overwriting my (potentially not yet committed) work tree.

This worked alright at first but became untenable much more quickly than I anticipated.

The latency syncing data to and from the volume felt like a papercut that grew larger every time I invoked claude. A minor annoyance, but something that was always there.

I always run tests in containers to help provide some sort of consistency across platforms. You already know where this is going.

Docker in Docker, as it’s most commonly executed, requires containers to have privileged permissions via the --privileged flag so that they can access the Docker socket. Claude will eventually figure out that it start a root container through the socket; once it does, getting pwned is only a matter of time. So Claude via Docker isn’t the move.

Then I had a shower thought, during the day, not in the shower. “But, hold up. I use Lima. It’s stupid easy to create a VM and SSH into it with Lima. Maybe???”

Attempt 2: Claude via Lima. (spoiler alert: DO IT!)

I went back to the drawing board and created a Lima VM config for Claude, which I remembered to commit this time.

This was basically a copy of the VM I created for my Docker VM with three differences:

Mounting default rules and skills

mounts:
  - location: "~/src/setup/ai/claude/skills"
    mountPoint: "{{.Home}}/.claude/skills"
    writable: false
  - location: "~/src/setup/ai/claude/default_rules"
    mountPoint: "{{.Home}}/.claude/rules"
    writable: false
  - location: "~/src/setup/ai/claude/markdowns"
    mountPoint: "{{.Home}}/.claude/markdowns"
    writable: false

These mounts will make sure that my default set of rules and skills for Claude are always available. Since I also manage these in my dotfiles which are always located at $HOME/src/setup, this makes it really easy for me to keep my preferences for Claude up to date.

Really simple provision steps

provision:
- mode: system
  script:
    apt -y update && apt -y install git rsync ssh bash
- mode: user
  script: |
    cat >{{.Home}}/.bash_profile <<-EOF
    export PATH="$PATH:$HOME/.local/bin"
    export GPG_TTY=$(tty)
    EOF
    gpg-connect-agent reloadagent /bye    
- mode: user
  script: |
    curl -fsSL https://claude.ai/install.sh | bash -x    
- mode: user
  script: |
    cp ~/.claude/markdowns/CLAUDE.md ~/.claude/CLAUDE.md    

I only install just enough stuff to get Claude and gpg working. (gpg is important since one of my rules mandates that my GPG keys be present on the system before doing literally anything; this worked surprisingly well in practice.)

I’m fine with Claude installing anything it needs to install to get the job done, since my dotfile for Claude makes it super easy to trash the VM and create another one without losing my authentication information and settings.

Wait for claude to become available

probes:
- script: |
    #!/bin/bash
    timeout 180s bash -c 'while true; do >/dev/null which $HOME/.local/bin/claude && exit 0; done; sleep 1'    

Speaks for itself. It waits for Claude to become available. That’s all it does.

After creating the VM, I created a dotfile to contain my Claude wrapper.

As you can see here, it’s not too similar from how I did it with Docker:

  local rc
  _create_and_start_claude_vm
  _sync_config_dir_to_claude_vm
  _set_up_gpg_keys
  _mark_claude_ready
  _run_claude "$@"
  rc="$?"
  _sync_config_dir_from_claude_vm
  return "$rc"

There are a few changes, however:

  • I don’t need to sync my working directory since that’s handled by Lima. (I’m using a writable mount to keep my changes synchronized. This isn’t ideal, but, fortunately, Lima also has the ability to sync folders for additional safety, so I might transition to that at some point).

  • Since some of my GPG keys have passphrases, I have a _set_up_gpg_keys step that prompts me to enter my passphrase beforehand so that Claude doesn’t get tripped up while creating commits.

This approach has been very successful thus far. Claude runs mostly like it’s on my own system, and it’s mostly respected the guardrails I put up through my rules. It still gets tripped up over my commit rules, so I’ll need to tweak them some more.

The Verdict

  • Use claude through Docker if you’re not going to be interacting with your system’s Docker daemon. If you are, switch to Podman and create a runtime socket. Make sure that Podman is running in rootless mode!

  • Use claude through a VM if you want to give Claude more leeway to do things without affecting your host system. Consider using wrapper functions to ensure that your VM is initialized before claude starts.

  • In either case, definitely invest time creating default rules and skills for Claude. Not too much time, though; you’ll discover new things to add along the way!