Cómo desplegar una aplicación React con Vite en AWS

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

Y 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 plan

El 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 --delete

y 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 dev

eso 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:5173

Sin embargo, cuando hacemos

npm run build

Ahí ya no “sirve” la web.

Lo que hace es transformar el proyecto fuente:

.ts
.tsx
CSS
assets
imports

en archivos finales optimizados:

dist/
├── index.html
├── assets/
│   ├── index-xxxxx.js
│   └── index-xxxxx.css

es 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@latest

Vite 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.tsx

App.tsx es el componente principal, el que une las grandes secciones del proyecto.

App
├── Hero
├── About
├── Experience
├── Skills
└── Project

SEO 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
/projects

podí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 propia

El 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.html

De esta forma, CloudFront sigue exponiendo URLs limpias:

/career
/about
/projects

pero internamente solicita:

/career/index.html
/about/index.html
/projects/index.html

El 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ífico

Esto 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ágina

Con 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

¿Te ha ayudado este artículo?

☕ Invítame a un café

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Rubenortiz.es
Información sobre privacidad

Este sitio web utiliza cookies para ofrecerte la mejor experiencia de usuario posible. La información sobre cookies se almacena en tu navegador y realiza funciones como reconocerte cuando vuelve a nuestro sitio web y ayudarme a comprender qué secciones de la web resultan más interesantes y útiles.