Skip to main content

Secrets Management for Dotfiles: Bitwarden, Chezmoi Templates, and Safe Automation

·3219 words·16 mins
David Cajio
Author
David Cajio

Secrets Management for Dotfiles: Templates, Bitwarden, and Safe Automation
#

A reproducible desktop setup is only useful if it can rebuild the parts that actually matter.

It is easy to version control shell aliases, Hyprland configs, Waybar themes, Git settings, package lists, and service files. That is the comfortable part of dotfiles. The uncomfortable part is everything those configs eventually touch:

  • SSH private keys
  • API tokens
  • Wi-Fi credentials
  • Git signing material
  • Cloud provider credentials
  • Personal service tokens
  • Local .env values
  • Application-specific secrets that quietly accumulate over time

That is where dotfiles can get dangerous.

My rule is simple:

Secrets never go in the dotfiles repo. Ever.

Not plaintext. Not “temporarily.” Not because the repo is private. Not because I plan to clean it up later. Not even encrypted unless I have a very specific reason and a very specific threat model.

For my workstation setup, Bitwarden is the source of truth for secrets. Chezmoi is responsible for rendering templates. Git is responsible for versioning the structure around those templates.

That separation matters.

Git stores the shape of the machine.

Bitwarden stores the sensitive values.

Chezmoi joins them together at apply time.

The Problem With “Just Put It in Dotfiles”
#

Dotfiles start innocently.

First it is a .zshrc.

Then it is Git config.

Then it is SSH config.

Then it is application settings.

Then it is a helper script.

Then one day there is an API token inside a shell export because it was faster to paste it there than wire up proper secret management.

That is usually how secrets leak. Not because someone sat down and made a reckless architectural decision, but because convenience slowly beat discipline.

The risks are obvious:

  • A private repo can become public.
  • A committed secret lives forever in Git history unless aggressively purged.
  • A backup, fork, clone, or CI job can preserve data you thought you removed.
  • A debug log can print values during bootstrap.
  • A local machine can become harder to audit because secrets are scattered everywhere.

For a reproducible workstation, I want the opposite.

I want my dotfiles repo to be boring. If someone sees it, the worst thing they should get is my preferred editor settings, Hyprland structure, shell aliases, and maybe some strong opinions about Linux.

They should not get credentials.

The Architecture: Git for Configuration, Bitwarden for Secrets
#

The architecture I want looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
┌──────────────────────────┐
│ Dotfiles Git Repository  │
│                          │
│ - templates              │
│ - scripts                │
│ - package lists          │
│ - service definitions    │
│ - non-secret defaults    │
└─────────────┬────────────┘
              │ chezmoi apply
┌──────────────────────────┐
│ Chezmoi Template Engine  │
│                          │
│ - renders .tmpl files    │
│ - calls Bitwarden CLI    │
│ - writes local files     │
└─────────────┬────────────┘
              │ bw get / session unlock
┌──────────────────────────┐
│ Bitwarden Vault          │
│                          │
│ - SSH private keys       │
│ - API tokens             │
│ - Wi-Fi credentials      │
│ - local credentials      │
└─────────────┬────────────┘
┌──────────────────────────┐
│ Local Workstation        │
│                          │
│ - ~/.ssh/id_ed25519      │
│ - ~/.config/...          │
│ - NetworkManager config  │
│ - app credentials        │
└──────────────────────────┘

The important part is that the secret does not move through Git.

The repo only contains references like:

1
{{ (bitwardenFields "item" "github-api-token").token.value }}

or:

1
{{ bitwardenAttachmentByRef "id_ed25519" "item" "ssh-main-key" }}

Those references are useless without an unlocked Bitwarden session on the local machine.

That gives me the automation I want without turning my dotfiles into a credential dump.

Why Bitwarden Fits This Workflow
#

I use Bitwarden here because it gives me a practical balance between security and usability.

For a workstation bootstrap, I do not want a complicated enterprise secrets platform just to restore my SSH key and a handful of local credentials. I also do not want secrets copied into random shell files.

Bitwarden gives me:

  • A real encrypted vault
  • A CLI interface through bw
  • Cross-machine access
  • Attachments for things like SSH private keys
  • Custom fields for API tokens and structured values
  • A workflow that pauses for human unlock when needed

