Site statique sécurisé sur AWS S3 & CloudFront
Ce tutoriel met en place un site statique sur S3, gardé privé derrière CloudFront avec Origin Access Control, un certificat TLS géré par ACM, et une étape de déploiement qui invalide correctement le cache, le schéma sécurisé plutôt que le raccourci du bucket public.
Configuration du bucket : privé par défaut
Le bucket ne doit jamais être lisible publiquement ni avoir l'hébergement de site statique activé (cette fonctionnalité ne sert que du HTTP simple). Il n'existe que comme stockage privé que CloudFront est autorisé à lire.
resource "aws_s3_bucket" "site" {
bucket = "www.example-consulting.fr"
}
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}Origin Access Control est aujourd'hui le mécanisme recommandé par AWS pour permettre à CloudFront de lire un bucket privé. Il remplace Origin Access Identity (OAI), qu'AWS classe désormais comme « legacy ». L'OAI fonctionne toujours, mais les nouvelles configurations devraient passer par l'OAC. L'OAC signe avec SigV4 chaque requête que CloudFront envoie à S3.
resource "aws_cloudfront_origin_access_control" "site" {
name = "site-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}La bucket policy n'accorde l'accès en lecture qu'au principal de service CloudFront. La condition AWS:SourceArn la limite à cette distribution, pour qu'aucune autre (y compris une distribution dans le compte de quelqu'un d'autre) ne puisse utiliser le bucket comme origine. Sans cette policy, l'OAC signe des requêtes que S3 refuse ensuite, et chaque page renvoie un 403.
data "aws_iam_policy_document" "site" {
statement {
sid = "AllowCloudFrontServicePrincipal"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = data.aws_iam_policy_document.site.json
depends_on = [aws_s3_bucket_public_access_block.site]
}On a ici un problème de l'œuf et de la poule. L'ARN de la distribution n'existe pas tant que la distribution n'est pas créée, et la distribution a besoin que l'OAC existe déjà. Terraform déduit l'ordre des références ci-dessus. La bucket policy dépend de la distribution, donc Terraform crée l'OAC, puis la distribution, puis la policy. Le blocage de l'accès public reste entièrement actif. Une policy limitée à un principal de service avec une condition SourceArn n'est pas considérée comme « publique », donc block_public_policy ne la rejette pas. Comme la policy n'accorde que s3:GetObject, une clé manquante renvoie un 403 plutôt qu'un 404, car S3 ne révèle pas si un objet existe sans s3:ListBucket.
URL propres : réécrire vers index.html
Un bucket S3 privé derrière l'OAC est servi via l'endpoint REST de S3, qui ne connaît pas la notion de document d'index. default_root_object ne règle que /. Une requête vers /about/ ou /about demande à S3 une clé qui n'existe pas et reçoit un 403. Si votre export statique produit une arborescence de répertoires (Next.js avec trailingSlash: true écrit out/about/index.html), il vous faut une petite CloudFront Function en viewer-request pour ajouter index.html :
// rewrite-index.js
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
// /about/ -> /about/index.html
request.uri += 'index.html';
} else if (!uri.split('/').pop().includes('.')) {
// /about -> /about/index.html (pas d'extension dans le dernier segment)
request.uri += '/index.html';
}
return request;
}resource "aws_cloudfront_function" "rewrite_index" {
name = "site-rewrite-index"
runtime = "cloudfront-js-2.0"
publish = true
code = file("${path.module}/rewrite-index.js")
}Si votre framework exporte plutôt des fichiers à plat (about.html), adaptez la réécriture pour ajouter .html. Dans tous les cas, il faut que quelque chose fasse correspondre l'URL propre à une vraie clé, car S3 ne le fera pas.
La distribution CloudFront
La distribution relie le bucket, l'OAC et la fonction de réécriture. Elle configure aussi le TLS, la redirection HTTP vers HTTPS et le document par défaut.
resource "aws_cloudfront_distribution" "site" {
enabled = true
is_ipv6_enabled = true
http_version = "http2and3"
default_root_object = "index.html"
aliases = ["www.example-consulting.fr"]
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "s3-site-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
default_cache_behavior {
target_origin_id = "s3-site-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # AWS managed : CachingOptimized
response_headers_policy_id = aws_cloudfront_response_headers_policy.security.id
function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.rewrite_index.arn
}
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = aws_acm_certificate_validation.site.certificate_arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}viewer_protocol_policy = "redirect-to-https" impose le TLS aux visiteurs. Sans cette ligne, CloudFront sert aussi du HTTP en clair. L'ARN du certificat vient de aws_acm_certificate_validation plutôt que du certificat lui-même, pour que Terraform attende qu'ACM ait émis le certificat avant de l'attacher. Si vous pointez directement vers aws_acm_certificate.site.arn, le premier apply échoue, car le certificat est encore en attente de validation.
En-têtes de sécurité et HTTP/3
S3 n'envoie aucun en-tête de sécurité, et un site statique n'a pas de serveur à lui pour les ajouter. CloudFront peut les ajouter à chaque réponse avec une response headers policy, que le default cache behavior ci-dessus référence.
resource "aws_cloudfront_response_headers_policy" "security" {
name = "static-site-security-headers"
security_headers_config {
strict_transport_security {
access_control_max_age_sec = 31536000
include_subdomains = true
preload = false
override = true
}
content_type_options {
override = true
}
frame_options {
frame_option = "DENY"
override = true
}
referrer_policy {
referrer_policy = "strict-origin-when-cross-origin"
override = true
}
}
custom_headers_config {
items {
header = "Permissions-Policy"
value = "camera=(), microphone=(), geolocation=()"
override = true
}
}
}Strict-Transport-Security indique aux navigateurs d'utiliser HTTPS pour le domaine pendant un an, si bien qu'après la première visite ils sautent la redirection HTTP vers HTTPS. include_subdomains étend la règle aux sous-domaines de l'hôte qui envoie l'en-tête. Si cette distribution sert aussi le domaine racine, cela couvre tous les sous-domaines de votre domaine. Ne l'activez donc que lorsqu'ils servent tous du HTTPS. Laissez preload désactivé tant que vous n'êtes pas sûr, car faire retirer un domaine de la liste de préchargement des navigateurs prend des mois.
X-Content-Type-Options: nosniff empêche les navigateurs de deviner le type d'un fichier d'après son contenu, ce qui compte sur un bucket où un mauvais Content-Type à l'upload passe facilement inaperçu. X-Frame-Options: DENY empêche d'autres sites d'afficher vos pages dans un cadre. Utilisez SAMEORIGIN si vous intégrez vos propres pages. Avec strict-origin-when-cross-origin, un visiteur qui suit un lien vers un autre site n'envoie que votre domaine, sans l'URL complète.
override = true fait remplacer par CloudFront un en-tête déjà envoyé par l'origine. Avec un bucket S3 privé, cela revient à l'ajouter, mais la policy garde la main si vous ajoutez plus tard une origine qui fixe ses propres en-têtes.
La policy laisse volontairement de côté la Content-Security-Policy. Une CSP dépend des scripts, polices et intégrations que chargent vos pages, et une politique mal réglée casse le site pour une partie des visiteurs sans erreur visible de votre côté. Commencez par un en-tête personnalisé Content-Security-Policy-Report-Only, surveillez les violations dans la console du navigateur, puis passez en mode bloquant quand il n'y en a plus. AWS propose aussi une policy managée, SecurityHeadersPolicy. Elle permet de démarrer vite, mais ses valeurs ne sont pas modifiables, et une policy à vous est plus simple à durcir ensuite.
http_version = "http2and3" sur la distribution active HTTP/3 sur QUIC. CloudFront l'annonce avec un en-tête Alt-Svc. Les navigateurs qui le prennent en charge basculent dessus pour les connexions suivantes, les autres restent en HTTP/2. Le gain se voit surtout sur mobile et sur les réseaux instables, où l'établissement de connexion plus rapide de QUIC et l'absence de blocage en tête de file aident le plus. Pour vérifier les deux une fois la distribution déployée :
curl -sI https://www.example-consulting.com | grep -iE 'alt-svc|strict-transport|x-content-type|x-frame|referrer-policy|permissions-policy'Certificat ACM et domaine personnalisé
CloudFront n'accepte que des certificats ACM émis dans la région us-east-1, quelle que soit la région du reste de votre infrastructure. C'est pour cela que le provider a un alias. On suppose ici que la zone hébergée de votre domaine existe déjà dans Route 53.
provider "aws" {
region = "eu-west-1" # votre région principale : le bucket vit ici
}
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
data "aws_route53_zone" "site" {
name = "example-consulting.fr"
private_zone = false
}
resource "aws_acm_certificate" "site" {
provider = aws.us_east_1
domain_name = "www.example-consulting.fr"
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.site.domain_validation_options : dvo.domain_name => dvo
}
allow_overwrite = true
zone_id = data.aws_route53_zone.site.zone_id
name = each.value.resource_record_name
type = each.value.resource_record_type
records = [each.value.resource_record_value]
ttl = 60
}
resource "aws_acm_certificate_validation" "site" {
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.site.arn
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}
resource "aws_route53_record" "site" {
for_each = toset(["A", "AAAA"])
zone_id = data.aws_route53_zone.site.zone_id
name = "www.example-consulting.fr"
type = each.key
alias {
name = aws_cloudfront_distribution.site.domain_name
zone_id = aws_cloudfront_distribution.site.hosted_zone_id
evaluate_target_health = false
}
}Les enregistrements alias A et AAAA pointent le domaine vers la distribution (AAAA parce que l'IPv6 y est activé). Vous avez maintenant un domaine personnalisé avec un certificat géré qui se renouvelle tout seul, sans fichier de certificat à faire tourner à la main. ACM le renouvelle tant que le CNAME de validation reste dans la zone, ce qui est une raison de plus de gérer cet enregistrement dans Terraform.
CloudFront met aussi les réponses d'erreur en cache. Par défaut, un 403 ou un 404 renvoyé par l'origine reste en cache 10 secondes (le TTL minimum de mise en cache des erreurs), ou la durée error_caching_min_ttl que vous avez définie dans un custom_error_response. Si vous déboguez une erreur d'accès refusé juste après un changement de configuration, attendez la fin de cette fenêtre avant de conclure que le correctif ne marche pas, ou invalidez /* si vous avez configuré un TTL d'erreur plus long. La distribution elle-même met aussi quelques minutes à se déployer après le retour de terraform apply.
L'étape de déploiement : synchroniser puis invalider
Les tutoriels "il suffit d'uploader sur S3" oublient cette étape, ce qui explique pourquoi un correctif poussé n'apparaît souvent pas au rechargement : les caches en périphérie de CloudFront ne savent pas que vos fichiers ont changé tant que vous ne le leur dites pas.
#!/usr/bin/env bash
set -euo pipefail
BUCKET="s3://www.example-consulting.fr"
# 1. Assets de build hashés : leur nom change à chaque build, cache d'un an sans risque.
# Pas de --delete, pour que les visiteurs avec une ancienne page HTML chargent encore ses chunks.
aws s3 sync ./out/_next/static "$BUCKET/_next/static" \
--cache-control "public,max-age=31536000,immutable"
# 2. Tout le reste hors HTML (favicon, robots.txt, sitemap, images) : cache court
aws s3 sync ./out "$BUCKET" \
--delete \
--exclude "_next/static/*" --exclude "*.html" \
--cache-control "public,max-age=3600"
# 3. Le HTML en dernier, pour qu'il ne référence jamais des assets pas encore envoyés.
# Les navigateurs le revalident à chaque visite, et l'edge le garde jusqu'à l'invalidation ci-dessous.
aws s3 sync ./out "$BUCKET" \
--delete \
--exclude "*" --include "*.html" \
--cache-control "public,max-age=0,s-maxage=31536000,must-revalidate"
aws cloudfront create-invalidation \
--distribution-id "$DISTRIBUTION_ID" \
--paths "/*"Le script fait trois synchronisations distinctes, parce que les fichiers n'ont pas tous la même durée de cache. Seuls les fichiers dont le nom contient une empreinte, sous _next/static/ (les bundles JS/CSS d'un export statique Next.js ; sinon, le répertoire d'assets hashés de votre framework), reçoivent un cache d'un an en immutable, puisque leur nom change à chaque build. Les fichiers qui gardent le même nom d'un build à l'autre, comme favicon.ico, robots.txt, sitemap.xml ou les images de public/, reçoivent un cache court. Les marquer immutables figerait les anciennes versions dans les navigateurs pendant un an, et aucune invalidation n'atteint le cache d'un navigateur. Les fichiers HTML reçoivent max-age=0,must-revalidate, pour que les navigateurs vérifient s'il existe une nouvelle version à chaque visite, et s-maxage=31536000, pour que CloudFront les garde en edge jusqu'à ce que le déploiement suivant les invalide. Sans s-maxage, l'edge ne garderait le HTML qu'une seconde (le TTL minimum de la cache policy) et irait le chercher sur S3 à presque chaque requête. Pour le HTML, c'est donc l'invalidation en fin de script qui rend un déploiement visible.
Les invalidations ne sont plus gratuites au-delà des 1 000 premiers chemins par mois, mais à la fréquence de déploiement d'un site personnel ou d'une petite structure, /* reste suffisamment bon marché : pas la peine de calculer une liste précise des fichiers modifiés. N'optimisez ce point que lorsque vous déployez des dizaines de fois par jour.
En-têtes de cache et behaviors
La cache policy de la distribution et les en-têtes Cache-Control posés pendant la synchronisation sont faits pour fonctionner ensemble. Avec la policy managée CachingOptimized, CloudFront respecte les en-têtes Cache-Control de l'origine dans les limites du TTL minimum et maximum de la policy (d'une seconde à un an). Il ne retombe sur son TTL par défaut d'un jour que si l'origine n'en fournit pas, et c'est pour cela que l'étape de synchronisation fixe des en-têtes explicites sur chaque objet. Quand les deux sont présents, CloudFront utilise s-maxage pour son propre cache et les navigateurs utilisent max-age. La response headers policy de la section sur la sécurité ne modifie que les en-têtes envoyés aux navigateurs. Elle ne change pas la durée pendant laquelle CloudFront garde un objet en cache.
Vue d'ensemble
Le Terraform ici prend quelques heures à écrire une fois ; ensuite c'est un module qu'on copie et qu'on renomme.
Envie de faire tourner ça en production ?
Ce tutoriel couvre les concepts et l'architecture. Si vous voulez l'implémenter dans votre propre infrastructure, ou monter en compétence pour posséder ce sujet durablement, je propose du mentorat individuel construit autour de votre environnement réel, pas une formation générique.
Ce tutoriel
- Concepts clés et architecture principale
- Extraits de code illustratifs
- Le raisonnement derrière chaque décision
Mentorat individuel
- Des sessions de travail sur votre propre environnement
- Des réponses directes aux cas particuliers que vous rencontrez
- Un retour sur votre implémentation réelle
- Un accompagnement continu pendant que vous la construisez
Tutoriels similaires
Lambda & EventBridge : architecture événementielle
Concevez un pipeline événementiel sur Lambda et EventBridge qui gère messages empoisonnés, retries et cold starts.
Sécuriser un cluster AWS EKS de production
Trois contrôles qui distinguent un cluster EKS durci d'un cluster par défaut : IRSA, NetworkPolicies et OPA Gatekeeper.
Nginx en reverse proxy avec Let's Encrypt SSL
Un reverse proxy Nginx durci devant votre application, avec des certificats Let's Encrypt qui se renouvellent vraiment tout seuls.