Tu proyecto funciona, está probado, se mide y una máquina lo vigila en cada push. Y sin embargo sigue viviendo en tu portátil, donde no le sirve a nadie. Desplegar es lo que convierte un repositorio en un producto, y es también el momento en el que aparecen los problemas que ningún entorno de desarrollo enseña: las rutas que funcionaban en localhost y dan 404 en el servidor, la caché que sirve la versión antigua durante una semana, el service worker que se queda pegado con código de hace tres despliegues, la clave de API que creías protegida y que cualquiera puede leer con dos clics, y las cabeceras de seguridad que nadie configura porque nadie se las ha explicado. En esta lección aprenderás a preparar la compilación de producción entendiendo qué hace cada parte y por qué ninguna clave secreta puede vivir en el cliente; a elegir dónde alojar con una tabla comparativa honesta; a configurar el servidor de verdad —rutas de SPA, caché coherente con el hashing y con el service worker, compresión y cabeceras de seguridad explicadas una a una—; a decidir qué hacer con el backend sin escribirlo, con la advertencia de que la validación del cliente nunca sustituye a la del servidor; a montar el despliegue continuo con vistas previas por rama y una estrategia de reversión que funcione a las tres de la madrugada; a monitorizar en producción sin convertirte en un problema de privacidad; y a actualizar una PWA ya instalada sin dejar a nadie con una versión antigua. Terminarás con la aplicación desplegada con HTTPS, su flujo de despliegue continuo y una lista de comprobación de lanzamiento firmada.

Contenido

  1. Qué cambia cuando el código sale de tu máquina
  2. La compilación de producción
  3. Variables de entorno y por qué no hay secretos en el cliente
  4. Dónde alojar: tabla comparativa honesta
  5. Dominio, HTTPS y certificados
  6. Las rutas de una SPA y el fallback a index.html
  7. Caché: la parte que más se hace mal
  8. Compresión
  9. Cabeceras de seguridad, una a una
  10. El backend: qué opciones hay sin escribir un servidor
  11. La validación del cliente nunca sustituye a la del servidor
  12. Despliegue continuo con GitHub Actions
  13. Entornos de vista previa por rama
  14. Estrategia de reversión
  15. Monitorización de errores en producción
  16. Métricas de campo con web-vitals
  17. Actualizar una PWA ya instalada
  18. La lista de comprobación de lanzamiento
  19. Errores Comunes y Consejos
  20. Ejercicios
  21. Conclusión

  1. Qué cambia cuando el código sale de tu máquina

En desarrollo, Vite hace muchísimas cosas por ti que en producción no existen. Conviene ver la lista completa antes de nada, porque cada fila es una fuente de sorpresas:

Aspecto En desarrollo (npm run dev) En producción
Módulos Se sirven sin empaquetar, uno por fichero Empaquetados y minificados
Rutas El servidor devuelve index.html para todo Devuelve 404 si no lo configuras
Caché Desactivada Agresiva, y difícil de deshacer
Errores Consola visible, con mapas de código Nadie los ve salvo que los recojas
Red Local, instantánea Latencia real, pérdidas, 3G
Variables de entorno Del fichero .env Las que inyecte el proceso de compilación
HTTPS Opcional Obligatorio (y muchas API lo exigen)
Service worker Normalmente desactivado Activo, y cachea con persistencia
Usuarios Tú, en un navegador reciente Cualquiera, en cualquier cosa

La consecuencia práctica es una regla de oro que ahorra muchísimo tiempo:

Nunca despliegues algo que no hayas probado con npm run build y npm run preview en tu máquina.

preview sirve la compilación real desde dist/, y ahí aparecen la mitad de los problemas: rutas mal resueltas, imágenes que no se copiaron, import dinámicos que en desarrollo funcionaban por casualidad, y variables de entorno que no se inyectaron.

Y una segunda regla, del consejo de 11-01 que ojalá hayas seguido: el primer despliegue se hace con la aplicación vacía, en el hito H1. Si lo hiciste, este apartado es un repaso. Si no, hazlo ahora antes de seguir: descubrir los problemas de configuración con una página en blanco cuesta una hora; descubrirlos con todo el producto en juego cuesta un fin de semana.

  1. La compilación de producción

npm run build

Y lo que produce, comentado:

dist/
├── index.html                      2,1 kB   ← referencias con hash
├── manifest.json                   0,6 kB
├── sw.js                          4,3 kB   ← SIN hash, a propósito
├── assets/
│   ├── index-C4f8a2b1.js          38,2 kB  ← el paquete principal
│   ├── informe-a91b3c7d.js         9,4 kB  ← trozo cargado a demanda
│   ├── calendario-7f2e9d10.js      6,5 kB
│   └── index-B2d91e0f.css         11,8 kB
└── iconos/
    ├── icono-192.png
    └── icono-512.png

Cuatro cosas ocurren aquí y conviene entender cada una.

1 · Empaquetado. Los cientos de módulos ES de src/ se combinan en unos pocos ficheros. En desarrollo cada import era una petición; en producción son tres o cuatro descargas. Es lo que hace posible cumplir el presupuesto de «≤ 4 peticiones» de 11-01.

2 · División de código (09-05). Las pantallas que no se ven al arrancar salen en trozos aparte, cargados cuando hacen falta:

// El informe solo se descarga si alguien lo abre
const { crearInformeVista } = await import('./vista/informe-vista.js');

3 · Minificación. Se eliminan espacios, comentarios y nombres largos, y se aplican optimizaciones como el tree shaking: si importas una función de un módulo y no usas las otras cinco, las otras cinco no se incluyen. Esto solo funciona con módulos ES estáticos, que es una de las razones por las que 05-04 insistía en ellos.

4 · Hashing del nombre. index-C4f8a2b1.js contiene un resumen del contenido. Si el contenido cambia, el nombre cambia. Esta es la pieza que hace posible la estrategia de caché del apartado 7, y merece verla con claridad:

Situación Nombre Consecuencia
Sin hash index.js Hay que elegir entre caché larga (usuarios con versión vieja) o corta (descarga en cada visita)
Con hash index-C4f8a2b1.js Caché de un año y actualización inmediata: el fichero nuevo tiene otro nombre

Es una solución elegante a un problema que parecía irresoluble, y explica por qué index.html no lleva hash: alguien tiene que ser el punto de entrada estable que apunte a los ficheros con hash.

Configuración de Vite comentada:

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  base: '/orbita/',          // ← si sirves desde un subdirectorio (GitHub Pages)
  build: {
    outDir: 'dist',
    sourcemap: true,         // ← mapas de código: ver más abajo
    target: 'es2020',
    rollupOptions: {
      output: {
        manualChunks: {
          // Separar lo que cambia poco de lo que cambia mucho
          dominio: ['./src/dominio/tarea.js', './src/dominio/tablero.js', './src/dominio/arbol.js']
        }
      }
    },
    chunkSizeWarningLimit: 60   // avisa si un trozo supera tu presupuesto
  }
});

base es la causa número uno de despliegues rotos. Si sirves en https://usuario.github.io/orbita/, las rutas absolutas /assets/... apuntarían a la raíz del dominio, donde no hay nada. Con base: '/orbita/', Vite genera /orbita/assets/.... En Netlify, Vercel o un dominio propio, donde la aplicación está en la raíz, base debe ser '/'.

Sobre sourcemap: true, que tiene un matiz que conviene decidir conscientemente: los mapas de código permiten depurar en producción viendo tu código original en lugar del minificado, y son imprescindibles para que un servicio de seguimiento de errores dé trazas legibles. La contrapartida es que exponen tu código fuente. Para un proyecto de portafolio con repositorio público, no hay ninguna razón para ocultarlo. En un producto comercial, la práctica habitual es generarlos y subirlos al servicio de errores sin publicarlos en el servidor.

  1. Variables de entorno y por qué no hay secretos en el cliente

Vite inyecta en el paquete las variables que empiecen por VITE_:

# .env.production
VITE_API_URL=https://api.orbita.example/v1
VITE_ENTORNO=produccion
VITE_VERSION=1.0.0
const base = import.meta.env.VITE_API_URL;

Y aquí viene el punto más importante de toda la lección, que hay que entender sin ambigüedad:

import.meta.env.VITE_LO_QUE_SEA se sustituye literalmente por su valor durante la compilación. Ese valor acaba escrito en un fichero .js que se descarga en el navegador de cualquiera. No es una variable: es una constante pública.

Compruébalo tú mismo, y hazlo ahora:

npm run build
grep -r "VITE_" dist/          # se ven los nombres reemplazados
grep -r "sk_live\|api_key\|secret" dist/    # ← esto debe devolver CERO resultados

