Skip to content

Ports and service access

  • Lesson 7
  • 40 min
  • Module 3: Networking, configuration and persistence
Lesson 7 of 15 · Docker and Cloud Essentials for AI DevelopersPorts and service access

What you will be able to do

Reach the ticket API in its container from your computer, and only from your computer. Learn to see why a container can run and still not answer, and let two containers talk to each other by name.

Before you startWhat to know and to have readyAbout 40 minutes

What you need to know

What you need on your computer

Tested with Docker Desktop 4.81.0 (Docker Engine 29.6.1) on macOS, Apple silicon. The Windows and Linux steps are not tested.

Start with a small test. Amira runs the image with no extra options, except the API key:

Terminal
docker run -d --name ticket-api -e API_KEY=local-dev-key ticket-api:1.0.0
docker ps

The output starts with the new container’s ID:

7afe3d39fe5b1f08c2e9474dafa7aef9acfa3902a2abf0e137a52da4dc7b4903
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7afe3d39fe5b ticket-api:1.0.0 "fastapi run --host …" 3 seconds ago Up 3 seconds 8000/tcp ticket-api

The container is up, and the API listens on port 8000. She calls it from her computer:

Terminal
curl -sS http://127.0.0.1:8000/health

The output:

curl: (7) Failed to connect to 127.0.0.1 port 8000 after 0 ms: Couldn't connect to server

Then she asks the same question from inside the container. The slim image has no curl, so she uses Python’s urllib:

Terminal
docker exec ticket-api python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/health').read().decode())"

The output:

{"status":"ok"}

The app works. Only the way in is missing. A container has its own network: its own network addresses and its own ports, separate from your computer’s. Port 8000 in the container is not port 8000 on your computer. PORTS 8000/tcp in docker ps comes from the line EXPOSE 8000 in the Dockerfile. EXPOSE only documents the port. It does not open anything.

Terminal
docker rm -f ticket-api

To let your computer in, you publish a port when the container starts, with -p. A published port is a port on your computer that Docker forwards to a port in the container:

-p 127.0.0.1:8000:8000
└ host address └ host port └ container port
  • The container port is where the app listens: 8000, from the start command in the Dockerfile.
  • The host port is the port that you use on your computer. It can be different, for example 8001.
  • The host address says which network address of your computer accepts the connection.

Amira runs the API again, with the port published:

Terminal
docker run -d --name ticket-api -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.0.0
docker ps
docker port ticket-api
curl -s http://127.0.0.1:8000/health

The output:

721de8ac187e07bc74f72ee4b8a94b213c2dd56e73b349a88cf04778d23fd51b
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
721de8ac187e ticket-api:1.0.0 "fastapi run --host …" 3 seconds ago Up 3 seconds 127.0.0.1:8000->8000/tcp ticket-api
8000/tcp -> 127.0.0.1:8000
{"status":"ok"}

127.0.0.1:8000->8000/tcp reads: “port 8000 of the address 127.0.0.1 on this computer goes to port 8000 in the container”. http://localhost:8000/health gives the same answer, because localhost is the name of 127.0.0.1.

The app’s log shows where the request came from:

Terminal
docker logs --tail 2 ticket-api

The output:

INFO ticket_api: GET /health 200 5ms id=338f1241cca9
INFO: 172.17.0.1:58980 - "GET /health HTTP/1.1" 200 OK

The client is 172.17.0.1, not 127.0.0.1. From the app’s point of view, the request comes from Docker’s network, not from your computer’s own address. On another computer, the numbers can be different: you see an address like 172.17.0.1.

The host address decides who can reach the published port. Amira tests it from another device. First she finds her computer’s address on the local network:

Terminal window
ipconfig getifaddr en0

en0 is usually the Wi-Fi. If the command prints nothing, try en1.

Her computer’s address was 10.0.0.109. From a second device on the same network, she opens http://10.0.0.109:8000/health. The result, here with curl from that address:

curl: (7) Failed to connect to 10.0.0.109 port 8000 after 8 ms: Couldn't connect to server

Then she starts a second container, with no host address in -p, on host port 8001:

