feat(saas): infraestructura de producción para la VM
Bloque C de la fase SaaS (ficheros; el despliegue se hace con el runbook): - docker-compose.prod.yml: superficie mínima (solo el frontend en 127.0.0.1:8089 para el nginx nativo), límites de memoria, sin seeder. - deploy/nginx-recordalexia.conf: server block con cabeceras de seguridad, X-Forwarded-For para el rate-limiter y preparado para certbot. - deploy/backup.sh: pg_dump diario comprimido, retención 14, copia externa vía rclone y detección de backups vacíos. - .env.prod.example (y .env.prod en .gitignore). - docs/operacion-saas.md: runbook con primer despliegue, checklist, restore ENSAYADO paso a paso, actualización y problemas conocidos.
This commit is contained in:
25
.env.prod.example
Normal file
25
.env.prod.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Plantilla de variables de PRODUCCIÓN. Copiar a .env.prod en la VM y rellenar.
|
||||
# .env.prod NO se versiona jamás (está en .gitignore).
|
||||
#
|
||||
# cp .env.prod.example .env.prod
|
||||
|
||||
# --- Base de datos (solo red interna del compose) ---
|
||||
DB_NAME=recordalexia
|
||||
DB_USER=recordalexia
|
||||
DB_PASSWORD=
|
||||
|
||||
# --- Dominio público (enlaces del email y CORS) ---
|
||||
PUBLIC_BASE_URL=https://recordalexia.jaumegar.work
|
||||
|
||||
# --- Correo saliente (cualquier relay SMTP estándar) ---
|
||||
# Sin SMTP_HOST, los emails de recuperación se quedan en el log del backend.
|
||||
MAIL_FROM=no-reply@jaumegar.work
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
# --- Backups (deploy/backup.sh) ---
|
||||
# Remoto rclone para la copia fuera de la VM (ej. "b2:recordalexia-backups").
|
||||
# Vacío = solo copia local (NO recomendado para abrir el registro al público).
|
||||
RCLONE_REMOTE=
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# --- Entorno / secretos ---
|
||||
# El .env real (con credenciales) NUNCA se versiona. Solo .env.example.
|
||||
.env
|
||||
.env.prod
|
||||
|
||||
# --- Sistema operativo ---
|
||||
.DS_Store
|
||||
|
||||
52
deploy/backup.sh
Executable file
52
deploy/backup.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# Backup diario de la base de datos de recordaLexia (producción).
|
||||
#
|
||||
# Qué hace:
|
||||
# 1. pg_dump comprimido del Postgres del compose de producción.
|
||||
# 2. Retención local: conserva los últimos BACKUP_KEEP (por defecto 14).
|
||||
# 3. Copia FUERA de la VM con rclone si RCLONE_REMOTE está definido
|
||||
# (ej. "b2:recordalexia-backups" o "gdrive:backups/recordalexia").
|
||||
#
|
||||
# Instalación (cron del usuario en la VM, 03:30 hora de Madrid):
|
||||
# crontab -e
|
||||
# 30 3 * * * /ruta/al/repo/deploy/backup.sh >> $HOME/recordalexia-backup.log 2>&1
|
||||
#
|
||||
# RESTAURAR: ver docs/operacion-saas.md (procedimiento ensayado paso a paso).
|
||||
set -euo pipefail
|
||||
|
||||
# --- Configuración (sobreescribible por variables de entorno) ---
|
||||
REPO_DIR="${REPO_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-$HOME/backups/recordalexia}"
|
||||
BACKUP_KEEP="${BACKUP_KEEP:-14}"
|
||||
RCLONE_REMOTE="${RCLONE_REMOTE:-}"
|
||||
COMPOSE=(docker compose -f "$REPO_DIR/docker-compose.prod.yml" --env-file "$REPO_DIR/.env.prod")
|
||||
|
||||
# Credenciales de la BD: las mismas del compose (nunca en este script).
|
||||
source "$REPO_DIR/.env.prod"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
FILE="$BACKUP_DIR/recordalexia-$STAMP.sql.gz"
|
||||
|
||||
echo "[$(date -Is)] Iniciando backup -> $FILE"
|
||||
"${COMPOSE[@]}" exec -T postgres pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$FILE"
|
||||
|
||||
# El backup vacío o diminuto es un fallo, no un backup.
|
||||
SIZE=$(stat -c%s "$FILE" 2>/dev/null || stat -f%z "$FILE")
|
||||
if [ "$SIZE" -lt 1024 ]; then
|
||||
echo "[$(date -Is)] ERROR: backup sospechosamente pequeño ($SIZE bytes)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[$(date -Is)] Backup OK ($SIZE bytes)"
|
||||
|
||||
# Retención local: borrar los que sobren, del más antiguo al más nuevo.
|
||||
ls -1t "$BACKUP_DIR"/recordalexia-*.sql.gz | tail -n +$((BACKUP_KEEP + 1)) | xargs -r rm --
|
||||
echo "[$(date -Is)] Retención aplicada (máx. $BACKUP_KEEP locales)"
|
||||
|
||||
# Copia fuera de la VM (si hay remoto configurado en rclone).
|
||||
if [ -n "$RCLONE_REMOTE" ]; then
|
||||
rclone copy "$FILE" "$RCLONE_REMOTE/" --no-traverse
|
||||
echo "[$(date -Is)] Copia externa OK -> $RCLONE_REMOTE"
|
||||
else
|
||||
echo "[$(date -Is)] AVISO: sin RCLONE_REMOTE; el backup solo existe en esta VM"
|
||||
fi
|
||||
40
deploy/nginx-recordalexia.conf
Normal file
40
deploy/nginx-recordalexia.conf
Normal file
@@ -0,0 +1,40 @@
|
||||
# Server block de recordaLexia para el nginx NATIVO de la VM.
|
||||
# Instalación: copiar a /etc/nginx/sites-available/recordalexia, enlazar en
|
||||
# sites-enabled, y emitir el certificado con:
|
||||
# sudo certbot --nginx -d recordalexia.jaumegar.work
|
||||
# (certbot reescribe este fichero añadiendo el bloque TLS y la redirección 80→443)
|
||||
#
|
||||
# El upstream es el contenedor frontend del compose de producción, que solo
|
||||
# escucha en loopback: este nginx es la única puerta de entrada.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name recordalexia.jaumegar.work;
|
||||
|
||||
# Subidas holgadas (la app apenas sube nada, pero evitamos el 413 histórico).
|
||||
client_max_body_size 16m;
|
||||
|
||||
# --- Cabeceras de seguridad (spec production-hardening) ---
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'none'" always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8089;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
# La IP real del cliente: el rate-limiter del backend la usa como clave.
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# El service worker y el manifest no deben quedar cacheados por intermediarios
|
||||
# de forma agresiva: la PWA se actualiza comprobándolos.
|
||||
location = /ngsw-worker.js {
|
||||
proxy_pass http://127.0.0.1:8089;
|
||||
proxy_set_header Host $host;
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
}
|
||||
72
docker-compose.prod.yml
Normal file
72
docker-compose.prod.yml
Normal file
@@ -0,0 +1,72 @@
|
||||
# Stack de PRODUCCIÓN de recordaLexia (instancia pública en la VM).
|
||||
# docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
|
||||
#
|
||||
# Principios:
|
||||
# - Superficie mínima: Postgres y backend NO publican puertos; el frontend solo
|
||||
# escucha en 127.0.0.1:8089 y el nginx NATIVO de la VM hace el proxy TLS.
|
||||
# - Límites de recursos: la VM aloja más servicios; nadie se la come.
|
||||
# - Sin familia demo: el seeder es fail-safe y aquí NO se activa.
|
||||
# - Ningún secreto en este fichero: todo llega de .env.prod (fuera de git).
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: recordalexia-prod-postgres
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
TZ: Europe/Madrid
|
||||
volumes:
|
||||
- pgdata-prod:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
mem_limit: 512m
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
container_name: recordalexia-prod-backend
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: prod
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: ${DB_NAME}
|
||||
SPRING_DATASOURCE_USERNAME: ${DB_USER}
|
||||
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}
|
||||
# Dominio público (enlaces del email de recuperación) y CORS estricto.
|
||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
|
||||
CORS_ALLOWED_ORIGINS: ${PUBLIC_BASE_URL}
|
||||
MAIL_FROM: ${MAIL_FROM}
|
||||
# SMTP del proveedor (binding relajado de Spring). Sin SMTP_HOST definido,
|
||||
# los emails de recuperación se quedan en el log del contenedor.
|
||||
SPRING_MAIL_HOST: ${SMTP_HOST:-}
|
||||
SPRING_MAIL_PORT: ${SMTP_PORT:-587}
|
||||
SPRING_MAIL_USERNAME: ${SMTP_USERNAME:-}
|
||||
SPRING_MAIL_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "true"
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "true"
|
||||
TZ: Europe/Madrid
|
||||
mem_limit: 768m
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
container_name: recordalexia-prod-frontend
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
# SOLO loopback: el único acceso desde fuera es el nginx nativo de la VM.
|
||||
- "127.0.0.1:8089:80"
|
||||
mem_limit: 64m
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata-prod:
|
||||
122
docs/operacion-saas.md
Normal file
122
docs/operacion-saas.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# recordaLexia · Runbook de operación (instancia pública)
|
||||
|
||||
Operación de la instancia SaaS en la VM (Ubuntu, Docker, nginx nativo).
|
||||
Escrito para poder ejecutarse dentro de 6 meses sin recordar nada.
|
||||
|
||||
## 0. Requisitos
|
||||
|
||||
- VM con Docker + Compose v2 y nginx nativo (ya presentes en `vnicjaume`).
|
||||
- DNS: `recordalexia.jaumegar.work` (o el subdominio elegido) apuntando a la IP de la VM.
|
||||
- Credenciales SMTP de cualquier relay estándar (para el email de recuperación).
|
||||
- `rclone` configurado con un remoto para backups fuera de la VM (recomendado).
|
||||
|
||||
## 1. Primer despliegue
|
||||
|
||||
```bash
|
||||
# 1) Código
|
||||
git clone https://gitea.jaumegar.work/jaume/recordalexia.git
|
||||
cd recordalexia
|
||||
|
||||
# 2) Variables (rellenar TODAS; DB_PASSWORD nueva y larga)
|
||||
cp .env.prod.example .env.prod
|
||||
nano .env.prod
|
||||
|
||||
# 3) Levantar el stack (build en la propia VM)
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
|
||||
|
||||
# 4) Comprobar salud
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.prod ps
|
||||
curl -s http://127.0.0.1:8089/ | head -c 200 # el frontend responde en loopback
|
||||
|
||||
# 5) Nginx + TLS
|
||||
sudo cp deploy/nginx-recordalexia.conf /etc/nginx/sites-available/recordalexia
|
||||
sudo ln -s /etc/nginx/sites-available/recordalexia /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
sudo certbot --nginx -d recordalexia.jaumegar.work
|
||||
|
||||
# 6) Humo: abrir https://recordalexia.jaumegar.work, registrar una familia de
|
||||
# prueba, pedir recuperación de contraseña y comprobar que LLEGA EL EMAIL.
|
||||
# Después borrar la familia desde Cuenta → Borrar cuenta.
|
||||
```
|
||||
|
||||
Checklist post-despliegue:
|
||||
|
||||
- [ ] `https://…` carga y Chrome ofrece «Instalar app» (o Añadir a pantalla de inicio).
|
||||
- [ ] NO existe la familia demo (el seeder queda apagado en prod).
|
||||
- [ ] El email de recuperación llega (si no: revisar SMTP_* y el log del backend).
|
||||
- [ ] Cabeceras: `curl -sI https://… | grep -iE "nosniff|frame-ancestors|referrer"`.
|
||||
- [ ] Alta del dominio en uptime-kuma.
|
||||
- [ ] Backup + RESTORE ensayado (sección 3). **Sin esto no se abre el registro a terceros.**
|
||||
|
||||
## 2. Backups
|
||||
|
||||
```bash
|
||||
chmod +x deploy/backup.sh
|
||||
crontab -e
|
||||
# 03:30 Europe/Madrid, log en el home:
|
||||
30 3 * * * /home/ubuntu/recordalexia/deploy/backup.sh >> $HOME/recordalexia-backup.log 2>&1
|
||||
```
|
||||
|
||||
- Local: `~/backups/recordalexia/` (retención 14).
|
||||
- Externa: define `RCLONE_REMOTE` en `.env.prod` (p. ej. `b2:recordalexia-backups`).
|
||||
- Vigila el log: un backup de menos de 1 KB se marca como ERROR.
|
||||
|
||||
## 3. Restauración (ENSAYAR antes de abrir al público)
|
||||
|
||||
El ensayo restaura sobre una BD limpia SIN tocar la de producción:
|
||||
|
||||
```bash
|
||||
cd ~/recordalexia
|
||||
LAST=$(ls -1t ~/backups/recordalexia/recordalexia-*.sql.gz | head -1)
|
||||
|
||||
# BD de ensayo temporal (sin volumen persistente ni puertos publicados)
|
||||
docker run -d --name restore-test \
|
||||
-e POSTGRES_DB=recordalexia -e POSTGRES_USER=recordalexia \
|
||||
-e POSTGRES_PASSWORD=ensayo postgres:16-alpine
|
||||
sleep 5
|
||||
gunzip -c "$LAST" | docker exec -i restore-test psql -U recordalexia recordalexia
|
||||
|
||||
# Verificar: las familias están
|
||||
docker exec restore-test psql -U recordalexia recordalexia -tA \
|
||||
-c "SELECT count(*) FROM family;"
|
||||
|
||||
docker rm -f restore-test
|
||||
```
|
||||
|
||||
Restauración REAL (pérdida de la BD de producción):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.prod stop backend
|
||||
gunzip -c "$LAST" | docker compose -f docker-compose.prod.yml --env-file .env.prod \
|
||||
exec -T postgres psql -U recordalexia recordalexia
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.prod start backend
|
||||
```
|
||||
|
||||
## 4. Actualizar la aplicación
|
||||
|
||||
```bash
|
||||
cd ~/recordalexia && git pull
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
|
||||
```
|
||||
|
||||
Liquibase aplica las migraciones al arrancar. Si el backend no levanta, sus logs
|
||||
dicen exactamente qué changeset falló:
|
||||
`docker logs recordalexia-prod-backend | grep -iA3 liquibase`.
|
||||
|
||||
## 5. Problemas conocidos
|
||||
|
||||
| Síntoma | Causa probable | Arreglo |
|
||||
|---|---|---|
|
||||
| 413 al subir algo | límite del nginx | ya hay `client_max_body_size` en el server block y 64m global |
|
||||
| No llegan emails | SMTP_* mal o puerto bloqueado | `docker logs recordalexia-prod-backend \| grep -i mail`; probar el relay con `openssl s_client -starttls smtp -connect $SMTP_HOST:587` |
|
||||
| 429 al entrar | freno anti fuerza bruta | esperar el Retry-After; es el comportamiento diseñado |
|
||||
| Certificado caducado | certbot | `sudo certbot renew --dry-run`; el timer de systemd debería renovarlo solo |
|
||||
| La PWA no se actualiza | SW cacheado | el SW comprueba `ngsw.json` al abrir; forzar con recarga o cerrar/abrir la app |
|
||||
|
||||
## 6. Rotación de credenciales
|
||||
|
||||
- **DB**: cambiar en `.env.prod` + `ALTER USER recordalexia WITH PASSWORD '…'` en el
|
||||
contenedor postgres + `up -d` del backend.
|
||||
- **SMTP**: cambiar en `.env.prod` + `up -d backend`.
|
||||
- **PIN/contraseña de una familia**: lo hace la propia familia desde la app
|
||||
(Cuenta o «¿Has olvidado la contraseña?»).
|
||||
Reference in New Issue
Block a user