deploya

Documento 3 de 4 · Manual técnico

Manual técnico

Tecnologías, instalación local y en servidor, configuración, despliegue en AWS con Terraform, modo autoalojado, operación diaria, pruebas y solución de problemas.

01RequisitosQué hace y para quién
02DiseñoCómo está hecho y por qué
03Manual técnicoCómo se instala y opera
04Manual de usuarioCómo se usa
Institución
Facultad de Ingeniería
Ingeniería en Informática y Sistemas
Curso
Ingeniería de Software I · Proyecto final 2026
Entrega
Presentación final · 13 de noviembre de 2026
Repositorio
github.com/derekCmorales/deploya

Equipo

  • Eddy PorojCuentas, seguridad y notificaciones (M1, M10)
  • Javier RodríguezSuscripciones, pagos y administración (M2, M9)
  • Eduardo PortilloProyectos, observabilidad y design system (M3, M7)
  • Derek Calderón MoralesMotor de despliegue, arquitectura e infraestructura (M4–M6)
Versión
1.1
Fecha
13 de noviembre de 2026
Estado
Presentación final
Fuente
docs/sitio/doc/03-deploya-manual-tecnico.html

Contenido

  1. 01Introducción3
  2. 02Tecnologías utilizadas4
  3. 2.1Stack4
  4. 2.2Estructura del repositorio5
  5. 03Instalación local6
  6. 3.1Requisitos6
  7. 3.2Levantar la plataforma con un comando6
  8. 3.3Cuentas de demostración6
  9. 3.4Desarrollo fuera de Docker7
  10. 04Configuración8
  11. 4.1Variables principales8
  12. 4.2Archivos de configuración9
  13. 05Despliegue en AWS10
  14. 5.1Infraestructura10
  15. 5.2Primera puesta en marcha12
  16. 5.3Landing en S3 y CloudFront12
  17. 5.4Costo del piloto12
  18. 5.5Comprobar que quedó13
  19. 5.6Integración continua y publicación14
  20. 06Modo autoalojado16
  21. 07Operación19
  22. 7.1Actualizar y respaldar19
  23. 7.2Recursos de datos: respaldo y restauración19
  24. 7.3Tareas programadas recomendadas19
  25. 7.4Bitácoras y diagnóstico20
  26. 08Pruebas21
  27. 09Solución de problemas22
  28. 10Generar esta documentación23

Introducción

Este manual explica cómo se instala, se configura, se despliega y se opera Deploya. Está escrito para tres lectores:

Tecnologías utilizadas

Stack

CapaTecnologíaVersiónPara qué
LenguajeTypeScript5.7Todo el sistema: API, trabajador y web
EntornoNode.js22Ejecución de la API, el trabajador y la web
Gestor de paquetespnpm (workspaces)9.15Monorepo apps/api, apps/web, e2e
BackendNestJS10.4Módulos, inyección de dependencias, guards, filtros
ORM y migracionesPrisma6.5Esquema, cliente tipado, migraciones versionadas
Base de datosPostgreSQL16Almacén único de la plataforma
ColaRedis + BullMQ7 · 6.3Cola de despliegues, de operación y de muestreo
ContenedoresDocker Engine + dockerode24+ · 5.0Construir imágenes y correr las apps de los clientes
Enrutador de bordeTraefik3.3Rutas por archivo, HTTPS comodín, redirección
CertificadosLet's Encrypt (DNS-01)—Certificado *.dominio renovado solo
Correonodemailer + proveedor SMTP6.10Resend en producción, Mailpit en desarrollo
PDFPDFKit0.17Comprobantes de pago
FrontendNext.js + React15 · 19Panel del cliente y del administrador
EstilosTailwind CSS + tokens OKLCH4.3Design system v4.1
ComponentesRadix UI, Lucide, Framer Motion—Accesibilidad, iconos y movimiento
PruebasJest, @nestjs/testing, node --test29 · —Unitarias de API y de lógica web
CIGitHub Actions, gitleaks, shellcheck—Pruebas, secretos y scripts en cada PR
Infraestructura como códigoTerraform + proveedor hashicorp/aws1.9 · 5.80EC2, red, IAM, snapshots, presupuesto y la landing
NubeAWS: EC2, EBS, DLM, S3, CloudFront, ACM, SSM, Budgets—Piloto en us-east-2; la landing va por CloudFront
Recursos de datosPostgreSQL + pgvector, Redis, almacenamiento S3 (pgsty/silo)17 · 7 · —Bases que el cliente crea con un clic; contenedor con volumen en el nodo
EspecificaciónOpenSpec—Un change por historia: propuesta, diseño, spec y tareas

