Your Dockerfile contains EXPOSE 8000. The container is running. docker ps even lists 8000/tcp. Yet opening http://localhost:8080 gives you a connection error.
EXPOSE records a container port in the image's metadata. docker run -p publishes a container port through an address and port on the Docker host. Neither instruction makes your application start listening. These are three separate controls, and a working browser request depends on more than the first one.
This guide uses one small HTTP server to reproduce three states: an unpublished port, a working published port, and a published port pointing at an unreachable listener. You will inspect each state before changing it, so the commands remain useful when your next container has a different application inside.
What EXPOSE, publish, and bind actually control
| Setting | Where it belongs | What it does | What it does not prove |
|---|---|---|---|
EXPOSE 8000 |
Dockerfile | Records an intended container port | That a server is running or a host port exists |
-p 127.0.0.1:8080:8000 |
docker run |
Maps host loopback port 8080 to container port 8000 | That the application accepts that traffic |
--bind 0.0.0.0 |
Our Python server command | Makes the application listen on all IPv4 interfaces inside its container | That Docker publishes a host port |
Docker's Dockerfile reference explicitly separates exposing from publishing. An image can document a port without making it available through localhost on the host. Conversely, an explicit -p mapping can target a port that the Dockerfile never declared.
For this example, read the publishing argument from left to right:
-p 127.0.0.1:8080:8000
└ host IP │ └ container port
└ host port
Host request to 127.0.0.1:8080
→ Docker port mapping
→ container network interface, port 8000
→ HTTP server
The two port numbers need not match. Use 8080 in the host-side URL because that is the entrance you created; the server still listens on 8000 inside the container. Docker documents this syntax in its port publishing walkthrough.
Prepare one server you can test from both sides
You need a working Docker Engine, a terminal with a POSIX shell, curl, and permission to run Docker commands. Check the engine before copying the example:
docker version
You should see both Client and Server information. A missing Server section or a daemon connection error is an environment problem to resolve first. This guide uses Linux containers on Docker's ordinary bridge network; it does not use --network host or custom routing rules.
The experiments and screenshots below were captured in a LabEx Ubuntu 22.04 instance VM with Docker Engine 20.10.21 on September 7, 2026. The image tag python:3.12-alpine can change over time. The experiment concerns port behavior, rather than a specific Python patch release.
If creating images and running named containers are new to you, the Docker for Beginners course includes Docker Run Command Parameters, Custom Docker Images, and Docker Networking Basics. Those activities provide the surrounding practice for this example.
Create an empty working directory:
mkdir docker-port-demo
cd docker-port-demo
Save this as index.html. The page deliberately has no application dependencies: if it loads, an HTTP request reached the server.
<!doctype html>
<html lang="en">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Docker port demo</title>
<style>
body {
font: 22px/1.6 system-ui;
max-width: 760px;
margin: 12vh auto;
padding: 32px;
color: #18334a;
background: #f1f7fa;
}
h1 {
font-size: 44px;
line-height: 1.15;
}
code {
background: #dcecf5;
padding: 3px 9px;
border-radius: 6px;
}
</style>
<p>DOCKER NETWORKING EXPERIMENT</p>
<h1>Your request reached the container.</h1>
<p>This page is served by Python on container port <code>8000</code>.</p>
<p>
Change the port mapping or listening address, then test the same request
again.
</p>
</html>
In the same directory, save this as Dockerfile:
FROM python:3.12-alpine
WORKDIR /site
COPY index.html .
EXPOSE 8000
CMD ["python", "-u", "-m", "http.server", "8000", "--bind", "0.0.0.0"]
WORKDIR selects the directory to serve, COPY adds the page, and CMD starts the process. The -u option makes Python's output unbuffered, which helps when reading startup messages through container logs. The EXPOSE line describes the port; the http.server command is what opens the listener.
Build the image:
docker build -t port-demo .
Python's HTTP server documentation explains the command-line bind option and cautions against using this server for production. Use this small static page for the experiment, not as a deployment template for a sensitive application.
Experiment 1: EXPOSE without a published port
Start the image without -p:
docker run -d --name exposed-only port-demo
Inspect the image metadata and the running container:
docker image inspect port-demo --format '{{json .Config.ExposedPorts}}'
docker ps --filter name=exposed-only --format 'table {{.Names}}\t{{.Ports}}'
docker port exposed-only
The image records {"8000/tcp":{}}, and the container's port column shows 8000/tcp. However, docker port exposed-only produces no mapping. The docker port reference defines this command as a mapping inspection tool; it is not an application health check.
Try a request from the Docker host terminal:
curl --max-time 3 http://127.0.0.1:8080/
It should fail if nothing else is listening on host port 8080. If it succeeds, inspect that other service before continuing: this container did not publish that address.
Now make a request inside the container:
docker exec exposed-only python -c 'import urllib.request; print(urllib.request.urlopen("http://127.0.0.1:8000").status)'
The result is 200. This test uses Python because the image already contains it; you do not need to install curl inside the container.
Together, the observations say something precise: the application answers HTTP internally, but this container has no host port mapping. They do not say that the application is inaccessible from every possible network location. On a Linux bridge, the host and other containers on the same bridge may reach a reachable container listener directly. Docker explains that distinction in its bridge network documentation.
Experiment 2: publish the host entrance
Start a second container from exactly the same image:
docker run -d --name published -p 127.0.0.1:8080:8000 port-demo
After the server starts, inspect and test it:
docker port published
curl -s -o /dev/null -w 'Host HTTP status: %{http_code}\n' http://127.0.0.1:8080/
The mapping is 8000/tcp -> 127.0.0.1:8080, and the request returns Host HTTP status: 200. If a request made immediately after docker run fails, check the logs and retry after startup; creating the container and accepting HTTP are separate events.
The same image, tested in a LabEx VM. Adding a host mapping changes the host request result without changing the Dockerfile.
You can now open http://127.0.0.1:8080 in a browser on the Docker host. If Docker runs in a remote VM, your laptop's browser is a different machine; use the remote-preview section below.
Why specify 127.0.0.1? With Docker's default settings, omitting the host IP publishes on all host interfaces. Explicit loopback binding is the appropriate starting point for a local development example. It does not add authentication or encryption. Docker also documents a caveat for engines older than 28.0.0: another host on the same layer-2 network may reach a localhost-published port. Our older demonstration VM is not evidence that loopback publishing provides complete isolation. See Docker's publishing and mapping security notes.
Experiment 3: a mapping exists, but the listener is wrong
Now keep port publishing and change only the application's listening address. Use host port 8081 so the working container can remain available for comparison:
docker run -d --name wrong-bind -p 127.0.0.1:8081:8000 \
port-demo python -u -m http.server 8000 --bind 127.0.0.1
The command after port-demo replaces this image's default CMD. Python now listens only on the container's loopback interface.
Check the mapping, then test from the host:
docker port wrong-bind
curl --max-time 3 http://127.0.0.1:8081/
A mapping exists, yet the HTTP request fails. Depending on your engine and network implementation, the error may be a reset or another connection failure; the important observation is that you do not receive the page.
Repeat the internal check:
docker exec wrong-bind python -c 'import urllib.request; print(urllib.request.urlopen("http://127.0.0.1:8000").status)'
docker logs --tail 5 wrong-bind
The internal request returns 200, and the startup log identifies 127.0.0.1 as the bind address.
Port forwarding exists. The application is listening at the wrong address for traffic arriving through the container's network interface.
There are two different loopbacks here. 127.0.0.1 in the publishing argument belongs to the host. 127.0.0.1 in the Python command belongs to the container. Forwarded traffic arrives at the container's network interface, not its private loopback listener.
For this server, restore --bind 0.0.0.0 by recreating the container with the image's default command:
docker rm -f wrong-bind
docker run -d --name wrong-bind -p 127.0.0.1:8081:8000 port-demo
curl --retry 5 --retry-all-errors --retry-delay 1 http://127.0.0.1:8081/
This final read-only request retries startup connection failures, including resets. The --retry-all-errors option requires curl 7.71.0 or newer; with an older curl, wait for the startup log and repeat the request manually.
The host-side mapping can stay on 127.0.0.1 while the application listens on 0.0.0.0 inside the container. These settings control different boundaries. 0.0.0.0 is a listening address meaning all IPv4 interfaces in that network namespace, not the URL to type into your browser.
Viewing the page from a LabEx VM
A remote experiment adds another step to the request path:
Your browser → LabEx web preview → VM port 8082
→ Docker mapping → container port 8000
For the captured browser demonstration, we started a separate preview container:
docker run -d --name preview -p 8082:8000 port-demo
This mapping omits a host IP so the VM's preview service can reach it. In the LabEx environment, open a web preview for VM port 8082 and use the provided preview URL. The exact preview controls depend on the lab environment. Typing localhost:8082 into your laptop browser would target your laptop instead.
The actual example page reached through the LabEx VM preview. The temporary environment URL is excluded from the capture.
Only the disposable static demo is being served here. On your own remote server, publishing on all interfaces changes who may reach the application; choose the host binding and any surrounding network controls deliberately. A successful curl inside the VM followed by a failed external preview means you should inspect the preview port and intervening network path before editing EXPOSE again.
A troubleshooting order that preserves the evidence
When another container is unreachable, start with the closest observable boundary and work outward.
| Question | Check | How to interpret it |
|---|---|---|
| Is the container still running? | docker ps -a --filter name=published |
An exited process needs log inspection before networking changes |
| What did the application report? | docker logs --tail 50 published |
Look for startup errors, port numbers, and bind addresses |
| What command actually started? | docker inspect published --format '{{json .Config.Cmd}}' |
A runtime override may differ from the Dockerfile |
| Is there a host mapping? | docker port published |
No mapping means no published host entrance for this container |
| Does HTTP work inside? | Use the internal Python request above | Success establishes internal HTTP response, not outside reachability |
| Does HTTP work on the Docker host? | curl -v --max-time 3 http://127.0.0.1:8080/ |
Failure with internal success narrows attention to mapping and bind address |
| Does the remote browser use the same host and port? | Compare its destination with the VM preview configuration | A laptop localhost URL does not target a remote VM |
The logs command retrieves container output, while inspect returns configuration and state. Neither replaces a request to the actual endpoint. Some applications also write logs to files rather than standard output, so an empty docker logs result is not proof that nothing happened.
If Docker reports that a host port is already allocated, change the host number, for example to 127.0.0.1:8083:8000, and use 8083 in your URL. Changing the container number to 8083 would point forwarding at a port where this server is not listening.
An HTTP 404 or 500 is different from a connection failure: an HTTP server responded. Confirm you reached the intended service, then investigate its path or application behavior. Repeatedly rebuilding the image to add more EXPOSE lines will not explain an HTTP error response.
Questions that come up next
Do I need EXPOSE if I already use -p?
No. Explicit publishing does not require an EXPOSE declaration. Keeping the declaration can still communicate the image's intended port to people and tools. It should agree with the application configuration, but it does not enforce that configuration.
To test that distinction yourself, remove EXPOSE 8000, build under a second tag, and run it with an explicit mapping on an unused host port. Avoid using a base image that already declares the same port if your experiment is intended to show the absence of exposed-port metadata.
What is the difference between -p and -P?
Lowercase -p specifies a publication, such as -p 127.0.0.1:8080:8000. Uppercase -P publishes all exposed ports to automatically selected host ports. Inspect the resulting mappings rather than guessing the chosen numbers. Neither option changes the application's listening port or address.
Does Compose expose publish a port?
No. For the working example, the equivalent host publication belongs under ports:
services:
web:
image: port-demo
ports:
- "127.0.0.1:8080:8000"
Stop the earlier published container before starting this service, because both would otherwise claim host port 8080. The image must already exist on the Docker host for this example.
Compose's expose describes container ports without publishing them to the host. It is not a firewall between services. The official Compose services reference documents both fields and their different purposes.
Do two containers need published ports to communicate?
Not when they can already communicate over the same user-defined bridge network. They can use a container name and the application's container port, such as web:8000; the application still needs to listen on a reachable interface. Publishing is for the additional host entrance. Do not publish a database port merely because another container needs to talk to the database.
Clean up, then practice a different failure
Remove only the containers created by this guide:
docker rm -f exposed-only published wrong-bind preview
If you skipped the preview step, Docker will report that preview does not exist. If you tried the optional Compose example, stop it with docker compose down from its project directory as well. Keep port-demo if you want to repeat the experiments; otherwise remove that image with docker image rm port-demo.
For the next practice session, choose the gap your experiment exposed:
- If reading
docker runarguments was the hard part, work through Docker Run Command Parameters. - If you want to extend the example to container networks, continue with Docker Networking Basics and Docker Network Playground.
- If addresses, processes, and service troubleshooting still feel disconnected, use the networking and service checkpoints in the Linux learning roadmap.
Repeat the failed request before applying a fix, change one boundary, and repeat the same request afterward. The useful result is being able to explain which observation changed and why.
References
- Dockerfile reference: EXPOSE — exposed-port metadata and its relationship to explicit publishing.
- Docker: Publishing and exposing ports — mapping syntax and automatically selected host ports.
- Docker: Port publishing and mapping — host binding scope and the pre-28.0.0 localhost caveat.
- Docker: Bridge network driver — communication within a bridge and name resolution on user-defined networks.
- Docker CLI: container port — inspect an existing port mapping.
- Docker CLI: container logs and container inspect — distinguish application output from container configuration.
-
Docker Compose services reference —
ports,expose, and service configuration. - curl manual — retry behavior for the post-startup verification request.
- Python 3.12: http.server — the demonstration server, bind address, and production limitations.
- LabEx: Docker for Beginners — the related image, container, and networking practice sequence.



Top comments (0)