El app-minimo.js de la lección anterior cabía en diez líneas y arrancaba con aplicacion.listen(3000). Funciona, pero no se puede probar, no se puede apagar de forma ordenada, no se puede configurar por entorno y no tiene sitio donde crecer. En esta lección lo convertimos en el esqueleto real de Escena Viva: una aplicación que se crea en un sitio y se arranca en otro, con la configuración centralizada y validada, los ajustes del framework elegidos a conciencia y una estructura de carpetas que aguante los seis módulos que quedan de curso.

Contenido

  1. La separación clave: crear la aplicación y arrancarla
  2. src/app.js: la factoría crearAplicacion()
  3. src/servidor.js: el arranque
  4. Estructura de carpetas de Escena Viva
  5. Ajustes de la aplicación con app.set y app.get
  6. Configuración por entorno: dotenv y src/config/index.js
  7. Montar la aplicación en un servidor http propio
  8. Apagado ordenado con SIGTERM
  9. app.locals frente a res.locals
  10. Una nota sobre motores de vistas

  1. La separación clave: crear la aplicación y arrancarla

Regla que no vamos a romper en el resto del curso:

Un módulo crea la aplicación y la devuelve. Otro módulo distinto la pone a escuchar.

Es decir: src/app.js exporta crearAplicacion(), que construye el objeto de Express con todas sus rutas y middleware y lo devuelve sin llamar a listen. Y src/servidor.js importa esa función, crea el servidor HTTP y lo pone a escuchar.

Parece un detalle de estilo. No lo es:

Razón Qué pasaría si mezclaras ambas cosas
Pruebas (M9) supertest necesita la aplicación sin arrancar: le pasa la app y él levanta un servidor efímero en un puerto libre. Si app.js llama a listen, cada fichero de prueba intentaría ocupar el puerto 3000 y las pruebas fallarían en paralelo.
Apagado ordenado (M11) Para cerrar bien necesitas la referencia al objeto Server, no a la app. Solo la tienes si el arranque es explícito.
Reutilización Puedes montar varias instancias (API y métricas en puertos distintos) o anidarla con app.use('/api/v1', crearAplicacion()).

Además, crearAplicacion() es una factoría, no un objeto exportado directamente: permite pasarle opciones y garantiza que cada prueba obtenga una aplicación limpia, sin estado compartido de la anterior.

  1. src/app.js: la factoría crearAplicacion()

Esta es la primera versión. Irá creciendo en 06-03 (routers), 06-04 (middleware propio), 06-05 (middleware de terceros) y 06-07 (errores).

// src/app.js
const express = require('express');
const { configuracion } = require('./config/index.js');

/**
 * Construye la aplicacion Express de Escena Viva.
 * No arranca ningun servidor: solo devuelve la aplicacion configurada.
 */
function crearAplicacion(opciones = {}) {
  const { servirEstaticos = true } = opciones;
  const aplicacion = express();

  // --- Ajustes del framework (apartado 5) ---
  aplicacion.disable('x-powered-by');
  aplicacion.set('trust proxy', configuracion.confiarEnProxy);
  aplicacion.set('case sensitive routing', true);
  aplicacion.set('json spaces', configuracion.esProduccion ? 0 : 2);

  // --- Valores compartidos por toda la aplicacion (apartado 9) ---
  aplicacion.locals.nombrePlataforma = 'Escena Viva';
  aplicacion.locals.version = require('../package.json').version;

  // --- Middleware integrado: lectura del cuerpo JSON y estaticos ---
  aplicacion.use(express.json({ limit: configuracion.limiteCuerpo }));
  if (servirEstaticos) {
    aplicacion.use(express.static(configuracion.directorioPublico, { index: 'index.html' }));
  }

  // --- Rutas (se llenaran en 06-03) ---
  aplicacion.get('/salud', (peticion, respuesta) => {
    const { nombrePlataforma, version } = aplicacion.locals;
    respuesta.json({ estado: 'ok', plataforma: nombrePlataforma, version });
  });

  return aplicacion;
}

module.exports = { crearAplicacion };

