feat(database): enhance PostgreSQL setup documentation and commands for non-admin access (#11631)

Users deploying ZITADEL against a managed PostgreSQL service (RDS, Cloud
SQL, Azure Database, etc.) often do not have superuser access and cannot
provide `Admin.*` credentials. The documented workaround — provisioning
the user and database manually and then running `start-from-setup` —
silently skips schema bootstrapping, causing `relation
"eventstore.events" does not exist` errors with no clear recovery path.

The root cause is that `zitadel init` conflates two steps that require
different privileges without exposing them separately:

- **Provisioning step** (`CREATE ROLE`, `CREATE DATABASE`, `GRANT`) —
requires superuser.
- **Schema bootstrapping step** (create
`eventstore`/`projections`/`system` schemas and base tables) — requires
only DB owner.

Users who handle the provisioning step externally have no supported way
to run schema bootstrapping alone.

## Changes

- **`cmd/initialise/verify_schema.go`** (renamed from
`verify_zitadel.go`): Rename `newZitadel()` → `newSchema()` (internal);
rename the `init zitadel` sub-command to `zitadel init schema`
(backwards-compatible alias kept) with a clear description that it
bootstraps the ZITADEL database schema without admin/superuser
privileges. Fix stale error log message to reference `init schema`.
- **`cmd/initialise/verify_schema_test.go`** (renamed from
`verify_zitadel_test.go`): Test file renamed to match source file.
- **`cmd/initialise/init.go`**: Update call site to `newSchema()`. Add
guidance in the `init` command's Long description about using `zitadel
init schema` for users without admin credentials.
- **`cmd/initialise/verify_database.go`**: Add a `pg_database` catalog
pre-check before attempting `CREATE DATABASE`, so `zitadel init` with
`ADMIN=service_user` no longer fails with `permission denied to create
database` when the database was already provisioned externally.
- **`cmd/initialise/verify_database_test.go`**: Add test cases covering
the new catalog-check skip path, the existing error-skip path, and the
error-propagation path when the `pg_database` query itself fails.
- **`apps/docs/content/self-hosting/manage/database/index.mdx`**: Inline
the `_postgres.mdx` partial (now only PostgreSQL is supported), add a
top-level callout, and add a **Managed PostgreSQL / No Admin Access**
section with explicit 3-step instructions and security guidance (strong
passwords, SSL, TLS). Additional improvements: add inline `# Use
'require' or 'verify-full' for production` comments on `Mode: disable`
lines in the YAML example; add clarifying comment to the redundant
`GRANT` in the SQL snippet; replace admonition syntax with proper
Fumadocs `<Callout>` components; add full database connection env vars
to the `start-from-setup` example.
- **`apps/docs/content/self-hosting/manage/updating_scaling.mdx`**:
Rewrite the init phase description to clearly distinguish the
provisioning and schema bootstrapping steps, and document `zitadel init
schema` as the path for manual provisioning. Replace admonition syntax
with proper Fumadocs `<Callout>` components.
- **`apps/docs/content/self-hosting/manage/database/_postgres.mdx`**:
Deleted (content merged into `index.mdx`).

## Problem

This relates to https://github.com/zitadel/zitadel/discussions/9363

I think we can improve our UX in cases where a user wants to use an
external DB and/or does not want to share too broad permissions with
zitadel

## Related problems

* https://github.com/zitadel/zitadel/issues/10432
* https://github.com/zitadel/zitadel/discussions/8583
* https://github.com/zitadel/zitadel/issues/7903
* https://github.com/zitadel/zitadel/issues/9718
* https://github.com/zitadel/zitadel/issues/8012
* https://github.com/zitadel/zitadel/issues/8558

## Related PRs

https://github.com/zitadel/zitadel/pull/11021
This commit is contained in:
Florian Forster
2026-03-03 07:05:32 +01:00
committed by GitHub
parent 9fb0eea125
commit 92a628d892
8 changed files with 195 additions and 78 deletions
+7 -1
View File
@@ -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
}
+16
View File
@@ -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})
+26 -5
View File
@@ -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{
@@ -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 {