Secure system/data separation with SOPS, age, and YubiKey
A reproducible, hardware-backed method to separate executable system logic from sensitive configuration data, while keeping all secrets encrypted at rest in Git.
Goal
The objective of this architecture is to split a deployed platform into two repositories:
| Repository | Purpose |
|---|---|
| system | Scripts, installers, services, deployment logic, executable code |
| data | Configuration files, keys, tokens, secrets, sensitive runtime data |
The system repository may remain plain Git. The data repository must be encrypted before commit. This ensures that secrets are never stored in plaintext in version control, while still allowing deterministic deployment on the target system.
Architecture overview
Developer machine Edit files Encrypt data before push system repo Scripts, services, installers No secrets data repo Encrypted .sops files only Config + secrets YubiKey primary Hardware age identity Private key never leaves token YubiKey backup Second hardware recipient Same files remain decryptable Target system Pulls system repo + data repo Decrypts data locally using inserted YubiKey
Repository layout
The recommended layout for the encrypted data repository is:
data/
├── README.md
├── .sops.yaml
│
├── system-core/
│ └── config.conf.sops
│
├── feature-wireguard/
│ └── wg0.conf.sops
│
├── feature-dnscrypt/
│ └── dnscrypt-proxy.toml.sops
│
├── syncblock/
│ ├── codeberg.token.sops
│ └── sync-adlists.conf.sops
│
├── host-hardening-ssh/
│ └── ssh_host_ed25519_key.sops
│
└── bootstrap/
└── bootstrap-secrets.sops
The naming rule is:
Name the encrypted file after the real deployed file, then append
.sops.
Example:
| Encrypted repo file | Runtime target path |
|---|---|
system-core/config.conf.sops | /etc/system/config.conf |
syncblock/codeberg.token.sops | /etc/syncblock/codeberg.token |
Technical encryption model
Encryption is performed with SOPS, using age recipients. The private key material is not file-based. Instead, it is stored inside two separate YubiKeys. Both recipients are added to the same SOPS rule set, so any encrypted file can be decrypted with either hardware token.
A minimal .sops.yaml for this model is:
creation_rules:
- path_regex: \.sops$
age:
- <PRIMARY_YUBIKEY_RECIPIENT>
- <BACKUP_YUBIKEY_RECIPIENT>
Important notes:
path_regex: \.sops$means every file ending in.sopswill be encrypted using the listed recipients.- No
encrypted_regexis used here. That option is mainly for structured documents such as Kubernetes manifests and is not appropriate for ordinary config files, tokens, or keys. - Multiple age recipients create true redundancy: both YubiKeys must be tested against the same encrypted file.
Correct YubiKey setup procedure
This procedure reproduces the working setup that was validated here. It is intentionally plugin-native. Do not mix provisioning methods. Once using age-plugin-yubikey, do not manually provision age keys with ykman piv keys generate or yubico-piv-tool for the actual age identity slots.
Prerequisites on the target system
sudo apt update
sudo apt install -y age sops pcscd pcsc-tools yubikey-manager yubico-piv-tool cargo rustc pkg-config libpcsclite-dev
sudo systemctl enable --now pcscd
Build and install age-plugin-yubikey:
cargo install --locked age-plugin-yubikey
sudo install -m 755 ~/.cargo/bin/age-plugin-yubikey /usr/local/bin/
age-plugin-yubikey --help
After installation, build tools may be removed if desired.
Provision the primary YubiKey
Insert the primary YubiKey and launch the plugin:
sudo age-plugin-yubikey
Use these choices:
- Select the inserted YubiKey
- Select a free slot, for example Slot 1
- Generate a new identity
- Allow migration away from the default management key when prompted
- Set a PIN
- Use PIN policy = NEVER
- Use Touch policy = NEVER
- Write the identity file to a root-owned location such as:
/root/.config/sops/age/system-yubikey-primary.txt
Secure the identity file:
sudo chmod 600 /root/.config/sops/age/system-yubikey-primary.txt
Verify:
sudo age-plugin-yubikey --list
A valid recipient should be listed.
Provision the backup YubiKey
Repeat the exact same procedure with the second YubiKey, but store its identity file separately, for example:
/root/.config/sops/age/system-yubikey-backup.txt
Mandatory cross-decryption test
It is not enough that each YubiKey works independently. Both must decrypt the same encrypted file.
printf 'dual\n' | age \
-r '<PRIMARY_RECIPIENT>' \
-r '<BACKUP_RECIPIENT>' \
-o /tmp/dual.age
sudo age -d -i /root/.config/sops/age/system-yubikey-primary.txt /tmp/dual.age
sudo age -d -i /root/.config/sops/age/system-yubikey-backup.txt /tmp/dual.age
Both commands must return:
dual
Encrypting and decrypting files
Encrypt
sops --encrypt --in-place system-core/config.conf.sops
Decrypt
sops -d system-core/config.conf.sops
SOPS will use the local age identity stubs under /root/.config/sops/age/, which in turn require the corresponding YubiKey to be physically present.
Operational deployment model
The intended flow is:
- Edit files on the developer machine
- Encrypt sensitive files in the data repository
- Push both system and data repositories
- On the target system, run deployment
- The target system pulls both repositories and decrypts data locally using the inserted YubiKey
This keeps plaintext secrets out of Git while allowing the target system to perform all updates itself.
Rules that must not be violated
- Do not commit plaintext secrets
- Do not store YubiKey identity files in Git
- Do not manually provision age identity slots with
ykmanoryubico-piv-tool - Do not mix provisioning methods after adopting
age-plugin-yubikey - Do not skip cross-decryption testing
Result
This design yields:
- Separation of executable code from sensitive data
- Hardware-backed non-exportable decryption keys
- Redundant decryption paths via two YubiKeys
- Safe encrypted storage in Git
- A fully reproducible setup that can be followed exactly
Architecture visual
┌──────────────────────────────┐
│ Developer Machine │
│ │
│ - edits system code │
│ - edits secrets (plaintext) │
│ - encrypts via age │
└─────────────┬────────────────┘
│
┌────────────────────────┴────────────────────────┐
│ │
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ system repo │ │ data repo │
│ (plaintext, versioned) │ │ (encrypted via sops + age) │
│ │ │ │
│ - scripts │ │ - configs │
│ - installers │ │ - secrets │
│ - services │ │ - tokens │
│ │ │ │
│ ❗ NO secrets │ │ 🔒 encrypted │
└─────────────┬────────────────┘ └─────────────┬────────────────┘
│ │
│ │
└───────────────┬──────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ Target System │
│ │
│ deploy-travel-router │
│ ───────────────────── │
│ 1. pulls system repo (plaintext) │
│ 2. pulls data repo (encrypted) │
│ 3. decrypts using YubiKey (primary OR backup) │
│ 4. executes deployment │
│ │
└────────────────────────────────────────────────────────────┘
