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
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.