Terminal
docker run -d --name ticket-api-open -p 8001:8000 -e API_KEY=local-dev-key ticket-api:1.0.0
docker ps --filter name=ticket-api-open
curl -s http://10.0.0.109:8001/health

The output:

bd5e48b1b1def0fc2b29ed84076bd91fa2304121002a480e8c16d09c28d290a9
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
bd5e48b1b1de ticket-api:1.0.0 "fastapi run --host …" 3 seconds ago Up 3 seconds 0.0.0.0:8001->8000/tcp, [::]:8001->8000/tcp ticket-api-open
{"status":"ok"}

0.0.0.0 (and [::], the same for IPv6) means every network address of this computer. Any device that can reach your computer can now call the API. On a café’s Wi-Fi, that is everyone in the café.

-p option docker ps shows Who can connect
-p 127.0.0.1:8000:8000 127.0.0.1:8000->8000/tcp Only programs on this computer
-p 8001:8000 0.0.0.0:8001->8000/tcp, [::]:8001->8000/tcp Every device that can reach this computer
no -p 8000/tcp Nobody outside the container’s network

This course always writes -p 127.0.0.1:.... Leave out the address only when other devices must connect, and only on a network that you trust.

Amira removes the open container:

Terminal
docker rm -f ticket-api-open

The host address is one half of the path. The other half is the address that the app listens on inside the container. The Dockerfile starts the app with fastapi run --host 0.0.0.0 --port 8000. What if it said --host 127.0.0.1, as fastapi dev and uvicorn do by default?

Predict: the container runs, and the port is published. What does curl show?

Amira removes the working container with docker rm -f ticket-api. Then she starts the image with a different command at the end of docker run. That command replaces the image’s CMD:

Terminal
docker run -d --name ticket-api -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.0.0 fastapi run --host 127.0.0.1 --port 8000
docker ps
curl -sS http://localhost:8000/health

The output:

af205d8ff12517ec100dfb4adebfed5b3bc3aaff4d44aca93c88de17933fdac7
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
af205d8ff125 ticket-api:1.0.0 "fastapi run --host …" 4 seconds ago Up 3 seconds 127.0.0.1:8000->8000/tcp ticket-api
curl: (52) Empty reply from server

Everything in docker ps looks right, but the reply is empty. The log shows the cause:

Terminal
docker logs ticket-api

The output (the last line):

INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

Inside the container, 127.0.0.1 is the container itself, not your computer. The app accepts only connections that start inside the container. Docker’s forwarded connection arrives at the container’s network address, for example 172.17.0.2, and nobody listens there. A test from inside the container still works, so docker exec alone does not find this problem.

Two boxes. Your computer: curl on this computer calls 127.0.0.1 port 8000, the published port. The Wi-Fi address 10.0.0.109 has no port 8000. Docker forwards the published port to the container's own network address, 172.17.0.2 port 8000. Inside the container, fastapi run --host 0.0.0.0 --port 8000 listens on both the container's address and the container's own 127.0.0.1, which only the container can use.
Your computer and the container each have their own 127.0.0.1. Docker forwards to the container's network address, so the app must listen on 0.0.0.0.
Text description of the diagram

On your computer, curl calls 127.0.0.1 port 8000. That is the published port. The Wi-Fi address 10.0.0.109 has no port 8000, because -p chose 127.0.0.1. Docker forwards the connection to the container’s own network address, 172.17.0.2, port 8000. The container also has its own 127.0.0.1, which only programs in the container can use. fastapi run --host 0.0.0.0 listens on both addresses of the container, so the forwarded connection arrives. With --host 127.0.0.1, it listens only on the container’s 127.0.0.1, and the forwarded connection finds nobody.

A sequence with three participants: curl on your computer, Docker with the port 127.0.0.1:8000, and fastapi run in the container. curl sends GET /health to Docker, which forwards it to 172.17.0.2 port 8000. If the app listens on 0.0.0.0, it answers 200 with status ok, and Docker passes the answer to curl. If the app listens on 127.0.0.1, nothing listens on 172.17.0.2, Docker closes the connection, and curl shows: (52) Empty reply from server.
The same request with the two bind addresses. Only 0.0.0.0 gets an answer.
Text description of the diagram

