ICINTEGRACORPMANUAL DE INSTALACIÓN

DESPLIEGUE EN PRODUCCIÓN · SERVIDOR DE APLICACIÓN

Manual de instalación
integracorp-api

Guía paso a paso para levantar integracorp-api en un servidor Linux dedicado, con la base de datos MySQL alojada en otro servidor. Incluye el porqué de cada decisión, no sólo los comandos.

https://integracorp-api.tudrgroup.com Ubuntu 24.04 LTS 10 vCPU 10 GB RAM 600 GB HDD Node.js 20 LTS

Panorama: qué corre en este servidor

Este servidor es sólo aplicación: Node.js y Nginx. La base de datos MySQL vive en otro servidor, así que aquí no se instala ningún motor de base de datos.

Internet → Nginx (443, TLS, caché, rate limit) → Node cluster (8 workers, :4000) → MySQL remoto

¿Por qué Nginx delante?

Express sabe escuchar en un puerto, pero Nginx termina el TLS (costoso para Node), sirve /docs como archivos estáticos sin despertar a Node, cachea las métricas y corta el tráfico abusivo antes de que consuma un worker.

¿Por qué modo cluster?

Node es monohilo. Con 10 vCPU, un solo proceso usaría un núcleo y dejaría nueve parados. src/cluster.js levanta varios procesos que comparten el puerto 4000 y el kernel reparte las conexiones.

¿Dónde está el cuello de botella?

En la red hacia MySQL. Con la base de datos fuera, cada consulta paga un tiempo de ida y vuelta. Toda la configuración de esta guía gira alrededor de reducir ese costo.

Reparto de recursos

ComponenteRAMvCPUNotas
Node.js (8 workers)~3–4 GB8Alrededor de 400 MB por worker en carga normal.
Nginx + sistema operativo~1 GB2Nginx es muy liviano; el resto es caché de página del kernel.
Libre para picos~5 GBColchón para campañas de correo y generación de PDFs.
El disco deja de importar. Al no haber MySQL local, el HDD de 600 GB sólo guarda documentos subidos por pacientes y logs. Un disco mecánico es perfectamente suficiente para eso.

Requisitos previos

  • Servidor Linux limpio con Ubuntu 24.04 LTS y acceso root.
  • Registro DNS de integracorp-api.tudrgroup.com apuntando a la IP pública del servidor (necesario para emitir el certificado TLS).
  • Acceso al servidor de base de datos MySQL: IP, usuario, clave y nombre de la base.
  • Credenciales de los servicios externos: SMTP para campañas de correo y Ultramsg para WhatsApp.
  • URL del repositorio de integracorp-api.
Esta página es pública. No pegues aquí ni en ningún archivo versionado los valores reales de INTEGRACORP_API_KEY, JWT_SECRET, claves de base de datos, SMTP o Ultramsg. Todos los ejemplos usan marcadores de posición a propósito.
01

Usuario de servicio y directorios

adduser --system --group --home /var/www --shell /bin/bash deploy
mkdir -p /var/www/storage/uploads
chown -R deploy:deploy /var/www
Por qué: la API no debe correr como root. Si algún día una vulnerabilidad permite ejecutar código —por ejemplo a través de un archivo subido por un paciente—, el daño queda limitado a lo que puede tocar el usuario deploy. Esta medida no te protege a ti como administrador: te protege de un atacante externo.

/var/www/storage/uploads queda fuera del directorio del repositorio a propósito: así un despliegue nuevo o un git pull nunca puede borrar documentos de pacientes.

02

Sistema base, paquetes y zona horaria

apt update && apt upgrade -y
apt install -y build-essential git curl ufw sysstat htop \
               unattended-upgrades logrotate mysql-client mtr-tiny

timedatectl set-timezone America/Caracas
dpkg-reconfigure --priority=low unattended-upgrades

Qué aporta cada paquete

