Cómo desplegar un agente de IA con Docker paso a paso
Resumen
Guía para desplegar un agente de IA con Docker en un VPS: Dockerfile multi-stage para Node con imagen final pequeña, claves por entorno en runtime (nunca en la imagen), HEALTHCHECK y restart policy para auto-recuperación, logs que no llenan el disco y exposición del webhook. Con tabla de decisión pm2 vs Docker y checklist de cierre.

Qué resuelve
Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.
Elegir dónde corre el agente es media decisión. La otra mitad es cómo se empaqueta para que corra igual en tu laptop y en un VPS de 5 USD, sin instalar dependencias a mano ni heredar el estado de la máquina. Docker resuelve exactamente eso: el contenedor lleva su propia versión de Node, sus dependencias compiladas y tu código; el host solo necesita el runtime. Esta guía arma el paquete mínimo que un agente en producción necesita: imagen multi-stage pequeña, claves fuera de la imagen, reinicio automático tras un fallo, chequeo de salud y logs que no llenen el disco.
Si aún no decidiste dónde desplegar, empieza por la comparativa de Vercel, Cloudflare Workers o VPS: los límites de tiempo de un serverless empujan al VPS + Docker cuando el agente hace trabajo largo o mantiene estado en disco. Y si el gasto por token te preocupa, revisa cuánto cuesta correr un agente: mover el hosting a un VPS plano cambia poco ese número, el costo real está en la API del modelo.
Por qué Docker y no un proceso con pm2
pm2 corre tu proceso directamente en el host. Si otro proyecto del mismo VPS actualiza dependencias, compila un paquete nativo o sube la versión de Node, tu agente hereda el daño. Con Docker cada proyecto lleva su propio Node y sus dependencias aisladas. Las ventajas concretas:
| Criterio | pm2 (proceso en host) | Docker (contenedor) |
|---|---|---|
| Versiones de Node | Una compartida por proyecto | Una por imagen, congelada |
| Dependencias nativas | Compilan contra el host | Compilan una vez en el build |
| Mismo entorno en staging y prod | Difícil de garantizar | La imagen es idéntica |
| Rollback | Reinstalar dependencias viejas | Correr el tag anterior de la imagen |
| Límites de memoria/CPU | Frágiles (reinicio del proceso) | Nativos (--memory, --cpus) |
| Curva de aprendizaje | Baja | Media: Dockerfile, build, volúmenes |
El costo de Docker es una capa más de abstracción y aprender tres o cuatro comandos. Para un agente que debe correr semanas sin que lo toques, el intercambio vale la pena.

