Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
2.0.0 - 2026-11-12
Section titled “2.0.0 - 2026-11-12”This release makes config loading strict and contains breaking changes; see Upgrading to 2.0.
*_FILEsecret support — every${VAR}reference falls back to the contents of the file named byVAR_FILE(Docker secrets / Kubernetes secret volumes; trailing newlines trimmed, max 64 KiB). The provisioner’s own environment variables (POSTGRES_USER,POSTGRES_PASSWORD, …) accept<NAME>_FILEas well. Setting bothVARandVAR_FILEis an error.- Default values —
${VAR:-default}usesdefaultwhen the variable is unset or empty (POSIX/Docker Compose semantics);${VAR:-}marks a value as optional. - Escape for a literal
${—$${yields${without expansion or validation (e.g. a password containing${). - Explicit revokes —
databases[].revokes(same shape as grants,on_database/on_schematargets) removes listed privileges, e.g.REVOKE CONNECT ON DATABASE ... FROM PUBLICto harden a database.PUBLIC(case-insensitive) is emitted as the bare keyword and doesn’t need to be declared. Revokes run before grants, only revoke privileges the grantee actually holds (checked viaaclexplode), warn if a privilege survives because another grantor granted it, and support dry-run. Revokes that conflict with a grant or duplicate another revoke are rejected at load time. Declaring a role namedpublicin any case is now rejected, since grants and revokes would treat it as the pseudo-role. Revokes never cascade: “dependent privileges exist” (the grantee re-granted a privilege it holdsWITH GRANT OPTION) fails the run with a hint. In dry-run, revokes on a schema created in the same run are previewed against a new schema’s default privileges (owner only). - Logical replication publications —
databases[].publicationscreates publications for CDC (tables: [schema.table, ...]orall_tables: true, optionalpublishoperations); listed tables must be tables or partitioned tables, other relation kinds such as views are rejected. They are provisioned after migrations; missing tables follow theon_tablesrule (an error if this run applied the database’s migrations, otherwise a warning and the publication covers the existing tables, or is skipped until one exists). Existing publications are reconciled additively: changedpublishoperations are set and missing tables added, while tables no longer configured are kept with a warning. A warning is logged when the server’swal_levelisn’tlogical. Replication slots are left to consumers. - Table- and column-level grants — new grant target
on_tables: ["schema.table", ...]with optionalcolumns: [...](GRANT SELECT ("id", "email") ON TABLE ...). Targets may be tables, partitioned tables, views, materialized views or foreign tables; other relation kinds (indexes, sequences, composite types) are rejected. These grants run in a new phase after migrations, so they can target tables created by migrations in the same run; a table still missing at that point fails provisioning if this run applied the database’s migrations (dry-run only logs it). With migrations disabled (MIGRATIONS_ENABLED=false, e.g. the app runs its own) or not configured, missing tables only produce a warning and the grant covers the existing tables, so an init container doesn’t deadlock on a fresh database; the next run grants the rest. Table names must be schema-qualified and are used verbatim as quoted identifiers (case-sensitive; pre-quoted names containing"are rejected, and a missing table’s error suggests a case-insensitive match). - Sequence and function grants — new grant targets
on_sequences_in_schema(GRANT ... ON ALL SEQUENCES IN SCHEMA+ALTER DEFAULT PRIVILEGES ... ON SEQUENCES) andon_functions_in_schema(ON ALL ROUTINES, covering functions and procedures, + default privilegesON FUNCTIONS). Likeon_tables_in_schema, both run transactionally and cover objects created later by the provisioning user and the schema owner. Sequence grants are required forINSERTinto tables withserialcolumns. - Extension options —
databases[].extensionsentries can be a mapping withname,schema,versionandcascade(plain names keep working). Missing extensions are created withSCHEMA/VERSION/CASCADE; an installed extension whose pinnedversiondiffers is updated withALTER EXTENSION ... UPDATE TO(not withstrategy: create; skipped with a warning whenpg_extension_update_pathshas no path, e.g. a downgrade); a differingschemais only warned about, since relocating an extension can break dependent objects (the hint suggestsSET SCHEMAonly for relocatable extensions, otherwise drop and recreate).cascadeinstalls dependencies at their default versions, into the extension’s configuredschemaif given; list a dependency first to give it its ownschema/version. shared_preload_librariescheck — whenpg_stat_statements,pg_cronortimescaledbis configured, the server’sshared_preload_librariesis read once per run, before any extension is created (entries like$libdir/pg_stat_statementsorpg_stat_statements.soare normalized).pg_cronandtimescaledbcan’t be created without their library preloaded: if one isn’t installed yet, the run fails early with an error naming the server flag to add.pg_stat_statementsinstalls fine but fails when used (e.g. postgres_exporter’s metrics), so it is installed and a warning is logged once per library. If the admin role can’t read the setting, the check is skipped and logged at info level.- Role and database settings —
roles[].settings(ALTER ROLE ... SET),databases[].settings(ALTER DATABASE ... SET) anddatabases[].role_settings(ALTER ROLE ... IN DATABASE ... SET) set run-time parameter defaults such asstatement_timeoutorsearch_path. Lists are quoted per element (needed forsearch_path), an explicitnullresets a setting (an empty value or~is rejected), and removing a setting from the config leaves it untouched. Current values are read frompg_db_role_setting, so unchanged settings are skipped. Supports dry-run and${VAR}expansion in values;strategy: createskips role settings for existing roles, and database settings androle_settingsfor existing databases (role_settingsfollow the database’s strategy, not the role’s). Use canonical parameter names: obsolete aliases such assort_memare stored aswork_memand therefore re-applied on every run.
Changed
Section titled “Changed”- BREAKING: unresolved
${VAR}references now fail config loading. Previously an unset variable was left in place as literal text, so e.g. a missingAPP_DB_PASSWORDsilently set the role’s password to the string${APP_DB_PASSWORD}. Loading now fails and reports every unresolved reference with its config path. Migration: for values that are intentionally optional, use${VAR:-}or${VAR:-default}. Role passwords are the exception, because they must not resolve to empty (see below): either omit thepasswordfield or provide a value. - BREAKING: role passwords that resolve to an empty string are rejected. A
passwordcontaining a reference that expands to""would otherwise provision the role without a password. - BREAKING: setting both
<NAME>and<NAME>_FILEfor the provisioner’s own environment variables is an error. E.g. a deployment that sets bothPOSTGRES_PASSWORDandPOSTGRES_PASSWORD_FILE(previously the_FILEvariable was ignored) now fails at startup. Migration: remove one of the two. $${in config values now yields a literal${(escape) instead of$followed by an expanded reference.- Invalid references such as
${foo bar}or${}are now errors instead of being left as literal text. - Nested references in defaults (
${A:-${B}}) are rejected; they are not supported and previously produced wrong values (a literal${B}or a stray}). roles[].options.valid_untilnow supports${VAR}expansion.- Grant privileges are validated per target — e.g.
SELECTwithon_databaseorUSAGEwithon_tables_in_schemais now rejected when the config is loaded, instead of failing partway through provisioning withinvalid privilege type. Only configs that could never apply successfully are affected. - A grant with both an invalid privilege and no (or multiple) targets now reports the target error first, since privileges are validated against the resolved target.
- Null bytes in
on_schema/on_tables_in_schemaare now rejected during validation, like all other identifier fields. ALLcan no longer be combined with other privileges in the same grant or revoke (e.g.[ALL, SELECT]). PostgreSQL rejects such aGRANTwith a syntax error, so these configs could never apply; they now fail at load time with a path-qualified error.- Provisioner sessions ignore risky role/database defaults — every provisioner connection sets
statement_timeout=0,lock_timeout=0,idle_in_transaction_session_timeout=0anddefault_transaction_read_only=offas connection startup options, so settings such as a read-only database default or a shortstatement_timeoutcan’t break schema creation or migrations.search_pathis not overridden. - Duplicate extensions in a database are rejected at load time, as are unknown keys in an extension mapping (e.g. a misspelled
shema). - Schemas are now provisioned before extensions (per database), so an extension can be installed into a schema the provisioner creates.
CREATE SCHEMAnever depends on an extension, but an extension withoutschemalands in the first existing schema of the admin connection’ssearch_path: with the default"$user", public, a provisioned schema named like the admin login role now exists at that point and is chosen overpublic. Setschema: publicexplicitly to keep the previous placement. - Documentation restructured — the README is now an entry point; the documentation moved to
docs/: a getting-started guide, how it works, how-to guides (monitoring, read-only users, hardening, secrets, application-owned migrations, CDC), deployment (including Kubernetes), a configuration reference, an FAQ and an upgrade guide for 2.0.
1.4.0 - 2026-11-12
Section titled “1.4.0 - 2026-11-12”- Role memberships (
member_of) —roles[].member_ofgrants a role membership in other roles (GRANT <group> TO <role>), including PostgreSQL predefined roles such aspg_monitor. This enables a least-privilege, non-superuser account forpostgres_exporterinstead of the admin user. Memberships are applied in a separate pass after all roles are provisioned (config order doesn’t matter), already-granted memberships are skipped, nothing is ever revoked, andstrategy: createskips memberships for pre-existing roles. Circular memberships among declared roles are rejected at validation. Supported in dry-run mode.
1.3.0 - 2026-07-16
Section titled “1.3.0 - 2026-07-16”bypassrlsrole option —roles[].options.bypassrlsnow emitsBYPASSRLS/NOBYPASSRLSonCREATE ROLE/ALTER ROLE, allowing a role to bypass row-level security policies.inherit,replication, andvalid_untilrole options —roles[].optionsnow also supportsinherit(INHERIT/NOINHERIT),replication(REPLICATION/NOREPLICATION), andvalid_until(VALID UNTIL '<timestamp>', password expiry), applied onCREATE ROLE/ALTER ROLE.
1.2.0 - 2026-07-12
Section titled “1.2.0 - 2026-07-12”- Per-database create options (#32) — the
databases[].optionsblock now supportsencoding,locale,lc_collate,lc_ctype, andtemplate, applied atCREATE DATABASEtime (e.g.LC_COLLATE 'C' TEMPLATE template0, required by collation-sensitive apps like Synapse). Options are create-time only: on an existing database they are ignored and a warning is logged, since Postgres cannotALTERthem.localeis rejected when combined withlc_collate/lc_ctype.
- Connection DSN credentials are now percent-encoded (#32) —
ConnConfig.DSN()builds the connection URL vianet/urlinstead of raw string concatenation. Admin/user passwords containing URL-special characters (e.g.@or:) previously corrupted the parsed host/port (invalid port), making the provisioner unable to connect. IPv6 hosts are bracketed and the database name is escaped (including/) so it round-trips intact. - Connection errors no longer leak credentials into logs (#32) — the connection-retry
WARNline now passes the driver error throughredactConnError, which masks the userinfo section of any embedded connection URL and the raw/escaped password value before logging. The escaped-value match uses the same userinfo encoding as the DSN (e.g. a space as%20), so it also catches truncated parse errors that don’t include the full URL.
1.1.0 - 2026-04-25
Section titled “1.1.0 - 2026-04-25”--dry-runmode (#24) — new CLI flag--dry-runand env varDRY_RUNthat logs every mutation as a preview without executing it. Read-only queries (existence checks, applied-migration lookups) still run so the preview reflects live state.- Migrations report their full plan at INFO level (
would apply,would re-execute repeatable migration,migration already applied — would skip) without creating the tracking table or applying any files. A missing_schema_migrationstable (Postgres SQLSTATE42P01) is treated as zero applied migrations; any other error from the tracking-table query propagates so a misleading plan can’t be produced from a hidden permission or connectivity failure. - When dry-run previews a database creation,
Run()skips the per-databaseConnectToDatabasestep (which would otherwise block on the 5-minute connection-retry timeout) and logs a summary of the extensions/schemas/grants/migrations that would be provisioned. Existing-database dry-runs continue to preview sub-resources via the real connection. PASSWORD '...'literals in the logged SQL are redacted to'***REDACTED***'(case-insensitive, handles Postgres-style''escaping inside string literals).
- Migrations report their full plan at INFO level (
Changed
Section titled “Changed”grantOnTablesInSchemais now transactional (#24) — the three coupled statements (GRANT ... ON ALL TABLES IN SCHEMAplus twoALTER DEFAULT PRIVILEGES) run inside a singleBeginTx/Commit. A failure in statement 2 or 3 no longer leaves the schema with grants on existing tables but missing default privileges for newly created ones; the transaction is rolled back instead.- Go dependencies updated (#23) — direct:
github.com/lib/pq1.11.2 → 1.12.3,github.com/testcontainers/testcontainers-goand/modules/postgres0.40.0 → 0.42.0. Indirect highlights: OpenTelemetry 1.38 → 1.43, golangci-lint tool 2.8.0 → 2.11.4.denis-tingaikin/go-headerandgo-simpler.org/sloglintare pinned at the versions golangci-lint v2.11.4 expects (later releases break its source-level imports). - GitHub Actions updated (#23):
codecov/codecov-actionv4 → v6actions/upload-artifactv4 → v7aquasecurity/trivy-action@master→@v0.36.0(pinned)docker/setup-buildx-actionv3 → v4docker/build-push-actionv6 → v7docker/login-actionv3 → v4docker/metadata-actionv5 → v6goreleaser/goreleaser-actionv6 → v7
Internal
Section titled “Internal”- Package-level mutators (
createRole,alterRole,createDatabase,alterDatabaseOwner,grantOn*) refactored to methods on*Provisionerand routed through a singleexecMutationhelper that switches onOptions.DryRun. Reads remain package-level functions.
1.0.0 - 2026-02-20
Section titled “1.0.0 - 2026-02-20”Initial release.
Docs for v2.0.0 · built from the release tag