Chezmoi can call Bitwarden from templates, so I can keep my dotfiles declarative while still pulling sensitive values from the vault during chezmoi apply.

That means a fresh machine can be rebuilt with a flow like this:

1
2
chezmoi init git@github.com:dcajio/dotfiles.git
chezmoi apply

At some point, the process should pause and require me to unlock Bitwarden.

That pause is a feature, not a bug.

I do not want a fully unattended system silently pulling every secret I own onto a new machine. I want safe automation: automate the boring parts, but require explicit authentication before sensitive material is rendered locally.

Installing the Required Tools
#

On Arch, the packages I care about are:

1
yay -S chezmoi bitwarden-cli jq

Depending on the machine and package availability, bitwarden-cli may come from the AUR. The jq package is not strictly required for chezmoi templates, but it is useful when inspecting Bitwarden CLI output manually.

I also want to confirm the CLI works before wiring it into automation:

1
2
bw --version
chezmoi --version

Then I log in:

1
bw login

Or, if already logged in, unlock the vault:

1
export BW_SESSION="$(bw unlock --raw)"

The important part is BW_SESSION.

That environment variable represents the active unlocked Bitwarden CLI session. If it is not set, bw may require an unlock before it can retrieve secrets.

For my workflow, I want this to be temporary. I do not want to permanently export BW_SESSION in .zshrc, commit it somewhere, or write it into a log.

A session token is sensitive. Treat it like a secret.

Configuring Chezmoi to Use Bitwarden
#

Chezmoi can be configured to automatically unlock Bitwarden when needed.

In my chezmoi config:

1
2
3
4
# ~/.config/chezmoi/chezmoi.toml

[bitwarden]
    unlock = "auto"

With this enabled, chezmoi can call bw unlock when BW_SESSION is missing.

That gives me the behavior I want during a fresh machine bootstrap:

  1. Start chezmoi apply.
  2. Chezmoi reaches a template that needs Bitwarden.
  3. Bitwarden requires unlock.
  4. I authenticate.
  5. Chezmoi renders the file locally.
  6. Secrets are written only to the destination machine.

This keeps the process automated without pretending secrets should be unattended.

Vault Organization
#

The biggest mistake I see with password-manager-backed dotfiles is poor naming.

If the vault is messy, the templates become messy.

I prefer names that describe infrastructure purpose, not just application names.

For example:

1
2
3
4
5
6
dotfiles/ssh/main-ed25519
dotfiles/api/github-personal-token
dotfiles/api/cloudflare-token
dotfiles/wifi/home-network
dotfiles/git/signing-key
dotfiles/aws/personal-lab

Inside Bitwarden, I would use a folder or collection like:

1
Dotfiles

Then each item should have predictable fields.

For API tokens, I like custom fields:

1
2
3
4
5
Item name: dotfiles/api/github-personal-token

Custom fields:
  token = ghp_xxxxxxxxxxxxxxxxxxxx
  note  = Used for local GitHub CLI/dev workflows

For Wi-Fi:

1
2
3
4
5
Item name: dotfiles/wifi/home-network

Custom fields:
  ssid     = MyNetwork
  password = super-secret-password

For SSH keys, I prefer attachments:

1
2
3
4
5
Item name: dotfiles/ssh/main-ed25519

Attachments:
  id_ed25519
  id_ed25519.pub

You could store the SSH private key as a secure note field, but I prefer attachments because it maps cleanly to files.

The goal is consistency. Templates should be boring because the vault structure is predictable.

Rendering an SSH Key With Chezmoi
#

For SSH, I want the private key restored locally with strict permissions.

In the chezmoi source directory, the target file might look like this:

1
~/.local/share/chezmoi/private_dot_ssh/private_id_ed25519.tmpl

The private_ prefix tells chezmoi to apply restrictive permissions.

Inside the template:

1
{{ bitwardenAttachmentByRef "id_ed25519" "item" "dotfiles/ssh/main-ed25519" }}

For the public key:

1
~/.local/share/chezmoi/private_dot_ssh/id_ed25519.pub.tmpl
1
{{ bitwardenAttachmentByRef "id_ed25519.pub" "item" "dotfiles/ssh/main-ed25519" }}

After applying:

1
chezmoi apply

I still like to verify permissions:

1
ls -la ~/.ssh

Expected result:

1
2
-rw------- id_ed25519
-rw-r--r-- id_ed25519.pub

If needed:

1
2
3
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

I would usually enforce that with a script rather than trust myself to remember.

Example chezmoi script:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# ~/.local/share/chezmoi/run_after_ensure-ssh-permissions.sh

#!/usr/bin/env bash
set -euo pipefail

chmod 700 "$HOME/.ssh"

if [[ -f "$HOME/.ssh/id_ed25519" ]]; then
  chmod 600 "$HOME/.ssh/id_ed25519"
fi

if [[ -f "$HOME/.ssh/id_ed25519.pub" ]]; then
  chmod 644 "$HOME/.ssh/id_ed25519.pub"
fi

The key detail: the private key itself never exists in the repository. Only the template reference does.

Rendering API Tokens
#

API tokens are a better fit for Bitwarden custom fields.

Example Bitwarden item:

1
2
3
4
Item name: dotfiles/api/github-personal-token

Custom fields:
  token = ghp_xxxxxxxxxxxxxxxxxxxx

A chezmoi-managed shell environment file might look like this:

1
~/.local/share/chezmoi/private_dot_config/shell/private_exports.tmpl

Template:

1
2
3
4
# This file is generated by chezmoi.
# Do not edit directly.

export GITHUB_TOKEN="{{ (bitwardenFields "item" "dotfiles/api/github-personal-token").token.value }}"

That renders to:

1
export GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx"

Only on the local machine.

The source repo only contains:

1
{{ (bitwardenFields "item" "dotfiles/api/github-personal-token").token.value }}

That is the pattern I want everywhere.

Avoiding Secrets in Shell Startup Files
#

One thing I am still cautious about: exporting secrets globally from .zshrc.

It is convenient, but it also means every shell inherits those values. That can leak into child processes, logs, debugging tools, shell history accidents, and application environments.

For some tokens, that may be acceptable.

For others, I prefer a function that loads the secret only when needed.

Example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# ~/.config/zsh/functions/github-token

load_github_token() {
  if [[ -z "${BW_SESSION:-}" ]]; then
    export BW_SESSION="$(bw unlock --raw)"
  fi

  export GITHUB_TOKEN="$(
    bw get item "dotfiles/api/github-personal-token" \
      | jq -r '.fields[] | select(.name == "token") | .value'
  )"

  echo "GitHub token loaded into current shell session."
}

That keeps the default shell cleaner.

The tradeoff is convenience. For commands I run constantly, I may allow a generated private exports file. For rarely used or higher-risk secrets, I would rather load on demand.

That is the kind of decision worth making per secret, not globally.

Wi-Fi Credentials
#

Wi-Fi credentials are another interesting case.

On Linux, NetworkManager can store Wi-Fi profiles under:

1
/etc/NetworkManager/system-connections/

Those files require root ownership and strict permissions, so I do not want to casually render them as a normal user without thinking through the process.

A possible template could look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[connection]
id={{ (bitwardenFields "item" "dotfiles/wifi/home-network").ssid.value }}
type=wifi
interface-name=wlan0

[wifi]
mode=infrastructure
ssid={{ (bitwardenFields "item" "dotfiles/wifi/home-network").ssid.value }}

[wifi-security]
key-mgmt=wpa-psk
psk={{ (bitwardenFields "item" "dotfiles/wifi/home-network").password.value }}

[ipv4]
method=auto

[ipv6]
method=auto

But I would not blindly drop that into place as part of my normal user-level dotfiles apply.

Instead, I would separate this into a machine bootstrap step:

1
2
3
4
5
sudo install -m 600 -o root -g root \
  ./generated-home-wifi.nmconnection \
  /etc/NetworkManager/system-connections/home-wifi.nmconnection

sudo systemctl restart NetworkManager

For Wi-Fi, the important question is not “can chezmoi template it?”

It can.

