Skip to content

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

bash
npm run build
dist/
├── 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.svg

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

nginx
# /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)

dockerfile
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 un config.json cargado 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.html según TU cabecera — otra razón para el no-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 (revalidateTag cuando 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 a main. Es la forma de que un revisor vea el cambio funcionando, no solo el diff de código.
yaml
# 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

bash
npx vite-bundle-visualizer     # ¿QUÉ pesa tanto? (verás cada dependencia como un rectángulo)

Reglas que rinden más de lo que cuestan:

  1. 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.
  2. Cuidado con las dependencias gordas: un moment (300 KB) por formatear una fecha que Intl.DateTimeFormat hace gratis. Antes de npm install, mira el coste en bundlephobia.
  3. Imágenes: el 90% del peso típico. AVIF/WebP, srcset para tamaños, lazy loading (cap. 15.6). Next <Image> lo automatiza (cap. 18.8).
  4. 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:
yaml
# .github/workflows/ci.yml (fragmento)
- run: npm run build
- run: npx @lhci/cli autorun --collect.staticDistDir=dist \
       --assert.assertions.categories:performance=0.9

L.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:

bash
# 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 5
nginx
root /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 ChunkLoadError tras 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)

nginx
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