HerméticaDocumentación técnica

Operación

Guía de despliegue

Cómo se instala, configura y pone en producción cada componente. Incluye el aprovisionamiento desde cero en una cuenta nueva de Google Cloud y Cloudflare, el flujo de despliegue del día a día, el entorno local y el procedimiento de reversión.

Resumen en una línea: ambos componentes despliegan solos al hacer merge a main. El trabajo manual es únicamente el aprovisionamiento inicial y la carga de variables de entorno.

0 · Requisitos previos

HerramientaPara quéVerificación
Docker + Compose v2Construir la imagen y correr el entorno localdocker compose version
Google Cloud SDKAprovisionar y operar GCPgcloud version
Node.js 24 + pnpm 10.18Construir el frontendnode -v · pnpm -v
Wrangler CLIOperar Cloudflare Workerswrangler whoami
Cliente PostgreSQL 17Backup y restore de la basepg_dump --version
Python 3.11/3.12Correr manage.py fuera de Dockerpython --version

El cliente de PostgreSQL debe ser 17. pg_dump se niega a volcar un servidor más nuevo que él, y Cloud SQL corre 17.9. El postgresql-client de Debian bookworm es 15 y no sirve; hay que instalar el paquete 17 desde el repositorio PGDG. El repositorio incluye un Dockerfile.local que ya lo trae.


Parte A · Backend en Google Cloud

El backend es un contenedor Docker que corre en Cloud Run, con la base en Cloud SQL y los archivos en Cloud Storage. Cloud Build es quien construye y publica.

A1 · Crear el proyecto y habilitar APIs

# Proyecto (el actual se llama "hermetica"; usar el id que corresponda)
gcloud projects create PROJECT_ID --name="Hermetica"
gcloud config set project PROJECT_ID
gcloud billing projects link PROJECT_ID --billing-account=BILLING_ID

# APIs necesarias
gcloud services enable \
  run.googleapis.com \
  cloudbuild.googleapis.com \
  artifactregistry.googleapis.com \
  sqladmin.googleapis.com \
  storage.googleapis.com \
  cloudscheduler.googleapis.com \
  secretmanager.googleapis.com

A2 · Base de datos (Cloud SQL)

gcloud sql instances create hermetica-comercial \
  --database-version=POSTGRES_17 \
  --region=us-central1 \
  --tier=db-custom-2-7680 \
  --storage-auto-increase \
  --backup-start-time=07:00 \
  --retained-backups-count=14 \
  --require-ssl

gcloud sql databases create hermetica-comercial-db --instance=hermetica-comercial
gcloud sql users create hermetica-sql --instance=hermetica-comercial --password='…'

# Allowlist de IPs que pueden conectarse (Cloud Run sale por IPs dinámicas:
# usar conector de VPC / Cloud SQL Auth Proxy, o autorizar las IPs de trabajo)
gcloud sql instances patch hermetica-comercial --authorized-networks=IP_1,IP_2

Hoy la instancia usa IP pública con allowlist y sslmode=require. Si se reconstruye desde cero, la opción recomendada es conectar Cloud Run por Cloud SQL Auth Proxy / conector serverless y dejar la instancia sin IP pública. El código no cambia: solo cambian DB_HOST y DB_PORT.

A3 · Almacenamiento de archivos (Cloud Storage)

El propio cloudbuild.yaml crea el bucket si no existe, pero conviene crearlo a mano para controlar la política de acceso:

gsutil mb -l us-central1 gs://hermetica_bucket
gsutil iam ch allUsers:objectViewer gs://hermetica_bucket   # lectura pública (config. actual)
gsutil cors set cors.json gs://hermetica_bucket

La cuenta de servicio de Cloud Run necesita roles/storage.objectAdmin sobre el bucket para poder escribir adjuntos y fotos. Para migrar los archivos ya existentes a una cuenta nueva, ver Parte E.

A4 · Registro de imágenes (Artifact Registry)

gcloud artifacts repositories create lu-hermetica-repo \
  --repository-format=docker --location=us-central1 \
  --description="Imágenes del backend comercial"

A5 · Permisos de Cloud Build

