DEV Community

Cover image for Docker ports explained with EXPOSE expose and ports
Sharma
Sharma

Posted on

Docker ports explained with EXPOSE expose and ports

A container can be running normally while your browser gets a connection error. The application may be listening on the right port inside the container, but nothing has published that port on the host.
Docker uses three similar-looking settings here: EXPOSE in a Dockerfile, expose in Compose, and ports in Compose. The first two describe container ports. The third creates a host port mapping. None of them starts a web server or changes the port on which your application listens.
This walkthrough uses a small HTTP service to show the difference. It assumes Docker Engine with the Compose plugin, ordinary bridge networking, and a terminal on the Docker host. Host networking, custom routing and Kubernetes are outside its scope. The examples are a disposable learning setup, not a production deployment.
Three settings with different effects
EXPOSE 8000 records an intended port in an image. You can inspect that metadata, but writing it does not publish port 8000 on the host. An application can also listen on a port that the Dockerfile never mentions. Docker’s Dockerfile reference explains this distinction and the separate -P option that publishes exposed ports to automatically assigned host ports.
Compose expose declares container-side ports without creating a host mapping. It is not an access-control list: leaving a port out does not prevent another container on the same network from connecting to a listening service. See the Compose service reference.
Compose ports maps a host address and port to a container port. In 127.0.0.1:8080:8000, the host-side port is 8080 and the application-side port is 8000. The address restricts this mapping to the host’s IPv4 loopback interface.
Build a small service without publishing it
Create an empty directory for this exercise. Save these three files in it. The image tag selects a Python 3.13 Alpine image; tags can change, so a repeatable production build should pin a reviewed digest.
Dockerfile
FROM python:3.13-alpine
WORKDIR /site
COPY index.html .
EXPOSE 8000
CMD ["python", "-m", "http.server", "8000", "--bind", "0.0.0.0"]
index.html

Container port 8000 is responding

compose.yaml
services:
web:
build: .
expose:
- "8000"
Python’s basic HTTP server keeps this example small. Do not use it to serve a real public application. The explicit 0.0.0.0 binding lets it accept connections through the container’s network interface; 127.0.0.1 inside the container would refer to that container alone.
Run these commands from the directory containing the files:
docker compose up -d --build
docker compose ps
docker compose logs web
With a successful build, the service should be running. The port display may show 8000/tcp; that is not a host mapping. You have not configured anything like 127.0.0.1:8080->8000/tcp yet.
Reach it from another container
Compose gives services on its default network names that other attached containers can resolve. Add a temporary client so the complete compose.yaml becomes:
services:
web:
build: .
expose:
- "8000"
probe:
image: python:3.13-alpine
profiles: ["tools"]
Request the page through the service name:
docker compose run --rm probe python -c '
import urllib.request
with urllib.request.urlopen("http://web:8000", timeout=5) as r:
print(r.read().decode())
'
The expected response contains the heading from index.html. This is an expected result for the example, not a recorded benchmark. If the request fails, check that web is running before investigating publishing rules.
Now remove the expose section, leaving web with only build: .. Run docker compose up -d --build and repeat the probe. It should still work. Containers sharing this user-defined bridge network can reach listening ports without an expose declaration. Docker documents the behaviour in its bridge network guide.
The probe uses web:8000, not localhost:8000. Inside the probe, localhost means the probe itself. Docker’s Compose networking guide covers service names and the difference between container and host ports.
Publish a port for the host
Replace compose.yaml with this version:
services:
web:
build: .
ports:
- "127.0.0.1:8080:8000"
probe:
image: python:3.13-alpine
profiles: ["tools"]
Apply it, inspect the mapping, and request the page from the Docker host:
docker compose up -d --build
docker compose port web 8000
curl --fail http://127.0.0.1:8080
The mapping command should report 127.0.0.1:8080. The host uses port 8080; the probe continues to use web:8000. Publishing has not changed Python’s listening port.
On a remote VPS, run that curl command in your VPS terminal. Opening the same URL on your laptop contacts the laptop. For local browser access, an SSH tunnel is one option:
ssh -N -L 8080:127.0.0.1:8080 user@your-vps
Replace the account and hostname, keep the tunnel open, and browse to http://127.0.0.1:8080 on the laptop. Its local port 8080 must be free.
Check the address before going public
Changing the mapping to 8080:8000 normally publishes on all host interfaces. Whether someone can reach it then also depends on routing and firewall rules. Keep the loopback mapping for this exercise. Docker’s port publishing documentation also notes that releases before 28.0.0 had a localhost-publishing caveat involving other machines on the same layer-2 network; use a supported, updated engine.
On Linux, published Docker traffic can bypass the filtering path people expect from UFW. Review Docker’s firewall documentation for your backend rather than assuming an ordinary host rule covers every published container. A real deployment needs verification from outside the server, including IPv6 where enabled.
For an application behind a reverse proxy, the proxy’s location determines the connection. A proxy on the host can use a loopback mapping. A proxy in another container normally reaches the application through a shared Docker network and its service name. Databases used only by application containers generally do not need a published host port.
When you are done, run docker compose down from this exercise directory. It removes the example containers and project network; the image and source files remain.
I’m affiliated with Hostomize. If you still need the host setup, our guide to installing Docker on a VPS covers that earlier step.
Before adding another port mapping, name the client that needs it: another container, the host, or an external user. That answer usually tells you which address and port to configure.

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

The distinction between EXPOSE being documentation and ports actually binding the port is the sentence I wish every Docker tutorial led with. It's the root of a lot of "container runs but I can't connect" debugging, and the walkthrough makes the mechanics concrete without burying the point in Compose edge cases.

One gap worth covering: when you run a container with docker run -p but the image also declares EXPOSE, there's no conflict, but when people mix Compose expose and ports on the same service the mental model gets muddy fast. A short table mapping the three settings to what they actually change on the host would turn this from a good guide into the reference I'd bookmark.