Estructura del repositorio

CarpetaContenido
apps/api/API NestJS y trabajador (src/main.ts y src/trabajador.ts), prisma/ con esquema, migraciones y seed
apps/api/src/modules/Los once módulos: identidad, suscripciones, proyectos, construccion, orquestacion, datos, enrutamiento, observabilidad, herramientas, administracion, notificaciones
apps/api/src/adapters/Adaptadores reales (reales/), en memoria (memoria/), Prisma (prisma/) y stubs
apps/web/Next.js: rutas por grupo ((auth), (billing), (projects), (admin), (cuenta)), componentes y tokens
e2e/Smoke y recorrido de punta a punta
infra/traefik/Configuración estática de Traefik para local y producción
infra/terraform/Servidor en AWS, landing en S3 + CloudFront, snapshots diarios y presupuesto
scripts/instalar.sh, actualizar.sh, respaldar.sh, desplegar-vps.sh y sus etapas en lib/
openspec/Specs por módulo y changes archivados
docs/Alcance, ADR, contratos, diagramas, design system y esta documentación (docs/sitio/)

Instalación local

Requisitos

ParaNecesitas
Levantar todo con Docker (recomendado)Docker con Compose v2 y 4 GB de RAM libres
Desarrollar fuera de DockerAdemás, Node.js 22 y pnpm 9 (corepack enable)
Generar esta documentación en PDFNode.js 22 y Chromium (lo instala Playwright)

Levantar la plataforma con un comando

git clone https://github.com/derekCmorales/deploya.git
cd deploya
cp .env.example .env
# Edita .env y define ADMIN_CLAVE (obligatoria)
docker compose up --build

Compose arranca en orden: postgres y redis (con healthcheck), luego migracion (aplica las migraciones y el seed, y termina), luego api, worker, traefik, mailpit y web.

ServicioDirección
Panel webhttp://localhost:3000
API (salud)http://localhost:3001/health
Bandeja de Mailpit (correos de verificación y recuperación)http://localhost:8025
Apps desplegadashttp://<proyecto>.localhost
Catálogo del design systemhttp://localhost:3000/sistema

Cuentas de demostración

El seed es idempotente: se puede correr las veces que haga falta sin duplicar nada ni reescribir contraseñas.

CuentaRolEstado para la demoContraseña
admin@deploya.app (o ADMIN_CORREO)AdministradorActivaADMIN_CLAVE
cliente@deploya.appClienteActiva, plan SandboxCLIENTE_CLAVE (o ADMIN_CLAVE si está vacía)
vencida@deploya.appClienteStarter con la suscripción VencidaIgual que cliente@
suspendida@deploya.appClienteStarter con la suscripción SuspendidaIgual que cliente@

Desarrollo fuera de Docker

pnpm install
docker compose up postgres redis mailpit     # solo la base, la cola y el correo
set -a; . ./.env; set +a
pnpm --filter @deploya/api exec prisma migrate deploy
pnpm --filter @deploya/api prisma:seed
pnpm dev:api                                 # http://localhost:3001 con recarga
pnpm dev:web                                 # http://localhost:3000 con recarga

Con MOTOR_ADAPTADORES=stub (valor de .env.example) el motor corre en memoria: los despliegues avanzan por sus etapas sin Docker, útil para trabajar en la interfaz. Con MOTOR_ADAPTADORES=docker se usan BullMQ, git, Docker y Traefik reales, y hace falta el worker.

Configuración

Toda la configuración entra por variables de entorno. El código no tiene dominios, credenciales ni proveedores escritos: el mismo build corre en local, en AWS y en autoalojado.

Variables principales

