Dans les articles précédents de la série TheForge, nous avons vu comment déployer un cluster GKE + VPC + ArgoCD sur GCP via Terraform.

Cette stack est fonctionnelle mais elle a ses limites : le state Terraform est local, l’authentification GCP repose sur gcloud et il n’y a aucun workflow CI/CD.

Comment passer d’un workflow PoC local à une configuration CI/CD complète applicable en production ?

  • Authentification GCP via Workload Identity Federation (WIF) pour GitHub Actions
  • Remote state Terraform sur GCS
  • Workflows GitHub Actions

Workload Identity Federation

Workload Identity Federation (WIF) est une fonctionnalité de GCP qui permet à des applications externes (comme GitHub Actions) d’obtenir des identités temporaires pour accéder aux ressources GCP, sans avoir besoin de stocker des clés JSON de service account.

Variables shell à exporter

Remplacez les valeurs par celles de votre projet GCP et de votre repo GitHub.

export PROJECT_ID="deft-accord-496812-k9"
export REGION="europe-west1"
export GITHUB_ORG="Bernedotcom2312"
export GITHUB_REPO="theforge-infra"
export SA_NAME="github-actions-terraform"
export SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
export WIF_POOL="github-wif-pool"
export WIF_PROVIDER="github-actions-provider"
export TF_STATE_BUCKET="theforge-infra-tfstate-${PROJECT_ID}"

Activer les APIs GCP nécessaires

Dans la suite de cet article nous allons avoir besoin d’activer plusieurs APIs GCP pour que Terraform et GitHub Actions puissent interagir avec GCP, notamment l’IAM, GKE, Compute Engine et Cloud Storage.

gcloud services enable cloudresourcemanager.googleapis.com iam.googleapis.com \
  iamcredentials.googleapis.com sts.googleapis.com container.googleapis.com \
  compute.googleapis.com storage.googleapis.com --project="${PROJECT_ID}"

Créer le bucket GCS pour le state Terraform

gcloud storage buckets create "gs://${TF_STATE_BUCKET}" \
  --project="${PROJECT_ID}" --location="${REGION}" \
  --uniform-bucket-level-access --public-access-prevention
gcloud storage buckets update "gs://${TF_STATE_BUCKET}" --versioning

Créer le Service Account

gcloud iam service-accounts create "${SA_NAME}" \
  --project="${PROJECT_ID}" \
  --display-name="GitHub Actions Terraform SA"

Attribuer les rôles IAM minimaux

gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
  --member="serviceAccount:${SA_EMAIL}" --role="roles/container.admin"
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
  --member="serviceAccount:${SA_EMAIL}" --role="roles/compute.networkAdmin"
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
  --member="serviceAccount:${SA_EMAIL}" --role="roles/viewer"
gcloud storage buckets add-iam-policy-binding "gs://${TF_STATE_BUCKET}" \
  --member="serviceAccount:${SA_EMAIL}" --role="roles/storage.objectAdmin"
gcloud iam service-accounts add-iam-policy-binding "${SA_EMAIL}" \
  --project="${PROJECT_ID}" \
  --member="serviceAccount:${SA_EMAIL}" --role="roles/iam.serviceAccountUser"

Créer le pool WIF et le provider OIDC

GCP recommande de créer un pool WIF par environnement (dev, staging, prod) pour limiter l’accès aux ressources GCP. Dans cet exemple, nous allons créer un pool WIF unique pour le repo GitHub.

gcloud iam workload-identity-pools create "${WIF_POOL}" \
  --project="${PROJECT_ID}" --location="global" \
  --display-name="GitHub Actions WIF Pool"

gcloud iam workload-identity-pools providers create-oidc "${WIF_PROVIDER}" \
  --project="${PROJECT_ID}" --location="global" \
  --workload-identity-pool="${WIF_POOL}" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
  --attribute-condition="assertion.repository_owner == '${GITHUB_ORG}'"

L’attribute-condition est ce qui garantit que seul mon organisation GitHub peut s’authentifier, pas n’importe qui avec un token OIDC GitHub.

Lier le SA au pool

export WIF_POOL_NAME=$(gcloud iam workload-identity-pools describe "${WIF_POOL}" \
  --project="${PROJECT_ID}" --location="global" --format="value(name)")

gcloud iam service-accounts add-iam-policy-binding "${SA_EMAIL}" \
  --project="${PROJECT_ID}" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/${WIF_POOL_NAME}/attribute.repository/${GITHUB_ORG}/${GITHUB_REPO}"

Récupérer la valeur du provider WIF pour GitHub

gcloud iam workload-identity-pools providers describe "${WIF_PROVIDER}" \
  --project="${PROJECT_ID}" --location="global" \
  --workload-identity-pool="${WIF_POOL}" --format="value(name)"

Configurer les variables GitHub

VariableValeur
GCP_PROJECT_IDdeft-accord-496812-k9
GCP_REGIONeurope-west1
GCP_SA_EMAILgithub-actions-terraform@deft-accord-496812-k9.iam.gserviceaccount.com
GCP_WIF_PROVIDERsortie de la commande 1.7
TF_STATE_BUCKETtheforge-infra-tfstate-deft-accord-496812-k9

Terraform Github Actions Secrets

Créer l’environnement GitHub production

Settings → Environments → New environment → production

  • Required reviewers : soi-même (ou le lead)
  • Deployment branches : main uniquement

Terraform Github Actions Production Environment


Migration du state vers GCS

Maintenant que les pré-requis de gestion de droits sont en place, nous allons migrer le state Terraform local vers GCS.

Créer backend.tf