La cuenta de servicio de Cloud Build necesita poder desplegar en Cloud Run y usar la cuenta de servicio del servicio. Sin estos dos roles, la build falla en el último paso:

PROJECT_NUMBER=$(gcloud projects describe PROJECT_ID --format='value(projectNumber)')
CB_SA="${PROJECT_NUMBER}@cloudbuild.gserviceaccount.com"

gcloud projects add-iam-policy-binding PROJECT_ID \
  --member="serviceAccount:${CB_SA}" --role="roles/run.admin"
gcloud projects add-iam-policy-binding PROJECT_ID \
  --member="serviceAccount:${CB_SA}" --role="roles/iam.serviceAccountUser"
gcloud projects add-iam-policy-binding PROJECT_ID \
  --member="serviceAccount:${CB_SA}" --role="roles/artifactregistry.writer"

A6 · Disparador de Cloud Build

Conectar el repositorio de GitHub y crear el disparador sobre main:

gcloud builds triggers create github \
  --name=hermetica-backend \
  --repo-owner=HERMETICA-SAC \
  --repo-name=lu-hermetica-comercial-backend \
  --branch-pattern="^main$" \
  --build-config=cloudbuild.yaml \
  --region=us-central1

El disparador publica un check-run en GitHub llamado hermetica-backend en cada commit de main. Eso permite seguir el estado del despliegue desde el propio pull request, sin abrir la consola de GCP.

A7 · Variables de entorno del servicio

cloudbuild.yaml despliega con --update-env-vars (no --set-env-vars) precisamente para no borrar en cada deploy las variables que se administran fuera del repositorio. Estas se cargan una sola vez sobre el servicio de Cloud Run y sobreviven a los despliegues.

VariableObligatoriaPara qué
DB_PASSWORDContraseña de Cloud SQL. Además es el interruptor que hace a settings.py usar PostgreSQL en vez de SQLite y activar el almacenamiento en GCS.
DB_NAME · DB_USER · DB_HOST · DB_PORT · DB_SSLMODENoTienen valores por defecto apuntando a la instancia actual. Definirlas explícitamente al migrar de instancia.
GCS_PROJECT_IDProyecto del bucket de archivos.
SECRET_KEYClave de firma de Django (sesiones, tokens). Rotarla invalida las sesiones abiertas.
DEBUG · ALLOWED_HOSTSDEBUG=False en producción; hosts permitidos separados por coma.
FRONTEND_URLURL pública del frontend; se usa para construir los enlaces de los correos.
CRON_SECRETSecreto que valida el disparo del cron de tareas vencidas (cabecera X-Cron-Secret).
ANTHROPIC_API_KEY · ANTHROPIC_MODELSolo Chat NisiraMotor del chat analítico sobre el datawarehouse.
OPENAI_API_KEY · WHISPER_MODELSolo vozTranscripción de audio en el chat.
ELEVENLABS_API_KEY · ELEVENLABS_VOICE_ID · ELEVENLABS_MODELSolo vozSíntesis de voz de las respuestas.
ANALYTICS_CHAT_ENABLED · ANALYTICS_CHAT_MAX_TOOL_ITERATIONSNoInterruptor y tope de iteraciones del Chat Nisira.
DISPATCH_SOURCE_BASE_URL · DISPATCH_SOURCE_USERNAME · DISPATCH_SOURCE_PASSWORDConexión al datawarehouse Nisira que alimenta la importación.
APPROVAL_NOTIFY_EMAILS · OVERDUE_NOTIFY_EMAILS · DELETE_NOTIFY_EMAILSNoDestinatarios de las notificaciones por correo (CSV). Tienen valores por defecto en el código que conviene reemplazar en el traspaso.
MEDIA_URL_OVERRIDE · USE_GCSNoAyudas para desarrollo: ver imágenes de producción desde un entorno local.

Plantilla en el repositorio. Cada repositorio incluye un .env.example que enumera todas las variables con su descripción y sin valores reales. Es el punto de partida: copiarlo a .env, completar los valores —que se entregan por canal aparte— y cargarlos en el servicio de Cloud Run. En el frontend la única variable es VITE_API_BASE_URL.

