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
| Herramienta | Para qué | Verificación |
|---|---|---|
| Docker + Compose v2 | Construir la imagen y correr el entorno local | docker compose version |
| Google Cloud SDK | Aprovisionar y operar GCP | gcloud version |
| Node.js 24 + pnpm 10.18 | Construir el frontend | node -v · pnpm -v |
| Wrangler CLI | Operar Cloudflare Workers | wrangler whoami |
| Cliente PostgreSQL 17 | Backup y restore de la base | pg_dump --version |
| Python 3.11/3.12 | Correr manage.py fuera de Docker | python --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.
| Variable | Obligatoria | Para qué |
|---|---|---|
DB_PASSWORD | Sí | Contraseñ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_SSLMODE | No | Tienen valores por defecto apuntando a la instancia actual. Definirlas explícitamente al migrar de instancia. |
GCS_PROJECT_ID | Sí | Proyecto del bucket de archivos. |
SECRET_KEY | Sí | Clave de firma de Django (sesiones, tokens). Rotarla invalida las sesiones abiertas. |
DEBUG · ALLOWED_HOSTS | Sí | DEBUG=False en producción; hosts permitidos separados por coma. |
FRONTEND_URL | Sí | URL pública del frontend; se usa para construir los enlaces de los correos. |
CRON_SECRET | Sí | Secreto que valida el disparo del cron de tareas vencidas (cabecera X-Cron-Secret). |
ANTHROPIC_API_KEY · ANTHROPIC_MODEL | Solo Chat Nisira | Motor del chat analítico sobre el datawarehouse. |
OPENAI_API_KEY · WHISPER_MODEL | Solo voz | Transcripción de audio en el chat. |
ELEVENLABS_API_KEY · ELEVENLABS_VOICE_ID · ELEVENLABS_MODEL | Solo voz | Síntesis de voz de las respuestas. |
ANALYTICS_CHAT_ENABLED · ANALYTICS_CHAT_MAX_TOOL_ITERATIONS | No | Interruptor y tope de iteraciones del Chat Nisira. |
DISPATCH_SOURCE_BASE_URL · DISPATCH_SOURCE_USERNAME · DISPATCH_SOURCE_PASSWORD | Sí | Conexión al datawarehouse Nisira que alimenta la importación. |
APPROVAL_NOTIFY_EMAILS · OVERDUE_NOTIFY_EMAILS · DELETE_NOTIFY_EMAILS | No | Destinatarios de las notificaciones por correo (CSV). Tienen valores por defecto en el código que conviene reemplazar en el traspaso. |
MEDIA_URL_OVERRIDE · USE_GCS | No | Ayudas 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:
- Abrir un pull request contra
main. - Hacer merge. Cloud Build arranca solo.
- Esperar el check
hermetica-backenden 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)
- En el panel de Cloudflare: Workers & Pages → Create → Connect to Git y elegir el repositorio del frontend.
- Configurar la build:
- Comando de build:
pnpm install --frozen-lockfile && pnpm build - Directorio de salida:
dist - Rama de producción:
main
- Comando de build:
- Definir la variable de build
VITE_API_BASE_URLcon la URL pública de la API (por ejemplohttps://…run.app/api). Es la única variable que necesita el frontend. - Guardar. A partir de ahí, cada push a una rama genera un preview y cada
merge a
maindespliega 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:
- Agregar la zona (por ejemplo
hermetica.pe) a la cuenta de Cloudflare, o delegar solo el subdominio. - 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. - Agregar ese origen a
CORS_ALLOWED_ORIGINSenhermetica_dir/settings.pydel backend y desplegar. Si se omite este paso, el sitio carga pero todas las llamadas a la API fallan por CORS. - Actualizar
FRONTEND_URLen 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.
| Atributo | Valor |
|---|---|
| Servicio | Google Cloud Scheduler · us-central1 |
| Frecuencia | */30 6-21 * * * · zona America/Lima |
| Destino | GET /api/dispatch/import/ en el servicio de Cloud Run |
| Qué hace | Pide un token al datawarehouse y ejecuta los comandos import_group e import_dispatch |
| Duración típica | 2 a 4 minutos · del orden de 2.000 órdenes de venta y 5.000 de producción por corrida |
| Concurrencia | Protegida 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
- Proyecto de GCP creado, facturación vinculada y APIs habilitadas. A1
- Instancia de Cloud SQL, base y usuario creados; backups automáticos activos. A2
- Bucket de Cloud Storage con la política de acceso decidida. A3
- Repositorio de Artifact Registry creado. A4
- Roles de IAM otorgados a la cuenta de servicio de Cloud Build. A5
- Disparador de Cloud Build apuntando a
main. A6 - Variables de entorno cargadas en el servicio de Cloud Run. A7
- Primera build en verde y
/health/respondiendo. A8 - Datos restaurados en la base nueva si es una migración. D
- Archivos del bucket copiados y
GCS_BUCKET_NAMEapuntando al bucket nuevo. E - Cloud Scheduler recreado, con plazo de intento de 300 s, y verificado con una corrida manual. F
- Worker de Cloudflare conectado al repositorio, con
VITE_API_BASE_URL. B2 - Dominio propio apuntado y agregado a
CORS_ALLOWED_ORIGINS. B3 - 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.