VariableEjemplo / valoresPara qué
DATABASE_URLpostgresql://deploya:deploya@localhost:5432/deployaConexión de Prisma
REDIS_URLredis://localhost:6379Colas BullMQ
PORT · URL_WEB · NEXT_PUBLIC_API_URL3001 · http://localhost:3000 · http://localhost:3001Puertos y enlaces entre web y API (CORS, enlaces de correo)
ADMIN_CORREO · ADMIN_CLAVE · CLIENTE_CLAVE—Cuentas del seed; ADMIN_CLAVE es obligatoria
CORREO_ADAPTADORsmtp · consolaElige el adaptador de correo (ADR 0001)
SMTP_HOST · SMTP_PORT · SMTP_USUARIO · SMTP_CLAVE · CORREO_REMITENTElocalhost · 1025Servidor SMTP (Mailpit en desarrollo, Resend en producción)
MOTOR_ADAPTADORESdocker · stubMotor real o en memoria
DOMINIO_BASE · ESQUEMA_PUBLICO · CERT_RESOLVERlocalhost · http · vacíoDónde y cómo se publican las apps; resolvedor vacío = sin TLS
TRABAJADOR_CONCURRENCIA1Construcciones simultáneas en el nodo
MOTOR_TIEMPO_MAXIMO_CONSTRUCCION_MIN20Tope de una construcción; proyectos grandes de Next.js o NestJS lo necesitan
HOST_WEB · URL_LANDINGapp.<dominio> · https://<dominio>La web vive en app. y quien llega a / sin sesión va a la landing (ADR 0011)
CLAVE_CIFRADO_VARIABLES32 bytes exactosCifrado AES-256-GCM de las variables de los clientes
GITHUB_TOKEN · GITHUB_API_URLopcionalSube el límite de 60 peticiones por hora de la API pública
MODO_INSTALACIONnube · autoalojadoElige las estrategias de catálogo, cuotas, registro y primer usuario
NEXT_PUBLIC_DOMINIO_APPS · NEXT_PUBLIC_ESQUEMA_APPSlocalhost · httpLa web arma la URL de cada proyecto

Archivos de configuración

ArchivoUso
.env.example → .envDesarrollo local y compose
.env.vps.example → .env.vpsServidor de producción: dominio, token DNS, SMTP, secretos. Se copia a mano por Session Manager; nunca se sube al repositorio
docker-compose.ymlServicios base para todos los entornos
docker-compose.vps.ymlCapa de producción (el nombre «vps» es histórico): HTTPS, certificado comodín, redirección y volúmenes de ACME
docker-compose.http01.ymlVariante con HTTP-01 para un servidor sin token DNS
docker-compose.autoalojado.ymlCapa del modo autoalojado
infra/traefik/Configuración estática de Traefik; plataforma.generada.yml la escribe el script
infra/terraform/terraform.tfvarsDominio, región, tipo de instancia, correos del presupuesto; sin secretos

Despliegue en AWS

El piloto corre en una EC2 creada con Terraform (ADR 0009): la misma plataforma de docker compose que en local, más HTTPS y el certificado comodín. La landing vive fuera del servidor, en S3 y CloudFront, para que siga arriba aunque la EC2 se reinicie (ADR 0011). Ningún secreto pasa por Terraform.

Infraestructura

RecursoQué esArchivo
ServidorEC2 m7i-flex.large (2 vCPU, 8 GB) con Ubuntu 24.04 en us-east-2, IMDSv2 de un salto y 4 GB de swap para construirmain.tf, cloud-init.yml
RedVPC propia con subred pública, sin NAT; grupo de seguridad con 80 y 443; el 22 solo si se pide un CIDRred.tf
AccesoSession Manager (SSM) en vez de SSH; el perfil de la instancia solo tiene AmazonSSMManagedInstanceCoreiam.tf
DiscoEBS gp3 de 80 GB cifrado; guarda la base, las imágenes y los volúmenes de los recursos de datosmain.tf
RespaldoSnapshot diario con Data Lifecycle Manager, se guardan 7respaldos.tf
IPIP elástica: no cambia aunque se reemplace la instanciamain.tf
LandingBucket S3 privado, CloudFront con OAC, certificado de ACM en us-east-1 y cabeceras de seguridadlanding.tf
CostoAWS Budgets con tope de 70 USD y avisos por correomain.tf
DNSCloudflare en «Solo DNS»: *.<dominio> → IP elástica; raíz y www → CloudFronta mano
CorreoResend con el dominio verificado (SPF, DKIM y DMARC).env.vps

EC2 m7i-flex.large · 8 GB

Landing · ADR 0011

red de cada proyecto

docker compose

clona

ContenedorPuerto

