Despliega en un servidor¶
Un host, Docker Compose, un proxy inverso delante. Ese es todo el camino que se publica, y es el que hace funcionar los despliegues en los que se usa este proyecto.
No hay manifiestos de Kubernetes, ni quickstarts de un clic para los proveedores
de plataforma como servicio. No es modestia sobre la escala. El stack son seis
contenedores, dos de los cuales guardan estado, uno de los cuales puede arrancar
contenedores propios, y uno de los cuales es un Postgres que debe ser pgvector -
que ya es más de lo que modela un destino de despliegue a base de git push, y
una guía que fingiera lo contrario estaría describiendo un despliegue que nadie
ha ejecutado.
Lee antes la lista de comprobación para producción
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¶
| Un host | 4 vCPU y 8 GB de RAM lo hacen funcionar. Ver dimensionado |
| Docker | 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 |
| Dos nombres de host | uno para el sitio, otro para la API — ver por qué dos |
| Un proxy inverso | Traefik o 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 |
El host también necesita los puertos 80 y 443 abiertos, y nada más. Postgres, Redis y la API de Prefect no se publican en ninguna interfaz.
Por qué dos nombres de host¶
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.
Dimensionar el 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 |
prefect-runner |
241 MiB | 1,5 GB |
prefect-server |
245 MiB | 768 MB |
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 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.
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.
Apunta los nombres al 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.
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:
Llévalo al host¶
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¶
ghcr.io/vstorm-co/agenticos-backend |
La API, el runner de Prefect 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 etiquetav*empujada desde una rama, o una ejecución lanzada sobre ella, se rechaza antes de construir nada - así quelatestno puede pasar la frontera de la pull request. - Una release sobre un commit que
mainya construyó no se reconstruye. Su manifiestosha-<short>recibe la versión ylatestcomo 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 etiquetasha-<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ó.
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.
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.
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 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¶
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.
Opción 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.
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:
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.
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 servidor de Prefect 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.
Opción 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í:
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.
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.
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 los dos servicios de
Prefect. 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¶
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:
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 funciona el agent de demostración. Sin ella el agent se crea y no puede responder; cualquier otro provider se añade en el producto, por organización, desde el vault.
Compruébalo desde fuera, no desde el host
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 al agent de demostración (lo que demuestra la clave
de provider y el WebSocket). Cada cosa recorre un camino que aquí no comprueba
nada más.
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.
Encender la sandbox¶
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:
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. También exporta DOCKER_GID leído del socket — todos
los archivos de compose de aquí lo interpolan en el group_add de la sandbox, y
su valor por defecto 0 es el propietario del socket en casi ninguna
distribución de Linux. 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.
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¶
A mano¶
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.
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).
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.
Desde GitHub, con una aprobación¶
.github/workflows/deploy.yml ofrece cada merge a main 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 merge sin
supervisión.
Configúralo una vez:
- Settings → Environments → New environment, con el nombre
production. - Marca Required reviewers y añade a quien pueda aprobar. Esa es la barrera.
- Añade las variables del entorno:
SITE_URL,API_URLyAPP_DIRsi el checkout no está en/opt/agenticos. - Añade los secretos de abajo.
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 merges 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.
| 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:
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
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¶
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í |
redis_data |
buckets del límite de peticiones y cachés | no, todo reconstruible |
prefect_data |
el historial de ejecuciones de los flows | no |
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.
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¶
| 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¶
- 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_WORKERSdecide 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_dataybackend/.envjuntos. Cualquiera de los dos sin el otro no es una restauración.