Puntos que merece la pena señalar: no hay listen por ninguna parte (ese es el contrato); la función recibe opciones con valores por defecto, así que crearAplicacion() sin argumentos sigue funcionando; la configuración llega de un módulo, no de process.env esparcido por el fichero (apartado 6); y los diagnósticos no se imprimen aquí, porque app.js no habla por consola, solo construye.

  1. src/servidor.js: el arranque

// src/servidor.js
const http = require('node:http');
const { crearAplicacion } = require('./app.js');
const { configuracion } = require('./config/index.js');

/** Crea el servidor HTTP y lo pone a escuchar. */
function arrancarServidor() {
  // Creamos el servidor a mano en lugar de usar aplicacion.listen(...):
  // asi conservamos la referencia al Server para cerrarlo (apartados 7 y 8).
  const servidor = http.createServer(crearAplicacion());
  const { puerto, host, entorno } = configuracion;

  servidor.listen(puerto, host, () => {
    console.error(`[escena-viva] entorno=${entorno} escuchando en http://${host}:${puerto}`);
  });

  registrarApagadoOrdenado(servidor);
  return servidor;
}

module.exports = { arrancarServidor };

// El fichero es modulo y script a la vez.
if (require.main === module) arrancarServidor();

La función registrarApagadoOrdenado la escribiremos en el apartado 8. Actualiza también los scripts del package.json, porque el punto de entrada ha cambiado respecto al módulo 4: "start": "node src/servidor.js" y "dev": "node --watch src/servidor.js".

  1. Estructura de carpetas de Escena Viva

Express no impone estructura. Esta es la nuestra, pensada para que cada fichero tenga una sola razón para cambiar.

escena-viva/
├── datos/                    # eventos.json, ventas.csv (ya existia)
├── publico/                  # index.html, estilos.css, app.js (ya existia)
├── src/
│   ├── app.js                # crearAplicacion()
│   ├── servidor.js           # arranque y apagado
│   ├── config/               # index.js (NUEVO) + rutas.js (ya existia)
│   ├── rutas/                # routers de Express      (06-03)
│   ├── controladores/        # manejadores (req, res)  (06-03)
│   ├── middleware/           # middleware propio       (06-04)
│   ├── esquemas/             # validacion con zod      (06-06)
│   ├── errores.js            # jerarquia de errores    (06-07)
│   ├── dominio/              # Evento, Sesion, GestorDeVentas (ya existia)
│   ├── servicios/            # cambio-divisas.js y logica de negocio
│   ├── catalogo-datos.js     # acceso al JSON (ya existia)
│   ├── informes/ streams/ utiles/ y servidor/ (el artesanal del M4, referencia)
└── package.json

Responsabilidades, para que nadie tenga dudas de dónde va cada cosa:

Carpeta Responsabilidad Qué no debe contener
rutas/ Declarar qué URL llama a qué controlador y qué middleware la protege Lógica de negocio
controladores/ Traducir HTTP a dominio: leer req, llamar a un servicio, responder Reglas de aforo, acceso a ficheros
servicios/ Casos de uso: orquestar dominio y datos Objetos req/res
dominio/ Reglas de Escena Viva: aforo, estados, precios Nada de HTTP ni de Express
middleware/ Preocupaciones transversales: registro, identificador, caché Reglas específicas de un endpoint
esquemas/ y config/ Forma esperada de la entrada; lectura y validación del entorno Consultas, respuestas ni lógica

La regla que lo resume: el dominio no importa Express, y Express no importa fs. Si un fichero de dominio/ necesita require('express'), algo está mal colocado.

Advertencia contra la sobreingeniería

Esta estructura tiene sentido para una API que va a crecer durante seis módulos más. Para un proyecto de tres endpoints es exagerada. Señales de que te estás pasando: controladores que solo llaman a un servicio que solo llama a un repositorio que solo hace una línea (tres ficheros para una operación trivial), carpetas vacías "por si acaso", e interfaces y abstracciones para una única implementación que nunca va a tener otra.

Empieza por rutas/ + controladores/ y saca servicios/ cuando la lógica se repita en dos controladores, no antes. En Escena Viva sí llegaremos a ese punto, porque la venta de entradas se usa desde la API y desde los informes.

  1. Ajustes de la aplicación con app.set y app.get

