A process inside a container runs curl http://localhost:5432 to reach a database on the host and gets Connection refused, or on Linux the name lookup itself fails with Could not resolve host: host.docker.internal. Both symptoms trace back to one fact: inside a container, localhost is the container, not the machine, and the portable way to reach the host is the special name host.docker.internal — which behaves differently on Docker Desktop and on plain Docker Engine. This how-to is part of Local Network & Port Mapping, and it makes host access work identically on macOS, Windows, WSL2, and Linux.

The scenario is common during onboarding: a native Postgres or Redis a developer has not yet containerized, a mock OAuth server bound to the laptop, a webpack dev server, or an SSH tunnel that a container needs to consume. In every case the container must dial out of the Docker network to a listener on the host. Getting it right means fixing two independent things — name resolution and the interface the host service listens on — and knowing that the first is automatic on Docker Desktop but a one-line addition on Linux.

Diagnostic

Reproduce the failure from inside the container so you see exactly which of the two hops breaks. Start a throwaway container on the same Compose project network and probe the name and the port separately:

#!/usr/bin/env bash
# probe-host.sh — run resolution and reachability as two distinct tests
set -euo pipefail

SERVICE_PORT="${1:-5432}"

echo "== 1. name resolution =="
docker compose exec app getent hosts host.docker.internal \
  || echo "FAILED: host.docker.internal did not resolve"

echo "== 2. tcp reachability =="
docker compose exec app sh -c \
  "nc -z -w2 host.docker.internal ${SERVICE_PORT} && echo reachable" \
  || echo "FAILED: port ${SERVICE_PORT} refused or unreachable"

On a plain Linux Docker Engine host with no extra configuration, step 1 fails outright:

== 1. name resolution ==
FAILED: host.docker.internal did not resolve
== 2. tcp reachability ==
getent: command terminated with exit code 2

On Docker Desktop the name resolves but you can still hit a refused connection when the host service is bound only to loopback:

== 1. name resolution ==
host.docker.internal  192.168.65.254
== 2. tcp reachability ==
FAILED: port 5432 refused or unreachable

Those are two different faults with two different fixes. A resolution failure is a DNS-name problem you solve in the Compose file. A refused connection after a successful lookup is an interface-binding problem you solve on the host process. Confirm which one you have before changing anything, because adding extra_hosts does nothing for a service that is listening on 127.0.0.1 and only 127.0.0.1.

Root cause

host.docker.internal is not a real DNS record — it is a name Docker injects so containers have a stable way to address the host regardless of the daemon's private subnet, which the daemon may renumber between restarts. On Docker Desktop (macOS and Windows) the engine runs inside a lightweight Linux VM, and Desktop's networking stack populates this name automatically for every container; that is why it "just works" on a colleague's Mac and mysteriously fails on your Linux CI runner.

On native Linux there is no VM and no automatic injection. The container talks to the host across the docker0 bridge, whose host-side address is typically 172.17.0.1, but Docker does not add the host.docker.internal entry for you. Since Docker 20.10 you can opt in with the magic value host-gateway, which the daemon resolves to that bridge gateway IP at container-create time. Add it and the name resolves; omit it and the lookup returns nothing.

The second, quieter cause is interface binding. Even once the name points at 172.17.0.1, the container can only connect if the host service is actually listening on an address the bridge can reach. A service started with --bind 127.0.0.1 or listen 127.0.0.1 accepts connections that arrive on loopback only; a packet from a container arrives on the bridge interface instead, so the kernel returns RST and you see Connection refused. The host service must listen on 0.0.0.0 (all interfaces) or explicitly on the docker0 gateway address for container traffic to land.

Path from a container to a host service via host-gateway A left-to-right flow: the container app resolves host.docker.internal, the resolver returns the docker0 gateway address, and the connection reaches the host service listening on all interfaces. Container to Host Service Container app dials host.docker.internal Name resolves host-gateway docker0 gateway 172.17.0.1 Host service 0.0.0.0:5432 Resolution is a Compose concern; the last hop depends on the host listener's bind address.
Two independent conditions must both hold: the name must resolve, and the host service must listen on an interface the bridge can reach.

Resolution

Fix resolution in the Compose file, fix binding on the host process, then verify both hops.

  1. Add the host-gateway mapping so the name resolves on every platform. On Docker Desktop the entry is harmless duplication; on Linux it is what makes the lookup succeed. Put it on every service that needs host access:

    # docker-compose.yml
    services:
      app:
        image: myorg/app:latest
        extra_hosts:
          - "host.docker.internal:host-gateway"
        environment:
          DATABASE_URL: "postgres://[email protected]:5432/appdb"
  2. Bind the host service to a reachable interface. A service listening only on loopback rejects container traffic. Start the native database on all interfaces (or, more tightly, on the bridge gateway) so packets arriving from 172.17.0.1 are accepted:

    #!/usr/bin/env bash
    # start-host-postgres.sh — listen where the bridge can reach
    set -euo pipefail
    # 0.0.0.0 accepts loopback AND bridge traffic; scope it with a firewall, not a bind
    pg_ctl -D "$PGDATA" -o "-c listen_addresses=0.0.0.0 -p 5432" start
    ss -tuln | grep -q ':5432 ' && echo "postgres listening on all interfaces"
  3. Pin the gateway IP when the default collides. If 172.17.0.0/16 overlaps a corporate VPN route, tell the daemon which address host-gateway should resolve to, then restart it:

    #!/usr/bin/env bash
    set -euo pipefail
    sudo mkdir -p /etc/docker
    printf '%s\n' '{ "host-gateway-ip": "10.200.0.1" }' | sudo tee /etc/docker/daemon.json
    sudo systemctl restart docker
    docker compose up -d --force-recreate
  4. Verify both hops from inside the running container, exactly as the diagnostic did, and only declare success when resolution and reachability pass:

    #!/usr/bin/env bash
    set -euo pipefail
    docker compose exec app getent hosts host.docker.internal
    docker compose exec app sh -c 'nc -z -w2 host.docker.internal 5432 && echo OK'

If the container must reach the host but you would rather not depend on the special name at all, an alternative is network_mode: host, which puts the container directly on the host network stack so plain localhost works. That erases the isolation boundary and is unavailable on Docker Desktop (it silently behaves differently inside the VM), so treat it as a last resort for a single debug container rather than a stack-wide default. When you can keep services on a user-defined bridge, prefer the host.docker.internal mapping and keep inter-service traffic on named endpoints per the DNS routing for microservices convention.

Docker Desktop versus Docker Engine host access Comparison of how host.docker.internal behaves on Docker Desktop against native Docker Engine on Linux across three concerns. Desktop vs Engine Host Access Docker Desktop name injected automatically resolves to VM host address extra_hosts optional network_mode host differs Docker Engine (Linux) name NOT injected resolves to 172.17.0.1 extra_hosts required network_mode host is literal
The single portable rule: always declare the host-gateway mapping so the same Compose file works on both.

Expected output

With the mapping in place and the host service bound to 0.0.0.0, both probes succeed. The getent call returns the gateway address the daemon chose, and the port answers:

$ docker compose exec app getent hosts host.docker.internal
172.17.0.1        host.docker.internal
$ docker compose exec app sh -c 'nc -z -w2 host.docker.internal 5432 && echo OK'
OK

An application configured with DATABASE_URL=postgres://[email protected]:5432/appdb now connects on boot instead of crash-looping on ECONNREFUSED. On Docker Desktop the resolved address will be a private VM address such as 192.168.65.254 rather than 172.17.0.1; that difference is expected and is precisely why you address the host by name and never hardcode the IP.

Prevention

