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

text
                ┌─────────────────────────────────────────────────────────────────┐
│                        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/:

text
                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

hcl
                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

hcl
                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

hcl
                # ─── 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.

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

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

text
                | 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)

text
                | 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:

bash
                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:

bash
                # 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:

hcl
                # 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:

bash
                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:

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

hcl
                # ❌ Incorrecto
configuration = {
  DetectChanges = "true"
}

# ✅ Correcto
configuration = {
  PollForSourceChanges = "true"
}
              

Verificar la ejecución del pipeline

bash
                # 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:

text
                +---------+-------------+
|  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

hcl
                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:

text
                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

bash
                # 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

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