Skip to content

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 --> PG

El 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=#1e3a8a

Todas 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-stopped

Reverse 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-stopped

Cada 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" 3001

SSL/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.com

Auto-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 --build

Todos los tenants

bash
for dir in deployments/*/; do
  echo "Rebuilding ${dir}..."
  (cd "${dir}" && docker compose up -d --build)
done

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