No Exposed Database Ports: Routing Everything Through Traefik Labels

The production docker-compose for Fishing Tracker Pro publishes zero ports except Traefik's own. Postgres, backend, and frontend are reachable only through label-based routing on a shared Docker network.

No Exposed Database Ports: Routing Everything Through Traefik Labels

The development docker-compose.yml at the repo root publishes port 5432 for Postgres and ports 80/443 for the frontend directly to the host — convenient for psql -h localhost while iterating locally. The production compose file in domain.md publishes none of that. Every container sits on a Docker network with Traefik, and Traefik is the only thing with a port mapped to the host at all. That's not a small config tweak — it's the difference between "the database is unreachable from the internet" and "the database is unreachable from the internet as long as the firewall config doesn't drift."

No exposed database ports
No exposed database ports

postgres talks only to backend, on the internal network — never to the internet.

The routing is declared next to the service, not in a separate reverse-proxy config

Traefik's Docker provider reads routing rules from container labels, so the backend service declares its own routing:

backend:
  build: ./backend
  networks:
    - fishing-network
    - traefik-network
  labels:
    - "traefik.enable=true"
    - "traefik.http.routers.fishing-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)"
    - "traefik.http.routers.fishing-backend.entrypoints=websecure"
    - "traefik.http.routers.fishing-backend.tls=true"
    - "traefik.http.services.fishing-backend.loadbalancer.server.port=5000"
    - "traefik.docker.network=traefik-network"

No ports: key at all. Traefik discovers the container on traefik-network, reads the labels, and routes Host(fishing.example.com) && PathPrefix(/api) to port 5000 inside the Docker network — a port that is never bound to the host's network interface. The frontend gets an equivalent block matching just the bare Host() rule with no path prefix, so path-based routing decides whether a request hits the React static build or the Express API on the same domain.

Postgres gets no labels at all — and that's the point

postgres:
  image: postgres:15-alpine
  networks:
    - fishing-network
    - traefik-network
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U postgres"]

No traefik.enable label, no ports: mapping. It's reachable by hostname (postgres:5432) from any container that shares fishing-network with it — which is just backend — and from nothing else, including Traefik itself. There's no reverse-proxy rule that could accidentally route external traffic to a Postgres wire protocol, because the mechanism that would do that (a label) was never written. The absence of a label is doing security work here, not just an omission.

Two networks, deliberately

fishing-network is internal-only, shared by postgres and backend. traefik-network is the external one, shared by backend, frontend, and Traefik itself, and marked external: true since Traefik manages it outside this compose file entirely:

networks:
  fishing-network:
    driver: bridge
  traefik-network:
    external: true

backend sits on both networks — it needs to talk to Postgres over fishing-network and be reachable via Traefik over traefik-network. postgres only ever needs the first. Putting Postgres on traefik-network too would work functionally (Traefik would just never route to it without a label) but it's an unnecessary widening of the blast radius if a future label ever gets added by mistake — keeping Postgres off the externally-facing network entirely means that mistake isn't even possible to make.

What this actually buys you

nmap against the production host shows exactly two open ports: 80 and 443, both Traefik's, both TLS-terminated. There's no iptables rule doing that work and no separate firewall config to keep in sync with the app's architecture — the absence of a ports: mapping is the firewall rule, and it lives in the same file as the service it protects.

Series: Fishing Tracker Pro. Next: a GitHub Action that checks the real production URL after every merge to main.