PaquetePara qué
build-essentialAlgunas dependencias npm compilan código nativo al instalarse; sin compilador, npm ci falla.
mysql-clientNo instala el servidor, sólo el comando mysql para probar a mano la conexión remota. Imprescindible para diagnosticar.
mtr-tinyMide latencia y pérdida de paquetes hacia la base de datos. Te dice si un problema es de red o de consultas.
sysstatTrae iostat y sar para observar saturación de CPU y disco.
unattended-upgradesAplica parches de seguridad del sistema automáticamente.
La zona horaria es crítica y falla en silencio. Los endpoints de métricas usan CURDATE() y comparan «mes actual vs mes anterior». Si este servidor está en UTC y el de base de datos en -04:00, los rangos de fecha se calculan desplazados cuatro horas: no verás ningún error, sólo números ligeramente equivocados en las primeras y últimas horas de cada mes. Anota la zona elegida y aplica exactamente la misma en el servidor de base de datos.
Verifica: timedatectl debe mostrar Time zone: America/Caracas.
03

Firewall

ufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
ufw status verbose
Por qué, incluso siendo el único administrador: el firewall no está aquí por las personas, sino por los puertos que abre la propia aplicación. Node escucha en el 4000 en todas las interfaces; sin firewall, cualquiera en internet puede saltarse Nginx y pegarle directo a Express, evitando el TLS, el rate limit y la caché. Con deny incoming por defecto sólo existen 22, 80 y 443, y el 4000 queda accesible únicamente desde localhost, que es exactamente lo que Nginx necesita.
04

Swap

fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
Por qué: con 10 GB de RAM no deberías tocar swap en operación normal. Está como red de seguridad: si una campaña de correo con imágenes grandes o varios PDFs simultáneos disparan la memoria, prefieres que el sistema se ponga lento unos segundos a que el kernel mate un worker (OOM killer). En el paso 5 fijamos vm.swappiness = 10 para que el kernel recurra al swap sólo cuando no le queda alternativa, no de forma preventiva.
Verifica: free -h debe mostrar 4 GB de swap y ~10 GB de RAM.
05

Kernel y límites del sistema

Crea /etc/sysctl.d/99-integracorp.conf:

vm.swappiness = 10
fs.file-max = 200000

net.core.somaxconn = 4096
net.core.netdev_max_backlog = 5000
net.ipv4.tcp_max_syn_backlog = 4096
net.ipv4.ip_local_port_range = 10240 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_slow_start_after_idle = 0

net.ipv4.tcp_keepalive_time = 120
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 5

Qué hace cada bloque

Cola de conexiones. somaxconn define cuántas conexiones puede tener el kernel esperando a que la aplicación las acepte. Evita que en un pico de tráfico el kernel empiece a rechazar conexiones mientras los workers están ocupados.

Puertos efímeros. ip_local_port_range y tcp_tw_reuse importan porque este servidor abre muchas conexiones salientes: al MySQL remoto, a la API de Ultramsg y al servidor SMTP. Cada conexión cerrada deja un puerto en estado TIME_WAIT durante segundos. Ampliar el rango a unos 55.000 puertos y permitir reutilizarlos evita el error EADDRNOTAVAIL bajo carga sostenida, un fallo desconcertante porque parece de la aplicación y es del kernel.

Keepalive TCP. Este bloque es específico de tener la base de datos remota. Las conexiones del pool pasan largos ratos ociosas de madrugada, y los routers NAT o firewalls intermedios cortan conexiones inactivas sin avisar a nadie: la aplicación se entera cuando intenta usarlas y recibe PROTOCOL_CONNECTION_LOST. Con tcp_keepalive_time = 120 el kernel envía un paquete cada dos minutos y la conexión nunca parece muerta. El pool de la API ya trae enableKeepAlive: true a nivel de mysql2; esto lo refuerza a nivel de sistema.

