Guía9 min

expose vs ports: el webhook no publica 5432 en el VPS

Resumen

Cómo no abrir Postgres al mundo: ports publica en el host, expose solo documenta el puerto interno. Distinto de networks internal. Caddy sí tiene 80/443; la DB no. Techos oficiales Compose spec, Engine y Dockerfile, verificados con curl el 3 de septiembre de 2026.

Docker
Puerto publicado en el host versus expose interno entre servicios

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.

ports: ["5432:5432"] en postgres es un imán de scanners. expose: ["5432"] no publica en el host; los servicios de la misma red igual hablan por el nombre. Distinto de bind 0.0.0.0: eso es dentro del proceso; esto es el mapa host. Fuentes oficiales consultadas el 3 de septiembre de 2026.

La regla

Prod: ports solo en el reverse-proxy (80/443) o el webhook si no hay Caddy. Postgres, redis, adminer: cero ports. profiles si adminer existe.

127.0.0.1:5432:5432 es menos peor (solo loopback del VPS). Sigue siendo visible con ss. Prefiere sin ports + networks.

expose no es firewall

El spec de Compose (05-services.md, curl 2026-09-03): expose define el puerto o rango interno que el contenedor ofrece. Esos puertos deben ser accesibles a servicios enlazados y no deben publicarse en la máquina host. Solo el puerto del contenedor; sintaxis <puerto>/[<proto>] o <inicio-fin>/[<proto>]. Sin proto, TCP.

Nota del spec: si el Dockerfile ya tiene EXPOSE, el puerto es visible a otros contenedores de la red aunque no declares expose en el yaml. expose no abre iptables ni cierra UFW.

Dockerfile EXPOSE (docs oficiales, curl hoy): informa que el proceso escucha esos puertos. No publica. Es documentación entre quien construye la imagen y quien la corre. Para publicar hace falta -p / ports. -P / --publish-all publica todos los EXPOSE a puertos altos efímeros del host (ip_local_port_range). TCP y UDP con -P no comparten el mismo puerto host.

CLI docker run --expose 80: expone sin publicar en las interfaces del host. No es un sustituto de ports ni de un firewall.

ports: short y long

Compose ports (docs + spec, curl hoy): mapea al host. No combinar con network_mode: host — error en runtime porque el contenedor ya ve la pila del host.

Short: [HOST:]CONTAINER[/PROTOCOL]. HOST es [IP:](puerto|rango), opcional. Si omites la IP, bindea todas las interfaces (0.0.0.0). Docs Compose: eso salta las reglas de firewall del host y deja el contenedor en internet si el VPS tiene IP pública. Protocolo default TCP. HOST y CONTAINER deben usar rangos equivalentes. Solo el puerto del contenedor ("3000") deja que el runtime asigne un puerto host libre.

HOST:CONTAINER siempre como string entre comillas: YAML interpreta 80:80 como float base-60. IPv6 entre corchetes ("[::1]:6001:6001").

Long (spec):

  • target: puerto del contenedor.
  • published: puerto público, string; rango start-end = el runtime elige uno libre dentro.
  • host_ip: si falta, 0.0.0.0.
  • protocol: tcp o udp, default tcp.
  • app_protocol: pista L7; Compose 2.26.0.
  • mode: host (publica en cada nodo Swarm) o ingress (balanceo). Default ingress.
  • name: etiqueta humana.

Swarm: los servicios se exponen siempre en 0.0.0.0. Cambiar el bind default del daemon no aplica a Swarm.

Qué publica el Engine

Port publishing (curl hoy): docker run -p 8080:80 crea NAT/PAT entre 8080 en cualquier dirección del host y el 80 del contenedor. Sin -p, en bridge los puertos se ven desde el host y desde contenedores de la misma red; no desde fuera ni, con la config default, desde otros bridges.