Guard the configuration so a fresh clone cannot regress into localhost again:

  1. Fail fast at bootstrap. Add a startup check that verifies host reachability before the application tries to use it, so the failure message names the real problem instead of a generic connection error deep in the ORM:

    #!/usr/bin/env bash
    # entrypoint-preflight.sh
    set -euo pipefail
    host="host.docker.internal"; port="${DB_PORT:-5432}"
    getent hosts "$host" >/dev/null || { echo "FATAL: $host unresolved — add extra_hosts host-gateway" >&2; exit 1; }
    nc -z -w2 "$host" "$port" || { echo "FATAL: $host:$port refused — bind the host service to 0.0.0.0" >&2; exit 1; }
    exec "$@"
  2. Document the host dependency in .env.example with the port and the reason, so a teammate knows a native service must be running before docker compose up, and keep the devcontainer forwarding consistent with the devcontainer configuration standards.

  3. Lint the Compose file in CI to reject any service that references host.docker.internal without the matching extra_hosts entry:

    #!/usr/bin/env bash
    set -euo pipefail
    cfg="$(docker compose config --format json)"
    echo "$cfg" | jq -e '
      .services | to_entries
      | map(select((.value | tostring | contains("host.docker.internal"))
                   and ((.value.extra_hosts // []) | join(" ") | contains("host-gateway") | not)))
      | length == 0' >/dev/null \
      || { echo "service uses host.docker.internal without host-gateway extra_hosts" >&2; exit 1; }
    echo "host-gateway lint passed"

Platform caveats

macOS (Docker Desktop): host.docker.internal resolves automatically to the VM's host address, not 172.17.0.1. There is no docker0 bridge, so pinning host-gateway-ip in daemon.json has no effect. network_mode: host does not expose the Mac's network stack — it stays inside the Linux VM — so it is not a substitute here. WSL2: With the Docker Desktop WSL2 backend, host.docker.internal points at the Windows host, not the WSL2 distribution. A service started inside the same distro is reached over that distro's loopback instead; run your probes from within WSL2, never from PowerShell, or you test the wrong network namespace. Apple Silicon (ARM64): Resolution and binding behave exactly as on Intel Docker Desktop. Pull multi-arch images for the container side so emulation does not add latency to the host round-trip; the mapping itself needs no platform: override. Rootless Docker (Linux): slirp4netns changes the gateway, so host-gateway may resolve to an address in the 10.0.2.0/24 range rather than 172.17.0.1. Do not hardcode the IP; rely on the name and confirm with getent after the first up.

Decision path for restoring host access A decision tree: if the name resolves, check the port; if it refuses, rebind the host service, otherwise add the host-gateway mapping. Which Hop Is Broken? Does the name resolve? getent hosts No add host-gateway mapping Yes, port refused rebind host to 0.0.0.0 no yes
Resolve the name first, then the listener — treating them as one problem is what turns a two-minute fix into an afternoon.

Rollback

If adding host access destabilizes the stack — for example the container now depends on a native service a teammate does not run — revert to the committed configuration and recreate. This removes the extra_hosts change without touching data volumes:

#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD -- docker-compose.yml
docker compose up -d --force-recreate --wait

If you changed host-gateway-ip in /etc/docker/daemon.json and want the default back, remove the key and restart the daemon: sudo rm -f /etc/docker/daemon.json && sudo systemctl restart docker. Re-run the two-hop probe afterward so you leave the environment in a state you have verified rather than an assumed one.

Frequently Asked Questions

Why does host.docker.internal work on my Mac but not on a Linux CI runner?

Docker Desktop injects the name automatically because the engine runs in a VM whose networking layer populates it. Native Docker Engine on Linux does not inject it, so the lookup returns nothing until you add extra_hosts: - "host.docker.internal:host-gateway" to the service. The host-gateway value is a magic string Docker resolves to the bridge gateway (typically 172.17.0.1) at container-create time. Add the mapping and the same Compose file works on both.

The name resolves but I still get Connection refused — what now?

Resolution and reachability are separate. A successful getent hosts host.docker.internal only proves the name points at the host; the connection still fails if the host service listens only on 127.0.0.1. Container packets arrive on the bridge interface, not loopback, so the kernel rejects them. Rebind the host service to 0.0.0.0 (or explicitly to the docker0 gateway address) and test again with nc -z host.docker.internal <port>.

Should I use network_mode: host instead?

Only as a narrow debug measure. network_mode: host puts the container on the host network stack so plain localhost reaches host services, but it removes the isolation boundary, disables published-port mapping, and behaves differently under Docker Desktop because there is a VM in between. For anything you commit, keep services on a user-defined bridge and use the host.docker.internal mapping so the configuration is portable.

Can I hardcode 172.17.0.1 instead of the name?

Avoid it. The bridge gateway is 172.17.0.1 only for the default bridge on rootful Linux; Docker Desktop uses a private VM address, rootless Docker uses a slirp4netns range, and a pinned host-gateway-ip changes it again. The whole point of host.docker.internal is to give you one stable name across all of those. Address the host by name and let the daemon supply the correct IP per platform.