# Cargar/actualizar variables sin tocar las demás
gcloud run services update lu-hermetica-comercial-backend \
  --region=us-central1 \
  --update-env-vars SECRET_KEY=…,ANTHROPIC_API_KEY=…,CRON_SECRET=…

# Ver las que están cargadas hoy
gcloud run services describe lu-hermetica-comercial-backend --region=us-central1 \
  --format='value(spec.template.spec.containers[0].env)'

Deuda técnica a corregir en el traspaso. Hoy hay credenciales escritas directamente en el código: la contraseña de la base en cloudbuild.yaml, la del correo en settings.py y la de Nisira en dispatch/management/commands/import_dispatch.py. Lo correcto es moverlas a Secret Manager y referenciarlas con --set-secrets en el deploy. Mientras eso no se haga, cualquiera con acceso de lectura al repositorio tiene acceso a producción.

A8 · Primer despliegue y despliegues posteriores

Con el disparador creado, el despliegue es simplemente:

  1. Abrir un pull request contra main.
  2. Hacer merge. Cloud Build arranca solo.
  3. Esperar el check hermetica-backend en verde (3–6 minutos típicos).

Para lanzar una build a mano, sin pasar por GitHub:

gcloud builds submit --config=cloudbuild.yaml --project=PROJECT_ID .

# Seguimiento
gcloud builds list --project=PROJECT_ID --limit=5
gcloud builds log BUILD_ID --project=PROJECT_ID --stream

Un check en verde implica que las migraciones se aplicaron. El paso manage.py migrate --noinput corre antes del gcloud run deploy y Cloud Build aborta la build si devuelve un código distinto de cero. Si la build terminó bien, el esquema de la base ya está al día.

A9 · Verificación, logs y reversión

# ¿Responde el servicio?
curl -s https://SERVICIO.run.app/health/
# → {"status":"healthy","service":"lu-hermetica-comercial-backend"}

# Logs en vivo
gcloud run services logs tail lu-hermetica-comercial-backend --region=us-central1

# Errores de la última hora
gcloud logging read \
  'resource.type="cloud_run_revision" AND severity>=ERROR' \
  --limit=50 --freshness=1h --project=PROJECT_ID

# Revertir a la revisión anterior (inmediato, sin rebuild)
gcloud run revisions list --service=lu-hermetica-comercial-backend --region=us-central1
gcloud run services update-traffic lu-hermetica-comercial-backend \
  --region=us-central1 --to-revisions=REVISION_ANTERIOR=100

La reversión del código no revierte la base. Si el despliegue incluía una migración destructiva, volver a la revisión anterior deja el código viejo contra un esquema nuevo. Ante una migración de riesgo, tomar un backup antes (sección D) y planificar la migración inversa.


Parte B · Frontend en Cloudflare

El frontend es un sitio estático (el dist/ que produce Vite) servido por un Cloudflare Worker con static assets. No hay servidor Node en ejecución.

B1 · Configuración del Worker

Toda la configuración vive en wrangler.json, en la raíz del repositorio:

{
  "name": "lu-hermetica-comercial-webapp",
  "compatibility_date": "2025-12-18",
  "assets": { "directory": "./dist" }
}