The better question is “should this be part of my normal dotfiles apply, or part of a privileged machine bootstrap?”

For me, Wi-Fi belongs in the bootstrap category because it touches system-level networking.

Safe Automation Rules
#

This is the part I care about most.

The goal is not just automation.

The goal is safe automation.

My rules:

  1. Never commit secrets to the repo.
  2. Never print secrets during bootstrap.
  3. Never run with set -x around secret commands.
  4. Never store BW_SESSION permanently.
  5. Use restrictive file permissions for rendered secrets.
  6. Keep privileged system secrets separate from normal user dotfiles.
  7. Make secret rendering explicit enough that I can audit it later.

That means my scripts should look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#!/usr/bin/env bash
set -euo pipefail

# Do not use set -x in scripts that touch secrets.

if ! command -v bw >/dev/null 2>&1; then
  echo "Bitwarden CLI is required before applying secrets."
  exit 1
fi

if ! command -v chezmoi >/dev/null 2>&1; then
  echo "chezmoi is required."
  exit 1
fi

BW_STATUS="$(bw status | jq -r '.status')"

case "$BW_STATUS" in
  "unauthenticated")
    echo "Logging into Bitwarden..."
    bw login
    export BW_SESSION="$(bw unlock --raw)"
    ;;
  "locked")
    echo "Unlocking Bitwarden..."
    export BW_SESSION="$(bw unlock --raw)"
    ;;
  "unlocked")
    echo "Bitwarden is already unlocked."
    ;;
  *)
    echo "Unknown Bitwarden status: $BW_STATUS"
    exit 1
    ;;
esac

echo "Applying chezmoi configuration..."
chezmoi apply

echo "Locking Bitwarden CLI session..."
bw lock >/dev/null || true
unset BW_SESSION

echo "Dotfiles applied."

Notice what this does not do.

It does not echo secret values.

It does not store the session token.

It does not run debug tracing.

It does not redirect sensitive command output into logs.

It keeps the workflow human-approved while still automated.

Preventing Accidental Secret Commits
#

Even with this setup, I still want guardrails.

The first one is a .gitignore inside the chezmoi source repo.

Example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Never commit generated secret material
*.secret
*.key
*.pem
*.p12
*.pfx
*.env
.env
.env.*
id_rsa
id_ed25519
*.nmconnection

# Local scratch files
scratch/
tmp/

That is not enough by itself, but it prevents obvious mistakes.

I also like the idea of using a pre-commit secret scanner.

For example:

1
yay -S gitleaks

Then:

1
gitleaks detect --source ~/.local/share/chezmoi

For a repo that is supposed to never contain secrets, a scanner should be boring. If it finds something, I want to know before that commit leaves my machine.

I would eventually wire that into a pre-commit hook:

1
2
3
4
#!/usr/bin/env bash
set -euo pipefail

gitleaks detect --source . --redact

Again, the goal is not to rely on one perfect tool.

The goal is layers:

  • Discipline
  • Repository structure
  • .gitignore
  • Chezmoi templates
  • Bitwarden source of truth
  • Secret scanning
  • Careful bootstrap scripts

What Else Should Live in Bitwarden?
#

SSH keys and API tokens are obvious.

Wi-Fi credentials are also reasonable.

But once I started thinking about my dotfiles this way, I realized there are probably more values that belong in Bitwarden than I originally considered.

Candidates include:

  • GitHub personal access tokens
  • Cloudflare API tokens
  • AWS access keys for personal lab accounts
  • Tailscale auth keys
  • SMTP credentials for local tools
  • Grafana or Datadog API keys
  • Package registry tokens
  • Docker registry credentials
  • Private GPG keys or signing material
  • Recovery codes for developer services
  • License keys for paid software
  • Local-only .env values for side projects

The test is simple:

Would I be uncomfortable if this value appeared in a public GitHub repo?

If yes, it belongs in Bitwarden, not dotfiles.

What I Would Not Automate Blindly
#

There are some things I do not want a dotfiles apply to handle without a deliberate step.

For example:

  • Replacing system NetworkManager profiles
  • Installing private SSH keys onto a machine I have not verified
  • Pulling every cloud credential automatically
  • Writing production credentials onto a personal workstation
  • Rendering secrets into globally sourced shell files without thinking about scope

