RSS →
ARTICLES · CI/CD

CI/CD pour Snowflake + DBT avec GitLab : du commit au déploiement

J'ai eu des plateformes Data où chaque déploiement DBT se faisait à la main : connexion SSH au serveur, git pull, dbt run --target prod, prière pour que ça passe. Un vendredi soir, un développeur fait un dbt run sans --target et écrase les tables de prod avec les données de dev. Le lundi matin, 4 dashboards PowerBI vides. Voici comment j'ai mis en place une pipeline CI/CD GitLab qui déploie DBT + Terraform sur Snowflake sans intervention manuelle — et sans risque d'écraser la prod.

CI/CD GitLab + DBT + Snowflake + Terraform

Le problème : pourquoi le déploiement manuel est un cauchemar

Le pattern que tout le monde fait au début :

# ⛔ Le déploiement manuel
ssh data-server
cd /opt/dbt-project
git pull origin main
dbt run --target prod
dbt test --target prod

Ça marche pour 5 modèles. À 380, c'est l'enfer :

  • Pas de revue de code avant déploiement
  • Pas de tests automatisés avant la prod
  • Un dbt run sans --target écrase la prod avec les données de dev
  • Pas de rollback si quelque chose casse
  • Pas de trace de qui a déployé quoi et quand

L'architecture cible

Trois pipelines GitLab CI qui correspondent à trois étapes du cycle de vie :

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   MR / PR    │     │   Merge to   │     │  Terraform   │
│  (Slim CI)   │     │    main      │     │   (IaC)      │
│              │     │  (Deploy)    │     │              │
│ dbt build    │     │ dbt deploy   │     │ terraform    │
│ state:modif. │     │ to prod      │     │ plan & apply │
│ + defer      │     │              │     │              │
└──────────────┘     └──────────────┘     └──────────────┘
  1. Pipeline CI (sur Merge Request) : teste seulement les modèles modifiés avec Slim CI
  2. Pipeline CD (sur merge to main) : déploie en production
  3. Pipeline Terraform (sur changement IaC) : provisionne l'infrastructure Snowflake

Pipeline 1 — Slim CI : tester que ce qui a changé

Le concept du Slim CI : comparer le manifest DBT courant avec celui de la prod, et ne builder que les modèles modifiés.

Le fichier .gitlab-ci.yml

# .gitlab-ci.yml — étape CI
stages:
  - ci

variables:
  DBT_TARGET: ci
  DBT_SNOWFLAKE_ACCOUNT: $SNOWFLAKE_ACCOUNT
  DBT_SNOWFLAKE_USER: $SNOWFLAKE_CI_USER
  DBT_SNOWFLAKE_PASSWORD: $SNOWFLAKE_CI_PASSWORD
  DBT_SNOWFLAKE_WAREHOUSE: $SNOWFLAKE_CI_WH
  DBT_SNOWFLAKE_DATABASE: ANALYTICS_CI
  DBT_SNOWFLAKE_SCHEMA: ci_${CI_COMMIT_SHORT_SHA}

dbt_slim_ci:
  stage: ci
  image: python:3.11-slim
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  before_script:
    - pip install dbt-snowflake
    - |
      cat << EOF > profiles.yml
      default:
        target: ci
        outputs:
          ci:
            type: snowflake
            account: "{{ env_var('DBT_SNOWFLAKE_ACCOUNT') }}"
            user: "{{ env_var('DBT_SNOWFLAKE_USER') }}"
            password: "{{ env_var('DBT_SNOWFLAKE_PASSWORD') }}"
            warehouse: "{{ env_var('DBT_SNOWFLAKE_WAREHOUSE') }}"
            database: "{{ env_var('DBT_SNOWFLAKE_DATABASE') }}"
            schema: "{{ env_var('DBT_SNOWFLAKE_SCHEMA') }}"
      EOF
  script:
    # 1. Télécharger le manifest de prod (artefact ou S3)
    - mkdir -p state
    - curl -sL -o state/manifest.json ${S3_MANIFEST_URL}

    # 2. Installer les dépendances DBT
    - dbt deps --profiles-dir .

    # 3. Slim CI : builder seulement les modèles modifiés + leurs dépendances
    - dbt build
        --select state:modified+
        --defer
        --state state/
        --profiles-dir .
        --target ci

    # 4. Tests sur les modèles modifiés
    - dbt test
        --select state:modified
        --defer
        --state state/
        --profiles-dir .
        --target ci