Ahora los descriptores de archivo, en /etc/security/limits.d/99-nofile.conf:

*    soft nofile 65535
*    hard nofile 65535
root soft nofile 65535
root hard nofile 65535
Por qué 65535: en Linux cada socket es un archivo abierto. Un worker sumando conexiones entrantes de clientes, conexiones del pool a MySQL y archivos temporales de subidas puede llegar a miles. El default de 1024 se agota antes de lo que parece y produce EMFILE: too many open files. Ojo: systemd no respeta este archivo para los servicios que gestiona, por eso en el paso 11 repetimos el límite con LimitNOFILE=.
sysctl --system
sysctl net.ipv4.tcp_keepalive_time    # debe responder 120
06

Node.js 20 LTS

curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs
node -v && npm -v
Por qué la 20: el package.json exige >=18, pero Node 18 ya salió de soporte. La 20 es LTS madura y trae fetch global estable, que es lo que usa el cliente de Ultramsg. La 22 también sirve; evita las versiones impares (21, 23), que no son LTS y dejan de recibir parches en meses.
No instales Node desde el repositorio de Ubuntu (apt install nodejs a secas): trae una versión antigua. El script de NodeSource añade el repositorio oficial y así apt upgrade te mantiene actualizado dentro de la rama 20.
07

Conectividad con la base de datos remota

Esta es la decisión técnica más importante del despliegue.

No abras el puerto 3306 a internet. Un MySQL expuesto recibe escaneos automatizados en cuestión de horas. Además, el pool de esta API (src/config/database.js) no tiene TLS configurado, así que las consultas y sus resultados —incluidos datos de historia clínica— viajarían en texto plano. Un túnel resuelve ambas cosas sin tocar código.

Si ambos servidores están en el mismo datacenter y el proveedor ofrece red privada, usa esa IP privada y sáltate WireGuard. Si no:

apt install -y wireguard
cd /etc/wireguard && umask 077
wg genkey | tee privkey | wg pubkey > pubkey
cat pubkey     # esta clave pública se entrega al servidor de base de datos

Archivo /etc/wireguard/wg0.conf:

[Interface]
PrivateKey = <contenido de /etc/wireguard/privkey>
Address = 10.8.0.2/24

[Peer]
PublicKey = <clave pública del servidor de base de datos>
Endpoint = <ip-publica-bd>:51820
AllowedIPs = 10.8.0.1/32
PersistentKeepalive = 25
systemctl enable --now wg-quick@wg0
wg show

Qué significa cada línea. Address = 10.8.0.2 es la IP privada de este servidor dentro del túnel; el de base de datos será 10.8.0.1. AllowedIPs restringe el túnel a esa única IP: no estás enrutando todo tu tráfico por ahí, sólo lo que va a la base de datos. PersistentKeepalive = 25 envía un paquete cada 25 segundos para que el NAT del proveedor no cierre el túnel en periodos de inactividad.

Mide la latencia: define el resto de la configuración

ping -c 5 10.8.0.1
mtr -rwc 30 10.8.0.1
time mysql -h 10.8.0.1 -u <usuario> -p -e "SELECT 1"
Cómo interpretarlo: cada consulta paga el tiempo de ida y vuelta (RTT) además del tiempo que MySQL tarda en resolverla. Una conexión ocupada durante RTT + tiempo_de_consulta sirve 1000 / (RTT + t) consultas por segundo. Con un RTT de 20 ms y consultas de 5 ms, cada conexión rinde apenas 40 consultas por segundo; para sostener 400 necesitas unas 10 conexiones trabajando en paralelo.
RTT medidoQué significaDB_CONNECTION_LIMIT por worker
< 1 msRed privada del mismo datacenter — ideal10
1 – 5 msMismo datacenter o muy cerca15
5 – 30 msRegiones distintas, funcional20
> 30 msDuele; ver los ajustes finales25 + caché agresiva

