+1 (415) 997-4269

Managing Snowflake with Terraform: Provider v2, RBAC as Code, and Importing an Existing Account

Most Snowflake accounts start life as a pile of SQL someone ran in a worksheet. It works, until you need a second account, an audit trail of who created that ACCOUNTADMIN-owned warehouse, or a dev environment that actually matches prod. Terraform answers all three, and the official snowflakedb/snowflake provider reached v1 and then v2 with a much stricter, saner resource model than the community-era 0.x releases.

This tutorial builds a small but realistic Snowflake footprint with Terraform: an environment-scoped database, warehouses, a functional/access role model, and a service user authenticating with a key pair. It also covers the part teams get wrong — what belongs in Terraform, and what should stay in SQL migrations.

What belongs in Terraform (and what does not)

Terraform is a good fit for objects that are long-lived, few, and privileged. It is a bad fit for objects that change with every pull request.

Put in TerraformKeep in SQL migrations (dbt, Schemachange, EXECUTE IMMEDIATE FROM)
Accounts, databases, schemas (the containers)Tables, views, dynamic tables, streams, tasks
Warehouses and resource monitorsStored procedures and UDFs
Roles, role hierarchies, account-level grantsRow-level data and backfills
Users, service users, key pairs, network policiesMasking policy bodies you iterate on daily
Integrations: storage, external volumes, catalog, API, notificationAnything generated per-model by dbt

The dividing line: if a data engineer should be able to change it without a platform review, it does not go in Terraform.

Provider setup and authentication

Never point Terraform at a password-authenticated human user. Create a dedicated service user with a key pair (Snowflake now blocks single-factor password auth for programmatic users anyway).

USE ROLE ACCOUNTADMIN;

CREATE USER terraform_svc
  TYPE = SERVICE
  RSA_PUBLIC_KEY = 'MIIBIjANBgkq...'
  DEFAULT_ROLE = TERRAFORM_ADMIN
  COMMENT = 'Terraform state applier';

CREATE ROLE terraform_admin;
GRANT ROLE terraform_admin TO USER terraform_svc;

-- Deliberately narrower than ACCOUNTADMIN
GRANT CREATE DATABASE, CREATE WAREHOUSE, CREATE ROLE, CREATE USER,
      CREATE INTEGRATION, EXECUTE TASK, MANAGE GRANTS
  ON ACCOUNT TO ROLE terraform_admin;

Then the provider block:

terraform {
  required_version = ">= 1.6"
  required_providers {
    snowflake = {
      source  = "snowflakedb/snowflake"
      version = "~> 2.0"
    }
  }
  backend "s3" {
    bucket = "acme-tfstate"
    key    = "snowflake/prod.tfstate"
    region = "us-east-1"
  }
}

provider "snowflake" {
  organization_name = var.snowflake_org
  account_name      = var.snowflake_account
  user              = "TERRAFORM_SVC"
  authenticator     = "SNOWFLAKE_JWT"
  private_key       = var.snowflake_private_key   # from Vault / GitHub secret, never committed
  role              = "TERRAFORM_ADMIN"
  warehouse         = "WH_TERRAFORM_XS"
  preview_features_enabled = ["snowflake_table_resource"]  # opt in explicitly, per feature
}

Two v2-era details worth knowing:

  • organization_name + account_name replaced the old account locator style.
  • Preview resources are gated. If a resource errors with "unknown preview feature", you add it to preview_features_enabled — which is a useful signal that the resource's schema may still change.

Use a remote backend with state locking from day one. Two engineers applying Snowflake changes against local state will produce grant drift you cannot reason about.

Databases, schemas, warehouses

variable "env" { type = string }   # dev | test | prod

locals {
  suffix = upper(var.env)
}

resource "snowflake_database" "analytics" {
  name                        = "ANALYTICS_${local.suffix}"
  comment                     = "Curated analytics, managed by Terraform"
  data_retention_time_in_days = var.env == "prod" ? 7 : 1
}

resource "snowflake_schema" "marts" {
  database            = snowflake_database.analytics.name
  name                = "MARTS"
  with_managed_access = true   # only the schema owner can grant on objects inside
}

resource "snowflake_warehouse" "transform" {
  name                                = "WH_TRANSFORM_${local.suffix}"
  warehouse_size                      = var.env == "prod" ? "MEDIUM" : "XSMALL"
  resource_constraint                 = "STANDARD_GEN_2"   # Gen2 compute
  auto_suspend                        = 60
  auto_resume                         = true
  initially_suspended                 = true
  statement_timeout_in_seconds        = 3600
  statement_queued_timeout_in_seconds = 300
}

resource "snowflake_resource_monitor" "transform_budget" {
  name         = "RM_TRANSFORM_${local.suffix}"
  credit_quota = var.env == "prod" ? 500 : 50
  notify_triggers = [80, 90]
  suspend_trigger = 100
}

resource "snowflake_warehouse_resource_monitor" "transform" {
  warehouse_name   = snowflake_warehouse.transform.name
  resource_monitor = snowflake_resource_monitor.transform_budget.name
}

