Nothing is wrong with the app and nothing is wrong with the tunnel, and the dashboard still does not load. When a service runs with network_mode: service:vpn, it shares the VPN container's network namespace, which means its listener and its published ports belong to that namespace rather than to the service. Three decisions follow from that: where the app binds, which container publishes the port, and how narrow the management path is. Get them in the wrong order and every individual setting looks correct while the combination cannot work.

Who owns the port namespace

With a shared namespace, adding a ports: entry to the dependent service does nothing useful, and several networking options are unsupported for it by design. Publish on the container that owns the namespace instead. That container is also the one whose address appears on the host, so the mapping reads as one entry rather than one per service.

services:
  vpn:
    image: vpn-client-image
    cap_add: [NET_ADMIN]
    ports:
      - "127.0.0.1:8080:8080"
  app:
    network_mode: "service:vpn"

Note the address on the left of the mapping. Binding to 127.0.0.1 keeps the published port on the host's loopback, which is usually what a management interface wants. Binding to 0.0.0.0 exposes it on every host interface, and that is a different security decision rather than a convenience.

Make the app listen where the namespace expects

A listener bound to 127.0.0.1 inside the namespace is reachable only from inside that namespace. Published host ports arrive from outside it, so the app has to bind a broader address for the mapping to work. That is a one-line change in most servers, and it is the step people skip after copying a hardening guide written for a non-container deployment.

docker compose exec app ss -lntp
docker port vpn
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/

The listener listing inside the app tells you the truth about the bind address, and docker port tells you what the owner actually published. If the first shows 127.0.0.1 and the second shows a host mapping, you have found the mismatch without touching the VPN configuration.

Choose how the management path gets in

There are three reasonable arrangements. Publish on the VPN container and reach it from the host or the LAN. Put a reverse proxy in front of the namespace and give the proxy the only exposed port. Or keep the dashboard reachable only from inside the namespace through an exec session or an SSH tunnel, which is the smallest exposure and the least convenient.

Which one fits depends on who needs the interface and from where. A single administrator on the same host is served by a loopback binding. A team reaching it across the VPN needs a deliberate path and a named owner. Choosing the proxy option buys you TLS and access logs, at the cost of another component to keep updated.

Open the management port without opening everything

The VPN client's own local-access or lockdown policy can drop an inbound management request even while the outbound tunnel works perfectly. Permit one documented exception for the intended source and port, then confirm the tunnel's ordinary protection is unchanged. Disabling the kill switch to make a dashboard load trades a real control for a browser tab.

Leave the application's own authentication enabled. Being inside a VPN-connected stack does not authorize every local user or every device that can reach the host, and an unauthenticated admin panel behind a tunnel is still an unauthenticated admin panel.

Verify from the management device, then re-verify later

Test from the machine that will actually manage the service, not from the host, and then verify the app's required internet path separately. A management success proves one direction and says nothing about egress.

Retest after the stack is recreated. A replaced network owner changes the namespace the app attaches to, and a URL that worked before an update can point at nothing afterwards. Write down the published mapping and the binding address so the next rebuild is a two-minute check instead of a repeat investigation.

អត្ថបទពាក់ព័ន្ធ