-p 127.0.0.1:8080:80 + -p '[::1]:8080:80': solo el host Docker. Warning oficial: en Engine anterior a 28.0.0, hosts del mismo segmento L2 podían alcanzar puertos publicados a localhost (moby/moby#45610). Actualiza el daemon o no trates 127.0.0.1: como aislamiento de red.

CLI: -p 80:80 (sin IP) publica en 0.0.0.0. UFW denegando ese puerto no basta: Docker escribe sus propias reglas iptables.

How-to de Compose: entre servicios se usa el CONTAINER_PORT. El HOST_PORT solo vale desde fuera de la red. Ejemplo oficial: postgres://db:5432 entre contenedores; localhost:8001 en el host si publicaste 8001:5432.

Tabla

ports en el host versus red interna

KnobPublica en el hostVisible entre serviciosTecho verificado
ports: ["443:443"]sí, 0.0.0.0short; IP omitida = todas las ifaces
ports: ["127.0.0.1:5432:5432"]loopback del VPSshort HOST IP; Engine < 28.0.0 filtra mal L2
expose: ["5432"]nosí (red Compose)spec: no publicar al host
Dockerfile EXPOSE 5432no, salvo -Psí aunque no haya expose yamlDockerfile + nota spec
-P / --publish-allpuertos efímerosCLI; rango ip_local_port_range
network_mode: host + portserror runtimen/aspec + docs Compose
Caddy 80/443how-to HOST_PORT
postgres / redisnopostgres:5432how-to CONTAINER_PORT
host_ip omitido (long)0.0.0.0spec long
Swarm mode defaultingress en 0.0.0.0n/aspec; Swarm ignora bind default

Errores comunes

5432 en 0.0.0.0

SíntomaCausaFix
nmap 5432 abiertoports sin host_ipquita ports de postgres
laptop no conecta psqlsin portscorrecto en prod; profile dev
3000 y 443 ambosdoble publicaciónsolo Caddy
UFW deny 5432 y nmap igualiptables de Dockerquita ports; UFW no gana
network_mode: hostcopy de laptoperror si mezclas ports; no lo uses en el webhook
Fly services copiado de Composeno hay ports Composehttp_service / internal_port en fly.toml
yaml 80:80 sin comillasfloat base-60"80:80"
-P en prodpublica todos los EXPOSEnunca; lista explícita

Relación con el resto

Checklist

  • docker compose config sin 5432 en ports
  • ss -lntp en el host: 22 y 443, no 5432 ni 3000 si hay Caddy
  • docker compose port postgres 5432 no imprime 0.0.0.0:5432
  • webhook solo 443, no 443+3000
  • adminer sin ports; profile tools si hace falta
  • strings entre comillas; nada de 80:80 desnudo
  • Engine ≥ 28.0.0 si usas 127.0.0.1: como “solo yo”
  • nmap al VPS desde fuera, no desde el propio box

FAQ

¿Workers? No hay ports Compose.

¿Railway? Un servicio público; la DB en red privada. No copies 5432:5432.

¿!reset ports? No. Lista explícita.

¿UDP 443? No hace falta para webhook HTTPS.

El ensayo: ss -lntp en el VPS. Solo 22 y 443. compose exec agent nc postgres 5432 ok.

Qué no hacer

No dejes ports de un compose.yml de laptop en prod. preview vs prod o un override compose.prod.yml que borra 5432.

No publiques 3000 “por si Caddy cae”. Entonces el agente queda HTTP plano. Caddy o nada.

UFW 5432 deny + ports igual DNAT a docker0: confunde. Quita ports.

mode: host en long syntax de Swarm publica en cada nodo. En un VPS Compose no lo necesitas.

Debug laptop

Profile dev con 127.0.0.1:5432:5432. Prod yaml sin esa clave. Un solo archivo con profiles > dos archivos si te pierdes.

El ensayo extra: compose config en el yaml de prod y busca published. Solo 80/443.

El hub de seguridad, coste y operación agrupa el resto del runtime. Sin el bot en el aire: curso.