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.

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:80 — solo 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

| Dónde | Bind (docs 2026-09-03) | Público |
|---|---|---|
| Laptop dev | 127.0.0.1 | no |
docker run -p 8080:80 | proceso en 0.0.0.0:80 | host 8080 en todas las IPs |
docker run -p 127.0.0.1:8080:80 | igual adentro | solo el Docker host |
Compose sin host_ip | 0.0.0.0 en el host | todas las interfaces |
Fly http_service | proceso = internal_port (default 8080) | 80/443 del proxy |
| Railway | proceso alcanzable por el proxy | RAILWAY_PUBLIC_DOMAIN |
| Worker / Function | N/A | fetch / handler |
Errores comunes

| Síntoma | Causa | Fix |
|---|---|---|
| Timeout de fuera, curl local ok | 127.0.0.1 | 0.0.0.0 |
| Puerto publicado vacío | listen 8080, map 3000 | mismo número |
| Fly 502 | internal_port ≠ listen | fly.toml = PORT |
| IPv6 only | Node :: vs A record | dual / 0.0.0.0 |
| Preview sordo | env HOST no set | default prod 0.0.0.0 |
Relación con el resto
- TLS delante: dominio.
- Check: healthchecks.
- USER no-root no cambia el bind: no-root.
- Apagar: kill switch.
Checklist
- Prod escucha
0.0.0.0 -
PORTde env, no magia - fly.toml / Compose usan ese puerto
- Health interno y proxy al mismo PORT
- Worker/Function sin
listencopiado - Ensayo:
curldesde 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.
Lecturas relacionadas
Sigue explorando Deploy y otras piezas para builders.



