Secrets#
What Are Secrets?#
Secrets are the passwords, API keys, tokens, and credentials your apps need to function. Things like database passwords, SSO signing keys, and provider API tokens.
PSW encrypts all secrets using SOPS
(Secrets OPerationS) with age
encryption, so they’re safe to store in your git repository. You never have to worry about accidentally leaking a password in a commit — everything in secrets/ is encrypted before it touches git.
Why Does Encryption Matter?#
Your user project is a git repository that gets pushed to Forgejo and pulled by the convergence engine. If secrets were stored in plain text, anyone with access to the repository could read every password in your self-hosted solution.
With SOPS encryption:
- Secrets are encrypted at rest in your project files
- They’re safe to commit to git
- Only someone with the age key can decrypt them
- Convergence on the bootstrap target has a copy of the key, so it can decrypt secrets during deployment
The Age Key#
The age key is the master encryption key for your entire project. It’s a keypair:
- Public key — used to encrypt secrets (embedded in
.sops.yaml) - Private key — used to decrypt secrets (stored in
secrets/.age-key)
The age key is generated once during SOPS setup and stored at secrets/.age-key with restrictive permissions (0600 — only the owner can read it).
This key cannot be regenerated. If you lose it, you lose access to all your encrypted secrets. Back it up securely.
The Wizard Backup Gate#
Because losing the key is unrecoverable, the Setup Wizard
turns the backup into a hard gate. The moment psw project init finishes on the Start
screen, the page flips into backup mode:
- A “Download age-key” button saves
secrets/.age-keyto your laptop - An acknowledgment checkbox — “I’ve stored this somewhere safe” — unlocks the Continue button
- PSW writes the
age-key-acknowledgedkey into.psw/checkpoints.yml
The Launch screen refuses to render until that checkpoint exists. If you delete the checkpoint or bail before acknowledging, reloading Start lands you straight on the backup panel — it won’t re-ask for credentials you’ve already given it. This means a user can never finish the wizard without having been told, in clear language, to back up the only thing that can’t be regenerated.
Copy to the Bootstrap Target#
During bootstrap , the age key is securely copied to the bootstrap target via SSH, so the convergence engine can decrypt secrets when deploying apps .
Secret Files#
PSW organizes secrets into separate encrypted files, each with a specific purpose:
| File | What It Contains | Created By |
|---|---|---|
secrets/infra.yml | SSH keys for connecting to nodes and targets | psw-proxmox-installer |
secrets/apps.yml | Per-app passwords, tokens, and signing keys | psw project generate-secrets / psw app add |
secrets/providers.yml | Provider connection credentials (OPNsense API, Cloudflare token) | psw provider configure |
infra.yml#
Infrastructure-level credentials shared across all deployments:
ssh:
public_key: "ssh-ed25519 AAAA..."
proxmox:
root_password: "..."The SSH public key content is embedded verbatim into the Proxmox auto-installer’s answer file, so it needs to live in the secrets store. The path to the matching private key lives in .psw/workstation.yml → ssh_key_path — it’s a per-workstation fact (the key sits at a different absolute path on different operators’ machines), so it’s gitignored rather than committed. A path is also not a secret, so it doesn’t belong in the SOPS-encrypted files either.
This file is created by psw-proxmox-installer when you set up your Proxmox
node
. It’s the foundation — without SSH access, PSW can’t reach anything.
apps.yml#
Per-app secrets plus a couple of shared-infra sections (SMTP relay, VPS provider token) all live in one file:
apps:
postgres:
postgres_password: "generated-password-here"
forgejo:
forgejo_admin_password: "generated-password-here"
forgejo_db_password: "generated-password-here"
authelia:
authelia_db_password: "generated-password-here"
authelia_jwt_secret: "generated-token-here"
smtp:
host: "smtp.gmail.com"
port: 587
username: "you@example.com"
password: "generated-password-here"
from: "you+psw@example.com"
tls: true
vps:
hostinger_api_token: "your-token-here"apps.<name>.* keys are created every time you add an app
with psw app add — each app’s metadata
declares which keys it needs and PSW auto-generates them.
smtp and vps sit at the top level, not under an app name, because they’re shared infrastructure: any app that sends mail uses the same relay; the VPS token is used by the remote-access
feature, not any particular app. The SMTP section is written during the wizard’s Start step
; the VPS section is populated by the VPS installer the first time you set up remote access.
providers.yml#
Connection credentials for providers :
opnsense:
url: "https://10.10.0.1"
api_key: "your-api-key"
api_secret: "your-api-secret"
cloudflare:
api_token: "your-cloudflare-token"Written by psw provider configure when you set up providers
.
Two Types of Secrets#
Auto-Generated Secrets#
Most secrets are auto-generated by PSW. When you add an app
, PSW looks at the app’s metadata
to find which secrets it needs (required_secrets), then generates them automatically:
- Secrets ending in
_password→ complex passwords (with uppercase, digits, special chars) - Secrets ending in
_api_key→ hex tokens - Secrets ending in
_jwks_key→ RSA private keys - Everything else → URL-safe random tokens
You never have to come up with passwords yourself for these.
User-Provided Secrets#
Some secrets can’t be auto-generated — they come from external sources. For example, an API token from a third-party service, or a license key.
These are declared in the app’s metadata
as user_provided_secrets and must be supplied when adding the app:
psw app add homeassistant --target home --secret ha_long_lived_token=your-token-herePSW blocks deployment if user-provided secrets are missing.
How SOPS Works#
SOPS encrypts YAML (a human-readable configuration file format) files value-by-value — the keys (field names) stay readable, but the values are encrypted. This means you can see what secrets exist without being able to read what they contain:
# What an encrypted file looks like in git:
apps:
postgres:
postgres_password: ENC[AES256_GCM,data:abc123...,type:str]
forgejo:
forgejo_admin_password: ENC[AES256_GCM,data:def456...,type:str]
sops:
age:
- recipient: age1qx...
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...Transparent Read/Write#
PSW uses a SecureYAML layer that makes encryption completely transparent. When your code reads a secret file, it gets back a plain Python dictionary — decryption happens automatically. When it writes, encryption happens automatically. You never deal with SOPS directly.
Partial Updates (Smart Diffs)#
When a single secret changes (e.g., you add a new app), SOPS could re-encrypt the entire file — which would change every value’s ciphertext and make the git diff unreadable.
PSW avoids this with partial updates: it compares the old and new data, then uses SOPS
set to update only the changed values. The result is clean git diffs that show exactly what was added or modified:
# Git diff after adding homeassistant:
+ homeassistant:
+ ha_long_lived_token: ENC[AES256_GCM,data:xyz789...,type:str]
# All other values: unchanged ciphertext
Setting Up SOPS#
SOPS is initialized as part of project setup:
psw -C ~/my-project sops setupThis:
- Generates an age keypair (
secrets/.age-key) - Creates
.sops.yamlwith encryption rules - Displays the public key (back it up!)
After setup, all writes to secrets/ are automatically encrypted.
How Secrets Reach Your Apps#
Here’s the full journey of a secret from encrypted file to running app:
secrets/apps.yml (encrypted in git)
│
▼ SecureYAML.read() — decrypts with age key
│
Plain Python dict (in memory only)
│
▼ build_deploy_vars() — flattens into deployment variables
│
.psw/deploy/vars.yml (temporary plaintext file, gitignored)
│
▼ Deploy engine renders templates with secrets as context
│
App receives its secrets in environment files and config files
│
▼ Cleanup
│
.psw/deploy/vars.yml deletedThe temporary plaintext file exists only during deployment and is deleted immediately after. Both the file write and any subprocess invocation that touches secrets are scrubbed from logs and ansible-runner artifacts before they leave memory — ANTHROPIC_API_KEY, CLOUDFLARE_API_TOKEN, and the rest never appear in plaintext on disk outside .psw/deploy/vars.yml.
Reusing Secrets Across Project Rebuilds#
Looking for the full disaster-recovery story? This section covers only the secrets half of recovery — the panic backup, the age key, what happens to your encrypted credentials when you rebuild. For the umbrella view that ties secrets together with your git remote (project shape) and per-app bundles (application data), see disaster-recovery.md . Three layers, restored in three different ways, each documented independently. The umbrella doc is the right starting point if you’ve never actually walked through a recovery before.
Sometimes you want to start fresh on the infrastructure side without losing the secrets — rebuilding the same project after a reset, recovering from a wiped workstation, copying a working setup into a sibling project for testing. PSW makes this work because SOPS-encrypted secrets are just files: with the right age key, any new project on any machine can decrypt them.
Two recovery scenarios — pick yours first#
Before diving in, figure out which of these you’re in. They look similar but the steps are different.
Scenario A: “Bring my old project back.” Your project still exists in your off-site git remote (project shape + encrypted secrets/*.yml files), but your workstation is gone or your infrastructure is gone. You want to rebuild the same project, with the same name and the same identity. This is the most common disaster-recovery scenario and what PSW’s design assumes.
Scenario B: “Carry settings forward into a brand new project.” You’re starting a fresh project (different name, maybe different domain — typical reasons: staging clone of production, migrating to a new domain, splitting one project into two). You don’t want to retype your Cloudflare token, OPNsense credentials, SMTP relay — you want to inherit those from an existing project.
The two scenarios share one piece of plumbing — the panic-backup tarball made by psw project export-recovery — but the operator flow on the receiving end differs.
| Scenario A (bring back) | Scenario B (carry forward) | |
|---|---|---|
| Project name | Same as before | New |
| Project shape (project.yml, network.yml, deployment-plan.yml, services/, roles/) | From git remote | The wizard creates fresh |
Encrypted secrets (secrets/*.yml) | From git remote (already there) | From the panic backup |
Age key (secrets/.age-key) | From your separate backup | From the panic backup |
| What you walk through in the wizard | Skip Start, jump to Launch | Full wizard from Start |
Both scenarios work today — but only Scenario B has a one-click wizard UI right now. Scenario A still uses a 3-line manual recipe (
git clone+cp .age-key+psw wizard). A wizard UI for Scenario A is planned atdocs/plans/wizard-key-recovery/— same panic-backup tarball, smart import that detects “you’ve git-cloned this project and just need the key back”.
The panic backup — applies to both scenarios#
Whichever scenario you eventually need, the input is the same: a panic-backup tarball you made on a healthy day.
psw project export-recovery
# → ~/psw-recovery/<project>-<utc-iso-timestamp>.tar.gzThat command bundles up your age key + every encrypted file under secrets/ + a small label saying “this came from project X, domain Y”. Drop the resulting .tar.gz somewhere safe — USB stick, encrypted cloud drive, password-manager attachment, your safety-deposit box. Wherever you’d keep an important paper document. Run the command again whenever you change a credential; each run produces a fresh timestamped file, so old ones never get accidentally overwritten.
Why one file is safe. The bundle has the age key (the master decryption key) AND the encrypted secrets. You need both to unlock anything; one without the other is useless. That’s why keeping the bundle on encrypted storage matters — anyone who steals the file can decrypt your secrets. Treat it the way you’d treat your SSH private key.
Want extra defense-in-depth? Keep the panic-backup tarball in one place and the age key in a different one (e.g. tarball on a USB stick in the office, age key in a safety-deposit box across town). The wizard’s restore card has an “advanced” toggle for that flow — most operators don’t need it.
Scenario A — bring my old project back (manual today, UI planned)#
The state of the world after your house burns down:
- Off-site git remote of your
~/casaeureka(or whatever you named your project) checkout, including the encryptedsecrets/apps.yml,secrets/providers.yml,secrets/infra.yml,secrets/smtp.yml. (Encrypted SOPS files are safe to commit; they’re already ciphertext.) - The age key, backed up separately from the git repo (panic-backup tarball, USB stick, password manager, encrypted cloud drive — anywhere that doesn’t share a single point of failure with the git remote).
The walkthrough:
# 1. New machine, clone the project — restores everything except .age-key.
git clone <offsite-repo-url> ~/casaeureka
# 2. Restore just the age key.
# If your panic backup is a tarball, extract recovery/age-key from it.
# If you saved the file directly, copy it over.
cp /path/to/your-backup/casaeureka.age-key ~/casaeureka/secrets/.age-key
chmod 0600 ~/casaeureka/secrets/.age-key
# 3. Open the wizard. It detects you're past Start (project.yml is
# already there, network.yml is already there, deployment plan is
# already there) and routes you straight to where you left off —
# typically Hardware (re-import inventory), then Plan (re-confirm),
# then Install, then Launch. Bootstrap reads the existing encrypted
# secrets — no regeneration, no retyping.
psw wizard --project ~/casaeurekaSince the encrypted files are already in git, no credential prompts appear. PSW reads the existing apps.yml, providers.yml, infra.yml, smtp.yml exactly as they were when you committed them — same Cloudflare token, same OPNsense URL/key/secret, same SSO admin password, same auto-generated app passwords, same SMTP relay. The new infrastructure (new Proxmox install, new LXCs, new ZFS datasets, new postgres database) ends up wired to the old identity.
psw project generate-secrets (the secret-generation step inside bootstrap) is preservation-aware: when an existing secrets/apps.yml is present, it runs sync_missing_app_secrets, which fills in only secrets that newer meta.yml files have started declaring since the last run. Existing values are never touched.
This is what makes the “git-clone-and-bootstrap” disaster-recovery story actually a story.
Why isn’t this in the wizard UI yet? The wizard’s “Restore from a previous project” card refuses to drop files when the project shape is already in place from git (it’d be clobbering
project.yml,.sops.yaml, the encryptedsecrets/*.yml, etc.). The right design is a separate card that detects the git-cloned state and only installs the missing age key — same panic-backup tarball as Scenario B, different operator intent. Tracked atdocs/plans/wizard-key-recovery/.
Scenario B — carry settings forward into a brand new project (one-click in the wizard)#
This is what the wizard’s “Restore from a previous project” card does today. Use it when you’re starting a new project (new name, maybe new domain) and you want to inherit settings from an old one — staging clone, domain migration, sibling project sharing the same Cloudflare zone, etc.
The flow:
- Start the wizard against the new project’s directory:
psw wizard --project ~/casaeureka-staging. - At the top of the Start page you’ll see a card titled “Restore from a previous project”. Click it open.
- Pick your panic-backup tarball. (Toggle “Use a separately-stored age key” only if you keep your age key separate from the bundle — most operators don’t.)
- Click Restore from bundle. PSW validates the bundle, makes sure the key actually decrypts the secrets, drops every file into the new project, and fills the Start form with everything it found: domain, Cloudflare token, OPNsense credentials, SMTP relay. A green banner shows up saying “Restored credentials from
<source-project>(domain<source-domain>)”. - Review the form. Edit anything you want to change — e.g. you’re deliberately moving to a new domain and your Cloudflare token covers the new zone, or you want to rotate the OPNsense credentials. Hit Submit.
- The wizard continues to Hardware → Plan → Install → Launch the same as any brand-new project. The difference is your credentials are inherited, not retyped.
If you accidentally close the browser between step 4 and step 5, no panic — re-open the page and the form comes back populated. The Restore card stays expanded with the green banner so you know you’re picking up where you left off.
Headless / CI variant uses the same plumbing through psw wizard --prefill <json> with restore_from (and optionally age_key) fields — see wizard-prefill.md § Recovery mode
. The headless path is what the disaster-recovery proving test exercises in CI.
The manual fall-back recipe for Scenario B (no wizard UI, hand-copy the files) is documented in docs/testing/disaster-recovery.md
Phase 1 + 3 — it’s the under-the-hood reference if the wizard card itself ever breaks.
Just the age key, throw the rest away#
If you want fresh everything but the same encryption identity (so future encrypted files still need the same age key to decrypt), copy only secrets/.age-key and let bootstrap regenerate apps.yml + providers.yml from scratch. This is rarely what you want — it loses the continuity of the SOPS secret values — but it’s an option for “I want to use the same offsite-backed-up age key envelope across multiple projects without copy-pasting tokens between them.”
What’s NOT preserved by SOPS reuse#
The bundle restore story (see backups.md § Where to Keep Your Bundles ) is separate from SOPS reuse. SOPS preserves credentials and configuration; bundles preserve application state (rows in the Vaultwarden DB, files in Forgejo’s git store, recordings in Frigate). For real disaster recovery across a project rebuild you need both:
| Layer | Preserved by | What it carries |
|---|---|---|
| Encryption identity | The age key alone | The ability to decrypt old SOPS files |
| Credentials + config | The age key + the encrypted secrets/ files | DB passwords, API tokens, ACME certs, SSO admin password |
| Project shape | The git repo | Which apps live where, deployment plan, conventions |
| Application state | Bundles (backups.md ) | Vaultwarden’s encrypted vault, Forgejo’s repos, Home Assistant’s automations |
A complete recovery is “git clone + restore age key + psw deploy bootstrap + psw bundle import for each app you want to bring back”. Without all four, the recovery is partial.
Quick Reference#
| Term | What It Is |
|---|---|
| SOPS | Encryption tool that encrypts YAML values while keeping keys readable |
| age | Modern encryption algorithm used by SOPS (replaces GPG) |
| Age key | Master keypair at secrets/.age-key — back this up! |
| Auto-generated | Secrets PSW creates for you (passwords, tokens) |
| User-provided | Secrets you supply from external sources (API tokens, licenses) |
| SecureYAML | Transparent layer — read/write dicts, encryption is automatic |
| Partial update | Only re-encrypts changed values for clean git diffs |