Skip to content
Daniel Schmidt's Blog
Go back

Keeping Development Environments in Sync with Chezmoi

Development Environment Setup with Chezmoi

Keeping my personal and professional Macbook development environment in sync is a challenge. Daniel Banck recomended chezmoi to me so I gave it a try.

If you need to keep multiple machines in sync, you should check it out. I’ll focus on some interesting capabilities / patterns I saw that I found worth sharing.

My dream for automating my development setup has always been a 1-Click installation. I want to be able to run a script and have my development environment set up the way I like it. I want to be able to run the same script on my personal and professional Macbook and have different tools and environment variables set up for each machine.

Where am I coming from?

I previously explored using CDK for Terraform for development environments. Since then, my approach has evolved quite a bit.

I have been using a combination of Homebrew / Homebrew Bundle together with an Ansible playbook to manage my development environment. This setup has been working well for me, but I am always looking for ways to improve my workflow. I symlinked most of my dotfiles, so I can update them where they are used and do occasional commits on the reposiory where they are stored. I had a bunch of bash scripts to automate the setup of my development environment, but I was looking for a more elegant solution. Especially having a different set of tools and environment variables for my personal and professional Macbook was a pain. I had one script that updated my brewfile automaticaly, which sounds great in theory, but in practice I had a nearly unmaintainable brewfile. brew bundle dump adds all formulas installed with brew, including the ones that come as dependencies. This made cleaning up formulas a pain, there was no deliberate decision to include or exclude things from the setup anymore.

You can find my old setup under DanielMSchmidt/personal-provisioning if you are interested.

What is Chezmoi?

Chezmoi is a tool that helps you manage your dotfiles across multiple machines. It is written in Go and uses a simple configuration file to manage your dotfiles. It has a templating engine built in, so you can use the same configuration file for multiple machines and have different values for different machines. That’s a huge win for me. It also integrates tightly with 1Password, so you can store your secrets in 1Password and use them in your dotfiles. I am relying heavily on 1Password, so this is a big plus for me. It can also run scripts only once or each time a file changes, which makes running the automation a lot faster.

Keeping it simple

In my last automation I had a portion that installed Homebrew and ran xcode install in the same script I used for every run. I rely on these tools in the rest of my workflows, but essentially they are an extra step to be run on a fresh machine. This time I have a very simple startup script that can be run through curl that kicks the entire process of:

# Source: https://github.com/DanielMSchmidt/dotfiles/blob/74d5cf6d4e74e2aab652c29523bbf5fed54ab979/.startup.sh#L7-L24
# Install XCode Command Line Tools if necessary
xcode-select --install || echo "XCode already installed"

# Install Homebrew if necessary
if which -s brew; then
    echo 'Homebrew is already installed'
else
    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
    (
        echo
        echo 'eval "$(/opt/homebrew/bin/brew shellenv)"'
    ) >>$HOME/.zprofile
    eval "$(/opt/homebrew/bin/brew shellenv)"
fi

brew install chezmoi
chezmoi init danielmschmidt
chezmoi apply

In the day to day work, I will only need to run chezmoi apply to apply the changes to my dotfiles. Adding new files under version control is as simple as running chezmoi add <file>. To template a file I just need to add a .tmpl to the file and use the templating engine in the file.

The rest of my one-time setup (setting system prefereances / default editor / shell) is done in a one-time run script. All I had to do to make it run only once per machine was naming it run_once_after_setup-<name>.sh. Very simple.

Handling differences between machines

This was my biggest pain point in my old setup and it’s one of the strenghts of chezmoi. This is all I need to do to have different values for different machines:

// Source: https://github.com/DanielMSchmidt/dotfiles/blob/d36f2533e3b7a6a915692ee482cb461df9c94352/.chezmoi.toml.tmpl#L1-L4
{{- $isWorkComputer := promptBoolOnce . "isWorkComputer" "Is this your work computer" -}}

[data]
    isWorkComputer = {{ $isWorkComputer }}

If I want to have for example different environment variables containing secrets in my shell, I just need to add the .tmpl extension to the shell config and add some templating to the file:

// Source: https://github.com/DanielMSchmidt/dotfiles/blob/d36f2533e3b7a6a915692ee482cb461df9c94352/dot_config/fish/conf.d/secrets.fish.tmpl#L1-L7
set -x GITHUB_TOKEN {{ onepasswordRead "op://gv2pj3dcyg2wxx3ahtvgdamp2e/dv6xj4ndkqmenm63d3f45dts3e/token" "EI6GPO6VNJAVLGDDID3B75JI6E" }}

{{ if .isWorkComputer -}}
set -x HOMEBREW_GITHUB_API_TOKEN {{ onepasswordRead "op://gv2pj3dcyg2wxx3ahtvgdamp2e/dv6xj4ndkqmenm63d3f45dts3e/token" "EI6GPO6VNJAVLGDDID3B75JI6E" }}
set -x BOB_GITHUB_TOKEN {{ onepasswordRead "op://gv2pj3dcyg2wxx3ahtvgdamp2e/dv6xj4ndkqmenm63d3f45dts3e/token" "EI6GPO6VNJAVLGDDID3B75JI6E" }}
set -x NGROK_API_KEY {{ onepasswordRead "op://dm6p7vq2kzadhezt5qhuvhavxe/tflocal-cloud Ngrok API key/password" "2HZZS3CSKVA7REGL25XWFDGOPE" }}
{{ end -}}