Express guarda ajustes en un diccionario interno. app.set(nombre, valor) escribe, app.get(nombre) lee (ojo: app.get con dos argumentos registra una ruta GET; con uno, lee un ajuste), y app.enable/app.disable son atajos para valores booleanos.

Ajuste Valor recomendado Qué hace y por qué
env process.env.NODE_ENV o development En producción se activa la caché de vistas y el manejador de errores deja de enviar la traza (06-07)
x-powered-by desactivado Express añade X-Powered-By: Express a cada respuesta: información gratuita para quien busca objetivos con una versión conocida. No es gran defensa, pero no cuesta nada
trust proxy 1 o la lista de proxies de confianza Detrás de Nginx o de un balanceador, req.ip es la IP del proxy y req.protocol es http aunque el cliente venga por HTTPS. Con este ajuste, Express lee X-Forwarded-For y X-Forwarded-Proto. Imprescindible para el limitador de peticiones de 06-05 y para el registro del módulo 11
json spaces 2 en desarrollo, 0 en producción Indenta el JSON de res.json(). Cómodo al depurar con curl, desperdicio de ancho de banda en producción
case sensitive routing true Sin él, /Eventos y /eventos son la misma ruta. Que dos URL distintas devuelvan lo mismo perjudica a la caché y al SEO
strict routing false Con false, /eventos y /eventos/ son la misma ruta (comportamiento amable). Con true, son distintas. Nuestro normalizarRuta del módulo 4 hacía justo esto a mano
query parser 'simple' o 'extended' Cómo se interpretan las query strings anidadas (?filtro[sala]=Bóveda). 'simple' usa querystring del núcleo; 'extended' usa qs y admite anidamiento

Desde un manejador puedes leerlos con aplicacion.get('env') o aplicacion.get('trust proxy'); de ese último dependen peticion.ip y peticion.protocol.

Cuidado con trust proxy. Actívalo solo si realmente hay un proxy delante. Expuesto directamente a internet, cualquiera puede falsificar su IP enviando una cabecera X-Forwarded-For, y tu limitador de peticiones deja de servir para nada. Por eso en nuestra configuración es un valor de entorno, no una constante.

  1. Configuración por entorno: dotenv y src/config/index.js

dotenv ya es dependencia desde el módulo 5. Su trabajo es cargar un fichero .env dentro de process.env. Nada más: no valida ni convierte tipos. Eso lo hacemos nosotros.

# .env  (NO se sube al repositorio; en el repositorio va .env.ejemplo)
NODE_ENV=desarrollo
PUERTO=3000
LIMITE_CUERPO=100kb
CONFIAR_EN_PROXY=0
ORIGENES_PERMITIDOS=http://localhost:5173,http://127.0.0.1:5173
// src/config/index.js
const path = require('node:path');
require('dotenv').config();

const { RAIZ } = require('./rutas.js');

// Lectores tipados: cada uno lanza si el valor no cumple lo esperado.
const opcional = (nombre, porDefecto) => (process.env[nombre] ?? '').trim() || porDefecto;

function obligatoria(nombre) {
  const valor = opcional(nombre, '');
  if (valor === '') throw new Error(`Falta la variable obligatoria: ${nombre}`);
  return valor;
}

function entero(nombre, porDefecto, { minimo, maximo }) {
  const numero = Number.parseInt(opcional(nombre, String(porDefecto)), 10);
  if (!Number.isInteger(numero) || numero < minimo || numero > maximo) {
    throw new Error(`La variable ${nombre} debe ser un entero entre ${minimo} y ${maximo}`);
  }
  return numero;
}

const lista = (n) => opcional(n, '').split(',').map((e) => e.trim()).filter(Boolean);

const ENTORNOS_VALIDOS = ['desarrollo', 'pruebas', 'produccion'];

