Aller au contenu
6 min de lecture Moustakime KIFIA

Infrastructure SaaS sur AWS : tout en Terraform, rien à la main

Comment on a construit l'infrastructure de Cercly sur AWS avec Terraform — VPC, EC2, RDS, ECR, Route53, ACM, OIDC — de façon à pouvoir recréer entièrement la prod avec un seul apply.

  • Operations
  • CI/CD
  • Engineering
Infrastructure SaaS sur AWS : tout en Terraform, rien à la main

Le vrai test d’une infrastructure, c’est de pouvoir répondre à la question : si on perdait tout demain, combien de temps pour repartir ?

Pour Cercly, la réponse est : le temps d’un terraform apply et d’un pipeline GitOps. Pas de clics dans la console AWS, pas de documentation à retrouver, pas d’étapes manuelles oubliées.

Cet article documente l’architecture AWS et les choix qui ont conduit à ce résultat.

Vue d’ensemble

Cercly est une plateforme SaaS multi-tenant pour associations. L’infrastructure héberge deux environnements distincts — staging et production — dans la région AWS eu-west-3 (Paris).

Internet

Route53 (DNS)

ALB (HTTPS, certificat ACM)

EC2 (Docker Compose + Traefik)
    ├── platform-api         (Symfony/FrankenPHP)
    ├── platform-api-worker  (Messenger consumer)
    ├── operator-api         (NestJS)
    ├── notification-api     (NestJS + Brevo)
    ├── platform-bff         (NestJS)
    ├── member-bff           (NestJS)
    ├── platform             (Next.js)
    ├── member               (Next.js)
    ├── operator             (Next.js)
    ├── portal               (Next.js — landing + tenant portal)
    ├── RabbitMQ
    ├── Redis
    └── Grafana Alloy        (logs → Grafana Cloud)

    RDS PostgreSQL 18

Un seul EC2 par environnement fait tourner l’ensemble des services via Docker Compose. C’est un choix délibéré pour un SaaS en phase de croissance : la complexité opérationnelle d’un cluster Kubernetes n’est pas justifiée à ce stade.

Ce que Terraform gère

Tout. C’est la contrainte centrale.

Réseau

# VPC dédié par environnement
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

# Subnets publics (ALB, NAT) et privés (RDS)
resource "aws_subnet" "public" { count = 2 ... }
resource "aws_subnet" "private" { count = 2 ... }

Chaque environnement a sa propre VPC. Les subnets privés hébergent RDS — la base de données n’est jamais exposée publiquement.

Compute

resource "aws_instance" "production" {
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.large"
  iam_instance_profile = aws_iam_instance_profile.ec2.name
  user_data = file("scripts/user-data.sh")
}

Amazon Linux 2023, Docker installé au démarrage via user_data. L’instance a un profil IAM qui lui donne accès à ECR (pull d’images), SSM (déploiement sans SSH) et Secrets Manager (secrets au runtime).

Base de données

resource "aws_db_instance" "production" {
  engine         = "postgres"
  engine_version = "18"
  instance_class = "db.t3.small"
  multi_az       = false
  storage_encrypted = true
}

RDS PostgreSQL dans les subnets privés. multi_az = false en production actuelle — le point à faire évoluer quand le SLA le justifiera.

DNS et certificats

resource "aws_route53_zone" "public" {
  name = "cercly.co"
}

resource "aws_acm_certificate" "production" {
  domain_name = var.domain_name
  subject_alternative_names = [
    "*.${var.domain_name}",
    "www.${var.domain_name}",
    "*.platform.${var.domain_name}",
    "*.member.${var.domain_name}",
  ]
  validation_method = "DNS"
}

Terraform crée la zone Route53, émet le certificat ACM et crée les records de validation CNAME — tout en une passe. Les records mail (MX, DKIM Brevo, DMARC) sont aussi déclarés.

Conteneurs

resource "aws_ecr_repository" "services" {
  for_each = toset(var.ecr_services)
  name     = "cercly/${each.key}"
}

resource "aws_ecr_lifecycle_policy" "cleanup" {
  for_each   = aws_ecr_repository.services
  repository = each.value.name
  policy = jsonencode({
    rules = [{
      rulePriority = 1
      selection    = { tagStatus = "untagged", countType = "sinceImagePushed", countNumber = 7 }
      action       = { type = "expire" }
    }]
  })
}

Un repo ECR par service. La lifecycle policy supprime les images non taguées après 7 jours.

Secrets

Les conteneurs de secrets sont déclarés dans Terraform, mais leurs valeurs sont gérées hors du state — via CLI ou un script load-secrets.sh au premier déploiement.

locals {
  sm_secrets = {
    "platform-api"     = "db_password, app_secret, stripe_secret_key..."
    "operator-api"     = "db_password, operator_jwt_secret, stripe_key..."
    "notification-api" = "db_password, brevo_api_key"
    "jwt"              = "passphrase, secret_key_path, public_key_path"
    "infra"            = "rabbitmq_password, gcloud_rw_api_key..."
    "operator-admin"   = "email, password — init compte opérateur"
  }
}

resource "aws_secretsmanager_secret" "production" {
  for_each                = local.sm_secrets
  name                    = "cercly/production/${each.key}"
  recovery_window_in_days = 7
}

À l’exécution, l’EC2 charge les secrets depuis Secrets Manager via son profil IAM — sans variable d’environnement en clair, sans fichier .env sur le disque.

Artifacts S3

Un bucket S3 sert de dépôt intermédiaire pour les artifacts de build — scripts de déploiement, fichiers de configuration pré-compilés.

resource "aws_s3_bucket" "artifacts" {
  bucket        = "${var.artifacts_bucket_prefix}-${data.aws_caller_identity.current.account_id}"
  force_destroy = false
}