terraform {
  backend "gcs" {
    bucket = "lenomdevotrebucket"
    prefix = "terraform/state"
  }
}

Migrer le state local → GCS

gcloud auth application-default login
terraform init -migrate-state   # répondre "yes" à la question de migration
terraform plan -var-file=terraform.tfvars   # vérifier que tout fonctionne, il ne doit pas y avoir de changement
rm terraform.tfstate terraform.tfstate.backup
git add backend.tf
git commit -m "chore: migrate Terraform state backend to GCS"
git push origin main

Workflows GitHub Actions

Dernière étape : mettre en place les workflows GitHub Actions pour CI/CD pour valider notre configuration dans une PR et appliquer les changements sur main après approbation.

Fichiers à créer

.github/
└── workflows/
    ├── terraform-ci.yml   (PR → validate + plan commenté)
    └── terraform-cd.yml   (merge main → apply)

terraform-ci.yml

name: Terraform CI

on:
  pull_request:
    branches:
      - main
    paths:
      - "**.tf"
      - "**.tfvars"
      - ".github/workflows/terraform-ci.yml"

permissions:
  contents: read
  id-token: write
  pull-requests: write

jobs:
  terraform-ci:
    name: Validate & Plan
    runs-on: ubuntu-26.04
    defaults:
      run:
        shell: bash

    steps:
      - name: Setup
        uses: ./.github/actions/terraform-setup
        with:
          terraform_wrapper: 'true'

      - name: Terraform fmt
        id: fmt
        run: terraform fmt -check -recursive
        continue-on-error: true

      - name: Terraform init
        id: init
        run: |
          terraform init \
            -backend-config="bucket=${{ vars.TF_STATE_BUCKET }}" \
            -input=false

      - name: Terraform validate
        id: validate
        run: terraform validate -no-color

      - name: Setup TFLint
        uses: terraform-linters/setup-tflint@v6.2.2
        with:
          tflint_version: v0.63.1

      - name: TFLint init
        run: tflint --init

      - name: TFLint run
        id: tflint
        run: tflint --format=compact --no-color
        continue-on-error: true

      - name: Checkov scan
        id: checkov
        uses: bridgecrewio/checkov-action@v12.1347.0
        with:
          directory: .
          framework: terraform
          output_format: cli
          soft_fail: false
        continue-on-error: true

      - name: Terraform plan
        id: plan
        run: |
          terraform plan \
            -var="project_id=${{ vars.GCP_PROJECT_ID }}" \
            -var="region=${{ vars.GCP_REGION }}" \
            -out=plan.tfplan \
            -no-color \
            -input=false 2>&1 | tee plan-output.txt
          echo "exitcode=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
        continue-on-error: true

      - name: Upload plan artifact
        if: steps.plan.outcome == 'success'
        uses: actions/upload-artifact@v7.0.1
        with:
          name: plan-${{ github.sha }}
          path: plan.tfplan
          retention-days: 1

Si vous avez suivi mon article précédent sur les pre-commit pour terraform voici les contrôles que je vais appliquer : terraform fmt, terraform validate, tflint et checkov pour la sécurité.

terraform-cd.yml

name: Terraform CD

on:
  push:
    branches:
      - main
    paths:
      - "**.tf"
      - "**.tfvars"
      - ".github/workflows/terraform-cd.yml"

permissions:
  contents: read
  id-token: write
  actions: read

jobs:
  terraform-apply:
    name: Apply to production
    runs-on: ubuntu-26.04
    environment: production
    defaults:
      run:
        shell: bash

    steps:
      - name: Setup
        uses: ./.github/actions/terraform-setup

      - name: Terraform init
        run: |
          terraform init \
            -backend-config="bucket=${{ vars.TF_STATE_BUCKET }}" \
            -input=false

      - name: Find CI run for this commit
        id: find-ci-run
        uses: actions/github-script@v7.0.1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const { data } = await github.rest.actions.listWorkflowRuns({
              owner: context.repo.owner,
              repo: context.repo.repo,
              workflow_id: 'terraform-ci.yml',
              head_sha: context.sha,
              status: 'success',
              per_page: 1,
            });
            const run = data.workflow_runs[0];
            if (!run) core.setFailed(`No successful CI run found for ${context.sha}`);
            core.setOutput('run-id', String(run.id));

      - name: Download plan artifact
        uses: actions/download-artifact@v8.0.1
        with:
          name: plan-${{ github.sha }}
          run-id: ${{ steps.find-ci-run.outputs.run-id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Terraform apply
        run: |
          terraform apply \
            -auto-approve \
            -input=false \
            plan.tfplan

Ce fichier sera déclenché uniquement sur le merge de la branche main et après approbation de l’environnement production, c’est le seul endroit où j’autorise le terraform apply pour respecter les principes GitOps


Vérification

Pour valider la chaine compplète il faut désormais appliquer un changement mineur (ex: ajout d’un tag sur une ressource).

Vous devriez avoir un résultat semblable à celui-ci dans la PR : Terraform CI/CD Result

Une fois merged, vous devriez voir le workflow terraform-cd.yml s’exécuter et appliquer les changements sur GCP : Terraform Pending Deployment

Il suffira ensuite d’approuver le déploiement dans l’environnement production pour que le terraform apply soit exécuté.

Apply to deployment

Conclusion

Nous avons vu comment passer d’un workflow Terraform local à une configuration CI/CD complète sur GCP applicable en entreprise en respectant les principes GitOps et en optimisant la boucle de feedback pour les développeurs.

L’ensemble du code est disponible sur mon repo GitHub : the-forge-infra.