function construirConfiguracion() {
  const entorno = opcional('NODE_ENV', 'desarrollo');
  if (!ENTORNOS_VALIDOS.includes(entorno)) {
    throw new Error(`NODE_ENV debe ser uno de: ${ENTORNOS_VALIDOS.join(', ')}`);
  }

  return Object.freeze({
    entorno,
    esProduccion: entorno === 'produccion',
    puerto: entero('PUERTO', 3000, { minimo: 1, maximo: 65535 }),
    host: opcional('HOST', '127.0.0.1'),
    limiteCuerpo: opcional('LIMITE_CUERPO', '100kb'),
    confiarEnProxy: entero('CONFIAR_EN_PROXY', 0, { minimo: 0, maximo: 10 }),
    origenesPermitidos: lista('ORIGENES_PERMITIDOS'),
    directorioPublico: path.join(RAIZ, 'publico'),
    // En produccion exigimos clave de firma; en desarrollo hay valor de relleno.
    clavePasarela: entorno === 'produccion'
      ? obligatoria('CLAVE_PASARELA')
      : opcional('CLAVE_PASARELA', 'clave-de-desarrollo'),
  });
}

let configuracion;
try {
  configuracion = construirConfiguracion();
} catch (error) {
  console.error(`[configuracion] ${error.message}`); // fallar rapido
  process.exit(1);
}

module.exports = { configuracion, ENTORNOS_VALIDOS };

Las ideas que hay detrás de este fichero:

  1. Un único punto de lectura de process.env. Buscarlo en el proyecto debe dar exactamente un resultado: este fichero. Así sabes de un vistazo qué necesita la aplicación para arrancar.
  2. Fallar rápido. Si falta la clave de la pasarela en producción, el proceso muere en el arranque con un mensaje claro. La alternativa —arrancar y fallar tres horas después, en la primera venta— es mucho peor.
  3. Tipos correctos. process.env.PUERTO es la cadena '3000'; la configuración expone el número 3000 y evita comparaciones sorpresa.
  4. Objeto congelado con Object.freeze: nadie cambia la configuración en tiempo de ejecución.
  5. El .env nunca va al repositorio. Se versiona .env.ejemplo; añade .env a .gitignore.

En el módulo 11 ampliaremos esto con secretos gestionados; por ahora, con esto basta.

  1. Montar la aplicación en un servidor http propio

En 06-01 vimos que app es una función manejadora. Aquí lo aprovechamos: escribimos http.createServer(aplicacion) en lugar de aplicacion.listen(...). ¿Qué ganas exactamente?

Ventaja Para qué la necesitarás
Tienes la referencia al Server Cerrarlo con servidor.close() en el apagado ordenado (apartado 8)
Puedes acoplar otros protocolos al mismo puerto Socket.IO en el módulo 12 hace new Server(servidor)
Puedes ajustar tiempos de espera servidor.keepAliveTimeout, servidor.headersTimeout en el módulo 11
Puedes escuchar eventos del servidor servidor.on('error', ...) para capturar EADDRINUSE con un mensaje decente
Puedes tener dos servidores con la misma app HTTP y HTTPS, o un puerto de métricas aparte
// El error clasico: el puerto ya esta ocupado.
servidor.on('error', (error) => {
  if (error.code !== 'EADDRINUSE') throw error;
  console.error(`[escena-viva] el puerto ${configuracion.puerto} ya esta en uso`);
  process.exit(1);
});

  1. Apagado ordenado con SIGTERM

Cuando un orquestador (PM2, Docker, Kubernetes) quiere parar tu proceso, le envía la señal SIGTERM. Si no haces nada, Node muere de inmediato y las peticiones en curso se cortan a mitad: un comprador podría ver su navegador colgado justo después de enviar un pedido. El apagado ordenado consiste en dejar de aceptar conexiones nuevas, terminar las que están en curso, cerrar recursos y salir.

// src/servidor.js (continuacion)
const MILISEGUNDOS_DE_GRACIA = 10_000;

function registrarApagadoOrdenado(servidor) {
  let apagando = false;

  function apagar(senal) {
    if (apagando) return; // una segunda senal no duplica el apagado
    apagando = true;
    console.error(`[escena-viva] recibida ${senal}, cerrando ordenadamente...`);

    // 1. Dejamos de aceptar conexiones nuevas; las en curso terminan.
    servidor.close((error) => {
      if (error) console.error('[escena-viva] error al cerrar:', error.message);
      process.exit(error ? 1 : 0);
    });

    // 2. Cerramos las conexiones keep-alive ociosas para no esperar a que expiren.
    servidor.closeIdleConnections();

    // 3. Red de seguridad; unref evita que el temporizador retenga el proceso.
    setTimeout(() => {
      console.error('[escena-viva] cierre forzado tras el tiempo de gracia');
      process.exit(1);
    }, MILISEGUNDOS_DE_GRACIA).unref();
  }

  process.on('SIGTERM', () => apagar('SIGTERM'));
  process.on('SIGINT', () => apagar('SIGINT')); // Ctrl+C en desarrollo
}