Automation should reduce mistakes, not remove judgment.

My ideal bootstrap asks me to unlock Bitwarden, renders only what the machine needs, sets permissions correctly, and then gets out of the way.

A Practical Bootstrap Flow
#

The full new-machine flow should eventually look something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/usr/bin/env bash
set -euo pipefail

echo "Installing base packages..."
yay -S --needed chezmoi bitwarden-cli jq gitleaks

echo "Initializing dotfiles..."
chezmoi init git@github.com:dcajio/dotfiles.git

echo "Authenticating with Bitwarden..."
if [[ "$(bw status | jq -r '.status')" == "unauthenticated" ]]; then
  bw login
fi

export BW_SESSION="$(bw unlock --raw)"

echo "Applying dotfiles..."
chezmoi apply

echo "Validating secret hygiene..."
gitleaks detect --source "$(chezmoi source-path)" --redact

echo "Cleaning up Bitwarden session..."
bw lock >/dev/null || true
unset BW_SESSION

echo "Bootstrap complete."

This is the direction I want my workstation automation to go.

One command should be able to rebuild the machine, but not at the expense of exposing secrets.

Troubleshooting and Gotchas
#

Chezmoi Cannot Find bw
#

If chezmoi fails because it cannot find the Bitwarden CLI, confirm it is installed and on the path:

1
2
command -v bw
bw --version

If bw exists in an unusual location, fix the path before running chezmoi apply.

Bitwarden Is Logged In but Locked
#

Being logged in is not the same as being unlocked.

Check status:

1
bw status | jq

If locked:

1
export BW_SESSION="$(bw unlock --raw)"

Then re-run:

1
chezmoi apply

The Template Works Manually but Fails in Chezmoi
#

This is usually an environment issue.

The current shell may have BW_SESSION, but the process running chezmoi may not.

Check:

1
echo "${BW_SESSION:+BW_SESSION is set}"

If needed, run:

1
2
export BW_SESSION="$(bw unlock --raw)"
chezmoi apply

Secrets Show Up in Diffs
#

Be careful with:

1
chezmoi diff

If templates render secrets, diffs may display generated values depending on what changed.

For sensitive files, I avoid casually pasting diffs into tickets, chats, logs, or blog posts. I also avoid running verbose debug output around secret rendering.

SSH Rejects the Key
#

SSH is strict about permissions.

Fix them:

1
2
3
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

Then test:

1
ssh -T git@github.com

Secret Names Changed in Bitwarden
#

Templates depend on stable item names or IDs.

If I rename Bitwarden items freely, templates can break.

For long-term stability, item IDs are more reliable. For readability, names are nicer. I generally prefer readable names while the system is still evolving, then switch critical templates to IDs once the vault structure settles.

The Security Boundary I Want
#

This setup is not trying to make my local machine magically immune to compromise.

If my workstation is compromised while Bitwarden is unlocked, there is risk. That is true of any local secret workflow.

The boundary I care about here is narrower and practical:

  • Git should never contain secrets.
  • A cloned dotfiles repo should be safe to inspect.
  • A new machine should require explicit Bitwarden authentication.
  • Secrets should only render locally.
  • Generated secret files should have correct permissions.
  • Automation should not print sensitive values.
  • Sessions should not be stored permanently.

That is a realistic boundary for workstation automation.

It is not security theater. It is reducing the most likely mistakes.

Final Thoughts
#

Dotfiles are infrastructure.

Maybe not production infrastructure. Maybe not customer-facing infrastructure. But they are still the automation layer for the machine I use every day to write code, manage systems, access cloud environments, and do real work.

That means they deserve the same discipline I would apply anywhere else:

  • Separate config from secrets.
  • Keep sensitive values out of Git.
  • Make automation repeatable.
  • Make failure modes obvious.
  • Avoid cleverness where boring security works better.

For my setup, Bitwarden and chezmoi hit the right balance.

Chezmoi gives me reproducible configuration.

Bitwarden gives me a safe place for secrets.

The repo describes what the machine should look like.

The vault provides the values that should never be in the repo.

That is exactly the split I want.