RSS →
ARTICLES · TERRAFORM

Terraform pour Snowflake : déployer une plateforme complète en une commande

J'ai hérité d'une plateforme Snowflake chez un client où tout avait été créé à la main : databases, schemas, warehouses, roles, grants, pipes, integrations. Le SHOW GRANTS renvoyait 340 lignes. Personne ne savait qui avait créé quoi, quand, ni pourquoi. Le jour où il a fallu recréer l'environnement de recette à l'identique, l'équipe a passé trois semaines à reverse-engineerer la prod. Trois semaines. Depuis, je ne crée plus rien à la main sur Snowflake. Tout passe par Terraform. Voici comment.

Terraform pour Snowflake

Cet article utilise le provider Terraform Snowflake v2.18.0. Le provider v1 (sorti en janvier 2025) introduisait plusieurs limitations : authentification par mot de passe, ressources RBAC monolithiques, pas de gestion des utilisateurs. Le provider v2, disponible depuis début 2026, corrige tout ça avec des ressources renommées, l'authentification JWT par clé RSA, et un nouveau provider USERADMIN. Cet article reflète la pratique que j'utilise en production aujourd'hui.

Pourquoi Terraform sur Snowflake

La question légitime : "Snowflake a déjà un SQL pour tout faire, pourquoi ajouter Terraform ?" Parce que le SQL n'est pas versionné, pas reviewable, pas reproductible, et pas auditable.

ACTION SQL À LA MAIN TERRAFORM
Créer un warehouse CREATE WAREHOUSE dans un onglet terraform apply
Savoir qui a modifié le RBAC Chercher dans ACCESS_HISTORY git log
Recréer la recette à l'identique Reverse-engineerer la prod terraform apply
Ajouter un schéma en CI/CD Script SQL manuel Pull request → merge → apply
Audit conformité SHOW GRANTS (340 lignes) terraform plan (lisible)
Détecter un drift Impossible sans script terraform plan le signale

Le provider Terraform Snowflake v2

Le provider officiel snowflakedb/snowflake a atteint la version 2.0 début 2026, avec des changements majeurs que je détaillerai au fil de l'article. La version 2.18.0 couvre désormais : databases, schemas, warehouses, account roles (nouveau nom), grants, users, pipes, stages, integrations, masks, tags, policies.

Installation

# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    snowflake = {
      source  = "snowflakedb/snowflake"
      version = "~> 2.18.0"
    }
  }
}

Authentification : fini le mot de passe, place à la clé RSA

Le plus gros changement du provider v2 côté authentification : l'authentification par mot de passe est dépréciée. Le mode recommandé est désormais SNOWFLAKE_JWT avec une clé RSA. C'est plus sécurisé, plus reproductible, et indispensable pour la CI/CD.

Générer la clé RSA

# Générer une clé RSA 2048 bits
openssl genrsa -out snowflake_key.p8 2048

# Extraire la clé publique
openssl rsa -in snowflake_key.p8 -pubout -out snowflake_key.pub

# Récupérer la clé publique au format one-line (sans header/footer)
grep -v -- '----' snowflake_key.pub | tr -d '\n'

Configurer l'utilisateur Terraform dans Snowflake

