feat: add DSN/URL connection string support for PostgreSQL and Redis (#11729)

This commit is contained in:
Mridang Agarwalla
2026-03-20 15:21:15 +00:00
committed by GitHub
parent dad4565d5d
commit 471ed4a5d7
25 changed files with 810 additions and 302 deletions
@@ -15,94 +15,66 @@ Caching is an experimental beta feature. See the [Caches](/self-hosting/manage/c
Zitadel supports Redis or any Redis-compatible store (for example, Valkey) in standalone mode. Sentinel and Redis Cluster are not supported because the cache connector expects a single endpoint and does not implement cluster routing or sentinel failover logic.
<Callout type="note">
Zitadel supports server-auth TLS (`EnableTLS: true`) but does not support client certificates/mTLS for cache connections.
Zitadel supports server-auth TLS (`rediss://` scheme) but does not support client certificates/mTLS for cache connections.
</Callout>
Each cache uses its own Redis database index. Set `DBOffset` so Zitadel-owned indexes do not overlap with other applications. Zitadel issues `FLUSHDB` on its cache databases, so sharing an index with other apps is unsafe.
## Connect without authentication (development only)
Pass the Redis URL as an environment variable:
```yaml
zitadel:
configmapConfig:
Caches:
Connectors:
Redis:
Enabled: true
Addr: "redis-master.caching.svc.cluster.local:6379"
DBOffset: 10
Instance:
Connector: "redis"
MaxAge: 1h
LastUseAge: 10m
Organization:
Connector: "redis"
MaxAge: 1h
LastUseAge: 10m
env:
- name: ZITADEL_CACHES_CONNECTORS_REDIS_URL
value: "redis://redis-master.caching.svc.cluster.local:6379"
- name: ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED
value: "true"
```
Use this only on isolated clusters where the Redis endpoint does not require authentication.
## Connect with password from a Kubernetes Secret
Keep credentials in a secret while the rest of the cache configuration stays in values:
```yaml
zitadel:
configSecretName: zitadel-cache-credentials
configmapConfig:
Caches:
Connectors:
Redis:
Enabled: true
Addr: "redis.database.svc.cluster.local:6379"
DBOffset: 10
Instance:
Connector: "redis"
MaxAge: 1h
LastUseAge: 10m
Organization:
Connector: "redis"
MaxAge: 1h
LastUseAge: 10m
```
Create the secret with your Redis ACL user and password:
Store the Redis URL (with credentials) in a secret and pass it as an environment variable:
```bash
kubectl create secret generic zitadel-cache-credentials \
--from-literal=config.yaml="
Caches:
Connectors:
Redis:
Username: cache-user # Omit for default user
Password: your-redis-password
"
--from-literal=url="redis://cache-user:[email protected]:6379"
```
```yaml
zitadel:
env:
- name: ZITADEL_CACHES_CONNECTORS_REDIS_URL
valueFrom:
secretKeyRef:
name: zitadel-cache-credentials
key: url
- name: ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED
value: "true"
```
## Connect with TLS (Redis or Valkey)
Enable TLS when your endpoint requires encryption. This uses server-auth TLS with the container's trust store.
Use the `rediss://` scheme (note the double `s`) when your endpoint requires encryption. This uses server-auth TLS with the container's trust store.
```bash
kubectl create secret generic zitadel-cache-credentials \
--from-literal=url="rediss://cache-user:[email protected]:6380"
```
```yaml
zitadel:
configSecretName: zitadel-cache-credentials
configmapConfig:
Caches:
Connectors:
Redis:
Enabled: true
Addr: "redis.example.com:6380"
EnableTLS: true
DBOffset: 10
Instance:
Connector: "redis"
MaxAge: 1h
LastUseAge: 10m
Organization:
Connector: "redis"
MaxAge: 1h
LastUseAge: 10m
env:
- name: ZITADEL_CACHES_CONNECTORS_REDIS_URL
valueFrom:
secretKeyRef:
name: zitadel-cache-credentials
key: url
- name: ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED
value: "true"
```
Reuse the secret from the previous section for credentials if needed. Custom certificate authorities must be added to the container's trust bundle (for example, by mounting a CA file into the pod); client certificate authentication is not supported.
Custom certificate authorities must be added to the container's trust bundle (for example, by mounting a CA file into the pod); client certificate authentication is not supported.
@@ -162,36 +162,23 @@ kubectl create secret generic zitadel-masterkey \
--from-literal=masterkey="$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)"
```
Create a config secret for sensitive values. Save this as `zitadel-config-secret.yaml`:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: zitadel-config-secret
type: Opaque
stringData:
config.yaml: |
Database:
Postgres:
User:
Password: "secure-app-password"
Admin:
Password: "secure-admin-password"
```
Apply the secret:
Create a secret containing the database DSN:
```bash
kubectl apply --filename zitadel-config-secret.yaml
kubectl create secret generic zitadel-config-secret \
--from-literal=dsn="postgresql://zitadel:[email protected]:5432/zitadel?sslmode=disable"
```
Reference it in your values:
Pass it to Zitadel as an environment variable:
```yaml
zitadel:
configSecretName: zitadel-config-secret
configSecretKey: config.yaml
env:
- name: ZITADEL_DATABASE_POSTGRES_DSN
valueFrom:
secretKeyRef:
name: zitadel-config-secret
key: dsn
```
## Scaling and Availability
@@ -14,66 +14,45 @@ Zitadel requires PostgreSQL 14 or later. The chart supports multiple ways to con
This method connects to PostgreSQL without encryption. Only use this for testing or when the database is on a private network with no risk of interception.
```yaml
zitadel:
configmapConfig:
Database:
Postgres:
Host: "postgres.database.svc.cluster.local"
Port: 5432
Database: "zitadel"
User:
Username: "zitadel"
Password: "your-password"
SSL:
Mode: "disable"
Admin:
Username: "postgres"
Password: "your-admin-password"
SSL:
Mode: "disable"
Store the DSN in a Kubernetes Secret and pass it as an environment variable:
```bash
kubectl create secret generic zitadel-db-credentials \
--from-literal=dsn="postgresql://zitadel:[email protected]:5432/zitadel?sslmode=disable"
```
The `SSL.Mode: "disable"` setting turns off TLS entirely. Traffic between Zitadel and PostgreSQL is unencrypted.
```yaml
zitadel:
env:
- name: ZITADEL_DATABASE_POSTGRES_DSN
valueFrom:
secretKeyRef:
name: zitadel-db-credentials
key: dsn
```
The `sslmode=disable` setting turns off TLS entirely. Traffic between Zitadel and PostgreSQL is unencrypted.
### Connecting with Credentials and TLS
This method connects to PostgreSQL with TLS encryption but without certificate verification. Use this when you trust the network path but want encryption in transit.
```yaml
zitadel:
configSecretName: zitadel-db-credentials
configmapConfig:
Database:
Postgres:
Host: "postgres.database.svc.cluster.local"
Port: 5432
Database: "zitadel"
User:
Username: "zitadel"
SSL:
Mode: "require"
Admin:
Username: "postgres"
SSL:
Mode: "require"
```
Create a secret for the database passwords:
```bash
kubectl create secret generic zitadel-db-credentials \
--from-literal=config.yaml="
Database:
Postgres:
User:
Password: your-secure-app-password
Admin:
Password: your-secure-admin-password
"
--from-literal=dsn="postgresql://zitadel:[email protected]:5432/zitadel?sslmode=require"
```
The `SSL.Mode: "require"` setting enforces TLS but does not verify the server certificate. This protects against passive eavesdropping but not against man-in-the-middle attacks.
```yaml
zitadel:
env:
- name: ZITADEL_DATABASE_POSTGRES_DSN
valueFrom:
secretKeyRef:
name: zitadel-db-credentials
key: dsn
```
The `sslmode=require` setting enforces TLS but does not verify the server certificate. This protects against passive eavesdropping but not against man-in-the-middle attacks.
### Connecting with Certificates
@@ -81,23 +60,14 @@ This method connects to PostgreSQL with full TLS verification using certificates
```yaml
zitadel:
configSecretName: zitadel-db-credentials
env:
- name: ZITADEL_DATABASE_POSTGRES_DSN
valueFrom:
secretKeyRef:
name: zitadel-db-credentials
key: dsn
dbSslCaCrt: "ca.crt"
dbSslCaCrtSecret: "postgres-ca-cert"
configmapConfig:
Database:
Postgres:
Host: "postgres.database.svc.cluster.local"
Port: 5432
Database: "zitadel"
User:
Username: "zitadel"
SSL:
Mode: "verify-full"
Admin:
Username: "postgres"
SSL:
Mode: "verify-full"
```
Create a secret containing the CA certificate:
@@ -107,18 +77,11 @@ kubectl create secret generic postgres-ca-cert \
--from-file=ca.crt=/path/to/your/ca-certificate.crt
```
Create a secret for the database passwords:
Create a secret containing the DSN with full verification:
```bash
kubectl create secret generic zitadel-db-credentials \
--from-literal=config.yaml="
Database:
Postgres:
User:
Password: your-secure-app-password
Admin:
Password: your-secure-admin-password
"
--from-literal=dsn="postgresql://zitadel:[email protected]:5432/zitadel?sslmode=verify-full&sslrootcert=/db-ssl-ca-crt/ca.crt"
```
The `SSL.Mode: "verify-full"` setting enforces TLS and verifies that the server certificate is signed by the CA and that the server hostname matches the certificate. This provides full protection against eavesdropping and man-in-the-middle attacks. The `dbSslCaCrtSecret` references the Kubernetes Secret containing the CA certificate, and `dbSslCaCrt` specifies the key within that secret.
The `sslmode=verify-full` setting enforces TLS and verifies that the server certificate is signed by the CA and that the server hostname matches the certificate. This provides full protection against eavesdropping and man-in-the-middle attacks. The `dbSslCaCrtSecret` references the Kubernetes Secret containing the CA certificate, and `dbSslCaCrt` specifies the key within that secret.
@@ -76,7 +76,7 @@ The bundled PostgreSQL subchart is for quickstart use only. You can replace it i
| Component | How to replace |
|-----------|---------------|
| **Database** | Set `postgresql.enabled: false` and configure `zitadel.configmapConfig.Database` pointing to your own PostgreSQL |
| **Database** | Set `postgresql.enabled: false` and configure `ZITADEL_DATABASE_POSTGRES_DSN` pointing to your own PostgreSQL. Use the `postgres` maintenance database so ZITADEL can create its own database during initialization |
| **Ingress controller** | Update `ingress.className` (and `login.ingress.className`) to match your controller's IngressClass |
| **Caching** | Add Redis by configuring `zitadel.configmapConfig.Caches` (see [Caching](/self-hosting/deploy/kubernetes/caching)) |
@@ -106,16 +106,9 @@ The ZITADEL management console [requires end-to-end HTTP/2 support](/self-hostin
kubectl create secret generic zitadel-masterkey \
--from-literal=masterkey="$(tr -dc A-Za-z0-9 </dev/urandom | head -c 32)"
# Database credentials
# Database credentials — store the full DSN
kubectl create secret generic zitadel-db-credentials \
--from-literal=config.yaml="
Database:
Postgres:
User:
Password: your-secure-app-password
Admin:
Password: your-secure-admin-password
"
--from-literal=dsn="postgresql://zitadel:[email protected]:5432/postgres?sslmode=verify-full"
```
### Configure values
@@ -127,26 +120,18 @@ replicaCount: 2
zitadel:
masterkeySecretName: zitadel-masterkey
configSecretName: zitadel-db-credentials
env:
- name: ZITADEL_DATABASE_POSTGRES_DSN
valueFrom:
secretKeyRef:
name: zitadel-db-credentials
key: dsn
configmapConfig:
ExternalDomain: "zitadel.example.com"
ExternalSecure: true
ExternalPort: 443
TLS:
Enabled: false
Database:
Postgres:
Host: "postgres.database.svc.cluster.local"
Port: 5432
Database: "zitadel"
User:
Username: "zitadel"
SSL:
Mode: "verify-full"
Admin:
Username: "postgres"
SSL:
Mode: "verify-full"
FirstInstance:
Org:
Human:
@@ -132,13 +132,22 @@ When scaling Zitadel horizontally, ensure your PostgreSQL database can handle th
Example connection pooling setup with PgBouncer:
```bash
kubectl create secret generic zitadel-db-credentials \
--from-literal=dsn="postgresql://zitadel:[email protected]:6432/zitadel?sslmode=disable"
```
```yaml
zitadel:
env:
- name: ZITADEL_DATABASE_POSTGRES_DSN
valueFrom:
secretKeyRef:
name: zitadel-db-credentials
key: dsn
configmapConfig:
Database:
Postgres:
Host: "pgbouncer.database.svc.cluster.local"
Port: 6432
MaxOpenConns: 20
MaxIdleConns: 10
```
@@ -40,7 +40,7 @@ LATEST=$(curl -i https://github.com/zitadel/zitadel/releases/latest | grep locat
## Run Zitadel
```bash
ZITADEL_DATABASE_POSTGRES_HOST=localhost ZITADEL_DATABASE_POSTGRES_PORT=5432 ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=zitadel ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=root ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD=postgres ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable ZITADEL_EXTERNALSECURE=false zitadel start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://root:postgres@localhost:5432/postgres?sslmode=disable ZITADEL_EXTERNALSECURE=false zitadel start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled
```
<DefaultUser components={props.components} />
@@ -62,7 +62,7 @@ ZITADEL_DATABASE_POSTGRES_HOST=localhost ZITADEL_DATABASE_POSTGRES_PORT=5432 ZIT
### Setup Zitadel with a service account
```bash
ZITADEL_DATABASE_POSTGRES_HOST=localhost ZITADEL_DATABASE_POSTGRES_PORT=5432 ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=zitadel ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=root ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable ZITADEL_EXTERNALSECURE=false ZITADEL_FIRSTINSTANCE_MACHINEKEYPATH=/tmp/zitadel-admin-sa.json ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME=zitadel-admin-sa ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME=Admin ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINEKEY_TYPE=1 zitadel start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://root:postgres@localhost:5432/postgres?sslmode=disable ZITADEL_EXTERNALSECURE=false ZITADEL_FIRSTINSTANCE_MACHINEKEYPATH=/tmp/zitadel-admin-sa.json ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME=zitadel-admin-sa ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME=Admin ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINEKEY_TYPE=1 zitadel start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled
# then you can move your machine key
mv /tmp/zitadel-admin-sa.json $HOME/zitadel-admin-sa.json
@@ -45,7 +45,7 @@ LATEST=$(curl -i https://github.com/zitadel/zitadel/releases/latest | grep locat
## Run ZITADEL
```bash
ZITADEL_DATABASE_POSTGRES_HOST=localhost ZITADEL_DATABASE_POSTGRES_PORT=5432 ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=zitadel ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=$(whoami) ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD=postgres ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable ZITADEL_EXTERNALSECURE=false zitadel start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled
ZITADEL_DATABASE_POSTGRES_DSN="postgresql://root:postgres@localhost:5432/postgres?sslmode=disable" ZITADEL_EXTERNALSECURE=false zitadel start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled
```
<DefaultUser components={props.components} />
@@ -57,7 +57,7 @@ ZITADEL_DATABASE_POSTGRES_HOST=localhost ZITADEL_DATABASE_POSTGRES_PORT=5432 ZIT
### Setup ZITADEL with a service account
```bash
ZITADEL_DATABASE_POSTGRES_HOST=localhost ZITADEL_DATABASE_POSTGRES_PORT=5432 ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=zitadel ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=$(whoami) ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable ZITADEL_EXTERNALSECURE=false ZITADEL_FIRSTINSTANCE_MACHINEKEYPATH=/tmp/zitadel-admin-sa.json ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME=zitadel-admin-sa ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME=Admin ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINEKEY_TYPE=1 zitadel start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled
ZITADEL_DATABASE_POSTGRES_DSN="postgresql://root:postgres@localhost:5432/postgres?sslmode=disable" ZITADEL_EXTERNALSECURE=false ZITADEL_FIRSTINSTANCE_MACHINEKEYPATH=/tmp/zitadel-admin-sa.json ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME=zitadel-admin-sa ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME=Admin ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINEKEY_TYPE=1 zitadel start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled
# then you can move your machine key
mv /tmp/zitadel-admin-sa.json $HOME/zitadel-admin-sa.json
@@ -71,6 +71,25 @@ Drawbacks:
- Increased operational overhead: need to run a Redis instance as part of your infrastructure.
- When running multiple servers of ZITADEL in different regions, network roundtrip time might impact performance, neutralizing the benefit of a cache.
Connect to Redis by providing a URL via environment variable. Use `redis://` for plain connections or `rediss://` for TLS:
```bash
export ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=true
export ZITADEL_CACHES_CONNECTORS_REDIS_URL=redis://user:password@localhost:6379
```
If you prefer configuration files, set the URL in your YAML config:
```yaml
Caches:
Connectors:
Redis:
Enabled: true
URL: redis://user:password@localhost:6379
```
Pool, timeout, retry, and circuit breaker settings can be configured alongside the URL as overlays. The DB number in the URL path is ignored for cache DB selection — ZITADEL derives the actual DB from `DBOffset` + purpose.
#### Circuit breaker
A [circuit breaker](https://learn.microsoft.com/en-us/previous-versions/msp-n-p/dn589784(v=pandp.10)?redirectedfrom=MSDN) is provided for the Redis connector, to prevent a single point of failure in the case persistent errors. When the circuit breaker opens, the cache is temporary disabled and ignored. ZITADEL will continue to operate using queries to the database.
@@ -80,7 +99,7 @@ Caches:
Connectors:
Redis:
Enabled: true
Addr: localhost:6379
URL: redis://user:password@localhost:6379
# Many other options...
CircuitBreaker:
# Interval when the counters are reset to 0.
@@ -226,14 +245,24 @@ Caches:
MaxAge: 1s
```
When running many instances on multiple servers:
When running many instances on multiple servers, set the Redis URL and enable the connector:
```bash
export ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=true
export ZITADEL_CACHES_CONNECTORS_REDIS_URL=redis://user:[email protected]:6379
export ZITADEL_CACHES_INSTANCE_CONNECTOR=redis
export ZITADEL_CACHES_MILESTONES_CONNECTOR=redis
export ZITADEL_CACHES_ORGANIZATION_CONNECTOR=redis
```
Or in a configuration file:
```yaml
Caches:
Connectors:
Redis:
Enabled: true
# Other connection options
URL: redis://user:[email protected]:6379
Instance:
Connector: "redis"
@@ -51,16 +51,12 @@ zitadel start-from-init \
### Configure by Environment Variables
The environment variable approach uses a single DSN string instead of the individual field-based configuration shown above.
```bash
# Set runtime environment variables
export ZITADEL_DATABASE_POSTGRES_HOST=my.database
export ZITADEL_DATABASE_POSTGRES_PORT=5432
export ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel
export ZITADEL_DATABASE_POSTGRES_USER_USERNAME=my_zitadel_db_user
export ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=Secret_DB_User_Password
export ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable
export ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=root
export ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable
# Set the PostgreSQL connection via a single DSN
# Use the "postgres" maintenance database so ZITADEL can create its own database during initialization
export ZITADEL_DATABASE_POSTGRES_DSN=postgresql://my_zitadel_db_user:[email protected]:5432/postgres?sslmode=disable
export ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME="root"
export ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD="RootPassword1!"
@@ -93,22 +93,18 @@ There are three ways to pass the masterkey to the `zitadel` binary:
<Tabs
groupId="installation-target"
default="linuxunix"
values={[
{ label: "Linux and Unix", value: "linuxunix" },
{ label: "Docker Compose", value: "compose" },
{ label: "Kubernetes (Helm)", value: "k8s" },
]}
defaultIndex={1}
items={['Linux and Unix','Docker Compose', 'Kubernetes (Helm)']}
>
<Tab value="linuxunix">
<Tab value="Linux and Unix">
<LinuxUnix components={props.components} />
<Login components={props.components} />
</Tab>
<Tab value="compose">
<Tab value="Docker Compose">
<Compose components={props.components} />
<Login components={props.components} />
</Tab>
<Tab value="k8s">
<Tab value="Kubernetes (Helm)">
<Helm components={props.components} />
</Tab>
</Tabs>
@@ -22,35 +22,29 @@ If you are using Zitadel v2 and want to use a PostgreSQL database, you can [over
Currently, versions 14 to 18 are supported.
Postgres can be configured as follows:
Connect ZITADEL to your PostgreSQL database by providing a DSN (connection URL) via the environment variable:
```bash
export ZITADEL_DATABASE_POSTGRES_DSN=postgresql://zitadel:password@localhost:5432/zitadel?sslmode=disable
```
If you prefer configuration files, set the DSN in your YAML config:
```yaml
Database:
postgres:
Host: localhost
Port: 5432
Database: zitadel
AwaitInitialConn: 5m
DSN: postgresql://zitadel:password@localhost:5432/zitadel?sslmode=disable
MaxOpenConns: 15
MaxIdleConns: 10
MaxConnLifetime: 1h
MaxConnIdleTime: 5m
Options:
User:
Username: zitadel
Password: zitadel
SSL:
Mode: disable # Use 'require' or 'verify-full' for production
RootCert:
Cert:
Key:
Admin:
Username: postgres
Password: postgres
SSL:
Mode: disable # Use 'require' or 'verify-full' for production
RootCert:
Cert:
Key:
```
Pool and tuning settings can be configured alongside the DSN as overlays.
SSL is configured through DSN query parameters. For example, to require TLS with certificate verification:
```bash
postgresql://zitadel:[email protected]:5432/zitadel?sslmode=verify-full&sslrootcert=/path/to/ca.crt
```
### Managed PostgreSQL / No Admin Access
@@ -83,17 +77,10 @@ Adjust `pg_hba.conf` if needed to allow the zitadel user to connect.
**Schema bootstrapping step — Bootstrap schemas and tables (run as the service user)**
Set only the service user credentials — no `Admin.*` variables are needed for this command.
For managed/remote PostgreSQL, set `SSL_MODE` to `require` or `verify-full` to encrypt the connection. `disable` is only appropriate for local development.
Provide the DSN with the service user credentials. For managed/remote PostgreSQL, set `sslmode` to `require` or `verify-full` to encrypt the connection. `disable` is only appropriate for local development.
```bash
ZITADEL_DATABASE_POSTGRES_HOST=<your-host> \
ZITADEL_DATABASE_POSTGRES_PORT=5432 \
ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel \
ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel \
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=<strong-password> \
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=require \
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://zitadel:<strong-password>@<your-host>:5432/zitadel?sslmode=require \
zitadel init schema
```
@@ -104,12 +91,7 @@ For production deployments, configure TLS to encrypt authentication traffic. See
</Callout>
```bash
ZITADEL_DATABASE_POSTGRES_HOST=<your-host> \
ZITADEL_DATABASE_POSTGRES_PORT=5432 \
ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel \
ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel \
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=<strong-password> \
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=require \
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://zitadel:<strong-password>@<your-host>:5432/zitadel?sslmode=require \
zitadel start-from-setup --masterkey "<32-character-key>"
```
@@ -117,7 +99,7 @@ See the [phases guide](/self-hosting/manage/updating_scaling#separating-init-and
## Zitadel Credentials
The [init phase](/self-hosting/manage/updating_scaling#separating-init-and-setup-from-the-runtime) of ZITADEL creates the ZITADEL database user (`Database.*.User.Username` & `Database.*.User.Password`) if it does not exist and admin credentials are provided. **Init does not update or deprecate existing users or passwords.** If you need to change the ZITADEL database user or rotate its password, you must do this manually.
The [init phase](/self-hosting/manage/updating_scaling#separating-init-and-setup-from-the-runtime) of ZITADEL creates the ZITADEL database user if it does not exist and admin credentials are provided. **Init does not update or deprecate existing users or passwords.** If you need to change the ZITADEL database user or rotate its password, you must do this manually.
<Callout type="warn">
The `init` command is designed to be run **only once**, during the initial setup of ZITADEL. Running `init` again after changing the database user or credentials does **not** migrate database object ownerships, nor does it reassign permissions on schemas, tables, or other objects to a new user. You must update these permissions manually if you switch users.
@@ -125,19 +125,28 @@ please create an issue in our [github repository](https://github.com/zitadel/zit
Depending on your environment, you maybe would want to tweak some settings about how ZITADEL interacts with the database in the database section of your ZITADEL configuration. Read more about your [database configuration options](/self-hosting/manage/database).
Set the database connection via environment variable:
```bash
export ZITADEL_DATABASE_POSTGRES_DSN=postgresql://zitadel:password@localhost:5432/zitadel?sslmode=disable
export ZITADEL_DATABASE_POSTGRES_MAXOPENCONNS=10
export ZITADEL_DATABASE_POSTGRES_MAXIDLECONNS=5
export ZITADEL_DATABASE_POSTGRES_MAXCONNLIFETIME=30m
export ZITADEL_DATABASE_POSTGRES_MAXCONNIDLETIME=5m
```
Or in a configuration file:
```yaml
Database:
postgres:
Host: localhost
Port: 5432
Database: zitadel
DSN: postgresql://zitadel:password@localhost:5432/zitadel?sslmode=disable
//highlight-start
MaxOpenConns: 10
MaxIdleConns: 5
MaxConnLifetime: 30m
MaxConnIdleTime: 5m
//highlight-end
Options: ""
```
You also might want to configure how [projections](/concepts/eventstore/implementation#projections) are computed. These are the default values:
@@ -56,17 +56,17 @@ when you install ZITADEL from scratch.
`zitadel init` performs two steps that require different privileges:
**Provisioning step** (requires a PostgreSQL superuser via `Database.postgres.Admin.Username`):
- If it doesn't exist already, it creates the unprivileged user configured in `Database.postgres.User.Username`.
Subsequent phases connect to the database with this user's credentials only.
- If it doesn't exist already, it creates a database with the configured database name.
**Provisioning step** (requires a PostgreSQL superuser):
- If it doesn’t exist already, it creates the unprivileged database user.
Subsequent phases connect to the database with this user’s credentials only.
- If it doesn’t exist already, it creates a database with the configured database name.
- If not already done, it grants the necessary permissions to the unprivileged user.
**Schema bootstrapping step** (requires database owner privileges, uses the service user):
- If they don't exist already, it creates all schemas (`eventstore`, `projections`, `system`) and base tables.
- If they don’t exist already, it creates all schemas (`eventstore`, `projections`, `system`) and base tables.
<Callout type="note">
**Manual provisioning:** If you handle the provisioning step yourself (e.g. on a managed PostgreSQL service where you don't have superuser access),
**Manual provisioning:** If you handle the provisioning step yourself (e.g. on a managed PostgreSQL service where you don’t have superuser access),
you can skip it entirely by running only the schema bootstrapping step:
```bash
+19
View File
@@ -231,6 +231,16 @@ WebAuthNName: ZITADEL # ZITADEL_WEBAUTHNNAME
Database:
# Postgres is the default database of ZITADEL
postgres:
# DSN is a full PostgreSQL connection URL (see
# https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING).
# When set, individual connection fields (Host, Port, Database, User, Admin,
# Options) are ignored.
# Format: postgresql://user:password@host:port/dbname?sslmode=disable
# Note: In DSN mode, zitadel init cannot use the Admin connection to create
# a separate target database/user. The DB and user from the DSN must already
# exist and have sufficient privileges, or the DSN must directly point to
# the final ZITADEL database.
DSN: "" # ZITADEL_DATABASE_POSTGRES_DSN
Host: localhost # ZITADEL_DATABASE_POSTGRES_HOST
Port: 5432 # ZITADEL_DATABASE_POSTGRES_PORT
Database: zitadel # ZITADEL_DATABASE_POSTGRES_DATABASE
@@ -286,6 +296,15 @@ Caches:
TimeOut: 30s
Redis:
Enabled: false
# URL is a full Redis connection URL (see
# https://redis.io/docs/latest/develop/tools/cli/#host-port-password-and-database).
# When set, individual connection fields
# (Addr, Username, Password, Network, EnableTLS) are ignored.
# Pool, timeout, retry, and circuit breaker settings can still be used as overlays.
# Format: redis://user:password@host:port/db or rediss://... for TLS.
# Note: ZITADEL ignores the `/db` path when selecting Redis databases; DBs are chosen
# internally via DBOffset + purpose. Use `/0` in the URL to avoid confusion.
URL: "" # ZITADEL_CACHES_CONNECTORS_REDIS_URL
# The network type, either tcp or unix.
# Default is tcp.
# Network string
+2
View File
@@ -136,6 +136,7 @@ Source:
# Postgres is used as soon as a value is set
# The values describe the possible fields to set values
postgres:
DSN: "" # ZITADEL_SOURCE_POSTGRES_DSN
Host: # ZITADEL_SOURCE_POSTGRES_HOST
Port: # ZITADEL_SOURCE_POSTGRES_PORT
Database: # ZITADEL_SOURCE_POSTGRES_DATABASE
@@ -155,6 +156,7 @@ Source:
Destination:
postgres:
DSN: "" # ZITADEL_DESTINATION_POSTGRES_DSN
Host: localhost # ZITADEL_DESTINATION_POSTGRES_HOST
Port: 5432 # ZITADEL_DESTINATION_POSTGRES_PORT
Database: zitadel # ZITADEL_DESTINATION_POSTGRES_DATABASE
+11 -5
View File
@@ -53,8 +53,14 @@ [email protected]
POSTGRES_DB=zitadel
POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres
POSTGRES_ZITADEL_USER=zitadel
POSTGRES_ZITADEL_PASSWORD=zitadel
# DSN used by ZITADEL to connect to PostgreSQL.
# The start-from-init command uses this DSN to create the database schema and for
# normal operation. When a DSN is configured, ZITADEL does not create or switch
# to a separate unprivileged database user; the user in this DSN is used directly
# and must already exist. For production, configure this DSN to use a non-superuser
# role with only the permissions ZITADEL needs.
# See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://postgres:postgres@postgres:5432/zitadel?sslmode=disable
# -----------------------------
# Access logs
@@ -67,9 +73,9 @@ ZITADEL_ACCESS_LOG_STDOUT_ENABLED=true
# Enable with --profile cache and switch connectors to redis.
# -----------------------------
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=false
ZITADEL_CACHES_CONNECTORS_REDIS_ADDR=redis:6379
ZITADEL_CACHES_CONNECTORS_REDIS_USERNAME=
ZITADEL_CACHES_CONNECTORS_REDIS_PASSWORD=
# DSN used by ZITADEL to connect to Redis.
# See https://redis.io/docs/latest/develop/tools/cli/#host-port-password-and-database
ZITADEL_CACHES_CONNECTORS_REDIS_URL=redis://redis:6379/0
ZITADEL_CACHES_INSTANCE_CONNECTOR=
ZITADEL_CACHES_MILESTONES_CONNECTOR=
ZITADEL_CACHES_ORGANIZATION_CONNECTOR=
+2 -5
View File
@@ -32,8 +32,7 @@ TRAEFIK_TRUSTED_IPS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
POSTGRES_DB=zitadel
POSTGRES_ADMIN_USER=postgres
POSTGRES_ADMIN_PASSWORD=postgres
POSTGRES_ZITADEL_USER=zitadel
POSTGRES_ZITADEL_PASSWORD=zitadel
ZITADEL_DATABASE_POSTGRES_DSN=postgresql://postgres:postgres@postgres:5432/zitadel?sslmode=disable
# Disable access logs to reduce noise in test output
ZITADEL_ACCESS_LOG_STDOUT_ENABLED=false
@@ -46,9 +45,7 @@ ZITADEL_INSTRUMENTATION_TRACE_EXPORTER_INSECURE=false
# Cache disabled (no Redis profile)
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED=false
ZITADEL_CACHES_CONNECTORS_REDIS_ADDR=redis:6379
ZITADEL_CACHES_CONNECTORS_REDIS_USERNAME=
ZITADEL_CACHES_CONNECTORS_REDIS_PASSWORD=
ZITADEL_CACHES_CONNECTORS_REDIS_URL=redis://redis:6379/0
ZITADEL_CACHES_INSTANCE_CONNECTOR=
ZITADEL_CACHES_MILESTONES_CONNECTOR=
ZITADEL_CACHES_ORGANIZATION_CONNECTOR=
+1 -9
View File
@@ -1,13 +1,5 @@
x-zitadel-db-env: &zitadel-db-env
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: ${POSTGRES_DB}
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_ADMIN_USER}
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: ${POSTGRES_ZITADEL_USER}
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${POSTGRES_ZITADEL_PASSWORD}
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_DSN: ${ZITADEL_DATABASE_POSTGRES_DSN}
services:
zitadel-init:
+3 -13
View File
@@ -40,15 +40,7 @@ services:
ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNALSECURE}
ZITADEL_TLS_ENABLED: false
ZITADEL_DATABASE_POSTGRES_HOST: postgres
ZITADEL_DATABASE_POSTGRES_PORT: 5432
ZITADEL_DATABASE_POSTGRES_DATABASE: ${POSTGRES_DB}
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_ADMIN_USER}
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: ${POSTGRES_ZITADEL_USER}
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${POSTGRES_ZITADEL_PASSWORD}
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
ZITADEL_DATABASE_POSTGRES_DSN: ${ZITADEL_DATABASE_POSTGRES_DSN}
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false
ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel/bootstrap/login-client.pat
@@ -70,9 +62,7 @@ services:
ZITADEL_INSTRUMENTATION_SERVICENAME: ${ZITADEL_INSTRUMENTATION_SERVICENAME}
ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED: ${ZITADEL_CACHES_CONNECTORS_REDIS_ENABLED}
ZITADEL_CACHES_CONNECTORS_REDIS_ADDR: ${ZITADEL_CACHES_CONNECTORS_REDIS_ADDR}
ZITADEL_CACHES_CONNECTORS_REDIS_USERNAME: ${ZITADEL_CACHES_CONNECTORS_REDIS_USERNAME}
ZITADEL_CACHES_CONNECTORS_REDIS_PASSWORD: ${ZITADEL_CACHES_CONNECTORS_REDIS_PASSWORD}
ZITADEL_CACHES_CONNECTORS_REDIS_URL: ${ZITADEL_CACHES_CONNECTORS_REDIS_URL}
ZITADEL_CACHES_INSTANCE_CONNECTOR: ${ZITADEL_CACHES_INSTANCE_CONNECTOR}
ZITADEL_CACHES_MILESTONES_CONNECTOR: ${ZITADEL_CACHES_MILESTONES_CONNECTOR}
ZITADEL_CACHES_ORGANIZATION_CONNECTOR: ${ZITADEL_CACHES_ORGANIZATION_CONNECTOR}
@@ -149,7 +139,7 @@ services:
- CMD
- /bin/sh
- -c
- node /app/healthcheck.js http://localhost:3000/ui/v2/login/healthy
- node /app/healthcheck.mjs http://localhost:3000/ui/v2/login/healthy
interval: 10s
timeout: 30s
retries: 12
+5 -1
View File
@@ -38,11 +38,15 @@ func StartConnectors(conf *CachesConfig, client *database.DB) (Connectors, error
if conf == nil {
return Connectors{}, nil
}
redisConnector, err := redis.NewConnector(conf.Connectors.Redis)
if err != nil {
return Connectors{}, fmt.Errorf("start redis connector: %w", err)
}
return Connectors{
Config: *conf,
Memory: gomap.NewConnector(conf.Connectors.Memory),
Postgres: pg.NewConnector(conf.Connectors.Postgres, client),
Redis: redis.NewConnector(conf.Connectors.Redis),
Redis: redisConnector,
}, nil
}
+90 -3
View File
@@ -2,6 +2,7 @@ package redis
import (
"crypto/tls"
"fmt"
"time"
"github.com/redis/go-redis/v9"
@@ -10,6 +11,14 @@ import (
type Config struct {
Enabled bool
// URL is a full Redis connection URL. When set, individual connection
// fields (Addr, Username, Password, Network, EnableTLS) are ignored.
// Pool, timeout, retry, and circuit breaker settings can still be used as overlays.
// Format: redis://user:password@host:port[/db] or rediss://... for TLS.
// Note: the DB number in the URL path is ignored for cache DB selection.
// ZITADEL derives the actual DB from DBOffset + purpose; use /0 or omit the /db path.
URL string
// The network type, either tcp or unix.
// Default is tcp.
Network string
@@ -114,14 +123,91 @@ type Connector struct {
Config Config
}
func NewConnector(config Config) *Connector {
func NewConnector(config Config) (*Connector, error) {
if !config.Enabled {
return nil
return nil, nil
}
var opts *redis.Options
if config.URL != "" {
var err error
opts, err = optionsFromURL(config)
if err != nil {
return nil, err
}
} else {
opts = optionsFromConfig(config)
}
return &Connector{
Client: redis.NewClient(optionsFromConfig(config)),
Client: redis.NewClient(opts),
Config: config,
}, nil
}
func optionsFromURL(c Config) (*redis.Options, error) {
opts, err := redis.ParseURL(c.URL)
if err != nil {
return nil, fmt.Errorf("invalid Redis URL: %w", err)
}
opts.Protocol = 3
opts.ContextTimeoutEnabled = true
if c.ClientName != "" {
opts.ClientName = c.ClientName
}
if c.MaxRetries != 0 {
opts.MaxRetries = c.MaxRetries
}
if c.MinRetryBackoff != 0 {
opts.MinRetryBackoff = c.MinRetryBackoff
}
if c.MaxRetryBackoff != 0 {
opts.MaxRetryBackoff = c.MaxRetryBackoff
}
if c.DialTimeout != 0 {
opts.DialTimeout = c.DialTimeout
}
if c.ReadTimeout != 0 {
opts.ReadTimeout = c.ReadTimeout
}
if c.WriteTimeout != 0 {
opts.WriteTimeout = c.WriteTimeout
}
if c.PoolFIFO {
opts.PoolFIFO = c.PoolFIFO
}
if c.PoolSize != 0 {
opts.PoolSize = c.PoolSize
}
if c.PoolTimeout != 0 {
opts.PoolTimeout = c.PoolTimeout
}
if c.MinIdleConns != 0 {
opts.MinIdleConns = c.MinIdleConns
}
if c.MaxIdleConns != 0 {
opts.MaxIdleConns = c.MaxIdleConns
}
if c.MaxActiveConns != 0 {
opts.MaxActiveConns = c.MaxActiveConns
}
if c.ConnMaxIdleTime != 0 {
opts.ConnMaxIdleTime = c.ConnMaxIdleTime
}
if c.ConnMaxLifetime != 0 {
opts.ConnMaxLifetime = c.ConnMaxLifetime
}
opts.DisableIdentity = c.DisableIndentity
if c.IdentitySuffix != "" {
opts.IdentitySuffix = c.IdentitySuffix
}
opts.Limiter = newLimiter(c.CircuitBreaker, c.MaxActiveConns)
return opts, nil
}
func optionsFromConfig(c Config) *redis.Options {
@@ -140,6 +226,7 @@ func optionsFromConfig(c Config) *redis.Options {
WriteTimeout: c.WriteTimeout,
ContextTimeoutEnabled: true,
PoolFIFO: c.PoolFIFO,
PoolSize: c.PoolSize,
PoolTimeout: c.PoolTimeout,
MinIdleConns: c.MinIdleConns,
MaxIdleConns: c.MaxIdleConns,
+187
View File
@@ -0,0 +1,187 @@
package redis
import (
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOptionsFromURL(t *testing.T) {
t.Run("basic URL", func(t *testing.T) {
c := Config{URL: "redis://localhost:6379/0"}
opts, err := optionsFromURL(c)
require.NoError(t, err)
assert.Equal(t, "localhost:6379", opts.Addr)
assert.Equal(t, 0, opts.DB)
assert.Equal(t, 3, opts.Protocol)
assert.True(t, opts.ContextTimeoutEnabled)
})
t.Run("with auth", func(t *testing.T) {
c := Config{URL: "redis://myuser:mypass@host:6380/2"}
opts, err := optionsFromURL(c)
require.NoError(t, err)
assert.Equal(t, "host:6380", opts.Addr)
assert.Equal(t, "myuser", opts.Username)
assert.Equal(t, "mypass", opts.Password)
assert.Equal(t, 2, opts.DB)
})
t.Run("TLS via rediss scheme", func(t *testing.T) {
c := Config{URL: "rediss://host:6380/0"}
opts, err := optionsFromURL(c)
require.NoError(t, err)
assert.NotNil(t, opts.TLSConfig)
})
t.Run("password only auth", func(t *testing.T) {
c := Config{URL: "redis://:secret@host:6379/0"}
opts, err := optionsFromURL(c)
require.NoError(t, err)
assert.Equal(t, "", opts.Username)
assert.Equal(t, "secret", opts.Password)
})
t.Run("URL without DB number", func(t *testing.T) {
c := Config{URL: "redis://localhost:6379"}
opts, err := optionsFromURL(c)
require.NoError(t, err)
assert.Equal(t, "localhost:6379", opts.Addr)
assert.Equal(t, 0, opts.DB)
})
t.Run("overlays pool settings", func(t *testing.T) {
c := Config{
URL: "redis://localhost:6379/0",
PoolSize: 42,
MaxActiveConns: 100,
MinIdleConns: 5,
}
opts, err := optionsFromURL(c)
require.NoError(t, err)
assert.Equal(t, 42, opts.PoolSize)
assert.Equal(t, 100, opts.MaxActiveConns)
assert.Equal(t, 5, opts.MinIdleConns)
})
t.Run("overlays timeout settings", func(t *testing.T) {
c := Config{
URL: "redis://localhost:6379/0",
DialTimeout: 10 * time.Second,
ReadTimeout: 5 * time.Second,
}
opts, err := optionsFromURL(c)
require.NoError(t, err)
assert.Equal(t, 10*time.Second, opts.DialTimeout)
assert.Equal(t, 5*time.Second, opts.ReadTimeout)
})
t.Run("overlays retry settings", func(t *testing.T) {
c := Config{
URL: "redis://localhost:6379/0",
MaxRetries: 5,
}
opts, err := optionsFromURL(c)
require.NoError(t, err)
assert.Equal(t, 5, opts.MaxRetries)
})
t.Run("overlays client name", func(t *testing.T) {
c := Config{
URL: "redis://localhost:6379/0",
ClientName: "zitadel",
}
opts, err := optionsFromURL(c)
require.NoError(t, err)
assert.Equal(t, "zitadel", opts.ClientName)
})
t.Run("overlays circuit breaker", func(t *testing.T) {
c := Config{
URL: "redis://localhost:6379/0",
CircuitBreaker: &CBConfig{
MaxConsecutiveFailures: 10,
},
MaxActiveConns: 50,
}
opts, err := optionsFromURL(c)
require.NoError(t, err)
assert.NotNil(t, opts.Limiter)
})
t.Run("invalid URL returns error", func(t *testing.T) {
c := Config{URL: "garbage://???"}
_, err := optionsFromURL(c)
require.Error(t, err)
})
}
func TestOptionsFromConfig(t *testing.T) {
t.Run("maps all fields", func(t *testing.T) {
c := Config{
Addr: "myhost:6380",
Username: "user",
Password: "pass",
PoolSize: 42,
MaxRetries: 5,
}
opts := optionsFromConfig(c)
assert.Equal(t, "myhost:6380", opts.Addr)
assert.Equal(t, "user", opts.Username)
assert.Equal(t, "pass", opts.Password)
assert.Equal(t, 42, opts.PoolSize)
assert.Equal(t, 5, opts.MaxRetries)
assert.Equal(t, 3, opts.Protocol)
assert.True(t, opts.ContextTimeoutEnabled)
})
t.Run("EnableTLS sets TLSConfig", func(t *testing.T) {
c := Config{Addr: "host:6379", EnableTLS: true}
opts := optionsFromConfig(c)
assert.NotNil(t, opts.TLSConfig)
})
}
func TestNewConnector(t *testing.T) {
t.Run("disabled returns nil without error", func(t *testing.T) {
c, err := NewConnector(Config{Enabled: false, URL: "redis://localhost:6379/0"})
require.NoError(t, err)
assert.Nil(t, c)
})
t.Run("URL mode connects to miniredis", func(t *testing.T) {
server := miniredis.RunT(t)
url := "redis://" + server.Addr() + "/0"
c, err := NewConnector(Config{
Enabled: true,
URL: url,
DisableIndentity: true,
})
require.NoError(t, err)
require.NotNil(t, c)
t.Cleanup(func() { c.Close() })
})
t.Run("legacy mode connects to miniredis", func(t *testing.T) {
server := miniredis.RunT(t)
c, err := NewConnector(Config{
Enabled: true,
Addr: server.Addr(),
DisableIndentity: true,
})
require.NoError(t, err)
require.NotNil(t, c)
t.Cleanup(func() { c.Close() })
})
t.Run("invalid URL errors", func(t *testing.T) {
_, err := NewConnector(Config{
Enabled: true,
URL: "garbage://???",
})
require.Error(t, err)
})
}
+2 -1
View File
@@ -705,7 +705,8 @@ func prepareCache(t *testing.T, conf cache.Config, options ...func(*Config)) (ca
for _, option := range options {
option(&connConfig)
}
connector := NewConnector(connConfig)
connector, err := NewConnector(connConfig)
require.NoError(t, err)
t.Cleanup(func() {
connector.Close()
server.Close()
+68 -4
View File
@@ -3,8 +3,10 @@ package postgres
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
@@ -29,6 +31,17 @@ const (
)
type Config struct {
// DSN is a full PostgreSQL connection URL. When set, individual connection
// fields (Host, Port, Database, User, Admin, Options) are ignored, and the
// database name and user are taken exclusively from the DSN.
// Format: postgresql://user:password@host:port/dbname?sslmode=disable
// Note: In DSN mode, ZITADEL will not create the target database or user.
// The referenced database/user must already exist, and admin-creation
// semantics used in non-DSN mode (e.g. via useAdmin) do not apply.
DSN string
parsedDSN *pgxpool.Config `mapstructure:"-"`
parseOnce sync.Once `mapstructure:"-"`
Host string
Port int32
Database string
@@ -70,15 +83,35 @@ func (_ *Config) Decode(configs []interface{}) (dialect.Connector, error) {
}
}
if connector.DSN != "" {
parsed, err := pgxpool.ParseConfig(connector.DSN)
if err != nil {
return nil, fmt.Errorf("invalid PostgreSQL DSN: %w", err)
}
connector.parsedDSN = parsed
}
return connector, nil
}
func (c *Config) Connect(useAdmin bool) (*sql.DB, *pgxpool.Pool, error) {
connConfig := dialect.NewConnectionConfig(c.MaxOpenConns, c.MaxIdleConns)
config, err := pgxpool.ParseConfig(c.String(useAdmin))
if err != nil {
return nil, nil, err
var config *pgxpool.Config
var err error
if parsed := c.ensureParsedDSN(); parsed != nil {
config = parsed.Copy()
if config.ConnConfig.RuntimeParams == nil {
config.ConnConfig.RuntimeParams = map[string]string{}
}
if _, ok := config.ConnConfig.RuntimeParams["application_name"]; !ok {
config.ConnConfig.RuntimeParams["application_name"] = dialect.DefaultAppName
}
} else {
config, err = pgxpool.ParseConfig(c.String(useAdmin))
if err != nil {
return nil, nil, err
}
}
if len(connConfig.AfterConnect) > 0 {
@@ -146,15 +179,43 @@ func (c *Config) Connect(useAdmin bool) (*sql.DB, *pgxpool.Pool, error) {
return stdlib.OpenDBFromPool(pool), pool, nil
}
// ensureParsedDSN lazily parses the DSN on first access so that accessors
// return correct values even if Decode() was not called.
func (c *Config) ensureParsedDSN() *pgxpool.Config {
if c.DSN == "" {
return nil
}
c.parseOnce.Do(func() {
if c.parsedDSN == nil {
parsed, err := pgxpool.ParseConfig(c.DSN)
if err != nil {
logging.WithError(err).Warn("failed to parse PostgreSQL DSN")
return
}
c.parsedDSN = parsed
}
})
return c.parsedDSN
}
func (c *Config) DatabaseName() string {
if parsed := c.ensureParsedDSN(); parsed != nil {
return parsed.ConnConfig.Database
}
return c.Database
}
func (c *Config) Username() string {
if parsed := c.ensureParsedDSN(); parsed != nil {
return parsed.ConnConfig.User
}
return c.User.Username
}
func (c *Config) Password() string {
if parsed := c.ensureParsedDSN(); parsed != nil {
return parsed.ConnConfig.Password
}
return c.User.Password
}
@@ -204,7 +265,10 @@ func (s *Config) checkSSL(user User) {
}
}
func (c Config) String(useAdmin bool) string {
func (c *Config) String(useAdmin bool) string {
if c.DSN != "" {
return c.DSN
}
user := c.User
if useAdmin {
user = c.Admin.User
+231
View File
@@ -0,0 +1,231 @@
package postgres
import (
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// parseDSN is a test helper that parses a DSN and returns a Config
// with parsedDSN populated, mimicking what Decode() does.
func parseDSN(t *testing.T, dsn string) Config {
t.Helper()
parsed, err := pgxpool.ParseConfig(dsn)
require.NoError(t, err)
return Config{DSN: dsn, parsedDSN: parsed}
}
func TestConfig_parseDSN_parsesDSN(t *testing.T) {
c := parseDSN(t, "postgresql://myuser:mypass@dbhost:5433/mydb?sslmode=require")
require.NotNil(t, c.parsedDSN)
assert.Equal(t, "dbhost", c.parsedDSN.ConnConfig.Host)
assert.Equal(t, uint16(5433), c.parsedDSN.ConnConfig.Port)
assert.Equal(t, "mydb", c.parsedDSN.ConnConfig.Database)
assert.Equal(t, "myuser", c.parsedDSN.ConnConfig.User)
assert.Equal(t, "mypass", c.parsedDSN.ConnConfig.Password)
}
func TestConfig_DatabaseName(t *testing.T) {
t.Run("from DSN", func(t *testing.T) {
c := parseDSN(t, "postgresql://u:p@h:5432/fromdsn?sslmode=disable")
assert.Equal(t, "fromdsn", c.DatabaseName())
})
t.Run("from legacy field", func(t *testing.T) {
c := Config{Database: "fromlegacy"}
assert.Equal(t, "fromlegacy", c.DatabaseName())
})
}
func TestConfig_Username(t *testing.T) {
t.Run("from DSN", func(t *testing.T) {
c := parseDSN(t, "postgresql://dsnuser:p@h:5432/db?sslmode=disable")
assert.Equal(t, "dsnuser", c.Username())
})
t.Run("from legacy field", func(t *testing.T) {
c := Config{User: User{Username: "legacyuser"}}
assert.Equal(t, "legacyuser", c.Username())
})
}
func TestConfig_Password(t *testing.T) {
t.Run("from DSN", func(t *testing.T) {
c := parseDSN(t, "postgresql://u:dsnpass@h:5432/db?sslmode=disable")
assert.Equal(t, "dsnpass", c.Password())
})
t.Run("from legacy field", func(t *testing.T) {
c := Config{User: User{Password: "legacypass"}}
assert.Equal(t, "legacypass", c.Password())
})
}
func TestConfig_String(t *testing.T) {
t.Run("returns DSN when set", func(t *testing.T) {
dsn := "postgresql://u:p@h:5432/db?sslmode=disable"
c := Config{DSN: dsn}
assert.Equal(t, dsn, c.String(false))
})
t.Run("builds legacy string", func(t *testing.T) {
c := Config{
Host: "myhost",
Port: 5433,
Database: "mydb",
User: User{
Username: "myuser",
Password: "mypass",
SSL: SSL{Mode: "disable"},
},
}
s := c.String(false)
assert.Contains(t, s, "host=myhost")
assert.Contains(t, s, "port=5433")
assert.Contains(t, s, "user=myuser")
assert.Contains(t, s, "password=mypass")
assert.Contains(t, s, "dbname=mydb")
assert.Contains(t, s, "sslmode=disable")
})
}
func TestConfig_DSN_fullSpec(t *testing.T) {
tests := []struct {
name string
dsn string
wantHost string
wantPort uint16
wantDB string
wantUser string
wantPass string
}{
{
name: "standard URL format",
dsn: "postgresql://myuser:mypass@myhost:5433/mydb?sslmode=disable",
wantHost: "myhost",
wantPort: 5433,
wantDB: "mydb",
wantUser: "myuser",
wantPass: "mypass",
},
{
name: "postgres:// scheme",
dsn: "postgres://u:p@h:5432/db?sslmode=disable",
wantHost: "h",
wantPort: 5432,
wantDB: "db",
wantUser: "u",
wantPass: "p",
},
{
name: "URL-encoded password",
dsn: "postgresql://user:p%40ss%23word@host:5432/db?sslmode=disable",
wantHost: "host",
wantPort: 5432,
wantDB: "db",
wantUser: "user",
wantPass: "p@ss#word",
},
{
name: "with SSL parameters",
dsn: "postgresql://user:pass@host:5432/db?sslmode=require",
wantHost: "host",
wantPort: 5432,
wantDB: "db",
wantUser: "user",
wantPass: "pass",
},
{
name: "with application_name",
dsn: "postgresql://user:pass@host:5432/db?sslmode=disable&application_name=myapp",
wantHost: "host",
wantPort: 5432,
wantDB: "db",
wantUser: "user",
wantPass: "pass",
},
{
name: "with options parameter",
dsn: "postgresql://user:pass@host:5432/db?sslmode=disable&options=-c%20search_path%3Dpublic",
wantHost: "host",
wantPort: 5432,
wantDB: "db",
wantUser: "user",
wantPass: "pass",
},
{
name: "key-value format",
dsn: "host=kvhost port=5434 user=kvuser password=kvpass dbname=kvdb sslmode=disable",
wantHost: "kvhost",
wantPort: 5434,
wantDB: "kvdb",
wantUser: "kvuser",
wantPass: "kvpass",
},
{
name: "default port (omitted)",
dsn: "postgresql://user:pass@host/db?sslmode=disable",
wantHost: "host",
wantPort: 5432,
wantDB: "db",
wantUser: "user",
wantPass: "pass",
},
{
name: "without password",
dsn: "postgresql://user@host:5432/db?sslmode=disable",
wantHost: "host",
wantPort: 5432,
wantDB: "db",
wantUser: "user",
wantPass: "",
},
{
name: "without database",
dsn: "postgresql://user:pass@host:5432?sslmode=disable",
wantHost: "host",
wantPort: 5432,
wantDB: "",
wantUser: "user",
wantPass: "pass",
},
{
name: "IPv6 host",
dsn: "postgresql://user:pass@[::1]:5432/db?sslmode=disable",
wantHost: "::1",
wantPort: 5432,
wantDB: "db",
wantUser: "user",
wantPass: "pass",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := parseDSN(t, tt.dsn)
assert.Equal(t, tt.wantDB, c.DatabaseName())
assert.Equal(t, tt.wantUser, c.Username())
assert.Equal(t, tt.wantPass, c.Password())
assert.Equal(t, tt.wantHost, c.parsedDSN.ConnConfig.Host)
assert.Equal(t, tt.wantPort, c.parsedDSN.ConnConfig.Port)
})
}
}
func TestConfig_DSN_ignores_legacy_fields(t *testing.T) {
// When DSN is set and parsedDSN is populated, accessors return
// values from the DSN, not from the legacy fields.
c := parseDSN(t, "postgresql://dsnuser:dsnpass@dsnhost:5433/dsndb?sslmode=disable")
c.Host = "legacyhost"
c.Port = 9999
c.Database = "legacydb"
c.User = User{Username: "legacyuser", Password: "legacypass"}
assert.Equal(t, "dsndb", c.DatabaseName())
assert.Equal(t, "dsnuser", c.Username())
assert.Equal(t, "dsnpass", c.Password())
}