DataLane
4 min readTerraform

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

Illustrated overview of Terraform for Data Platforms: Modules for Warehouses, Not Click-Ops Grants
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.

Share this post:X / TwitterLinkedIn

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.

↑↓ navigate openesc close