Skip to content

Security

On first run, Holden generates keys in /data:

FilePurpose
age.keyAge keypair for encrypting secrets
password_seedSeed 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.

Database passwords are derived, not stored:

password = sha256(password_seed + ":" + app-id + ":" + service-name) // 64 hex chars

Same seed + same config = same passwords. This means:

  • Passwords survive Holden restarts
  • No password storage needed
  • Deterministic credentials for debugging

Secrets in holden.vars.yml are encrypted with age. The age keypair is generated on first run:

/data/age.key

The private key stays on the server. The CLI fetches the public key to encrypt secrets locally.

When you run holden vars set --secret, the CLI needs the age public key:

  1. CLI calls Holden’s internal API at localhost:6021/keys/age.pub
  2. For remote servers, CLI uses SSH to tunnel: ssh user@server curl localhost:6021/keys/age.pub
  3. Public key is cached in ~/.holden/config.yml
  4. Subsequent encryptions use the cached key (works offline)

Holden exposes two HTTP servers:

PortAccessProtected by
6020Public (via Traefik)HOLDEN_WEBHOOK_SECRET (HMAC signatures) and HOLDEN_API_KEY (shared secret)
6021Localhost 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_SECRET to enable GitHub/Forgejo webhook pushes
  • Set HOLDEN_API_KEY to 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.

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.

SSH access = full trust. Anyone with SSH access to the server can:

  • Read /data and 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.

If keys are compromised, see Key Rotation for rotation procedures.