🎯 Meta: desplegar la Parte V como un profesional. Un frontend mal desplegado se manifiesta como "a los usuarios les sale la versión vieja", "el refresh da 404" o "tarda 8 segundos en cargar desde el móvil". Aquí aprendes por qué pasan y cómo se arreglan de raíz.
Requisitos: caps. 12-14 (Docker, Nginx), cap. 15 (Vite,
npm run build).
L.1 · Qué produce un build de frontend (y por qué importa)
npm run builddist/
├── index.html ← 2 KB, SIN hash, cambia en cada deploy
└── assets/
├── index-B3kF9dQ2.js ← CON hash del contenido
├── index-Ck2mP8xL.css ← CON hash
└── logo-D4nQ7rWs.svgEl detalle que lo cambia todo: los assets llevan un hash de su contenido en el nombre. Si el contenido cambia, el nombre cambia. Eso permite la estrategia de caché perfecta:
index.html → Cache-Control: no-cache (se revalida SIEMPRE)
assets/*-hash.* → Cache-Control: max-age=31536000, immutable (1 año, inmutable)index.html fresco apunta a los assets nuevos; los assets, al ser inmutables, se cachean para siempre. Deploy instantáneo + caché máxima, sin contradicción.
⚠️ El error clásico: cachear
index.html"para que vaya rápido". Resultado: usuarios con el HTML viejo pidiendo assets con hash viejo que ya no existen → página rota hasta que expira la caché. Si alguna vez viste "funciona en incógnito pero no en normal", fue esto.
L.2 · SPA en Nginx: el fallback que evita los 404
Una SPA (caps. 17, 19) enruta en el cliente: /productos/42 no existe como archivo. Si el usuario refresca ahí, Nginx busca dist/productos/42 → 404. La solución:
# /etc/nginx/conf.d/tienda.conf
server {
listen 443 ssl;
server_name tienda.com;
root /var/www/tienda/dist;
# 1. La API va al backend (cap. 13)
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 2. Assets con hash: caché infinita
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# 3. Todo lo demás: el archivo si existe, si no → index.html (LA CLAVE de las SPA)
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
# 4. Compresión (gzip aquí; brotli si tu build de nginx lo trae)
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}🧠
try_files $uri /index.html= "sirve el archivo pedido; si no existe, devuelve el index.html y que React Router / Vue Router resuelvan la ruta". Con esto, refrescar en cualquier URL funciona. Con HTMX (cap. 16) esto no aplica: allí cada URL existe de verdad en el servidor.
Dockerfile del frontend (multi-stage, cap. 12)
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Las VITE_* se fijan EN EL BUILD, no en runtime ⚠️
ARG VITE_API_URL=/api
RUN npm run build
FROM nginx:1.30-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80⚠️ Las variables
VITE_*/NEXT_PUBLIC_*se hornean en el bundle en build-time. Una imagen construida para staging apunta a la API de staging para siempre. Opciones: una imagen por entorno, rutas relativas (/api+ reverse proxy, la más limpia), o unconfig.jsoncargado en runtime.
L.3 · CDN: acercar los bytes al usuario
Tu VPS está en un datacenter; tus usuarios, en todo el mundo. Una CDN (Cloudflare, CloudFront, Fastly) copia tus estáticos en cientos de ubicaciones:
Usuario en México ──▶ nodo CDN en México (5 ms) ──(solo si no lo tiene)──▶ tu VPS (180 ms)- Los assets inmutables son el caso perfecto: la CDN los sirve años sin tocar tu servidor.
- La forma fácil: Cloudflare delante de tu dominio (proxy naranja). Respeta tus
Cache-Control, te da TLS, HTTP/3 y absorbe picos y ataques DDoS básicos. - La CDN cachea
index.htmlsegún TU cabecera — otra razón para elno-cache.
Plataformas de frontend (Netlify, Vercel, Cloudflare Pages, GitHub Pages): son "CDN + build + deploy por git push". Para una SPA/SSG pura son gratis y excelentes. Autohospedar (Nginx + Cloudflare) te da control total y es lo que practica este libro; las plataformas son la opción pragmática cuando no quieres gestionar servidores. Next.js con SSR necesita un servidor Node (cap. 18.9) o una plataforma que lo soporte — no es un puñado de estáticos.
Edge functions, ISR/PPR y despliegues de preview
Más allá de "estáticos en CDN" hay un escalón intermedio que usan Next.js (cap. 18) y las plataformas modernas:
- Edge functions: código que corre en el nodo de CDN más cercano al usuario (no en tu datacenter), con arranque casi instantáneo. El límite: runtime restringido — sin módulos nativos de Node, sin acceso a filesystem, CPU acotada. Sirven para lo ligero y latencia-crítico (redirecciones geolocalizadas, A/B testing, autenticación previa a servir la página); la lógica de negocio pesada sigue en tu backend (caps. 04-08).
- ISR (Incremental Static Regeneration): una página se sirve estática desde CDN pero se regenera en segundo plano tras un tiempo (
revalidate: 3600) o bajo demanda (revalidateTagcuando cambia el dato en tu BD). Es el punto intermedio entre "estático para siempre" (build) y "SSR en cada petición" (cap. 18.9): rápido como estático, fresco como dinámico. - PPR (Partial Prerendering): una misma página mezcla un shell estático (cacheado en CDN, instantáneo) con huecos dinámicos que hacen streaming por detrás (
<Suspense>) — por ejemplo la ficha de un producto es estática, pero "stock en tiempo real" y "recomendados para ti" llegan después sin bloquear el resto. - Despliegues de preview: cada Pull Request (ap. E) despliega una URL única (
pr-123.tuapp.com) con esa versión exacta, antes de fusionar amain. Es la forma de que un revisor vea el cambio funcionando, no solo el diff de código.
# Ejemplo autohospedado de preview deployments (sin plataforma gestionada):
# CI construye la imagen, la etiqueta con el nº de PR y la publica en un subdominio efímero.
# .github/workflows/preview.yml (fragmento)
- run: docker build -t tienda:pr-${{ github.event.number }} .
- run: docker run -d --name pr-${{ github.event.number }} tienda:pr-${{ github.event.number }}
- run: |
echo "location /pr-${{ github.event.number }}/ { proxy_pass http://pr-${{ github.event.number }}; }" \
>> /etc/nginx/conf.d/previews.conf && nginx -s reload
# Al cerrar el PR: un job de cleanup para el contenedor y el bloque de Nginx🧠 La regla mental: edge/ISR/PPR son afinaciones de dónde y cuándo se genera el HTML, no sustituyen el
Cache-Control+ hash de L.1 — se combinan con él. Y los previews existen para que "funciona en mi máquina" nunca sea la única prueba antes de un merge (ap. E.6).
L.4 · Presupuesto de rendimiento y análisis del bundle
npx vite-bundle-visualizer # ¿QUÉ pesa tanto? (verás cada dependencia como un rectángulo)Reglas que rinden más de lo que cuestan:
- Code-splitting por ruta (
lazy()en React 17.8,() => import()en Vue 19.7): la página de admin no debe viajar al cliente que mira la carta. - Cuidado con las dependencias gordas: un
moment(300 KB) por formatear una fecha queIntl.DateTimeFormathace gratis. Antes denpm install, mira el coste en bundlephobia. - Imágenes: el 90% del peso típico. AVIF/WebP,
srcsetpara tamaños, lazy loading (cap. 15.6). Next<Image>lo automatiza (cap. 18.8). - Ponle números: presupuesto tipo "JS inicial < 200 KB gzip, LCP < 2,5 s en móvil" y Lighthouse CI en el pipeline (cap. 14) que falla el build si te pasas:
# .github/workflows/ci.yml (fragmento)
- run: npm run build
- run: npx @lhci/cli autorun --collect.staticDistDir=dist \
--assert.assertions.categories:performance=0.9L.5 · Deploys atómicos y rollback
El patrón de release que usan Netlify y compañía, replicable en tu VPS en 10 líneas:
# deploy.sh — cada release es una carpeta; "current" es un symlink
REL=/var/www/tienda/releases/$(date +%Y%m%d%H%M%S)
mkdir -p "$REL"
tar xzf dist.tar.gz -C "$REL"
ln -sfn "$REL" /var/www/tienda/current # ← cambio ATÓMICO
ls -dt /var/www/tienda/releases/* | tail -n +6 | xargs rm -rf # conserva 5root /var/www/tienda/current;- Rollback = re-apuntar el symlink a la release anterior. Un segundo.
- Nunca borres los assets de la release anterior inmediatamente: usuarios con la pestaña abierta siguen pidiendo chunks viejos (por eso el error
ChunkLoadErrortras un deploy). Conservar N releases lo resuelve; detectar la versión nueva y sugerir recargar, lo remata.
L.6 · Cabeceras de seguridad del frontend (el remate del ap. C)
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; script-src 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
# HSTS solo cuando TODO funcione en https:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;💡 La CSP es la red de seguridad contra XSS (caps. 15-16, ap. C): aunque un atacante inyecte un
<script src=evil.com>, el navegador no lo carga. Empieza estricta y abre solo lo que de verdad necesites; valida en securityheaders.com.
✅ Ejercicio del apéndice
1. Dockeriza tu SPA del cap. 17/19 con el multi-stage de L.2 y súbela detrás
de tu Nginx: /api → backend, fallback SPA, gzip.
2. Verifica con las DevTools (Network): index.html con no-cache y assets con
immutable + "(from disk cache)" en la segunda visita.
3. Rompe el fallback a propósito (quita try_files), refresca en /productos/42,
entiende el 404, y arréglalo.
4. Monta deploys atómicos con symlink + 5 releases; haz deploy, rollback, y
comprueba que un usuario con pestaña abierta no sufre ChunkLoadError.
5. Añade las cabeceras de seguridad y consigue A en securityheaders.com
(la CSP te obligará a ajustar algo — ese es el ejercicio).
6. Pasa vite-bundle-visualizer, elimina o parte tu dependencia más gorda, y
añade Lighthouse CI con presupuesto de performance 0.9 al pipeline.💡 Pistas
- Si la CSP te rompe los estilos con Vite en dev, es por los estilos inline del HMR: la CSP estricta se aplica al build de producción, no al dev server.
- Para simular el ChunkLoadError: deploy v1, abre la app, deploy v2 borrando v1, navega a una ruta lazy. Con las 5 releases conservadas, repite y verás que ya no ocurre.
🧠 Autoevaluación
Los assets llevan hash de contenido: si cambian, cambia la URL, así que la versión cacheada jamás queda obsoleta → caché infinita segura. index.html es el que apunta a los hashes vigentes: debe revalidarse siempre para que cada deploy llegue al instante.
Falta try_files $uri /index.html. La ruta existe solo en el router del cliente; el servidor no tiene ese archivo. En HTMX/SSR cada URL existe en el servidor, así que el problema no se da.
Porque Vite la sustituye por su valor literal durante el build: en la imagen ya no existe la variable, existe el string. Soluciones: rutas relativas + proxy, imagen por entorno, o config.json cargado en runtime.
Sus pestañas (HTML viejo) piden chunks con hash antiguo que el deploy borró. Mitigar: conservar N releases anteriores servibles, y/o detectar nueva versión y ofrecer recargar.
La página estática de build no cambia hasta el próximo deploy; con ISR, esa misma página se regenera en segundo plano pasado un tiempo o cuando invocas revalidateTag, sin necesitar un deploy nuevo. El usuario sigue recibiendo HTML servido desde CDN (rápido), pero el contenido se refresca solo.
Volver al: README.md · Relacionado: 13-nginx-apache.md, 12-docker.md, 15-fundamentos-frontend.md