Ctrl K
ComunidadGuías…

docs/deploy.es.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
---
source_sha: "0d080c65b077"
---

# Despliega en un servidor { #deploy-to-a-server }

Un host, Docker Compose y un proxy inverso siguen siendo la vía publicada para
un solo servidor. Para clústeres, usa el [chart Helm de Kubernetes](kubernetes.md)
mantenido, con servicios de datos externos y un Job de migración bloqueante.
La configuración de OpenShift es una propuesta todavía sin validar. No hay
quickstarts de un clic para proveedores PaaS.

Más allá de un host - un Postgres gestionado, varios hosts de API, varios
despliegues - está [Escalar más allá de un host](scaling.md).

!!! tip "Lee antes la [lista de comprobación para producción](configuration.md#production-checklist)"

    Nueve ajustes se publican con valores por defecto que están bien en un
    portátil y mal en un host al que puede llegar otra persona.
    `scripts/server-init.sh`, más abajo, genera los nueve, así que la lista es lo
    que compruebas después y no lo que escribes.

## Qué necesitas { #what-you-need }

| | |
|---|---|
| **Un host** | 4 vCPU y 8 GB de RAM lo hacen funcionar. Ver [dimensionado](#sizing-the-host) |
| **Motor de contenedores** | Engine 24+ con el plugin Compose (2.24 o posterior), y tu usuario en el grupo `docker`. En el host no se construye nada: las imágenes se descargan de GHCR. Como alternativa, usa la [ruta manual de Podman](#podman-support). |
| **Dos nombres de host** | uno para el sitio, otro para la API — ver [por qué dos](#why-two-hostnames) |
| **Un proxy inverso** | [Traefik](#option-a-traefik) o [Nginx](#option-b-nginx). Termina el TLS |
| **Ninguna clave de provider** | los modelos de chat y los embeds llevan ambos su clave por organización, en el vault del producto. El entorno no guarda credenciales para ninguno de los dos — consulta [Protección de datos](data-protection.md#nothing-leaves-by-default) |

El host también necesita los puertos 80 y 443 abiertos, y nada más. Postgres y
Redis no se publican en ninguna interfaz, y el worker no sirve nada.

### Por qué dos nombres de host { #why-two-hostnames }

El navegador habla con los dos. La mayoría de las llamadas pasan por las rutas de
servidor del propio frontend, pero el WebSocket del chat se conecta directamente
a la API, así que la API necesita un nombre que un navegador pueda resolver y un
certificado propio.

`app.example.com` y `api.example.com` es la forma. Pueden ser dos nombres
cualesquiera; lo que no pueden ser es un solo nombre con un prefijo de ruta,
porque las cookies de la API y las del sitio están limitadas al host.

### Compatibilidad con Podman { #podman-support }

**Podman está soportado mediante la instalación manual con Docker Compose como
proveedor.** Se probaron Podman 6.1.4 y Docker Compose v2.40.3 en Fedora CoreOS
44 (VM de Podman en macOS, ARM64, cgroups v2, SELinux enforcing). El archivo
distribuido requiere Compose 2.24 o posterior.

**No uses `podman-compose` 1.6.0.** Arrancó el stack y un agente respondió, pero
una migración que falló de forma controlada no impidió iniciar la app dependiente.
Docker Compose rechazó correctamente ese inicio. Los proveedores tienen versiones
independientes; `podman-compose` 1.6.0 no es Docker Compose 1.6.0.

El inicio rápido sigue limitado a Docker y dirige Podman detectado a esta ruta
manual, también con `--no-sandbox`. Desde la raíz del repositorio configurado,
elige el ejecutable real de Docker Compose (binario independiente o plugin CLI):

```bash
export PODMAN_COMPOSE_PROVIDER=/absolute/path/to/docker-compose
podman compose version
podman compose -f docker-compose.yml up -d
```

Comprueba que `podman compose version` identifica Docker Compose. El `-f`
explícito excluye el override local de compilación desde fuentes; esta
instalación usa imágenes publicadas. Tras iniciar los servicios, sigue
[Instalación](install.md) para configurar la organización y el modelo.

**Sandbox opcional.** `pydantic-ai-backend` 0.2.39 funcionó con sockets Podman
rootful y rootless. No necesita un backend ni una versión nuevos. Usa
`docker-compose-podman.yml` para adaptar los montajes del socket y workspace;
prepara sus permisos antes de activar el perfil.

En Linux nativo con Podman rootless:

```bash
systemctl --user enable --now podman.socket
export SANDBOX_DOCKER_SOCKET="$XDG_RUNTIME_DIR/podman/podman.sock"
export SANDBOX_WORKSPACE_ROOT="$HOME/.local/share/agenticos/sandbox-workspaces"
mkdir -p "$SANDBOX_WORKSPACE_ROOT"
podman unshare chown 10001:10001 "$SANDBOX_WORKSPACE_ROOT"
export DOCKER_GID=0
```

Para Linux rootful, activa el `podman.socket` del sistema, establece
`SANDBOX_DOCKER_SOCKET=/run/podman/podman.sock` y prepara el workspace con
`sudo install -d -o 10001 -g 10001 "$SANDBOX_WORKSPACE_ROOT"`. Dirige Compose a
ese motor rootful. El grupo del socket suele ser 0; para un socket rootful
personalizado, indica su grupo real en `DOCKER_GID`.

En una VM Podman rootless en macOS, ambos orígenes bind pertenecen a la VM Linux,
no a macOS. Prepáralos dentro de esa VM:

```bash
export SANDBOX_DOCKER_SOCKET="$(podman machine ssh 'printf "%s/podman/podman.sock" "$XDG_RUNTIME_DIR"')"
export SANDBOX_WORKSPACE_ROOT=/tmp/agenticos-sandbox-workspaces
podman machine ssh 'mkdir -p /tmp/agenticos-sandbox-workspaces && podman unshare chown 10001:10001 /tmp/agenticos-sandbox-workspaces'
export DOCKER_GID=0
```

Conserva las claves existentes del despliegue. Configura un `SANDBOXD_TOKEN`
no vacío y `SANDBOX_URL=http://sandboxd:8080` en el archivo env que lee el stack,
como se describe en [configuración de sandbox](sandbox.md). Después, inicia con
ambos archivos:

```bash
podman compose -f docker-compose.yml -f docker-compose-podman.yml \
  --profile sandbox up -d
```

El override desactiva el etiquetado SELinux solo para `sandboxd`, que ya posee
el socket del motor. Comparte la etiqueta del workspace con `:z`, pues también
lo montan las sandboxes hermanas. Los contenedores sandbox conservan sus etiquetas
y `no-new-privileges`; no cambies la etiqueta del socket ni desactives SELinux
globalmente. En rootless, la propiedad del socket se mapea al grupo 0 dentro
del espacio de nombres de usuario.

En un servidor usa una ruta persistente para el workspace: `/tmp` es solo un
ejemplo local. Conserva los ajustes de socket, workspace y grupo en el entorno
del operador para los siguientes comandos Compose. `DOCKER_HOST` en el host no
cambia el montaje del socket dentro de `sandboxd`.

Se verificaron un run por HTTP con finalización persistida, archivos de workspace,
códigos de error, timeouts, límites RAM/CPU/PID efectivos y builds de runtimes.
La imagen workbench distribuida leyó/escribió XLSX, generó un gráfico y ejecutó
Node y LibreOffice. El modelo fue un servidor de prueba determinista compatible
con OpenAI; no se probaron el runtime desktop ni todas las distribuciones Linux.
El [informe de pruebas](https://github.com/vstorm-co/agenticos/blob/main/docs/audits/podman-2026-11-03.md)
registra versiones, alcance y reproducción del fallo de migración.

## Dimensionar el host { #sizing-the-host }

Medido sobre un despliegue en reposo, no estimado:

| | en reposo | techo |
|---|---|---|
| `app` (2 workers de uvicorn) | ~1,0 GB | 2,5 GB con los 4 workers por defecto |
| `db` | ~1,3 GB con el ajuste de abajo | 2 GB |
| `worker` (un proceso hijo de ingesta) | 387 MiB | 2,5 GB (`WORKER_MEMORY_LIMIT`) |
| `frontend` | ~300 MB | 1 GB |
| `redis` | 9 MiB | 512 MB |

El número que decide el host es **`UVICORN_WORKERS`**. Cada worker es un proceso
aparte que importa la aplicación entera — 460 MiB, creado con spawn y no con
fork, así que no se comparte nada. Cuatro de ellos son 1,9 GB antes de que llegue
una petición.

Dos workers bastan para un equipo de diez y aun así dejan uno atendiendo mientras
[el watchdog](configuration.md#a-worker-whose-event-loop-has-stopped-turning)
reemplaza a un hermano atascado. Un solo worker es el ajuste que hay que evitar:
un bucle de eventos bloqueado es entonces todo el despliegue, hasta que se mata a
sí mismo.

Un host al que le falta CPU muestra otro síntoma: líneas `Child process [N] died`
en el log de `app` sin un veredicto del watchdog encima, mientras todas las
páginas van lentas. Son workers que el supervisor de uvicorn mató por responder
tarde a su ping, no workers que fallaron. Espera
[`UVICORN_WORKER_HEALTHCHECK_TIMEOUT`](configuration.md#a-worker-whose-event-loop-has-stopped-turning)
segundos, 60 por defecto. Mira el steal de CPU del host antes de subirlo.

Dos ajustes mantienen diagnosticable un despliegue lento:

- **Cada log tiene un tope.** Cada servicio de los ficheros de compose de
  producción escribe como mucho cinco ficheros de 20 MB. Por defecto, Docker
  guarda el log de un contenedor hasta que el contenedor se elimina, y nada en el
  host lo rota.
- **Postgres registra lo que cuesta cada sentencia.** El servicio `db` precarga
  `pg_stat_statements` y una migración crea la extensión. Donde el rol de la base
  de datos no puede crearla, la migración registra un aviso y sigue.
  `agenticos cmd doctor` imprime una línea `query statistics` en cualquier caso.

!!! note "La base de datos está ajustada contra su propio límite"

    `docker-compose-prod.yml` ejecuta Postgres con `shared_buffers=512MB` frente
    a un límite de 2 GB, y le da 512 MB de `/dev/shm` — el valor por defecto de
    Docker es 64 MB, que un escaneo en paralelo sobre los vectores de una
    colección agota, informando `could not resize shared memory segment`. Si
    mueves el límite, mueve el ajuste con él; están escritos uno al lado del otro
    por esa razón.

    También fija `max_connections=200`, porque los pools pueden abrir más que
    los 100 de serie de Postgres: dos pools de 15 por worker de uvicorn, o sea
    120 con los cuatro por defecto, y 30 más para el worker de tareas en segundo
    plano. Súbelo junto con `UVICORN_WORKERS` o `DB_POOL_SIZE`, o Postgres
    rechazará conexiones con «too many clients» antes de que un pool pueda
    encolarlas o descartar la carga.

## Apunta los nombres al host { #point-the-names-at-the-host }

Dos registros A, antes que nada. Let's Encrypt comprueba que controlas un nombre
descargando un archivo por HTTP desde donde ese nombre resuelva, así que no se
emite ningún certificado hasta que esto sea cierto y se haya propagado.

```
app.example.com   A   203.0.113.10
api.example.com   A   203.0.113.10
```

!!! warning "Un comodín no hace esto por ti"

    Donde `*.example.com` ya apunta a algún sitio — normalmente una web de
    marketing — los dos nombres resuelven ahí. Un registro para el nombre
    concreto gana al comodín, así que el arreglo es añadir los dos de arriba, no
    quitar el comodín.

Compruébalo desde algún sitio que no sea el host, porque el host puede tener su
propia respuesta:

```bash
dig +short app.example.com api.example.com
```

## Llévalo al host { #get-it-onto-the-host }

```bash
sudo install -d -o "$USER" -g "$USER" /opt/agenticos
git clone https://github.com/vstorm-co/agenticos.git /opt/agenticos
cd /opt/agenticos
bash scripts/server-init.sh
```

`server-init.sh` escribe `backend/.env`: genera los cinco secretos, pide los dos
nombres de host y una dirección para Let's Encrypt, y deriva las URL públicas y
el origen CORS de lo que le hayas dado. No pide ninguna clave de provider: esas
viven en el vault de cada organización. Se niega a sobrescribir un archivo
existente.

El clon es donde viven los archivos de compose y ese archivo de entorno; de él no
se ejecuta ningún código. Lo que se ejecuta son las dos imágenes que publica el
repositorio.

### Las imágenes { #the-images }

| | |
|---|---|
| `ghcr.io/vstorm-co/agenticos-backend` | La API, el worker y las migraciones - una imagen, tres comandos |
| `ghcr.io/vstorm-co/agenticos-frontend` | La consola |

Las dos se construyen para `linux/amd64` y `linux/arm64` en
`.github/workflows/images.yml`. Una release (`v0.0.380`) publica `0.0.380` y
mueve `latest`; cada commit en `main` publica `edge` y `sha-<short>`. Los
archivos de compose leen la etiqueta de `AGENTICOS_VERSION` en `backend/.env` y
por defecto usan `latest`.

Tres reglas que mantiene el workflow, y las tres conviene conocerlas antes de
fiarse de una etiqueta:

- **Solo se publica un commit de `main`.** Una etiqueta `v*` empujada desde una
  rama, o una ejecución lanzada sobre ella, se rechaza antes de construir nada -
  así que `latest` no puede pasar la frontera de la pull request.
- **Una release sobre un commit que `main` ya construyó no se reconstruye.** Su
  manifiesto `sha-<short>` recibe la versión y `latest` como nombres adicionales,
  así que los digests a los que un host se fijó son exactamente los que nombra la
  release.
- **A un commit sin imágenes se le pueden dar.** Ejecuta el workflow a mano con
  su entrada `sha` - `gh workflow run images.yml --ref main -f sha=<commit>` - y
  publica la etiqueta `sha-<short>` de ese commit y nada que se mueva. Ese es el
  camino para un commit más antiguo que el workflow, y para uno cuya ejecución se
  perdió.

!!! warning "Fija una release en un host que te importe"

    `AGENTICOS_VERSION=0.0.380` en `backend/.env`, para que `make prod` en un mal
    día descargue lo que funcionaba ayer y no lo que se publicó esta mañana.
    `scripts/deploy.sh` la fija por ti - a la etiqueta `sha-` del commit que
    despliega - exactamente mientras dura el despliegue.

    Fíjala desde el checkout de esa release: `git checkout v0.0.380` junto a la
    variable. La imagen del servicio de sandbox está fijada en el archivo compose,
    en la versión que habla el backend del mismo commit, así que un backend más
    antiguo ejecutado desde un checkout más nuevo se encuentra con un `sandboxd`
    más nuevo - y `latest` desde un checkout antiguo es el mismo desajuste al
    revés. `scripts/deploy.sh` hace checkout del commit que despliega, así que un
    despliegue o una vuelta atrás con el script mantiene los dos juntos.

Los dos paquetes se descargan de forma anónima. Si una descarga responde
`unauthorized`, el paquete se ha hecho privado o hay un `docker login ghcr.io`
caducado en medio; ninguna de las dos cosas la puede arreglar un host por sí
solo.

!!! danger "`backend/.env` guarda la clave que descifra todas las credenciales almacenadas"

    `VAULT_MASTER_KEY` es lo que hace legibles las claves de provider de una
    organización, los tokens de bot y las credenciales MCP. Perderla no te deja
    fuera del producto; hace irrecuperable cada secreto que hay en él. Guarda una
    copia del archivo en algún sitio que un disco perdido no se lleve consigo, y
    rótala con [`agenticos cmd vault-rotate`](secrets.md#operations) en vez de
    editando.

Dos cosas opcionales por las que no pregunta, las dos en ese archivo: `SMTP_*`,
sin el cual no se pueden enviar invitaciones ni restablecimientos de contraseña,
y `LOGFIRE_TOKEN`, que es adonde van las trazas de los runs de los agents.

## Elige un proxy inverso { #choose-a-reverse-proxy }

Algo tiene que terminar el TLS y enrutar los dos nombres. Las dos opciones de
abajo llegan a los mismos contenedores; elige según si ya tienes uno en marcha.

!!! note "Deja la compresión a la aplicación"

    La API comprime con gzip sus propias respuestas, el proxy `/api/*` de la
    consola vuelve a comprimir para el navegador lo que la API comprimió, y Next
    comprime sus páginas. Así que el proxy no necesita `gzip on`, y no debe vaciar
    el `Accept-Encoding` del cliente: eso solo apaga la compresión. Una respuesta
    que ya lleva `Content-Encoding` pasa tal cual por Nginx y por Traefik.

### Opción A: Traefik { #option-a-traefik }

El camino más corto, y el que hay que elegir en un host que ya tiene Traefik: los
contenedores llevan etiquetas, Traefik los descubre, pide el certificado y lo
renueva. Nada que recargar y ningún segundo archivo de configuración que mantener
sincronizado.

Si Traefik aún no está, el repositorio trae uno: `traefik/traefik.yml` y
`docker-compose-traefik.yml`, que es un entrypoint en el 443 con un resolver de
Let's Encrypt y el 80 redirigiendo hacia él.

```bash
docker network create traefik_webgateway
docker compose --env-file backend/.env -f docker-compose-traefik.yml up -d
```

Donde Traefik **ya** está funcionando, deja esos archivos en paz y apunta
`TRAEFIK_NETWORK` a la red que vigila. Los overlays leen ese nombre, así que no
hay que cambiar nada del proxy existente.

Después levanta el stack con `PROXY=traefik`, que añade los dos archivos de
overlay que llevan las etiquetas:

```bash
make prod PROXY=traefik
make prod-frontend PROXY=traefik
```

`server-init.sh` ya ha escrito `PROXY=traefik` en `backend/.env`, que es de donde
lo lee `scripts/deploy.sh` — así que los despliegues posteriores conservan el
proxy con el que se configuró este host y no el que asumiera un script.

!!! info "`exposedByDefault: false` está haciendo trabajo de verdad"

    Es el único ajuste de `traefik/traefik.yml` que merece la pena leer antes de
    ejecutarlo. Solo `app` y `frontend` llevan `traefik.enable=true`, así que
    Postgres, Redis, el worker y el demonio de la sandbox no son
    alcanzables desde nada fuera del host — y eso es una propiedad de *no estar
    etiquetados*, así que sobrevive a que alguien añada un servicio sin pensar en
    el proxy.

!!! warning "Algunas rutas y query strings de petición son credenciales"

    Estos segmentos de ruta abren algo a quien los lea:

    - la clave de un enlace público, en `/a/<key>`, `/api/v1/public/artifacts/<key>`
      y `/api/v1/artifact-embed/<key>`, hasta que se rota el enlace;
    - una dirección de contenido firmada, `/api/v1/artifact-content/<token>`,
      mientras dura su vigencia;
    - el token de una conversación compartida, en `/shared/<token>` y
      `/api/v1/conversations/shared/<token>`;
    - el token de un formulario de workflow, en `/f/<token>` y
      `/api/v1/public/workflow-forms/<token>`;
    - el token de una prueba de webhook, en `/api/v1/workflow-webhook-tests/<token>`
      y `/api/v1/workflows/<id>/webhook-test/<token>`;
    - el token de un enlace de canal, en `/link/<token>` y
      `/api/v1/me/channel-link/<token>`;
    - el token de una invitación, en `/invitations/<token>`,
      `/api/v1/invitations/<token>` y `/api/v1/invitations/<token>/accept`;
    - el token de reanudación de una ejecución de workflow, en
      `/api/v1/workflow-resume/<run_id>/<token>`.

    Y también estos parámetros de consulta:

    - `visitor` y `token` en el socket del widget del agente,
      `/api/v1/embed/<key>/ws`: la clave de una página alojada, que reanuda la
      conversación del visitante, y el token de visitante firmado del cliente;
    - `code` y `state` en el retorno de un proveedor de inicio de sesión,
      `/api/v1/oauth/<provider>/callback`, y `code` en el `/auth/callback` de
      la consola;
    - `invitation_handle` en `/api/v1/oauth/<provider>/login` y
      `/api/v1/auth/kerberos/login`, que canjea la invitación que representa;
    - `token` en `/reset-password`, `/auth/magic-link` y `/auth/email-change`
      de la consola, los enlaces que envían un restablecimiento de contraseña,
      un enlace de inicio de sesión y un cambio de correo.

    Una petición hecha desde una de esas páginas puede llevar su dirección en la
    cabecera `Referer`. La API sustituye los segmentos de la API y los valores
    de esos parámetros por `[REDACTED]` en su propio log de acceso, en la línea
    de uvicorn de cada WebSocket aceptado, en cada registro de log y en los
    spans de Logfire, incluidos la salida de consola, los argumentos del
    endpoint y la copia que Logfire hace al empezar un span. Los demás
    parámetros de consulta, como `q`, `skip`, `limit` y los ids, se conservan.

    Traefik puede conservar, eliminar o redactar un campo entero, pero no
    reescribir una parte de él, así que `traefik/traefik.yml` elimina
    `RequestPath`, que incluye la query string, del log de acceso de
    Traefik. Nginx sí puede reescribir parte de un valor, pero
    `nginx/nginx.conf` elimina la ruta, la query string y el `Referer` de
    la misma manera, con el formato de log `agenticos_no_path` que nombra cada
    bloque `server`, para que la lista de rutas secretas se mantenga en un solo
    sitio. Ambos logs siguen teniendo el cliente, el estado, el tamaño y la
    duración. El log de errores de Nginx escribe la línea de petición y el
    `Referer` de una petición fallida, y ningún ajuste lo cambia, así que
    mantén el log de errores en el host. Si necesitas las rutas en el log de
    acceso, quita esa regla de campo o ese formato de log y trata el log como un
    secreto: guárdalo en el host y no lo envíes a un servicio de logs.

### Opción B: Nginx { #option-b-nginx }

Para un host donde Nginx ya termina el TLS, o donde el proxy no está en Docker en
absoluto. El stack publica los dos puertos en `127.0.0.1` y Nginx llega a ellos
ahí:

```bash
make prod
make prod-frontend
```

`nginx/nginx.conf` es la plantilla. Dos sustituciones antes de que sirva nada: el
`server_name` de cada bloque es `${DOMAIN:-localhost}`, y Nginx no lo expande —
pon los dos nombres de host a mano. Los certificados son tuyos de obtener y
renovar, y también lo es la cabecera `Strict-Transport-Security`, que el backend
deja deliberadamente a lo que termine el TLS. El log de acceso de la plantilla deja
fuera la ruta de la petición, la query string y el `Referer`; *Algunas
rutas y query strings de petición son credenciales*, más arriba, explica
por qué y qué sigue escribiendo el log de errores.

!!! warning "`BIND_HOST` es un ajuste de seguridad, no una comodidad"

    El valor por defecto de loopback es lo que hace que el límite de intentos de
    autenticación signifique algo. `RATE_LIMIT_TRUST_FORWARDED_FOR` le dice a la
    API que cuente un intento contra la dirección que reenvía el proxy, así que
    lo que pueda llegar a la API *saltándose* el proxy elige la dirección contra
    la que se cuentan sus intentos. Pon `BIND_HOST=0.0.0.0` solo para un proxy en
    otra máquina, y limita el puerto por firewall a esa máquina.

!!! warning "El frontend es un proyecto de compose propio"

    Compose nombra un proyecto según el directorio, así que los dos stacks eran
    `agenticos` — y levantar el frontend informaba entonces de los cinco
    contenedores del backend como **orphans**, con la propia sugerencia de
    compose de volver a ejecutar el comando con `--remove-orphans`. Seguir ese
    consejo detiene la API, la base de datos, Redis y el worker. Los targets de `make` y `scripts/deploy.sh` pasan
    `-p agenticos-frontend`, así que el aviso ha desaparecido. Los archivos de
    compose tampoco fijan nombres de contenedor - cada proyecto nombra los suyos,
    así que dos stacks en un host no pueden apropiarse de los contenedores del
    otro, y `deploy.sh` espera a los servicios `app` y `frontend` y no a un
    nombre. Un despliegue anterior a los dos arreglos se recrea con los nombres
    nuevos en su siguiente `up`; no hay que quitar nada a mano.

    Los dos proyectos se siguen encontrando en una red con un nombre fijo -
    `agenticos_edge` en producción, `agenticos_backend` en el servidor de
    desarrollo - porque el frontend se une a ella como red externa. Un host que
    ejecute **dos** stacks de AgenticOS separa `AGENTICOS_EDGE_NETWORK` y
    `AGENTICOS_DATA_NETWORK` (o `AGENTICOS_NETWORK`) en el `backend/.env` de cada
    stack; si no, los `db`, `redis` y `app` de ambos stacks resuelven en un mismo
    bridge, y una petición puede llegar a la base de datos del vecino.

## Arráncalo y crea la primera cuenta { #start-it-and-create-the-first-account }

`make prod` descarga las imágenes, arranca el stack y ejecuta las migraciones -
lo último como un servicio `migrate` al que espera la API, así que un
`docker compose up -d` a mano sobre los mismos archivos hace lo mismo. La primera
descarga son unos 2 GB; una posterior son las capas que hayan cambiado.

Después crea una organización, un propietario (owner) y un agent que funcione:

```bash
docker compose --env-file backend/.env -f docker-compose-prod.yml \
  exec -T app agenticos cmd bootstrap \
  --email you@example.com --password 'a real password' \
  --org 'Your Company' --provider anthropic --api-key sk-ant-...
```

La clave de provider que va aquí es con lo que funcionan los dos agents de
partida. Sin ella se crean y no pueden responder; cualquier otro
provider se añade en el producto, por organización, desde el vault.

!!! tip "Compruébalo desde fuera, no desde el host"

    ```bash
    curl -fsS https://api.example.com/api/v1/health
    curl -fsSo /dev/null -w '%{http_code}\n' https://app.example.com
    ```

    Un stack que está sano en el host e inalcanzable desde internet es DNS, el
    firewall o el certificado — tres cosas que una comprobación de salud dentro
    del host no puede ver.

Después, una vez, a mano: inicia sesión, invita a alguien (lo que demuestra
`SMTP_*`) y envía un mensaje a AgenticOS (lo que demuestra la clave
de provider y el WebSocket). Cada cosa recorre un camino que aquí no comprueba
nada más.

!!! info "Las cabeceras de seguridad vienen del backend, así que cualquier proxy queda cubierto"

    Una Content-Security-Policy, `X-Frame-Options: DENY`,
    `X-Content-Type-Options: nosniff`, `Referrer-Policy` y `Permissions-Policy`
    se ponen en cada respuesta — incluido el 500 de una excepción no controlada,
    que se construye fuera del stack de middleware y las estampa él mismo. La
    documentación interactiva de la API renuncia solo a la CSP, porque Swagger
    carga recursos que una política estricta prohíbe.

    **HSTS se deja deliberadamente al proxy**, que es donde termina el TLS. Un
    proxy que ponga su propia CSP debería ser al menos tan estricto como esta.

### Servicios en tu propia red { #services-on-your-own-network }

Una petición que configura un miembro - un paso HTTP de workflow, una fuente de
Git, de sitio web o de S3, un agente A2A o una conexión MCP - se rechaza cuando su host
resuelve a una dirección privada, así que una wiki, un servidor Git o un servidor
MCP dentro de tu red quedan fuera de alcance por defecto.

Para abrir uno, lístalo en `OUTBOUND_PRIVATE_ALLOWLIST` en `backend/.env`, como
host, `.suffix`, dirección o rango CIDR, y reinicia la API y el worker. La fila
de este ajuste en [la referencia de configuración](configuration.md#project-settings)
da las formas, lo que se rechaza sea cual sea tu lista y lo que puede alcanzar
entonces toda cuenta. Lista solo servicios que comprueben su propia credencial.

`agenticos cmd doctor` informa de la lista en su línea `private network`. Falla
cuando un rango listado cubre la base de datos o Redis, y avisa de un rango más
ancho que /16 (IPv4) o /48 (IPv6).

### Encender la sandbox { #turning-the-sandbox-on }

El servicio que ejecuta el código de un agent está detrás de un perfil de
compose, porque es el único contenedor que tiene el socket de Docker y montarlo
en un host compartido debería ser una decisión y no un valor por defecto. Tres
cosas, una vez:

```bash
make sandbox-token                       # writes SANDBOXD_TOKEN to backend/.env
sudo mkdir -p /var/lib/agenticos/sandbox-workspaces
sudo chown 10001:10001 /var/lib/agenticos/sandbox-workspaces
```

Después un despliegue la levanta: `scripts/deploy.sh` pasa `--profile sandbox`
cuando `SANDBOXD_TOKEN` en `backend/.env` tiene valor, así que es el propio host
quien dice si ejecuta una. Como `make` y quickstart, exporta `DOCKER_GID` al
`group_add` de la sandbox: el grupo del socket en Linux (siguiendo enlaces simbólicos)
o `0` en Docker Desktop, donde el socket montado pertenece a la VM. Un `DOCKER_GID`
explícito en el entorno o en la línea de comandos de `make` tiene prioridad. No hace falta nada más en `.env`: el backend llega al
demonio a través de una *conexión* de sandbox que alguien crea en la consola, y
`http://sandboxd:8080` se reconoce como propio de este despliegue.

!!! warning "Un perfil del que no se avisa a compose es un servicio que compose detiene"

    `up -d` sobre el mismo proyecto sin `--profile sandbox` no deja la sandbox en
    paz — la detiene. Así que a un host que la había arrancado a mano se la
    quitó su siguiente despliegue, con la ejecución de código de un agent
    fallando por motivos que no estaban ni cerca del despliegue que lo causó
    (#1506). Por eso el script lee el token en vez de aceptar un flag.

## Desplegar un cambio { #deploying-a-change }

### Memoria nativa y continuidad de rutinas { #native-memory-upgrade }

Antes de actualizar, crea [copias de la base, los medios y las claves del vault](#backups)
y coordina el despliegue de API y workers. Usa el paso normal de migración del
despliegue; no copies filas ni crees horarios sustitutos manualmente.

Antes de aplicar `0285_member_proactivity.py`, detén API y productores de barridos, espera a que finalicen los jobs proactivos activos y detén los workers. La migración rechaza jobs de modelo de vigilancia o nocturnos en ejecución y añade a los pendientes una clave compartida de concurrencia por persona. Actualiza API y workers juntos para que los nuevos jobs usen la misma clave; una vigilancia y una revisión nocturna de una persona no deben gastar independientemente el mismo presupuesto restante.

- `0248_drop_memory_mem0.py` detiene la actualización mientras `memory_mem0` siga
  en algún borrador guardado o spec publicada. Antes de reintentar, inventaría todos
  los endpoints y las asignaciones de credenciales del vault desde la copia,
  elimina los recuerdos externos y verifica la finalización en cada proveedor.
  Solo entonces retira explícitamente esos vínculos de `agents.draft_spec` y
  `agent_versions.spec`. Exportar no equivale a borrar. Conserva las pruebas de
  limpieza, revisa claves del vault sin uso y revoca credenciales retiradas.
  La migración no hace peticiones remotas ni presupone un borrado correcto.
  Si una versión de desarrollo anterior ya eliminó los vínculos, recupera el
  inventario desde la copia previa y realiza la misma limpieza; el borrado nativo
  de personas o agentes no alcanza esos datos externos.
- `0251_primary_conversations.py` y `0258_primary_channel_history.py` establecen
  chats privados principales. Una asignación existente prevalece. Solo se adopta
  una vez el historial demostrablemente privado de un canal vinculado; los
  historiales de proyecto, grupo o dudosos siguen separados. No repitas la carga
  después de que una persona vacíe su chat principal.
- Se conservan los ID de rutinas, horarios y registros existentes.
  `0255_trigger_delivery.py` asigna `run_log` a las rutinas antiguas; las nuevas
  usan por defecto el chat principal o su conversación de origen de confianza.
  Revisa destino, persona actuante, fin y presupuesto en **Rutinas**, en lugar
  de crear copias.
- `0259_trigger_version_pin.py` permite fijar versiones inmutables. Un pin y un
  entorno con nombre se excluyen. Retira el pin antes de borrar su versión.
  Un run aparcado conserva su versión admitida; `0260_routine_run_origin.py`
  conserva su origen para que borrar la rutina no libere su control de acceso.
- `0261_watch_attempt_status.py` comienza el registro explícito de intentos sin
  inventar éxitos o fallos anteriores. Una comprobación no disponible o
  incompleta no avanza la fecha de éxito. Revisa las observaciones existentes
  y su acceso a cuentas: las filas históricas no prueban cómo se otorgó el
  consentimiento original.

Elige explícitamente un modelo antes de activar el mantenimiento de memoria;
el briefing diario conserva los ajustes de modelo/presupuesto de la organización.
Desde `0262_watch_agent_sources.py`, las vigilancias ejecutan el agente elegido
y retienen las fuentes aprobadas en el run. Las existentes requieren seleccionar
un agente y aprobar fuentes; conservan sus IDs y hallazgos. Revisa
[gobernanza](governance.md#proactive-work) y la [configuración de canales](channels.md#sms),
incluidos `PUBLIC_BASE_URL` y los vínculos SMS caducados. En una organización de
prueba verifica un chat privado, Actividad/entrega de una rutina, pausa/caducidad
y una observación con fuente no disponible antes de ampliar el despliegue.

### El límite del formato del historial en 1.25.0 { #retry-history-upgrade }

La versión 1.25.0 adopta los resultados de herramientas `retried` y los mensajes
independientes `retry-feedback` de Pydantic AI 2.61. Los historiales y checkpoints
de runs existentes se leen sin migrar la base de datos ni volver a publicar agentes.
Los historiales con estas partes requieren el lector nuevo: un backend anterior no puede
reproducir estos formatos de mensajes.

Actualiza juntos todos los procesos de API y workers, drenando o deteniendo los
anteriores antes de que los nuevos acepten trabajo. No mezcles réplicas de backend
anteriores y nuevas con la misma base de datos. Después de guardar uno de los nuevos formatos de reintento,
no se admite volver atrás solo en el código a través de este límite; hay
que mantener el lector nuevo o restaurar un snapshot de la base de datos anterior
a la actualización.

### A mano { #by-hand }

```bash
remote=$(ssh you@your-host 'mktemp -t agenticos-deploy.XXXXXX')
ssh you@your-host "cat > $remote" < scripts/deploy.sh
ssh you@your-host "trap 'rm -f $remote' EXIT; bash $remote <commit-sha>"
```

`scripts/deploy.sh` trae ese commit, espera a las imágenes que CI publicó para él
(`sha-<short>`, normalmente ya están), las descarga, reinicia y espera a que los
dos contenedores se declaren sanos antes de devolver distinto de cero o no. Toma
un **commit** y no una rama, así que lo que se despliega es lo que se revisó y no
aquello a lo que `main` se haya movido desde entonces - y lo que se ejecuta es
byte a byte lo que construyó CI, en un host que nunca necesita la cadena de
herramientas.

!!! warning "Cópialo al host y ejecútalo allí — no lo canalices a `bash -s`"

    Bajo `bash -s` el script es la propia entrada estándar del shell, y el primer
    comando dentro de él que lee stdin se come el resto. `docker compose exec`
    reenvía stdin al contenedor incluso con `-T`, así que la migración se comió
    todo lo que había debajo, bash llegó a EOF y el despliegue salió con **0**
    sin haber construido nunca el frontend ni haber esperado a ningún contenedor.
    El sitio estaba caído y el despliegue en verde
    ([#1488](https://github.com/vstorm-co/agenticos/issues/1488)).

    Dos conexiones en lugar de una es lo que impide que el procedimiento pueda
    truncarse a sí mismo.

No es un despliegue sin interrupción. Compose recrea los contenedores cuya imagen
ha cambiado, así que el sitio no está disponible durante los pocos segundos que
eso lleva.

### Retención de imágenes de versiones { #release-image-retention }

Antes de descargar, el script elimina imágenes antiguas y sin uso únicamente de
`ghcr.io/vstorm-co/agenticos-backend`, `agenticos-frontend` y `agenticos-browser`.
Conserva dos identificadores adicionales de imágenes sin uso por repositorio,
empezando por los más recientes. Varias etiquetas SHA o de versión del mismo ID
cuentan como una imagen. Solo son elegibles las etiquetas `sha-<hex>` y las
versiones numéricas. Todos los alias de un ID eliminable se retiran juntos,
sin `--force`. `--no-prune` también impide eliminar implícitamente las imágenes
padre sin etiqueta.

Cada contenedor del host, en ejecución o detenido, protege su imagen, incluidos
otros proyectos Compose. La imagen de destino, cualquier etiqueta mutable o
manual y las etiquetas o digests de otro repositorio también protegen todo el
ID. Las imágenes de sandbox y entornos de ejecución, las imágenes sin etiqueta
y los volúmenes de la aplicación quedan fuera de esta limpieza. Estas
protecciones pueden conservar más imágenes que el cupo de imágenes sin uso.

El hash del directorio de despliegue separa los marcadores de varios despliegues
en el mismo host. Las etiquetas locales `deploy-<stack-hash>-current` y `deploy-<stack-hash>-rollback` conservan la última
imagen sana de cada componente y su predecesora tras reemplazar los contenedores.
En el primer uso, `deploy-<stack-hash>-current` se obtiene de los contenedores sanos de este
despliegue, no del checkout; varios ID sanos para un mismo componente rechazan
el despliegue. Las referencias avanzan solo después de superar las comprobaciones
de salud de los nuevos servicios. Los despliegues fallidos conservan las
referencias y repetir la misma imagen no sustituye su rollback. Las reversiones
siguen usando un SHA de commit aprobado; estas etiquetas locales son marcadores
de retención, no versiones del registro.

El script exige al menos **5 GiB libres** en el sistema de archivos del almacén
Docker antes de cada descarga y después de preparar los entornos opcionales.
Comprueba `DockerRootDir` y el almacén predeterminado `/var/lib/containerd` cuando
existe. Es un umbral mínimo, no una reserva ni una estimación del tamaño de las
imágenes; los almacenes containerd personalizados requieren supervisión aparte.
Las imágenes del backend y frontend se descargan antes de reiniciar cualquiera
de los servicios. Si falta espacio, el rechazo indica el sistema de archivos y
la capacidad disponible: libera espacio fuera de las protecciones o amplía el
almacenamiento y repite el despliegue aprobado. No implementes esta política
mediante un `docker image prune -a` global.

En Docker Desktop, el directorio de almacenamiento está dentro de la VM Linux.
El script mide su espacio libre mediante un contenedor temporal, de solo lectura
y sin red, usando una imagen del backend ya disponible localmente, preferentemente
la última sana. La prueba no puede descargar imágenes y el contenedor se elimina
automáticamente. También comprueba el sistema de archivos del host que contiene
el directorio de despliegue; supervisa por separado el de un disco VM
personalizado. Este procedimiento de actualización requiere una imagen local
del backend; completa primero la instalación inicial normal.

### Desde GitHub, con una aprobación { #from-github-with-an-approval }

`.github/workflows/deploy.yml` ofrece cada release estable publicado para desplegarlo y
espera a que alguien lo apruebe. Esa barrera es **un ajuste del repositorio, no
un paso del archivo** — sin ella, el workflow despliega cada release estable publicado sin
supervisión.

Configúralo una vez:

1. **Settings → Environments → New environment**, con el nombre `production`.
2. Marca **Required reviewers** y añade a quien pueda aprobar. Esa es la barrera.
3. Añade las variables del entorno: `SITE_URL`, `API_URL` y `APP_DIR` si el
   checkout no está en `/opt/agenticos`.
4. Añade los secretos de abajo.

!!! warning "Cancela un despliegue que no vayas a aprobar"

    Todas las ejecuciones comparten el grupo de concurrencia `deploy-production`,
    y una ejecución parada en la barrera de aprobación lo retiene. No caduca por
    sí sola — GitHub cancela una sin atender después de 30 días — así que hasta
    que alguien la apruebe o la cancele, los releases posteriores hacen cola detrás
    de una decisión que nadie va a tomar, y el servidor sigue ejecutando lo
    último que se desplegó.

    Así que un despliegue que has decidido no hacer se cancela, no se deja. Uno
    dejado esperando sobre un commit ya superado bloqueó aquí tres ejecuciones
    posteriores antes de que nadie se fijara en la cola en vez de en las
    ejecuciones.

    `deploy-queue.yml` es lo que ocurre cuando se deja de todos modos: cada seis
    horas cancela una ejecución de Deploy que lleve más de doce esperando una
    aprobación, y abre una incidencia que nombra esa ejecución y dice cuánto se ha
    alejado el último release estable del último despliegue correcto. No aprueba nada ni despliega
    nada — vacía la cola para que el siguiente release publicado llegue a la barrera, y dice
    que lo ha hecho. A mano, con `gh workflow run deploy-queue.yml`.

!!! danger "Aprobar desde la cola del entorno aprueba la ejecución **más antigua**"

    Es decir, la obsoleta. Una aprobación tardía dada ahí despliega el commit del
    momento en que la cola se atascó, no el que se acaba de empujar — así llegó a
    este servidor un commit de hace dos semanas. Aprueba desde la página de la
    ejecución del commit que quieres, o arranca una nueva con
    `gh workflow run deploy.yml --ref main -f ref=v1.34.3` y aprueba esa.

| Secreto | Qué |
|---|---|
| `DEPLOY_HOST` | La dirección del host |
| `DEPLOY_USER` | La cuenta a la que pertenece el checkout |
| `DEPLOY_SSH_KEY` | Una clave privada cuya mitad pública está en el `authorized_keys` de esa cuenta |
| `DEPLOY_KNOWN_HOSTS` | `ssh-keyscan your-host`, ejecutado desde algún sitio de confianza |

Genera la clave para esto y para nada más:

```bash
ssh-keygen -t ed25519 -N '' -C 'github-actions-deploy' -f deploy_key
ssh-copy-id -f -i deploy_key.pub you@your-host
ssh-keyscan your-host                    # → DEPLOY_KNOWN_HOSTS
cat deploy_key                           # → DEPLOY_SSH_KEY, then delete it locally
```

!!! note "La clave del host es un secreto en vez de un `ssh-keyscan` en el momento del despliegue"

    Escanear en el momento del despliegue confía en lo que sea que responda en
    esa dirección, que es justo lo que una clave de host existe para evitar.
    Escanea una vez, desde un sitio de confianza, y guarda la respuesta.

Entonces aparece una ejecución con **Review deployments**; aprobarla arranca el
job. `workflow_dispatch` ejecuta el mismo job contra una ref que tú indiques, que
es como se hace una vuelta atrás, y pasa por la misma aprobación.

## Copias de seguridad { #backups }

Un volumen importa, y no es obvio cuál:

| Volumen | Qué guarda | ¿Copia? |
|---|---|---|
| `postgres_data` | todo — agents, conversaciones, credenciales selladas | **sí** |
| `media_data` | archivos subidos, antes de la ingesta | sí |

La caché ya no tiene ningún volumen. Los buckets del límite de peticiones, las
marcas de deduplicación de los canales y las respuestas de pertenencia llevan
todas un TTL y se reconstruyen solas, así que Valkey se ejecuta con `--save ''` y
arranca vacío tras cada reinicio. Un host que ejecutó una versión anterior sigue
guardando un volumen `agenticos_redis_data` que ya no monta nadie;
`docker volume rm agenticos_redis_data` lo elimina.

```bash
docker compose --env-file backend/.env -f docker-compose-prod.yml exec -T db \
  sh -c 'pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB"' > "agenticos-$(date +%F).dump"
```

Los identificadores salen del propio entorno del contenedor en vez de estar
escritos, porque los dos son ajustes: un despliegue que hubiera cambiado
cualquiera de ellos obtendría, si no, un archivo vacío y un error que nadie lee
al pasar.

!!! danger "Una copia de la base de datos sin `backend/.env` no es una copia"

    Las credenciales que hay en ella están selladas con `VAULT_MASTER_KEY`.
    Restauradas junto a una clave distinta, cada clave de provider, token de bot
    y credencial MCP del volcado es ilegible — y el producto te lo dirá, negativa
    a negativa.

## Volver atrás { #rolling-back }

| | Cómo |
|---|---|
| **Código** | Despliega el commit anterior: `workflow_dispatch` con su sha, o `scripts/deploy.sh`. Las imágenes siguen en el registro, así que esto es una descarga y no una construcción. Un commit sin imágenes `sha-<short>` - más antiguo que `images.yml`, o con su ejecución perdida - se publica primero con `gh workflow run images.yml --ref main -f sha=<commit>`; el despliegue nombra ese comando cuando se rinde de esperar |
| **Esquema** | `agenticos db downgrade --revision=-1`, y después despliega el código que le corresponde |
| **Datos** | `pg_restore` del volcado, y después comprueba la migración que el código espera |

Volver el código atrás **a través de una migración es una decisión, no un
comando**. El código antiguo se encuentra con un esquema que nunca ha visto; si
eso funciona depende de la migración. Léela antes de dar nada por supuesto.

## Resumen { #recap }

- **Un host, Compose, un proxy delante.** Siete contenedores - uno de ellos
  ejecuta las migraciones y termina - dos de ellos con estado, y todos se
  descargan: en el host no se construye nada. Fija `AGENTICOS_VERSION`.
- **`UVICORN_WORKERS` decide lo que cuesta el host.** 460 MiB por worker, nada
  compartido. Dos para un equipo, cuatro para tráfico de verdad.
- **El DNS antes que todo lo demás.** No se emite ningún certificado hasta que
  los nombres resuelvan al host.
- **La aprobación es un ajuste del repositorio**, no una línea del workflow. Sin
  required reviewers en el entorno `production`, cada merge se despliega solo.
- **Copia `postgres_data` y `backend/.env` juntos.** Cualquiera de los dos sin el
  otro no es una restauración.