Comment ça marche

  • state:modified+ : compare le manifest actuel avec celui de prod. Ne sélectionne que les modèles dont le code a changé et leurs dépendances.
  • --defer : les modèles non modifiés sont résolus vers la prod existante (pas besoin de les reconstruire).
  • ci_${CI_COMMIT_SHORT_SHA} : chaque MR gets son propre schéma Snowflake. Pas de collision entre développeurs.

Le gain

Métrique Avant (dbt run complet) Après (Slim CI)
Modèles buildés 380 5-15 (seulement les modifiés)
Temps CI 47 min 3-5 min
Crédits Snowflake par MR 14 0,5
Tests exécutés Tous (380) Seulement les modifiés

Pipeline 2 — Déploiement CD : du merge à la prod

Quand la MR est mergée sur main, le déploiement en prod est automatique.

# .gitlab-ci.yml — étape CD
stages:
  - ci
  - deploy

dbt_deploy_prod:
  stage: deploy
  image: python:3.11-slim
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  before_script:
    - pip install dbt-snowflake
    - |
      cat << EOF > profiles.yml
      default:
        target: prod
        outputs:
          prod:
            type: snowflake
            account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
            user: "{{ env_var('SNOWFLAKE_PROD_USER') }}"
            password: "{{ env_var('SNOWFLAKE_PROD_PASSWORD') }}"
            warehouse: COMPUTE_PROD_WH
            database: ANALYTICS_PROD
            schema: public
            private_key_path: "{{ env_var('SNOWFLAKE_KEY_PATH') }}"
      EOF
  script:
    - dbt deps --profiles-dir .
    - dbt build --profiles-dir . --target prod
    - dbt test --profiles-dir . --target prod
    # Sauvegarder le manifest pour le prochain Slim CI
    - aws s3 cp target/manifest.json ${S3_MANIFEST_URL}
  environment:
    name: production
    url: https://snowflake.example.com
  only:
    - main

Points clés :

  • Le déploiement utilise un service account (pas un utilisateur humain) avec authentification par clé privée (key pair), pas par mot de passe.
  • Le manifest de prod est sauvegardé sur S3 après chaque déploiement — c'est ce qui permet au Slim CI de comparer.
  • environment: production active la protection GitLab (approval manuelle possible).

Pipeline 3 — Terraform : provisionner Snowflake as Code

L'infrastructure Snowflake (warehouses, roles, databases, grants) est gérée par Terraform dans un dépôt séparé. La pipeline applique les changements après revue.

Structure du dépôt Terraform

terraform-snowflake/
├── modules/
│   ├── warehouse/
│   ├── role/
│   └── database/
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   └── terraform.tfvars
│   ├── recette/
│   │   ├── main.tf
│   │   └── terraform.tfvars
│   └── prod/
│       ├── main.tf
│       └── terraform.tfvars
└── .gitlab-ci.yml

Le .gitlab-ci.yml Terraform

stages:
  - validate
  - plan
  - apply

variables:
  TF_VERSION: "1.9.0"

.terraform:setup: &terraform_setup
  image:
    name: hashicorp/terraform:${TF_VERSION}
    entrypoint: [""]
  before_script:
    - export SNOWFLAKE_ACCOUNT=$SNOWFLAKE_ACCOUNT
    - export SNOWFLAKE_USER=$SNOWFLAKE_TF_USER
    - export SNOWFLAKE_PRIVATE_KEY=$SNOWFLAKE_TF_KEY
    - cd environments/${ENV}

terraform_validate:
  <<: *terraform_setup
  stage: validate
  script:
    - terraform init -backend=false
    - terraform validate
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

terraform_plan:
  <<: *terraform_setup
  stage: plan
  script:
    - terraform init
    - terraform plan -out=tfplan
  artifacts:
    paths:
      - environments/${ENV}/tfplan
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

terraform_apply:
  <<: *terraform_setup
  stage: apply
  script:
    - terraform init
    - terraform apply -auto-approve tfplan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual  # approval manuel pour prod
  environment:
    name: ${ENV}

Un module warehouse typique

# modules/warehouse/main.tf
variable "name"           { type = string }
variable "size"           { type = string }
variable "auto_suspend"   { type = number }
variable "environment"    { type = string }

