Production Deployment Guidelines
This guide covers the critical configuration changes you must make before deploying ThunderID in a production environment. The default configuration is designed for local development and evaluation only. Do not use it in production without applying the changes below.
Prerequisites
Before you begin, ensure you have:
- A running ThunderID instance configured for your deployment target (see Choose Your Deployment).
- Access to a supported production database (PostgreSQL 13 or later recommended).
- Valid TLS certificates issued by a trusted Certificate Authority (CA).
- The
opensslcommand-line tool installed on your system.
Place ThunderID Behind a Front Door
Do not expose the ThunderID server port directly to the internet. Route external traffic through a managed front door, load balancer, ingress controller, or API gateway.
Use the front door to enforce:
- Public TLS termination and certificate renewal.
- HTTP-to-HTTPS redirects.
- DDoS and web application firewall protection.
- Request-size, connection, and timeout limits.
- Route-specific rate limits.
- Access logs that preserve the authoritative client IP address.
Restrict the network so clients cannot bypass the front door and connect directly to the ThunderID workload.
Control Endpoint Exposure
Expose only the routes required by the enabled deployment features:
| Route group | Recommended exposure |
|---|---|
/.well-known/**, /oauth2/**, /gate/**, /flow/execute/**, and /flow/meta | Public when required by browser sign-in or OAuth and OIDC clients |
| OpenID4VP, OpenID4VCI, and MCP protocol routes | Public only when the corresponding feature is enabled. Keep their management APIs restricted |
/console/** and management APIs | Restrict to administrative networks, a VPN, or an identity-aware proxy where possible |
/auth/**, /register/passkey/**, and /access/** | Place behind an API gateway or trusted service network |
/health/liveness and /health/readiness | Keep private to the orchestrator, load balancer, or monitoring network |
The default Helm Ingress and HTTPRoute forward the / prefix to ThunderID. Configure route restrictions in the ingress controller, Gateway API implementation, or upstream front door.
Test sign-in and Console operations after applying path restrictions. The Console loads /console/** and calls management APIs, so both route groups must remain available through the administrative entry point.
Configure a Production Database
The default configuration uses SQLite, which stores data in local files. SQLite does not support concurrent writes from multiple processes and is not suitable for production use. Use PostgreSQL for production deployments.
ThunderID uses four separate databases:
| Database | Purpose |
|---|---|
config | Application configuration, flows, and identity providers |
runtime_transient | Short-lived OAuth state: authorization codes, requests, and nonces |
entity | User accounts and credentials |
runtime_persistent | Long-lived operational state: revoked tokens, SSO sessions, and consents |
Configure each database in deployment.yaml:
database:
config:
type: "postgres"
postgres:
hostname: "your-db-host"
port: 5432
name: "configdb"
username: "dbuser"
password: "dbpassword"
sslmode: "require"
max_open_conns: 500
max_idle_conns: 100
conn_max_lifetime: 3600
runtime_transient:
type: "postgres"
postgres:
hostname: "your-db-host"
port: 5432
name: "runtime_transient"
username: "dbuser"
password: "dbpassword"
sslmode: "require"
max_open_conns: 500
max_idle_conns: 100
conn_max_lifetime: 3600
entity:
type: "postgres"
postgres:
hostname: "your-db-host"
port: 5432
name: "entitydb"
username: "dbuser"
password: "dbpassword"
sslmode: "require"
max_open_conns: 500
max_idle_conns: 100
conn_max_lifetime: 3600
runtime_persistent:
type: "postgres"
postgres:
hostname: "your-db-host"
port: 5432
name: "runtime_persistent"
username: "dbuser"
password: "dbpassword"
sslmode: "require"
max_open_conns: 500
max_idle_conns: 100
conn_max_lifetime: 3600
Key settings:
- Set
sslmodeto"require"to enforce encrypted connections to the database. - Adjust
max_open_conns,max_idle_conns, andconn_max_lifetimebased on your expected traffic and database capacity. - Never store database credentials in
deployment.yamldirectly. Use environment variables, Kubernetes secrets, or a secrets manager.
For stronger server identity verification, prefer verify-full when the database certificate includes its hostname. Mount the trusted database CA and set PGSSLROOTCERT when the CA is not available from the container's system trust store. See the PostgreSQL SSL documentation for the verification modes.
Each PostgreSQL-backed pool has its own max_open_conns limit. For the four pools shown above, calculate the per-process total by adding their limits. Multiply that total by the maximum replica count, then compare the result with the database connection limit.
Replace TLS Certificates
For quickstart and development, ThunderID generates a self-signed TLS certificate per deployment during setup. Self-signed certificates trigger browser warnings and are not trusted by clients in production. Replace the server certificate with one issued by a trusted CA, or terminate TLS at your ingress or load balancer with a CA-issued certificate.
Step 1: Get a TLS Certificate
Get a certificate and private key for your production domain from a trusted CA. You can use:
- Let's Encrypt for free, automated certificates.
- Your organization's internal CA.
- A commercial CA.
Step 2: Replace the Default Certificates
Copy your certificate and private key to the ThunderID security directory:
cp your-domain.cert config/certs/server.cert
cp your-domain.key config/certs/server.key
Step 3: Update the Configuration
Verify the paths in deployment.yaml point to the correct files:
tls:
min_version: "1.3"
cert_file: "config/certs/server.cert"
key_file: "config/certs/server.key"
If you store certificates in a different location, update cert_file and key_file accordingly.
For container deployments, include these files in the read-only security-directory mount described in Mount Production Configuration.
Harden TLS Termination
Configure automated certificate renewal and alert before the certificate expires. Redirect HTTP traffic to HTTPS at the external TLS terminator.
Configure HSTS only after every required subdomain is available over HTTPS. Review the OWASP HSTS guidance before enabling includeSubDomains or preloading.
Set server.public_url to the exact external URL used by browsers and protocol clients:
server:
public_url: "https://id.example.com"
Prefer TLS between the ingress or gateway and ThunderID as well as on the public connection. If the platform sends HTTP to the pod after terminating TLS, use server.http_only only when the backend network is private and protected from untrusted workloads.
Configure the front door to replace untrusted forwarding headers. The ThunderID application access log records the direct network peer, so retain the authoritative client IP address in trusted front-door logs.
Restrict Network Access
Kubernetes permits pod traffic by default when no NetworkPolicy selects a pod. The ThunderID Helm chart does not create NetworkPolicy resources, so the cluster operator must provide them with a network plugin that enforces NetworkPolicy.
Start with default-deny ingress and egress policies. Allow only:
- Ingress from the selected ingress controller or Gateway API implementation to port
8090. - Ingress from the orchestrator or monitoring network to port
8090when required for health checks. - Egress to the configured PostgreSQL databases.
- Egress to Redis when configured.
- Egress to cluster DNS.
- Egress to configured identity providers, SMTP servers, SMS gateways, webhooks, and OpenTelemetry collectors.
NetworkPolicy filters traffic by network source and port, not by HTTP path. Allowing a monitoring source to port 8090 also permits that source to reach other routes served on the same port. When monitoring access must be limited to /health/liveness and /health/readiness, enforce path-level rules through an ingress controller, Gateway API implementation, or service mesh.
Block access to cloud instance metadata and cluster control-plane endpoints unless the deployment explicitly requires it. Apply equivalent firewall and private-network controls to Docker and virtual-machine deployments.
See the Kubernetes NetworkPolicy documentation for default-deny examples.
Generate a Unique Encryption Key
ThunderID uses a symmetric encryption key to protect sensitive data at rest, including credentials and tokens. Provision a unique key for each production deployment, persist it outside the workload, and back it up through your secrets-management process.
Step 1: Generate a New Key
Generate a cryptographically secure 32-byte key:
openssl rand -hex 32
This command outputs a 64-character hexadecimal string. Copy the output.
Step 2: Store the Key
Write the generated value to the key file:
printf '%s' "<generated-key>" > config/certs/crypto.key
Replace <generated-key> with the output from the previous command. Do not include whitespace or newline characters.
Step 3: Verify the Configuration
Confirm deployment.yaml references the key file:
crypto:
encryption:
key: "file://config/certs/crypto.key"
You can also supply the key directly as an inline value, but this is not recommended for production. Prefer the file:// path form, an environment variable, or a secrets manager to avoid storing the key in deployment.yaml:
crypto:
encryption:
key: "<generated-key>"
Protect the encryption key with the same level of care as a database password. If the key is lost, encrypted data becomes unrecoverable. If the key is compromised, all protected data is exposed. This key material lives in config/certs (the certs PVC on Helm) and must persist across restarts and replicas. If it is lost or changed, previously encrypted data cannot be decrypted and previously issued tokens can no longer be validated.
Provide Token Signing Keys
ThunderID uses an RSA key pair for JWT signing and an ECDSA key pair for OpenID4VP and OpenID4VCI. The default deployment.yaml expects both pairs under config/certs.
Generate the RSA signing pair:
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout config/certs/signing.key \
-out config/certs/signing.cert \
-subj "/O=ThunderID/OU=ThunderID/CN=id.example.com" \
-addext "subjectAltName=DNS:id.example.com"
Generate the ECDSA signing pair:
openssl ecparam -name prime256v1 -genkey -noout -param_enc named_curve \
-out config/certs/ecdsa-signing.key
openssl req -new -x509 -days 365 \
-key config/certs/ecdsa-signing.key \
-out config/certs/ecdsa-signing.cert \
-subj "/O=ThunderID/OU=ThunderID/CN=id.example.com" \
-addext "subjectAltName=DNS:id.example.com"
Replace id.example.com with the production hostname. Confirm deployment.yaml references the generated files:
crypto:
keys:
- id: "default-key"
cert_file: "config/certs/signing.cert"
key_file: "config/certs/signing.key"
- id: "ecdsa-key"
cert_file: "config/certs/ecdsa-signing.cert"
key_file: "config/certs/ecdsa-signing.key"
Every replica must use the same signing pairs. Replacing a signing key changes the material used to validate tokens, so plan key rotation before replacing these files.
Provide the Direct Auth Secret
ThunderID gates the Direct API endpoints (/auth/**, /register/passkey/**) and the protected AuthZEN access endpoints (/access/**) with a server-level Direct Auth Secret. Provision a unique production secret and store it outside the workload. The default deployment.yaml references it as a file, so the value stays out of the config file:
server:
security:
direct_auth_secret: "file://config/secrets/direct_auth_secret"
For production, either keep the generated per-deployment secret (ensuring config/secrets is persisted) or provide your own. To provide your own, generate a strong value and write it to the secret file:
openssl rand -hex 32
printf '%s' "<generated-secret>" > config/secrets/direct_auth_secret
Replace <generated-secret> with the output from the previous command. Do not include whitespace or newline characters. Callers must send this value in the Direct-Auth-Secret header. You can also supply the secret directly as an inline value, but prefer the file:// path form or a secrets manager to keep it out of deployment.yaml.
Mount Production Configuration
Keep production configuration and security material outside the container image. Mount the following host-managed files and directories into every ThunderID replica:
- Deployment configuration: Mount
deployment.yamlat/opt/thunderid/deployment.yaml. This file contains server, database, cache, and security configuration. - Certificates and keys: Mount
config/certsat/opt/thunderid/config/certs. This directory contains the TLS pair, RSA and ECDSA signing pairs, and encryption key. - File-backed secrets: Mount
config/secretsat/opt/thunderid/config/secrets. This directory contains the Direct Auth Secret and other file-backed secrets. - CORS configuration: Mount
config/resources/server_configs/cors.yamlat/opt/thunderid/config/resources/server_configs/cors.yaml. - CSP configuration: Mount
config/resources/server_configs/csp.yamlat/opt/thunderid/config/resources/server_configs/csp.yaml.
Mount these paths read-only in the running container:
docker run -d \
--name thunderid \
--restart unless-stopped \
-p 8090:8090 \
--mount type=bind,src="$(pwd)/deployment.yaml",dst=/opt/thunderid/deployment.yaml,readonly \
--mount type=bind,src="$(pwd)/config/certs",dst=/opt/thunderid/config/certs,readonly \
--mount type=bind,src="$(pwd)/config/secrets",dst=/opt/thunderid/config/secrets,readonly \
--mount type=bind,src="$(pwd)/config/resources/server_configs/cors.yaml",dst=/opt/thunderid/config/resources/server_configs/cors.yaml,readonly \
--mount type=bind,src="$(pwd)/config/resources/server_configs/csp.yaml",dst=/opt/thunderid/config/resources/server_configs/csp.yaml,readonly \
ghcr.io/thunder-id/thunderid:<version>
The ThunderID image runs as a non-root user. Configure host ownership, access control lists, or secret-store permissions so the effective container user can read the mounted files. Do not grant the running container write access to production configuration, certificates, private keys, or secrets.
When using the declarative CORS and CSP files in this guide, configure the server-config store to load the mounted files while retaining runtime overrides:
server_config:
store: "composite"
If PostgreSQL uses a private CA with verify-full, mount the CA certificate read-only and set PGSSLROOTCERT to its container path.
Configure Rate Limiting
ThunderID does not apply rate limiting, account lockout, or brute-force protection to Direct API endpoints. The Direct Auth Secret authenticates callers, but it does not limit repeated requests.
Configure rate limits at the API gateway, ingress, or front door for:
- Credential and passkey authentication under
/auth/**. - OTP delivery and verification under
/auth/otp/**. - Passkey registration under
/register/passkey/**. - Authorization and token operations under
/oauth2/**. - AuthZEN evaluations under
/access/**.
Use separate policies for operations with different costs and risks. Where supported, evaluate the client IP address, client identifier, account identifier, and tenant context. Avoid applying one global threshold to every route.
Return 429 Too Many Requests when a request exceeds a limit. Record rate-limit decisions in the front-door logs and tune the thresholds from load tests and observed traffic.
Configure a CORS Allowlist
Cross-Origin Resource Sharing (CORS) controls which browser origins can call ThunderID. Origins are stored in the server-config cors section. No origins are allowed by default, so list each production app explicitly.
Create config/resources/server_configs/cors.yaml:
name: cors
value:
allowedOrigins:
- "https://app.example.com"
- "https://admin.example.com"
To change origins without a restart, update the same section with PUT /server-config/cors. Runtime values are merged with the declarative file.
Guidelines:
-
List only origins that your applications actively use. Do not use wildcards (
*). -
Use the full origin format: scheme, hostname, and port if non-standard (for example,
https://app.example.com:8443). -
If your domain follows a consistent pattern, you can use a regular expression:
name: cors
value:
allowedOrigins:
- "https://app.example.com"
- regex: '^https://[a-z0-9-]+\.example\.com$'Anchor regex patterns with
^and$. ThunderID logs a startup warning for unanchored patterns because they can match unintended origins.
Harden the Content Security Policy
ThunderID sends a Content Security Policy (CSP) header on its Console and Gate UIs to restrict where the browser loads scripts, styles, images, and fonts from. The policy is stored in the server-config csp section. It ships in report-only mode so you can discover what your deployment loads before you enforce it.
Roll out enforcement in three steps:
- Keep
reportOnly: trueand open the Console and Gate in a browser. Violations are logged to the browser console without blocking any resource. - For each reported resource, add its origin to the matching directive. External images go in
img-src, fonts infont-src, and external stylesheets instyle-src-elem. - Set
reportOnly: falseto enforce the policy once the browser console reports no violations.
Create config/resources/server_configs/csp.yaml:
name: csp
value:
reportOnly: false
paths:
- location: "/console/"
directives:
img-src:
- "'self'"
- "https://cdn.example.com"
To change the policy without a restart, update the same section with PUT /server-config/csp. Runtime values are merged with the declarative file.
Guidelines:
- Listing a directive replaces its baseline value, so include
'self'when you still need same-origin resources. - When you add an external logo, font, or stylesheet URL in the Console, allow its origin in the matching directive. The Console shows the directive to update next to each field.
- User profile pictures set through the
pictureattribute also load as images, including in the Console user list and profile views. Add picture host to theimg-srcdirective. - Keep the policy as narrow as your applications allow. Avoid broad sources such as
https:or'unsafe-inline'once you know the exact origins you need.
See Content Security Policy for the full field reference and the deny-first baseline.
Configure Caching
ThunderID supports two cache backends: in-memory and Redis.
| Cache Type | Suitable For |
|---|---|
| In-memory | Single-pod deployments only |
| Redis | Multi-pod (replicated) deployments |
Do not use in-memory caching when running multiple replicas. Each pod maintains a separate cache, which causes cache inconsistency across the deployment. Cached entries written in one pod are invisible to other pods, leading to incorrect authentication or authorization decisions.
Single-Pod Deployments
For a single-pod deployment, in-memory caching is acceptable:
cache:
disabled: false
type: "inmemory"
size: 1000
ttl: 3600
evictionPolicy: "LRU"
cleanupInterval: 300
Multi-Pod Deployments
Use Redis when running more than one replica:
cache:
disabled: false
type: "redis"
ttl: 3600
redis:
address: "your-redis-host:6379"
username: ""
password: "your-redis-password"
db: 0
keyPrefix: "thunderid:"
Key settings:
- Set a
keyPrefixwhen multiple services share the same Redis instance. This avoids key collisions. - Secure Redis with authentication and network-level access controls. Do not expose Redis publicly.
- Use a managed Redis service (such as Amazon ElastiCache or Google Memorystore) for high availability.
Protect Redis Traffic
Place Redis on a private network and restrict access to ThunderID workloads. Require authentication and use a distinct keyPrefix when other services share the Redis instance.
The current ThunderID Redis client configuration does not expose TLS settings. If encryption in transit is required, use a tested local sidecar or service-mesh TLS proxy. Keep the unencrypted connection inside the pod or an equivalent isolated boundary.
A cache-only Redis deployment can rebuild its data. Redis can also serve as the runtime-transient database, in which case persistence and availability must account for in-flight authorization requests.
Harden the Workload
For Kubernetes deployments, preserve the Helm chart security defaults:
- Run the container as a non-root user.
- Disable privilege escalation.
- Drop all Linux capabilities.
- Use a read-only root filesystem.
- Mount writable temporary storage only where required.
Enable the RuntimeDefault seccomp profile:
deployment:
securityContext:
seccompProfile:
enabled: true
type: "RuntimeDefault"
Apply the Kubernetes Restricted Pod Security Standard where the cluster supports it. Review the chart service account and RBAC permissions before granting additional access.
Pin the container image to a release tag or digest. Do not use latest in production. Scan the selected image for known vulnerabilities and define a security-update process.
Configure Availability and Scaling
Run multiple replicas when the deployment must tolerate a pod or node failure. Multi-replica deployments require PostgreSQL, shared Redis caching, and shared cryptographic material.
The Helm chart provides startup, readiness, and liveness probes. It also supports a HorizontalPodAutoscaler and creates a PodDisruptionBudget when multiple replicas are configured.
Spread replicas across nodes and availability zones. The chart includes preferred zone anti-affinity, but the scheduler may place replicas together when capacity is constrained. Use stronger topology constraints when required by the availability target.
Scale PostgreSQL, Redis, ingress capacity, and database connection limits together with the ThunderID replica count. Additional application replicas do not provide high availability when a database, Redis node, storage volume, or front door remains a single point of failure.
Back Up and Recover the Deployment
Back up the state required to recover the same deployment:
- The
config,entity, andruntime_persistentdatabases. - Encryption and signing material.
- The Direct Auth Secret and other secrets that cannot be recreated safely.
- Declarative resources and reviewed deployment configuration.
Define a separate recovery policy for runtime_transient. Restoring old authorization codes, requests, and nonces can reintroduce expired or consumed state. A recovery plan may instead discard this database and require in-flight sign-in operations to restart.
Encrypt backups and restrict access to recovery operators. Keep cryptographic material associated with the database recovery point it protects.
Define recovery point and recovery time objectives. Test restoration into an isolated environment regularly. Verify that the databases, cryptographic material, secrets, and configuration can be restored together.
Monitor the Deployment
Collect ThunderID application logs, front-door access logs, ingress logs, and infrastructure metrics in a centralized system. Do not collect passwords, access tokens, private keys, or Direct Auth Secrets.
Alert on:
- Unavailable or repeatedly restarting replicas.
- Readiness failures and elevated error rates.
- Sustained latency or resource saturation.
- PostgreSQL or Redis connection failures.
- Front-door rate-limit and WAF activity.
- TLS certificates approaching expiry.
- Failed backups or restore tests.
- Loss of log or trace delivery.
Use /health/liveness for process health and /health/readiness for dependency-aware readiness. Keep both endpoints private.
Synchronize cluster nodes, database servers, Redis servers, and monitoring systems with a reliable time source. Clock drift affects token expiry, certificate validation, one-time credentials, and incident timelines.
See Observability for structured events and OpenTelemetry configuration.
Validate the Deployment
Before directing production traffic to ThunderID, verify:
- The public hostname serves the expected CA-issued certificate.
- HTTP requests redirect to HTTPS.
server.public_urlmatches discovery metadata and browser redirects.- Administrative, Direct API, and health routes use their intended networks.
- Rate limits and request-size limits reject test traffic.
- Network policies block unapproved ingress and egress.
- Every replica uses the same signing and encryption material.
- PostgreSQL connections use the configured SSL mode. When
verify-fullis enabled, certificate and hostname verification succeeds. - Redis is private and requires authentication.
- Replicas remain available during a rolling update.
- A backup can restore a working isolated deployment.
- Infrastructure alerts reach the responsible operators.
Next Steps
After applying the production configuration:
- Restrict access to
deployment.yaml,config/certs,config/secrets, and any declarative resource files that contain sensitive values. - Enable database backups on a regular schedule.
- Set up monitoring and alerting for ThunderID and its dependent services.
- Review the Kubernetes deployment guide for Helm-based configuration of the settings covered here.