B2 · Conectar el repositorio (Workers Builds)

  1. En el panel de Cloudflare: Workers & Pages → Create → Connect to Git y elegir el repositorio del frontend.
  2. Configurar la build:
    • Comando de build: pnpm install --frozen-lockfile && pnpm build
    • Directorio de salida: dist
    • Rama de producción: main
  3. Definir la variable de build VITE_API_BASE_URL con la URL pública de la API (por ejemplo https://…run.app/api). Es la única variable que necesita el frontend.
  4. Guardar. A partir de ahí, cada push a una rama genera un preview y cada merge a main despliega a producción (~4 minutos).

VITE_API_BASE_URL se congela en el bundle. Vite sustituye las variables en tiempo de compilación, no de ejecución. Cambiar la URL de la API exige reconstruir y volver a desplegar el frontend, no solo editar una variable.

B3 · Dominio propio

Hoy el sitio responde en hermetica.lumini.dev, un subdominio del proveedor. Para apuntarlo a un dominio de Hermética:

  1. Agregar la zona (por ejemplo hermetica.pe) a la cuenta de Cloudflare, o delegar solo el subdominio.
  2. En el Worker: Settings → Domains & Routes → Add custom domain e indicar app.hermetica.pe (o el que corresponda). Cloudflare emite el certificado TLS automáticamente.
  3. Agregar ese origen a CORS_ALLOWED_ORIGINS en hermetica_dir/settings.py del backend y desplegar. Si se omite este paso, el sitio carga pero todas las llamadas a la API fallan por CORS.
  4. Actualizar FRONTEND_URL en Cloud Run para que los enlaces de los correos apunten al dominio nuevo.

B4 · Despliegue manual y reversión

# Build y publicación manual (requiere wrangler autenticado)
pnpm install --frozen-lockfile
pnpm build
wrangler deploy

# Reversión: desde el panel, Deployments → versión anterior → Rollback
wrangler deployments list
wrangler rollback VERSION_ID

Después de desplegar, el usuario puede seguir viendo la versión anterior. La app es una PWA con precache: el service worker nuevo queda en espera hasta que se cierran todas las pestañas del dominio, y un refresco forzado no lo desbloquea. Para verificar un despliegue, comparar el hash del bundle (curl -s https://DOMINIO/ | grep -o 'index-[A-Za-z0-9_-]*\.js') en vez de fiarse de lo que se ve en pantalla.


Parte C · Entorno local

C1 · Backend

# 1. PostgreSQL local en Docker
docker compose -f docker-compose.local.yml --env-file .env.local up -d
# El contenedor hermetica_local_postgres debe quedar "healthy"

# 2. Variables y migraciones
set -o allexport && source .env.local && set +o allexport
python manage.py migrate
python manage.py create_super_admin

# 3. Servidor
python manage.py runserver 0.0.0.0:8001

Con DB_PASSWORD vacío, settings.py cae a SQLite (db.sqlite3) y a almacenamiento en disco. Es el modo más rápido para levantar el proyecto por primera vez; para reproducir el comportamiento de producción hay que usar PostgreSQL.

C2 · Frontend

pnpm install
echo 'VITE_API_BASE_URL=http://127.0.0.1:8001/api' > .env
pnpm dev   # http://localhost:5173

El backend ya permite CORS desde localhost:5173 a localhost:5177, así que no hace falta tocar nada del lado del servidor.


Parte D · Base de datos

El repositorio incluye comandos de gestión para respaldar y restaurar, con guardas para evitar accidentes (se niegan a volcar localhost, o a restaurar sobre un host remoto).

D1 · Respaldo de producción

set -o allexport && source .env.prod && set +o allexport

# Verificar a qué base apunta antes de nada
python manage.py shell -c "from django.conf import settings; d=settings.DATABASES['default']; print(d['HOST'], d['NAME'])"

python manage.py dump_production_db
# → backups/prod_AAAAMMDD_HHMMSS.dump  (formato pg_dump -Fc, comprimido)

Al terminar imprime un recuento fila por fila de cada tabla del esquema public. Ese reporte es lo que se compara contra el del restore para comprobar que no se perdió nada. Opciones: --format plain para SQL en texto, --output para otra ruta, --skip-counts para omitir el recuento.

D2 · Restauración en local

set -o allexport && source .env.local && set +o allexport
python manage.py restore_backup_to_local backups/prod_AAAAMMDD_HHMMSS.dump
# DROP SCHEMA + pg_restore. Solo acepta hosts locales.

D3 · Respaldos administrados

Cloud SQL tiene sus propios backups automáticos y recuperación a un punto en el tiempo. Es la red de seguridad real; los volcados manuales son para copias de trabajo.

gcloud sql backups list --instance=hermetica-comercial
gcloud sql backups create --instance=hermetica-comercial      # backup a demanda
gcloud sql export sql hermetica-comercial gs://BUCKET/dump.sql.gz \
  --database=hermetica-comercial-db                            # exportar a GCS


Parte E · Archivos y fotos

Además de la base de datos, el sistema guarda archivos binarios en Cloud Storage: fotos de productos, fotos de producción y almacenaje, evidencias de OPR, adjuntos de notas de servicio y documentos de importaciones. La base de datos solo guarda la ruta; si los archivos no se migran, la aplicación arranca sin errores pero todas las imágenes aparecen rotas.

Es la parte del traspaso que se olvida con más frecuencia, porque no aparece en un backup de PostgreSQL. Conviene migrarla en la misma ventana que la base, para que las rutas guardadas y los objetos existentes coincidan.

E1 · Qué hay en el bucket

# Tamaño total y número de objetos
gsutil du -sh gs://hermetica_bucket
gsutil ls -r gs://hermetica_bucket/** | wc -l

# Desglose por carpeta, para dimensionar la copia
gsutil du -sh gs://hermetica_bucket/*

E2 · Copiar el bucket a la cuenta destino

La copia es servidor a servidor: no pasa por la máquina que lanza el comando, así que el tiempo depende del volumen y no del ancho de banda local.

# 1. Crear el bucket destino en el proyecto de Hermética
gsutil mb -l us-central1 -p PROYECTO_DESTINO gs://hermetica_bucket_nuevo

# 2. Dar lectura del bucket origen a la cuenta que ejecuta la copia
gsutil iam ch user:CUENTA_DESTINO:objectViewer gs://hermetica_bucket

# 3. Copia incremental (-m paraleliza, -r recursivo)
gsutil -m rsync -r gs://hermetica_bucket gs://hermetica_bucket_nuevo

# 4. Repetir el mismo rsync en la ventana de corte: solo copia lo que cambió
gsutil -m rsync -r -d gs://hermetica_bucket gs://hermetica_bucket_nuevo

Para volúmenes grandes, Storage Transfer Service hace lo mismo de forma administrada, con reintentos y reporte, y se puede programar:

gcloud transfer jobs create \
  gs://hermetica_bucket gs://hermetica_bucket_nuevo \
  --project=PROYECTO_DESTINO \
  --name=migracion-archivos-hermetica

E3 · Apuntar la aplicación al bucket nuevo

El nombre del bucket está escrito en el código, no en una variable de entorno:

# hermetica_dir/settings.py
GCS_BUCKET_NAME = "hermetica_bucket"   # ← cambiar por el bucket destino

Al cambiarlo se modifica también MEDIA_URL, que se construye a partir de ese nombre. Verificar después del despliegue que una imagen ya existente carga correctamente: es la prueba de que la migración de archivos y el cambio de configuración quedaron alineados.

La cuenta de servicio de Cloud Run necesita roles/storage.objectAdmin sobre el bucket nuevo, o las cargas fallarán aunque las lecturas funcionen.

Decisión pendiente sobre el acceso. El bucket actual sirve todos los objetos con lectura pública y sin firma, que es lo que hace que las imágenes carguen directo en el navegador sin pasar por el backend. Si Hermética prefiere que los adjuntos no sean accesibles por URL, la alternativa es quitar el acceso público y activar URLs firmadas (GS_QUERYSTRING_AUTH = True y GS_DEFAULT_ACL = None en settings.py). Es un cambio de una línea, pero conviene decidirlo antes de migrar para no reprocesar las rutas.


Parte F · Proceso programado

El sistema tiene una tarea periódica: la sincronización con el ERP Nisira. Corre en Cloud Scheduler y es simplemente una llamada HTTP a un endpoint de la API; todo el trabajo ocurre del lado del backend.

AtributoValor
ServicioGoogle Cloud Scheduler · us-central1
Frecuencia*/30 6-21 * * * · zona America/Lima
DestinoGET /api/dispatch/import/ en el servicio de Cloud Run
Qué hacePide un token al datawarehouse y ejecuta los comandos import_group e import_dispatch
Duración típica2 a 4 minutos · del orden de 2.000 órdenes de venta y 5.000 de producción por corrida
ConcurrenciaProtegida en el backend por ImportStatus.acquire(): dos corridas no se solapan

F1 · Recrear el trabajo en la cuenta destino

gcloud scheduler jobs create http hermetica-import-dispatch \
  --project=PROYECTO_DESTINO \
  --location=us-central1 \
  --schedule="*/30 6-21 * * *" \
  --time-zone="America/Lima" \
  --uri="https://SERVICIO.run.app/api/dispatch/import/" \
  --http-method=GET \
  --attempt-deadline=300s \
  --max-retry-attempts=1

El --attempt-deadline debe acompañar al timeout de Cloud Run (300 s). Si se deja el valor por defecto, Cloud Scheduler corta antes de que la importación termine y la marca como fallida aunque del lado del servidor haya completado bien.

F2 · Verificación y operación

# Ver el trabajo y su próxima ejecución
gcloud scheduler jobs describe hermetica-import-dispatch --location=us-central1

# Disparar a mano, sin esperar al siguiente ciclo
gcloud scheduler jobs run hermetica-import-dispatch --location=us-central1

# Historial de ejecuciones
gcloud logging read \
  'resource.type="cloud_scheduler_job" AND resource.labels.job_id="hermetica-import-dispatch"' \
  --limit=20 --freshness=1d

# Pausar y reanudar (útil durante una migración de datos)
gcloud scheduler jobs pause  hermetica-import-dispatch --location=us-central1
gcloud scheduler jobs resume hermetica-import-dispatch --location=us-central1

Cómo confirmar que quedó bien: la pantalla de Despachos muestra la marca de tiempo de la última importación. Si avanza cada media hora, el trabajo está operativo. Durante el corte de datos conviene pausarlo para que no escriba sobre la base mientras se restaura.

F3 · Endurecer el endpoint

Hoy GET /api/dispatch/import/ no exige autenticación, que es lo que permite dispararlo con una llamada simple. El backend ya trae el mecanismo para protegerlo —la variable CRON_SECRET y la validación de la cabecera X-Cron-Secret, usada en portal_gerencial/cron.py—, así que extenderlo a este endpoint es un cambio pequeño:

# dispatch/views.py
@api_view(["GET"])
def run_dispatch_import(request):
    secret = getattr(settings, "CRON_SECRET", "")
    if secret:  # si no está configurada, el endpoint sigue abierto
        recibido = request.headers.get("X-Cron-Secret") or request.query_params.get("secret")
        if recibido != secret:
            return Response({"detail": "no autorizado"}, status=403)
    ...

Escrito así, la validación solo se activa cuando CRON_SECRET existe: se puede desplegar el backend primero y activar la protección después, sin ventana de corte. Del lado de Cloud Scheduler basta agregar --headers="X-Cron-Secret=EL_SECRETO".

F4 · Otros disparadores

El backend expone además POST /api/portal/cron/overdue/, que envía por correo las tareas vencidas del Portal Gerencial y ya valida X-Cron-Secret. No es indispensable para la operación; si Hermética quiere activarlo, se programa igual que el anterior:

gcloud scheduler jobs create http hermetica-overdue-notifications \
  --location=us-central1 \
  --schedule="0 8 * * *" --time-zone="America/Lima" \
  --uri="https://SERVICIO.run.app/api/portal/cron/overdue/" \
  --http-method=POST \
  --headers="X-Cron-Secret=EL_SECRETO"

Checklist de puesta en producción desde cero

  1. Proyecto de GCP creado, facturación vinculada y APIs habilitadas. A1
  2. Instancia de Cloud SQL, base y usuario creados; backups automáticos activos. A2
  3. Bucket de Cloud Storage con la política de acceso decidida. A3
  4. Repositorio de Artifact Registry creado. A4
  5. Roles de IAM otorgados a la cuenta de servicio de Cloud Build. A5
  6. Disparador de Cloud Build apuntando a main. A6
  7. Variables de entorno cargadas en el servicio de Cloud Run. A7
  8. Primera build en verde y /health/ respondiendo. A8
  9. Datos restaurados en la base nueva si es una migración. D
  10. Archivos del bucket copiados y GCS_BUCKET_NAME apuntando al bucket nuevo. E
  11. Cloud Scheduler recreado, con plazo de intento de 300 s, y verificado con una corrida manual. F
  12. Worker de Cloudflare conectado al repositorio, con VITE_API_BASE_URL. B2
  13. Dominio propio apuntado y agregado a CORS_ALLOWED_ORIGINS. B3
  14. Prueba de extremo a extremo: iniciar sesión, abrir Despachos, ver una imagen ya existente y confirmar que la marca de la última importación avanza.