Con 8 workers, el total de conexiones contra MySQL será 8 × ese número, y el max_connections del servidor de base de datos tendrá que superarlo con margen. Juega a tu favor que los controladores de métricas usen queryAll(), que lanza las consultas en paralelo: un endpoint con seis consultas paga aproximadamente un RTT, no seis.

08

Código de la aplicación

su - deploy
cd /var/www
git clone <URL_DEL_REPOSITORIO> integracorp-api
cd integracorp-api
npm ci --omit=dev
Por qué npm ci y no npm install: ci instala exactamente las versiones fijadas en package-lock.json, borrando node_modules antes. install puede subir dependencias a versiones más nuevas que cumplan el rango del package.json, y descubrir en producción que una dependencia menor cambió de comportamiento es la clase de sorpresa que no quieres. --omit=dev se salta nodemon, que sólo sirve en desarrollo.
09

Variables de entorno

cd /var/www/integracorp-api
cp .env.example .env
openssl rand -hex 32      # ejecútalo dos veces: API key y JWT_SECRET
nano .env
chmod 600 .env
NODE_ENV=production
PORT=4000
WEB_CONCURRENCY=8

DB_HOST=10.8.0.1
DB_PORT=3306
DB_USER=integracorp_api
DB_PASSWORD=<clave>
DB_NAME=<nombre_de_la_base>
DB_CONNECTION_LIMIT=15
DB_QUEUE_LIMIT=100
DB_CONNECT_TIMEOUT_MS=15000

INTEGRACORP_API_KEY=<32 bytes aleatorios>
JWT_SECRET=<otros 32 bytes aleatorios>
JWT_EXPIRES_IN=8h

CORS_ORIGINS=https://portal.tudrgroup.com,https://integracorp.tudrgroup.com
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX=30
LOGIN_RATE_LIMIT_MAX=20

STORAGE_ROOT=/var/www/storage/uploads
UPLOAD_MAX_BYTES=10485760

MAIL_HOST=<servidor smtp>
MAIL_PORT=587
MAIL_USERNAME=<usuario>
MAIL_PASSWORD=<clave>
MAIL_FROM_ADDRESS=<remitente>
MAIL_FROM_NAME=Integracorp
EMAIL_BATCH_CONCURRENCY=1
EMAIL_SEND_PAUSE_MS=1500

ULTRAMSG_INSTANCE_ID=<instancia>
ULTRAMSG_TOKEN=<token>
ULTRAMSG_MAX_BATCH_SIZE=50
ULTRAMSG_MESSAGE_DELAY_MS=4500

Las variables que de verdad mueven la aguja

VariablePor qué importa
NODE_ENV=productionNo es cosmético. Activa el modo producción de Express, hace que los errores 500 oculten el mensaje interno al cliente, aplica de verdad la lista de CORS_ORIGINS (en desarrollo el código permite cualquier origen) y añade Cache-Control a la documentación. Olvidarlo es el error de despliegue más común y más caro.
WEB_CONCURRENCY=8Con 10 vCPU deja dos para Nginx y el sistema. Poner 10 no rinde más: los workers compiten entre sí y con Nginx, y aumentan las conexiones contra la base de datos sin ganancia real.
DB_CONNECTION_LIMITSegún la tabla del paso 7. Cuidado con subirlo «por si acaso»: si las consultas son lentas, más conexiones sólo consiguen que el servidor de base de datos se ahogue más rápido.
DB_CONNECT_TIMEOUT_MSEl default del código es 10 s, pensado para base de datos local. Con red de por medio conviene dar más margen antes de declarar fallida la conexión.
RATE_LIMIT_MAX=30Es por worker, no global. Los contadores viven en memoria de cada proceso, así que con 8 workers el límite efectivo por IP es 8 × 30 ≈ 240 peticiones por minuto. Para un número exacto, usa el límite de Nginx del paso 12.
CORS_ORIGINSLista exacta de los frontends que consumen la API, sin barra final. Si el portal del paciente no carga datos y la consola del navegador se queja de CORS, el problema está aquí.
Detalle del código que conviene conocer: src/config/env.js carga dotenv con override: true, así que .env gana sobre cualquier variable definida en systemd o en el shell. Configura todo en .env y no te pelees con Environment=.
10

