Problema

Mantener un servicio web de e‑commerce (API de búsqueda, sincronizador de catálogos y analítica) en un entorno doméstico implica varios retos simultáneos: exposición segura a Internet sin una IP estática, gestión de actualizaciones sin tiempo de inactividad, respaldo fiable de datos críticos y rendimiento aceptable con recursos limitados (CPU de 2 hilos, 8 GB RAM, 12 W de consumo). Cuando la carga supera los miles de requests por día, la combinación de concurrencia en SQLite, contenedores Docker y túneles externos puede volverse inestable si no se planifica una arquitectura coherente.

Causa

  1. Exposición directa del puerto
    Sin una IP pública fija, abrir puertos 80/443 en el router obliga a usar NAT dinámico y a gestionar certificados manualmente. Cada cambio de IP rompe la disponibilidad.

  2. SQLite bajo alta concurrencia
    SQLite en modo WAL permite lecturas concurrentes, pero escrituras simultáneas pueden generar bloqueos si el daemon de sincronización escribe mientras llegan peticiones API.

  3. Actualizaciones sin orquestador
    Reiniciar procesos sin coordinación genera breves periodos sin respuesta, sobre todo cuando se actualiza código y dependencias a la vez.

  4. Backups locales sin redundancia
    Copiar el archivo .db mientras está abierto puede producir snapshots corruptos; la ausencia de verificación automática deja errores sin detección.

  5. Monitorización limitada
    Sin alertas proactivas, fallos de sincronización o errores de disco pasan desapercibidos hasta que el usuario final experimenta problemas.

Solución

Una arquitectura basada en tres capas resuelve los puntos críticos sin requerir hardware adicional:

1. Túnel de salida con Cloudflare Tunnel

Ejecutar cloudflared como servicio crea un túnel saliente que lleva tráfico HTTPS a un puerto local (ej. 8000). Cloudflare gestiona TLS, DDoS y caching, mientras el mini PC nunca expone puertos inbound. La configuración mínima consiste en:

  • Registro del dominio en Cloudflare.
  • Creación de un túnel (cloudflared tunnel create pcpicker) y asignación de un hostname (api.pcpicker.ma).
  • Sistema de servicio que mantiene el daemon activo.

2. Red privada con Tailscale

Instalar Tailscale permite SSH y acceso a los contenedores desde cualquier dispositivo autorizado sin abrir el puerto 22. Cada nodo recibe una IP de rango 100.x.x.x, y las reglas de ACL pueden limitar quién accede a qué servicio.

3. Stack Docker + SQLite WAL

  • FastAPI + Uvicorn en un contenedor expone la API en 0.0.0.0:8000.
  • SQLite en modo WAL (PRAGMA journal_mode=WAL;) garantiza lecturas sin bloqueo mientras el proceso de indexado escribe en transacciones breves.
  • Umami (Docker + PostgreSQL) se ejecuta aislado, evitando interferencias de I/O con la base principal.
  • Docker‑compose define dependencias y asegura que los contenedores se inicien en el orden correcto.

4. GitOps con systemd‑timer

Un timer de 15 minutos revisa el repositorio remoto. Si hay cambios, realiza:

  1. Copia de seguridad del .db en /tmp.
  2. git pull --ff-only.
  3. pip install -r requirements.txt (o poetry install).
  4. systemctl restart fastapi.service (graceful shutdown).

El proceso dura menos de 5 s, por lo que la ventana de indisponibilidad es prácticamente nula.

5. Backups automáticos y verificación

Dos timers separados:

  • Hot backup (03:30) ejecuta sqlite3 db.sqlite .backup /mnt/vault/backups/db_$(date +%F).bak y luego PRAGMA quick_check;. Los archivos se rotan 14 diarios y 8 semanales.
  • Off‑site sync (04:30) usa rclone sync para replicar /mnt/vault/backups a Google Drive. Cualquier error dispara un mensaje a Telegram mediante webhook.

6. Alertas proactivas

Un pequeño script de Python o Bash envía notificaciones a Telegram cuando:

  • El backup falla o el quick_check devuelve errores.
  • El proceso de sincronización del catálogo no completa en menos de X minutos.
  • El uso de CPU supera el 80 % durante más de 5 min.

Cuándo aplicar esta solución

  • Carga ligera‑media (≤ 100 req/s) en hardware de consumo (mini PC, NUC, Raspberry Pi 4 con SSD).
  • Necesidad de exposición pública sin IP estática ni infraestructura cloud.
  • Preferencia por SQLite por su simplicidad y bajo overhead, siempre que el modelo de datos sea mayormente de lectura.
  • Presupuesto limitado: evita VPS y servicios de CI/CD externos.

