Jitsumi
Back to the blog

Docker Compose networking: localhost, names and ports

Docker works in your browser but fails in a container? Use a two-service example to choose the right hostname, port and listening address.

Blue, grey and rust-coloured shipping containers arranged around an open lane in a sunlit industrial yard.

Your app opens in a browser at localhost:8080, but another container cannot connect to that same address. It is tempting to keep changing ports until something works. First, check where the request starts.

With Docker Compose's ordinary bridge networking, a container reaches another service by its service name and container port. A program on your computer can use a published host port. Inside a container, localhost refers to that container's own network environment. Docker documents these distinctions in its Compose networking guide and networking overview .

This guide uses a small HTTP example to make the difference visible. It covers Linux containers running locally with Docker Compose v2, including Docker Desktop in Linux-container mode. Remote Docker hosts, host networking and containers that deliberately share a network namespace need different assumptions.

Choose the address from the caller's point of view

Suppose a service named api listens on port 8000 inside its container, while Docker publishes it on your computer's port 8080.

Where the request starts

Address for this example

A browser or HTTP client on your computer

http://127.0.0.1:8080

Another container on the same Compose network

http://api:8000

A process inside the API container itself

http://127.0.0.1:8000

The hostname api comes from the service key in the Compose file. Docker's internal name resolution lets peers find that service without hard-coding its container IP. Your ordinary desktop browser does not automatically use that internal DNS. Docker's explanation of Desktop networking describes the separate container DNS and host port-forwarding paths.

Read a connection setting as a complete sentence: “This program, running here, needs to reach that service.” The location of the configuration file does not decide the answer; the location of the program using it does.

Try one server from two places

Use a new, empty folder for this exercise, separate from an existing project. You need a running, maintained Docker installation, Compose v2 and an available host port 8080. Python runs inside the containers, so a local Python installation is unnecessary.

Save this as compose.yaml:

services:
  api:
    image: python:3.13-alpine
    command:
      - python
      - -u
      - -m
      - http.server
      - "8000"
      - --bind
      - "0.0.0.0"
      - --directory
      - /tmp
    ports:
      - "127.0.0.1:8080:8000"

  client:
    image: python:3.13-alpine
    command:
      - python
      - -c
      - "import time; time.sleep(86400)"

The service named api is just a temporary HTTP file server. It serves its own container's /tmp directory; the example mounts no folders from your computer. The second container stays running for up to 24 hours so you can make requests from inside it. Python's HTTP server documentation explains the port, bind and directory options. The -u option keeps startup messages visible promptly in the logs. This server is for the exercise, not a production application server.

The host mapping explicitly uses 127.0.0.1 to limit the published entry point to host loopback under the normal configuration. Keep Docker updated: its port-publishing documentation records a limitation in Engine versions before 28.0.0 that let neighbouring machines reach localhost-published ports.

From a terminal in the new folder, run:

docker compose config
docker compose up -d

The first command shows the resolved configuration; check that it contains only these two services and the intended port mapping. The second starts them in the background and pulls the image if needed. See the references for Compose config and Compose up .

From your computer

Open http://127.0.0.1:8080 in your browser. Once the server is ready, expect a directory-listing page. It may be empty; the successful HTTP response is the useful result.

You can ask Compose which host address maps to container port 8000:

docker compose port api 8000

For this configuration, the expected mapping is 127.0.0.1:8080. The argument 8000 is the container port you are asking about, as documented in Compose port .

From the client container

In the same terminal, start a Python interpreter inside the running client:

docker compose exec client python

At its Python prompt, enter:

from urllib.request import ProxyHandler, build_opener
direct = build_opener(ProxyHandler({}))
response = direct.open("http://api:8000", timeout=5)
print(response.status)
response.close()

The expected status is 200. Then enter exit() to return to your normal terminal.

Although you typed the command on your computer, Compose exec runs the interpreter inside client. That is why api:8000 is the appropriate destination. The empty ProxyHandler makes this check connect directly even if proxy variables are configured in the container. These controls come from Python's built-in HTTP client .

If the request fails immediately after startup, check the server logs with docker compose logs api and retry once it is listening. Persistent failures belong in the diagnostic section below.

The browser and the client container reach the same server through different addresses.

The host browser reaches the API on port 8000 through 127.0.0.1:8080. A client inside the same Compose network reaches it directly at api:8000.

Arrows show request direction. The host uses the published port 8080; the client uses api:8000 inside the Compose network.

Changing the host port does not change the address used between the two services.

Read the port mapping from outside to inside

In "127.0.0.1:8080:8000", the three parts have different jobs:

Part

What it controls

127.0.0.1

The host address on which Docker publishes the port

