Terraform for Data Platforms: Modules for Warehouses, Not Click-Ops Grants
Click-ops Snowflake grants will not exist in the next account. Modules for warehouse plus IAM, secrets out of state, UI to explore, Terraform for anything that must last.
By Dinesh Chandra
Table of contents
A vendor POC needed a Snowflake account “by Friday.” I clicked
warehouses, roles, and grants for three days. ACCOUNTADMIN stayed
on a shared user. Six months later that POC was production. An
audit asked us to recreate the grant graph. Nobody could. Forty
warehouses were idle. The next account we built from Terraform in
two hours. The first one we still cannot explain.
Click-ops is fine for a tour. It is how mystery roles and orphan compute show up in every warehouse I inherit. The unit of work is a plan someone can review, not a screenshot of the admin UI.
One module: warehouse plus IAM
I do not apply a warehouse in one PR and “add grants later.” Later
is how TRANSFORMER gets SELECT on ANALYTICS from a human in
the UI and never appears in git. The module takes a warehouse size,
auto-suspend, a list of roles, and the databases they may use.
flowchart TD
pr["Module PR"] --> plan["terraform plan"]
plan --> apply["apply prod"]
apply --> wh["Warehouse"]
apply --> role["Role + grants"]
apply --> iam["Cloud IAM / SP"]
ui["Console click"] --> drift["Drift vs state"]
drift --> pr
Explore in the UI. Promote through a module. Drift comes back as a plan, not tribal knowledge.
# modules/snowflake_wh/main.tf — shape I actually use
resource "snowflake_warehouse" "this" {
name = var.name
warehouse_size = var.size
auto_suspend = 60
auto_resume = true
comment = var.comment
}
resource "snowflake_account_role" "usage" {
name = "${var.name}_USAGE"
comment = "Runtime role for ${var.name}"
}
resource "snowflake_grant_privileges_to_account_role" "wh" {
account_role_name = snowflake_account_role.usage.name
privileges = ["USAGE"]
on_account_object {
object_type = "WAREHOUSE"
object_name = snowflake_warehouse.this.name
}
}
Call it twice: transform_m and bi_s. dbt gets the first.
Analysts get the second. Sharing one Huge “for convenience” is how
a dashboard and a backfill fight, the same neighbor problem I see
on Fabric CUs and on
AWS warehouses-as-a-service.
The module makes the second warehouse cheaper than another Slack
thread.
State lives in a locked backend (S3 + Dynamo, Azure blob + lease,
GCS + prefix). Who may apply prod is a CI role, not every data
engineer’s laptop. I review plans that touch grants the way I
review a firewall change.
Secrets are not in .tf and barely in state
A password = var.okta_client_secret still lands in state. I use
the secret manager data source or a vault provider and I still
treat state as sensitive: encryption, tight IAM, no state in Slack.
Service users get key-pair or workload identity, not a password
someone pasted into Terraform Cloud in 2024.
# Do not do this.
# variable "snowflake_password" { sensitive = true }
# provider "snowflake" { password = var.snowflake_password }
# Do this shape: key pair from a manager, or OIDC to the provider.
data "aws_secretsmanager_secret_version" "keypair" {
secret_id = "prod/snowflake/tf-deployer"
}
Rotate by replacing the secret version and re-applying. Do not leave the old password in an old state file on a laptop.
UI to explore, Terraform to keep
I still click a new Snowflake feature on a sandbox. If we keep it,
it becomes a resource or it gets destroyed. The rule I write in the
README: if it must exist next quarter, it is in the module.
Temporary warehouses get a ttl tag and a janitor. ACCOUNTADMIN
is break-glass, not a daily role.
OpenTofu versus Terraform is not a grant PR. Pick what the org licensed. Data engineers write modules for schemas, warehouses, and job IAM. Platform owns the account, network, and backend. A hard split with no PR path is how click-ops returns through the side door.
# Import the click-ops leftover, then plan until empty.
# Recreating a production database to “get it in code” is not
# a migration.
# terraform import snowflake_warehouse.transform_m TRANSFORM_M
resource "snowflake_tag" "ttl" {
name = "ttl"
database = snowflake_database.ops.name
schema = "PUBLIC"
}
resource "snowflake_tag_association" "sandbox_wh" {
object_type = "WAREHOUSE"
object_name = snowflake_warehouse.sandbox.name
tag_id = snowflake_tag.ttl.id
tag_value = "2026-09-30"
}
I run a weekly janitor that lists warehouses without a module
address and warehouses past ttl. Forty idle warehouses was not
a Snowflake problem. It was a missing destroy path.
Pitfalls
A 4,000-line main.tf. Nobody reviews it. Split by warehouse
or by domain. One module, one apply blast radius.
Secrets in variables “because they are sensitive.” Sensitive hides them from the terminal. State still has them. Use a manager and key-pair or OIDC.
Laptop apply to prod. The plan that ran is not the plan that was reviewed. CI apply, locked backend, two people on grant changes.
UI grant on Friday, ticket on Monday. Monday never comes. If the grant must exist next quarter, it is a PR the same day or it is revoked.
Data team blocked from the repo. They will click. Give them a module and a PR path for schemas and job roles.
The POC account taught me the cost of “just this once.” The
rebuild taught me the cost of a 4,000-line main.tf. Small
modules, one warehouse plus its grants, secrets out of the
arguments, apply through CI. That is the whole practice. The
console is a flashlight. It is not the source of truth.
Enjoyed this post?
Get the next one in your inbox — one email a week, no spam.
Newsletter signup is not live yet. Use the contact form if you want to be notified.