Skip to content

Despliegue con Docker y Fly

源码版本v2026.6.11

Responsabilidad

El Dockerfile (Dockerfile CMD:343-344) es la entrada raíz del despliegue containerizado (containerization) de OpenClaw: un multi-stage build produce una imagen runtime mínima que por defecto arranca node openclaw.mjs gateway. fly.toml (fly.toml) es el descriptor de despliegue en Fly.io, montando la imagen Docker en una vm Fly + volumen persistente. render.yaml (render.yaml) es la config equivalente en Render.com. docker-compose.yml (docker-compose.yml) orquesta dos servicios gateway + CLI, útil tanto para dev local como para self-host.

Esta forma de despliegue es complementaria a Daemon: servicio del sistema: dentro del contenedor no hay launchd/systemd/schtasks, el orquestador (Docker Engine / Fly machine / Render) es el «gestor de servicios del sistema», responsable del auto-arranque y reinicio tras crash. OpenClaw solo necesita exponer un endpoint de health check + aceptar la señal estándar (SIGTERM) para una salida limpia, el resto al orquestador.

Motivación de diseño

¿Por qué separar el despliegue containerizado en su propia capa?

  1. Imagen mínima: la imagen completa node:24-bookworm pesa 1GB+, pero el runtime solo necesita node:24-bookworm-slim + unos pocos paquetes de sistema como ca-certificates/curl/git/tini. El multi-stage build separa la capa de build (Bun/pnpm install + pnpm build:docker + pnpm ui:build) de la capa de runtime; la imagen final no lleva Bun, ni código fuente, ni dev dependencies, ni .d.ts/.map (prune d.ts and maps:149).
  2. Defaults seguros: USER node (USER node:327), corre como non-root; cap_drop: [NET_RAW, NET_ADMIN] + security_opt: [no-new-privileges:true] (cap_drop), el contenedor no puede abrir raw sockets ni escalar privilegios. Umask=0o077 protege permisos de archivos en modo daemon; en contenedor se logra el mismo efecto pre-creando directorios 0700 (install -d permissions:312-320).
  3. Health check integrado: HEALTHCHECK (HEALTHCHECK:341-342) invoca directamente /healthz, sin depender de scripts de probing externos — el orquestador (Docker Compose/Fly/Render) lo usa para decidir si el contenedor vive. /healthz es liveness, /readyz es readiness, con alias /health y /ready por compatibilidad.
  4. Multi-orquestador: fly.toml, render.yaml, docker-compose.yml son tres configs que cubren tres destinos de despliegue, pero por debajo todas usan la misma imagen — las diferencias están solo en cómo se declaran puertos, montajes y variables de entorno.

Archivos clave

  • Dockerfile build args:10-25OPENCLAW_EXTENSIONS="", OPENCLAW_BUNDLED_PLUGIN_DIR=extensions, base image digest pinned (node:24-bookworm / bookworm-slim / Bun).
  • Stage 1: workspace-deps:26-45 — solo copia la lista de package.json, evita que la capa main build se invalidate por cambios de código fuente irrelevantes.
  • Stage 2: build:48-99 — COPY del binario Bun, corepack enable, pnpm install --frozen-lockfile, reintentos del native addon matrix-sdk-crypto, pnpm build:docker + pnpm ui:build.
  • matrix-sdk-crypto retry:82-97 — 5 reintentos para descargar el native addon, porque el CDN de matrix-sdk-crypto-nodejs a veces es inestable.
  • runtime-assets:135-154pnpm prune --prod --offline, elimina dev dependencies, .d.ts, .map, node_modules/openclaw (auto-referencia).
  • Stage 3: runtime:156-189 — base bookworm-slim, instala ca-certificates curl git hostname lsof openssl procps python3 tini.
  • ca-certificates install:184-189 — sin este paquete el HTTPS outbound falla en TLS handshake (error setting certificate file).
  • OPENCLAW_INSTALL_BROWSER:252-262 — Playwright Chromium + Xvfb opcional, evita un install de 60-90s en cada arranque.
  • OPENCLAW_INSTALL_DOCKER_CLI:268-301 — Docker CLI opcional, necesario para sandbox con gestión de contenedores; fingerprint GPG con doble validación, exige exactamente una pub key.
  • CLI symlink:304-305ln -sf /app/openclaw.mjs /usr/local/bin/openclaw, evita el npm global write como non-root.
  • pre-create dir perms:312-320install -d -m 0700 -o node -g node, luego stat -c para verificar, previene que un directorio owned by root contamine el named volume al montar por primera vez.
  • ENV + USER + HEALTHCHECK + CMD:322-344NODE_ENV=production, USER node, HEALTHCHECK 3min interval + entrypoint tini + CMD ["node","openclaw.mjs","gateway"].
  • fly.tomlapp = "openclaw", primary_region = "iad", internal_port = 3000, auto_stop_machines = false (mantiene residente), min_machines_running = 1, shared-cpu-2x + 2GB, volume openclaw_data montado en /data.
  • render.yamlruntime: docker, plan: starter, healthCheckPath: /health, OPENCLAW_GATEWAY_TOKEN: generateValue: true (autogenerado), 1GB disk montado en /data.
  • docker-compose.yml — dos servicios openclaw-gateway + openclaw-cli, CLI usa network_mode: service:openclaw-gateway para compartir network namespace, monta ~/.openclaw y el directorio de secrets.

