English · Español
Lab 06 — Deploy en un único VPS, copias diarias, simulacro de disaster-recovery¶
🇪🇸 Portal en vivo en un solo servidor: Caddy delante, uvicorn detrás, SQLite en
/var/lib/lynx-portal/. Copia diaria con BorgBackup a otro sitio. La prueba real: dropear la base de datos, restaurarla desde la copia de ayer, y comprobar que no falta nada. La puerta de salida de la Phase 41: una persona externa se registra, falla una pregunta, y al día siguiente esa pregunta vuelve.
Objetivo¶
Desplegar el portal en un único VPS de un modo que Borja pueda re-ejecutar en una tarde. Caddy termina TLS y hace reverse-proxy a uvicorn; SQLite vive en disco local bajo /var/lib/lynx-portal/. BorgBackup (o restic) snapshots la DB cada noche a un destino off-site, cifrado. La puerta de salida es un simulacro de disaster-recovery + un usuario externo real que completa el bucle de extremo a extremo.
Por qué existe este lab¶
Un portal que solo corre en el portátil de Borja no es un portal. El lab 06 es lo que hace durable el esfuerzo de la Fase 41: alguien que no es Borja, en una red distinta, en un día distinto, puede usar el sistema. El patrón de VPS único se elige porque (a) es lo que necesita una clase real de 3–20 estudiantes, (b) tiene cero lock-in a servicios gestionados, y © el simulacro de disaster-recovery es tratable sobre él — no puedes ensayar fácilmente RDS, puedes absolutamente ensayar un archivo SQLite.
La puerta de salida (usuario externo real) es el único test honesto de que la cadena lab-01..05 es coherente. Los tests internos pueden pasar mientras el sistema desplegado es inusable; el flujo registro-externo + fallar-una-pregunta + verla-mañana demuestra el bucle completo.
Prerrequisitos¶
- Labs 00–05 hechos; todos los tests verdes.
- Un VPS aprovisionado (cualquier instancia pequeña — 1 vCPU, 1 GB RAM, 10 GB de disco basta para ≤ 50 learners).
- Existe un destino de backup off-site (un segundo VPS, un bucket S3-compatible, o el NAS de casa de Borja accesible por SSH).
- Las reglas de manejo de secretos de la Fase 37 están interiorizadas (sin secretos en git; archivo env del vault bajo
0600).
Entregables¶
deploy/Caddyfile— reverse-proxy + config automática de Let's Encrypt.deploy/systemd/lynx-portal.service— uvicorn bajo systemd, política de reinicio, user/group.deploy/install.sh— instalación idempotente de primera vez en un VPS fresco (deps de apt, creación de usuario, layout de directorios, enable de systemd).deploy/backup/borg-backup.sh— script de snapshot diario.deploy/backup/borg-restore.sh— script de restore-from-snapshot.deploy/backup/crontab— fragmento instalado porinstall.sh.deploy/README.md— runbook (checklist de sign-off para un nuevo deploy).tests/integration/test_disaster_recovery.py— dropea + restaura + verifica.experiments/41-deploy/dr-drill-2026-XX-XX.md— log del simulacro real ejecutado.experiments/41-deploy/external-user-walkthrough.md— transcripción de la puerta de salida.
Paso 1 — El layout del VPS¶
/var/lib/lynx-portal/
portal.db # SQLite, WAL mode
portal.db-wal # write-ahead log
portal.db-shm # shared memory
audit/YYYY-MM-DD.log # JSONL audit
uploads/ # if any (notes attachments — out of scope for lab 06)
/etc/lynx-portal/
portal.env # 0600 root:portal — secrets (pepper, session_secret, backup repo passphrase)
/opt/lynx-portal/
app/ # checkout of the repo OR a uv-built venv slug
venv/ # uv-managed
/var/log/lynx-portal/
portal.log # if not using systemd-journald
Propiedad: portal:portal para todo bajo /var/lib/lynx-portal/ y /opt/lynx-portal/. Modo 0750 en directorios, 0640 en archivos regulares. El usuario de sistema portal es una cuenta sin login (/usr/sbin/nologin).
Paso 2 — install.sh¶
#!/usr/bin/env bash
# deploy/install.sh
# Idempotent. Safe to re-run.
set -euo pipefail
# Lab 06 step 2: implement the following actions, each guarded by an "is it already done?" check.
#
# 1. apt-get install -y caddy python3 sqlite3 borgbackup curl
# 2. id -u portal || useradd --system --home /var/lib/lynx-portal --shell /usr/sbin/nologin portal
# 3. mkdir -p /var/lib/lynx-portal /etc/lynx-portal /opt/lynx-portal
# 4. chown -R portal:portal /var/lib/lynx-portal
# chmod 0750 /var/lib/lynx-portal
# 5. install -m 0600 -o root -g portal portal.env.template /etc/lynx-portal/portal.env
# # Operator fills in real secrets after this script exits.
# 6. install -m 0644 deploy/Caddyfile /etc/caddy/Caddyfile
# 7. install -m 0644 deploy/systemd/lynx-portal.service /etc/systemd/system/
# 8. systemctl daemon-reload
# systemctl enable --now lynx-portal
# systemctl reload caddy
# 9. install -m 0755 deploy/backup/borg-backup.sh /usr/local/bin/lynx-backup
# crontab -u portal deploy/backup/crontab
echo "Lab 06 step 2 — implement the install script."
exit 1
El script termina con un checklist impreso de los pasos manuales que Borja todavía tiene que hacer (rellenar /etc/lynx-portal/portal.env, ejecutar borg init contra el repo remoto, apuntar el DNS del dominio al VPS). No intenta hacer esas cosas por sí mismo — son acciones que llevan credenciales y pertenecen al operador.
Paso 3 — Caddyfile¶
# deploy/Caddyfile
portal.example.com {
encode zstd gzip
log {
output file /var/log/caddy/access.log
format console
}
@healthz path /healthz
handle @healthz {
reverse_proxy 127.0.0.1:8001
}
handle /static/* {
root * /opt/lynx-portal/app/src/miniportal
file_server
}
handle {
reverse_proxy 127.0.0.1:8001
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
# CSP is set per-route by the app (some pages need inline event handlers; locked down in lab 03).
}
}
Reemplaza portal.example.com en el momento del deploy; documenta la elección en deploy/README.md para que el próximo deployer no lo olvide.
Paso 4 — Unidad systemd¶
# deploy/systemd/lynx-portal.service
[Unit]
Description=lynx-cortex learner portal
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=portal
Group=portal
WorkingDirectory=/opt/lynx-portal/app
EnvironmentFile=/etc/lynx-portal/portal.env
ExecStart=/opt/lynx-portal/venv/bin/uvicorn miniportal.app:make_app --factory --host 127.0.0.1 --port 8001
Restart=on-failure
RestartSec=3
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/lib/lynx-portal /var/log/lynx-portal
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
LockPersonality=true
[Install]
WantedBy=multi-user.target
Las líneas de hardening vienen del checklist de hardening de la Fase 37; no son negociables para producción. El test de integración en experiments/41-deploy/ ejecuta systemd-analyze security lynx-portal.service y espera un score ≤ 3.0.
Paso 5 — SQLite + WAL¶
SQLite es un único archivo. Esa es la fortaleza (copias triviales) y la debilidad (una escritura mala corrompe todo). Dos ajustes no negociables:
# Configured in miniportal.app on startup
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL; # WAL + NORMAL is the standard recommendation
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
No alojes la DB en un mount NFS/SMB. SQLite + sistemas de archivos en red es un anti-patrón documentado; las semánticas flock/fcntl requeridas por WAL no son fiables a través de muchos sistemas de archivos en red. El disco local del VPS es el destino del despliegue.
Paso 6 — Script de backup¶
#!/usr/bin/env bash
# deploy/backup/borg-backup.sh
# Runs as the portal user via cron, 02:30 daily.
set -euo pipefail
# Lab 06 step 6: implement the following pipeline, each step error-checked.
#
# 1. ts=$(date -u +%Y%m%dT%H%M%SZ)
# 2. sqlite3 /var/lib/lynx-portal/portal.db ".backup '/var/lib/lynx-portal/portal.snapshot.${ts}.db'"
# # SQLite's .backup is online-safe and produces a consistent snapshot.
# 3. borg create \
# --stats --compression zstd \
# ssh://borg@offsite.example.com/./repo::lynx-portal-${ts} \
# /var/lib/lynx-portal/portal.snapshot.${ts}.db \
# /var/lib/lynx-portal/audit
# 4. rm /var/lib/lynx-portal/portal.snapshot.${ts}.db
# 5. borg prune --keep-daily=14 --keep-weekly=8 --keep-monthly=12 \
# ssh://borg@offsite.example.com/./repo
# 6. Append a line to /var/log/lynx-portal/backup.log with the timestamp + borg's stats.
echo "Lab 06 step 6 — implement the backup pipeline."
exit 1
El comando .backup de SQLite es la forma correcta de snapshottear una DB en vivo; cp portal.db backup.db no es seguro bajo WAL. El script nunca lee portal.env — la passphrase de borg viene de un archivo env separado en /etc/lynx-portal/borg.env, modo 0400 root:portal, cargado por el wrapper de cron.
Paso 7 — Script de restore + simulacro DR¶
#!/usr/bin/env bash
# deploy/backup/borg-restore.sh
set -euo pipefail
# Lab 06 step 7: implement the restore pipeline.
#
# 1. systemctl stop lynx-portal
# 2. mv /var/lib/lynx-portal/portal.db /var/lib/lynx-portal/portal.db.broken.$(date -u +%s)
# 3. borg extract ssh://borg@offsite.example.com/./repo::"$1" \
# --strip-components 4 -C /var/lib/lynx-portal/
# 4. mv /var/lib/lynx-portal/portal.snapshot.*.db /var/lib/lynx-portal/portal.db
# 5. chown portal:portal /var/lib/lynx-portal/portal.db
# 6. systemctl start lynx-portal
# 7. curl -fsS http://127.0.0.1:8001/healthz
echo "Lab 06 step 7 — implement the restore pipeline."
exit 1
El simulacro DR se ejecuta una vez antes de la puerta de salida:
- Toma un backup fresco (
lynx-backupmanual). - Siembra tres estudiantes de prueba, dos journal entries cada uno, un quiz attempt cada uno.
- Dropea la DB (
rm /var/lib/lynx-portal/portal.db*). - Restaura desde el snapshot de ayer (
borg-restore.sh lynx-portal-YYYYMMDDTHHMMSSZ). - Verifica: los tres estudiantes siguen existiendo, las journal entries están presentes, los quiz attempts están presentes.
- Loggea todo en
experiments/41-deploy/dr-drill-2026-XX-XX.md.
# tests/integration/test_disaster_recovery.py
def test_dr_drill_end_to_end():
"""Run inside a docker-compose harness:
1. Start the portal container with a seeded DB.
2. Run the backup script against a local borg repo.
3. `rm` the DB file.
4. Run the restore script.
5. Assert /healthz is green and the three seeded students are queryable.
This test is slow (~30 s); marked @pytest.mark.slow and skipped from default `just test`.
Run as `just test-integration` or in CI nightly.
"""
raise NotImplementedError("Lab 06 step 7 — wire the docker-compose harness; run the four phases.")
Paso 8 — La puerta de salida¶
La puerta de salida de la Fase 41 es un usuario externo completando el bucle:
- Borja comparte la URL del portal y un username con alguien fuera del proyecto (un amigo, un estudiante).
- Visitan
/login, teclean el username, son redirigidos a/set-password. - Fijan una contraseña.
- Hacen el quiz de la fase-0.
- Deliberadamente fallan una pregunta.
- Al día siguiente, hacen login y la card de la pregunta-fallada está en su cola
/review.
Borja captura el walkthrough en experiments/41-deploy/external-user-walkthrough.md — incluyendo los tiempos reales de cada paso, cualquier fricción que el usuario reportó, y el screenshot o trace ID mostrando que la review card salió en el día dos.
Si el usuario no ve la review card en el día dos, el wiring de SM-2 (lab 04) o el manejo de zonas horarias (lab 06) tiene un bug — corrígelo en la fase de origen, no en el lab 06.
Cómo es "done"¶
-
deploy/install.shlleva un VPS fresco a un portal sano enhttps://portal.example.com/healthzen una sola ejecución. -
systemctl status lynx-portalmuestraactive (running); el score desystemd-analyze securityes ≤ 3.0. -
Caddyfiletermina TLS vía Let's Encrypt; la cabecera HSTS está presente. - SQLite está en modo WAL;
PRAGMA foreign_keysestá ON. - Los backups diarios de borg corren vía cron a las 02:30 UTC; el repo off-site tiene al menos un snapshot.
-
borg-restore.shtiene éxito en el simulacro DR; integridad de datos verificada post-restore. -
tests/integration/test_disaster_recovery.pyestá verde cuando se invoca. - Un usuario externo completó el bucle sign-up → set-password → quiz → fallar → review al día siguiente; walkthrough commiteado.
- El runbook
deploy/README.mdestá suficientemente completo para que un segundo deployer pueda repetir sin la ayuda de Borja.
Trampas comunes¶
- SQLite sobre NFS. Anti-patrón documentado; la corrupción es cuestión de cuándo, no de si. Disco local en el VPS.
cp portal.dben lugar de.backup. El modo WAL significa que el archivo en disco no es un snapshot consistente. Usa el comando.backupde SQLite.- Passphrase de backup en git. Incluso en un repo privado. La passphrase de borg vive en
/etc/lynx-portal/borg.env, modo 0400. - Sin simulacro DR. Los backups que nunca se restauran no son backups. El simulacro es el contrato.
- Permitir que
install.shescriba secretos en archivos rastreados por git. El script copia una plantilla; el operador rellena los valores reales fuera de banda. - Olvidar apuntar el DNS. Caddy fallará silenciosamente al adquirir un certificado; depura con
journalctl -u caddyy busca errores de ACME. - Ambigüedad de zona horaria en
due_onde SM-2. Si el portal almacenadue_onen UTC pero el usuario externo está en una zona horaria UTC+2, la card puede salir un día "tarde" según su reloj. Documenta la convención (fechas UTC en todo) y añade un campotzpor usuario si surgen quejas reales. - Confiar en el deploy sin la puerta de salida. Los tests internos pueden pasar en un deploy roto. El walkthrough con usuario externo es el único test de aceptación honesto.
Fin de la serie de labs de la Fase 41. Siguiente: PHASE_41_REPORT.md una vez el deploy esté en vivo y el usuario externo haya completado el bucle.