Primera ejecución en primer plano

Antes de convertirlo en servicio, arráncalo a mano para ver los errores en pantalla.

cd /var/www/integracorp-api
npm start

Deberías ver MySQL conectado: … y integracorp-api escuchando en http://localhost:4000. Desde otra terminal:

curl -s localhost:4000/api/health
curl -s localhost:4000/api/health/db
curl -s "localhost:4000/api/agents?page=1" -H "X-API-Key: <tu-api-key>"
Qué mirar: /api/health sólo confirma que el proceso vive. El importante es /api/health/db, que hace ping al pool y devuelve la latencia: ese número es tu RTT real más el overhead del pool, y será tu métrica de salud diaria.
Comportamiento del arranque: si MySQL no responde, la API igual levanta y sólo imprime una advertencia, para que el servicio no entre en bucle de reinicios por una caída pasajera de la base de datos. Por eso no basta con ver el proceso arriba: verifica siempre /api/health/db.

Detén el proceso con Ctrl+C cuando funcione.

11

Servicio systemd (modo cluster)

Archivo /etc/systemd/system/integracorp-api.service:

[Unit]
Description=integracorp-api
After=network-online.target wg-quick@wg0.service
Wants=network-online.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/integracorp-api
ExecStart=/usr/bin/node src/cluster.js
Restart=always
RestartSec=3
LimitNOFILE=65535
Environment=NODE_OPTIONS=--max-old-space-size=768
Environment=UV_THREADPOOL_SIZE=8
StandardOutput=journal
StandardError=journal
SyslogIdentifier=integracorp-api

[Install]
WantedBy=multi-user.target

Directiva por directiva

DirectivaQué resuelve
After=… wg-quick@wg0Arranca después del túnel, para que el primer intento de conexión a MySQL no falle por una carrera al reiniciar el servidor.
ExecStart=… src/cluster.jsModo cluster: crea WEB_CONCURRENCY workers y marca sólo al primero con WHATSAPP_WORKER=1.
Restart=alwaysSi el proceso muere, systemd lo revive en 3 segundos. Junto al reinicio de workers del propio cluster, son dos niveles de recuperación.
LimitNOFILE=65535Repite el límite del paso 5, porque systemd ignora /etc/security/limits.conf para sus servicios.
--max-old-space-size=768Techo de heap por worker: 8 × 768 MB ≈ 6 GB en el peor caso. En operación normal cada worker ronda 200–400 MB; el techo evita que una fuga se coma toda la RAM.
UV_THREADPOOL_SIZE=8Hilos de libuv para lo que no es asíncrono a nivel de sistema: archivos, criptografía y generación de PDFs con PDFKit. El default de 4 se satura si dos pacientes piden su PDF a la vez.
Una sola instancia, siempre. La cola de envíos masivos de WhatsApp vive en la memoria de un único proceso: el worker marcado con WHATSAPP_WORKER=1. Si todos los workers la tuvieran, un lote de 50 mensajes se enviaría ocho veces y Ultramsg podría banear la instancia. No pongas PM2 en modo cluster encima de esto ni dupliques el servicio.
systemctl daemon-reload
systemctl enable --now integracorp-api
systemctl status integracorp-api
journalctl -u integracorp-api -n 50
Verificación clave: journalctl -u integracorp-api | grep -c whatsapp-queue debe devolver exactamente 1. Si devuelve más, hay más de una instancia corriendo y las campañas saldrán duplicadas.
12

Nginx, TLS y caché