Qué puede ir en el cliente y qué no:

Puede ir No puede ir jamás
La URL pública de tu API Claves de API secretas
El nombre del entorno Contraseñas o cadenas de conexión a base de datos
La versión de la aplicación Secretos de firma de tokens
Identificadores públicos de servicios (Google Analytics, Sentry DSN público) Credenciales de correo, pasarelas de pago, servicios en la nube
Banderas de funcionalidad no sensibles Cualquier cosa que dé acceso a datos que no son de ese usuario

Por qué la gente se equivoca aquí, y con qué consecuencias. El razonamiento erróneo es: «está en una variable de entorno, y las variables de entorno son secretas». Lo son en el servidor. En una compilación de cliente, la variable de entorno solo describe dónde se escribió el valor, no dónde acaba. Y el valor acaba en un fichero público, minificado pero perfectamente legible con Ctrl+F.

Las consecuencias son reales y caras: claves de servicios en la nube filtradas en repositorios públicos se detectan automáticamente por robots en cuestión de minutos, y el resultado suele ser una factura por uso ajeno.

Qué hacer si tu proyecto necesita hablar con un servicio que exige clave secreta. Solo hay una respuesta correcta: un intermediario en el servidor.

flowchart LR
    A["Navegador<br/><i>sin secretos</i>"] -->|"petición pública"| B["Tu función en servidor<br/><i>guarda la clave</i>"]
    B -->|"clave secreta"| C["Servicio externo"]
    C --> B --> A

    style A fill:#dcfce7,stroke:#16a34a
    style B fill:#dbeafe,stroke:#2563eb

Netlify Functions, Vercel Functions y Cloudflare Workers permiten hacerlo con veinte líneas y sin montar un servidor completo. Es el apartado 10.

Y la regla del repositorio: .env en .gitignore, siempre, con un .env.example versionado que documente qué variables hacen falta y con valores de ejemplo. Si alguna vez confirmas un secreto por error, cambiarlo es obligatorio: borrarlo del historial no basta, porque ya está en cualquier clon que se hiciera mientras tanto.

  1. Dónde alojar: tabla comparativa honesta

Una aplicación como la tuya es un sitio estático: HTML, CSS, JavaScript y algunas imágenes. No hay proceso de servidor ejecutando tu código. Eso abre muchas opciones y todas son buenas; las diferencias están en los detalles.

