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 Terraform | Keep in SQL migrations (dbt, Schemachange, EXECUTE IMMEDIATE FROM) |
|---|---|
| Accounts, databases, schemas (the containers) | Tables, views, dynamic tables, streams, tasks |
| Warehouses and resource monitors | Stored procedures and UDFs |
| Roles, role hierarchies, account-level grants | Row-level data and backfills |
| Users, service users, key pairs, network policies | Masking policy bodies you iterate on daily |
| Integrations: storage, external volumes, catalog, API, notification | Anything 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_namereplaced the oldaccountlocator 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:
- Warehouses and resource monitors (low blast radius, immediate cost win).
- Databases and schemas (containers only — do not import tables).
- Roles and grants, one functional role at a time, verifying the plan is empty after each.
- 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 dev → test → prod 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 planon 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
countover 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.