Skip to content

Security Configuration

fuomag9 edited this page Sep 26, 2026 · 10 revisions

Security Configuration

How to secure your Caddy Proxy Manager deployment in production.

Table of Contents

  1. Production Security Requirements
  2. Password Security
  3. Session Secret Management
  4. Rate Limiting
  5. Container Security
  6. Network Security
  7. Web UI Security Headers
  8. Certificate Security
  9. Multi-Instance Deployments
  10. Production Deployment Checklist

Production Security Requirements

The app refuses to start in production if you haven't set strong credentials. No "admin/admin" in production, and no example values copied from the documentation. Any runtime NODE_ENV other than development gets these checks.

Required Variables

  1. SESSION_SECRET

    • Minimum 32 characters
    • Must be cryptographically random
    • Cannot use placeholder values, including your-secure-session-secret-here-min-32-chars from older .env.example files
  2. ADMIN_PASSWORD

    • Minimum 12 characters
    • Must include uppercase letters (A-Z)
    • Must include lowercase letters (a-z)
    • Must include numbers (0-9)
    • Must include special characters (!@#$%^&* etc.)
    • Cannot be default "admin"
    • Cannot be an example password from the README or .env.example

.env.example leaves SESSION_SECRET, ADMIN_PASSWORD and CLICKHOUSE_PASSWORD empty, so docker compose refuses to start until you fill them in. (The example-value checks and the empty .env.example values are new in v1.13.1.)

Quick Production Setup

# Generate secure session secret
export SESSION_SECRET=$(openssl rand -base64 32)

# Set secure admin credentials
export ADMIN_USERNAME="admin"
export ADMIN_PASSWORD="<choose-your-own: 12+ chars, upper, lower, digit, symbol>"

# Start containers
docker compose up -d

Password Security

Password Requirements

Production passwords must:

  • Be at least 12 characters long
  • Contain at least one uppercase letter (A-Z)
  • Contain at least one lowercase letter (a-z)
  • Contain at least one number (0-9)
  • Contain at least one special character (!@#$%^&*)
  • Not be the default value "admin"

User passwords set in the app are also limited to 256 characters.

Password Validation

Validation occurs:

  • At application startup (ADMIN_PASSWORD)
  • When changing or setting a password on the Profile page
  • When an admin creates a user (Users page and POST /api/v1/users)
  • On Better Auth self-registration (AUTH_ALLOW_SELF_REGISTRATION=true) and password reset

(Before v1.13.1, the policy applied only to ADMIN_PASSWORD and password changes.) A password that fails the policy is rejected with a message such as "Password must be at least 12 characters long, must include at least one number".

Password Changes and Sessions

(Since v1.13.1.)

  • Changing or setting a password signs out the user's other dashboard sessions and all of their forward-auth sessions; the current session stays. API tokens are kept, so revoke them under Profile → API Tokens if needed.
  • An OAuth-only account can add a first password only within 10 minutes of signing in; otherwise the request is refused with "Please sign in again before setting a password."
  • A password changed in the UI is no longer overwritten by ADMIN_PASSWORD on restart. To recover a lost admin password, set ADMIN_PASSWORD to a new value and run docker compose up -d. See Environment Variables Reference#how-admin_username-and-admin_password-are-applied.
  • Better Auth's own self-service endpoints that CPM does not use (/api/auth/update-user, /change-password, /change-email, /delete-user, /unlink-account, /update-session, /verify-password, /is-username-available) are disabled; use the Profile page or /api/v1/ instead.

Password Storage

  • Passwords are hashed with bcrypt (cost 12)
  • Hashes stored in SQLite database
  • Original passwords never stored in plain text
  • Bcrypt provides salt and key derivation

Password Best Practices

  1. Use a password manager to generate and store passwords
  2. Use unique passwords for each deployment
  3. Rotate passwords regularly (every 90 days recommended)
  4. Never share passwords via insecure channels (email, chat, etc.)
  5. Change default password immediately after first login
  6. Never reuse an example password from documentation; CPM rejects the known ones

Session Secret Management

What is SESSION_SECRET?

The session secret is used for:

  • Encrypting session cookies
  • Signing JWT tokens
  • CSRF protection
  • OAuth state parameter signing
  • Encrypting secrets stored in the database (AES-256-GCM): DNS provider credentials, OAuth client secrets and sign-in tokens, imported certificate private keys, CA private keys, instance API tokens and the instance sync master token
  • On an instance sync slave, deriving the sync key that the master seals synced secrets to

Requirements

  • Minimum length: 32 characters
  • Uniqueness: Different for each deployment
  • Randomness: Cryptographically secure random data
  • Persistence: Changing it invalidates all sessions; change it only with SESSION_SECRET_PREVIOUS (see Secret Rotation) so stored secrets stay readable
  • Backup: Back it up together with the database; without it the stored secrets cannot be decrypted

Generating Secure Secrets

Recommended method (OpenSSL):

openssl rand -base64 32

Alternative methods:

# Using /dev/urandom
head -c 32 /dev/urandom | base64

# Using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

# Using Python
python3 -c "import os; import base64; print(base64.b64encode(os.urandom(32)).decode())"

Secret Rotation

When to rotate:

  • Suspected compromise
  • Security incident
  • Employee offboarding
  • Compliance requirements

How to rotate (since v1.13.1):

  1. Generate new secret
  2. In .env, set SESSION_SECRET to the new value and SESSION_SECRET_PREVIOUS to the old one (comma-separated if there are several)
  3. Recreate the web container: docker compose up -d (docker compose restart does not re-read .env)
  4. On startup every stored secret that only the old secret decrypts is re-encrypted with the new SESSION_SECRET, logged as Re-encrypted N stored secret(s) with the current SESSION_SECRET. SESSION_SECRET_PREVIOUS is never used to encrypt
  5. Remove SESSION_SECRET_PREVIOUS after one successful start, then recreate the container again
  6. All users will be logged out (this is expected)

Warning: Rotating the secret logs out all users immediately.

Without SESSION_SECRET_PREVIOUS, stored secrets encrypted with the old secret can no longer be decrypted and must be re-entered. A value that no key decrypts is left as stored and logged (see Troubleshooting#secret-decryption-issues); if a CA private key is affected, issuing client certificates from that CA fails until you restore the old secret via SESSION_SECRET_PREVIOUS or create a new CA. Values stored under a public placeholder secret are re-encrypted without any extra configuration.

Instance sync slaves: a slave's sync key is derived from its SESSION_SECRET, and the master has pinned the old key. Keep the old value in the slave's SESSION_SECRET_PREVIOUS until the master has synced to it once since the restart (click Sync now on the master), so the slave can prove its new key; removed too early, the sync fails with "Slave sync key changed; verify the slave, then pin its new key or reset its key pin". If the old secret may have leaked, pin the slave's new key on the master by hand instead of relying on the automatic re-pin. See Feature Guide Instance Sync#rotating-a-slaves-session_secret.


Rate Limiting

Login Rate Limiting

Prevents brute force attacks by limiting login attempts. Powered by Better Auth's built-in rate limiter.

Default configuration:

  • Dashboard sign-in and sign-up: 3 requests per 10 seconds per client address (Better Auth's built-in rule; AUTH_RATE_LIMIT_MAX and AUTH_RATE_LIMIT_WINDOW do not change it)
  • Better Auth's other /api/auth endpoints: AUTH_RATE_LIMIT_MAX requests per AUTH_RATE_LIMIT_WINDOW seconds (default 5 per 60)

How it works:

  1. Every request to /api/auth counts against its endpoint's limit
  2. Once the limit is reached, further requests are rejected with HTTP 429 "Too many requests. Please try again later."
  3. Counter resets after the window expires

Custom Rate Limiting

Configure via environment variables. The stock docker-compose.yml does not pass the AUTH_RATE_LIMIT_* variables through; add them to the web service's environment: to change them.

# Disable rate limiting entirely, sign-in included (not recommended)
AUTH_RATE_LIMIT_ENABLED=false

# Allow more requests per window (Better Auth endpoints other than sign-in and sign-up)
AUTH_RATE_LIMIT_MAX=10

# Longer observation window (120 seconds)
AUTH_RATE_LIMIT_WINDOW=120

See Environment Variables Reference#rate-limiting for details.

Forward Auth Portal Login

(Since v1.13.1.) Portal logins (/portal) are limited with LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MS and LOGIN_BLOCK_MS, per client, per client and account, and with an hourly ceiling per account from all clients combined (65 failures with the defaults). Unknown and disabled accounts get the same 401 as a wrong password. The stock docker-compose.yml does not pass the LOGIN_* variables through, so add them to the web service's environment: to change them. See Feature Guide Forward Auth#portal-login-rate-limits.

The client address is the rightmost X-Forwarded-For entry, which is the real client when browsers connect to Caddy directly. A client-sent X-Real-IP is no longer trusted. Behind a CDN, set TRUSTED_CLIENT_IP_HEADER (e.g. cf-connecting-ip), but only if the origin accepts connections from the CDN alone; otherwise clients can forge the header. The same client address is used for the rate limit of the instance sync endpoint on slaves.

Recommendation: don't publish port 3000 to untrusted networks. Clients that reach it directly control X-Forwarded-For, so the per-client limits are only best effort; serve the dashboard and portal (BASE_URL) through Caddy instead. ufw does not filter Docker-published ports; see Firewall Configuration.

Rate Limiting Considerations

Limitations:

  • In-memory storage (not persistent across restarts)
  • Not suitable for multi-instance deployments
  • Per-instance tracking only

Recommendations:

  • Use OAuth for better security
  • Monitor failed login attempts in audit logs
  • Consider external firewall/WAF for additional protection

Container Security

Rootless Containers

Run containers as non-root users for improved security.

Default UIDs/GIDs:

  • Web service: 10001:10001
  • Caddy service: 10000:10000

Custom user (recommended for development):

# Match your host user
PUID=1000  # id -u
PGID=1000  # id -g

# Rebuild containers
docker compose up --build -d

See Rootless Docker Operation for complete guide.

Image Security

  • Minimal base images (Alpine Linux)
  • Regular security updates
  • SBOM (Software Bill of Materials) generated
  • Build provenance attestation
  • Automated vulnerability scanning

Volume Permissions

Secure data directories:

chmod 700 data caddy-data caddy-config

Restrict .env file:

chmod 600 .env
chown root:root .env  # If running as root

Database files: the SQLite database holds password hashes, session tokens and encrypted secrets. Since v1.13.1, the web container removes the world permission bits from the database and its -journal/-wal/-shm files whenever it opens the database. Owner and group bits are unchanged, so a backup job in the files' group keeps working; one running as an unrelated user can no longer read them. See Rootless Docker Operation.

Container Isolation

  • Containers run in isolated bridge network
  • Caddy admin API (port 2019) not exposed externally
  • Only web UI port (3000) and HTTP/HTTPS (80/443) exposed
  • Secrets not accessible from Caddy container

Network Security

Port Exposure

Exposed ports:

  • 80 (HTTP) - Public
  • 443 (HTTPS) - Public
  • 3000 (Web UI) - Should be behind reverse proxy in production

Internal ports (not exposed):

  • 2019 (Caddy Admin API) - Internal only

Reverse Proxy for Web UI

Recommended: Put web UI behind reverse proxy

Example Caddy configuration:

proxy-ui.example.com {
    reverse_proxy localhost:3000
}

With CPM's own Caddy container, create a proxy host for the UI domain with the upstream web:3000 (both containers share caddy-network), and set BASE_URL to its URL (e.g. https://proxy-ui.example.com).

Benefits:

  • HTTPS for web UI
  • Access control
  • Rate limiting
  • Logging

Firewall Configuration

Docker-published ports bypass ufw and other host INPUT rules, so ufw allow/ufw deny rules for port 3000 have no effect. Since v1.13.1 this matters for more than the UI itself: clients that reach port 3000 directly control X-Forwarded-For, which the per-client login limits use.

Recommended: publish port 3000 on loopback only and reach the UI through Caddy (see Reverse Proxy for Web UI). In docker-compose.yml, change the web service's ports: from "3000:3000" to:

    ports:
      - "127.0.0.1:3000:3000"

Then run docker compose up -d.

Or filter it in the DOCKER-USER chain (or an external firewall), e.g. to allow only 192.0.2.0/24:

# Replace eth0 with the host's external interface; without -i the rule also
# drops Caddy's own requests to the web container
iptables -I DOCKER-USER -i eth0 -p tcp --dport 3000 -j DROP
iptables -I DOCKER-USER -i eth0 -p tcp --dport 3000 -s 192.0.2.0/24 -j ACCEPT

These rules are not persistent; save them with your distribution's iptables persistence tooling, and add matching ip6tables rules if the host has public IPv6.

Network Isolation

  • Use Docker network isolation
  • Don't expose internal services
  • Use VPN for remote access to UI
  • Consider Cloudflare Tunnel for secure access

Web UI Security Headers

Every page and API route the web container serves (static assets aside) gets a nonce-based Content Security Policy plus X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin and a restrictive Permissions-Policy. The policy allows scripts only from the same origin or carrying the per-request nonce, and includes frame-ancestors 'none', base-uri 'none', object-src 'none' and form-action 'self'.

(Since v1.13.1.) Public routes (/login, /portal, /api/auth/*, /api/forward-auth/*, /api/v1/*, /api/health and /api/instances/sync) get the full policy too; they previously only sent the framing protections. A client-supplied Content-Security-Policy request header is overwritten, and after a password login the dashboard loads as a new document with its own policy and nonce.

If you put another reverse proxy in front of the web UI, don't strip or replace these headers.


Certificate Security

Private Key Storage

Imported certificate private keys and CA private keys (the built-in CA used for mTLS client certificates) are stored encrypted in the SQLite database with AES-256-GCM, using a key derived from SESSION_SECRET. Keys stored in plaintext by older versions are encrypted on startup (CA private keys since v1.13.1). Imported keys are write-only in ordinary API responses and browser payloads.

Implications:

  • A copy of the database alone does not reveal the keys, but the database together with SESSION_SECRET does
  • Back up SESSION_SECRET with the database, but store them separately; without the secret the keys are lost
  • If a CA private key can no longer be decrypted, issuing client certificates from that CA fails with "The CA private key cannot be decrypted with the current SESSION_SECRET. Restore the previous secret via SESSION_SECRET_PREVIOUS or create a new CA." Certificates already issued keep working
  • With instance sync, CA private keys stay on the master: slaves receive CA certificates without keys and cannot issue client certificates
  • Consider HSM for high-security requirements

Mitigation:

  • Encrypt database backups
  • Restrict database file permissions (chmod 600)
  • Use short-lived certificates
  • Rotate certificates regularly

Certificate Best Practices

  1. Use Let's Encrypt for automatic renewal
  2. Enable Cloudflare DNS-01 for wildcard certificates
  3. Monitor expiration dates (Caddy handles this automatically)
  4. Use strong key sizes (2048-bit RSA minimum, 256-bit ECDSA recommended)
  5. Limit certificate scope (don't use one cert for everything)

ACME Account Key

  • Stored by Caddy in /data volume
  • Encrypted by Caddy
  • Backup Caddy data volume for continuity

Multi-Instance Deployments

Instance Sync (Master/Slave)

Caddy Proxy Manager supports a master/slave configuration via Instance Sync.

The master pushes proxy hosts, certificates, access lists, and settings to each slave on every configuration change. User accounts are not synced.

# Master
INSTANCE_MODE=master
INSTANCE_SLAVES='[{"name":"replica","url":"https://replica.example.com","token":"<32-char-token>"}]'

# Slave
INSTANCE_MODE=slave
INSTANCE_SYNC_TOKEN=<32-char-token>

See Environment Variables Reference for the full list of INSTANCE_* variables.

Sync Security

(Since v1.13.1.)

  • Sealed secrets: before each sync the master fetches the slave's sync public key (GET /api/instances/sync, same bearer token) and seals certificate private keys and DNS provider credentials to it. The slave stores them encrypted with its own SESSION_SECRET, so master and slaves no longer need to share a secret. The rest of the configuration is not sealed, so HTTPS is still required.
  • Key pinning: the master pins each slave's sync key on first use and refuses to sync to a different one ("Slave sync key changed; verify the slave, then pin its new key or reset its key pin"). To avoid trusting the first key, pin it in advance with syncPublicKey in INSTANCE_SLAVES or from Settings → Instance Sync on the master.
  • CA private keys stay on the master: slaves get CA certificates without their keys, so they validate client certificates but cannot issue them.
  • Transport: the master does not follow redirects from a slave, requires the slave's acknowledgement, and stops a request after INSTANCE_SYNC_TIMEOUT_MS (default 60 s). Slave URLs must not contain credentials, a query string or a fragment.
  • Proxies in front of a slave must pass both GET and POST on /api/instances/sync, including the Authorization header and the query string, and must not cache the GET reply.

See Feature Guide Instance Sync for setup, key pinning, rotation and upgrade order.

Remaining Limitations

Even with instance sync, some limitations apply:

  1. Rate limiting — In-memory only, not shared across instances
  2. Session storage — Per-instance; logging into master does not log you into slaves
  3. Database — SQLite; only one writer at a time per instance
  4. Caddy API — Each instance manages its own Caddy process
  5. Certificate issuing — Only the master holds CA private keys; a slave promoted to master cannot issue client certificates from the existing CAs

Active-active deployments (load-balanced identical nodes sharing state) are not supported. The master/slave model is one-way push: only the master manages configuration.

Single Instance HA

For simpler deployments, run one instance with Docker restart policies:

services:
  web:
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "node", "-e", "..."]
      interval: 30s
      timeout: 10s
      retries: 3

Production Deployment Checklist

Before Deployment

  • Generate unique SESSION_SECRET (32+ chars)
  • Set strong ADMIN_PASSWORD (12+ chars, mixed, not an example value)
  • Set correct BASE_URL for your domain
  • Configure ACME email in Settings
  • Review and customize rate limiting
  • Set FORWARD_AUTH_ALLOWED_PORTS if forward-auth protected sites are served on a port other than 80/443
  • Set TRUSTED_CLIENT_IP_HEADER only behind a CDN whose header every route to CPM overwrites
  • Plan backup strategy (database and SESSION_SECRET)
  • Document secrets storage location

Security Configuration

  • Enable HTTPS for web UI (reverse proxy)
  • Restrict web UI access (firewall/VPN)
  • Set up a DNS provider for DNS-01 (wildcard certs) — see DNS Provider Configuration
  • Configure OAuth if using SSO
  • Enable audit logging
  • Restrict .env file permissions (chmod 600)
  • Secure data directory permissions (chmod 700)

Container Security

  • Use rootless containers (PUID/PGID)
  • Keep images updated regularly
  • Enable automatic security updates
  • Monitor security advisories
  • Review Docker Compose configuration

Operational Security

  • Set up monitoring and alerting
  • Configure automated backups
  • Test backup restoration
  • Document incident response plan
  • Plan secret rotation schedule
  • Review access logs regularly

Post-Deployment

  • Change admin password from profile
  • Test all functionality
  • Verify HTTPS certificates
  • Test backup and restore
  • Monitor logs for errors
  • Set up health check monitoring

Security Incident Response

Suspected Compromise

  1. Immediate actions:

    • Rotate SESSION_SECRET (with SESSION_SECRET_PREVIOUS, see Secret Rotation)
    • Change ADMIN_PASSWORD to a new value and run docker compose up -d (this also signs out all of the admin's sessions)
    • Revoke API tokens under Profile → API Tokens (password changes keep them)
    • Review audit logs
    • Check for unauthorized changes
    • With instance sync, pin each slave's new sync key by hand on the master rather than relying on the automatic re-pin
  2. Investigation:

    • Review access logs
    • Check the Users page for unauthorized users, and for sign-in usernames that are somebody else's email address or name (the web container logs Sign-in username "…" of user <id> … on every start for each one; see Troubleshooting#sign-in-username-warnings-on-startup)
    • Verify proxy host configurations
    • Check certificate changes
  3. Recovery:

    • Restore from known-good backup
    • Re-deploy with new secrets
    • Force logout all users
    • Notify relevant parties

User Roles

CPM enforces a three-tier role system:

  • Viewer — Dashboard login and forward-auth access only
  • User — Same as Viewer (intended for forward auth users)
  • Admin — Full access to all features, settings, API, and user management

New users (including OAuth sign-ups) default to user. Only admins can promote users.

(since v1.13.1) A sign-in username is either the account's own email address, when that is a valid username, or one an administrator sets; CPM never derives one from an email address, and refuses a username or email address that would let one name reach two accounts. See Feature Guide User Management#sign-in-usernames.

Non-admin users see only the welcome page — no stats, traffic data, analytics, audit log, or configuration.

See Feature Guide User Management for details.


Related Documentation


Need help? See Troubleshooting or open an issue.

Clone this wiki locally