RecursosDatosPuerto

verificación y avisos

Navegador

Cloudflare · solo DNS
raíz y www → CloudFront · app, api y * → IP elástica

CloudFront + ACM
HSTS · nosniff

S3 privado · landing Astro

Traefik v3 · TLS comodín *.<dominio> por DNS-01

web · Next.js
app.<dominio>

api · NestJS
api.<dominio>

worker
construye y orquesta · docker.sock

PostgreSQL 16
plataforma

Redis 7
colas BullMQ

app del cliente
límites del plan · CapDrop ALL

Postgres 17 + pgvector · Redis · S3
volumen persistente

EBS gp3 80 GB cifrado · snapshot diario DLM, 7 días

GitHub · repos públicos

Resend · correo

Despliegue — Deploya en AWS (piloto) y en local
Figura 1. Despliegue — Deploya en AWS (piloto) y en local

Primera puesta en marcha

Requisitos: Terraform 1.9 o más, AWS CLI v2 con el plugin de Session Manager y credenciales de la cuenta.

cd infra/terraform
cp terraform.tfvars.example terraform.tfvars   # dominio y correos del presupuesto
terraform init
terraform plan -out=piloto.tfplan
terraform apply piloto.tfplan
terraform output siguientes_pasos             # imprime los pasos con la IP y el ID reales
  1. DNS en Cloudflare, en «Solo DNS»: lo que imprime terraform output registros_dns.
  2. Esperar a cloud-init (5 a 10 min): aws ssm start-session --target <id> y cloud-init status --wait. Deja Docker, el usuario deploya y el repositorio en /srv/deploya.
  3. Copiar .env.vps dentro de la sesión de SSM, sin que pase por Terraform ni por un chat: sudo -iu deploya, cd /srv/deploya && umask 077 && cat > .env.vps y chmod 600 .env.vps.
  4. Publicar: RAMA=main scripts/desplegar-vps.sh.
  5. Guardar CLAVE_CIFRADO_VARIABLES fuera del servidor.

desplegar-vps.sh es idempotente: hace git pull --ff-only de la rama, genera la ruta de la plataforma para Traefik, corre docker compose --env-file .env.vps -f docker-compose.yml -f docker-compose.vps.yml up -d --build, repite migraciones y seed y espera a que la API responda. Cada publicación posterior es solo ese comando.

Con HOST_WEB=app.<dominio> la web queda en app.<dominio>, la API en api.<dominio>, cada proyecto en <proyecto>.<dominio> y el S3 de un proyecto en <proyecto>-s3.<dominio>. La raíz es la landing.

Landing en S3 y CloudFront

La landing es un sitio estático de Astro en el repositorio deploya_landing. Se publica con AWS CLI desde ese repositorio:

LANDING_BUCKET=$(terraform -chdir=infra/terraform output -raw landing_bucket) \
LANDING_DISTRIBUCION=$(terraform -chdir=infra/terraform output -raw landing_distribucion) \
pnpm deploy:aws     # aws s3 sync + invalidación de CloudFront

La primera vez, ACM necesita ver los CNAME de validación en Cloudflare antes de emitir el certificado: terraform apply -target=aws_acm_certificate.landing, crear los registros de registros_dns_validacion_landing y repetir el apply.

Costo del piloto

Configuración5 díasMes
m7i-flex.large + 80 GB + IPv4 + snapshots (la del piloto)≈ 13.5 USD≈ 82 USD
m7i-flex.xlarge (16 GB), plan B si Nimbo convive con otros clientes≈ 25 USD≈ 152 USD
Landing (S3 + CloudFront), ACM, SSM, DLM, Budgets0≈ 0

El modelo completo, con la capacidad por nodo y el margen por plan, está en docs/costos.md. terraform destroy borra también el disco: antes, respaldo y copia de la clave de cifrado.

Comprobar que quedó

curl -I https://<dominio>                     # la landing, desde CloudFront
curl -I https://app.<dominio>                 # la web
curl -I https://api.<dominio>/health          # 200 con el certificado *.<dominio>
curl -I https://algo.<dominio>                # 404 de Traefik, pero con el certificado comodín
curl -I http://hola-deploya.<dominio>         # 301 hacia https
echo | openssl s_client -connect <dominio>:443 -servername x.<dominio> 2>/dev/null \
  | openssl x509 -noout -subject -enddate