resource "snowflake_warehouse" "wh" {
  name           = "${var.name}_${upper(var.environment)}"
  warehouse_size = var.size
  auto_suspend   = var.auto_suspend
  initially_suspended = true
}

# Grants automatiques
resource "snowflake_grant_privileges_to_warehouse" "usage" {
  warehouse_name = snowflake_warehouse.wh.name
  privileges    = ["USAGE"]
  roles         = ["FR_DATA_ENGINEER_${upper(var.environment)}"]
}
# environments/prod/main.tf
module "compute_wh" {
  source       = "../../modules/warehouse"
  name         = "COMPUTE"
  size         = "X-LARGE"
  auto_suspend = 60
  environment  = "prod"
}

module "transform_wh" {
  source       = "../../modules/warehouse"
  name         = "TRANSFORM"
  size         = "LARGE"
  auto_suspend = 60
  environment  = "prod"
}

Les variables GitLab CI à configurer

Dans Settings → CI/CD → Variables de ton projet GitLab :

Variable Description Protégée Masquée
SNOWFLAKE_ACCOUNT Identifiant du compte Snowflake
SNOWFLAKE_CI_USER Service account CI (lecture seule sur prod)
SNOWFLAKE_CI_PASSWORD Mot de passe du CI user
SNOWFLAKE_PROD_USER Service account prod (key pair)
SNOWFLAKE_PROD_PASSWORD Mot de passe prod (si pas de key pair)
SNOWFLAKE_TF_USER Service account Terraform
SNOWFLAKE_TF_KEY Clé privée Terraform
S3_MANIFEST_URL URL S3 du manifest de prod

Les 5 pièges que j'ai vu en production

Piège 1 — Utiliser un utilisateur humain pour le CI/CD

Un développeur part en vacances. Son token Snowflake expire. Le pipeline plante. Créez des service accounts dédiés avec AUTHENTICATOR = 'SNOWFLAKE' et des mots de passe ou clés qui n'expirent pas.

Piège 2 — Pas de schéma CI isolé par MR

Sans schéma isolé, deux MR en parallèle s'écrasent mutuellement. Le pattern ci_${CI_COMMIT_SHORT_SHA} garantit l'isolation. Nettoyez les schémas CI après merge avec une tâche de cleanup :

cleanup_ci_schema:
  stage: cleanup
  script:
    - snowsql -q "DROP SCHEMA IF EXISTS ci_${CI_COMMIT_SHORT_SHA}"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: on_success

Piège 3 — Manifest de prod non sauvegardé

Si vous ne sauvegardez pas le manifest.json après chaque déploiement prod, le Slim CI ne peut pas comparer. Le dbt build se fait sur tous les modèles. Utilisez S3, GCS, ou les artefacts GitLab pour stocker le manifest.

Piège 4 — Terraform sans state backend distant

Sans backend distant, le state Terraform est local. Deux développeurs qui appliquent en parallèle écrasent le state. Utilisez le backend HTTP de GitLab ou S3 avec locking :

terraform {
  backend "http" {
    address        = "https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/terraform/state/${ENV}"
    lock_address   = "https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/terraform/state/${ENV}/lock"
    unlock_address = "https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/terraform/state/${ENV}/lock"
  }
}

Piège 5 — Pas de terraform plan en artefact

Le terraform plan doit être sauvegardé en artefact GitLab. Ça permet de reviewer le plan avant d'appliquer, et d'éviter les surprises :

artifacts:
  paths:
    - environments/${ENV}/tfplan
  expire_in: 1 week

Le résultat

Métrique Avant (manuel) Après (CI/CD)
Déploiement SSH + dbt run manuel Merge to main → auto
Tests avant prod Aucun Slim CI sur chaque MR
Temps de review CI N/A 3-5 min
Risque d'écraser la prod Élevé (un dbt run sans target) Nul (target auto)
Infrastructure Manuelle (SQL scripts) Terraform + plan/apply
Rollback git revert + dbt run manuel Re-merge du commit précédent
Trace de déploiement Aucune GitLab CI pipeline history

Le CI/CD n'est pas une luxe pour les équipes Data. C'est la seule façon de déployer en production sans peur. Si ton pipeline GitLab ne teste pas tes modèles DBT avant la prod, tu déploies à l'aveugle.

M

Mikael Paulhiout

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

RSS LinkedIn