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:

  1. A “Download age-key” button saves secrets/.age-key to your laptop
  2. An acknowledgment checkbox — “I’ve stored this somewhere safe” — unlocks the Continue button
  3. PSW writes the age-key-acknowledged key 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:

FileWhat It ContainsCreated By
secrets/infra.ymlSSH keys for connecting to nodes and targetspsw-proxmox-installer
secrets/apps.ymlPer-app passwords, tokens, and signing keyspsw project generate-secrets / psw app add
secrets/providers.ymlProvider 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-here

PSW 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 setup

This:

  1. Generates an age keypair (secrets/.age-key)
  2. Creates .sops.yaml with encryption rules
  3. 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 deleted

The 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 nameSame as beforeNew
Project shape (project.yml, network.yml, deployment-plan.yml, services/, roles/)From git remoteThe wizard creates fresh
Encrypted secrets (secrets/*.yml)From git remote (already there)From the panic backup
Age key (secrets/.age-key)From your separate backupFrom the panic backup
What you walk through in the wizardSkip Start, jump to LaunchFull 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 at docs/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.gz

That 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 encrypted secrets/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 ~/casaeureka

Since 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 encrypted secrets/*.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 at docs/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:

  1. Start the wizard against the new project’s directory: psw wizard --project ~/casaeureka-staging.
  2. At the top of the Start page you’ll see a card titled “Restore from a previous project”. Click it open.
  3. 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.)
  4. 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>)”.
  5. 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.
  6. 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:

LayerPreserved byWhat it carries
Encryption identityThe age key aloneThe ability to decrypt old SOPS files
Credentials + configThe age key + the encrypted secrets/ filesDB passwords, API tokens, ACME certs, SSO admin password
Project shapeThe git repoWhich apps live where, deployment plan, conventions
Application stateBundles (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#

TermWhat It Is
SOPSEncryption tool that encrypts YAML values while keeping keys readable
ageModern encryption algorithm used by SOPS (replaces GPG)
Age keyMaster keypair at secrets/.age-key — back this up!
Auto-generatedSecrets PSW creates for you (passwords, tokens)
User-providedSecrets you supply from external sources (API tokens, licenses)
SecureYAMLTransparent layer — read/write dicts, encryption is automatic
Partial updateOnly re-encrypts changed values for clean git diffs