Appearance
Infraestructura y Deployment
Estado: Planificado
Arquitectura General
mermaid
graph TD
Internet["Internet"]
RP["Reverse Proxy<br/>(Traefik o nginx-proxy)"]
Internet --> RP
subgraph "Docker Host"
RP --> C1["portal-tenant-a<br/>:3001"]
RP --> C2["portal-tenant-b<br/>:3002"]
RP --> C3["portal-tenant-c<br/>:3003"]
RP --> CN["portal-tenant-n<br/>:300N"]
end
subgraph "Backend Compartido"
API["bautista-backend<br/>PHP 8.2 + Slim 4<br/>(NO dockerizado por tenant)"]
PG["PostgreSQL<br/>Schemas por sucursal"]
end
C1 --> API
C2 --> API
C3 --> API
CN --> API
API --> PGEl modelo es Docker por tenant para el frontend. Cada tenant recibe su propia instancia Docker con configuracion pre-establecida. El backend es compartido.
No hay multi-tenancy por dominio. La URL de acceso es irrelevante para la resolucion de tenant; cada contenedor ya sabe a que tenant pertenece por sus variables de entorno.
Requisitos del Docker Host
Hardware minimo:
- CPU: 2 cores (escala con cantidad de tenants)
- RAM: 2 GB base + ~50 MB por contenedor nginx:alpine
- Disco: 10 GB base + ~20 MB por contenedor (build estatico)
Software:
- Ubuntu 22.04 LTS (o similar)
- Docker Engine 24+
- Docker Compose v2
- Reverse proxy: Traefik o nginx-proxy
Estimacion de capacidad: Un servidor modesto (4 cores, 8 GB RAM) puede alojar facilmente 50+ portales de tenants, dado que cada contenedor es un nginx:alpine sirviendo archivos estaticos.
Template .env
Cada instancia Docker se configura con estas variables de entorno (inyectadas como build args):
env
# Backend
VITE_BACKEND_URL=https://api.bautista.com
# Tenant
VITE_TENANT_ID=tenant_codigo
VITE_SUCURSAL_ID=suc0001
# Branding
VITE_APP_NAME=Portal Mi Empresa
VITE_LOGO_URL=https://mi-empresa.com/logo.png
VITE_PRIMARY_COLOR=#1e40af
VITE_THEME_COLOR=#1e3a8aTodas las variables VITE_* son resueltas en build time por Vite y quedan embebidas en el bundle estatico.
Dockerfile (Multi-stage Build)
dockerfile
# Stage 1: Build con Node
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production=false
COPY . .
# Variables inyectadas en build time
ARG VITE_BACKEND_URL
ARG VITE_TENANT_ID
ARG VITE_SUCURSAL_ID
ARG VITE_APP_NAME
ARG VITE_LOGO_URL
ARG VITE_PRIMARY_COLOR
ARG VITE_THEME_COLOR
RUN npm run build
# Stage 2: Serve con nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]Resultado: Imagen final de ~30 MB (nginx:alpine + archivos estaticos). No incluye Node.js.
docker-compose Template por Tenant
Cada tenant tiene su propio archivo docker-compose o se agrupa en un docker-compose compartido:
Opcion A: Archivo individual por tenant
yaml
# deployments/tenant-a/docker-compose.yml
version: "3.8"
services:
portal:
build:
context: ../../portal-usuarios
args:
VITE_BACKEND_URL: "https://api.bautista.com"
VITE_TENANT_ID: "tenant_a"
VITE_SUCURSAL_ID: "suc0001"
VITE_APP_NAME: "Portal Empresa A"
VITE_LOGO_URL: "https://empresa-a.com/logo.png"
VITE_PRIMARY_COLOR: "#1e40af"
VITE_THEME_COLOR: "#1e3a8a"
container_name: portal-tenant-a
ports:
- "3001:80"
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.portal-tenant-a.rule=Host(`portal-a.ejemplo.com`)"
- "traefik.http.routers.portal-tenant-a.tls=true"
- "traefik.http.routers.portal-tenant-a.tls.certresolver=letsencrypt"Opcion B: Archivo unico con todos los tenants
yaml
# docker-compose.portals.yml
version: "3.8"
services:
portal-tenant-a:
build:
context: ./portal-usuarios
args:
VITE_BACKEND_URL: "https://api.bautista.com"
VITE_TENANT_ID: "tenant_a"
VITE_SUCURSAL_ID: "suc0001"
VITE_APP_NAME: "Portal Empresa A"
VITE_LOGO_URL: "https://empresa-a.com/logo.png"
VITE_PRIMARY_COLOR: "#1e40af"
VITE_THEME_COLOR: "#1e3a8a"
container_name: portal-tenant-a
ports:
- "3001:80"
restart: unless-stopped
portal-tenant-b:
build:
context: ./portal-usuarios
args:
VITE_BACKEND_URL: "https://api.bautista.com"
VITE_TENANT_ID: "tenant_b"
VITE_SUCURSAL_ID: "suc0001"
VITE_APP_NAME: "Portal Empresa B"
VITE_LOGO_URL: "https://empresa-b.com/logo.png"
VITE_PRIMARY_COLOR: "#10b981"
VITE_THEME_COLOR: "#065f46"
container_name: portal-tenant-b
ports:
- "3002:80"
restart: unless-stoppedReverse Proxy
No se usa wildcard DNS ni multi-tenancy por dominio. Cada contenedor expone un puerto local y el reverse proxy enruta trafico externo.
Opcion 1: Traefik (Recomendado)
Traefik descubre contenedores automaticamente via labels de Docker.
yaml
# docker-compose.traefik.yml
version: "3.8"
services:
traefik:
image: traefik:v3.0
command:
- "--api.dashboard=true"
- "--providers.docker=true"
- "--providers.docker.exposedByDefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=admin@bautista.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
restart: unless-stoppedCada contenedor de portal se registra con labels de Traefik:
yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.portal-tenant-a.rule=Host(`portal-a.ejemplo.com`)"
- "traefik.http.routers.portal-tenant-a.entrypoints=websecure"
- "traefik.http.routers.portal-tenant-a.tls.certresolver=letsencrypt"
- "traefik.http.routers.portal-tenant-a.middlewares=redirect-https"
- "traefik.http.middlewares.redirect-https.redirectscheme.scheme=https"SSL se maneja automaticamente con Let's Encrypt por cada dominio/ruta configurada. No se necesita wildcard DNS ni wildcard certificate.
Opcion 2: nginx reverse proxy
nginx
# /etc/nginx/conf.d/portal-tenant-a.conf
server {
listen 443 ssl;
server_name portal-a.ejemplo.com;
ssl_certificate /etc/letsencrypt/live/portal-a.ejemplo.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/portal-a.ejemplo.com/privkey.pem;
location / {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name portal-a.ejemplo.com;
return 301 https://$server_name$request_uri;
}Script de Onboarding
Script para levantar una nueva instancia de portal para un tenant:
bash
#!/bin/bash
# scripts/portal-onboard.sh
# Uso: ./portal-onboard.sh tenant_id sucursal_id "Nombre App" "https://logo.url" "#color" "#theme" puerto
set -euo pipefail
TENANT_ID=$1
SUCURSAL_ID=$2
APP_NAME=$3
LOGO_URL=$4
PRIMARY_COLOR=$5
THEME_COLOR=$6
PORT=$7
BACKEND_URL=${8:-"https://api.bautista.com"}
DEPLOY_DIR="deployments/${TENANT_ID}"
echo "Creando deployment para tenant: ${TENANT_ID}"
mkdir -p "${DEPLOY_DIR}"
cat > "${DEPLOY_DIR}/docker-compose.yml" <<EOF
version: "3.8"
services:
portal:
build:
context: ../../portal-usuarios
args:
VITE_BACKEND_URL: "${BACKEND_URL}"
VITE_TENANT_ID: "${TENANT_ID}"
VITE_SUCURSAL_ID: "${SUCURSAL_ID}"
VITE_APP_NAME: "${APP_NAME}"
VITE_LOGO_URL: "${LOGO_URL}"
VITE_PRIMARY_COLOR: "${PRIMARY_COLOR}"
VITE_THEME_COLOR: "${THEME_COLOR}"
container_name: portal-${TENANT_ID}
ports:
- "${PORT}:80"
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.portal-${TENANT_ID}.rule=Host(\`portal-${TENANT_ID}.ejemplo.com\`)"
- "traefik.http.routers.portal-${TENANT_ID}.tls=true"
- "traefik.http.routers.portal-${TENANT_ID}.tls.certresolver=letsencrypt"
EOF
echo "Archivo creado: ${DEPLOY_DIR}/docker-compose.yml"
echo "Para levantar: cd ${DEPLOY_DIR} && docker compose up -d --build"Uso:
bash
./scripts/portal-onboard.sh tenant_a suc0001 "Portal Empresa A" \
"https://empresa-a.com/logo.png" "#1e40af" "#1e3a8a" 3001SSL/TLS
No se usan wildcard certificates ni wildcard DNS. Cada portal tiene su propio dominio o subdominio con certificado individual.
Con Traefik: SSL automatico via Let's Encrypt TLS challenge. Al agregar un nuevo contenedor con label de Host, Traefik solicita y renueva el certificado automaticamente.
Con nginx: Certificados individuales con certbot.
bash
certbot certonly --nginx -d portal-tenant-a.ejemplo.comAuto-renewal configurado via cron o systemd timer.
Actualizacion de Portales
Para actualizar el frontend (nueva version del codigo):
Un tenant especifico
bash
cd deployments/tenant_a
docker compose up -d --buildTodos los tenants
bash
for dir in deployments/*/; do
echo "Rebuilding ${dir}..."
(cd "${dir}" && docker compose up -d --build)
doneEl build reconstruye la imagen con el codigo actualizado y las mismas variables de entorno. El contenedor se reemplaza sin downtime gracias a restart: unless-stopped.
Monitoreo
Health Checks
Frontend (cada contenedor):
GET / -> 200 OK (nginx sirviendo index.html)Backend API:
GET /health -> { "status": "ok", "database": "connected" }Logs
- Contenedores Docker:
docker logs portal-tenant-a - Traefik: Access logs y error logs
- Backend PHP: Error logs
Metricas Importantes
- Estado de cada contenedor Docker (running/stopped)
- Uso de CPU/RAM por contenedor
- Tiempo de respuesta del backend API
- Tasa de error (5xx)
- Conexiones a base de datos
Seguridad
Headers de Seguridad (nginx.conf en cada contenedor)
nginx
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;Firewall
- Puertos abiertos: 80, 443 (HTTP/HTTPS via reverse proxy)
- Puertos de contenedores (3001, 3002, etc.): solo accesibles desde localhost o red Docker interna
- Puerto 5432 (PostgreSQL): solo accesible desde backend
Rate Limiting
Configurado en el reverse proxy (Traefik o nginx):
- Max 100 requests/minuto por IP
- Max 5 intentos de login/minuto
- Bloqueo temporal despues de fallos repetidos
Backup
Base de Datos (gestionado por backend compartido)
- Backup diario de cada schema de tenant
- Retention: 7 dias diarios, 4 semanas semanales, 12 meses mensuales
Codigo
- Repositorio Git (
portal-usuarios) como fuente de verdad - Las imagenes Docker se pueden reconstruir en cualquier momento desde el repo + .env
Configuraciones de Tenant
- Archivos docker-compose por tenant en
deployments/ - Versionados en Git o respaldados junto con scripts de onboarding
Escalabilidad
Vertical
- Aumentar CPU/RAM del Docker host
- nginx:alpine consume recursos minimos (~5 MB RAM por contenedor)
Horizontal
- Mover tenants a Docker hosts diferentes
- Load balancer entre hosts
- El backend escala independientemente (mas instancias PHP-FPM, PgBouncer, read replicas)