El contexto
Tenía un landing page construido con Astro 4 para [factufacil.pe](https://factufacil.pe), con un pipeline de GitHub Actions que usaba OIDC para asumir un IAM Role y deployar a S3 + CloudFront.
El objetivo: migrar todo a AWS. Nada de GitHub. El código vive en CodeCommit, el build corre en CodeBuild, y el orquestador es CodePipeline. Infraestructura declarada con Terraform.
Arquitectura final
┌─────────────────────────────────────────────────────────────────┐
│ DEVELOPER │
│ git push codecommit main │
└───────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ AWS CodeCommit │
│ repo: factufacil-landing (main) │
└───────────────────────────┬─────────────────────────────────────┘
│ PollForSourceChanges
▼
┌─────────────────────────────────────────────────────────────────┐
│ AWS CodePipeline │
│ factufacil-landing-pipeline │
│ │
│ ┌──────────┐ ┌──────────────────────────────────┐ │
│ │ Source │────────▶│ Build │ │
│ │CodeCommit│ │ CodeBuild │ │
│ └──────────┘ │ npm ci → build → s3 sync → CF │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────────────────┐
│ S3 Bucket │ │ CloudFront Distribution │
│ factufacil.pe │ │ E1F5TQ6MXTTBPU │
│ (static hosting) │ │ factufacil.pe │
└─────────────────────┘ └─────────────────────────────────┘
Infraestructura como Código con Terraform
Toda la infraestructura de CI/CD está declarada en Terraform. La estructura del directorio infra/:
infra/
├── providers.tf # AWS provider + alias us-east-1 para ACM
├── variables.tf # Variables del proyecto
├── main.tf # S3, CloudFront OAC, ACM (infra base)
├── pipeline.tf # CodeCommit, CodeBuild, CodePipeline, IAM
└── outputs.tf # URLs y nombres de recursos creados
providers.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
}
# ACM para CloudFront SIEMPRE debe estar en us-east-1
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
variables.tf
variable "region" {
description = "AWS region principal"
type = string
default = "us-east-1"
}
variable "domain" {
description = "Dominio raíz"
type = string
default = "factufacil.pe"
}
variable "bucket_name" {
description = "Nombre del bucket S3"
type = string
default = "factufacil-landing"
}
variable "repo_name" {
description = "Nombre del repositorio CodeCommit"
type = string
default = "factufacil-landing"
}
variable "pipeline_branch" {
description = "Rama de CodeCommit que dispara el pipeline"
type = string
default = "main"
}
variable "existing_bucket_name" {
description = "Nombre del bucket S3 existente"
type = string
default = "factufacil.pe"
}
variable "existing_cloudfront_id" {
description = "ID de la distribución CloudFront existente"
type = string
default = "E1F5TQ6MXTTBPU"
}
pipeline.tf — el núcleo del CI/CD
# ─── DATA SOURCES — referencia a infra existente ──────────────────────────────
# Usamos data sources cuando los recursos ya existen y no queremos
# que Terraform los destruya/recree. Solo los leemos.
data "aws_s3_bucket" "landing" {
bucket = var.existing_bucket_name
}
data "aws_cloudfront_distribution" "landing" {
id = var.existing_cloudfront_id
}
# ─── CODECOMMIT ───────────────────────────────────────────────────────────────
resource "aws_codecommit_repository" "landing" {
repository_name = var.repo_name
description = "FactuFacil landing page source"
}
# ─── S3 — Artifact store para CodePipeline ────────────────────────────────────
resource "aws_s3_bucket" "pipeline_artifacts" {
bucket = "${var.bucket_name}-pipeline-artifacts"
}
resource "aws_s3_bucket_versioning" "pipeline_artifacts" {
bucket = aws_s3_bucket.pipeline_artifacts.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "pipeline_artifacts" {
bucket = aws_s3_bucket.pipeline_artifacts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# ─── IAM — CodeBuild ──────────────────────────────────────────────────────────
resource "aws_iam_role" "codebuild" {
name = "${var.bucket_name}-codebuild"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "codebuild.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "codebuild" {
role = aws_iam_role.codebuild.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Resource = "arn:aws:logs:*:*:*"
},
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
]
Resource = [
data.aws_s3_bucket.landing.arn,
"${data.aws_s3_bucket.landing.arn}/*",
aws_s3_bucket.pipeline_artifacts.arn,
"${aws_s3_bucket.pipeline_artifacts.arn}/*"
]
},
{
Effect = "Allow"
Action = "cloudfront:CreateInvalidation"
Resource = data.aws_cloudfront_distribution.landing.arn
}
]
})
}
# ─── CODEBUILD ────────────────────────────────────────────────────────────────
resource "aws_codebuild_project" "landing" {
name = "${var.bucket_name}-build"
service_role = aws_iam_role.codebuild.arn
build_timeout = 10
source {
type = "CODEPIPELINE"
buildspec = "buildspec.yml"
}
artifacts {
type = "CODEPIPELINE"
}
environment {
compute_type = "BUILD_GENERAL1_SMALL"
image = "aws/codebuild/standard:7.0"
type = "LINUX_CONTAINER"
environment_variable {
name = "S3_BUCKET"
value = var.existing_bucket_name
}
environment_variable {
name = "CLOUDFRONT_DISTRIBUTION_ID"
value = var.existing_cloudfront_id
}
}
logs_config {
cloudwatch_logs {
group_name = "/aws/codebuild/${var.bucket_name}"
stream_name = "build"
}
}
}
# ─── IAM — CodePipeline ───────────────────────────────────────────────────────
resource "aws_iam_role" "codepipeline" {
name = "${var.bucket_name}-codepipeline"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "codepipeline.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "codepipeline" {
role = aws_iam_role.codepipeline.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"codecommit:GetBranch",
"codecommit:GetCommit",
"codecommit:UploadArchive",
"codecommit:GetUploadArchiveStatus",
"codecommit:CancelUploadArchive"
]
Resource = aws_codecommit_repository.landing.arn
},
{
Effect = "Allow"
Action = [
"codebuild:BatchGetBuilds",
"codebuild:StartBuild"
]
Resource = aws_codebuild_project.landing.arn
},
{
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:PutObject",
"s3:GetBucketVersioning"
]
Resource = [
aws_s3_bucket.pipeline_artifacts.arn,
"${aws_s3_bucket.pipeline_artifacts.arn}/*"
]
}
]
})
}
# ─── CODEPIPELINE ─────────────────────────────────────────────────────────────
resource "aws_codepipeline" "landing" {
name = "${var.bucket_name}-pipeline"
role_arn = aws_iam_role.codepipeline.arn
artifact_store {
type = "S3"
location = aws_s3_bucket.pipeline_artifacts.bucket
}
stage {
name = "Source"
action {
name = "Source"
category = "Source"
owner = "AWS"
provider = "CodeCommit"
version = "1"
output_artifacts = ["source_output"]
configuration = {
RepositoryName = aws_codecommit_repository.landing.repository_name
BranchName = var.pipeline_branch
PollForSourceChanges = "true"
OutputArtifactFormat = "CODE_ZIP"
}
}
}
stage {
name = "Build"
action {
name = "Build"
category = "Build"
owner = "AWS"
provider = "CodeBuild"
version = "1"
input_artifacts = ["source_output"]
configuration = {
ProjectName = aws_codebuild_project.landing.name
}
}
}
}
El buildspec.yml
Este archivo vive en la raíz del proyecto y le dice a CodeBuild exactamente qué hacer. La clave está en la estrategia de caché diferenciada: los assets con hash (JS, CSS, imágenes) tienen cache de 1 año porque su nombre cambia con cada build. El HTML no tiene cache porque su nombre nunca cambia.
version: 0.2
phases:
install:
runtime-versions:
nodejs: 20
commands:
- npm ci
build:
commands:
- npm run build
post_build:
commands:
# Assets con hash en el nombre (JS/CSS/imágenes): cache agresivo de 1 año
- |
aws s3 sync dist/ s3://$S3_BUCKET \
--delete \
--exclude "*.html" \
--exclude "*.xml" \
--exclude "*.txt" \
--cache-control "public, max-age=31536000, immutable"
# HTML y archivos sin hash: sin cache (siempre frescos)
- |
aws s3 sync dist/ s3://$S3_BUCKET \
--exclude "*" \
--include "*.html" \
--include "*.xml" \
--include "*.txt" \
--cache-control "public, max-age=0, must-revalidate"
# Invalida CloudFront para que los edges sirvan el HTML nuevo
- aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths "/*"
artifacts:
files:
- "**/*"
base-directory: dist
Por qué dos syncs separados
Un solo aws s3 sync no puede aplicar distintos Cache-Control por tipo de archivo. La solución: dos pasadas.
| Pasada | Archivos | Cache-Control | Razón |
|--------|----------|---------------|-------|
| 1° | JS, CSS, imágenes | `max-age=31536000, immutable` | El hash en el nombre garantiza que cambian con cada deploy |
| 2° | HTML, XML, TXT | `max-age=0, must-revalidate` | Mismos nombres entre deploys, deben estar siempre frescos |
Recursos AWS creados
| Recurso | Nombre | Propósito |
|---------|--------|-----------|
| CodeCommit | `factufacil-landing` | Repositorio Git |
| CodeBuild | `factufacil-landing-build` | Compilación y deploy |
| CodePipeline | `factufacil-landing-pipeline` | Orquestador CI/CD |
| S3 | `factufacil-landing-pipeline-artifacts` | Artifacts intermedios del pipeline |
| IAM Role | `factufacil-landing-codebuild` | Permisos para CodeBuild |
| IAM Role | `factufacil-landing-codepipeline` | Permisos para CodePipeline |
Infraestructura existente (referenciada, no recreada)
| Recurso | ID/Nombre |
|---------|-----------|
| S3 Bucket | `factufacil.pe` |
| CloudFront | `E1F5TQ6MXTTBPU` → `factufacil.pe` |
| ACM Certificate | `*.factufacil.pe` (ISSUED, us-east-1) |
Configurar credenciales Git para CodeCommit
CodeCommit HTTPS usa credenciales específicas del servicio, distintas a las credenciales AWS normales. Se crean por usuario IAM:
aws iam create-service-specific-credential \
--user-name TU_USUARIO_IAM \
--service-name codecommit.amazonaws.com
La respuesta incluye ServiceUserName y ServicePassword. Con eso configurás el remote:
# Agregar CodeCommit como remote adicional (sin eliminar GitHub)
git remote add codecommit \
https://<ServiceUserName>:<ServicePassword_URL_encoded>@git-codecommit.us-east-1.amazonaws.com/v1/repos/factufacil-landing
# Push
git push codecommit main
Desplegar solo los recursos de CI/CD con `-target`
El problema real: la infraestructura base (S3, CloudFront, ACM) ya existía desplegada manualmente. Si corría terraform apply completo, fallaba porque CloudFront no admite dos distribuciones con el mismo alias de dominio.
La solución fue doble:
1. Data sources en vez de recursos:
# En lugar de crear nuevos recursos, leemos los existentes
data "aws_s3_bucket" "landing" {
bucket = var.existing_bucket_name
}
data "aws_cloudfront_distribution" "landing" {
id = var.existing_cloudfront_id
}
2. Apply con `-target` para crear solo el pipeline:
terraform apply \
-target=aws_codecommit_repository.landing \
-target=aws_s3_bucket.pipeline_artifacts \
-target=aws_iam_role.codebuild \
-target=aws_iam_role_policy.codebuild \
-target=aws_codebuild_project.landing \
-target=aws_iam_role.codepipeline \
-target=aws_iam_role_policy.codepipeline \
-target=aws_codepipeline.landing \
-auto-approve
El bug que no esperaba: `DetectChanges` no existe
El primer apply de CodePipeline falló con:
InvalidActionDeclarationException: Action configuration for action 'Source'
contains unknown configuration 'DetectChanges'
Resulta que para la fuente CodeCommit en CodePipeline V1, el parámetro correcto es PollForSourceChanges, no DetectChanges (que sí existe en otros contextos de AWS).
# ❌ Incorrecto
configuration = {
DetectChanges = "true"
}
# ✅ Correcto
configuration = {
PollForSourceChanges = "true"
}
Verificar la ejecución del pipeline
# Ver el estado de cada stage
aws codepipeline get-pipeline-state \
--name factufacil-landing-pipeline \
--query "stageStates[].{Stage:stageName,Status:latestExecution.status}" \
--output table
# Disparar manualmente si no detectó el push automático
aws codepipeline start-pipeline-execution \
--name factufacil-landing-pipeline
Output esperado cuando todo va bien:
+---------+-------------+
| Stage | Status |
+---------+-------------+
| Source | Succeeded |
| Build | Succeeded |
+---------+-------------+
Tips que te van a ahorrar tiempo
💡 Tip 1 — `PollForSourceChanges`, no `DetectChanges`
El parámetro para que CodePipeline escuche cambios en CodeCommit se llama PollForSourceChanges. Si ponés DetectChanges (que existe en otros contextos de AWS), el apply falla con un error críptico de InvalidActionDeclarationException. Este error cuesta tiempo la primera vez.
💡 Tip 2 — Usá `data sources` cuando la infra ya existe
Si S3, CloudFront o ACM ya están creados fuera del state de Terraform, no intentes recrearlos — CloudFront rechaza dos distribuciones con el mismo alias de dominio. La solución es data "aws_s3_bucket" y data "aws_cloudfront_distribution": leés los atributos sin que Terraform tome posesión del recurso.
💡 Tip 3 — El password de CodeCommit necesita URL encoding
Las credenciales Git de CodeCommit suelen tener + y =. En la URL del remote Git esos caracteres rompen la autenticación. Encodealos: + → %2B, = → %3D. Si el push falla con 401, es esto.
💡 Tip 4 — S3 website hosting ≠ OAC, son incompatibles
S3 como sitio web estático usa el endpoint *.s3-website-*.amazonaws.com. OAC (Origin Access Control), el método moderno, requiere el endpoint regional del bucket. No son intercambiables en la misma distribución de CloudFront — elegí uno desde el principio.
💡 Tip 5 — IAM mínimo por rol, siempre separados
CodeBuild y CodePipeline tienen responsabilidades distintas: roles distintos con permisos mínimos. CodeBuild necesita escribir en S3 e invalidar CloudFront. CodePipeline solo necesita leer de CodeCommit y disparar CodeBuild. Nunca uses AdministratorAccess en automatización.
💡 Tip 6 — Dos `s3 sync` para cache headers diferenciados
Un solo comando aws s3 sync no puede aplicar distintos Cache-Control por tipo de archivo. La solución: primera pasada para assets con hash (JS/CSS/imágenes) con max-age=31536000, immutable, segunda pasada para HTML con max-age=0, must-revalidate. Así tus usuarios siempre ven el HTML más reciente y aprovechan el cache de assets.
💡 Tip 7 — `npm ci` en lugar de `npm install` en CI
npm ci instala exactamente lo que está en package-lock.json, es más rápido, y falla si hay discrepancias entre el lock y el package.json. npm install puede actualizar dependencias silenciosamente y producir builds que no se pueden reproducir.
💡 Tip 8 — Usá `-target` solo para situaciones excepcionales
terraform apply -target es una herramienta de escape, no de uso cotidiano. Es útil cuando tenés infra preexistente fuera del state. El camino limpio a largo plazo es terraform import para incorporar esos recursos al state y poder gestionarlos todo junto.
Outputs de Terraform
output "codecommit_clone_https" {
value = aws_codecommit_repository.landing.clone_url_http
}
output "codecommit_clone_ssh" {
value = aws_codecommit_repository.landing.clone_url_ssh
}
output "codepipeline_name" {
value = aws_codepipeline.landing.name
}
Resultado:
codecommit_clone_https = "https://git-codecommit.us-east-1.amazonaws.com/v1/repos/factufacil-landing"
codecommit_clone_ssh = "ssh://git-codecommit.us-east-1.amazonaws.com/v1/repos/factufacil-landing"
codepipeline_name = "factufacil-landing-pipeline"
Flujo completo de trabajo
# 1. Hacer cambios en el código
vim src/pages/index.astro
# 2. Commit
git add .
git commit -m "feat: actualizar hero section"
# 3. Push a CodeCommit (dispara el pipeline automáticamente)
git push codecommit main
# 4. Verificar el pipeline (opcional)
aws codepipeline get-pipeline-state \
--name factufacil-landing-pipeline \
--query "stageStates[].{Stage:stageName,Status:latestExecution.status}" \
--output table
En menos de 2 minutos el sitio está actualizado en producción.
Stack completo
| Categoría | Tecnología |
|-----------|-----------|
| Framework | Astro 4 |
| Estilos | Tailwind CSS 3 |
| Source control | AWS CodeCommit |
| Build | AWS CodeBuild (Node 20, standard:7.0) |
| Pipeline | AWS CodePipeline V1 |
| Hosting | AWS S3 (static website) |
| CDN | AWS CloudFront |
| Certificado SSL | AWS ACM (us-east-1) |
| IaC | Terraform >= 1.5, provider AWS ~> 5.0 |
| Logs | AWS CloudWatch Logs |