{{ if .isWorkComputer -}} is all we need, making it very simple to adjust the configuration for different machines.

Using 1Password

Another problem I had was handling secrets. I like to keep them accessible in my shell, but I don’t want to store them in plain text in my dotfiles. Chezmoi has a tight integration with 1Password, so I can store my secrets in 1Password and use them in my dotfiles.

I can even do this across different vaults and different accounts. This is a huge win for me, as I am using 1Password for all my secrets. Let me show you how this works on the example of adding a GPG key for signing commits. In my .gitconfig (or dot_gitconfig.tmpl as chezmoi tracks it) I have these lines:

// Source: https://github.com/DanielMSchmidt/dotfiles/blob/d36f2533e3b7a6a915692ee482cb461df9c94352/dot_gitconfig.tmpl#L22-L25
[user]
	name = Daniel Schmidt
	email = [email protected]
	signingkey = {{ onepasswordRead "op://gv2pj3dcyg2wxx3ahtvgdamp2e/okuhh55hdfabbnwvgzomsywrxe/id" "EI6GPO6VNJAVLGDDID3B75JI6E" }}

I am accessing the secret through onepasswordRead "op://<vault_id>/<secret_id>/<key>" "<account_id>". The easiest way to get these values was to use the 1Password CLI and run op item get "name_of_your_secret" --share-link. This returns a link that contains the vault_id, secret_id and account_id: https://start.1password.com/open/i?a=<account_id>&h=<account_url>&i=<secret_id>&v=<vault_id>. It took me a minute to find this way, but it works like a charm.

The key mentioned above is the field name, to double check you can run op item get "name_of_your_secret" --format json and take a look at fields.label.

Most of my secrets were accessed this way, but in case of the GPG key I had used the document upload feature of 1Password. Downloading it required me to use the op read --out-file command manually in a script that runs after the chezmoi apply command. We will talk about this script in the next section.

Running scripts on updates

To run a script it changes you need to name it run_onchange-<name>.sh. If you make it a template by adding an extra .tmpl you can make the script respond to changing secrets / inputs). If you want to run it if a different file changed you can use a hash of the file in a comment in the script:

// Source: https://github.com/DanielMSchmidt/dotfiles/blob/d36f2533e3b7a6a915692ee482cb461df9c94352/run_onchange_mise-install.sh.tmpl#L3-L8
set -ex

# hash of the mise config file, used to determine if we need to rerun this script
# mise/config.toml hash: {{ include "dot_config/mise/config.toml" | sha256sum }}

mise install -y

If you need to react to a changing secret that you access through the 1Password CLI you can use the onepassword template command to inline a JSON value of the secret into the script. In this case I decided for the version since it changes when the secret changes:

// Source: https://github.com/DanielMSchmidt/dotfiles/blob/d36f2533e3b7a6a915692ee482cb461df9c94352/run_onchange_import-gpg-key.sh.tmpl#L1-L23
#!/bin/bash

set -e

# We use the version of the secret to determine if we need to update the secret
# This needs to be kept in sync with the .gitconfig file
# Secret version: {{ (onepassword "okuhh55hdfabbnwvgzomsywrxe" "gv2pj3dcyg2wxx3ahtvgdamp2e" "EI6GPO6VNJAVLGDDID3B75JI6E").version }}

# Early abort if the key is already added
if gpg --list-secret-keys | grep -q {{ onepasswordRead "op://gv2pj3dcyg2wxx3ahtvgdamp2e/okuhh55hdfabbnwvgzomsywrxe/id" "EI6GPO6VNJAVLGDDID3B75JI6E" }}; then
    exit 0
fi

# EOF the key into a temporary file

tmpDir=$(mktemp -d)
tmpFile="${tmpDir}/private.key"

# Manually use the onepassword command to get the file
op read --out-file $tmpFile op://gv2pj3dcyg2wxx3ahtvgdamp2e/okuhh55hdfabbnwvgzomsywrxe/gmail.key --account "EI6GPO6VNJAVLGDDID3B75JI6E"

# Import the key
gpg --import $tmpFile

Conclusion

I am so far very happy with the setup, it seems way simpler than any other automation I tried. Both my Macbooks run M-series processors and OSX so I did not have to work around any differences between the machines, but the chezmoi docs gave me confidence that the tool could handle this if I needed it.

I also have to say it was very quick to setup, especially since I had dotfiles to look at for inspiration. I hope this blog post helps you to get started with chezmoi and that you will enjoy it as much as I do. If you have any questions or feedback, feel free to reach out to me on Twitter / X.


Share this post on:

Related Posts

Daniel Schmidt
Daniel Schmidt

Terraform Core Engineer at HashiCorp. Designing and implementing the Terraform language and runtime.



Loading newsletter signup...



Previous Post
Building a Remark Plugin for Inline GitHub Code Snippets
Next Post
Implementing Discriminated Unions in Go: A TypeScript Perspective