Setting statement_timeout_in_seconds on the warehouse rather than the account is the single cheapest runaway-query guard you can put in code.

RBAC as code: functional roles, access roles, grants

The provider's v1+ grant resources are authoritative for what they declare, which is exactly what you want for a role model. Keep the two-layer pattern: access roles hold privileges, functional roles are granted to people and services.

# Access roles: privileges on one schema
resource "snowflake_account_role" "marts_read" {
  name = "AR_ANALYTICS_${local.suffix}_MARTS_R"
}

resource "snowflake_grant_privileges_to_account_role" "marts_read_db" {
  account_role_name = snowflake_account_role.marts_read.name
  privileges        = ["USAGE"]
  on_account_object {
    object_type = "DATABASE"
    object_name = snowflake_database.analytics.name
  }
}

resource "snowflake_grant_privileges_to_account_role" "marts_read_schema" {
  account_role_name = snowflake_account_role.marts_read.name
  privileges        = ["USAGE"]
  on_schema {
    schema_name = "\"${snowflake_database.analytics.name}\".\"MARTS\""
  }
}

# Future + existing grants so new models are readable without a re-apply
resource "snowflake_grant_privileges_to_account_role" "marts_read_tables" {
  account_role_name = snowflake_account_role.marts_read.name
  privileges        = ["SELECT"]
  on_schema_object {
    future {
      object_type_plural = "TABLES"
      in_schema          = "\"${snowflake_database.analytics.name}\".\"MARTS\""
    }
  }
}

# Functional role: what an analyst actually gets
resource "snowflake_account_role" "analyst" {
  name = "FR_ANALYST_${local.suffix}"
}

resource "snowflake_grant_account_role" "analyst_gets_marts_read" {
  role_name        = snowflake_account_role.marts_read.name
  parent_role_name = snowflake_account_role.analyst.name
}

Note the quoting: schema and object names in grant resources are fully qualified and case-sensitive, so "ANALYTICS_PROD"."MARTS" with explicit double quotes avoids a whole family of perpetual-diff bugs. Also declare future and on_all grants if you are adopting an existing schema — future only affects objects created later.

Importing an account you did not create

Almost nobody starts greenfield. Terraform 1.5+ lets you declare imports in config rather than running one-off CLI commands, which means the import itself is reviewable:

import {
  to = snowflake_warehouse.transform
  id = "WH_TRANSFORM_PROD"
}

import {
  to = snowflake_database.analytics
  id = "ANALYTICS_PROD"
}

Then terraform plan -generate-config-out=generated.tf gives you a starting HCL block to clean up. A sane adoption order:

  1. Warehouses and resource monitors (low blast radius, immediate cost win).
  2. Databases and schemas (containers only — do not import tables).
  3. Roles and grants, one functional role at a time, verifying the plan is empty after each.
  4. Users, network policies, and integrations last, because a bad apply here locks people out.

Expect the first plan on an imported account to be ugly. Resolve every diff to empty before you merge; a permanently non-empty plan trains reviewers to ignore plans.

CI/CD wiring

# .github/workflows/snowflake.yml (abridged)
jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -backend-config="key=snowflake/${{ matrix.env }}.tfstate"
      - run: terraform plan -var-file=envs/${{ matrix.env }}.tfvars -out=tf.plan
      - run: terraform show -no-color tf.plan >> "$GITHUB_STEP_SUMMARY"
    env:
      TF_VAR_snowflake_private_key: ${{ secrets.SNOWFLAKE_TF_KEY }}

Plan on every pull request, apply only on merge to main, and apply devtestprod as separate workspaces or state keys driven by the same modules. Pair it with your SQL deployment pipeline (Snowflake Git integration and CREATE OR ALTER for schema objects) so platform changes and model changes travel through the same review process without fighting over ownership of the same objects.

Guardrails that save you later

  • Never let Terraform manage tables holding production data unless you have accepted that a resource rename can drop them. Set lifecycle { prevent_destroy = true } on databases and warehouses.
  • Run terraform plan on a schedule (nightly) and alert on non-empty output — that is your drift detector for worksheet changes.
  • Keep secrets out of state where possible; Snowflake object definitions in state are still sensitive, so encrypt the backend and restrict access to it.
  • Version-pin the provider. The 0.x → 1.x → 2.x migrations included deliberate breaking changes to grants and to the user resource; read the migration guide before bumping.
  • Use one state per environment, not one giant state with count over environments. Blast radius matters more than DRY.

Where this leaves you

After a week of work a typical team has: warehouses with enforced auto-suspend and budgets, a role model that can be rebuilt into a fresh account from source, service users on key pairs, and a nightly drift alert. That is the foundation every later thing — governance policies, cost attribution, multi-account replication — is built on.

PowderInsights builds Snowflake platform automation: Terraform module design, RBAC-as-code, and CI/CD for accounts already in production. Get in touch with your current setup and we will tell you what to automate first.