Integración continua y publicación

GitHub Actions · ci.yml

No

Sí

Rama
feat/m‹n›-‹slug›

Pull request
plantilla + CODEOWNERS

Build y pruebas
pnpm test · pnpm build
validate:mermaid

Secretos
gitleaks sobre todo el historial

Scripts
shellcheck del instalador

Smoke compose
docker compose up · e2e/smoke.mjs

¿Verde y
aprobado?

main

/opsx-archive
el spec delta pasa al spec del módulo

scripts/desplegar-vps.sh
git pull · compose up --build
migraciones · seed · espera health

https://‹dominio›
en línea

Figura 2. Integración continua y publicación — de la rama al servidor en AWS
Job de CIQué comprueba
Build y pruebaspnpm install --frozen-lockfile, prisma generate, pnpm test, pnpm build, pnpm validate:mermaid
Secretos (gitleaks)Ningún secreto en todo el historial del repositorio público
Scripts (shellcheck)instalar.sh, respaldar.sh, actualizar.sh y sus etapas
Smoke composedocker compose up --build, espera la salud y corre e2e/smoke.mjs

Modo autoalojado

Deploya es de código abierto (AGPL-3.0) y se instala en un servidor propio. En autoalojado no hay cobro: un único plan «Autoalojado» sin vencimiento, límites por proyecto que fija el administrador y registro abierto, con invitación o cerrado (ADR 0008).

si una etapa falla

./scripts/instalar.sh

1 · Requisitos
Docker 24+, Compose v2
curl, dig, openssl

2 · Dominio
la raíz y el comodín
apuntan a esta IP

3 · Certificado
comodín por DNS-01
o HTTP-01 (--sin-comodin)

4 · Servicios
.env con secretos generados
compose up · migraciones

5 · Administrador
imprime el código de
configuración (30 min)

/configurar

Se detiene con instrucciones
corriges y corres --continuar
retoma en la etapa que falló

Figura 3. Instalador autoalojado — cinco etapas que comprueban antes de escribir
./scripts/instalar.sh                  # instala por etapas
./scripts/instalar.sh --continuar      # retoma después de corregir lo que falló
./scripts/instalar.sh --sin-comodin    # certificado por HTTP-01 si no hay token DNS
./scripts/instalar.sh --codigo-nuevo   # genera otro código de configuración
EtapaCompruebaAplica
1 · RequisitosDocker 24+, Compose v2, curl, dig, openssl—
2 · DominioQue la raíz y un nombre del comodín resuelvan (registro A, no CNAME) a esta máquina—
3 · CertificadoToken del proveedor DNS o, con --sin-comodin, el puerto 80Estrategia DNS-01 o HTTP-01
4 · Servicios—.env con permisos 600 y secretos generados (32 bytes), compose up, migraciones
5 · Administrador—Imprime una sola vez el código de configuración (vence en 30 min); solo su hash sha256 va al .env

Con el código, se abre https://<dominio>/configurar: el asistente pide el código, crea la cuenta de administrador, prueba el SMTP (o sigue sin correo) y elige la política de registro. Lo guardado vive en la tabla ConfiguracionInstalacion, no en archivos, y se cambia después en Admin · Instalación.

Operación

Actualizar y respaldar

./scripts/respaldar.sh                         # base + configuración, sin la clave de cifrado
./scripts/respaldar.sh --restaurar AAAA-MM-DD  # vuelve a un respaldo
./scripts/actualizar.sh                        # respalda, migra y reinicia api, web y worker
./scripts/actualizar.sh --volver               # restaura el respaldo previo a la actualización

Recursos de datos: respaldo y restauración

Cada PostgreSQL, Redis o S3 que crea un cliente es un contenedor deploya-datos-<subdominio>-<tipo> con un volumen del mismo nombre (ADR 0012). El volumen sobrevive a despliegues, reversiones y reinicios; solo se borra al eliminar el recurso o el proyecto dueño.

docker system df -v | grep deploya-datos-               # uso de disco por volumen
C=deploya-datos-nimbo-api-postgres
docker exec $C pg_dump -U deploya -Fc app > app.dump    # respaldo lógico sin detenerlo
docker exec -i $C pg_restore -U deploya -d app --clean < app.dump

Tareas programadas recomendadas