Detalles que la gente suele saltarse: la bandera apagando evita que pulsar Ctrl+C dos veces lance dos apagados simultáneos; sin closeIdleConnections() una conexión keep-alive ociosa mantiene el servidor vivo hasta que expire y el cierre se eterniza; y el temporizador con unref() garantiza que el proceso termine aunque algo se atasque, sin impedir que termine antes si todo va bien. Aquí es donde en el módulo 11 añadiremos el cierre de la base de datos y el volcado de los registros pendientes.

  1. app.locals frente a res.locals

Dos almacenes con nombres parecidos y vidas muy distintas.

app.locals res.locals
Ámbito Toda la aplicación Una única petición/respuesta
Vida Desde el arranque hasta el apagado Desde que llega la petición hasta que se responde
Compartido entre usuarios Sí No
Uso típico Nombre de la plataforma, versión, constantes Identificador de petición, usuario autenticado, instante de inicio
// Globales: se fijan una vez, en crearAplicacion().
aplicacion.locals.salas = ['Teatro Almendra', 'Sala Boveda', 'Auditorio Ribera'];

// Por peticion: los pone un middleware (06-04).
aplicacion.use((peticion, respuesta, siguiente) => {
  respuesta.locals.idPeticion = crypto.randomUUID();
  siguiente();
});

Error grave y frecuente: guardar datos del usuario actual en app.locals. Como es global, el segundo usuario vería los datos del primero. Todo lo que dependa de quién hace la petición va en res.locals (o en req, como haremos con req.evento y req.datosValidados).

  1. Una nota sobre motores de vistas

Express sabe renderizar plantillas en el servidor: app.set('view engine', 'ejs'), app.set('views', ...) y res.render('evento', { evento }). Es la forma clásica de generar HTML desde Node y sigue siendo válida para sitios con contenido servido.

Escena Viva no la usa. Nuestro front-end es la carpeta publico/ (HTML, CSS y un app.js que llama a la API con fetch), y el servidor es una API JSON: la misma API sirve a un front-end web, a una app móvil o a un panel interno, y sus respuestas se prueban con supertest sin parsear HTML. Lo que sí conservamos es express.static, que sirve esos ficheros y sustituye a tu estaticos.js del módulo 4; lo desmenuzaremos en 06-04.

Errores Comunes y Consejos

  • Llamar a listen() dentro de app.js. Rompe las pruebas del módulo 9 y el apagado del módulo 11. Si ves un listen fuera de servidor.js, es un error.
  • Exportar la app ya construida (module.exports = app) en vez de una factoría. Funciona hasta que una prueba necesita otra configuración; entonces hay que refactorizarlo todo.
  • process.env esparcido por veinte ficheros. Imposible saber qué necesita la aplicación. Centraliza en config/index.js.
  • Confundir app.get('nombre') con app.get('/ruta', manejador). Un argumento lee un ajuste; dos registran una ruta.
  • Activar trust proxy sin proxy. Regalas la capacidad de falsificar la IP a cualquiera.
  • Subir el .env al repositorio. A .gitignore desde el primer día; versiona .env.ejemplo.
  • Consejo: ejecuta node -e "require('./src/config/index.js')" en el script comprobar. Si la configuración es inválida, lo sabrás en la integración continua y no en producción.

Ejercicios

Ejercicio 1: la separación en la práctica

Crea src/app.js y src/servidor.js con la estructura de esta lección y demuestra que la separación funciona: escribe un tercer fichero probar-app.js que importe crearAplicacion(), la monte en un http.createServer sobre el puerto 0 (Node asigna uno libre), haga una petición a /salud con fetch y cierre el servidor. No debe tocar src/servidor.js en ningún momento.

Ejercicio 2: configuración que falla rápido

Amplía src/config/index.js con una variable MONEDA_POR_DEFECTO que solo admita EUR, USD o GBP, con EUR por defecto. Comprueba que un valor inválido impide el arranque con un mensaje claro y código de salida 1.

