Your Compose service times out against an API that the browser on the same machine opens without complaint. That split is the clue: the request that works leaves a host with a VPN tunnel, and the request that hangs leaves a container with its own network stack. Nothing inside the container knows the tunnel's routes, so the private destination stays unreachable however the name is spelled. The fix is a deliberate hop through the host, not a cleverer route table.

Start with why the two paths differ, then decide where the tunnel should live.

Why the host works and the container does not

Bringing the tunnel up gives the host an interface, a set of routes and, usually, a resolver entry supplied by the VPN. A container inherits none of it: it starts with one interface on the engine's bridge and a default route that points at that bridge. Routes installed on the host are invisible to anything running inside that namespace.

This is why the symptoms look inconsistent rather than simply broken. The host resolves the private name and connects, because both the name and the route exist there. The container sends its packet to a private address that nothing on the bridge path can reach, and the request times out. Adding the name to the container's hosts file changes nothing, because the address itself is unreachable from where the container sits.

Reach the host from inside the container

A process in the container that dials localhost is dialling the container. Docker Desktop publishes host.docker.internal as a name that points back at the machine running the engine; Linux engines leave it undefined unless the service asks for it, which is what the mapping below does. With that in place the application targets a name and a port instead of the private address.

services:
  app:
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      INTERNAL_API_HOST: "host.docker.internal:8666"

The tunnel stays on the host side of the hop, and the port in that variable is the one the proxy will listen on. A container that loses the tunnel now fails at a single known hop instead of attempting a route it never had.

Choose where the tunnel lives

Three shapes are possible for a Compose service that needs a private API, and they differ in which process owns the tunnel. A VPN client can run inside the container image, which requires exposing a tunnel device and granting the network administration capability that goes with it. The application can share the network namespace of a separate tunnel container, so both processes see one stack and one route table. Or the tunnel can stay on the host, with a narrow forwarder from the host into the container.

The first two pull profile files, reconnect behaviour and resolver policy into the project, and they make every service in the file depend on the tunnel's health. That is a lot of coupling for one API call. For a development stack that reads a single internal endpoint, the host-side forwarder is the smallest change that still keeps credentials where the platform team manages them.

Publish one narrow path on the host

Start from what the forwarder inherits: run it as the account that owns the tunnel and its outbound sockets pick up the host's routes and the host's resolver. One nginx location block covers one path.

server {
    listen 8666;
    location /search/ {
        proxy_pass http://10.20.30.40:8090/;
        proxy_set_header Host internal-api.example;
        proxy_read_timeout 60s;
    }
}

Treat the address and the port as stand-ins and substitute the pair the API actually listens on. Every further upstream earns its own location block, and each block should admit only the paths this service calls, so the proxy cannot become a general door into the private network. Bind the listener to the bridge address for clients that arrive through host-gateway, to loopback only when the client shares the host network namespace, and never to 0.0.0.0. Pick a port that is still free as well: a number already taken leaves the proxy not listening while the container keeps timing out, which is indistinguishable from the network fault you started with.

Ports, names and the TLS decision

Name resolution splits the same way as routing. The container's public lookups go through the engine's resolver, so a private name that resolves on the host will not resolve inside the container. The proxy is the component that resolves the private name, because it inherits the host resolver.

TLS is the decision that gets postponed and then explains the next failure. If the proxy forwards bytes untouched, the container sees the API's own certificate and needs the internal CA in its trust store. If the proxy terminates TLS, that trust moves to the host and the application starts building links from the host header it receives. Pick one arrangement and apply it to every resource, because a redirect issued by the other one points at a hostname the container has no way to resolve.

Verify from inside the service

Send the request from the container, not from the host: a plain curl against the proxy path, then the application's own health check. When the proxy answers but the application still fails, compare three things: the base URL it builds, any proxy variable in its environment, and the CA it trusts. All of them sit in the container's environment and logs, and fixing any one of them leaves the VPN alone.

Write the chosen arrangement into the project README while it is fresh. The next person to hit this timeout will otherwise spend the same afternoon rediscovering that the tunnel lives on the host.

ဆက်စပ်ဆောင်းပါးများ