feat: deploy en host LXC (nginx + systemd) y ajustes para mismo-origen

- deploy/nginx-host.conf: nginx del host sirve dist/ de las 4 apps y
  proxifica /api/ a 127.0.0.1:3000 (client_max_body_size 10m)
- deploy/frasa-api.service: API Gateway como servicio systemd
- landing: URLs relativas (/quote/, /client/, /pay/)
- Dockerfiles de apps: ARG VITE_API_URL=/api para builds same-origin
- docker-compose: NGINX_CONF parametrizado (nginx.http.conf sin SSL)
- README: instrucciones para host LXC y nota de DDNS pendiente
This commit is contained in:
2026-08-22 00:05:28 +00:00
parent 0fb92364d8
commit 05f6ceb01a
10 changed files with 132 additions and 19 deletions

View File

@@ -18,7 +18,20 @@ Cada app es un proyecto Vite independiente: `npm install && npm run dev` (o `npm
Variables de entorno: copiar `.env.example` a `.env` y completar (el `.env` nunca se commitea). Variables de entorno: copiar `.env.example` a `.env` y completar (el `.env` nunca se commitea).
## Deploy (servidor Ubuntu + Docker) ## Servidor actual (LXC sin Docker)
Esta máquina es un contenedor LXC sin nesting: Docker no puede correr contenedores.
El stack corre directo en el host:
- **NGINX** (apt) sirve los `dist/` de las apps y proxifica `/api/` → `127.0.0.1:3000`.
Config: `deploy/nginx-host.conf` → `/etc/nginx/sites-available/frasa`.
- **API** como servicio systemd: `deploy/frasa-api.service` → `frasa-api.service`
(`systemctl status frasa-api`, logs en `/var/log/frasa-api.log`).
- Rebuild de apps tras cambios: `VITE_API_URL=/api npm run build` en cada app + `systemctl reload nginx` (no requiere restart).
El `docker-compose.yml` queda listo para cuando el stack se mude a una VM con Docker.
## Deploy con Docker (VM futura)
```bash ```bash
git clone https://git.consultoria-as.com/consultoria-as/FRASA-CAS.git /opt/frasa-ecosystem git clone https://git.consultoria-as.com/consultoria-as/FRASA-CAS.git /opt/frasa-ecosystem
@@ -34,13 +47,12 @@ docker compose logs -f api
curl https://frasadesarrollos.ddns.net/api/health curl https://frasadesarrollos.ddns.net/api/health
``` ```
Nota primer arranque: mientras no existan los certs, comenta el bloque `server 443` de Modo local sin SSL (LAN): `.env` con `NGINX_CONF=nginx.http.conf` hace que NGINX
`nginx/nginx.conf` (o usa un cert autofirmado temporal); después de expedir, restaura y sirva todo por HTTP en el puerto 80. Cuando el DDNS apunte a este servidor:
`certbot certonly --webroot -w /var/www/certbot -d frasadesarrollos.ddns.net`,
quita `NGINX_CONF` del `.env` (vuelve `nginx.conf` con 443) y
`docker compose restart nginx`. Renovación: `certbot renew --deploy-hook "docker compose -f /opt/frasa-ecosystem/docker-compose.yml restart nginx"`. `docker compose restart nginx`. Renovación: `certbot renew --deploy-hook "docker compose -f /opt/frasa-ecosystem/docker-compose.yml restart nginx"`.
Antes del deploy, cambiar en `apps/landing/src/data/modulos.ts` las URLs absolutas de
preview por rutas relativas (`/quote/`, `/client/`, `/pay/`) y rehacer el build.
Backup: `scripts/backup.sh` (cron sugerido `17 2 * * *`). Backup: `scripts/backup.sh` (cron sugerido `17 2 * * *`).
## Monday.com ## Monday.com

View File

@@ -4,6 +4,8 @@ WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
ARG VITE_API_URL=/api
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build RUN npm run build
# Etapa 2: servidor estático # Etapa 2: servidor estático

View File

@@ -4,6 +4,8 @@ WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
ARG VITE_API_URL=/api
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build RUN npm run build
# Etapa 2: servidor estático # Etapa 2: servidor estático

View File

@@ -4,6 +4,9 @@ WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
# URL del API horneada en el build (detras de NGINX es same-origin)
ARG VITE_API_URL=/api
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build RUN npm run build
# Etapa 2: servidor estático # Etapa 2: servidor estático

View File

@@ -7,20 +7,19 @@ describe('MODULOS', () => {
expect(MODULOS.map((m) => m.id)).toEqual(['cotizador', 'alta-clientes', 'cobranza']) expect(MODULOS.map((m) => m.id)).toEqual(['cotizador', 'alta-clientes', 'cobranza'])
}) })
it('cada módulo tiene nombre, descripción y URL de preview válida', () => { it('cada módulo tiene nombre, descripción y URL válida', () => {
for (const modulo of MODULOS) { for (const modulo of MODULOS) {
expect(modulo.nombre.length).toBeGreaterThan(0) expect(modulo.nombre.length).toBeGreaterThan(0)
expect(modulo.descripcion.length).toBeGreaterThan(0) expect(modulo.descripcion.length).toBeGreaterThan(0)
expect(() => new URL(modulo.url)).not.toThrow() expect(modulo.url).toMatch(/^\/[a-z]+\/$/)
expect(new URL(modulo.url).protocol).toBe('http:')
} }
}) })
it('apunta a los puertos y prefijos de preview correctos', () => { it('apunta a los prefijos correctos (same-origin detrás de NGINX)', () => {
const urls = Object.fromEntries(MODULOS.map((m) => [m.id, m.url])) const urls = Object.fromEntries(MODULOS.map((m) => [m.id, m.url]))
expect(urls['cotizador']).toBe('http://192.168.10.123:3001/quote/') expect(urls['cotizador']).toBe('/quote/')
expect(urls['alta-clientes']).toBe('http://192.168.10.123:3002/client/') expect(urls['alta-clientes']).toBe('/client/')
expect(urls['cobranza']).toBe('http://192.168.10.123:3003/pay/') expect(urls['cobranza']).toBe('/pay/')
}) })
it('no hay ids ni URLs duplicadas', () => { it('no hay ids ni URLs duplicadas', () => {

View File

@@ -5,25 +5,24 @@ export interface Modulo {
url: string url: string
} }
// TODO: tras montar el NGINX de producción cambiar las URLs absolutas de // Rutas relativas: la landing se sirve desde el mismo NGINX que las apps.
// preview por rutas relativas '/quote/', '/client/' y '/pay/'.
export const MODULOS: Modulo[] = [ export const MODULOS: Modulo[] = [
{ {
id: 'cotizador', id: 'cotizador',
nombre: 'Cotizador', nombre: 'Cotizador',
descripcion: 'Propuestas comerciales y PDF en tiempo real', descripcion: 'Propuestas comerciales y PDF en tiempo real',
url: 'http://192.168.10.123:3001/quote/', url: '/quote/',
}, },
{ {
id: 'alta-clientes', id: 'alta-clientes',
nombre: 'Alta de clientes', nombre: 'Alta de clientes',
descripcion: 'Expediente digital, firmas y formatos PLD', descripcion: 'Expediente digital, firmas y formatos PLD',
url: 'http://192.168.10.123:3002/client/', url: '/client/',
}, },
{ {
id: 'cobranza', id: 'cobranza',
nombre: 'Cobranza', nombre: 'Cobranza',
descripcion: 'Planes de enganche, vencidos y estados de cuenta', descripcion: 'Planes de enganche, vencidos y estados de cuenta',
url: 'http://192.168.10.123:3003/pay/', url: '/pay/',
}, },
] ]

17
deploy/frasa-api.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=FRASA API Gateway
After=network.target
[Service]
Type=simple
WorkingDirectory=/root/frasa-ecosystem/api-gateway
EnvironmentFile=/root/frasa-ecosystem/.env
Environment=PATH=/root/tools/node22/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/root/tools/node22/bin/node dist/index.js
Restart=always
RestartSec=3
StandardOutput=append:/var/log/frasa-api.log
StandardError=append:/var/log/frasa-api.log
[Install]
WantedBy=multi-user.target

39
deploy/nginx-host.conf Normal file
View File

@@ -0,0 +1,39 @@
# FRASA Ecosystem — nginx del host (modo HTTP, LAN)
# Instalado en /etc/nginx/sites-available/frasa
server {
listen 80 default_server;
server_name _;
client_max_body_size 10m;
# API Gateway (servicio systemd frasa-api en 127.0.0.1:3000)
location /api/ {
proxy_pass http://127.0.0.1:3000;
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;
}
# Apps estáticas (builds de producción)
location /quote/ {
alias /root/frasa-ecosystem/apps/cotizador/dist/;
try_files $uri $uri/ /quote/index.html;
}
location /client/ {
alias /root/frasa-ecosystem/apps/alta-clientes/dist/;
try_files $uri $uri/ /client/index.html;
}
location /pay/ {
alias /root/frasa-ecosystem/apps/cobranza/dist/;
try_files $uri $uri/ /pay/index.html;
}
# Landing (raíz)
location / {
root /root/frasa-ecosystem/apps/landing/dist;
try_files $uri $uri/ /index.html;
}
}

View File

@@ -10,7 +10,8 @@ services:
- "80:80" - "80:80"
- "443:443" - "443:443"
volumes: volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro # NGINX_CONF=nginx.http.conf para modo HTTP local (sin SSL todavia)
- ./nginx/${NGINX_CONF:-nginx.conf}:/etc/nginx/nginx.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro # certs siempre al día tras certbot renew - /etc/letsencrypt:/etc/letsencrypt:ro # certs siempre al día tras certbot renew
- certbot_webroot:/var/www/certbot # challenge HTTP-01 - certbot_webroot:/var/www/certbot # challenge HTTP-01
depends_on: depends_on:

39
nginx/nginx.http.conf Normal file
View File

@@ -0,0 +1,39 @@
# FRASA Ecosystem — reverse proxy (modo HTTP, red local)
# Se usa cuando aun no hay certificado SSL (DDNS sin apuntar a este servidor).
# Cuando el dominio resuelva aqui: certbot --webroot y volver a nginx.conf (443).
server {
listen 80;
server_name _;
# Uploads de hasta 10MB (expedientes de clientes)
client_max_body_size 10m;
# Challenge de Let's Encrypt (para cuando se expida el cert)
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# API Gateway
location /api/ {
proxy_pass http://api:3000;
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;
}
# Apps web (cada contenedor sirve su SPA con su base path)
location /quote/ { proxy_pass http://cotizador:80; proxy_set_header Host $host; }
location /client/ { proxy_pass http://alta-clientes:80; proxy_set_header Host $host; }
location /pay/ { proxy_pass http://cobranza:80; proxy_set_header Host $host; }
# Landing (raíz)
location / {
proxy_pass http://landing:80;
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;
}
}