diff --git a/apps/docs/content/self-hosting/manage/database/_postgres.mdx b/apps/docs/content/self-hosting/manage/database/_postgres.mdx deleted file mode 100644 index 85a13a0f4..000000000 --- a/apps/docs/content/self-hosting/manage/database/_postgres.mdx +++ /dev/null @@ -1,56 +0,0 @@ -## ZITADEL with Postgres - -PostgreSQL is the default database for ZITADEL due to its reliability, robustness, and adherence to SQL standards. It is well-suited for handling the complex data requirements of an identity management system. - -If you are using Zitadel v2 and want to use a PostgreSQL database, you can [overwrite the default configuration](/self-hosting/manage/configure/configure). - -Currently, versions 14 to 18 are supported. - -Postgres can be configured as follows: -```yaml -Database: - postgres: - Host: localhost - Port: 5432 - Database: zitadel - AwaitInitialConn: 5m - MaxOpenConns: 15 - MaxIdleConns: 10 - MaxConnLifetime: 1h - MaxConnIdleTime: 5m - Options: - User: - Username: zitadel - Password: zitadel - SSL: - Mode: disable - RootCert: - Cert: - Key: - Admin: - Username: postgres - Password: postgres - SSL: - Mode: disable - RootCert: - Cert: - Key: -``` - -The admin user is only needed for the installation step but can also be avoided -by preparing the necessary requirements: - -* the zitadel user -* the zitadel database -* granting required permissions to the zitadel user - -```sql -CREATE ROLE zitadel LOGIN; -CREATE DATABASE zitadel; -GRANT CONNECT, CREATE ON DATABASE zitadel TO zitadel; -``` - -Don't forget to adjust `pg_hba.conf` and set a password for the zitadel user. - -With the setup done, follow the [phases guide](/self-hosting/manage/updating_scaling#separating-init-and-setup-from-the-runtime) -to run the init and then setup phase to get all necessary tables and data set up. diff --git a/apps/docs/content/self-hosting/manage/database/index.mdx b/apps/docs/content/self-hosting/manage/database/index.mdx index 09404110d..0ce9fc4fd 100644 --- a/apps/docs/content/self-hosting/manage/database/index.mdx +++ b/apps/docs/content/self-hosting/manage/database/index.mdx @@ -3,11 +3,117 @@ title: Database description: Configure and manage the ZITADEL database using PostgreSQL for persistent storage and audit logs --- -import Postgres from './_postgres.mdx' - ## Database Configuration - + +Even if you provision the PostgreSQL user and database manually, you must still run `zitadel init schema` +to bootstrap the required schemas and tables before running `start-from-setup` or `zitadel start`. +Skipping this step causes `relation "eventstore.events" does not exist` errors. +See the [Managed PostgreSQL / No Admin Access](#managed-postgresql--no-admin-access) section below for the exact steps. + +The command is `zitadel init schema` (alias: `zitadel init zitadel` for backwards compatibility). + + +## ZITADEL with Postgres + +PostgreSQL is the default database for ZITADEL due to its reliability, robustness, and adherence to SQL standards. It is well-suited for handling the complex data requirements of an identity management system. + +If you are using Zitadel v2 and want to use a PostgreSQL database, you can [overwrite the default configuration](/self-hosting/manage/configure/configure). + +Currently, versions 14 to 18 are supported. + +Postgres can be configured as follows: +```yaml +Database: + postgres: + Host: localhost + Port: 5432 + Database: zitadel + AwaitInitialConn: 5m + 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: +``` + +### Managed PostgreSQL / No Admin Access + +If you don't have a PostgreSQL superuser available (e.g. managed services like RDS, Cloud SQL, or Azure Database), +you can pre-provision the user and database yourself and skip the admin-credential requirement. + +The `init` command performs two steps that require different privileges: + +- **Provisioning step** — `CREATE ROLE`, `CREATE DATABASE`, `GRANT` → requires a superuser/admin. +- **Schema bootstrapping step** — creates schemas (`eventstore`, `projections`, `system`) and tables → requires only database owner privileges. + +If you handle the provisioning step manually, you still **must** run the schema bootstrapping step. Use the dedicated sub-command `zitadel init schema`, which performs only schema bootstrapping using the service user credentials — no admin privileges needed. + +**Provisioning step — Provision user and database (run as superuser)** + + +Replace `` with a strong, unique password and store it in a secret manager. Do not use trivial passwords in production. + + +```sql +CREATE ROLE zitadel LOGIN PASSWORD ''; +CREATE DATABASE zitadel WITH OWNER zitadel; +-- The GRANT below is redundant when zitadel is the database owner, +-- but is included for clarity. Owners implicitly hold all privileges. +GRANT CONNECT, CREATE ON DATABASE zitadel TO zitadel; +``` + +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. + +```bash +ZITADEL_DATABASE_POSTGRES_HOST= \ + ZITADEL_DATABASE_POSTGRES_PORT=5432 \ + ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel \ + ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel \ + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD= \ + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=require \ + zitadel init schema +``` + +**Run setup and start** + + +For production deployments, configure TLS to encrypt authentication traffic. See the [TLS configuration guide](/self-hosting/manage/tls_modes) for the available modes. + + +```bash +ZITADEL_DATABASE_POSTGRES_HOST= \ + ZITADEL_DATABASE_POSTGRES_PORT=5432 \ + ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel \ + ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel \ + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD= \ + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=require \ + zitadel start-from-setup --masterkey "<32-character-key>" +``` + +See the [phases guide](/self-hosting/manage/updating_scaling#separating-init-and-setup-from-the-runtime) for details on separating init, setup, and runtime for production deployments. ## Zitadel Credentials diff --git a/apps/docs/content/self-hosting/manage/updating_scaling.mdx b/apps/docs/content/self-hosting/manage/updating_scaling.mdx index fa5fd385b..2488473c0 100644 --- a/apps/docs/content/self-hosting/manage/updating_scaling.mdx +++ b/apps/docs/content/self-hosting/manage/updating_scaling.mdx @@ -53,14 +53,30 @@ you should know what happens during these phases. The command `zitadel init` ensures the database connection is ready to use for the subsequent phases. It just needs to be executed once over ZITADELs full life cycle, when you install ZITADEL from scratch. -During `zitadel init`, for connecting to your database, -ZITADEL uses the privileged and preexisting database user configured in `Database.postgres.Admin.Username`. -, `zitadel init` ensures the following: -- If it doesn’t exist already, it creates a database with the configured database name. -- If it doesn’t exist already, it creates the unprivileged user use configured in `Database.postgres.User.Username`. + +`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 not already done, it grants the necessary permissions ZITADEL needs to the non privileged user. -- If they don’t exist already, it creates all schemas and some basic tables. +- 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. + + +**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 +zitadel init schema +``` + +This sub-command connects using the service user credentials and only performs schema bootstrapping. +No `Admin.*` credentials are required. +See the [database documentation](/self-hosting/manage/database) for full details. + The init phase is idempotent if executed with the same binary version. diff --git a/cmd/initialise/init.go b/cmd/initialise/init.go index f8ec112a6..33c41c2bd 100644 --- a/cmd/initialise/init.go +++ b/cmd/initialise/init.go @@ -44,6 +44,12 @@ The user provided by flags needs privileges to - create the database if it does not exist - see other users and create a new one if the user does not exist - grant all rights of the ZITADEL database to the user created if not yet set + +If you don't have admin/superuser credentials (e.g. on a managed PostgreSQL +service), you can provision the database user and database manually and then +run only 'zitadel init schema' to bootstrap the required schemas and tables +using the service user credentials. No admin privileges are needed for that +sub-command. See the database documentation for details. `, RunE: func(cmd *cobra.Command, args []string) (err error) { defer func() { @@ -61,7 +67,7 @@ The user provided by flags needs privileges to }, } - cmd.AddCommand(newZitadel(), newDatabase(), newUser(), newGrant()) + cmd.AddCommand(newSchema(), newDatabase(), newUser(), newGrant()) return cmd } diff --git a/cmd/initialise/verify_database.go b/cmd/initialise/verify_database.go index 4b108706f..06ac71370 100644 --- a/cmd/initialise/verify_database.go +++ b/cmd/initialise/verify_database.go @@ -58,6 +58,22 @@ func VerifyDatabase(databaseName string) func(context.Context, *database.DB) err logging.Info(ctx, "database is same as config.database.postgres.admin.ExistingDatabase, skipping creation", "database", databaseName) return nil } + + // Check if the database already exists in the catalog before attempting CREATE DATABASE. + // This handles the case where the database was provisioned externally and the admin + // credentials are the same as the service user, which lacks the CREATEDB privilege. + var exists bool + err = db.QueryRowContext(ctx, func(r *sql.Row) error { + return r.Scan(&exists) + }, "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", databaseName) + if err != nil { + return fmt.Errorf("unable to check if database exists: %w", err) + } + if exists { + logging.Info(ctx, "database already exists, skipping creation", "database", databaseName) + return nil + } + logging.Info(ctx, "verify database", "database", databaseName) return exec(ctx, db, fmt.Sprintf(databaseStmt, databaseName), []string{dbAlreadyExistsCode}) diff --git a/cmd/initialise/verify_database_test.go b/cmd/initialise/verify_database_test.go index 62ae26186..d75a841f4 100644 --- a/cmd/initialise/verify_database_test.go +++ b/cmd/initialise/verify_database_test.go @@ -24,12 +24,15 @@ func Test_verifyDB(t *testing.T) { targetErr error }{ { - name: "doesn't exists, create fails", + name: "doesn't exist, create fails", args: args{ db: prepareDB(t, expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{ {"postgres"}, }), + expectQuery("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", nil, []string{"exists"}, [][]driver.Value{ + {false}, + }, "zitadel"), expectExec("-- replace zitadel with the name of the database\nCREATE DATABASE \"zitadel\"", sql.ErrTxDone), ), database: "zitadel", @@ -37,12 +40,15 @@ func Test_verifyDB(t *testing.T) { targetErr: sql.ErrTxDone, }, { - name: "doesn't exists, create successful", + name: "doesn't exist, create successful", args: args{ db: prepareDB(t, expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{ {"postgres"}, }), + expectQuery("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", nil, []string{"exists"}, [][]driver.Value{ + {false}, + }, "zitadel"), expectExec("-- replace zitadel with the name of the database\nCREATE DATABASE \"zitadel\"", nil), ), database: "zitadel", @@ -50,20 +56,35 @@ func Test_verifyDB(t *testing.T) { targetErr: nil, }, { - name: "already exists", + name: "already exists in catalog, skip creation", args: args{ db: prepareDB(t, expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{ {"postgres"}, }), - expectExec("-- replace zitadel with the name of the database\nCREATE DATABASE \"zitadel\"", nil), + expectQuery("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", nil, []string{"exists"}, [][]driver.Value{ + {true}, + }, "zitadel"), ), database: "zitadel", }, targetErr: nil, }, { - name: "same database, skip creation", + name: "catalog check fails", + args: args{ + db: prepareDB(t, + expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{ + {"postgres"}, + }), + expectQuery("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", sql.ErrConnDone, []string{"exists"}, [][]driver.Value{}, "zitadel"), + ), + database: "zitadel", + }, + targetErr: sql.ErrConnDone, + }, + { + name: "same database as admin connection, skip creation", args: args{ db: prepareDB(t, expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{ diff --git a/cmd/initialise/verify_zitadel.go b/cmd/initialise/verify_schema.go similarity index 81% rename from cmd/initialise/verify_zitadel.go rename to cmd/initialise/verify_schema.go index 2a5d3f41e..e0a3a3818 100644 --- a/cmd/initialise/verify_zitadel.go +++ b/cmd/initialise/verify_schema.go @@ -15,18 +15,26 @@ import ( es_v3 "github.com/zitadel/zitadel/internal/eventstore/v3" ) -func newZitadel() *cobra.Command { +func newSchema() *cobra.Command { return &cobra.Command{ - Use: "zitadel", - Short: "initialize ZITADEL internals", - Long: `initialize ZITADEL internals. + Use: "schema", + Aliases: []string{"zitadel"}, + Short: "bootstrap the ZITADEL database schema without admin/superuser privileges", + Long: `Bootstrap the ZITADEL database schema. + +Creates all required schemas (eventstore, projections, system) and base tables +using the service user credentials. No admin/superuser privileges are required. + +Use this command when you have provisioned the database user and database +yourself (e.g. on a managed PostgreSQL service) and want to skip the +admin-credential requirement of the full 'zitadel init' command. Prerequisites: -- postgreSQL with user and database +- PostgreSQL user exists and has ownership of the target database `, RunE: func(cmd *cobra.Command, args []string) (err error) { defer func() { - logging.OnError(cmd.Context(), err).Error("zitadel verify zitadel command failed") + logging.OnError(cmd.Context(), err).Error("zitadel init schema command failed") }() config, shutdown, err := NewConfig(cmd, viper.GetViper()) if err != nil { diff --git a/cmd/initialise/verify_zitadel_test.go b/cmd/initialise/verify_schema_test.go similarity index 100% rename from cmd/initialise/verify_zitadel_test.go rename to cmd/initialise/verify_schema_test.go