No es adecuada cuando:

  • Se esperan cientos de requests por segundo o consultas complejas que requieran joins extensos; entonces una base de datos cliente‑servidor (PostgreSQL, MySQL) será más estable.
  • Se requiere alta disponibilidad geográfica; el único nodo no brinda redundancia.
  • El entorno requiere cumplimiento normativo que imponga cifrado en reposo y auditoría de acceso.

Código

# cloudflared systemd service ( /etc/systemd/system/cloudflared.service )
[Unit]
Description=Cloudflare Tunnel
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/cloudflared tunnel --config /etc/cloudflared/config.yml run pcpicker
Restart=on-failure
User=nobody
Group=nogroup

[Install]
WantedBy=multi-user.target
# docker-compose.yml (fragmento esencial)
services:
  api:
    image: ghcr.io/miusuario/pcpicker-api:latest
    ports:
      - "8000:8000"
    volumes:
      - ./data:/app/data
    environment:
      - SQLITE_DB=/app/data/db.sqlite
    depends_on:
      - umami
  umami:
    image: ghcr.io/marcusolsson/umami:postgresql-latest
    environment:
      - DATABASE_URL=postgresql://umami:pass@postgres/umami
    ports:
      - "3000:3000"
  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=umami
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=umami
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:
# systemd timer para GitOps ( /etc/systemd/system/pcpicker-updater.timer )
[Unit]
Description=Actualizador GitOps de PCPicker

[Timer]
OnBootSec=5min
OnUnitActiveSec=15min
Persistent=true

[Install]
WantedBy=timers.target
# script ejecutado por el timer ( /usr/local/bin/pcpicker-updater.sh )
#!/usr/bin/env bash
set -euo pipefail
cd /opt/pcpicker
git fetch origin
if ! git diff --quiet HEAD origin/main; then
  cp data/db.sqlite /tmp/db_$(date +%F_%H%M).bak
  git reset --hard origin/main
  pip install -r requirements.txt
  systemctl restart fastapi.service
fi
# backup hot timer ( /etc/systemd/system/pcpicker-backup.timer )
[Unit]
Description=Hot backup SQLite

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true

[Install]
WantedBy=timers.target
# backup script ( /usr/local/bin/pcpicker-backup.sh )
#!/usr/bin/env bash
set -euo pipefail
DB=/opt/pcpicker/data/db.sqlite
BACKUP_DIR=/mnt/vault/backups
mkdir -p "$BACKUP_DIR"
sqlite3 "$DB" ".backup '$BACKUP_DIR/db_$(date +%F).bak'"
sqlite3 "$DB" "PRAGMA quick_check;" | grep -q "ok" || {
  curl -X POST -d "Backup failed on $(hostname)" https://api.telegram.org/bot$TOKEN/sendMessage?chat_id=$CHAT_ID
}
# Rotación simple
find "$BACKUP_DIR" -type f -mtime +14 -delete
# rclone sync timer ( /etc/systemd/system/pcpicker-rclone.timer )
[Unit]
Description=Sincronización off‑site de backups

[Timer]
OnCalendar=*-*-* 04:30:00
Persistent=true

[Install]
WantedBy=timers.target
# rclone sync script ( /usr/local/bin/pcpicker-rclone.sh )
#!/usr/bin/env bash
set -euo pipefail
rclone sync /mnt/vault/backups remote:pcpicker_backups --log-file=/var/log/rclone.log
if grep -q "ERROR" /var/log/rclone.log; then
  curl -X POST -d "Rclone sync error on $(hostname)" https://api.telegram.org/bot$TOKEN/sendMessage?chat_id=$CHAT_ID
fi

Verificación

  1. Conexión externa: abrir https://api.pcpicker.ma/health y comprobar que responde 200 OK.
  2. SQLite WAL: ejecutar sqlite3 db.sqlite "PRAGMA journal_mode;" y validar que el resultado sea wal.
  3. Backup: revisar que el archivo /mnt/vault/backups/db_$(date).bak exista y que sqlite3 backup.db "PRAGMA quick_check;" devuelva ok.
  4. Rclone: inspeccionar el log /var/log/rclone.log y confirmar que la última línea sea Transferred: 0 / 0, 0 B/s, done.
  5. Alertas: provocar un fallo (por ejemplo, cambiar permisos del directorio de backup) y verificar que llega el mensaje a Telegram.

Notas adicionales

  • Optimizar SQLite: usar índices en columnas de búsqueda frecuente (nombre del producto, SKU) y limitar la longitud de transacciones del daemon de sincronización a menos de 100 ms para evitar picos de bloqueo.
  • Ajuste de Docker: establecer `mem