Skip to content

Ports and Environment Variables

Measured on Docker Desktop 4.81.0 / Engine 29.6.1 (macOS, linux/arm64), 2026-09-30. The Mac’s LAN address in the tests was 192.168.1.86.

By default a container is fully isolated — its ports are not reachable from the host. The -p flag publishes a port, creating a mapping between a host port and a container port:

Terminal window
docker run -d -p 8080:80 nginx:1.31-alpine

The format is -p <host-port>:<container-port>. After this command, opening http://localhost:8080 in your browser reaches port 80 inside the container. docker port shows the mapping; on Engine 29 a plain -p is published on both IPv4 and IPv6, so there are two lines:

$ docker port web
80/tcp -> 0.0.0.0:18010
80/tcp -> [::]:18010

You can publish multiple ports:

Terminal window
docker run -d -p 8080:80 -p 8443:443 nginx:1.31-alpine

-P (capital) publishes every port the image EXPOSEs to a random high host port. -p 80 (no host part) does the same for one port:

$ docker run -d -P nginx:1.31-alpine # image EXPOSEs 80
$ docker port <container>
80/tcp -> 0.0.0.0:55002
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:55002
200

The ephemeral port here was 55002, inside the VM’s ephemeral range (cat /proc/sys/net/ipv4/ip_local_port_range printed 32768 60999). Use it for tests where the number does not matter; then discover it with docker port. EXPOSE in a Dockerfile only documents a port; without -p/-P nothing is published (docker ps shows 80/tcp with no arrow).

Terminal window
# Only accessible from localhost, not the network
docker run -d -p 127.0.0.1:8080:80 nginx:1.31-alpine

Proven with two containers, one published as -p 18010:80 and one as -p 127.0.0.1:18011:80, requested from the Mac itself and from its LAN address 192.168.1.86:

127.0.0.1:18010 -> 200 # all interfaces, loopback
192.168.1.86:18010 -> 200 # all interfaces, LAN address: any machine on the LAN could do this
127.0.0.1:18011 -> 200 # loopback-only, loopback
192.168.1.86:18011 -> curl rc=7 # loopback-only, LAN address: connection refused

-p 8080:80 is reachable from every machine that can route to your host. Docker Desktop for Mac proxies the port itself, so this test matched what a Linux host would do; on Linux the docs additionally note that published ports bypass ufw/firewalld rules (Docker with iptables).

Containers read configuration from environment variables. Pass them with -e:

Terminal window
docker run -d --name db \
-e POSTGRES_PASSWORD='<your-db-password>' \
-e POSTGRES_DB=myapp \
postgres:18-alpine

Each -e sets one variable inside the container. The postgres image requires one: with none, the container exits at once:

$ docker run --rm postgres:18-alpine
Error: Database is uninitialized and superuser password is not specified.
You must specify POSTGRES_PASSWORD to a non-empty value for the
superuser. For example, "-e POSTGRES_PASSWORD=password" on "docker run".

With the variables, psql -U postgres -d myapp -tAc "select current_database()" printed myapp. Placeholders here are angle-bracket fakes: put your real value in a secret store, not in a lesson or a repository.

-e NAME with no value copies NAME from your shell (FROMHOST=from-mac docker run -e FROMHOST … printed from-mac); if the shell has no such variable, nothing is set (NOPE=[unset]).

For more than a few variables, keep them in a file and use --env-file:

.env
POSTGRES_PASSWORD=<your-db-password>
POSTGRES_DB=myapp
POSTGRES_USER=app
Terminal window
docker run -d --name db --env-file .env postgres:18-alpine

The file is read by the Docker CLI: one NAME=value per line, # lines are ignored. Quotes are not removed, unlike in a shell or a Compose .env:

# t.env: GREETING="hello world" PLAIN=abc SPACED=a b EMPTY=
$ docker run --rm --env-file t.env alpine:3.24 sh -c 'env | grep -E "GREETING|PLAIN|SPACED|EMPTY"'
GREETING="hello world" # the double quotes are part of the value
PLAIN=abc
SPACED=a b # spaces are kept without quoting
EMPTY=

-e GREETING="hello world" on the command line gives hello world because your shell strips the quotes. So the same intent behaves differently between the two mechanisms: in an env file, write GREETING=hello world, not GREETING="hello world".

$ docker run -d --name x -e DB_PASSWORD='<your-db-password>' alpine:3.24 sleep 60
$ docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' x | grep DB_
DB_PASSWORD=<your-db-password>
$ docker exec x sh -c 'cat /proc/1/environ | tr "\0" "\n" | grep DB_'
DB_PASSWORD=<your-db-password>

Anyone who can run docker inspect (or read /proc/<pid>/environ) sees the value; it also ends up in your shell history when typed with -e. --env-file keeps it out of the history and the process list, but not out of docker inspect. For real secrets use a secret mechanism (Compose secrets:, Swarm/Kubernetes secrets, a runtime fetch from a vault).

