Files
zitadel/cmd/initialise/verify_schema.go
T
Florian ForsterandGitHub 92a628d892 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
2026-03-03 07:05:32 +01:00

154 lines
3.8 KiB
Go

package initialise
import (
"context"
"database/sql"
_ "embed"
"errors"
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
"github.com/zitadel/zitadel/internal/database"
es_v3 "github.com/zitadel/zitadel/internal/eventstore/v3"
)
func newSchema() *cobra.Command {
return &cobra.Command{
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 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 init schema command failed")
}()
config, shutdown, err := NewConfig(cmd, viper.GetViper())
if err != nil {
return err
}
defer func() {
err = errors.Join(err, shutdown(cmd.Context()))
}()
return verifyZitadel(cmd.Context(), config.Database)
},
}
}
func VerifyZitadel(ctx context.Context, db *database.DB, config database.Config) error {
err := ReadStmts()
if err != nil {
return err
}
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
logging.Info(ctx, "verify system")
if err := exec(ctx, conn, fmt.Sprintf(createSystemStmt, config.Username()), nil); err != nil {
return err
}
logging.Info(ctx, "verify encryption keys")
if err := createEncryptionKeys(ctx, conn); err != nil {
return err
}
logging.Info(ctx, "verify projections")
if err := exec(ctx, conn, fmt.Sprintf(createProjectionsStmt, config.Username()), nil); err != nil {
return err
}
logging.Info(ctx, "verify eventstore")
if err := exec(ctx, conn, fmt.Sprintf(createEventstoreStmt, config.Username()), nil); err != nil {
return err
}
logging.Info(ctx, "verify events tables")
if err := createEvents(ctx, conn); err != nil {
return err
}
logging.Info(ctx, "verify unique constraints")
if err := exec(ctx, conn, createUniqueConstraints, nil); err != nil {
return err
}
return nil
}
func verifyZitadel(ctx context.Context, config database.Config) error {
logging.Info(ctx, "verify zitadel", "database", config.DatabaseName())
db, err := database.Connect(config, false)
if err != nil {
return err
}
if err := VerifyZitadel(ctx, db, config); err != nil {
return err
}
return db.Close()
}
func createEncryptionKeys(ctx context.Context, db database.Beginner) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err = tx.Exec(createEncryptionKeysStmt); err != nil {
rollbackErr := tx.Rollback()
logging.WithError(ctx, rollbackErr).Error("rollback failed")
return err
}
return tx.Commit()
}
func createEvents(ctx context.Context, conn *sql.Conn) (err error) {
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
rollbackErr := tx.Rollback()
logging.WithError(ctx, rollbackErr).Error("rollback failed")
return
}
err = tx.Commit()
}()
// if events already exists events2 is created during a setup job
var count int
row := tx.QueryRow("SELECT count(*) FROM information_schema.tables WHERE table_schema = 'eventstore' AND table_name like 'events%'")
if err = row.Scan(&count); err != nil {
return err
}
if row.Err() != nil || count >= 1 {
return row.Err()
}
_, err = tx.Exec(createEventsStmt)
if err != nil {
return err
}
return es_v3.CheckExecutionPlan(ctx, conn)
}