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