Synchronize home directory dotfiles, keys, bashrc, etc. Encrypts keys and secrets at rest
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-05-16 13:32:23 -06:00
bin Adding to git 2026-05-16 13:32:23 -06:00
hosts Adding to git 2026-05-16 13:32:23 -06:00
secrets Adding to git 2026-05-16 13:32:23 -06:00
.gitignore Adding to git 2026-05-16 13:32:23 -06:00
.sops.yaml Adding to git 2026-05-16 13:32:23 -06:00
README.md Adding to git 2026-05-16 13:32:23 -06:00

home_dir

Dotfiles synced across hosts. Secrets are encrypted at rest with SOPS + age and decrypted only when syncing.

Layout

.sops.yaml          # encryption rules (which age keys can decrypt)
bin/
  sops              # sops binary
  sync-dotfiles     # main sync script
  sops-encrypt      # helper that encrypts files in place
public/             # plaintext files — copied verbatim to $HOME on each host
secrets/            # sops-encrypted files — decrypted during sync
hosts/
  targets           # rsync destinations, one per line (gitignored)
  targets.example   # template

public/ and secrets/ mirror the target home directory. A file at public/.bashrc lands at ~/.bashrc. A file at secrets/.ssh/id_ed25519.enc is decrypted and lands at ~/.ssh/id_ed25519.

Encrypted files always end in .enc. .gitignore refuses anything under secrets/ without that suffix, so a stray cp ~/.token secrets/.token can't be committed by mistake (use git add -f if you really mean to override).

Prerequisites

  • sops (bundled at bin/sops)
  • age for generating keys (pacman -S age / brew install age)
  • rsync and ssh on both source and target hosts
  • An age private key at ~/.config/sops/age/keys.txt on the source host

One-time setup on a new source host

mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
grep '^# public key:' ~/.config/sops/age/keys.txt

Add that public key to .sops.yaml under age: (comma-separated with any existing keys), then re-encrypt existing secrets so the new key can decrypt them:

for f in $(find secrets -type f); do bin/sops updatekeys -y "$f"; done

Set up the hosts file:

cp hosts/targets.example hosts/targets
$EDITOR hosts/targets

Adding files

A new plaintext dotfile

Drop it under public/ at the path it should appear in $HOME:

cp ~/.vimrc public/.vimrc

A new secret file

Put the plaintext under secrets/ at the path it should appear in $HOME, then encrypt it:

mkdir -p secrets/.ssh
cp ~/.ssh/id_ed25519 secrets/.ssh/id_ed25519
bin/sops-encrypt secrets/.ssh/id_ed25519
# Result: secrets/.ssh/id_ed25519.enc   (plaintext is shredded)

sops-encrypt writes <file>.enc and removes the plaintext input. The format is picked from the original extension:

  • *.yaml / *.yml / *.json / *.env / *.ini → field-level encryption (keys stay readable, values encrypted — good for review-friendly diffs)
  • everything else → binary mode (whole file encrypted)

Editing an existing secret

For structured files (yaml/json/env/ini), sops opens an editor with plaintext and re-encrypts on save. Because the file ends in .enc, pass --input-type so sops knows the format:

bin/sops --input-type yaml --output-type yaml secrets/secrets.yaml.enc

For binary files (SSH keys, .my.cnf, etc.), decrypt → edit → re-encrypt:

bin/sops -d --input-type binary --output-type binary secrets/.my.cnf.enc > /tmp/edit.tmp
$EDITOR /tmp/edit.tmp
rm secrets/.my.cnf.enc
mv /tmp/edit.tmp secrets/.my.cnf
bin/sops-encrypt secrets/.my.cnf

Removing a file from sync

Delete it from public/ or secrets/. To also remove it from target hosts on the next sync, pass -d (rsync --delete).

Syncing

Always dry-run first:

bin/sync-dotfiles -nv

Then real run:

bin/sync-dotfiles

Flags

Flag Effect
-n Dry run (rsync --dry-run)
-v Verbose
-d --delete extraneous files on the target
-f FILE Use an alternate hosts file

Syncing to a single ad-hoc host

Arguments override hosts/targets:

bin/sync-dotfiles -v greg@laptop.local:

The trailing colon matters — rsync needs it to recognize the path as a remote.

Adding a new target host

Each target only needs ssh access from the source — the source decrypts locally and pushes plaintext over SSH, so targets do not need their own age key.

  1. Make sure you can ssh user@host without a password prompt (key auth).
  2. Add the destination to hosts/targets.
  3. Run bin/sync-dotfiles -nv to dry-run, then drop -n once it looks right.

How secrets are handled during sync

  1. sync-dotfiles creates a staging directory under $TMPDIR (mode 0700).
  2. Files in public/ are copied in verbatim.
  3. Files in secrets/ are decrypted into the staging dir at the same relative path, with the .enc suffix stripped; each is chmod 600 and .ssh/ becomes 0700. The sync aborts if any file in secrets/ lacks .enc.
  4. rsync -a -e ssh pushes the staging tree to each target's $HOME.
  5. On exit (including errors / Ctrl-C), a trap shreds the staged files and removes the directory.

Plaintext only ever exists on disk on the source host (briefly, in a tmp dir) and on the target hosts (persistently, where it belongs). Set TMPDIR=/dev/shm if you want the staging dir in RAM.

Threat model notes

  • An attacker with read access to this repo (e.g. on GitHub) sees only ciphertext.
  • An attacker with the source host's age private key can decrypt everything. Treat ~/.config/sops/age/keys.txt as a root-equivalent secret.
  • Target hosts hold plaintext secrets on disk. Use full-disk encryption on every target.