Una de las ventajas que tenemos cuando desarrollamos aplicaciones web estáticas, desde sitios estáticos con enlaces entre páginas hasta aplicaciones «enriquecidas» con frameworks de desarrollo como Angular, es que no necesitamos un servidor que haga un renderizado de las páginas, si no que es el usuario con su navegador el que se encarga de esa tarea.
Para ello, las diferentes nubes nos ofrecen la posibilidad de ofrecer estos sitios a través de la generación de un CDN que sirvan estos documentos a partir del contenido de un contenedor de objetos, como puede ser un S3 dentro de Amazon o blobs dentro de Azure.
Este enfoque tiene ventajas de escalabilidad y resiliencia que serían difíciles de replicar a nivel de servicios si no fuese con un incremento significativo de costes.

Antes de nada, en la cabecera hablábamos de una ventaja significativa a nivel de costes y escalabilidad utilizando un CDN y un sitio estático alojado en un S3 frente a una infraestructura «tradicional» en la que tenemos que aprovisionar servidores web que sirvan nuestro sitio. Pero vamos a echar unos pequeños cálculos:
1. Escalabilidad
En términos de escalabilidad y durabilidad, los servicios como Amazon S3 están preparados para escalar hasta Exabytes de espacio y tener una durabilidad de hasta 11 nueves (con copias desde un mínimo de tres servidores dentro de la región), mientras que CloudFront realiza una copia en los endpoints más cercanos a los usuarios que trabajan con la aplicación, y mantiene la copia hasta la caducidad del contenido. Es decir, para alcanzar una disponibilidad parecida a la de S3, como mínimo tendríamos que disponer de tres servidores web con el contenido sincronizado, situados en diferentes ubicaciones.
2. Coste
Aquí vamos a analizar los costes que tendrá la infraestructura que acabamos de definir.
| CloudFront | El primer TB de salida a internet es gratuito, así como los primeros 10 millones de solicitudes al mes. |
| Amazon S3 | Los primeros 50 TB cotizan a 0,023 USD/Gb en la región de España |
Con los datos que acabamos de ver, el alojamiento y todo el tráfico que se produjese en nuestro sitio estático o SPA tendría un coste cercano a cero o muy pocos céntimos al mes, independientemente del tráfico que tenga la aplicación.
Es momento de empezar a configurar nuestra infraestructura. Antes de nada, vamos a desglosar las partes que necesitaremos para que todo funcione correctamente.
1. Un bucket de S3.
Este bucket deberá de tener una configuración orientada a que sea legible desde CloudFront, con las políticas de acceso y ACL correspondientes a un sitio web estático.
2. CloudFront configurado para leer de S3
Un perfil de distribución de CloudFront listo para leer de S3, junto con un certificado configurado dentro de Certificate Manager listo para ser utilizado en el S3.
3. Zona dentro de Route53
Una zona con un dominio asociado en Route53. Esto no es obligatorio, pero en este artículo hemos decidido utilizar la infraestructura de Amazon AWS para todo el sistema, así que vamos a definiremos las entradas DNS en un dominio adquirido en la propia AWS.
4. Un sitio preparado para ser desplegado
Obviamente, necesitaremos previamente un sitio estático listo para ser desplegado. Como comentamos, podremos utilizar un sitio totalmente estático (archivos html), o lo más práctico podría ser una aplicación SPA de renderizado en cliente.
Ahora que conocemos las partes con las que tenemos que trabajar, vamos a estructurar nuestro proyecto y definir nuestros ficheros de Terraform.
Por qué Terraform. Terraform es un sistema definido por Hashicorp que nos permite definir en un modelo IaC (Infrastructure-as-Code), la infraestructura necesaria para el despliegue de nuestras aplicaciones. La principal ventaja frente a otros sistemas es que es compatible prácticamente con todos los proveedores de nube y también con sistemas in-house.
Tradicionalmente, mi forma de estructurar los proyectos se basa en los directorios que muestro a continuación:
A continuación os muestro los ficheros de despliegue que irían dentro de la carpeta iac y luego desgranamos el resultado.
Punto de entrada a la aplicación
# /variables.tf
# ---------------------------
# La variable de DOMAIN_NAME nos servirá para definir el dominio que se configurará dentro de la infraestructura
variable "DOMAIN_NAME" {
type = string
}
# /main.tf
# ---------------------------
# Entrada a Terraform, cargando el proveedor de AWS, que ahora va por la v6.x
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~>6.0"
}
}
}
data "aws_caller_identity" "current" {}
Certificado SSL
Una de las partes que necesitaremos antes de seguir configurando nuestro proyecto, será definir un certificado que asegure nuestro sitio web. Esto lo podemos hacer de manera gratuita con Certificate Manager de AWS, con la particularidad de que deberemos de introducir varias entradas DNS en el gestor del dominio -en nuestro caso Route53-, para validar que se tiene la autorización sobre el dominio.
Como todo este proceso es un poco estándar, independientemente de que estemos montando un CDN, un balanceador de carga o cualquier punto de entrada de AWS -como una virtual en EC2-, vamos a definir un módulo que, tomando como entrada los datos de la zona hospedada en Route53, genere el certificado y nos dé como salida el identificador del recurso del certificado en AWS y su ARN para poder referenciarlo en nuestro destino.
Este módulo lo vamos a ubicar dentro de la carpeta modules/acm, y tendremos los siguientes ficheros dentro del mismo:
# /modules/acm/variables.tf
# --------------------------------
variable "zone_id" {
type = string
}
variable "domain_name" {
type = string
}
variable "subject_alternative_names" {
type = list(string)
}
variable "region" {
type = string
}
variable "validation_record_ttl" {
default = "300"
}
variable "tags" {
type = map(string)
}
# /modules/acm/outputs.tf
# -----------------------------
output "certificate_id" {
value = aws_acm_certificate.certificate.id
}
output "certificate_arn" {
value = aws_acm_certificate_validation.certificate.certificate_arn
}
# /modules/acm/main.tf
# -----------------------------
terraform {
required_providers {
aws = {}
}
}
provider aws {
region = var.region
}
# Definición del recurso del certificado con el nombre del dominio
# junto con el método de validación y los nombres que pueden tomar
resource "aws_acm_certificate" "certificate" {
domain_name = var.domain_name
validation_method = "DNS"
subject_alternative_names = var.subject_alternative_names
tags = var.tags
lifecycle {
create_before_destroy = true
}
}
# Recurso donde se recorre las entradas que se deben introducir para
# validar el certificado, y se añaden al dominio en Route53
resource "aws_route53_record" "validation" {
for_each = {
for dvo in aws_acm_certificate.certificate.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
} if dvo.domain_name == var.domain_name || contains(var.subject_alternative_names, dvo.domain_name)
}
name = each.value.name
type = each.value.type
zone_id = var.zone_id
records = [each.value.record]
ttl = var.validation_record_ttl
}
# Recurso sin entidad asociada en AWS que notifica de la validación
# exitosa del certificado
resource "aws_acm_certificate_validation" "certificate" {
certificate_arn = aws_acm_certificate.certificate.arn
validation_record_fqdns = [for record in aws_route53_record.validation : record.fqdn]
}
Bucket de S3
Ahora es momento de crear los recursos asociados al sitio en sí mismo. Empezaremos con el bucket de S3 con la que definiremos nuestro sitio para que sea un site público y tenga los permisos necesarios para que CloudFront pueda adquirir los ficheros y servirlos. Tradicionalmente, el nombre del bucket en el caso de sitios públicos, se pone con el mismo nombre que el dominio desde el que se va a servir. En nuestro caso simplemente es una nomenclatura sin impacto real, pero que puede ser positiva para localizar de manera rápida los sitios que tenemos dentro de una región.
# /_s3_site.tf
# ---------------------------------------------
# Declaración del recurso del bucket en sí mismo
resource "aws_s3_bucket" "static_website" {
tags = {
Name = "Static website"
}
force_destroy = true
bucket = var.DOMAIN_NAME
}
# Definición de acceso público al bucket, permitiendo acceso de lectura
resource "aws_s3_bucket_public_access_block" "static_website" {
bucket = aws_s3_bucket.static_website.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
resource "aws_s3_bucket_ownership_controls" "static_website" {
bucket = aws_s3_bucket.static_website.id
rule {
object_ownership = "ObjectWriter"
}
}
resource "aws_s3_bucket_acl" "static_website" {
bucket = aws_s3_bucket.static_website.id
acl = "private"
}
# Esta configuración la habilitaremos en el caso de que sea una aplicación de Angular
# resource "aws_s3_bucket_website_configuration" "static_website" {
# bucket = aws_s3_bucket.static_website.id
# index_document {
# suffix = "index.html"
# }
# error_document {
# key = "index.html"
# }
# }
# Política que permite a cloudfront capturar los ficheros desde el bucket
data "aws_iam_policy_document" "static_website_access" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
actions = [
"s3:GetObject"
]
resources = [
"${aws_s3_bucket.static_website.arn}/*"
]
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [
aws_cloudfront_distribution.static_website.arn
]
}
}
}
resource "aws_s3_bucket_policy" "static_website" {
bucket = aws_s3_bucket.static_website.id
policy = data.aws_iam_policy_document.static_website_access.json
depends_on = [aws_s3_bucket_acl.static_website]
}
Aclaraciones sobre el bloque anterior:
1. Desde este bloque hacemos referencia al recurso de CloudFront, que todavía no tenemos definido. Aunque pueda parecer que hay una referencia circular, ésta no se llega a producir nunca, puesto que la creación del recurso base no dependen entre sí, si no que son en la configuración de opciones cuando se crean estas vinculaciones.
2. Hay un área comentada que hace referencia a aplicaciones de Angular. Es importante aclarar que, cuando en nuestras apps de Angular no hemos activado SSR, y son aplicaciones web estáticas, podemos servirlas desde un S3 «aprovechándonos» de todo este potencial que tiene esta estructuración – o utilizar Amazon AWS Amplify -.
Esta configuración que vemos comentada sirve para que, teniendo en cuenta que las aplicaciones de Angular hacen un enrutado virtual – la página de destino no existe en sí misma -, cuando CloudFront o el sitio de S3 no encuentre el recurso solicitado, lo redirija automáticamente a utilizar el index.html. De esta manera conseguiremos, entre otras cosas, que si alguien accede a un área de nuestra aplicación SPA sin pasar por la raíz y haciendo todo el flujo de navegación, ésta se sirva como procede.
CDN con CloudFront
Ahora es momento de configurar nuestro CloudFront para que podamos servir nuestro sitio en cualquier punto del planeta desde los endpoints disponibles.
# /_cloudfront.tf
# -------------------------------------
# Política para acceder desde CloudFront a S3 que luego adjuntaremos a nuestro recurso
resource "aws_cloudfront_origin_access_control" "default" {
name = "example"
description = "Example Policy"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
# Variable local con el nombre del recurso que hemos definido de S3
locals {
s3_origin_id = "static_website_s3"
}
# Generación del certificado SSL utilizando el módulo de ACM
module "static_website_cm" {
source = "./modules/acm"
zone_id = data.aws_route53_zone.current.zone_id
domain_name = var.DOMAIN_NAME
subject_alternative_names = ["www.${var.DOMAIN_NAME}"]
region = "us-east-1" # Siempre en esta región
tags = {
Name = "Static website"
}
}
# Recurso en sí mismo de CloudFront
resource "aws_cloudfront_distribution" "static_website" {
origin {
domain_name = aws_s3_bucket.static_website.bucket_regional_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.default.id
origin_id = local.s3_origin_id
}
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
aliases = ["www.${var.DOMAIN_NAME}", "${var.DOMAIN_NAME}"]
# Comportamiento de caché por defecto
default_cache_behavior {
allowed_methods = ["HEAD", "GET"]
cached_methods = ["GET", "HEAD"]
target_origin_id = local.s3_origin_id
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "redirect-to-https"
min_ttl = 0
default_ttl = 60
max_ttl = 300
# Esta función la comentaremos en anotaciones
function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.www_to_non_www_redirect.arn
}
}
# Asignación de certificado
viewer_certificate {
acm_certificate_arn = module.static_website_cm.certificate_arn
ssl_support_method = "sni-only"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
}
# Recurso de función
resource "aws_cloudfront_function" "www_to_non_www_redirect" {
name = "www_to_non_www_redirect"
runtime = "cloudfront-js-1.0"
publish = true
code = file("${path.module}/cloudfront_functions/redirect_www_to_non_www.js")
}
# cloudfront_functions/redirect_www_to_non_www.js
# ---------------------------------------------------
function handler(event){
var host =
(event.request.headers && event.request.headers.host &&
event.request.headers.host.value) ||
'';
var redirect = false;
if (host.indexOf("www.") == 0){
redirect = true;
}
if (event.request.uri != event.request.uri.toLowerCase()){
redirect = true;
}
if (redirect){
var response = {
statusCode: 301,
statusDescription: "Found",
headers: {
"location": { "value": `https://${host.replace("www.", "")}${event.request.uri.toLowerCase()}` }
}
}
return response;
}
return event.request;
}
Desgranando la función. Como vemos, hemos construido una función sobre CloudFront que nos permite hacer una redirección de HTTP a HTTPS, así como de www.* a no www. Esta función mejora el comportamiento del sitio respecto a buscadores y genera enlaces permanentes que informan al navegador que, en un caso futuro, directamente puede viajar directamente al destino.
Route53. La pieza final
Ahora que ya tenemos los recursos preparados para servir nuestro sitio web estático, es momento de apuntar las direcciones de dominio dentro de Route53 a nuestro CloudFront y que éste empiece a procesar llamadas.
# /_route53.tf
# --------------------------------
data "aws_route53_zone" "current" {
name = "${var.DOMAIN_NAME}."
private_zone = false
}
resource "aws_route53_record" "api" {
zone_id = data.aws_route53_zone.current.zone_id
name = var.DOMAIN_NAME
type = "A"
alias {
name = aws_cloudfront_distribution.static_website.domain_name
zone_id = aws_cloudfront_distribution.static_website.hosted_zone_id
evaluate_target_health = true
}
}
resource "aws_route53_record" "static_website_www" {
zone_id = data.aws_route53_zone.current.zone_id
name = "www.${var.DOMAIN_NAME}"
type = "CNAME"
ttl = 300
records = ["${var.DOMAIN_NAME}"]
}
Este fichero tiene poco más que agregar. Por una parte apuntamos al recurso de la zona de DNS de nuestro dominio en Route53, y después hacemos un enlace A a nuestro CloudFront (en el ejemplo solo IPv4, pero podríamos crear el enlace correspondiente a AAAA para IPv6).
Por último agregamos una entrada de redirección canónica desde www al no www. Aunque tengamos la función, con esta entrada nos quitaremos bastante peso en las llamadas que tengamos a CloudFront.
Ha llegado el momento final. Contando que tengamos ya la infraestructura implementada, vamos a realizar una acción de GitHub que nos permita desplegar los ficheros que tengamos dentro de nuestra carpeta src.
# .github/workflows/deploy.yml
# -----------------------------------------
name: "Deploy static website"
on:
push:
branches: ["develop","main"]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
name: "Deploy on S3"
needs: [build]
steps:
- id: install-aws-cli
uses: unfor19/install-aws-cli-action@v2
- uses: actions/download-artifact@v3
id: download
with:
path: artifacts
- name: Upload to S3
run: |
aws s3 sync --region eu-west-1 --include "*.*" --acl public-read ./artifacts/data s3://bucket-name
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Invalidate CloudFront
uses: chetan/invalidate-cloudfront-action@v2
env:
DISTRIBUTION: ${{ secrets.DISTRIBUTION }}
PATHS: "/*"
AWS_REGION: "us-east-1"
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Este fichero es un parcial que estaría orientado al despliegue. Tenemos que tener en cuenta que, antes de hacer una subida de los ficheros, tendremos que hacer compilaciones intermedias si las hay (Angular, SaSS, etc.).
Los pasos que describe el fichero son muy sencillos:
– Primero hace la instalación de la AWS CLI necesaria para hacer la subida de los ficheros.
– A continuación descarga los «artifacts» creados en pasos anteriores, con la compilación del sitio que se va a desplegar
– En un tercer paso hace una subida al bucket de los ficheros del artefacto
– Por último, invalida la caché de CloudFront para que la nueva versión quede disponible desde el primer momento.
En este artículo hemos tratado cómo desplegar un sitio estático en una infraestructura de alta resiliencia y escalabilidad por muy poco dinero, siempre utilizando herramientas de uso gratuito tanto profesional como personal.
No obstante, dentro de Monkey Dev tenemos el compromiso y la férrea determinación de que, como europeos, deberíamos de reducir nuestra dependencia a servicios de empresas que no sean europeas. Por ello, en el próximo artículo orientado a web estáticas, trabajaremos contra proveedores totalmente europeos.