-- Une seule fois, à la main (ou via un script d'initialisation)
CREATE USER TF_ADMIN
  TYPE = SERVICE
  RSA_PUBLIC_KEY = 'MIIBIjANBgkqh...';  -- la clé publique one-line

-- Le rôle dédié à Terraform (pas ACCOUNTADMIN !)
CREATE ROLE TF_ADMIN;
GRANT ROLE TF_ADMIN TO USER TF_ADMIN;

-- TF_ADMIN doit pouvoir assumer les rôles qu'il gère
GRANT ROLE SYSADMIN TO ROLE TF_ADMIN;
GRANT ROLE SECURITYADMIN TO ROLE TF_ADMIN;
GRANT ROLE USERADMIN TO ROLE TF_ADMIN;

-- Privilège CREATE INTEGRATION pour les storage et notification integrations
-- (CREATE INTEGRATION est détenu par défaut par ACCOUNTADMIN mais peut être délégué)
GRANT CREATE INTEGRATION ON ACCOUNT TO ROLE TF_ADMIN;

⚠️ Le rôle du provider : Terraform doit utiliser un rôle avec les privilèges suffisants pour créer les ressources. En production, je crée un rôle TF_ADMIN dédié (pas ACCOUNTADMIN), avec CREATE sur databases, warehouses, roles, et CREATE INTEGRATION sur le compte pour les storage et notification integrations. Ce rôle n'existe que pour Terraform — aucun humain ne l'utilise.

Le pattern des 3 providers

C'est le pattern que je pose sur tous mes projets avec le provider v2. Terraform utilise trois providers avec des rôles différents, parce que le modèle RBAC Snowflake sépare rigoureusement les responsabilités :

# providers.tf

# SYSADMIN : databases, schemas, warehouses, pipes, stages, integrations
provider "snowflake" {
  alias = "sysadmin"

  organization_name = var.snowflake_org
  account_name      = var.snowflake_account
  user              = "TF_ADMIN"
  authenticator     = "SNOWFLAKE_JWT"
  private_key       = file("snowflake_key.p8")
  role              = "SYSADMIN"
}

# SECURITYADMIN : grants de privilèges et héritage de rôles
provider "snowflake" {
  alias = "securityadmin"

  organization_name = var.snowflake_org
  account_name      = var.snowflake_account
  user              = "TF_ADMIN"
  authenticator     = "SNOWFLAKE_JWT"
  private_key       = file("snowflake_key.p8")
  role              = "SECURITYADMIN"
}

# USERADMIN : création des rôles et des utilisateurs
provider "snowflake" {
  alias = "useradmin"

  organization_name = var.snowflake_org
  account_name      = var.snowflake_account
  user              = "TF_ADMIN"
  authenticator     = "SNOWFLAKE_JWT"
  private_key       = file("snowflake_key.p8")
  role              = "USERADMIN"
}

Pourquoi pas de provider ACCOUNTADMIN ? Le privilège CREATE INTEGRATION — bien que détenu par défaut uniquement par ACCOUNTADMINpeut être délégué à un autre rôle. En accordant GRANT CREATE INTEGRATION ON ACCOUNT TO ROLE TF_ADMIN, le rôle SYSADMIN (que TF_ADMIN hérite) peut créer les storage et notification integrations directement. Quant aux resource monitors, la doc Snowflake est claire : seuls les account administrators peuvent les créer, et ce privilège ne peut pas être délégué. Je préfère donc les laisser hors Terraform et les créer manuellement via SQL — c'est un objet compte-level qui change rarement.

Nouveau en v2 : USERADMIN. Dans le provider v1, SECURITYADMIN faisait tout (création de rôles + grants). En v2, on sépare : USERADMIN crée les rôles et les users, SECURITYADMIN accorde les privilèges et l'héritage. C'est aligné avec le modèle de gouvernance Snowflake, où USERADMIN est le seul à pouvoir créer des rôles.

Dans les modules, tu spécifies quel provider gère quelles ressources :

# Databases et warehouses → SYSADMIN
module "platform" {
  source = "./modules/platform"
  providers = {
    snowflake = snowflake.sysadmin
  }
}

# Création des rôles → USERADMIN
module "rbac_roles" {
  source = "./modules/rbac_roles"
  providers = {
    snowflake = snowflake.useradmin
  }
}

# Grants et héritage → SECURITYADMIN
module "rbac_grants" {
  source = "./modules/rbac_grants"
  providers = {
    snowflake = snowflake.securityadmin
  }

  depends_on = [
    module.platform,
    module.rbac_roles,
  ]
}

# Users → USERADMIN
module "users" {
  source = "./modules/users"
  providers = {
    snowflake = snowflake.useradmin
  }
}

# Ingestion (pipes, stages, integrations) → SYSADMIN
module "ingestion" {
  source = "./modules/ingestion"
  providers = {
    snowflake = snowflake.sysadmin
  }

  depends_on = [module.platform]
}

Le depends_on sur rbac_grants est crucial : on ne peut pas accorder des privilèges sur des objets qui n'existent pas encore, ni accorder un héritage sur des rôles pas encore créés.

Sans cette séparation, Terraform tente de créer une database avec SECURITYADMIN et échoue. Cette erreur, tout le monde la fait au début.

La structure de projet que j'utilise

terraform-snowflake/
├── environments/
│   ├── dev/
│   │   ├── main.tf          # compose les modules
│   │   ├── variables.tf
│   │   ├── terraform.tfvars  # valeurs spécifiques dev
│   │   └── backend.tf        # remote state (S3 / Azure Storage)
│   ├── recette/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── terraform.tfvars
│   │   └── backend.tf
│   └── prod/
│       ├── main.tf
│       ├── variables.tf
│       ├── terraform.tfvars
│       └── backend.tf
├── modules/
│   ├── platform/            # databases, schemas, warehouses
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── rbac_roles/          # création des rôles (USERADMIN)
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   ├── rbac_grants/         # grants, héritage, future grants (SECURITYADMIN)
│   │   ├── main.tf
│   │   └── variables.tf
│   ├── users/               # création des users + attribution (USERADMIN)
│   │   ├── main.tf
│   │   └── variables.tf
│   ├── ingestion/           # pipes, stages, integrations
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── security/            # masks, tags, policies
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── versions.tf
└── providers.tf

Évolution depuis le v1 : le module rbac unique est split en trois modules (rbac_roles, rbac_grants, users), chacun avec son provider. C'est plus propre, plus maintenable, et ça évite les conflits de permissions.

Un environnement = un dossier = un state. Le state dev ne peut pas toucher la prod. Et chaque environnement a ses propres tfvars (taille de warehouse, rétention, nombre de clusters, liste des utilisateurs).

Le module platform : databases, schemas, warehouses

C'est le squelette de la plateforme. Je le pose identique sur chaque projet, avec les conventions de nommage :

# modules/platform/main.tf

# --- Databases (architecture médaillon) ---

resource "snowflake_database" "bronze" {
  name    = "BRONZE_${var.env_suffix}"
  comment = "Raw layer - ingestion without transformation"
}

resource "snowflake_database" "silver" {
  name    = "SILVER_${var.env_suffix}"
  comment = "Cleaned + conformed layer"
}

resource "snowflake_database" "gold" {
  name    = "GOLD_${var.env_suffix}"
  comment = "Business marts - ready for BI"
}

# --- Schemas par domaine ---

resource "snowflake_schema" "bronze_raw" {
  database = snowflake_database.bronze.name
  name     = "RAW"
  comment  = "All raw ingested data"
}

resource "snowflake_schema" "silver_sales" {
  database = snowflake_database.silver.name
  name     = "SALES"
  comment  = "Cleaned sales data"
}

resource "snowflake_schema" "gold_reporting" {
  database = snowflake_database.gold.name
  name     = "REPORTING"
  comment  = "Business marts for BI"
}

# --- Warehouses ---

resource "snowflake_warehouse" "ingestion" {
  name                = "INGEST_WH_${var.env_suffix}"
  warehouse_size      = "X-SMALL"
  auto_suspend        = 60
  initially_suspended = true
  comment             = "Snowpipe + Airbyte ingestion"
}

resource "snowflake_warehouse" "transformation" {
  name                = "TRANSFORM_WH_${var.env_suffix}"
  warehouse_size      = "SMALL"
  auto_suspend        = 60
  initially_suspended = true
  comment             = "dbt transformations"
}

resource "snowflake_warehouse" "bi" {
  name                = "BI_WH_${var.env_suffix}"
  warehouse_size      = "X-SMALL"
  auto_suspend        = 60
  initially_suspended = true
  comment             = "Power BI / Looker queries"
}

Le piège du auto_suspend

# ⛔ Mauvais : warehouse qui tourne 24/7
resource "snowflake_warehouse" "bi" {
  name           = "BI_WH"
  warehouse_size = "SMALL"
  # auto_suspend absent = warehouse jamais suspendu = facture qui explose
}

# ✅ Bon : auto_suspend à 60s + initially_suspended
resource "snowflake_warehouse" "bi" {
  name                = "BI_WH_${var.env_suffix}"
  warehouse_size      = "SMALL"
  auto_suspend        = 60
  initially_suspended = true
}

Si tu as lu mon article sur les 5 erreurs de costing Snowflake, tu sais que l'absence d'auto-suspend est l'erreur n°1. En Terraform, il n'y a pas d'excuse : c'est un paramètre obligatoire.

Le module rbac_roles : création des rôles (USERADMIN)

Si tu as lu mon article sur le RBAC 3 couches, tu connais le modèle. Voici comment le traduire en Terraform v2 :

Breaking change v2 : snowflake_role devient snowflake_account_role. La ressource v1 snowflake_role est dépréciée et sera supprimée dans une future version.

# modules/rbac_roles/main.tf

# --- Access Roles (couche technique) ---

resource "snowflake_account_role" "ar_brz_raw_read" {
  name    = "AR_BRZ_RAW_READ_${var.env_suffix}"
  comment = "Read access on Bronze RAW schema"
}

resource "snowflake_account_role" "ar_slv_sales_write" {
  name    = "AR_SLV_SALES_WRITE_${var.env_suffix}"
  comment = "Write access on Silver SALES schema"
}

resource "snowflake_account_role" "ar_gld_report_read" {
  name    = "AR_GLD_REPORT_READ_${var.env_suffix}"
  comment = "Read access on Gold REPORTING schema"
}

# --- Functional Roles (couche métier) ---

resource "snowflake_account_role" "fr_data_engineer" {
  name    = "FR_DATA_ENGINEER_${var.env_suffix}"
  comment = "Data Engineer — bronze read + silver write + gold read"
}

resource "snowflake_account_role" "fr_data_analyst" {
  name    = "FR_DATA_ANALYST_${var.env_suffix}"
  comment = "Data Analyst — gold read only"
}

Simple, lisible. Chaque rôle est une ressource indépendante, créée par USERADMIN.

Le module rbac_grants : héritage et privilèges (SECURITYADMIN)

C'est ici que la v2 apporte le plus de changements. Deux ressources nouvelles remplacent les anciennes :

Provider v1 Provider v2 Usage
snowflake_role_grants snowflake_grant_account_role Héritage (role → parent) et attribution (role → user)
snowflake_grant_privileges_to_role snowflake_grant_privileges_to_account_role Privilèges sur objets

Héritage : Access Roles → Functional Roles

Breaking change v2 : snowflake_role_grants acceptait une liste de rôles dans roles = [...]. En v2, snowflake_grant_account_role fait un grant par ressource. C'est plus verbeux mais plus précis — et ça évite les race conditions quand Terraform tente de modifier plusieurs grants simultanément.

# modules/rbac_grants/main.tf

# --- Héritage: Access Roles → Functional Roles ---

resource "snowflake_grant_account_role" "fr_data_engineer_inherits_ar_brz" {
  role_name        = snowflake_account_role.ar_brz_raw_read.name
  parent_role_name = snowflake_account_role.fr_data_engineer.name
}

resource "snowflake_grant_account_role" "fr_data_engineer_inherits_ar_slv" {
  role_name        = snowflake_account_role.ar_slv_sales_write.name
  parent_role_name = snowflake_account_role.fr_data_engineer.name
}

resource "snowflake_grant_account_role" "fr_data_engineer_inherits_ar_gld" {
  role_name        = snowflake_account_role.ar_gld_report_read.name
  parent_role_name = snowflake_account_role.fr_data_engineer.name
}

resource "snowflake_grant_account_role" "fr_data_analyst_inherits_ar_gld" {
  role_name        = snowflake_account_role.ar_gld_report_read.name
  parent_role_name = snowflake_account_role.fr_data_analyst.name
}

Future Grants : automatiser les nouveaux objets

# Toute nouvelle table du bronze sera lisible par AR_BRZ_RAW_READ
resource "snowflake_grant_privileges_to_account_role" "ar_brz_future_tables" {
  account_role_name = snowflake_account_role.ar_brz_raw_read.name
  privileges        = ["SELECT"]

  on_schema_object {
    future {
      object_type_plural = "TABLES"
      in_schema           = "\"BRONZE_${var.env_suffix}\".\"RAW\""
    }
  }
}

# Toute nouvelle table du silver sera insérable par AR_SLV_SALES_WRITE
resource "snowflake_grant_privileges_to_account_role" "ar_slv_future_tables" {
  account_role_name = snowflake_account_role.ar_slv_sales_write.name
  privileges        = ["SELECT", "INSERT", "UPDATE", "DELETE"]

  on_schema_object {
    future {
      object_type_plural = "TABLES"
      in_schema           = "\"SILVER_${var.env_suffix}\".\"SALES\""
    }
  }
}

# Toute nouvelle table du gold sera lisible par AR_GLD_REPORT_READ
resource "snowflake_grant_privileges_to_account_role" "ar_gld_future_tables" {
  account_role_name = snowflake_account_role.ar_gld_report_read.name
  privileges        = ["SELECT"]

  on_schema_object {
    future {
      object_type_plural = "TABLES"
      in_schema           = "\"GOLD_${var.env_suffix}\".\"REPORTING\""
    }
  }
}

USAGE grants sur databases et schemas

# --- USAGE sur databases ---

resource "snowflake_grant_privileges_to_account_role" "ar_brz_usage_db" {
  account_role_name = snowflake_account_role.ar_brz_raw_read.name
  privileges        = ["USAGE"]

  on_account_object {
    object_type = "DATABASE"
    object_name = "BRONZE_${var.env_suffix}"
  }
}

resource "snowflake_grant_privileges_to_account_role" "ar_slv_usage_db" {
  account_role_name = snowflake_account_role.ar_slv_sales_write.name
  privileges        = ["USAGE"]

  on_account_object {
    object_type = "DATABASE"
    object_name = "SILVER_${var.env_suffix}"
  }
}

resource "snowflake_grant_privileges_to_account_role" "ar_gld_usage_db" {
  account_role_name = snowflake_account_role.ar_gld_report_read.name
  privileges        = ["USAGE"]

  on_account_object {
    object_type = "DATABASE"
    object_name = "GOLD_${var.env_suffix}"
  }
}

# --- USAGE sur schemas ---

resource "snowflake_grant_privileges_to_account_role" "ar_brz_usage_schema" {
  account_role_name = snowflake_account_role.ar_brz_raw_read.name
  privileges        = ["USAGE"]

  on_schema {
    schema_name = "\"BRONZE_${var.env_suffix}\".\"RAW\""
  }
}

resource "snowflake_grant_privileges_to_account_role" "ar_slv_usage_schema" {
  account_role_name = snowflake_account_role.ar_slv_sales_write.name
  privileges        = ["USAGE"]

  on_schema {
    schema_name = "\"SILVER_${var.env_suffix}\".\"SALES\""
  }
}

resource "snowflake_grant_privileges_to_account_role" "ar_gld_usage_schema" {
  account_role_name = snowflake_account_role.ar_gld_report_read.name
  privileges        = ["USAGE"]

  on_schema {
    schema_name = "\"GOLD_${var.env_suffix}\".\"REPORTING\""
  }
}

Grants sur warehouses

# --- Warehouse grants ---

resource "snowflake_grant_privileges_to_account_role" "fr_data_engineer_wh" {
  account_role_name = snowflake_account_role.fr_data_engineer.name
  privileges        = ["USAGE", "OPERATE"]

  on_account_object {
    object_type = "WAREHOUSE"
    object_name = "TRANSFORM_WH_${var.env_suffix}"
  }
}

resource "snowflake_grant_privileges_to_account_role" "fr_data_analyst_wh" {
  account_role_name = snowflake_account_role.fr_data_analyst.name
  privileges        = ["USAGE"]

  on_account_object {
    object_type = "WAREHOUSE"
    object_name = "BI_WH_${var.env_suffix}"
  }
}

Pourquoi c'est puissant

Un terraform plan te donne l'audit complet de qui a accès à quoi :

Plan: 24 to add, 0 to change, 0 to destroy.

  # snowflake_account_role.ar_brz_raw_read will be created
  # snowflake_account_role.fr_data_engineer will be created
  # snowflake_grant_account_role.fr_data_engineer_inherits_ar_brz will be created
  # snowflake_grant_privileges_to_account_role.ar_brz_future_tables will be created
  ...

Pas besoin de SHOW GRANTS qui renvoie 340 lignes. Le plan Terraform est lisible, reviewable en pull request, et versionné dans Git.

Le module users : gérer les utilisateurs via Terraform (USERADMIN)

Nouveau en v2 : le provider v1 ne permettait pas de gérer les utilisateurs proprement. Le provider v2 introduit snowflake_user avec un support complet (login, RSA key, rôles, warehouse par défaut). C'est un game changer : tout est dans Terraform, y compris les comptes utilisateurs.

# modules/users/main.tf

# --- Création des utilisateurs ---

resource "snowflake_user" "users" {
  count = length(var.users)

  name             = var.users[count.index].login_name
  login_name       = var.users[count.index].login_name
  display_name     = var.users[count.index].display_name
  first_name       = var.users[count.index].firstname
  last_name        = var.users[count.index].lastname
  email            = var.users[count.index].email
  default_warehouse = var.users[count.index].default_warehouse
  default_role     = var.users[count.index].default_role
  comment          = "User managed by Terraform"
}

# --- Attribution des Functional Roles à chaque utilisateur ---

# Aplatir la liste (user, role) en une liste de maps pour le for_each
locals {
  user_role_pairs = flatten([
    for u in var.users : [
      for role in u.roles : {
        login_name = u.login_name
        role_name  = role
      }
    ]
  ])
}

resource "snowflake_grant_account_role" "user_roles" {
  for_each = {
    for pair in local.user_role_pairs :
    "${pair.login_name}-${pair.role_name}" => pair
  }

  role_name = each.value.role_name
  user_name = each.value.login_name

  depends_on = [snowflake_user.users]
}

La variable users est une liste d'objets :

# terraform.tfvars
users = [
  {
    display_name      = "Alice Martin"
    firstname         = "Alice"
    lastname          = "Martin"
    login_name        = "ALICE.MARTIN"
    email             = "alice@company.com"
    default_warehouse = "TRANSFORM_WH_DEV"
    roles             = ["FR_DATA_ENGINEER_DEV"]
    default_role      = "FR_DATA_ENGINEER_DEV"
  },
  {
    display_name      = "Bob Durand"
    firstname         = "Bob"
    lastname          = "Durand"
    login_name        = "BOB.DURAND"
    email             = "bob@company.com"
    default_warehouse = "BI_WH_DEV"
    roles             = ["FR_DATA_ANALYST_DEV"]
    default_role      = "FR_DATA_ANALYST_DEV"
  }
]

Le pattern for_each sur les paires user/rôle évite la duplication et gère proprement le cas où un utilisateur a plusieurs rôles. Le depends_on sur snowflake_user.users garantit que les utilisateurs existent avant d'attribuer les rôles.

Le module ingestion : pipes, stages, integrations

Ce module n'a pas changé entre v1 et v2 — les ressources snowflake_storage_integration, snowflake_stage, snowflake_pipe, snowflake_notification_integration sont identiques.

# modules/ingestion/main.tf

# --- Storage Integration (AWS S3) ---

resource "snowflake_storage_integration" "s3_integration" {
  name                    = "S3_INTEGRATION_${var.env_suffix}"
  storage_provider        = "S3"
  storage_aws_iam_role_arn = var.s3_role_arn
  storage_allowed_locations = ["s3://${var.bucket_name}/"]
  enabled                 = true
}

# --- Stage ---

resource "snowflake_stage" "s3_raw" {
  name                = "S3_RAW_STAGE"
  database            = "BRONZE_${var.env_suffix}"
  schema              = "RAW"
  url                 = "s3://${var.bucket_name}/raw/"
  storage_integration = snowflake_storage_integration.s3_integration.name
}

# --- Notification Integration (SQS pour auto-ingest) ---

resource "snowflake_notification_integration" "sqs_notification" {
  name                     = "SQS_NOTIFICATION_${var.env_suffix}"
  notification_provider    = "AWS_SQS"
  notification_aws_sqs_arn = var.sqs_queue_arn
  enabled                  = true
}

# --- Snowpipe ---

resource "snowflake_pipe" "csv_ingestion" {
  name     = "CSV_INGESTION"
  database = "BRONZE_${var.env_suffix}"
  schema   = "RAW"

  copy_statement = <<-SQL
    COPY INTO BRONZE_${var.env_suffix}.RAW.CSV_FILES (file_name, raw_data, loaded_at)
    FROM @${snowflake_stage.s3_raw.fully_qualified_name}
    FILE_FORMAT = (TYPE = CSV SKIP_HEADER = 1)
  SQL

  auto_ingest                    = true
  notification_integration_name = snowflake_notification_integration.sqs_notification.name
}

Sans Terraform, créer un Snowpipe avec auto-ingest demande 4 étapes manuelles (storage integration, stage, notification integration, pipe) avec des dépendances entre elles. En Terraform, c'est terraform apply.

Le module security : masks et tags

# modules/security/main.tf

# --- Tag pour classification RGPD ---

resource "snowflake_tag" "rgpd_sensitive" {
  name     = "RGPD_SENSITIVE"
  database = "SILVER_${var.env_suffix}"
  schema   = "SALES"
  comment  = "Tag for columns containing personal data"
}

# --- Masking Policy pour les emails ---

resource "snowflake_masking_policy" "email_mask" {
  name     = "EMAIL_MASK"
  database = "SILVER_${var.env_suffix}"
  schema   = "SALES"

  signature = "(val string) returns string"

  masking_expression = <<-SQL
    CASE
      WHEN CURRENT_ROLE() IN ('FR_DATA_ANALYST_${var.env_suffix}')
      THEN REGEXP_REPLACE(val, '(.{2}).*(@.*)', '\\1***\\2')
      ELSE val
    END
  SQL

  comment = "Mask emails for analyst role"
}

Différence entre provider v1 et v2 : en v1, le masquage de données devait passer par un contournement — on créait une Dynamic Table avec un CASE WHEN CURRENT_ROLE() pour simuler le masquage. En v2, on utilise directement snowflake_masking_policy — c'est la bonne ressource pour le Dynamic Data Masking. La politique est ensuite appliquée sur les colonnes via des grants ou directement dans dbt.

Le masquage conditionnel basé sur CURRENT_ROLE() garantit qu'un analyste voit ma***@gmail.com tandis qu'un data engineer voit l'email complet. Tout est dans Terraform = auditable.

CI/CD : du commit au déploiement

Le workflow que je déploie avec GitLab CI (ou GitHub Actions) :

# .gitlab-ci.yml
stages:
  - plan
  - apply

variables:
  TF_VERSION: "1.12.2"

.terraform_setup: &terraform_setup
  image: hashicorp/terraform:${TF_VERSION}
  before_script:
    # La clé RSA est stockée comme variable CI/CD (file type)
    - cp $SNOWFLAKE_KEY_P8 snowflake_key.p8
    - chmod 600 snowflake_key.p8

terraform:plan:dev:
  stage: plan
  <<: *terraform_setup
  script:
    - cd environments/dev
    - terraform init
    - terraform plan -out=tfplan
  artifacts:
    paths:
      - environments/dev/tfplan
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

terraform:apply:dev:
  stage: apply
  <<: *terraform_setup
  script:
    - cd environments/dev
    - terraform init
    - terraform apply -auto-approve tfplan
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Le flux :

  1. Un dev ouvre une pull request → terraform plan tourne en CI
  2. La team review le plan (qui montre exactement ce qui va changer)
  3. Merge sur mainterraform apply en dev
  4. Promotion manuelle vers recette, puis prod

Le plan Terraform est reviewable en pull request. C'est le killer feature. Le reviewer voit exactement : "ce merge va créer 2 roles, 3 grants, 1 warehouse". Pas de surprise.

Note sur la clé RSA en CI/CD : la clé snowflake_key.p8 est stockée comme variable de type file dans GitLab CI (ou GitHub Secret). Elle n'est jamais commitée dans le repo. Le before_script la copie depuis la variable CI vers le filesystem local.

Remote state : ne jamais perdre le .tfstate

# environments/dev/backend.tf
terraform {
  backend "s3" {
    bucket = "terraform-state-snowflake"
    key    = "dev/terraform.tfstate"
    region = "eu-west-3"
    encrypt = true
  }
}

⚠️ Le pire scénario : perdre le fichier .tfstate. Terraform ne sait plus ce qu'il gère. Tu dois tout terraform import à la main (une resource à la fois). En remote backend (S3, Azure Storage, GCS), le state est chiffré, versionné, et partagé entre l'équipe. Sans ça, pas de CI/CD.

Le résultat

MÉTRIQUE AVANT (À LA MAIN) APRÈS (TERRAFORM)
Création environnement complet 3 jours 15 minutes (terraform apply)
Recréer la recette 3 semaines de reverse-engineering terraform apply
Audit d'accès SHOW GRANTS (340 lignes) terraform plan (lisible)
Modification RBAC SQL manuel, non tracé Pull request + review
Détection de drift Impossible terraform plan le signale
Onboarding nouveau data engineer "Va voir le wiki" Clone le repo, terraform init
Désastre recovery Recréer depuis zéro terraform apply depuis Git
Création d'un utilisateur SQL manuel + GRANT ROLE Une ligne dans terraform.tfvars

Les erreurs que j'ai faites pour vous

ERREUR CONSÉQUENCE CORRECTION
Utiliser ACCOUNTADMIN dans le provider Terraform peut tout casser Créer un rôle TF_ADMIN dédié
Authentification par mot de passe Rotations manuelles, pas de CI/CD Clé RSA + SNOWFLAKE_JWT
Pas de remote backend State perdu = tout est perdu S3/Azure Storage + encryption
Tout dans un seul provider Erreurs de permissions 3 providers (SYSADMIN, SECURITYADMIN, USERADMIN) + CREATE INTEGRATION délégué
snowflake_role (v1) au lieu de snowflake_account_role (v2) Deprecation warnings, futur breaking Migrer vers snowflake_account_role
Grants en liste (roles = [...]) (v1) Race conditions, plans illisibles Un snowflake_grant_account_role par relation
Pas de depends_on entre modules Grants sur objets inexistants depends_on = [module.platform, module.rbac_roles]
Pas de auto_suspend sur les warehouses Facture 24/7 auto_suspend = 60 obligatoire
Pas de var.env_suffix Dev et prod dans le même state Variables par environnement
terraform apply sans plan en prod Changements non reviewés CI/CD avec plan en MR
Pas de modules Code dupliqué par environnement Modules réutilisables + tfvars
Créer les users en SQL Drift permanent entre SQL et Terraform snowflake_user + snowflake_grant_account_role

Migration v1 → v2 : ce qui change

Si tu as déjà un projet en provider v1, voici la checklist de migration :

1. Version du provider

# Avant
version = "~> 1.0"

# Après
version = "~> 2.18.0"

2. Authentification

# Avant (v1)
provider "snowflake" {
  account  = var.snowflake_account
  user     = var.snowflake_user
  password = var.snowflake_password
}

# Après (v2)
provider "snowflake" {
  organization_name = var.snowflake_org
  account_name      = var.snowflake_account
  user              = "TF_ADMIN"
  authenticator     = "SNOWFLAKE_JWT"
  private_key       = file("snowflake_key.p8")
}

3. Ressources RBAC renommées

v1 (déprécié) v2 (recommandé)
snowflake_role snowflake_account_role
snowflake_role_grants snowflake_grant_account_role
snowflake_grant_privileges_to_role snowflake_grant_privileges_to_account_role
role_name = ... (dans grants) account_role_name = ...
roles = [...] (liste dans role_grants) parent_role_name = ... (un par ressource)

4. Nouveau provider USERADMIN

Ajoute un 3ème provider pour la création des rôles et users. Split ton module rbac en rbac_roles (USERADMIN) + rbac_grants (SECURITYADMIN). Délègue CREATE INTEGRATION à TF_ADMIN pour gérer les integrations sans provider ACCOUNTADMIN.

5. State migration

Après avoir mis à jour le code, fais un terraform plan — le provider v2 détectera les ressources renommées et proposera un plan de migration. Accepte-le, vérifie que tout est en ordre, puis commit.

⚠️ Ne fais jamais une migration v1→v2 directement en prod. Teste d'abord en dev, vérifie que terraform plan ne propose pas de destructions inattendues, et procède environnement par environnement.

Ce que Terraform ne gère pas (encore)

Même en v2, certains objets Snowflake ne sont pas couverts par le provider — ou ne doivent pas l'être :

  • Resource monitors — la doc Snowflake réserve la création aux account administrators et ce privilège ne peut pas être délégué. Pas de CREATE RESOURCE MONITOR granulé. Je les crée manuellement en SQL, c'est un objet compte-level qui change rarement
  • Streams (change data capture) — pas de ressource native
  • Dynamic Tables — support partiel, mais la syntaxe SQL dans statement est complexe
  • Certaines policies avancées (row access, aggregation) — en preview
  • Tasks avec schedules complexes — le provider gère les tasks simples mais pas les DAGs
  • Snowpark — pas encore
  • Alerts — pas encore

La règle que j'applique : Terraform gère l'infrastructure (databases, warehouses, roles, grants, users, integrations). dbt gère les transformations (tables, views, tests). Airflow orchestre les tasks et les pipelines. Trois outils, trois responsabilités, zéro chevauchement. Pour aller plus loin sur l'orchestration, voir mon article sur Airflow + DBT + Snowflake.

M

Mikael Paulhiout

Lead Tech / Data Architect. Écrit sur Snowflake, DBT, Airflow et l'IA appliquée.

RSS LinkedIn