Flujo de datos

Los últimos pasos del arranque del contenedor (Dockerfile tail:322-344) son clave:

dockerfile
ENV NODE_ENV=production

# Security hardening: Run as non-root user
# The node:24-bookworm image includes a 'node' user (uid 1000)
USER node

# Start gateway server with default config.
# Binds to loopback (127.0.0.1) by default for security.
#
# IMPORTANT: With Docker bridge networking (-p 18789:18789), loopback bind
# makes the gateway unreachable from the host. Either:
#   - Use --network host, OR
#   - Override --bind to "lan" (0.0.0.0) and set auth credentials
#
# Built-in probe endpoints for container health checks:
#   - GET /healthz (liveness) and GET /readyz (readiness)
#   - aliases: /health and /ready
HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["tini", "-s", "--"]
CMD ["node", "openclaw.mjs", "gateway"]

ENTRYPOINT ["tini", "-s", "--"] es clave — tini es un init ligero que se encarga de reap zombies (zombie process) y reenvía señales correctamente al proceso Node. En un contenedor, PID 1 por defecto no maneja SIGCHLD; sin tini, los subprocesos ACP/subagent al salir se convertirían en zombies. tini también reenvía limpiamente el SIGTERM de docker stop a Node, dejando que el gateway haga una salida limpia en lugar de recibir SIGKILL a los 10s.

HEALTHCHECK usa el fetch builtin de Node (global desde Node 18), no necesita curl, invoca directamente /healthz. --start-period=15s da margen al bootstrap del gateway (cargar plugins, conectar canales); un probe cada 3 minutos es suficientemente oportuno sin ser ruidoso. --retries=3 significa que 3 fallos consecutivos marcan unhealthy, evitando falsos positivos por jitter.

El health check de docker-compose.yml es más agresivo: intervalo 30s, 5 retries, 20s start_period (compose healthcheck). El compose además override --bind lan por defecto, porque en bridge networking el bind a loopback no es accesible desde el host — es el pozo que el comentario del Dockerfile advierte explícitamente. Compose también fija los paths de variables de entorno del lado contenedor:

yaml
environment:
  HOME: /home/node
  OPENCLAW_HOME: /home/node
  # Pin container-side state, workspace, and config paths so host values written to
  # `.env` (used by Compose for the bind-mount source below) cannot leak
  # into runtime code that resolves these env vars inside the container.
  # Without this override, a macOS host path like /Users/<you>/.openclaw/...
  # imported from .env caused first-reply `mkdir '/Users'` EACCES failures
  # in Linux Docker (#77436).
  OPENCLAW_STATE_DIR: /home/node/.openclaw
  OPENCLAW_CONFIG_PATH: /home/node/.openclaw/openclaw.json
  OPENCLAW_CONFIG_DIR: /home/node/.openclaw
  OPENCLAW_WORKSPACE_DIR: /home/node/.openclaw/workspace

Este override arregla #77436 — el usuario escribe OPENCLAW_STATE_DIR=/Users/alex/.openclaw en .env, compose lo inyecta al contenedor, y el Node del contenedor intenta mkdir '/Users' y falla con EACCES. Por eso compose fuerza el override de estos paths del lado contenedor a /home/node/.openclaw; el path del host solo se usa como bind-mount source.

fly.toml (fly.toml) despliega la misma imagen a Fly.io:

toml
app = "openclaw"
primary_region = "iad"

[build]
dockerfile = "Dockerfile"

[env]
NODE_ENV = "production"
OPENCLAW_PREFER_PNPM = "1"
OPENCLAW_STATE_DIR = "/data"
NODE_OPTIONS = "--max-old-space-size=1536"

[processes]
app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"

[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = false   # mantiene residente
auto_start_machines = true
min_machines_running = 1
processes = ["app"]

[[vm]]
size = "shared-cpu-2x"
memory = "2048mb"

[mounts]
source = "openclaw_data"
destination = "/data"

Fly usa internal_port = 3000 para el health probe (corresponde al OPENCLAW_GATEWAY_PORT del compose), force_https = true deja al edge de Fly terminar TLS, auto_stop_machines = false + min_machines_running = 1 garantiza que las conexiones WebSocket largas no se corten por el scale-to-zero de Fly — esto es clave en un despliegue de agent: el gateway debe ser residente; si se cae, todas las sesiones de IDE/clientes se pierden. NODE_OPTIONS = "--max-old-space-size=1536" con 2GB de RAM de vm deja ~500MB para Bun/native addon/Playwright, evitando OOM kill.

Límites y fallos

  • Base image SHA pin: OPENCLAW_NODE_BOOKWORM_IMAGE/OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE/OPENCLAW_BUN_IMAGE todos con pin al digest SHA256 (digest pin:12-17). Dependabot actualiza estos digests blessed — el build de release consume snapshots auditados en lugar de tirar latest cada vez, garantizando reproducible build.
  • Reintentos de descarga matrix-sdk-crypto (matrix-sdk-crypto retry:82-97): el CDN del native addon de matrix-sdk-crypto-nodejs es inestable, puede exit 0 sin que el archivo .node se haya descargado. El código hace 5 reintentos + sleep $((attempt * 2)) backoff exponencial, y si al final sigue fallando exit 1. Solo se hace esta comprobación si OPENCLAW_EXTENSIONS incluye matrix.
  • A2UI cross-arch stub (a2ui stub:113-118): pnpm canvas:a2ui:bundle puede fallar en builds QEMU cross-arch (Apple Silicon construyendo amd64), el CI construye nativamente cada arch así que no le afecta. Aquí se cae a un stub (no fatal), dejando que el cross-build local no se caiga por el bundle UI.
  • OPENCLAW_PREFER_PNPM=1 (OPENCLAW_PREFER_PNPM:124): el UI build usa pnpm en lugar de Bun, Bun tiene problemas de compat en ARM/Synology.
  • prune --prod --offline (runtime-assets prune:140-154): primero pnpm store add para seedear tarballs, luego --config.offline=true prune, evita tener que ir a red durante el prune. Tras prune se ejecuta node scripts/check-package-dist-imports.mjs para validar los bounds de import, previene que aparezcan dependencias dev-only en la imagen prod.
  • Non-root pero pozo de permisos en /home/node/.config (config dir ownership:309-320): install -d sin -o node crea /home/node/.config como root:root, luego los subdirectorios openclaw heredan root, y el contenedor corriendo como node no puede escribir (#85968). El código primero hace install -d -m 0755 -o node -g node /home/node/.config y luego crea subdirectorios, validando con stat -c que los permisos cumplen.
  • Docker CLI GPG single key enforcement (docker gpg single key:283-289): cuando OPENCLAW_INSTALL_DOCKER_CLI=1, tras descargar la Docker apt signing key se corre gpg --show-keys --with-colons para contar líneas pub, exigiendo exactamente 1 — archivos multi-key se rechazan, previniendo «apt confía en la primera key del archivo pero la que realmente importa es otra».
  • Bind-mount path leak (compose env override): los paths macOS del .env del host no pueden entrar al contenedor, así que compose fuerza el override de cuatro variables — OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH/OPENCLAW_CONFIG_DIR/OPENCLAW_WORKSPACE_DIR — a paths fijos bajo /home/node. El bind-mount source usa ${HOME}/.openclaw, el lado host se puede seguir especificando.
  • Semántica residente de Fly: auto_stop_machines = false + min_machines_running = 1 es un hard requirement para desplegar un agent gateway — Fly por defecto escala a cero para ahorrar, pero una conexión WebSocket larga cortada pierde todas las sesiones de IDE/clientes. El despliegue en Fly debe desactivar explícitamente el auto-stop. shared-cpu-2x + 2GB es la config mínima razonable; por debajo de esa memoria Node se va a OOM.
  • Render autogenera token: en render.yaml, OPENCLAW_GATEWAY_TOKEN: generateValue: true hace que Render autogenere e inyecte — evita que el usuario hardcodee token en yaml. healthCheckPath: /health usa el alias en lugar de /healthz, más común en la documentación de Render.
  • cap_drop: [NET_RAW, NET_ADMIN] + no-new-privileges: aunque el contenedor se compromise, no puede abrir raw sockets (previene port scanning/ARP spoofing) ni escalar privilegios con setuid. Es la baseline de defense-in-depth del despliegue containerizado; el modo daemon no puede alcanzar este nivel de aislamiento.

Resumen

El despliegue containerizado sustituye launchd/systemd/schtasks por Docker Engine/Fly machine/Render, pero la división de responsabilidades de OpenClaw no cambia: el proceso gateway sigue corriendo node openclaw.mjs gateway (ver Núcleo del gateway), solo que el entrypoint envuelve tini para señales + zombies, y el health check se apoya en los endpoints builtin /healthz//readyz. Los tres destinos de despliegue (Docker Compose local self-host / Fly.io cloud / Render.com cloud) comparten el mismo Dockerfile, con diferencias solo en cómo el orquestador monta volúmenes, define recursos e inyecta tokens. Para despliegue en bare metal o VM sin Docker se puede ir por Daemon: servicio del sistema e instalar directamente como servicio del sistema; el archivo de config (openclaw.json) es el mismo en ambas formas, ver openclaw.json.