To reach the API from a browser or curl 3,008 on the host, publish its port with -p hostPort:containerPort. Published ports are fixed when a container is created, so the API container is replaced:
docker rm -f l3-api
docker run -d --name l3-api --network l3-booknest -p 33000:3000 -v "$PWD/booknest:/app" \
-w /app -e PGHOST=postgres node:24-slim node server.js
sleep 2
docker port l3-api
curl -s localhost:33000/health; echo
sudo iptables -t nat -S DOCKER | grep -o 'dport 33000.*'
ps -o args= -C docker-proxy | grep 33000 | cut -c 1-90l3-api
d6d95cf4462f8c7c7c7b39b764a240b113bb1c03c02e98e737036d2f334bf82d
3000/tcp -> 0.0.0.0:33000
3000/tcp -> [::]:33000
{"status":"ok"}
dport 33000 -j DNAT --to-destination 172.19.0.3:3000
/usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 33000 -container-ip 172.19.0.
/usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 33000 -container-ip 172.19.0.3 -coDocker 514 publishes a port twice over. A DNAT rule in the nat table rewrites packets arriving from outside for port 33000 to the container's address, and a docker-proxy process per address family listens on the port for the cases rules cannot handle, such as connections to localhost from the host itself. Engine 29 still writes these rules with iptables 40,292 by default; its nftables 40,292 backend is experimental.

A published port listens on every host address, and Docker's rules run ahead of ufw (Official Apt Repository), so bind private services to loopback: -p 127.0.0.1:33000:3000. -P publishes every EXPOSEd port on random high ports.