curl on your computer sends GET /health to the published port. Docker forwards it to the container’s address, 172.17.0.2, port 8000. If the app listens on 0.0.0.0, it answers 200 {"status":"ok"}, and Docker passes the answer back. If the app listens on 127.0.0.1, nothing listens on 172.17.0.2. Docker closes the connection without an answer, and curl shows curl: (52) Empty reply from server.

Amira removes the broken container:

Terminal
docker rm -f ticket-api

The same rule works in the other direction. A program in a container that calls localhost calls its own container. Amira starts the API again with the normal command, then calls it from a second container:

Terminal
docker run -d --name ticket-api -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.0.0
docker run --rm python:3.14.8-slim-trixie python -c "import urllib.request; print(urllib.request.urlopen('http://localhost:8000/health').read().decode())"

The output (the last line):

urllib.error.URLError: <urlopen error [Errno 111] Connection refused>

The second container has no app on its own port 8000, so the connection is refused.

To call a program on your computer from a container, Docker Desktop gives the special name host.docker.internal. With http://host.docker.internal:8000/health, the same command printed {"status":"ok"} here. On Linux, Docker Engine needs an extra option for this name (--add-host=host.docker.internal:host-gateway); this course did not test it.

Container to container: a user-defined network

Section titled “Container to container: a user-defined network”

Two containers that work together, such as the API and a database, need a better way than a path through your computer. Put them on the same user-defined network. That is a network that you create and name. On it, Docker gives each container a name that the others can look up: its container name.

First, the default network. Every container that you start without --network joins it. Amira calls the API by its name:

Terminal
docker run --rm python:3.14.8-slim-trixie python -c "import urllib.request; print(urllib.request.urlopen('http://ticket-api:8000/health').read().decode())"

The output (the last line):

urllib.error.URLError: <urlopen error [Errno -2] Name or service not known>

On the default network, names do not work. Amira removes the container with docker rm -f ticket-api. Then she makes a network, and starts the API on it:

Terminal
docker network create ticket-net
docker run -d --name ticket-api --network ticket-net -e API_KEY=local-dev-key ticket-api:1.0.0
docker run --rm --network ticket-net python:3.14.8-slim-trixie python -c "import urllib.request; print(urllib.request.urlopen('http://ticket-api:8000/health').read().decode())"
docker logs --tail 1 ticket-api

The output:

6b3a99b0f573fcdcc2eada873a3281c43a8e7f3f499e0fd889a3f98e590fbed7
859b97b8df79a958829450eea324a06347cda25073a2759a6d9b230d6d79c030
{"status":"ok"}
INFO: 172.21.0.3:54638 - "GET /health HTTP/1.1" 200 OK

The first two lines are the IDs of the new network and the new container. The name ticket-api worked, and the request came from the second container’s address on ticket-net, 172.21.0.3. The API needed no -p: containers on the same network reach each other’s container ports directly. A published port is only for your computer.

In the next lessons, Docker Compose makes such a network for you, and the API finds its database with the name db.

Amira cleans up:

Terminal
docker rm -f ticket-api
docker network rm ticket-net
  1. Open a terminal in ~/projects/ticket-api.

  2. Start the API with the port published on 127.0.0.1:

    Terminal window
    docker run -d --name ticket-api -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.0.0

    If Docker says Bind for 127.0.0.1:8000 failed: port is already allocated, another program uses port 8000. Run docker ps to find a container that uses it, and remove it. Then remove the failed container with docker rm ticket-api, and try again.

  3. Run docker ps. Check that PORTS shows 127.0.0.1:8000->8000/tcp.

  4. Call the API: curl -s http://127.0.0.1:8000/health. Expect {"status":"ok"}.

  5. Find your computer’s network address, as in Choose who can connect. Call http://<that address>:8000/health from a phone or another computer on the same Wi-Fi. Expect no connection.

  6. Run docker logs --tail 2 ticket-api. Find the client’s address of your request from step 4.

  7. Remove the container: docker rm -f ticket-api.

Show the expected results

Step 3: 127.0.0.1:8000->8000/tcp in the PORTS column. Step 4: {"status":"ok"}. Step 5: the phone’s browser says that it cannot connect, because the port listens only on 127.0.0.1. Step 6: a line like INFO: 172.17.0.1:58980 - "GET /health HTTP/1.1" 200 OK. The address is Docker’s, not 127.0.0.1. The numbers can be different on your computer. Step 7: Docker prints ticket-api.