Ejercicio 3: apagado observable

Añade al apagado ordenado un contador de peticiones en curso: un middleware lo incrementa al entrar y lo decrementa en res.on('finish'). Al recibir SIGTERM, el servidor debe imprimir por stderr cuántas peticiones quedaban vivas. Pruébalo con un endpoint lento (/lento, que tarda 3 segundos) y enviando la señal mientras está en curso.

Soluciones

Solución 1

// probar-app.js
const http = require('node:http');
const { crearAplicacion } = require('./src/app.js');

async function principal() {
  const servidor = http.createServer(crearAplicacion({ servirEstaticos: false }));
  // Puerto 0: el sistema operativo asigna uno libre. Es lo que hace supertest.
  await new Promise((resolver) => servidor.listen(0, '127.0.0.1', resolver));

  const respuesta = await fetch(`http://127.0.0.1:${servidor.address().port}/salud`);
  console.log(JSON.stringify({ estado: respuesta.status, cuerpo: await respuesta.json() }));

  await new Promise((resolver) => servidor.close(resolver));
}

principal().catch((error) => {
  console.error('[probar-app]', error.message);
  process.exitCode = 1;
});

Que esto sea posible sin arrancar src/servidor.js es exactamente la prueba de que la separación está bien hecha.

Solución 2

// Dentro de src/config/index.js
const MONEDAS_VALIDAS = ['EUR', 'USD', 'GBP'];

function moneda(nombre, porDefecto) {
  const valor = opcional(nombre, porDefecto).toUpperCase();
  if (!MONEDAS_VALIDAS.includes(valor)) {
    throw new Error(`${nombre} debe ser una de: ${MONEDAS_VALIDAS.join(', ')}`);
  }
  return valor;
}
// ...dentro de construirConfiguracion(): monedaPorDefecto: moneda('MONEDA_POR_DEFECTO', 'EUR'),
MONEDA_POR_DEFECTO=YEN node src/servidor.js
# [configuracion] MONEDA_POR_DEFECTO debe ser una de: EUR, USD, GBP   (salida: 1)

Solución 3

// src/servidor.js — fragmentos con el contador
let enCurso = 0;

// Registrado el PRIMERO para contar absolutamente todo.
aplicacion.use((peticion, respuesta, siguiente) => {
  enCurso += 1;
  respuesta.on('finish', () => { enCurso -= 1; });
  siguiente();
});

aplicacion.get('/lento', (p, r) => setTimeout(() => r.json({ estado: 'ok' }), 3000));

process.on('SIGTERM', () => {
  console.error(`[escena-viva] SIGTERM con ${enCurso} peticiones en curso`);
  servidor.close(() => process.exit(0));
  servidor.closeIdleConnections();
});

Detalle importante: para que el contador cuente, hay que registrarlo antes que las rutas. Ese es el tema central de 06-04.

Conclusión

Ya no tienes un script de juguete, sino el esqueleto de una aplicación de verdad. La pieza central es la separación entre crearAplicacion() —que construye y devuelve la app sin arrancarla— y src/servidor.js —que crea el servidor http, lo pone a escuchar y sabe apagarlo ordenadamente cuando llega SIGTERM—. Esa separación no es estética: es lo que hará posible probar con supertest en el módulo 9, acoplar Socket.IO en el módulo 12 y desplegar con PM2 y Docker en el módulo 11. Además has definido la estructura de carpetas de Escena Viva con responsabilidades claras (y sabes cuándo no aplicarla), has elegido a conciencia los ajustes del framework (x-powered-by fuera, trust proxy según entorno, case sensitive routing activo) y has centralizado la configuración en un módulo que lee process.env una sola vez, valida tipos y rangos y falla rápido si algo no cuadra.

La app, sin embargo, tiene una única ruta: /salud. En la siguiente lección, Enrutamiento en Express, la llenamos: parámetros de ruta comparados con el compilarPatron que escribiste a mano, express.Router() para montar /api/eventos, /api/sesiones y /api/pedidos como módulos independientes, router.param() para cargar el evento una sola vez, y la regla que más quebraderos de cabeza evita: el orden de declaración de las rutas.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados