Guía9 min

Bind 0.0.0.0 y puertos del agente IA en Docker

Resumen

Cómo no dejar el bot sordo en el contenedor: Node debe escuchar 0.0.0.0, no 127.0.0.1. Compose publica 3000:3000, Fly y Railway mapean el puerto interno. Localhost dentro del box no es el localhost de tu Mac. Healthcheck al mismo bind.

DockerVercelRailway
Puerto publicado del contenedor frente a un proceso que solo escucha localhost

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.

El contenedor está “up” y Telegram no llega. El proceso escuchó 127.0.0.1:3000. Dentro de Linux eso es solo el loopback del box. Compose publica 3000 en el bridge; el paquete nunca entra al proceso. Healthchecks pegan a localhost dentro del mismo netns, así que el check puede ser verde y el mundo no. Fuentes oficiales consultadas el 3 de septiembre de 2026.

La regla

Prod: listen(PORT, "0.0.0.0"). PORT de env (Fly/Railway/Vercel lo inyectan). No hardcodees 3000 si el platform usa 8080.

Dev en laptop: 127.0.0.1 está bien. El mismo default en Docker es el incidente.

Node

net.Server.listen (docs Node, consultadas hoy): si omites host, el server acepta en la dirección IPv6 no especificada (::) cuando hay IPv6, o en 0.0.0.0 si no. En la mayoría de OS, escuchar :: también escucha 0.0.0.0. Eso no es lo mismo que 127.0.0.1. Si pasas "127.0.0.1" a listen, el kernel no entrega paquetes que llegaron por el bridge de Docker.

app.listen(3000) en Express delega a eso: host omitido ≠ loopback. Sé explícito: 0.0.0.0 en prod, 127.0.0.1 si NODE_ENV !== "production" y no hay BIND_HOST.

HOST=0.0.0.0 en env de Fly. Documenta la variable junto a secretos (no es secreto, es config).

Compose / Docker

Docs de port publishing (hoy): docker run -p 8080:80 mapea el 8080 de cualquier dirección del host al 80 del contenedor. --publish / -p crea la regla de firewall. Si incluyes la IP de loopback (127.0.0.1 o ::1) en el publish — docker run -p 127.0.0.1:8080:80solo el host Docker llega a ese puerto; el mundo no. Eso es correcto para un sidecar de debug, no para un webhook de Telegram.

Compose, sintaxis larga de ports (hoy): host_ip es el bind en el host. Si no lo pones, Compose bindea todas las interfaces (0.0.0.0). target es el puerto dentro del contenedor; published es el del host. El proceso tiene que escuchar 0.0.0.0 en target. Si escuchas 8080 adentro y publicas 3000:3000, silencio.

expose sin ports no sale al Mac. Útil para red privada entre servicios.

Fly / Railway / Vercel

Fly http_service (fly.toml, hoy): internal_port default 8080 — es el puerto del proceso, no el 443 público. El ejemplo oficial pone force_https = true, auto_stop_machines = "stop", auto_start_machines = true, min_machines_running = 0, y concurrency soft_limit = 200 / hard_limit = 250. Si Node escucha 3000 y internal_port sigue en 8080, Fly Proxy devuelve 502. El health del proxy pega a internal_port, no a tu HEALTHCHECK de Dockerfile.

Railway (variables, hoy) inyecta RAILWAY_PUBLIC_DOMAIN, RAILWAY_PRIVATE_DOMAIN y RAILWAY_TCP_PROXY_PORT. El hostname público no es el puerto del proceso. Si el servicio escucha solo loopback, el proxy de Railway no entra: mismo incidente que Compose. No inventes un PORT que no leíste en docs; lee el que el runtime te muestra en el deploy y bindea ese.

Vercel Functions no “escuchan” un puerto largo: el runtime invoca el handler. No copies listen(0.0.0.0) a un Worker ni a un Route Handler.

Healthcheck

HEALTHCHECK a http://127.0.0.1:3000/health está bien dentro del contenedor (mismo netns). El probe de Fly/Railway al internal_port usa la red del platform. Si listen es solo 127.0.0.1, el probe externo falla y el local pasa. Alínea: 0.0.0.0 + el mismo PORT.

Tabla

Proceso en loopback versus puerto publicado

DóndeBind (docs 2026-09-03)Público
Laptop dev127.0.0.1no
docker run -p 8080:80proceso en 0.0.0.0:80host 8080 en todas las IPs
docker run -p 127.0.0.1:8080:80igual adentrosolo el Docker host
Compose sin host_ip0.0.0.0 en el hosttodas las interfaces
Fly http_serviceproceso = internal_port (default 8080)80/443 del proxy
Railwayproceso alcanzable por el proxyRAILWAY_PUBLIC_DOMAIN
Worker / FunctionN/Afetch / handler

Errores comunes

Health interno verde y Telegram sin respuesta

SíntomaCausaFix
Timeout de fuera, curl local ok127.0.0.10.0.0.0
Puerto publicado vacíolisten 8080, map 3000mismo número
Fly 502internal_port ≠ listenfly.toml = PORT
IPv6 onlyNode :: vs A recorddual / 0.0.0.0
Preview sordoenv HOST no setdefault prod 0.0.0.0

Relación con el resto

Checklist

  • Prod escucha 0.0.0.0
  • PORT de env, no magia
  • fly.toml / Compose usan ese puerto
  • Health interno y proxy al mismo PORT
  • Worker/Function sin listen copiado
  • Ensayo: curl desde fuera del contenedor

FAQ

¿localhost en el Dockerfile HEALTHCHECK? Sí, es el box. ¿localhost en setWebhook de Telegram? No: hostname público.

¿Firewall host 3000 abierto? Preferible Caddy/Fly en 443 y 3000 solo en bridge.

¿IPv6? Si el proxy habla v6, escucha :: o dual. Mide; no copies StackOverflow.

ss / netstat

Dentro del contenedor: ss -lntp (o netstat) debe mostrar *:3000 o 0.0.0.0:3000, no 127.0.0.1:3000. Si ves solo loopback, el publish de Compose es teatro.

Caddy/nginx en el mismo Compose: el upstream es http://agent:3000, no localhost:3000 (localhost sería el proxy). Nombre del servicio en la red bridge.

El ensayo: docker run -p 3000:3000 y curl localhost:3000/health en el Mac. Si solo funciona docker exec curl 127.0.0.1, el bind está mal.

HOST=127.0.0.1 en un .env de ejemplo que Compose carga en prod es el pie. Separa .env.development (dockerignore para no hornearlo).

Un agente sordo no es “Telegram caído”. Es listen. Revisa bind antes de rotar tokens. ss tarda 2 segundos; rotar el bot, 20 minutos.

Si usas network_mode: host, 0.0.0.0 es el host de verdad: no publiques 3000 en internet sin Caddy. El modo host anula el aislamiento del bridge. Para un agente, bridge + ports es el default sano.

Node con host omitido no te salva de un HOST=127.0.0.1 en el .env que Compose carga. El default de listen ( :: / 0.0.0.0 ) solo aplica si no pasas host. Un wrapper tipo listen(process.env.PORT, process.env.HOST) con HOST de desarrollo es el pie en prod.

Fly: no confundas min_machines_running = 0 con “el puerto está mal”. Autostop apaga la Machine; el primer request la arranca. Eso es cold start, no bind. Si todas las requests fallan con 502 y fly logs muestra el proceso en 127.0.0.1:8080, entonces sí es bind. El soft_limit 200 / hard_limit 250 del ejemplo oficial es concurrencia del proxy, no el puerto.

El publish 127.0.0.1:3000:3000 en un VPS “para que no quede abierto” es coherente si Caddy está en el mismo host y habla con localhost. Si Caddy está en otro contenedor, ese publish no le sirve: Caddy ve el bridge, no el loopback del host. Ahí o publicas en 0.0.0.0 solo en la red Compose, o pones a Caddy en network_mode compartido. Mide con curl desde el contenedor de Caddy, no desde el Mac.

Siguiente paso: si el puerto ya responde y el bot no, firmar webhooks y TLS. Sin runtime: curso.