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.

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; rangostart-end= el runtime elige uno libre dentro.host_ip: si falta,0.0.0.0.protocol:tcpoudp, defaulttcp.app_protocol: pista L7; Compose 2.26.0.mode:host(publica en cada nodo Swarm) oingress(balanceo). Defaultingress.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

| Knob | Publica en el host | Visible entre servicios | Techo verificado |
|---|---|---|---|
ports: ["443:443"] | sí, 0.0.0.0 | sí | short; IP omitida = todas las ifaces |
ports: ["127.0.0.1:5432:5432"] | loopback del VPS | sí | short HOST IP; Engine < 28.0.0 filtra mal L2 |
expose: ["5432"] | no | sí (red Compose) | spec: no publicar al host |
Dockerfile EXPOSE 5432 | no, salvo -P | sí aunque no haya expose yaml | Dockerfile + nota spec |
-P / --publish-all | puertos efímeros | sí | CLI; rango ip_local_port_range |
network_mode: host + ports | error runtime | n/a | spec + docs Compose |
| Caddy 80/443 | sí | sí | how-to HOST_PORT |
| postgres / redis | no | postgres:5432 | how-to CONTAINER_PORT |
host_ip omitido (long) | 0.0.0.0 | sí | spec long |
Swarm mode default | ingress en 0.0.0.0 | n/a | spec; Swarm ignora bind default |
Errores comunes

| Síntoma | Causa | Fix |
|---|---|---|
| nmap 5432 abierto | ports sin host_ip | quita ports de postgres |
| laptop no conecta psql | sin ports | correcto en prod; profile dev |
| 3000 y 443 ambos | doble publicación | solo Caddy |
| UFW deny 5432 y nmap igual | iptables de Docker | quita ports; UFW no gana |
network_mode: host | copy de laptop | error si mezclas ports; no lo uses en el webhook |
Fly services copiado de Compose | no hay ports Compose | http_service / internal_port en fly.toml |
yaml 80:80 sin comillas | float base-60 | "80:80" |
-P en prod | publica todos los EXPOSE | nunca; lista explícita |
Relación con el resto
- Redes: compose networks.
internal: trueno bloqueaportsen el host. - DNS: compose dns no abre puertos.
- Bind: 0.0.0.0 es el listen del proceso.
- Secretos: Compose secrets no se “exponen” por
ports. 5432 abierto + password en env es el combo clásico. - TLS: dominio.
Checklist
-
docker compose configsin 5432 enports -
ss -lntpen el host: 22 y 443, no 5432 ni 3000 si hay Caddy -
docker compose port postgres 5432no imprime0.0.0.0:5432 - webhook solo 443, no 443+3000
- adminer sin
ports; profiletoolssi hace falta - strings entre comillas; nada de
80:80desnudo - 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.
Lecturas relacionadas
Sigue explorando Deploy y otras piezas para builders.



