Hacía mucho que no hacía nada tipo frontend. Hace poco me animé y me dedicí a crear una web tipo currículum vitae. Estuve mirando y no me gustaba ningún template hasta que encontré este. Me gustó mucho el rollo terminal de los años 80 y la verdad es que lo he fusilado, sin contemplaciones. Pero claro, tenía dudas de como desplegar esto en AWS. ¿Cómo desplegar un proyecto frontend hoy en día? ¿Con qué tecnología?¿Framework? De eso hablaré en este post. Vamos hablar de como desplegar una aplicación React con Vite en AWS.
Diagrama de infraestructura
Como siempre una imagen vale más que mil palabras.

Arquitectura
La arquitectura final quedó así:
GitHub
│
▼
GitHub Actions
│
├── npm ci / ESLint / TypeScript / Vite
├── Terraform fmt / validate / TFLint / plan
│
▼
Terraform
│
├── S3 privado
├── CloudFront
├── ACM
├── Route53
├── CloudFront Function
└── IAM / OIDC
│
▼
S3
│
▼
CloudFront
│
▼
https://cv.rubenortiz.es
El frontend está hecho con React, TypeScript y Vite, mientras que toda la infraestructura AWS está definida con Terraform.
No hay servidores ni contenedores ejecutándose permanentemente. Vite genera los ficheros estáticos y estos se publican en S3.
S3 privado y CloudFront
El bucket que contiene el frontend no es público.
Internet
│
▼
CloudFront
│
│ OAC
▼
Private S3 Bucket
CloudFront accede al bucket mediante Origin Access Control (OAC). De esta manera, los usuarios no acceden directamente a S3 y todo el tráfico pasa por CloudFront.
También configuré:
cv.rubenortiz.es
│
▼
Route53
│
▼
CloudFront
│
▼
ACM certificate
El certificado HTTPS se gestiona con ACM en us-east-1, como requiere CloudFront, mientras que el resto de infraestructura está principalmente en eu-west-1.
AWS sin access keys en GitHub
Uno de los requisitos desde el principio era no guardar:
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
como secretos permanentes de GitHub.
Para eso configuré OIDC entre GitHub Actions y AWS.
GitHub Actions
│
│ OIDC token
▼
AWS IAM
│
▼
AssumeRole
│
▼
Credenciales temporales
GitHub obtiene credenciales AWS temporales únicamente durante la ejecución del workflow.
Además, la trust policy está restringida al repositorio concreto utilizando los identificadores del repository y owner, en lugar de depender únicamente del nombre del repositorio.
Headers de seguridad
La siguiente mejora ha sido preparar una CloudFront Response Headers Policy gestionada desde Terraform.
La policy incluye:
Content-Security-Policy
Strict-Transport-Security
X-Content-Type-Options
X-Frame-Options
Referrer-Policy
Permissions-Policy
Por ejemplo, la CSP empieza con:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
object-src 'none';
frame-ancestors 'none';
El 'unsafe-inline' en estilos todavía es necesario porque el 404.html contiene CSS inline. Es una de las cosas que se podría endurecer más adelante moviendo ese CSS a un fichero separado.
La policy ya está definida en Terraform; el siguiente paso es asociarla definitivamente al cache behavior de CloudFront y comprobar los headers desde fuera con curl.
CI/CD con GitHub Actions
El pipeline se ejecuta tanto en Pull Requests como en main.
Para el frontend ejecuta:
npm ci
npm run lint
npm run build
node scripts/generate-static-html.mjsY el propio build ya incluye la validación TypeScript:
"build": "tsc -b && vite build"Por tanto, no necesitaba añadir un segundo tsc en la CI.
Para Terraform ejecuto:
terraform fmt -check
terraform init
terraform validate
tflint
terraform planEl apply únicamente se ejecuta después de hacer merge a main.
Después se publica el frontend:
aws s3 sync application/dist/ s3://rubenortiz-es-cv-frontend --deletey finalmente se invalida la caché de CloudFront:
aws cloudfront create-invalidation \<br> --distribution-id xxxxxxxxxx \<br> --paths "/*"DependaBot
Dependabot quedó configurado para revisar npm y GitHub Actions. Al activarlo detectamos dos cosas importantes: que el proyecto debía alinearse en Node 24 entre local, CI y @types/node, y que TypeScript 7 todavía no era compatible con typescript-eslint, por lo que mantuvimos TypeScript 6 sin forzar dependencias.
Además, añadimos .nvmrc y actualizamos las principales GitHub Actions del pipeline. En la práctica, Dependabot nos sirvió tanto para actualizar dependencias como para detectar incompatibilidades y desalineaciones del entorno.
Componentes
Vite
Vite es una herramienta de construcción (build tool) de frontend de última generación y un servidor de desarrollo local que proporciona una experiencia de desarrollo ultrarrápida para proyectos web modernos. Creado por Evan You (el creador de Vue.js) y mantenido por la comunidad junto a VoidZero, evita el empaquetado (bundling) tradicional previo durante el desarrollo para ofrecer inicios de servidor instantáneos y actualizaciones en caliente inmediatas.
Cuando estamos en local y ejecutamos
npm run deveso es Vite por detrás.
Piensa en Vite como esto
levanta un servidor local
↓
sirve index.html
↓
carga src/main.tsx
↓
procesa TypeScript / JSX / imports
↓
te muestra la app en localhost:5173Además, mientras desarrollas, Vite hace cosas muy útiles como actualizar la página casi al instante cuando cambias código.
Sin embargo, cuando hacemos
npm run buildAhí ya no “sirve” la web.
Lo que hace es transformar el proyecto fuente:
.ts
.tsx
CSS
assets
importsen archivos finales optimizados:
dist/
├── index.html
├── assets/
│ ├── index-xxxxx.js
│ └── index-xxxxx.csses decir
código que escribes
↓
Vite build
↓
HTML + JS + CSS optimizados
↓
dist/En resumen, Vite es la herramienta que levanta tu frontend en local y genera la versión optimizada para producción.
React
React es la librería con la que construimos la interfaz de la web usando componentes reutilizables.
Cada bloque es un componente React. React los combina y los renderiza en el navegador.
Además, permite que la interfaz cambie dinámicamente cuando cambian datos o estados, sin tener que recargar toda la página.
En resumen, React es librería para construir interfaces web mediante componentes reutilizables y dinámicos.
TypeScript
TypeScript es JavaScript con tipos. Nos ayuda a detectar errores antes de ejecutar la aplicación y hace el código más claro y mantenible.
Por ejemplo, podemos definir que una experiencia laboral debe tener:
type CareerItem = {
company: string
role: string
}
export const career: CareerItem[] = [...]Así, si falta un dato o usamos un tipo incorrecto, TypeScript nos avisa.
En resumen, TypeScript es JavaScript con tipos para detectar errores antes y hacer el código más seguro.
Bootstrap del proyecto
Para comenzar el proyecto ejecutamos
npm create vite@latestVite generó la estructura inicial del proyecto, incluyendo:
index.html
src/main.tsx
src/App.tsx
package.json
...Como funciona
main.tsx es el punto de entrada: arranca React y monta la aplicación en el HTML.
Vite crea index.html
↓
index.html carga main.tsx
↓
main.tsx arranca React
↓
React renderiza App.tsxApp.tsx es el componente principal, el que une las grandes secciones del proyecto.
App
├── Hero
├── About
├── Experience
├── Skills
└── ProjectSEO dinámico en una SPA detrás de CloudFront
Uno de los problemas que encontramos al desplegar el CV como una SPA fue el SEO.
En una aplicación React tradicional, muchas rutas terminan sirviendo el mismo index.html. Para navegación normal esto funciona, pero para SEO no es suficiente, porque cada URL debería poder exponer su propio contenido HTML relevante, especialmente elementos como:
<title><meta name="description"><link rel="canonical">- metadatos Open Graph
- cualquier otro dato específico de esa página
El problema era, por tanto, este:
/career
/about
/projectspodían acabar devolviendo el mismo HTML base, aunque semánticamente fueran páginas distintas.
Para resolverlo, generamos un HTML específico para cada ruta, de forma que cada una pudiera tener su propio SEO.
Conceptualmente:
/career
↓
career/index.html
↓
canonical propio
title propio
description propia
/about
↓
about/index.html
↓
canonical propio
title propio
description propiaEl siguiente reto era hacer que CloudFront supiera qué fichero debía servir para cada petición.
Para ello añadimos una función asociada a CloudFront que se ejecuta antes de llegar al origen. Su trabajo es inspeccionar la URI solicitada y transformarla en la ruta física del HTML correspondiente.
Por ejemplo:
Petición:
https://www.ejemplo.es/career
CloudFront recibe:
/career
Función:
/career
↓
/career/index.html
Origen:
S3 devuelve career/index.htmlDe esta forma, CloudFront sigue exponiendo URLs limpias:
/career
/about
/projectspero internamente solicita:
/career/index.html
/about/index.html
/projects/index.htmlEl flujo completo queda así:
Usuario / crawler
↓
https://www.ejemplo.es/career
↓
CloudFront
↓
CloudFront Function
↓
reescribe /career
como /career/index.html
↓
S3
↓
career/index.html
↓
HTML con SEO específicoEsto nos permite mantener una experiencia de navegación propia de una aplicación moderna, pero sin sacrificar el SEO de cada URL.
La diferencia importante es que ya no dependemos de que todas las rutas compartan exactamente el mismo documento HTML. Cada URL tiene su propio HTML generado, con su propio canonical y metadatos, mientras CloudFront se encarga de resolver de forma transparente qué fichero debe entregar.
En resumen:
URL pública limpia
+
CloudFront Function para reescritura
+
HTML específico por ruta
=
SEO independiente para cada páginaCon este enfoque evitamos uno de los problemas habituales de las SPA puras: que todas las rutas terminen presentando a buscadores y crawlers prácticamente el mismo documento HTML.
Lo interesante de un proyecto pequeño
El frontend de este CV no es especialmente complejo. Precisamente por eso me ha parecido un buen proyecto para practicar toda la cadena.
Lo que empezó siendo:
React → S3
ha acabado incluyendo:
React + TypeScript
↓
GitHub Actions
↓
OIDC
↓
Terraform
↓
S3 privado
↓
CloudFront + OAC
↓
CloudFront Functions
↓
Route53 + ACM
↓
CI/CD + Dependabot
Y probablemente esa es la parte más útil del proyecto: no tanto la página del CV en sí, sino tener una implementación pequeña donde puedo explicar por qué existe cada pieza, cómo se despliega, cómo se securiza y qué ocurre cuando algo falla.
Link
- https://github.com/neerajnakka/devops-portfolio
- https://cv.rubenortiz.es
- https://www.rubenortiz.es/tag/aws/
¿Te ha ayudado este artículo?
☕ Invítame a un café