Plataforma Coste Despliegue Vistas previas Funciones de servidor Cabeceras a medida Dominio propio Cuándo elegirla
GitHub Pages Gratis Acción de GitHub Muy limitadas ✅ con HTTPS Portafolio puro; lo más simple
Netlify Gratis generoso Git o CLI ✅ por PR ✅ Functions _headers, _redirects La recomendada para este proyecto
Vercel Gratis generoso Git o CLI ✅ por PR ✅ Functions vercel.json Muy similar; excelente si luego usas Next.js
Cloudflare Pages Gratis muy amplio Git o CLI ✅ por rama ✅ Workers _headers Red global excelente; límites generosos
Servidor propio + Nginx Coste del servidor Tuyo (rsync, CI) Manual Lo que montes Control total ✅ (Let's Encrypt) Cuando necesitas control o ya tienes servidor

Lo que la tabla no dice y conviene saber:

GitHub Pages es la opción más simple y tiene una limitación que afecta directamente a este proyecto: no permite configurar cabeceras HTTP. Eso significa que no puedes poner una Content-Security-Policy real (solo la versión en <meta>, que es más limitada), ni controlar el Cache-Control, ni configurar el fallback de SPA salvo con el truco del 404.html. Para un portafolio es perfectamente válido; para practicar el apartado 9 de esta lección, no.

Netlify, Vercel y Cloudflare Pages hacen esencialmente lo mismo desde el punto de vista de un sitio estático: conectas el repositorio, cada push a main despliega, cada PR genera una URL de vista previa, y tienes un fichero de configuración para cabeceras y redirecciones. Elegir entre ellas para este proyecto es cuestión de preferencia; las tres son excelentes.

Un servidor propio con Nginx es el único que te obliga a entender qué está pasando, y precisamente por eso es formativo. También es el único donde tú eres responsable de las actualizaciones de seguridad, las copias de seguridad y el certificado.

Un aviso sobre los planes gratuitos, porque conviene la honestidad: son generosos y suficientes para un proyecto personal, pero tienen límites (minutos de compilación, ancho de banda, invocaciones de función) y cambian con el tiempo. Lee los límites actuales antes de comprometerte, y ten en cuenta que un proyecto que crece puede acabar necesitando un plan de pago.

Recomendación para este módulo: despliega en Netlify o Cloudflare Pages. Tienes vistas previas por PR, cabeceras configurables y funciones de servidor si las necesitas, sin coste y sin administrar nada. Y si quieres el ejercicio completo, monta además una configuración de Nginx equivalente aunque sea en local: entender el fichero de configuración enseña más que cualquier panel de control.

  1. Dominio, HTTPS y certificados

HTTPS no es opcional, y no solo por seguridad:

Sin HTTPS no funcionan Motivo
Service workers La especificación lo exige
Notificaciones, geolocalización, cámara, portapapeles Contextos seguros obligatorios
crypto.subtle Ídem
La instalación como PWA Requisito del manifiesto
La confianza del usuario El navegador muestra «No es seguro»

Además, los datos viajan en claro: en una wifi pública, cualquiera puede leer y modificar lo que se envía.

Cómo se consigue. En las plataformas del apartado anterior, automáticamente y gratis: emiten y renuevan el certificado por ti. En un servidor propio, con Let's Encrypt y certbot:

sudo certbot --nginx -d orbita.example -d www.orbita.example
# Renovación automática cada 60 días mediante temporizador del sistema
sudo certbot renew --dry-run

El dominio. Un dominio propio cuesta entre diez y quince euros al año y cambia por completo la percepción del proyecto. La configuración es un registro DNS:

Tipo Nombre Valor Para qué
CNAME www tu-sitio.netlify.app El subdominio apunta a la plataforma
A o ALIAS @ La IP o el alias que indique la plataforma El dominio raíz

Y una decisión que hay que tomar y no dejar a medias: elige una forma canónica —con www o sin www— y redirige la otra con un 301. Tener las dos activas duplica el contenido para los buscadores, parte la caché y confunde a los usuarios. Lo mismo con httphttps, que debe ser una redirección permanente.

HSTS (Strict-Transport-Security) le dice al navegador que nunca intente conectarse por HTTP a tu dominio:

Strict-Transport-Security: max-age=31536000; includeSubDomains

Es una de las cabeceras más eficaces que existen. Con una advertencia importante: es difícil de revertir. Una vez que un navegador la ha visto, se niega a usar HTTP en ese dominio durante el max-age aunque tú quites la cabecera. Empieza con un valor pequeño (max-age=300), comprueba que todo funciona, y sube a un año.

  1. Las rutas de una SPA y el fallback a index.html

Este es el problema que sorprende a todo el mundo en su primer despliegue.

Tu enrutador (11-01) usa la History API: al navegar a la vista de informe, la URL pasa a https://orbita.example/informe. Funciona perfectamente… hasta que alguien recarga la página o comparte ese enlace.

sequenceDiagram
    participant U as Usuario
    participant S as Servidor
    U->>S: GET /informe
    S->>S: ¿Existe el fichero /informe?
    S-->>U: 404 Not Found ❌
    Note over U,S: Tu aplicación nunca llega a cargarse

La causa es simple: el servidor no sabe nada de tu enrutador. Busca un fichero llamado informe y no existe.

La solución se llama fallback a index.html: cualquier ruta que no corresponda a un fichero real devuelve index.html, y entonces tu JavaScript arranca, lee la URL y pinta la pantalla correspondiente.

Con una advertencia importante: el fallback debe devolver código 200, no 404, o los buscadores indexarán tus rutas como errores.

En Netlify (public/_redirects):

/*    /index.html   200

En Vercel (vercel.json):

{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }

En Cloudflare Pages (public/_redirects): idéntico a Netlify.

En Nginx:

location / {
    try_files $uri $uri/ /index.html;
}

try_files prueba en orden: el fichero exacto, el directorio, y si no existe ninguno, index.html. Es exactamente la lógica que necesitas.

En GitHub Pages no hay configuración de servidor, así que se usa un truco: crear un 404.html idéntico al index.html. GitHub lo sirve para rutas inexistentes y la aplicación arranca. Funciona, pero devuelve código 404, lo cual es malo para SEO. Es una de las razones para preferir otra plataforma si te importa la indexación.

Y el detalle que rompe el fallback: si aplicas la regla a todo, /api/tareas también devolverá index.html, y tu código recibirá HTML donde esperaba JSON — con un error de análisis desconcertante. Excluye las rutas de API antes de la regla general:

location /api/ { proxy_pass http://127.0.0.1:3000; }   # antes del location /
location /     { try_files $uri $uri/ /index.html; }

  1. Caché: la parte que más se hace mal

La caché es donde más se equivoca la gente, y produce dos fallos opuestos e igual de malos:

Fallo Causa Síntoma
Todo se cachea demasiado Caché larga en index.html Los usuarios siguen con la versión antigua durante días
Nada se cachea Sin Cache-Control Se descarga todo en cada visita: lento y caro

La solución correcta aprovecha el hashing del apartado 2, y se resume en una regla de dos líneas:

Los ficheros con hash en el nombre: caché de un año, inmutable. El index.html, el service worker y el manifiesto: nunca en caché.

La lógica es impecable: si el contenido cambia, el nombre cambia, luego un fichero con hash jamás necesita revalidarse. Y index.html, que es quien apunta a los nombres nuevos, debe pedirse siempre.

La tabla completa:

Recurso Cache-Control Por qué
assets/*-[hash].js y .css public, max-age=31536000, immutable El nombre cambia si cambia el contenido
Imágenes con hash public, max-age=31536000, immutable Ídem
Fuentes public, max-age=31536000, immutable Rara vez cambian; si lo hacen, se les pone hash
index.html no-cache Debe revalidarse siempre para descubrir los nombres nuevos
sw.js no-cache Si se cachea, los usuarios se quedan con el service worker antiguo
manifest.json no-cache o max-age=3600 Cambia poco pero debe poder actualizarse
Respuestas de API no-store si son privadas No cachear datos de usuario en intermediarios

no-cache no significa «no guardar». Significa «guarda, pero revalida con el servidor antes de usar». Con ETag, la revalidación suele devolver un 304 sin cuerpo: coste mínimo y siempre actualizado. Lo que no guarda nada es no-store.

En Netlify o Cloudflare Pages (public/_headers):

/assets/*
  Cache-Control: public, max-age=31536000, immutable

/index.html
  Cache-Control: no-cache

/sw.js
  Cache-Control: no-cache

/manifest.json
  Cache-Control: no-cache

En Nginx:

# Ficheros con hash: caché máxima
location ~* ^/assets/.*\.[0-9a-zA-Z_-]{8}\.(js|css|woff2|png|svg)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# El punto de entrada y el service worker: nunca en caché
location = /index.html { add_header Cache-Control "no-cache"; }
location = /sw.js      { add_header Cache-Control "no-cache"; }

El caso especial del service worker merece énfasis porque produce el fallo más desconcertante de todos. Si sw.js se sirve con caché larga, el navegador no descarga el nuevo, así que el service worker antiguo sigue sirviendo la versión antigua de tu aplicación indefinidamente. El usuario recarga, borra el historial, y sigue viendo lo mismo. Los navegadores modernos limitan la caché de sw.js a 24 horas por defecto, pero no confíes en eso: pon no-cache explícito.

Cómo comprobar que está bien configurado:

curl -sI https://orbita.example/assets/index-C4f8a2b1.js | grep -i cache-control
# → cache-control: public, max-age=31536000, immutable

curl -sI https://orbita.example/index.html | grep -i cache-control
# → cache-control: no-cache

  1. Compresión

Comprimir es la optimización con mejor relación entre esfuerzo y resultado que existe: una línea de configuración y entre un 60 % y un 80 % menos de bytes en texto.

Algoritmo Reducción típica en JS Compatibilidad Coste de CPU
Sin comprimir Todos 0
gzip ~70 % Universal Bajo
Brotli ~75–80 % Todos los navegadores actuales Mayor al comprimir

Las plataformas gestionadas lo hacen solas y no tienes que configurar nada. En Nginx:

gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;      # comprimir cosas de 200 bytes no compensa

# Brotli, si el módulo está disponible
brotli on;
brotli_types text/css application/javascript application/json image/svg+xml;

Dos matices importantes:

  • No comprimas lo ya comprimido. PNG, JPEG, WebP, MP4 y WOFF2 ya lo están. Volver a comprimirlos gasta CPU y a veces aumenta el tamaño.
  • Tu presupuesto de 11-01 está en bytes comprimidos. Los 60 kB del presupuesto se miden en la columna «Transferred» del panel Network, no en «Size». Confundirlas hace que creas que incumples cuando cumples, o al revés.

  1. Cabeceras de seguridad, una a una

Estas cabeceras son gratis, se configuran una vez y cierran clases enteras de ataques. Van explicadas de una en una porque copiarlas sin entenderlas produce sitios rotos que nadie sabe arreglar.

9.1 Content-Security-Policy

Es la más potente y la más delicada. Declara de dónde puede cargar recursos tu página; todo lo demás lo bloquea el navegador.

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.orbita.example; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'

Directiva a directiva:

Directiva Qué controla Valor y por qué
default-src 'self' Lo que no esté especificado Solo tu propio origen
script-src 'self' De dónde se cargan scripts La clave: bloquea scripts inyectados y en línea
style-src 'self' Hojas de estilo Ídem para CSS
img-src 'self' data: Imágenes data: permite SVG en línea e iconos incrustados
connect-src fetch, XHR, WebSocket Solo tu API: si te inyectan código, no puede exfiltrar datos a otro servidor
object-src 'none' <object>, <embed> No los necesitas y son vectores clásicos
base-uri 'self' La etiqueta <base> Impide reescribir todas tus rutas relativas
form-action 'self' Destino de los formularios Impide que un formulario envíe datos fuera
frame-ancestors 'none' Quién puede meterte en un iframe Evita el clickjacking; sustituye a X-Frame-Options

Por qué la CSP es tan eficaz: es la segunda línea de defensa contra XSS. En 11-03 aprendiste la primera —textContent en lugar de innerHTML—, y la CSP es el paracaídas de reserva: aunque un atacante consiga inyectar <script>alert(1)</script> en tu página, script-src 'self' impide que se ejecute, porque no viene de tu origen.

Los dos escollos prácticos:

  1. Los estilos en línea. Si tu código hace elemento.style.color = 'red', la CSP con style-src 'self' lo bloquea. La solución correcta es usar clases CSS en lugar de estilos en línea — que es mejor práctica de todos modos. La solución perezosa es 'unsafe-inline', que desactiva buena parte de la protección. Evítala.
  2. Los scripts en línea. Si tienes <script> con código dentro del HTML, se bloquea. Vite no genera ninguno por defecto, así que normalmente no es problema.

Cómo implantarla sin romper nada: empieza en modo informe, que no bloquea nada y te avisa de lo que bloquearía:

Content-Security-Policy-Report-Only: default-src 'self'; ...

Navega por toda tu aplicación, mira las advertencias de la consola, ajusta la política, y solo entonces quita el -Report-Only.

9.2 Las demás

X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: DENY
Cabecera Qué hace Por qué la quieres
X-Content-Type-Options: nosniff Impide que el navegador adivine el tipo de un fichero por su contenido Sin ella, un fichero subido por un usuario que «parece» JavaScript podría ejecutarse como tal
Referrer-Policy Cuánta información de la URL de origen se envía al navegar fuera strict-origin-when-cross-origin envía la URL completa dentro de tu sitio y solo el dominio hacia fuera: evita filtrar identificadores de tus URL a terceros
Permissions-Policy Desactiva funciones del navegador que no usas Si no usas cámara ni micrófono, declararlo cierra la puerta a que un script inyectado los pida
Strict-Transport-Security Fuerza HTTPS Apartado 5. Cuidado con max-age al principio
X-Frame-Options: DENY Impide que te metan en un iframe Redundante con frame-ancestors pero cubre navegadores antiguos

En Netlify o Cloudflare Pages (public/_headers):

/*
  Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https://api.orbita.example; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
  Strict-Transport-Security: max-age=31536000; includeSubDomains

Cómo verificarlo. Hay servicios públicos que analizan tus cabeceras y te dan una puntuación con explicaciones. También basta con:

curl -sI https://orbita.example/ | grep -iE "content-security|x-content|referrer|permissions|strict-transport"

Conseguir una buena puntuación de cabeceras de seguridad es un detalle pequeño con mucho peso en una revisión técnica: demuestra que has pensado en el despliegue, no solo en el código.

  1. El backend: qué opciones hay sin escribir un servidor

Tu proyecto puede funcionar perfectamente sin servidor: los datos en localStorage, todo en el navegador. Es una decisión legítima si está documentada con sus limitaciones (un solo dispositivo, sin privacidad real, sin compartir).

Si necesitas más, hay tres caminos:

Opción Qué es Esfuerzo Control Cuándo
Sin backend Todo en el navegador Ninguno Total sobre el cliente MVP, portafolio, uso personal
Backend como servicio (BaaS) Un servicio te da base de datos, autenticación y API Bajo Medio; dependes del proveedor Cuando necesitas multiusuario sin escribir servidor
Funciones sin servidor Trozos de código en el servidor para casos concretos Bajo-medio Alto en lo que escribes Ocultar una clave, enviar un correo, validar algo
Backend propio Node.js + base de datos, escrito por ti Alto Total Cuando la lógica de servidor es el producto

Backend como servicio. Servicios como Supabase, Firebase o PocketBase te dan base de datos, API automática, autenticación y a veces tiempo real, con configuración en lugar de código. Para un proyecto de portafolio que necesita usuarios y datos compartidos, es la opción con mejor relación entre esfuerzo y resultado.

Lo que hay que saber antes de elegir uno:

  • Dependes del proveedor. Migrar después tiene coste. Mitígalo manteniendo tu frontera del repositorio (11-02): si el BaaS vive detrás de RepositorioSupabase, cambiarlo es reescribir un fichero.
  • Las reglas de seguridad son tu responsabilidad. Estos servicios exponen la base de datos directamente al cliente, protegida por reglas que configuras. Una regla mal escrita expone todos los datos de todos los usuarios. Es el fallo más frecuente y más grave de estas plataformas.
  • Los planes gratuitos tienen límites y el servicio puede cambiar de precios o desaparecer.

Funciones sin servidor. Cuando solo necesitas ocultar una clave o hacer una operación puntual en servidor:

// netlify/functions/tipo-cambio.js — la clave NUNCA llega al navegador
export async function handler(evento) {
  const clave = process.env.CLAVE_SERVICIO;        // variable del servidor, no VITE_
  const respuesta = await fetch(`https://servicio.example/api?key=${clave}`);
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600' },
    body: JSON.stringify(await respuesta.json())
  };
}

Fíjate en que la variable no lleva prefijo VITE_: eso es exactamente lo que la mantiene fuera del paquete del cliente.

Backend propio. Es el camino natural si quieres crecer, y es el tema de la lección 11-07: Node.js con Express o Fastify, una base de datos, autenticación con tokens, y toda la lógica de servidor que tu aplicación necesite. Requiere aprender cosas nuevas y merece la pena, pero no es requisito de este proyecto.

  1. La validación del cliente nunca sustituye a la del servidor

Este apartado es corto, no tiene matices, y es de los que más importan.

Tienes quince reglas de negocio, R1 a R15, implementadas y probadas en dominio/. Funcionan perfectamente… en el navegador. Y el navegador es un entorno que el usuario controla por completo:

  • Puede abrir DevTools y llamar a tus funciones con lo que quiera.
  • Puede modificar tu código antes de que se ejecute.
  • Puede saltarse tu aplicación entera y llamar a la API con curl.
# Ninguna validación de cliente interviene aquí
curl -X POST https://api.orbita.example/v1/tareas \
  -H "Content-Type: application/json" \
  -d '{"titulo":"","horasEstimadas":9999,"estado":"hecha","responsableId":"otro-usuario"}'

Si el servidor acepta eso, tus quince reglas no valen nada.

La validación del cliente es una comodidad para el usuario. La validación del servidor es la que protege los datos. No son alternativas: son dos capas con propósitos distintos, y las dos son obligatorias en cuanto hay servidor.

Capa Propósito Qué pasa si falta
Cliente Retroalimentación inmediata, evitar viajes inútiles, buena experiencia La aplicación se siente lenta y torpe, pero los datos siguen protegidos
Servidor Integridad de los datos y seguridad Cualquiera puede escribir lo que quiera: datos corruptos, cuentas ajenas modificadas

La buena noticia es que tu arquitectura ya está preparada para esto. dominio/ es JavaScript puro sin dependencias del navegador — esa fue la primera frontera de 11-01. Si escribes el backend en Node.js, puedes importar exactamente los mismos ficheros y ejecutar las mismas reglas en el servidor:

// servidor/rutas/tareas.js — el MISMO dominio, sin duplicar nada
import { Tarea } from '../../src/dominio/tarea.js';
import { ErrorDeValidacion } from '../../src/dominio/errores.js';

app.post('/v1/tareas', async (peticion, respuesta) => {
  try {
    const tarea = new Tarea(peticion.body);          // R1-R15 aplicadas en el servidor
    respuesta.status(201).json(await repositorio.guardar(tarea));
  } catch (error) {
    if (error instanceof ErrorDeValidacion) {
      return respuesta.status(400).json({ codigo: 'VALIDACION', campo: error.campo, mensaje: error.message });
    }
    throw error;
  }
});

Una sola implementación de las reglas, ejecutada en los dos lados. Eso es lo que valía la pena de mantener el dominio libre de dependencias durante todo el proyecto, y es un argumento excelente para contar en una entrevista (11-06).

Y lo que el servidor debe validar además de tus reglas, porque son cosas que el cliente no puede hacer:

Comprobación Por qué solo puede hacerla el servidor
Autenticación: ¿quién eres? El cliente puede decir que es quien quiera
Autorización: ¿puedes tocar esto? R15 (roles) es trivial de saltarse en el cliente
Límite de peticiones Protege contra abuso y contra bucles accidentales
Tamaño máximo del cuerpo Evita que alguien envíe 500 MB
Unicidad real Solo la base de datos puede garantizarla sin carreras

  1. Despliegue continuo con GitHub Actions

Despliegue continuo significa que lo que se fusiona en main llega a producción automáticamente, sin pasos manuales. Con la CI de 11-04 protegiendo la rama, eso es seguro: nada llega a main sin lint, pruebas, cobertura, presupuesto y recorridos en verde.

# .github/workflows/desplegar.yml
name: Desplegar

on:
  push:
    branches: [main]
  workflow_dispatch:        # permite lanzarlo a mano desde la interfaz

concurrency:
  group: despliegue-produccion
  cancel-in-progress: false   # NO cancelar un despliegue a medias

jobs:
  desplegar:
    runs-on: ubuntu-latest
    environment:
      name: produccion
      url: https://orbita.example
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }

      - run: npm ci

      - name: Verificar antes de desplegar
        run: npm run verificar      # cinturón y tirantes: nunca se despliega en rojo

      - name: Compilar para producción
        run: npm run build
        env:
          VITE_API_URL: ${{ vars.API_URL }}
          VITE_VERSION: ${{ github.sha }}

      - name: Comprobar que no hay secretos en el paquete
        run: |
          if grep -rEq "(sk_live|api[_-]?key|BEGIN [A-Z ]*PRIVATE KEY)" dist/; then
            echo "::error::Posible secreto en dist/"
            exit 1
          fi

      - name: Desplegar en Netlify
        uses: nwtgck/actions-netlify@v3
        with:
          publish-dir: './dist'
          production-deploy: true
          deploy-message: ${{ github.event.head_commit.message }}
        env:
          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

      - name: Comprobación de humo
        run: |
          sleep 15
          curl -sfI https://orbita.example/ | head -1
          curl -sf https://orbita.example/ | grep -q "Órbita" || exit 1

Las decisiones del fichero:

Elemento Por qué
cancel-in-progress: false Cancelar un despliegue a mitad puede dejar el sitio inconsistente. Aquí sí se espera
workflow_dispatch Poder redesplegar a mano sin hacer un commit vacío
environment con url GitHub muestra el enlace y guarda el historial de despliegues
npm run verificar otra vez La CI ya pasó, pero un despliegue nunca debe fiarse de otro flujo
VITE_VERSION: github.sha Cada despliegue sabe de qué commit viene. Vale oro al depurar en producción
El grep de secretos Última red de seguridad automática antes de publicar
Comprobación de humo Que el despliegue termine no significa que el sitio funcione

La comprobación de humo es una idea que conviene interiorizar: una verificación mínima y rápida de que lo desplegado responde y contiene lo que debe. Cuesta cinco líneas y detecta el «se desplegó una carpeta vacía», que ocurre más de lo que parece.

Los secretos del repositorio (Settings → Secrets and variables): NETLIFY_AUTH_TOKEN y NETLIFY_SITE_ID van como secrets (cifrados, no visibles ni en los registros); API_URL va como variable (no es secreta y así se puede leer). Nunca escribas un token directamente en el YAML: el fichero está en el repositorio.

  1. Entornos de vista previa por rama

Un entorno de vista previa es un despliegue temporal de una rama, con su propia URL. Netlify, Vercel y Cloudflare Pages lo hacen automáticamente al abrir una Pull Request.

Por qué merece la pena, aunque trabajes solo:

Beneficio Detalle
Ves el cambio en condiciones reales Con la compilación de producción, HTTPS, caché y service worker
Puedes probarlo en el móvil Basta con abrir la URL; nada de configurar red local
Puedes pedir opinión Un enlace, no «clónate el repo y ejecuta esto»
Comparas antes y después Producción y vista previa abiertas en dos pestañas
Lighthouse sobre lo real La CI de 11-04 puede auditar la vista previa en vez de un servidor local

Tres precauciones importantes:

  1. noindex en las vistas previas. Si Google indexa deploy-preview-42--orbita.netlify.app, tendrás contenido duplicado compitiendo con tu sitio. Las plataformas suelen ponerlo, pero compruébalo.
  2. Nunca apuntes una vista previa a datos de producción. Una prueba destructiva en una vista previa que escribe en la base real es un desastre evitable. Usa un entorno de datos separado.
  3. Las vistas previas son públicas. Cualquiera con la URL entra. No pruebes ahí con datos reales de nadie.

  1. Estrategia de reversión

Todo despliegue puede salir mal. La pregunta no es si ocurrirá, sino cuánto tardarás en arreglarlo cuando ocurra — probablemente a una hora incómoda y con prisa.

Las opciones, de mejor a peor:

Estrategia Tiempo Riesgo Disponibilidad
Volver al despliegue anterior (botón de la plataforma) < 1 min Muy bajo Netlify, Vercel, Cloudflare
git revert + despliegue automático 3–5 min Bajo Siempre
Arreglar hacia delante (fix forward) 10–60 min Alto con prisa Siempre
Restaurar desde una copia Variable Medio Servidor propio

La primera es la buena, y hay que probarla antes de necesitarla. Las plataformas gestionadas guardan todos los despliegues anteriores y permiten volver a cualquiera con un clic, porque cada despliegue es un conjunto inmutable de ficheros estáticos.

El procedimiento escrito, que debe estar en tu README o en docs/operaciones.md:

## Si un despliegue rompe producción

1. **Revertir primero, investigar después.** El objetivo inmediato es que
   los usuarios tengan algo que funcione, no entender qué pasó.
   → Panel de Netlify → Deploys → el anterior → "Publish deploy"
2. Comprobar que el sitio funciona (comprobación de humo manual).
3. `git revert <sha>` en `main` para que el código refleje la realidad.
   NO usar `git reset --force` sobre una rama compartida.
4. Abrir una incidencia con: qué se rompió, cómo se detectó, qué se revirtió.
5. Reproducir el fallo **en local o en una vista previa**, con una prueba
   que falle (método de 11-04).
6. Corregir, con la prueba en verde, y volver a desplegar.
7. Escribir un post mortem breve: qué pasó, por qué la CI no lo detectó,
   qué prueba o comprobación se añade para que no vuelva.

El punto 1 es contraintuitivo y es el correcto. La tentación es investigar mientras el sitio está roto. Pero cada minuto de investigación es un minuto de servicio caído, y la prisa hace que se tomen peores decisiones. Revertir es reversible; arreglar con prisa, no.

El punto 7 es el que convierte un incidente en aprendizaje. Y la pregunta clave del post mortem no es «quién se equivocó» sino «por qué la CI no lo detectó», porque la respuesta es siempre una comprobación nueva que evita toda una familia de fallos futuros.

Una precaución sobre la base de datos: si tu despliegue incluyó una migración de datos (como las de 11-03), revertir el código no revierte los datos. Por eso las migraciones deben ser compatibles hacia atrás siempre que se pueda: añadir campos, no renombrarlos ni borrarlos en el mismo despliegue que los deja de usar. La secuencia segura es en dos despliegues: primero añadir y escribir en los dos sitios, después dejar de usar el viejo.

  1. Monitorización de errores en producción

En desarrollo, un error aparece en tu consola. En producción, aparece en la consola de un usuario que no la va a mirar y no te lo va a contar: simplemente dejará de usar tu aplicación.

En 11-02 montaste un registro local con las dos redes de seguridad globales (error y unhandledrejection). Ahora hay que enviarlo a algún sitio.

Las opciones:

Opción Esfuerzo Qué obtienes
Solo el registro local + panel de diagnóstico Ninguno Nada hasta que alguien te lo enseñe
Servicio de seguimiento de errores Bajo Errores agrupados, con traza, contexto y frecuencia
Punto de acceso propio que recibe errores Medio Control total, y también responsabilidad total

Servicios de seguimiento de errores. Sentry, Bugsnag, Rollbar y similares tienen planes gratuitos suficientes para un proyecto personal, se integran con tres líneas y agrupan errores idénticos —lo cual importa mucho: un fallo que ocurre 400 veces es una entrada, no 400 correos.

Y aquí viene la parte que hay que hacer bien:

Un servicio de seguimiento de errores envía datos de tus usuarios a un tercero. Eso es una decisión de privacidad, no solo técnica.

Lo que hay que configurar antes de activarlo:

Configuración Por qué
Filtrar datos personales antes de enviar Nombres, correos, contenido escrito por el usuario
No enviar el cuerpo de las peticiones Puede contener cualquier cosa
Enmascarar el DOM si usas grabación de sesión Una grabación captura literalmente todo lo que se ve
Reducir el muestreo No necesitas el 100 % de los eventos, y menos datos es mejor
Mencionarlo en el aviso de privacidad Es un encargado de tratamiento; hay que declararlo
Comprobar dónde se almacenan los datos Las transferencias fuera de la UE tienen requisitos propios
// src/util/registro-remoto.js
export function enviarError(entrada) {
  if (import.meta.env.DEV) return;

  const seguro = {
    mensaje: entrada.mensaje,
    nombre: entrada.nombre,
    pila: entrada.pila,
    version: import.meta.env.VITE_VERSION,
    ruta: location.pathname,                     // NO location.href: puede llevar parámetros
    contexto: { caso: entrada.contexto?.caso, campos: entrada.contexto?.campos }
    // NUNCA: valores de formulario, nombres de usuario, identificadores personales, tokens
  };

  navigator.sendBeacon('/api/errores', JSON.stringify(seguro));
}

Dos detalles del código: location.pathname en lugar de location.href evita enviar parámetros de consulta que pueden contener búsquedas o identificadores; y sendBeacon envía sin bloquear y funciona aunque la página se esté cerrando, que es justo cuando ocurren muchos errores.

Advertencia. No envíes datos personales a un servicio externo sin haberlo previsto en tu aviso de privacidad y sin conocer dónde se almacenan. En un proyecto de aprendizaje con datos ficticios el riesgo es nulo; en un producto real, activar un servicio de seguimiento sin más es un problema de cumplimiento normativo, no un detalle técnico.

  1. Métricas de campo con web-vitals

En 09-01 aprendiste la distinción que más confusión evita: laboratorio (tu Lighthouse, tu máquina, tu red) frente a campo (usuarios reales, dispositivos reales, redes reales). Los números de campo son siempre peores, y son los que importan.

npm install web-vitals
// src/util/vitales.js
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals';

function enviar(metrica) {
  navigator.sendBeacon('/api/vitales', JSON.stringify({
    nombre: metrica.name,              // LCP, INP, CLS, TTFB
    valor: Math.round(metrica.value),
    calificacion: metrica.rating,      // good | needs-improvement | poor
    ruta: location.pathname,
    version: import.meta.env.VITE_VERSION,
    conexion: navigator.connection?.effectiveType ?? 'desconocida'
  }));
}

if (import.meta.env.PROD) {
  onLCP(enviar); onINP(enviar); onCLS(enviar); onTTFB(enviar);
}

Cómo leer los resultados, que es donde está el valor:

Observación Qué significa Qué hacer
Laboratorio 1,9 s, campo p75 4,2 s Tus usuarios tienen peores dispositivos y redes Estrangula más al medir en local
CLS bueno en escritorio, malo en móvil Algo se reajusta solo en pantallas pequeñas Revisar con el emulador de dispositivos
INP malo solo en una ruta Una pantalla concreta tiene trabajo excesivo Perfilar esa pantalla (09-02)
Empeoramiento tras un despliegue Una regresión concreta Comparar por version: por eso se envía

Ese último punto es el que justifica todo el esfuerzo: enviar la versión con cada métrica permite atribuir una regresión a un despliegue concreto. Sin eso, solo sabes que empeoró en algún momento.

Y una nota de privacidad: las métricas de rendimiento no son datos personales si no incluyen identificadores. No añadas un identificador de usuario «para poder correlacionar»; el percentil 75 no lo necesita.

  1. Actualizar una PWA ya instalada

Este es el problema más específico del despliegue de una PWA, y produce una situación exasperante: el usuario tiene la aplicación instalada, tú despliegas una corrección, y él sigue viendo la versión antigua. Recarga, y nada.

La causa está en el ciclo de vida del service worker (07-05):

stateDiagram-v2
    [*] --> Instalando: se detecta un sw.js distinto
    Instalando --> Instalado: install completado
    Instalado --> EnEspera: hay un SW activo controlando pestañas
    EnEspera --> Activando: skipWaiting() o se cierran TODAS las pestañas
    Activando --> Activo: activate completado
    Activo --> [*]

El estado «En espera» es el problema. Por defecto, un service worker nuevo espera a que todas las pestañas de la aplicación se cierren. Y como mucha gente nunca cierra del todo una PWA instalada, esa espera puede durar días.

La solución completa, en tres piezas.

Pieza 1 · El service worker permite saltarse la espera bajo demanda:

// sw.js
const VERSION = 'orbita-v7';

self.addEventListener('install', (evento) => {
  evento.waitUntil(caches.open(VERSION).then((c) => c.addAll(RECURSOS)));
  // Nada de skipWaiting() automático: el usuario decide
});

self.addEventListener('activate', (evento) => {
  evento.waitUntil(
    caches.keys()
      .then((claves) => Promise.all(claves.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
      .then(() => self.clients.claim())
  );
});

self.addEventListener('message', (evento) => {
  if (evento.data?.tipo === 'SALTAR_ESPERA') self.skipWaiting();
});

Pieza 2 · La aplicación detecta que hay una versión nueva y lo dice:

// src/util/actualizacion.js
export async function vigilarActualizaciones() {
  if (!('serviceWorker' in navigator)) return;
  const registro = await navigator.serviceWorker.register('/sw.js');

  registro.addEventListener('updatefound', () => {
    const nuevo = registro.installing;
    nuevo.addEventListener('statechange', () => {
      if (nuevo.state === 'installed' && navigator.serviceWorker.controller) {
        mostrarAvisoDeActualizacion(() => {
          nuevo.postMessage({ tipo: 'SALTAR_ESPERA' });
        });
      }
    });
  });

  // Cuando el nuevo toma el control, recargar UNA vez
  let recargando = false;
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    if (recargando) return;
    recargando = true;
    location.reload();
  });

  setInterval(() => registro.update(), 60 * 60 * 1000);   // comprobar cada hora
}

Pieza 3 · El aviso, que debe ser discreto y accesible:

<div role="status" aria-live="polite" class="aviso-actualizacion" hidden>
  <p>Hay una versión nueva disponible.</p>
  <button type="button" data-accion="actualizar">Actualizar ahora</button>
  <button type="button" data-accion="mas-tarde">Más tarde</button>
</div>

Las cuatro reglas de la actualización de una PWA:

  1. Nunca recargues sin avisar. El usuario puede estar escribiendo. Una recarga sorpresa que pierde un formulario es imperdonable.
  2. La bandera recargando es imprescindible. Sin ella, controllerchange puede provocar un bucle de recargas: es un fallo real y muy desagradable.
  3. sw.js con Cache-Control: no-cache (apartado 7). Sin eso, nada de esto funciona porque el navegador ni siquiera descarga el service worker nuevo.
  4. Limpia las cachés viejas en activate. Si no, se acumulan versiones y acabas ocupando cientos de megabytes en el dispositivo de otra persona.

El plan de emergencia, que conviene tener escrito: si despliegas un service worker roto que rompe la aplicación para todos los que la tienen instalada, la salida es publicar un sw.js mínimo que se desregistre a sí mismo y limpie todas las cachés:

// sw.js de emergencia
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', async () => {
  const claves = await caches.keys();
  await Promise.all(claves.map((k) => caches.delete(k)));
  await self.registration.unregister();
  const clientes = await self.clients.matchAll();
  clientes.forEach((c) => c.navigate(c.url));
});

Guárdalo. El día que lo necesites, lo agradecerás.

  1. La lista de comprobación de lanzamiento

El entregable final del hito. Se recorre entera, se marca, y se guarda firmada y fechada en docs/lanzamiento.md.

18.1 Técnico

# Comprobación Cómo
1 npm run verificar en verde Local y en CI
2 Compilación de producción probada en local build + preview
3 Cero secretos en dist/ grep del apartado 3
4 HTTPS activo y http redirige curl -I http://… devuelve 301
5 Una sola forma canónica (con o sin www) La otra redirige con 301
6 Fallback de SPA con código 200 curl -I …/informe
7 Caché correcta en ficheros con hash curl -I sobre assets/…
8 index.html y sw.js con no-cache curl -I
9 Compresión activa Comparar Size y Transferred
10 Las cinco cabeceras de seguridad presentes curl -I o analizador público
11 CSP sin unsafe-inline y sin errores en consola Navegar por toda la aplicación
12 Presupuesto de rendimiento cumplido en producción Lighthouse sobre la URL real
13 Accesibilidad ≥ 95 en Lighthouse, 0 incidencias graves de axe Ídem
14 Funciona sin conexión DevTools en Offline
15 La PWA se instala y se actualiza Instalar, desplegar, ver el aviso
16 Reversión probada de verdad Volver al despliegue anterior y regresar

18.2 Contenido y descubribilidad

# Comprobación Detalle
17 <title> único y descriptivo «Órbita — Gestión de trabajo para equipos pequeños»
18 <meta name="description"> de 150–160 caracteres Lo que se lee en los resultados de búsqueda
19 <html lang="es"> Accesibilidad y buscadores
20 Open Graph completo og:title, og:description, og:image (1200×630), og:url, og:type
21 La tarjeta social se ve bien Probar con un validador de enlaces
22 Favicon en varios tamaños 16, 32, 180 (Apple), 192 y 512 (PWA)
23 robots.txt presente Con Sitemap: apuntando al sitemap
24 sitemap.xml con las rutas públicas Aunque sean pocas
25 Página 404 propia y útil Con enlace a inicio, no una pantalla vacía
26 manifest.json correcto name, short_name, start_url, display, theme_color, iconos
# public/robots.txt
User-agent: *
Allow: /
Sitemap: https://orbita.example/sitemap.xml
<meta property="og:title" content="Órbita — Gestión de trabajo para equipos pequeños">
<meta property="og:description" content="Tareas, subtareas, carga por persona e historial de cambios. Sin frameworks.">
<meta property="og:image" content="https://orbita.example/og-imagen.png">
<meta property="og:url" content="https://orbita.example/">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">

18.3 Legal y operativo

# Comprobación Detalle
27 Aviso de privacidad Qué datos se guardan, dónde, cuánto tiempo, y con qué terceros
28 Cookies y almacenamiento Si usas analítica o seguimiento, el consentimiento tiene requisitos legales
29 Aviso visible sobre los datos «Los datos se guardan solo en este navegador y no son privados frente a quien use este dispositivo»
30 Licencia en el repositorio MIT, Apache 2.0 o la que elijas
31 Copia de seguridad Exportación de datos disponible para el usuario
32 Procedimiento de reversión escrito docs/operaciones.md
33 Forma de contacto Para que alguien pueda reportar un fallo

Advertencia legal. Los puntos 27 y 28 no son formalidades. En la Unión Europea, informar sobre el tratamiento de datos personales es una obligación, y el uso de cookies o almacenamiento no estrictamente necesario requiere consentimiento previo, informado y revocable — un banner que solo dice «aceptar» no cumple. Si tu proyecto es un ejercicio con datos ficticios y sin analítica, el riesgo práctico es nulo, pero acostúmbrate a incluir el aviso. Si algún día publicas un producto con usuarios reales, esto requiere asesoramiento legal específico: ni esta lección ni ninguna documentación técnica lo sustituyen.

Un aviso de privacidad honesto para un proyecto como este es corto y se escribe en diez minutos:

# Aviso de privacidad — Órbita

**Qué datos se guardan.** Las tareas, usuarios e historial que introduces se
guardan **únicamente en el almacenamiento local de tu navegador**. No se envían
a ningún servidor.

**Quién puede verlos.** Cualquier persona con acceso a este navegador y a este
dispositivo. Órbita **no** ofrece privacidad frente a otros usuarios del mismo
equipo. No introduzcas información confidencial.

**Errores.** Si ocurre un fallo, se registran el mensaje técnico, la ruta y la
versión de la aplicación. **No** se registra el contenido de tus tareas.

**Cómo borrar tus datos.** Ajustes → Borrar todos los datos. También puedes
borrar los datos del sitio desde tu navegador.

**Exportar tus datos.** Ajustes → Exportar (formato JSON).

**Contacto.** <tu forma de contacto>

Última actualización: 2026-11-28

Errores Comunes y Consejos

Poner un secreto en una variable VITE_. Acaba escrito en un fichero público que cualquiera lee con Ctrl+F, y los robots que rastrean repositorios lo encuentran en minutos. Cualquier cosa que dé acceso a algo va detrás de una función de servidor. Comprueba dist/ con grep antes de cada despliegue, y automatízalo en el flujo.

No configurar el fallback de SPA. Todo funciona navegando desde la portada y da 404 al recargar o al abrir un enlace compartido — que es exactamente cómo llegará la gente a quien enseñes el proyecto.

Caché larga en index.html. Los usuarios se quedan con la versión antigua durante días y no hay forma de forzarlos a actualizar. Los ficheros con hash llevan caché de un año; el punto de entrada, no-cache.

Caché en sw.js. Produce el fallo más desconcertante que existe: el usuario recarga, borra el historial, reinstala, y sigue viendo la aplicación de hace tres despliegues.

base mal configurado. En GitHub Pages con subdirectorio, sin base: '/repo/' la página carga en blanco y la consola se llena de 404 sobre /assets/…. Es el fallo número uno del primer despliegue.

Añadir 'unsafe-inline' a la CSP para que deje de quejarse. Desactiva buena parte de la protección contra XSS, que es justo lo que la CSP existía para dar. Arregla el estilo o el script en línea; casi siempre son dos líneas.

Confiar solo en la validación del cliente. Cualquiera puede llamar a tu API con curl. Con servidor, las reglas van también allí — y tu dominio sin dependencias hace que sea el mismo código.

Desplegar sin haber probado la reversión. El día que la necesites, con el sitio caído y prisa, no es el momento de descubrir cómo funciona. Pruébala hoy, en frío.

Recargar la PWA sin avisar cuando hay versión nueva. El usuario puede estar escribiendo. Aviso discreto, botón, y recarga solo cuando lo pida — con la bandera que evita el bucle de recargas.

Enviar datos personales a un servicio de seguimiento de errores. Es un problema de cumplimiento, no de estilo. Filtra antes de enviar, envía pathname y no href, y declara el servicio en tu aviso de privacidad.

Consejo · Despliega desde el primer día y con frecuencia. Un despliegue por semana durante dos meses es aburrido y seguro. Un despliegue enorme el último día es la receta de un desastre.

Consejo · Ten un panel de diagnóstico en producción. Una ruta discreta que muestre versión, commit, fecha de compilación, estado del service worker, espacio ocupado y últimos errores. Cuando alguien te diga «no me funciona», esa pantalla responde en diez segundos.

Consejo · Guarda una captura de la línea base de producción. Lighthouse sobre la URL real, con fecha. Dentro de seis meses sabrás si has mejorado o empeorado, y tendrás la prueba.

Consejo · Prueba tu sitio desplegado en un móvil de verdad. No en el emulador. Con datos móviles, no wifi. Descubrirás cosas que ningún estrangulamiento simulado te enseña.

Ejercicios

Estos ejercicios son el hito H6, primera parte: la aplicación desplegada con HTTPS, su flujo de despliegue continuo y la lista de comprobación firmada.

Ejercicio 1 — La compilación y el despliegue.

  1. Configura vite.config.js con base correcto para tu plataforma, sourcemap, manualChunks y chunkSizeWarningLimit con tu presupuesto.
  2. Crea .env.example versionado y .env.production ignorado, y documenta en el README qué variables hacen falta.
  3. Compila y audita dist/: tamaño total, número de ficheros, y grep de secretos. Documenta los resultados.
  4. Prueba con npm run preview toda la aplicación antes de desplegar. Anota los problemas que aparezcan solo en producción (habrá al menos uno).
  5. Despliega en la plataforma que elijas, con dominio propio o subdominio de la plataforma, y HTTPS activo.
  6. Configura el fallback de SPA y comprueba con curl -I que devuelve 200 en una ruta interna.
  7. Configura la caché completa según la tabla del apartado 7 y verifícala con curl -I sobre un fichero con hash, sobre index.html y sobre sw.js.
  8. Configura las cinco cabeceras de seguridad, con la CSP primero en modo informe y luego activa, sin unsafe-inline.
  9. Documenta cada decisión en docs/despliegue.md: qué plataforma, por qué, y qué implica.

Ejercicio 2 — Despliegue continuo, vistas previas y reversión.

  1. Escribe .github/workflows/desplegar.yml con: verificación previa, compilación con la versión del commit, comprobación automática de secretos, despliegue y comprobación de humo.
  2. Configura los secretos y variables del repositorio correctamente (secretos cifrados, variables normales).
  3. Activa las vistas previas por PR y comprueba las tres precauciones: noindex, datos separados, y conciencia de que son públicas.
  4. Prueba la reversión de verdad: despliega un cambio visible, revierte al anterior, comprueba que el sitio vuelve, y vuelve a publicar el nuevo. Cronometra cuánto tardas.
  5. Escribe docs/operaciones.md con el procedimiento de reversión de siete pasos adaptado a tu plataforma.
  6. Configura la monitorización de errores con filtrado de datos personales, y demuestra que un error provocado a propósito llega con la traza y sin datos de usuario.
  7. Instrumenta web-vitals enviando la versión, y recoge al menos una sesión de datos de campo.
  8. Implementa la actualización de la PWA con las tres piezas del apartado 17, y demuéstralo: instala la aplicación, despliega un cambio, y comprueba que aparece el aviso, que el botón actualiza y que no hay bucle de recargas.

Ejercicio 3 — El lanzamiento.

  1. Recorre las 33 comprobaciones del apartado 18 sobre tu sitio desplegado. Marca cada una con evidencia (comando ejecutado, captura o URL), no de memoria.
  2. Completa el bloque de contenido: <title>, descripción, Open Graph con imagen de 1200×630, favicons en cinco tamaños, robots.txt, sitemap.xml, página 404 propia y manifest.json completo.
  3. Escribe el aviso de privacidad con la plantilla del apartado 18.3, adaptado a lo que tu aplicación hace realmente.
  4. Añade la licencia al repositorio.
  5. Ejecuta Lighthouse sobre la URL de producción (no en local) y compara con tu presupuesto de 11-01 y con tu línea base de 11-04. Documenta las diferencias y explícalas.
  6. Pásale el sitio a un analizador de cabeceras de seguridad y documenta la puntuación.
  7. Ábrelo en un móvil real con datos móviles y anota todo lo que no funcione como esperabas.
  8. Firma y fecha docs/lanzamiento.md.

Soluciones

Criterios de aceptación del ejercicio 1 — Despliegue

# Criterio Verificación
1 El sitio carga por HTTPS curl -sI https://… devuelve 200
2 HTTP redirige a HTTPS curl -sI http://… devuelve 301
3 Solo una forma canónica La otra devuelve 301
4 Una ruta interna recargada funciona curl -sI …/informe devuelve 200
5 Fichero con hash cacheado un año cache-control: …max-age=31536000, immutable
6 index.html con no-cache curl -sI
7 sw.js con no-cache curl -sI
8 Compresión activa Transferred < 40 % de Size
9 Las cinco cabeceras presentes curl -sI | grep -iE …
10 CSP sin unsafe-inline Inspección + consola sin violaciones
11 Cero secretos en dist/ `grep -rE "(sk_
12 Lighthouse en producción cumple el presupuesto Informe adjunto

Rúbrica del ejercicio 1 (21 puntos)

Dimensión 0 1 2 3
Compilación Sin configurar Compila base y trozos correctos Además con límite de tamaño activo
Secretos Hay alguno Ninguno por suerte Verificado a mano Verificado en el flujo automático
HTTPS y dominio Sin HTTPS Con HTTPS Con redirección Además canónico y con HSTS progresivo
Rutas 404 al recargar Fallback Con código 200 Además con API excluida
Caché Sin configurar Algo Tabla completa Verificada con curl y documentada
Seguridad Ninguna cabecera Algunas Las cinco CSP sin unsafe-inline y probada en modo informe
Documentación No hay Menciona la plataforma Con justificación Con las implicaciones de cada elección

Umbral: 15/21, con obligatoriamente 3 en «Secretos». Un secreto filtrado invalida el ejercicio: es el único fallo de esta lección con consecuencias reales fuera del proyecto.

Criterios de aceptación del ejercicio 2 — Continuo y operaciones

# Criterio Verificación
1 Un push a main despliega solo Ver el flujo y el sitio actualizado
2 Un fallo de verificar impide el despliegue Provocarlo
3 El paquete con un secreto falso rompe el flujo Provocarlo y quitarlo
4 La comprobación de humo detecta un despliegue vacío Provocarlo
5 Cada PR genera una vista previa Abrir una
6 Las vistas previas no se indexan Comprobar X-Robots-Tag o la meta
7 Revertir tarda menos de 2 minutos Cronometrado
8 El procedimiento está escrito docs/operaciones.md
9 Un error de producción llega al servicio Provocarlo
10 Ese error no contiene datos personales Inspeccionar el evento recibido
11 Las métricas de campo incluyen la versión Inspeccionar el envío
12 La PWA avisa de la versión nueva Demostración con la aplicación instalada
13 No hay bucle de recargas Recargar varias veces tras actualizar
14 Las cachés viejas se limpian caches.keys() en la consola: una sola

Criterios de aceptación del ejercicio 3 — Lanzamiento

# Criterio Verificación
1 Las 33 comprobaciones con evidencia No basta con marcar la casilla
2 El enlace compartido muestra tarjeta correcta Validador de enlaces
3 El favicon se ve en la pestaña Visual, en dos navegadores
4 El 404 es propio y ofrece salida Visitar una ruta inventada
5 robots.txt y sitemap.xml accesibles Por URL
6 El manifiesto permite instalar Aparece la opción de instalación
7 El aviso de privacidad es honesto y específico Describe tu aplicación, no una plantilla genérica
8 Hay licencia Fichero LICENSE
9 Lighthouse en producción documentado Con las diferencias respecto a local explicadas
10 Puntuación de cabeceras documentada Con las pendientes anotadas
11 Probado en móvil real Lista de hallazgos
12 Documento firmado y fechado docs/lanzamiento.md

Rúbrica global del hito H6 primera parte (24 puntos)

Dimensión Peso Qué se evalúa
Compilación y secretos 5 base, trozos, cero secretos verificado automáticamente
Servidor 5 Rutas, caché, compresión, HTTPS canónico
Seguridad 4 Las cinco cabeceras, CSP real sin unsafe-inline
Despliegue continuo 4 Automático, con verificación, humo y vistas previas
Operaciones 3 Reversión probada, procedimiento escrito, monitorización sin datos personales
PWA 2 Actualización con aviso, sin bucle, cachés limpias
Lanzamiento 1 Las 33 comprobaciones con evidencia

Umbral: 17/24. Con una condición que no se compensa: cero secretos en el paquete. Todo lo demás se puede mejorar en la siguiente iteración; una clave filtrada, no.

Autoevaluación del hito H6:

Pregunta Sí / No
¿Puedo abrir una ruta interna en una pestaña nueva y funciona?
¿He comprobado con grep que no hay secretos en dist/?
¿Sé cuánto tardo en revertir, porque lo he cronometrado?
¿Un usuario con la PWA instalada recibirá mi próxima corrección?
¿Mi aviso de privacidad describe lo que mi aplicación hace de verdad?
¿He abierto mi sitio en un móvil real con datos móviles?
¿Podría explicar cada una de las cinco cabeceras de seguridad?

Conclusión

Tu proyecto ya no vive en tu portátil: está en internet, con HTTPS, y cualquiera puede usarlo.

Sabes qué cambia cuando el código sale de tu máquina —nueve diferencias, cada una fuente de sorpresas— y tienes la regla que evita la mitad de ellas: nunca despliegues nada que no hayas probado con build y preview. Conoces la compilación de producción por dentro: empaquetado que reduce peticiones, división de código que retrasa lo que no se ve, minificación que solo funciona bien gracias a los módulos ES estáticos de 05-04, y el hashing del nombre, que es la pieza elegante que permite tener a la vez caché de un año y actualización inmediata — y que explica por qué index.html es el único que no lo lleva. Con base bien configurado, que es la causa número uno de primeros despliegues en blanco.

Tienes clarísimo lo más importante de la lección: no hay secretos en el cliente. import.meta.env.VITE_* no es una variable: es una constante pública escrita literalmente en un fichero que se descarga. Sabes qué puede ir y qué no, sabes que el razonamiento erróneo («está en una variable de entorno») confunde dónde se escribió el valor con dónde acaba, y sabes que la única respuesta correcta cuando hace falta una clave es un intermediario en el servidor. Con la verificación por grep automatizada en el flujo, porque las buenas intenciones se olvidan y los robots que rastrean repositorios no.

Sabes dónde alojar con una tabla honesta que incluye lo que las comparativas no dicen: que GitHub Pages no permite cabeceras reales, que Netlify, Vercel y Cloudflare Pages son equivalentes para un sitio estático, que un servidor propio es el único que te obliga a entender lo que pasa, y que los planes gratuitos tienen límites que cambian. Y sabes por qué HTTPS no es opcional: sin él no hay service worker, ni PWA, ni contextos seguros, ni confianza.

Sabes resolver el problema que sorprende a todo el mundo —las rutas de una SPA—, con fallback a index.html que devuelve 200 y no 404, en las cuatro plataformas, y con la exclusión de las rutas de API que evita recibir HTML donde esperabas JSON.

Tienes la caché bien hecha, que es donde más se equivoca la gente: un año e inmutable para lo que lleva hash, no-cache para el punto de entrada, el service worker y el manifiesto. Sabes que no-cache significa «revalida», no «no guardes». Y sabes que un sw.js cacheado produce el fallo más desconcertante que existe: usuarios atrapados en una versión de hace tres despliegues por mucho que recarguen.

Sabes configurar las cabeceras de seguridad una a una, entendiéndolas: la CSP como segunda línea de defensa contra XSS —con connect-src impidiendo la exfiltración, frame-ancestors evitando el clickjacking, y la advertencia de que 'unsafe-inline' desactiva justo lo que la política venía a dar—, implantada primero en modo informe; nosniff, Referrer-Policy, Permissions-Policy y HSTS con su max-age progresivo porque es difícil de revertir.

Sabes qué hacer con el backend: que no tenerlo es legítimo si está documentado con sus limitaciones; que un backend como servicio resuelve el multiusuario sin escribir servidor, con la advertencia de que las reglas de seguridad son tuyas y una mal escrita expone todos los datos; que una función sin servidor de veinte líneas basta para ocultar una clave; y que escribir el tuyo es el camino de 11-07. Y sabes, sin matices, que la validación del cliente nunca sustituye a la del servidor — con la recompensa de que tu dominio sin dependencias del navegador se puede importar tal cual en Node y ejecutar las mismas R1–R15 en los dos lados, que es exactamente por lo que valía la pena la primera frontera de 11-01.

Tienes despliegue continuo con verificación previa, versión del commit inyectada —lo que hace posible atribuir una regresión a un despliegue—, comprobación automática de secretos y prueba de humo, porque que un despliegue termine no significa que el sitio funcione. Con vistas previas por rama y sus tres precauciones, y con una estrategia de reversión cuya regla es contraintuitiva y correcta: revertir primero, investigar después, porque revertir es reversible y arreglar con prisa no lo es. Con el post mortem cuya pregunta clave no es quién se equivocó sino por qué la CI no lo detectó.

Sabes monitorizar en producción sin convertirte en un problema de privacidad: filtrar antes de enviar, pathname en lugar de href, sendBeacon porque funciona mientras la página se cierra, y la conciencia de que un servicio de seguimiento es un tercero que recibe datos de tus usuarios. Y sabes recoger métricas de campo con web-vitals, con la distinción de 09-01 entre laboratorio y campo, y con la versión adjunta para poder atribuir.

Sabes actualizar una PWA instalada, que es el problema más específico del despliegue de una aplicación web moderna: el estado «en espera» que puede durar días, las tres piezas que lo resuelven, las cuatro reglas —avisar siempre, la bandera contra el bucle de recargas, no-cache en sw.js, limpiar cachés viejas— y el service worker de emergencia que conviene tener guardado antes de necesitarlo.

Y tienes la lista de comprobación de lanzamiento con sus 33 puntos en tres bloques: técnico, contenido y descubribilidad, y legal y operativo — incluidos el aviso de privacidad honesto y específico, la advertencia de que el consentimiento de cookies tiene requisitos legales que un botón de «aceptar» no cumple, y la licencia.

El producto está publicado. Y aquí aparece lo que separa un proyecto terminado de un proyecto que además sirve para algo: nadie sabe que existe, nadie sabe qué decisiones hay detrás, y tú todavía no has practicado cómo contarlo. Un trabajo que no se puede enseñar ni defender vale, en la práctica, mucho menos de lo que es. Convertirlo en algo que alguien entienda en dos minutos, que puedas demostrar en cinco, y que sepas explicar en una entrevista técnica sin sonar ni inseguro ni fanfarrón, es Presentación y Revisión del Proyecto.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados