Terraform modules for PostgreSQL RBAC: roles, grants, and users

Published: 2026-02-25

PostgreSQL role management is one of those things that starts as a few manual GRANT statements and ends up as an undocumented mess that nobody dares touch. Terraform with the cyrilgdn/postgresql provider turns it into versioned, reviewable, idempotent infrastructure code.


Why Terraform for database access

The problem with manual grants:

  • No audit trail of who added what
  • Can't diff "desired vs current state"
  • Copy-paste errors between environments
  • Off-boarding is guesswork

With Terraform: every user, every role, every grant is in Git. Access changes go through code review. terraform plan shows exactly what will change before applying.


Provider setup

hcl# providers.tf
terraform {
  required_providers {
    postgresql = {
      source  = "cyrilgdn/postgresql"
      version = "1.22.0"
    }
  }
  required_version = ">= 0.13"
}

provider "postgresql" {
  host            = var.db_host
  port            = var.db_port
  database        = var.db_database
  username        = var.db_admin_user
  password        = var.db_admin_pass
  sslmode         = var.db_sslmode
  connect_timeout = var.db_connect_timeout
}

Credentials come from a terraform.tfvars file that lives outside the repo, or from environment variables (TF_VAR_db_admin_pass). Never commit passwords.

The admin user needs SUPERUSER or at least CREATEROLE + CREATEDB privileges.


Module: db_role

A reusable module that creates a group role and attaches privileges to it:

hcl# modules/db_role/variables.tf
variable "role_name" {}
variable "database" {}
variable "schema" { default = "public" }
variable "privileges" { type = list(string) }
variable "schema_owner" { default = "postgres" }
hcl# modules/db_role/main.tf

resource "postgresql_role" "group" {
  name  = var.role_name
  login = false   # group roles don't log in directly
}

resource "postgresql_grant" "connect" {
  database    = var.database
  role        = postgresql_role.group.name
  object_type = "database"
  privileges  = ["CONNECT"]
}

resource "postgresql_grant" "schema_usage" {
  database    = var.database
  role        = postgresql_role.group.name
  schema      = var.schema
  object_type = "schema"
  privileges  = ["USAGE"]
}

resource "postgresql_grant" "tables" {
  database    = var.database
  role        = postgresql_role.group.name
  schema      = var.schema
  object_type = "table"
  objects     = []   # empty = all existing tables
  privileges  = var.privileges
}

resource "postgresql_default_privileges" "future_tables" {
  database    = var.database
  role        = postgresql_role.group.name
  schema      = var.schema
  object_type = "table"
  privileges  = var.privileges
  owner       = var.schema_owner
}

postgresql_grant with objects = [] grants on all existing tables. postgresql_default_privileges covers all future tables created under that schema. Both are needed — forgetting default_privileges is the most common bug.

Without default_privileges, users lose access to newly created tables and developers spend hours debugging "permission denied" errors.


Module: db_user

hcl# modules/db_user/variables.tf
variable "username" {}
variable "password" { sensitive = true }
variable "member_of" { type = list(string) }
hcl# modules/db_user/main.tf

resource "postgresql_role" "user" {
  name     = var.username
  password = var.password
  login    = true
}

resource "postgresql_grant_role" "membership" {
  for_each   = toset(var.member_of)
  role       = each.value
  grant_role = postgresql_role.user.name
}

Putting it together

hcl# main.tf

module "role_rw" {
  source     = "./modules/db_role"
  role_name  = "rw"
  database   = "postgres"
  privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"]
}

module "role_ro" {
  source     = "./modules/db_role"
  role_name  = "ro"
  database   = "postgres"
  privileges = ["SELECT"]
}

module "alice" {
  source    = "./modules/db_user"
  username  = "alice"
  password  = var.alice_password
  member_of = ["rw"]
}

module "bob" {
  source    = "./modules/db_user"
  username  = "bob"
  password  = var.bob_password
  member_of = ["ro"]
}

Adding a new team member is a three-line change: add a module block, set member_of to the appropriate role, add a password variable. Reviewable PR, auditable history.

Role design

Keep roles simple:

Role Privileges
ro SELECT
rw SELECT, INSERT, UPDATE, DELETE
ddl rw + CREATE, DROP (for migrations)
app rw (assigned to application users)

Assign DDL rights only to the migration user, never to the application user.


Backend: remote state

For a team setup, store state in an S3-compatible backend:

hcl# backend.tf
terraform {
  backend "s3" {
    bucket                      = "terraform-state"
    key                         = "postgresql/terraform.tfstate"
    endpoint                    = "https://storage.yandexcloud.net"
    region                      = "ru-central1"
    skip_credentials_validation = true
    skip_requesting_account_id  = true
    skip_metadata_api_check     = true
    force_path_style            = true
  }
}

Two engineers running terraform apply simultaneously will corrupt the state without locking. Use a state backend that supports locking.


Passwords from Vault

Instead of storing passwords in tfvars, pull them from Vault using the Vault provider:

hcldata "vault_generic_secret" "db_users" {
  path = "secret/infra/postgresql"
}

module "alice" {
  source    = "./modules/db_user"
  username  = "alice"
  password  = data.vault_generic_secret.db_users.data["alice_password"]
  member_of = ["rw"]
}

This way no password ever appears in the terraform state file (which is plaintext JSON).


CI integration

yaml# .gitlab-ci.yml
terraform-plan:
  image: hashicorp/terraform:1.8
  script:
    - terraform init
    - terraform plan -out=tfplan
  artifacts:
    paths: [tfplan]

terraform-apply:
  image: hashicorp/terraform:1.8
  script:
    - terraform init
    - terraform apply tfplan
  when: manual
  needs: [terraform-plan]

Plan runs in MR. Apply is manual after review. Database access changes should always go through a human gate.


Troubleshooting

bash# Check current grants in PostgreSQL
psql -U postgres -c "\dp orders.*"

# List role memberships
psql -U postgres -c "\du"

# Check default privileges
psql -U postgres -c "\ddp"

# Import existing user into Terraform state (avoid re-create)
terraform import module.alice.postgresql_role.user alice

Common issue: if you add a table manually without Terraform, the postgresql_grant resource won't automatically grant access to it. Run terraform apply to sync grants, or enable postgresql_default_privileges to handle future tables automatically.