diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..b84ef45 --- /dev/null +++ b/.env.prod.example @@ -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= diff --git a/.gitignore b/.gitignore index ced353d..e12a022 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # --- Entorno / secretos --- # El .env real (con credenciales) NUNCA se versiona. Solo .env.example. .env +.env.prod # --- Sistema operativo --- .DS_Store diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 0000000..5a65d06 --- /dev/null +++ b/deploy/backup.sh @@ -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 diff --git a/deploy/nginx-recordalexia.conf b/deploy/nginx-recordalexia.conf new file mode 100644 index 0000000..7227264 --- /dev/null +++ b/deploy/nginx-recordalexia.conf @@ -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; + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2708938 --- /dev/null +++ b/docker-compose.prod.yml @@ -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: diff --git a/docs/operacion-saas.md b/docs/operacion-saas.md new file mode 100644 index 0000000..e68b112 --- /dev/null +++ b/docs/operacion-saas.md @@ -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?»).