Security
Key Generation
Section titled “Key Generation”On first run, Holden generates keys in /data:
| File | Purpose |
|---|---|
age.key | Age keypair for encrypting secrets |
password_seed | Seed for deriving database passwords |
These are created once on first boot and reused. The webhook secret is configured via the HOLDEN_WEBHOOK_SECRET environment variable.
Password Derivation
Section titled “Password Derivation”Database passwords are derived, not stored:
password = sha256(password_seed + ":" + app-id + ":" + service-name) // 64 hex charsSame seed + same config = same passwords. This means:
- Passwords survive Holden restarts
- No password storage needed
- Deterministic credentials for debugging
Secret Encryption
Section titled “Secret Encryption”Secrets in holden.vars.yml are encrypted with age. The age keypair is generated on first run:
/data/age.keyThe private key stays on the server. The CLI fetches the public key to encrypt secrets locally.
CLI Key Caching
Section titled “CLI Key Caching”When you run holden vars set --secret, the CLI needs the age public key:
- CLI calls Holden’s internal API at
localhost:6021/keys/age.pub - For remote servers, CLI uses SSH to tunnel:
ssh user@server curl localhost:6021/keys/age.pub - Public key is cached in
~/.holden/config.yml - Subsequent encryptions use the cached key (works offline)
API Authentication
Section titled “API Authentication”Holden exposes two HTTP servers:
| Port | Access | Protected by |
|---|---|---|
| 6020 | Public (via Traefik) | HOLDEN_WEBHOOK_SECRET (HMAC signatures) and HOLDEN_API_KEY (shared secret) |
| 6021 | Localhost only (SSH tunnel) | Network isolation |
Both secrets are optional. If a secret is not configured, its corresponding routes return 503 Service Unavailable — the endpoints exist but refuse all requests. This means you only enable what you need:
- Set
HOLDEN_WEBHOOK_SECRETto enable GitHub/Forgejo webhook pushes - Set
HOLDEN_API_KEYto enable management routes (dashboard UI) - Leave both unset and Holden only accepts requests on the internal port (6021) via SSH
Both must be at least 32 characters if set.
Sensitive operations like secret decryption (/decrypt) are only available on the internal port (6021) and are never exposed publicly, regardless of configuration.
Rate Limiting
Section titled “Rate Limiting”When HOLDEN_PUBLIC_DOMAIN is set, Holden adds Traefik rate limiting middleware to its own router: 10 requests/second average, burst of 30. This is a global rate limit (not per-IP) that provides baseline protection against brute-force attempts.
Trust Model
Section titled “Trust Model”SSH access = full trust. Anyone with SSH access to the server can:
- Read
/dataand decrypt all secrets - Derive all database passwords
- Manage containers via Docker socket
This is intentional for single-admin self-hosted deployments.
Git repo access = limited trust. Someone with repo access can see:
- Encrypted secret blobs (but not decrypt them)
- App configuration
- Plaintext config values
They cannot derive passwords or decrypt secrets without /data.
Key Rotation
Section titled “Key Rotation”If keys are compromised, see Key Rotation for rotation procedures.