8080

The port used by the browser on your computer

8000

The destination port inside the API container

Docker's Compose ports reference defines this syntax. Keep the mapping quoted in YAML.

For example, if port 8080 is already occupied, change the mapping to "127.0.0.1:8081:8000" and run docker compose up -d again. The browser address becomes http://127.0.0.1:8081. The client still calls http://api:8000, because the server is still listening on container port 8000.

The peer connection also does not require a published host port. Containers on the same ordinary user-defined bridge network can reach listening ports directly. If you remove the ports block and recreate this example, the client route remains available while the demonstrated host URL stops working.

That does not make the API inaccessible to every possible caller: other containers on the network remain peers, and native Linux host access differs from Docker Desktop's VM arrangement. Treat publication as an entry-point decision, not a complete access-control policy.

Likewise, adding Compose's expose field does not create the browser-facing mapping. Use ports when you intend to publish one.

Keep the server's listening address separate

There are two address choices in the example: Python listens on 0.0.0.0 inside the container, and Docker publishes a port on 127.0.0.1 on the host.

The server's bind address determines which of its own interfaces accept connections. A server bound only to its container's 127.0.0.1 can answer a local check inside that container while refusing connections arriving through its network interface. Binding to 0.0.0.0 makes it listen on its IPv4 interfaces. Docker's Python container tutorial demonstrates this distinction with a server bound to 0.0.0.0 and a separately mapped host port.

Use the documented bind or listen option for your application. Do not put 0.0.0.0 into every client URL: it is a listening configuration, not the destination name this example's clients should use.

A page served by a container still runs JavaScript in your browser

This is an easy trap when a frontend and an API are both containerised.

A server-side request from a frontend container on the shared network can use http://api:8000. But a fetch() call in JavaScript downloaded by your browser runs on your computer. Serving the JavaScript file from a container does not move its execution there. MDN explains this client-side versus server-side distinction .

For this local setup, browser code needs a browser-reachable API address, such as the published host URL, or a relative URL handled by an appropriately configured reverse proxy. An internal service name embedded in a JavaScript bundle will not acquire Docker DNS merely because the bundle was built in a container.

When the site is visited from another device, localhost refers to that visitor's device. A development URL is therefore not a production API URL. Also distinguish an address failure from a browser cross-origin restriction : changing Docker DNS will not resolve a CORS policy error.

If the address is right, check what actually failed

Start with the two services' state, including stopped containers , and the server's logs :

docker compose ps --all
docker compose logs api

Then match the symptom to the next useful question.

Symptom

What to investigate

The hostname cannot be resolved

Is the request really running inside a container on the API's network? Is the service name correct?

Connection refused

Is the server running and listening on the expected port and interface? Has it finished starting?

Connection times out

Check the route, network membership, firewall and destination. A timeout alone does not identify the cause.

An HTTP response such as 401, 404 or 500 arrives

Read the response and application logs. You reached an HTTP responder; confirm it is the intended service before investigating authentication, paths or application errors.

In a larger project, inspect the configuration that Compose actually receives. Overrides can attach services to different networks or alter a port mapping. A working request from your laptop is useful evidence about the host route; it does not demonstrate that the same URL works inside a container.

For database-backed apps, remember that running and ready are different states. Basic depends_on ordering does not wait for a database to accept queries. Docker documents health checks with condition: service_healthy for that startup dependency. This helps with initial readiness; application code still needs suitable handling for later failures and reconnections.

Avoid hard-coding a container IP after a successful inspection. A replacement container can get a different address, while its service name remains the intended discovery mechanism.

When the destination really runs on your host

If the server runs directly on your computer, rather than in the Compose project, that is a different route.

Docker Desktop provides host.docker.internal for containers to reach host services. For a host service listening on port 9000, the candidate URL would be http://host.docker.internal:9000, subject to its listener and firewall settings. This hostname is documented in Docker's Desktop networking explanation .

Do not assume the same automatic name exists on a standalone Linux Docker Engine. Docker documents an explicit host-gateway mapping for that environment. Creating a hostname mapping does not itself make a host process listening only on host loopback reachable through a bridge interface.

For two services already on the same Compose network, keep using the destination service name and container port.

Stop the example when you are finished

From the exercise folder, run:

docker compose down

This stops and removes the example's containers and its default network. It leaves your compose.yaml file and the downloaded image. The example created no volumes; there is no reason to add a volume-removal option. Docker lists the command's behaviour in Compose down .

For your own project, the useful result is a connection that succeeds from the program's actual execution environment. Identify that caller, choose the destination name it can resolve, and use the port belonging to that route. Those three checks make a failed connection much easier to explain.