TareaFrecuenciaComando
RespaldoDiaria (cron)./scripts/respaldar.sh
Limpiar imágenes huérfanasSemanaldocker image prune -f
Vigilar la saludCada 5 mincurl -fsS https://api.<dominio>/health
Ciclo de suscripcionesDiaria, 00:00 UTCLo programa la API (BullMQ); no requiere cron
Medición del disco de los recursos de datosPeriódicaLo programa el trabajador; guarda usoDiscoMb
Muestreo de métricasCada minutoLo programa el trabajador; purga lo de más de 24 h

Bitácoras y diagnóstico

docker compose logs -f api         # peticiones, errores de dominio, fallos de SMTP
docker compose logs -f worker      # pipeline de despliegue, docker build, salud
docker compose logs -f traefik     # rutas y emisión del certificado (ACME)
docker compose ps                  # estado y salud de cada servicio

El panel también muestra el estado: Admin · Infraestructura (CPU, memoria y disco del nodo, cola y última muestra de cada entorno) y Admin · Instalación (salud de Docker, DNS, certificado, disco y correo).

Pruebas

ComandoQué corre
pnpm testPruebas de la API (Jest), de la web (node --test) y del repositorio
pnpm test:covCobertura de la API (hoy 96.8 % de líneas)
pnpm checkLo mismo que CI: pruebas, build y diagramas
pnpm test:smokeSmoke contra una plataforma levantada
pnpm diagramas:syncCopia cada .mmd a los documentos que lo embeben

Las pruebas unitarias no usan Docker, red, base ni reloj reales: los servicios reciben dobles de sus puertos y un Reloj falso. Cada escenario de OpenSpec tiene su prueba con el mismo nombre.

Solución de problemas

SíntomaCausa probableQué hacer
docker compose up termina sin APIFalta ADMIN_CLAVE en .envDefínela y vuelve a levantar
Una base de datos no se ve desde la appEl proyecto no se ha redesplegado desde que se creó o compartió el recursoLas variables se inyectan en el siguiente despliegue: redespliega
La construcción se corta a los 20 minutosProyecto grande y tope por defectoSube MOTOR_TIEMPO_MAXIMO_CONSTRUCCION_MIN en .env.vps
La web llama a localhost:3001 en el servidorLa imagen de la web se construyó sin los build.argsdocker compose … up -d --build web con .env.vps
Traefik sirve su certificado por defectoError de ACME: token sin permiso, DNS sin propagar o límite de Let's Encryptdocker compose logs traefik; revisa el token y los registros A
certificado: null en la vista de despliegueacme.json vacío (aún no se emite) o la API sin el volumen traefik_acmeEspera la emisión; revisa el montaje de solo lectura
502 al abrir un proyectoEl contenedor no está en la red del proyectoRevisa la bitácora del worker
El correo no llegaSMTP_* incorrectas o dominio sin verificarLa API registra el fallo; revisa SPF y DKIM en el proveedor
«GitHub no disponible» al crear un proyectoLímite de 60 peticiones por hora agotadoDefine GITHUB_TOKEN (un token sin permisos basta)
Un despliegue falla en ConstrucciónError del Dockerfile o de la receta, o más de 10 minLa vista de despliegue marca la línea del error en la bitácora
Un despliegue falla en EjecuciónEl contenedor no respondió HTTP en 60 sLa app debe escuchar en el puerto interno (PORT, 8080 por defecto)
Variables ilegiblesCambió CLAVE_CIFRADO_VARIABLESRestaura la clave original; la versión activa sigue sirviendo
Disco lleno en el nodoImágenes acumuladasdocker image prune -f; la retención conserva 5 artefactos por proyecto

Generar esta documentación

Los cuatro documentos viven como HTML en docs/sitio/doc/, con el design system v4.1 en modo claro, y se publican tal cual en Cloudflare Pages junto con sus PDF. Los diagramas salen de los .mmd de docs/diagramas/.

pnpm diagramas:sync                    # los .md y .mmd al día
node scripts/docs-sitio/construir.mjs  # diagramas, índice con páginas y los cuatro PDF

El script redibuja los diagramas marcados, genera cada PDF con Chromium, numera el índice con las páginas reales (con pdftotext) y vuelve a generar el PDF. El sitio queda en docs/sitio/: Cloudflare Pages lo sirve sin paso de construcción.