Skip to main content
This dotfiles system uses a two-layer secrets management approach: Bitwarden for runtime credential retrieval and age for at-rest file encryption.

Security Architecture

Bitwarden CLI

Runtime SecretsRetrieves credentials during chezmoi apply

age Encryption

At-Rest ProtectionEncrypts files committed to Git
This dual approach ensures secrets are never stored in plaintext while keeping the workflow simple and automated.

Why Two Tools?

ScenarioToolReason
SSH private keysBitwardenGenerated externally, stored centrally
AWS credentialsBitwardenRotate frequently, per-environment
SSH config fileageStatic configuration, machine-specific
Git signing keyBitwardenShared across machines
Rule of thumb: Use Bitwarden for credentials that change or need central management. Use age for static config files that contain sensitive data.

Bitwarden Integration

Setup

The bootstrap script handles Bitwarden authentication:
bootstrap.sh
# 5. Bitwarden Login & Unlock
if bw status | grep -q '"status":"unauthenticated"'; then
    echo "Logging into Bitwarden..."
    bw login
fi

if bw status | grep -q '"status":"locked"'; then
    echo "Unlocking Bitwarden..."
    BW_SESSION=$(bw unlock --raw)
    export BW_SESSION
    bw sync
fi
1

Login

Authenticates with your Bitwarden account (email/password)
2

Unlock

Decrypts the vault using your master password
3

Export Session

Sets BW_SESSION environment variable for subsequent commands
4

Sync

Downloads latest vault contents from the server

Vault Organization

Organize items with consistent naming:
Store multi-line secrets like keys:Item Name: ssh-private-key
Type: Secure Note
Notes:
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
...
-----END OPENSSH PRIVATE KEY-----
Use exact item names as expected by your templates. Bitwarden queries are case-sensitive.

Template Functions

Chezmoi provides two Bitwarden template functions:

1. bitwarden

Retrieves the entire item as JSON:
{{- $item := bitwarden "item" "ssh-private-key" -}}
{{ $item.notes }}
Use for:
  • Secure Note contents (.notes)
  • Login passwords (.login.password)
  • Login usernames (.login.username)
Example: SSH private key
private_dot_ssh/id_ed25519.tmpl
{{- $sshKey := bitwarden "item" "ssh-private-key" -}}
{{ $sshKey.notes }}
Rendered output:
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmU...
-----END OPENSSH PRIVATE KEY-----

2. bitwardenFields

Retrieves custom fields as a map:
{{- $aws := bitwardenFields "item" "aws-credentials-work" -}}
aws_access_key_id = {{ $aws.access_key_id.value }}
aws_secret_access_key = {{ $aws.secret_access_key.value }}
Use for:
  • Structured credentials (AWS, database configs)
  • Multi-value secrets
  • Environment-specific settings
Example: AWS credentials
dot_aws/credentials.tmpl
{{- $aws := bitwardenFields "item" "aws-credentials-work" -}}
[work]
aws_access_key_id = {{ $aws.access_key_id.value }}
aws_secret_access_key = {{ $aws.secret_access_key.value }}
region = {{ $aws.region.value }}

{{- if eq .machine_type "hybrid" -}}
{{- $awsPersonal := bitwardenFields "item" "aws-credentials-personal" -}}
[personal]
aws_access_key_id = {{ $awsPersonal.access_key_id.value }}
aws_secret_access_key = {{ $awsPersonal.secret_access_key.value }}
region = {{ $awsPersonal.region.value }}
{{- end }}

Bitwarden CLI Configuration

Chezmoi’s Bitwarden integration is configured in .chezmoi.toml.tmpl:
[bitwarden]
    command = "bw"
    unlock = "auto"
  • command: Path to Bitwarden CLI binary
  • unlock = "auto": Automatically prompt for unlock if vault is locked

Common Patterns

{{- if eq .machine_type "work" -}}
{{-   $creds := bitwardenFields "item" "work-vpn" -}}
VPN_USER={{ $creds.username.value }}
VPN_PASS={{ $creds.password.value }}
{{- end -}}
private_dot_ssh/config.tmpl
Host github.com-personal
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_personal

Host github.com-work
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_work
private_dot_ssh/id_ed25519_personal.tmpl
{{- (bitwarden "item" "ssh-key-personal").notes -}}
private_dot_ssh/id_ed25519_work.tmpl
{{- (bitwarden "item" "ssh-key-work").notes -}}
{{- $token := "" -}}
{{- if bitwarden "item" "api-token" -}}
{{-   $token = (bitwarden "item" "api-token").login.password -}}
{{- end -}}
API_TOKEN={{ $token | default "PLACEHOLDER" }}

Age Encryption

What is age?

age is a simple, modern file encryption tool:
  • Simple: Single identity key, no key servers
  • Secure: Based on X25519, ChaCha20-Poly1305, and HKDF
  • Fast: Written in Go, minimal dependencies

Key Management

The age private key is stored in two places:
  1. Runtime: ~/.config/chezmoi/key.txt (NOT committed to Git)
  2. Backup: Bitwarden Secure Note named chezmoi-age-key

Key Initialization

The bootstrap script retrieves or generates the key:
bootstrap.sh
mkdir -p "$HOME/.config/chezmoi"
if [ ! -f "$HOME/.config/chezmoi/key.txt" ]; then
    echo "Checking for age key in Bitwarden..."
    if bw get notes "chezmoi-age-key" > "$HOME/.config/chezmoi/key.txt" 2>/dev/null; then
        echo "Successfully retrieved age key from Bitwarden."
    else
        echo "Could not find 'chezmoi-age-key' in Bitwarden."
        echo "Generating a new one instead..."
        age-keygen -o "$HOME/.config/chezmoi/key.txt"
        echo "IMPORTANT: Save the following content as a Secure Note named 'chezmoi-age-key' in Bitwarden:"
        cat "$HOME/.config/chezmoi/key.txt"
    fi