Requisitos
- Un VPS con Docker Engine instalado (Ubuntu 22.04/24.04 o Debian 12). La instalación oficial está en docs.docker.com, sección Engine, y son dos comandos. Si publicarás un webhook necesitas acceso SSH y los puertos 80/443 libres.
- El código del agente: un proyecto Node/TypeScript con scripts
buildystarten elpackage.json. El patrón sirve igual para Python cambiando la imagen base. - Las claves del agente (API key del modelo, token del bot, URL de base de datos) a mano para pasarlas en runtime, nunca dentro de la imagen.
Paso 1 — Dockerfile multi-stage
Un build multi-stage usa una imagen "builder" con todo el toolchain (dev dependencies, compiladores) y una imagen final que solo copia lo que corre. Docker Docs lo documenta como el estándar para imágenes ligeras. Para un agente el beneficio es directo: la imagen final no arrastra dev dependencies ni cachés, arranca más rápido, descarga más rápido si algún día la subes a un registry y expone menos superficie ante un atacante.
Crea un Dockerfile en la raíz del agente:
# ── Etapa 1: builder (todo el toolchain) ──────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── Etapa 2: runtime (solo lo que corre) ──────────────
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
# El usuario no-root 'node' ya existe en la imagen oficial
USER node
COPY --from=builder --chown=node:node /app/package*.json ./
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
Qué hace cada decisión clave:
node:22-alpinecomo base: alpine es la variante mínima (~50 MB base vs ~200 MB de la completa). Fija la versión mayor (22): no useslatest, porque un rebuild meses después puede traer una versión incompatible.npm cien vez denpm install: exige lockfile y produce instalaciones reproducibles. Si tu repo usa pnpm, cambia la base a una imagen con corepack habilitado y usapnpm install --frozen-lockfile; el patrón multi-stage es idéntico.USER node: la guía de best practices oficial de nodejs/docker-node recomienda correr como non-root. Si el contenedor se compromete, el atacante no es root dentro del contenedor.CMD ["node", "dist/index.js"]en forma exec (JSON array), noCMD npm start: la forma exec hace que node sea el proceso 1 del contenedor y reciba las señales del sistema (SIGTERM endocker stop). Connpm startel PID 1 es npm, que no siempre reenvía señales: el stop se vuelve lento o forzado.COPY --from=builderdenode_modules: necesario si el agente usa dependencias en runtime (Express, SDK del modelo). Si compilaste un bundle único (esbuild/tsup) basta copiardisty elpackage.json.
Guarda también un .dockerignore junto al Dockerfile, o el COPY . . arrastrará basura al build:
node_modules
dist
.git
.env
.env.*
*.log
La línea de .env es crítica: si el COPY . . la lleva a la imagen, cualquier persona con acceso al registry tiene tus claves.
Paso 2 — Construir y correr en el VPS
En tu máquina local (o en CI):
docker build -t agente-ia:1.0 .
docker run --rm agente-ia:1.0 node -e "console.log('build ok')"
Luego transfiere la imagen al VPS. Dos caminos válidos: subir la imagen a un registry (Docker Hub, GHCR) y hacer docker pull en el servidor — el estándar cuando hay más de un servidor o CI — o construir directamente en el VPS con git pull && docker build. Para un primer despliegue, construir en el VPS es lo más simple.
En el servidor:
git pull
docker build -t agente-ia:1.0 .
docker run -d \
--name agente \
--restart unless-stopped \
-p 3000:3000 \
--env-file /srv/agente/agente.env \
--memory 512m --cpus 0.5 \
agente-ia:1.0
Qué significa cada flag:
-d: corre en background (detached).--restart unless-stopped: si el contenedor se cae o el VPS se reinicia, Docker lo levanta de nuevo. Es el "auto-heal" básico; manualmente equivale a lo que hace systemd con un servicio, pero acá va incluido en el runtime del contenedor.-p 3000:3000: publica el puerto del contenedor en el host. Docker Docs documenta que solo los puertos publicados son accesibles desde fuera; todo lo demás queda aislado. Si el webhook entra por HTTPS en 443 vía Nginx o Caddy en el host, ese proxy se conecta alocalhost:3000y puedes omitir el-py usar una red Docker compartida — menos superficie expuesta.--env-file: inyecta las claves en runtime, como variables de entorno dentro del contenedor.--memoryy--cpus: cotas duras. Un agente con un loop desbocado se detiene en lugar de tumbar el resto del VPS.
Paso 3 — Claves en runtime, nunca en la imagen
El .env del proyecto no va dentro del contenedor. Las claves entran como variables de entorno en docker run, y eso tiene tres ventajas:
- Puedes rotar la API key sin reconstruir la imagen:
docker stop→docker runcon el env-file nuevo. - La imagen es la misma en staging y producción; solo cambia el env-file.
docker inspectdel contenedor muestra las variables solo a quien tenga acceso root en el VPS, no a quien descargue la imagen.
Crea /srv/agente/agente.env en el servidor (fuera del repo):
OPENAI_API_KEY=sk-...
TELEGRAM_BOT_TOKEN=123:ABC...
DATABASE_URL=postgres://...
PORT=3000
Y protégelo:
chmod 600 /srv/agente/agente.env
NODE_ENV=production conviene ponerlo en el Dockerfile (ya lo hicimos) porque afecta el build del código, no solo el runtime. Las demás claves son secretos por entorno: solo al runtime.
Paso 4 — HEALTHCHECK y auto-recuperación
Un contenedor "corriendo" puede estar muerto por dentro: el proceso vive pero se colgó esperando una respuesta que nunca llega. La instrucción HEALTHCHECK del Dockerfile le dice a Docker cómo distinguir vivo de zombie:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
Para que funcione, el agente necesita un endpoint mínimo:
// dist ya compilado: en el fuente del servidor Express
app.get("/health", (_req, res) => res.json({ ok: true }));
El estado visible con docker ps pasa de Up 3 hours a Up 3 hours (healthy) — o (unhealthy) tras 3 fallos consecutivos. Con esa señal:
- Monitoreo externo: UptimeRobot o similar apuntando a un endpoint de salud público, o un cron en el VPS que consulte
docker inspect --format='{{.State.Health.Status}}' agente. - Reacción automática:
--restartcubre proceso muerto; para el caso zombie necesitas algo que ejecutedocker restartal ver(unhealthy). Un cron de una línea basta para empezar. La versión completa (autoheal container, orquestador) tiene sentido con varios servicios.
El chequeo de salud es el que convierte "creo que está vivo" en un dato verificable. Sin esto, el primer aviso de caída es un usuario quejándose en el canal.
Paso 5 — Logs sin llenar el disco
docker logs acumula todo lo que el proceso imprime. Un agente verboso (cada mensaje del usuario, cada llamada al modelo) puede generar gigabytes en semanas y llenar el disco del VPS. La solución es rotación nativa del logging driver:
docker run -d \
--name agente \
--restart unless-stopped \
--log-driver json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
...
Eso acota los logs a 30 MB por contenedor, rotando en 3 archivos. Como alternativa permanente, fija el default en /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
Después de editar daemon.json: sudo systemctl restart docker (ojo: reinicia los contenedores con --restart automáticamente).
Comandos de operación diaria:

docker logs -f --tail 100 agente # seguir en vivo los últimos 100 líneas
docker stats agente # CPU/RAM en vivo
docker restart agente # tras cambiar env-file
docker ps -a # estado + health de todos
Checklist de cierre
Antes de dar el despliegue por terminado:
-
docker buildtermina sin warnings de credenciales y la imagen final < 300 MB. - El contenedor corre como non-root (
docker exec agente whoami→node). - Ningún
.envdentro de la imagen (docker run --rm agente-ia:1.0 ls -la /app | grep envno muestra nada). -
docker inspect agente --format '{{.HostConfig.RestartPolicy.Name}}'→unless-stopped. -
curl localhost:3000/health→{"ok":true}ydocker psmuestra(healthy). - Los logs tienen rotación configurada (30 MB tope).
- El webhook del canal (WhatsApp/Telegram/Slack) apunta a
https://tu-dominio/webhooky responde 200. - Rollback probado una vez:
docker runcon el tag anterior funciona.
FAQ
¿Necesito Kubernetes o Docker Compose? No para un agente. Compose ayuda cuando tienes agente + base de datos + proxy en el mismo host y quieres describirlos en un solo archivo YAML; es un siguiente paso natural pero no un requisito. Kubernetes resuelve problemas de escala que un solo VPS no tiene todavía.
¿Puedo usar el mismo contenedor para varios agentes? Mejor no: un contenedor por agente da aislamiento de fallos, límites de recursos independientes y rollback por agente. Compose orquesta varios contenedores en el mismo host sin dolor.
¿Dónde vive el estado (conversaciones, colas)? Fuera del contenedor: un volumen Docker (-v agente-data:/app/data) para archivos locales o la base de datos del paso anterior. Si destruyes el contenedor, el volumen sobrevive; lo que está dentro del filesystem del contenedor no.
¿Cómo actualizo el agente sin downtime? Construye la imagen nueva con otro tag, corre el contenedor nuevo en otro puerto, verifica health, y cambia el proxy al puerto nuevo. Con un solo VPS y un webhook, un docker stop && docker run de 10 segundos también es aceptable para empezar.
El empaquetado en contenedor es la mitad mecánica de tener el agente en producción. La otra mitad — detectar fallos, medir costos y responder cuando algo se rompe — la cubre la guía de observabilidad para agentes.
Lecturas relacionadas
Sigue explorando Deploy y otras piezas para builders.