Change one condition: publish the API on host port 9000, and put it on a user-defined network called ticket-net. Before you run anything, write down two URLs: the one that works from your computer, and the one that works from a second container on ticket-net. Then test both, and clean up.

Show the answer
Terminal window
docker network create ticket-net
docker run -d --name ticket-api --network ticket-net -p 127.0.0.1:9000:8000 -e API_KEY=local-dev-key ticket-api:1.0.0
curl -s http://127.0.0.1:9000/health
docker run --rm --network ticket-net python:3.14.8-slim-trixie python -c "import urllib.request; print(urllib.request.urlopen('http://ticket-api:8000/health').read().decode())"
docker rm -f ticket-api
docker network rm ticket-net

From your computer: http://127.0.0.1:9000/health, the host port. From the second container: http://ticket-api:8000/health, the container port. The host port is only on your computer. A call from the second container to http://ticket-api:9000/health gives Connection refused, because nothing in the API container listens on 9000.

Common mistake

The container runs, but curl says: Empty reply from server

Symptom: docker ps shows the container as Up, with the port published. curl to the published port prints curl: (52) Empty reply from server.

Cause: Docker accepted the connection on your computer, but nothing answered at the other end in the container. Three causes give this same symptom:

  1. The app listens on 127.0.0.1 inside the container, not on 0.0.0.0.
  2. The container port in -p is wrong. For example, -p 127.0.0.1:8000:80 forwards to port 80, but the app listens on 8000. This also gave (52) here.
  3. The app is still starting. A request in the first second after the start can get (52). Wait, and try again.

Fix:

  1. Read the start line in docker logs <container>: Uvicorn running on http://0.0.0.0:8000 is right. http://127.0.0.1:8000 is the bind problem. Change the start command to --host 0.0.0.0.
  2. Compare the port in that line with the second number in docker port <container>. They must be the same.
  3. If both are right, wait a few seconds and call again.

For a real AI service: model servers and API frameworks often listen on 127.0.0.1 by default, for safety on a laptop. In a container on a cloud platform, the same default gives a service that runs but never answers. The platform’s health check fails, and the platform restarts the container again and again (Module 5).

Check your understanding

What is the difference between -p 127.0.0.1:8000:8000 and -p 8000:8000?

Show the answer

Both forward port 8000 on your computer to port 8000 in the container. With 127.0.0.1, only programs on your computer can connect. Without an address, Docker listens on every network address of your computer (0.0.0.0 and [::]). Then every device that can reach your computer can call the API, for example on the same Wi-Fi. On Linux, such a port can also pass the ufw firewall.

Check your understanding

A container starts the app with --host 127.0.0.1 and is published with -p 127.0.0.1:8000:8000. docker ps shows it Up. Predict what curl http://localhost:8000/health shows, and explain why.

Show the answer

curl: (52) Empty reply from server. Inside the container, 127.0.0.1 means the container itself, so the app accepts only connections from inside the container. Docker forwards the request to the container’s network address (for example 172.17.0.2), where nothing listens. The fix is --host 0.0.0.0 in the container’s start command.

Check your understanding

Container B must call the API in container A. Why does http://localhost:8000 not work in container B, and what works instead?

Show the answer

In container B, localhost is container B itself, which has no API on port 8000. Put both containers on the same user-defined network (docker network create, then --network for both), and call container A by its name and its container port, for example http://ticket-api:8000. No published port is needed for this.

Recap

  • A container has its own network. EXPOSE only documents a port. -p host-address:host-port:container-port publishes it.
  • -p 127.0.0.1:... lets in only your computer. Without the address, every device that can reach your computer can connect.
  • The app in the container must listen on 0.0.0.0. With 127.0.0.1, the container runs, but curl gets (52) Empty reply from server.
  • Inside a container, localhost is that container. Containers on a user-defined network find each other by name, on the container port.

Next, you give the container its settings and its secrets when it starts. The API stops with a clear message when a required setting is missing.

Next: Runtime configuration