Repository navigation
Security Configuration
How to secure your Caddy Proxy Manager deployment in production.
- Production Security Requirements
- Password Security
- Session Secret Management
- Rate Limiting
- Container Security
- Network Security
- Web UI Security Headers
- Certificate Security
- Multi-Instance Deployments
- Production Deployment Checklist
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.
-
SESSION_SECRET
- Minimum 32 characters
- Must be cryptographically random
- Cannot use placeholder values, including
your-secure-session-secret-here-min-32-charsfrom older.env.examplefiles
-
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.)
# 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 -dProduction 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.
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".
(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_PASSWORDon restart. To recover a lost admin password, setADMIN_PASSWORDto a new value and rundocker 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.
- 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
- Use a password manager to generate and store passwords
- Use unique passwords for each deployment
- Rotate passwords regularly (every 90 days recommended)
- Never share passwords via insecure channels (email, chat, etc.)
- Change default password immediately after first login
- Never reuse an example password from documentation; CPM rejects the known ones
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
- 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
Recommended method (OpenSSL):
openssl rand -base64 32Alternative 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())"When to rotate:
- Suspected compromise
- Security incident
- Employee offboarding
- Compliance requirements
How to rotate (since v1.13.1):
- Generate new secret
- In
.env, setSESSION_SECRETto the new value andSESSION_SECRET_PREVIOUSto the old one (comma-separated if there are several) - Recreate the web container:
docker compose up -d(docker compose restartdoes not re-read.env) - On startup every stored secret that only the old secret decrypts is re-encrypted with the new
SESSION_SECRET, logged asRe-encrypted N stored secret(s) with the current SESSION_SECRET.SESSION_SECRET_PREVIOUSis never used to encrypt - Remove
SESSION_SECRET_PREVIOUSafter one successful start, then recreate the container again - 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.
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_MAXandAUTH_RATE_LIMIT_WINDOWdo not change it) - Better Auth's other
/api/authendpoints:AUTH_RATE_LIMIT_MAXrequests perAUTH_RATE_LIMIT_WINDOWseconds (default 5 per 60)
How it works:
- Every request to
/api/authcounts against its endpoint's limit - Once the limit is reached, further requests are rejected with HTTP 429 "Too many requests. Please try again later."
- Counter resets after the window expires
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=120See Environment Variables Reference#rate-limiting for details.
(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.
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
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 -dSee Rootless Docker Operation for complete guide.
- Minimal base images (Alpine Linux)
- Regular security updates
- SBOM (Software Bill of Materials) generated
- Build provenance attestation
- Automated vulnerability scanning
Secure data directories:
chmod 700 data caddy-data caddy-configRestrict .env file:
chmod 600 .env
chown root:root .env # If running as rootDatabase 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.
- 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
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
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
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 ACCEPTThese rules are not persistent; save them with your distribution's iptables persistence tooling, and add matching ip6tables rules if the host has public IPv6.
- Use Docker network isolation
- Don't expose internal services
- Use VPN for remote access to UI
- Consider Cloudflare Tunnel for secure access
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.
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_SECRETdoes - Back up
SESSION_SECRETwith 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
- Use Let's Encrypt for automatic renewal
- Enable Cloudflare DNS-01 for wildcard certificates
- Monitor expiration dates (Caddy handles this automatically)
- Use strong key sizes (2048-bit RSA minimum, 256-bit ECDSA recommended)
- Limit certificate scope (don't use one cert for everything)
- Stored by Caddy in
/datavolume - Encrypted by Caddy
- Backup Caddy data volume for continuity
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.
(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 ownSESSION_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
syncPublicKeyinINSTANCE_SLAVESor 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
GETandPOSTon/api/instances/sync, including theAuthorizationheader and the query string, and must not cache theGETreply.
See Feature Guide Instance Sync for setup, key pinning, rotation and upgrade order.
Even with instance sync, some limitations apply:
- Rate limiting — In-memory only, not shared across instances
- Session storage — Per-instance; logging into master does not log you into slaves
- Database — SQLite; only one writer at a time per instance
- Caddy API — Each instance manages its own Caddy process
- 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.
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- 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_PORTSif forward-auth protected sites are served on a port other than 80/443 - Set
TRUSTED_CLIENT_IP_HEADERonly behind a CDN whose header every route to CPM overwrites - Plan backup strategy (database and SESSION_SECRET)
- Document secrets storage location
- 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)
- Use rootless containers (PUID/PGID)
- Keep images updated regularly
- Enable automatic security updates
- Monitor security advisories
- Review Docker Compose configuration
- Set up monitoring and alerting
- Configure automated backups
- Test backup restoration
- Document incident response plan
- Plan secret rotation schedule
- Review access logs regularly
- Change admin password from profile
- Test all functionality
- Verify HTTPS certificates
- Test backup and restore
- Monitor logs for errors
- Set up health check monitoring
-
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
- Rotate SESSION_SECRET (with
-
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
-
Recovery:
- Restore from known-good backup
- Re-deploy with new secrets
- Force logout all users
- Notify relevant parties
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.
- Environment Variables Reference - Security-related variables
- Installation Guide - Secure installation steps
- Rootless Docker Operation - Non-root container security
- OAuth Authentication Setup - SSO security
- Feature Guide User Management - User roles and account management
- Feature Guide Forward Auth - Built-in forward auth portal
- Feature Guide Instance Sync - Sealed sync and sync key pinning
Need help? See Troubleshooting or open an issue.