Giving a container a predictable name makes every subsequent command easier:

Terminal window
docker run -d \
--name mysite \
-p 8080:80 \
nginx:1.31-alpine

Now you can use mysite instead of a random ID in every docker stop, docker logs, and docker exec command. A name must be unique and stays reserved while the container exists, even when stopped (first-container showed the conflict error).

Terminal window
docker run -d \
--name webserver \
-p 8080:80 \
-e NGINX_HOST=localhost \
nginx:1.31-alpine
# Verify
docker ps
curl http://localhost:8080
# Run nginx with a port mapping and an env var
docker run -d \
  --name webserver \
  -p 8080:80 \
  -e NGINX_HOST=localhost \
  nginx:1.31-alpine

# Confirm the mapping
docker ps
docker port webserver

# Fetch the default page (should return HTML)
curl -s http://localhost:8080 | head -5

# Inspect the env var inside the container
docker exec webserver env | grep NGINX

# Clean up
docker stop webserver && docker rm webserver

An AI-written run script for a small Node service that “listens on 3000”:

🐛 Spot the bug

app/server.js
const http = require('http');
// AI: "bind to localhost for safety"
const host = process.env.HOST || '127.0.0.1';
http.createServer((req, res) => res.end('hello\n'))
.listen(3000, host, () => console.log(`listening on ${host}:3000`));
Show the answer

Run with the usual -p 3000:3000 (here -p 127.0.0.1:18020:3000), the published port answers with an empty reply. The app is only listening on the container’s own loopback, and Docker forwards published traffic to the container’s network interface, not to its loopback:

$ docker logs api
listening on 127.0.0.1:3000
$ curl -sS http://127.0.0.1:18020/
curl: (52) Empty reply from server
$ docker exec api wget -qO- http://127.0.0.1:3000/ # from inside the container it works
hello

The application is fine and the port mapping is fine; the bind address is the bug. Inside a container, bind to 0.0.0.0 and let -p decide who can reach it from outside:

scripts/run-api.sh
#!/bin/sh
set -eu
# app/server.js reads HOST; 0.0.0.0 = all container interfaces
docker run -d --name api -e HOST=0.0.0.0 \
-p 127.0.0.1:3000:3000 \
-v "$PWD/app:/app:ro" \
node:24-alpine node /app/server.js

Result: listening on 0.0.0.0:3000 and curl returned hello. “Bind to localhost for safety” is good advice on a shared host, but inside a container the safety belongs to the 127.0.0.1: prefix of -p, not to the app.

A published port is a forwarding rule from the host side to the container’s network interface. Three independent things must line up: the app listens on a non-loopback address inside the container, the container’s port matches the right-hand number of -p, and the host side (127.0.0.1: or all interfaces) matches who should be allowed in. Environment variables are the container’s inputs: copied into the container’s process at start, visible to anyone with Docker access, and read only once by most programs.

OptionBenefitCost
Bind only 127.0.0.1 (-p 127.0.0.1:8080:80)Safer — only reachable from the same host (LAN request got connection refused)Not reachable from other machines on the network; needs a reverse proxy to share
Bind all interfaces (-p 8080:80)Reachable from the network immediately, simpleWide open — risks unintended access if there is no firewall in front of it
-P / ephemeralNo port conflictsNumber changes every run; must look it up
-e on the command lineExplicit and visible in scriptsIn shell history and ps output
--env-fileKeeps values out of history, many variables at onceQuotes are literal; still visible in docker inspect
  • Hardcoding config like a DB password or hostname into the Dockerfile with ENV instead of passing it via -e/--env-file at run time, so every change needs a new image build (and the value is baked into the image history).
  • Committing a .env file with real secrets to git instead of using .env.example as a template.
  • Forgetting to publish a port with -p and wondering why curl from the host gets connection refused even though the container is running fine.
  • Publishing a database with -p 5432:5432 (all interfaces) when only the app on the same host needs it.
  • Writing NAME="value with spaces" in an --env-file and getting literal quotes in the value.
  • “EXPOSE opens the port.” It only documents it. Without -p/-P, nothing is published.
  • “docker port prints one line.” On Engine 29 it prints one line per address family, so a plain -p yields 0.0.0.0 and [::].
  • “--env-file is a secure way to pass secrets.” It hides the value from history and ps, not from docker inspect.
  • “If the container is up and the port is mapped, the app is reachable.” Only if the app listens on 0.0.0.0 inside the container (empty reply proven for 127.0.0.1).
What does -p 8080:80 do?
You start a container with -p 127.0.0.1:8080:80. Who can reach it?
What is the difference between -e NAME=value and --env-file .env?
Are environment variables passed with -e hidden from other users of the Docker host?
A service listens on 127.0.0.1:3000 inside the container and you run it with -p 3000:3000. What does curl on the host see?
What does -P (capital) do?