apt install -y nginx certbot python3-certbot-nginx
mkdir -p /var/cache/nginx/metrics
chown -R www-data:www-data /var/cache/nginx
El orden importa. No puedes validar un bloque listen 443 ssl antes de que exista el certificado: nginx -t falla. La secuencia correcta es configuración HTTP mínima → certbot → configuración completa.

12.1 · Bloques globales

Archivo /etc/nginx/conf.d/integracorp.conf (upstream, caché y zonas de rate limit; van fuera de cualquier server):

cat > /etc/nginx/conf.d/integracorp.conf <<'EOF'
upstream integracorp_api {
    server 127.0.0.1:4000;
    keepalive 64;
}

proxy_cache_path /var/cache/nginx/metrics levels=1:2 keys_zone=metrics:20m
                 max_size=500m inactive=30m use_temp_path=off;

limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
EOF

keepalive 64 mantiene 64 conexiones abiertas hacia Node en lugar de abrir y cerrar una por petición. Requiere proxy_http_version 1.1 y proxy_set_header Connection "" en el sitio, o no surte efecto.

12.2 · Sitio en HTTP y certificado

cat > /etc/nginx/sites-available/integracorp-api <<'EOF'
server {
    listen 80;
    server_name integracorp-api.tudrgroup.com;
    location / { proxy_pass http://integracorp_api; }
}
EOF

ln -sf /etc/nginx/sites-available/integracorp-api /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx

# comprueba que responde por HTTP antes de pedir el certificado
curl -s http://integracorp-api.tudrgroup.com/api/health

certbot --nginx -d integracorp-api.tudrgroup.com

Certbot edita ese mismo archivo: añade el bloque listen 443 ssl, las líneas ssl_certificate y ssl_certificate_key, y la redirección de 80 a 443. También instala una renovación automática por temporizador de systemd; no hay que hacer nada más con el certificado.

12.3 · Configuración definitiva

cat > /etc/nginx/sites-available/integracorp-api <<'EOF'
server {
    listen 80;
    server_name integracorp-api.tudrgroup.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name integracorp-api.tudrgroup.com;

    ssl_certificate     /etc/letsencrypt/live/integracorp-api.tudrgroup.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/integracorp-api.tudrgroup.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    client_max_body_size 12m;

    proxy_http_version 1.1;
    proxy_set_header Connection "";
    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;
    proxy_connect_timeout 5s;
    proxy_read_timeout 120s;

    # Login: freno contra fuerza bruta antes de tocar Node
    location /api/auth/login {
        limit_req zone=login burst=5 nodelay;
        proxy_pass http://integracorp_api;
    }

    # Métricas: caché de 120s, la mayor mejora con base de datos remota
    location /api/metrics/ {
        limit_req zone=api burst=20 nodelay;
        proxy_cache metrics;
        proxy_cache_key "$request_uri|$http_x_api_key";
        proxy_cache_valid 200 120s;
        proxy_cache_use_stale error timeout updating;
        proxy_cache_lock on;
        add_header X-Cache-Status $upstream_cache_status;
        proxy_pass http://integracorp_api;
    }

    # Documentación estática servida por nginx, sin pasar por Node
    location /docs/ {
        alias /var/www/integracorp-api/public/;
        expires 1h;
        try_files $uri $uri/ @node;
    }
    location @node { proxy_pass http://integracorp_api; }

    location / {
        limit_req zone=api burst=40 nodelay;
        proxy_pass http://integracorp_api;
    }
}
EOF

nginx -t && systemctl reload nginx

Las decisiones detrás de esta configuración

Cabeceras X-Forwarded-*. Sin ellas, Express vería todas las peticiones como provenientes de 127.0.0.1 y el rate limit trataría a todo internet como un solo cliente. El código ya trae app.set('trust proxy', 1), que le indica a Express que confíe en exactamente un proxy delante: encaja con este montaje.

client_max_body_size 12m. El límite de subida de la API es 10 MB (UPLOAD_MAX_BYTES); los 2 MB extra cubren el overhead del multipart. Con el default de 1 MB, Nginx rechaza las subidas de documentos con un 413 antes de que Node se entere, y el mensaje confunde.

proxy_read_timeout 120s. Coincide con el requestTimeout que el código fija en el servidor HTTP. Si Nginx cortara antes, verías 504 en operaciones legítimamente lentas como generar un PDF.

La caché de /api/metrics/. Con la base de datos en otro servidor, es la mejora individual más grande. Los endpoints de métricas hacen agregaciones pesadas cuyos resultados cambian poco: si el dashboard llama a veinte de ellos al abrirse y hay cinco personas mirando, pasas de cien consultas remotas por minuto a unas veinte. proxy_cache_lock on evita la estampida —cuando la caché expira, sólo una petición va a Node mientras las demás esperan ese resultado— y proxy_cache_use_stale error timeout hace que, si el enlace con la base de datos se cae, el dashboard siga mostrando los últimos datos conocidos en lugar de errores.

Qué no cachear nunca. La clave de caché incluye $http_x_api_key para no servirle a un cliente lo cacheado para otro. Y la caché se aplica sólo a /api/metrics/: jamás a /api/me, /api/documents, /api/clinical-history, /api/appointments ni /api/cases, que devuelven datos por paciente.

Rate limit de login aparte. 10r/m sobre /api/auth/login frena la fuerza bruta contra las claves del portal en Nginx, antes de consumir un worker y una conexión a la base de datos. La API ya tiene su propio limitador, pero este actúa antes y es global, no por worker.

/docs/ servido por Nginx. La documentación son archivos estáticos; que los sirva Nginx libera a Node. El try_files … @node deja pasar hacia la aplicación las rutas dinámicas como /docs/api-catalog.json, que sí genera Express.

Dos notas de compatibilidad. El include de options-ssl-nginx.conf y el ssl_dhparam sólo existen si certbot los creó; si nginx -t se queja de que faltan, coméntalos. Y a partir de nginx 1.25, listen 443 ssl http2; se escribe como listen 443 ssl; más una línea http2 on;. En el nginx 1.24 de Ubuntu 24.04 la forma de arriba es la correcta.
curl -sI http://integracorp-api.tudrgroup.com/api/health          # 301 → https
curl -s  https://integracorp-api.tudrgroup.com/api/health
curl -sI https://integracorp-api.tudrgroup.com/docs/endpoints.html # 200 sin pasar por Node
13

PHP para el PDF de órdenes de servicio

El endpoint POST /api/appointments/:id/service-order/whatsapp no genera el PDF en Node: ejecuta php artisan operation-service-orders:ensure-pdf <id> dentro del proyecto Laravel de Integracorp.

Si ese repositorio no vive en este servidor, el endpoint responde 400 con un mensaje claro y el resto de la API funciona con normalidad. No es un fallo del despliegue.
apt install -y php8.3-cli php8.3-mbstring php8.3-xml php8.3-gd php8.3-mysql

Y en .env:

INTEGRACORP_APP_PATH=/var/www/integracorp
PHP_BINARY=/usr/bin/php
INTEGRACORP_STORAGE_BASE_URL=https://integracorp.tudrgroup.com/storage

El usuario deploy necesita permiso de escritura sobre el storage/ de ese Laravel, porque ahí se deposita el PDF generado. Después: systemctl restart integracorp-api.

14

Operación diaria

Desplegar una versión nueva

su - deploy
cd /var/www/integracorp-api
git pull
npm ci --omit=dev
exit
systemctl restart integracorp-api
curl -s https://integracorp-api.tudrgroup.com/api/health/db

El reinicio corta las peticiones en vuelo. Si más adelante necesitas despliegues sin corte, se puede implementar recarga progresiva de workers; con el volumen actual, un reinicio de dos segundos de madrugada no afecta a nadie.

Logs

journalctl -u integracorp-api -f                              # en vivo
journalctl -u integracorp-api --since "1 hour ago" | grep -i error
tail -f /var/log/nginx/error.log

Limita el tamaño del journal para que no llene el disco con el tiempo: en /etc/systemd/journald.conf fija SystemMaxUse=1G y reinicia systemd-journald.

Respaldo de documentos de pacientes

La base de datos la respalda el otro servidor, pero los archivos subidos viven aquí y sólo aquí:

mkdir -p /var/backups/storage
cat > /usr/local/bin/backup-storage.sh <<'EOF'
#!/bin/bash
tar czf /var/backups/storage/uploads-$(date +%F).tar.gz -C /var/www/storage uploads
find /var/backups/storage -name '*.tar.gz' -mtime +14 -delete
EOF
chmod +x /usr/local/bin/backup-storage.sh
( crontab -l 2>/dev/null; echo "0 4 * * * /usr/local/bin/backup-storage.sh" ) | crontab -
Un respaldo en la misma máquina no te salva de perder la máquina. Copia además ese archivo comprimido fuera del servidor.

Qué vigilar

ComandoQué te dice
curl -s localhost:4000/api/health/dbLatencia a la base de datos. Es el indicador principal: si sube, todo se siente lento.
htopSi los 8 workers están al 100 % de forma constante, sube WEB_CONCURRENCY.
free -hLos workers deberían sumar bastante menos de 4 GB. Un crecimiento sostenido indica fuga de memoria.
mtr -rwc 30 10.8.0.1Pérdida de paquetes o jitter hacia la base de datos.
curl -sI … | grep X-Cache-StatusSi siempre responde MISS, la caché de métricas no está funcionando.

Monta además un chequeo externo (UptimeRobot o similar) contra https://integracorp-api.tudrgroup.com/api/health: es lo único que te avisa cuando el servidor entero deja de responder.

15

Verificación final y ajustes

systemctl is-enabled integracorp-api nginx wg-quick@wg0     # los tres: enabled
ufw status                                                  # sólo 22, 80, 443

curl -s  https://integracorp-api.tudrgroup.com/api/health
curl -s  https://integracorp-api.tudrgroup.com/api/health/db
curl -s  https://integracorp-api.tudrgroup.com/api/endpoints -H "X-API-Key: $KEY" | head -c 200
curl -sI https://integracorp-api.tudrgroup.com/docs/endpoints.html
curl -sI https://integracorp-api.tudrgroup.com/api/metrics/dashboard/venezuela-by-state \
  -H "X-API-Key: $KEY" | grep X-Cache-Status                # MISS, y HIT al repetir

journalctl -u integracorp-api | grep -c whatsapp-queue      # exactamente 1

reboot                                                      # y repite todo lo anterior
El reboot final no es paranoia: es la única forma de comprobar que el túnel, el servicio y Nginx levantan solos y en el orden correcto tras un corte de energía.

Qué reajustar tras una semana de tráfico real

  • CPU sobrada y latencia estable → sube WEB_CONCURRENCY a 10.
  • El servidor de base de datos reporta demasiadas conexionesbaja DB_CONNECTION_LIMIT. Es síntoma de consultas lentas acumulándose, no de falta de conexiones.
  • Los dashboards toleran datos algo más viejos → sube proxy_cache_valid a 300s.
  • Si el RTT resultó mayor a 30 ms → el orden correcto de soluciones es: mover ambos servidores al mismo datacenter primero; subir el TTL de caché después; y sólo como último recurso, una réplica de lectura local en este servidor, para lo que sobran RAM y disco.
Siguiente etapa: la configuración del servidor de base de datos — my.cnf con InnoDB afinado, usuario integracorp_api@10.8.0.2, wait_timeout coherente con el pool, la misma zona horaria e índices para las consultas de métricas.