fi
chmod 600 "$HOME/.config/chezmoi/key.txt"
1

Check for Existing Key

Looks for ~/.config/chezmoi/key.txt
2

Try Bitwarden Retrieval

Attempts to pull chezmoi-age-key from vault
3

Generate if Missing

Creates new key with age-keygen if not found
4

Prompt for Backup

Displays key and reminds you to save it in Bitwarden
5

Set Permissions

Ensures key file is readable only by you (600)
Critical: If you generate a new key, immediately save it as a Secure Note in Bitwarden. If you lose this key, you cannot decrypt your files.

Chezmoi Configuration

.chezmoi.toml.tmpl
encryption = "age"

[age]
    identity = "~/.config/chezmoi/key.txt"
    recipient = {{ output "age-keygen" "-y" (joinPath .chezmoi.homeDir ".config/chezmoi/key.txt") | trim | quote }}
  • identity: Your private key (for decryption)
  • recipient: Your public key (for encryption, derived from private key)

Encrypting Files

chezmoi add --encrypt ~/.ssh/config
Creates private_dot_ssh/config.age in the source directory.

File Naming

Encrypted files use the .age suffix:
private_dot_ssh/config.age           → ~/.ssh/config (decrypted)
private_dot_ssh/known_hosts.age      → ~/.ssh/known_hosts (decrypted)
private_dot_aws/credentials.age      → ~/.aws/credentials (decrypted)
The .age extension is automatically removed when applying. The private_ prefix sets permissions to 0600.

What to Encrypt

Encrypt with age

  • SSH config files
  • Known hosts files
  • GPG keyrings
  • Application configs with tokens

Use Bitwarden Instead

  • SSH private keys
  • API credentials
  • Database passwords
  • OAuth tokens

Security Best Practices

  • Never commit ~/.config/chezmoi/key.txt to Git
  • Always backup age key to Bitwarden
  • Rotate Bitwarden master password regularly
  • Use strong, unique passwords for Bitwarden
  • Enable two-factor authentication
  • Lock vault when not in use (bw lock)
  • Sync before making changes (bw sync)
  • Organize with folders (Personal, Work, Shared)
  • Mark sensitive files with private_ prefix (sets 0600)
  • Verify after apply: ls -la ~/.ssh/
  • Never commit unencrypted secrets
  • Unset BW_SESSION when done: unset BW_SESSION
  • Lock vault: bw lock
  • Logout on shared machines: bw logout

Troubleshooting

Bitwarden Issues

Symptom: chezmoi apply fails with “Vault is locked”Solution:
BW_SESSION=$(bw unlock --raw)
export BW_SESSION
chezmoi apply
Symptom: Template error about missing itemSolution:
# List all items
bw list items | jq '.[].name'

# Search for specific item
bw get item "ssh-private-key"
Verify the item name matches exactly.
Symptom: bw commands fail after some timeSolution:
bw unlock --check || BW_SESSION=$(bw unlock --raw)
export BW_SESSION

Age Encryption Issues

Symptom: chezmoi apply fails with decryption errorSolution:
# Verify key exists
test -f ~/.config/chezmoi/key.txt && echo "Key found" || echo "Key missing"

# Check permissions
ls -la ~/.config/chezmoi/key.txt  # Should be 0600

# Retrieve from Bitwarden
bw get notes "chezmoi-age-key" > ~/.config/chezmoi/key.txt
chmod 600 ~/.config/chezmoi/key.txt
Symptom: Error about recipient mismatchSolution: Files were encrypted with a different key. Re-encrypt:
# Decrypt with old key
age -d -i ~/.config/chezmoi/old-key.txt file.age > file

# Re-add with new key
chezmoi add --encrypt ~/file

Real-World Examples

Complete SSH Setup

{{- $sshKey := bitwarden "item" "ssh-private-key" -}}
{{ $sshKey.notes }}
Apply with:
chezmoi apply
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

Multi-Environment AWS Config

dot_aws/credentials.tmpl
{{- if or (eq .machine_type "work") (eq .machine_type "hybrid") -}}
{{-   $awsWork := bitwardenFields "item" "aws-credentials-work" -}}
[work]
aws_access_key_id = {{ $awsWork.access_key_id.value }}
aws_secret_access_key = {{ $awsWork.secret_access_key.value }}
{{- end }}

{{- if or (eq .machine_type "personal") (eq .machine_type "hybrid") -}}
{{-   $awsPersonal := bitwardenFields "item" "aws-credentials-personal" -}}
[personal]
aws_access_key_id = {{ $awsPersonal.access_key_id.value }}
aws_secret_access_key = {{ $awsPersonal.secret_access_key.value }}
{{- end }}

Git Signing Key

run_once_before_import-gpg-key.sh.tmpl
#!/bin/bash
{{- $gpgKey := bitwarden "item" "gpg-signing-key" }}

echo "{{ $gpgKey.notes }}" | gpg --import
gpg --list-secret-keys --keyid-format=long

Security Model Summary

Defense in Depth:
  1. Bitwarden vault protected by master password + 2FA
  2. Age key stored in Bitwarden (not in Git)
  3. Encrypted files use age encryption
  4. Applied files have restrictive permissions
  5. Git repository contains no plaintext secrets

Next Steps

System Architecture

Understand how all components interact

Add Secrets

Learn to add your own credentials

Bitwarden CLI Docs

Official Bitwarden CLI reference

age Documentation

age encryption tool documentation

Build docs developers (and LLMs) love