Chiffrement AES256 côté serveur, versioning activé, lifecycle : passage en STANDARD_IA après 30 jours, expiration après 180 jours.

GitLab CI sans credentials statiques

Pas de AWS_ACCESS_KEY_ID dans les variables GitLab. À la place, OIDC :

resource "aws_iam_openid_connect_provider" "gitlab" {
  url             = "https://gitlab.com"
  client_id_list  = ["sts.amazonaws.com"]
}

resource "aws_iam_role" "gitlab_build" {
  assume_role_policy = jsonencode({
    Statement = [{
      Effect    = "Allow"
      Action    = "sts:AssumeRoleWithWebIdentity"
      Principal = { Federated = aws_iam_openid_connect_provider.gitlab.arn }
      Condition = {
        StringLike = {
          "gitlab.com:sub" = [
            "project_path:keyson/cercly/*:ref_type:branch:ref:main",
            "project_path:keyson/cercly/*:ref_type:tag:ref:*",
          ]
        }
      }
    }]
  })
}

Chaque job CI échange un token OIDC GitLab contre des credentials AWS temporaires (1h). Zéro secret à rotation, zéro risque de fuite via les logs.

GitOps : l’état de la prod est dans Git

Le déploiement production ne passe pas par un job CI qui pousse directement. Il passe par un repo gitops qui déclare l’état souhaité :

infra/gitops/
├── overlays/
│   ├── production/
│   │   ├── .env.production   ← tags des images
│   │   └── docker-compose.yml
│   └── staging/
│       ├── .env.staging
│       └── docker-compose.yml
└── scripts/
    └── load-secrets.sh       ← charge depuis AWS Secrets Manager

Quand un service est taggé et déployé en staging, le job CI met à jour .env.staging via l’API GitLab. Après validation, on merge sur main et le même mécanisme met à jour .env.production.

Le pipeline gitops tire les images ECR, charge les secrets depuis AWS Secrets Manager et redémarre les containers via SSM — sans SSH, sans agent sur l’instance.

Runner GitLab sur EC2

Les jobs CI n’utilisent pas les runners partagés de GitLab.com. Un EC2 dédié porte le runner — avec une option Spot pour réduire le coût :

resource "aws_instance" "runner" {
  instance_type = var.runner_instance_type   # t3.medium
  subnet_id     = aws_subnet.private_runner.id
  iam_instance_profile = aws_iam_instance_profile.runner.name

  dynamic "instance_market_options" {
    for_each = var.runner_use_spot ? [1] : []
    content {
      market_type = "spot"
      spot_options {
        spot_instance_type             = "persistent"
        instance_interruption_behavior = "stop"
      }
    }
  }
}

Au démarrage, le user_data installe Docker et gitlab-runner, récupère le token d’enregistrement depuis Secrets Manager et lance l’enregistrement de façon non-interactive. Max 6 jobs concurrents.

Optimisation des coûts : EventBridge Scheduler

Un scheduler EventBridge arrête automatiquement l’EC2 et RDS chaque soir à 22h (heure de Paris). Le démarrage reste manuel.

resource "aws_scheduler_schedule" "stop_production" {
  schedule_expression          = "cron(0 22 * * ? *)"
  schedule_expression_timezone = "Europe/Paris"

  target {
    arn      = "arn:aws:scheduler:::aws-sdk:ec2:stopInstances"
    role_arn = aws_iam_role.scheduler.arn
    input    = jsonencode({ InstanceIds = [aws_instance.production.id] })
  }
}

Trois schedules distincts : EC2 app, EC2 runner, RDS. Sur un staging qui tourne 12h/jour au lieu de 24h, l’économie dépasse 50% sur la ligne compute.

Ce que ce choix coûte et ce qu’il rapporte

Un seul EC2 par environnement est un single point of failure. Une panne de l’instance arrête tous les services. C’est un risque accepté à ce stade.

Un développeur qui rejoint le projet peut lire ec2.tf et rds.tf et comprendre l’infrastructure en 20 minutes. Pas de couche d’abstraction supplémentaire à apprendre.

Recréer un environnement complet — VPC, EC2, RDS, DNS, certificat, repos ECR, secrets — prend le temps d’un terraform apply. C’est la garantie réelle que l’infrastructure est maîtrisée.

La prochaine étape

L’architecture actuelle atteint ses limites dès qu’on a besoin de scaler un service indépendamment des autres. Si la charge sur platform-api explose, on ne peut pas en ajouter des instances sans faire évoluer l’ensemble du setup Docker Compose.

La prochaine évolution naturelle est un cluster d’orchestration. Le choix entre Kubernetes et Docker Swarm reste ouvert : Kubernetes offre un écosystème plus riche et une communauté plus large, Swarm conserve la proximité avec Docker Compose et réduit la courbe d’apprentissage. Les deux s’intègrent bien avec Terraform et ECR.

Ce qui ne changera pas : les images, le registry ECR, l’OIDC CI/CD et RDS. L’orchestration est une couche au-dessus — le reste de l’infrastructure reste intact.

Continuer la lecture

Quelques articles relies pour renforcer le maillage interne et prolonger les sujets techniques voisins.

3 min

OIDC GitLab vers AWS : la trust policy et le job CI

Comment on a branché GitLab CI sur AWS via OIDC, avec une trust policy serrée, un seul rôle pour tous les projets et la limite ACLSizePerRole qu'on a frôlée.

  • CI/CD
  • Auth
  • Operations
Lire la note
5 min

Une carte simple de l'architecture SaaS de Cercly

Une lecture de l'architecture SaaS de Cercly centrée sur la découpe applicative : design system partagé, frontends séparés, BFFs dédiés, APIs métier et infra commune.

  • Architecture
  • Engineering
  • Product
Lire la note