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.
Publishing ports with -p
Section titled “Publishing ports with -p”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:
docker run -d -p 8080:80 nginx:1.31-alpineThe 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 web80/tcp -> 0.0.0.0:1801080/tcp -> [::]:18010You can publish multiple ports:
docker run -d -p 8080:80 -p 8443:443 nginx:1.31-alpineLet Docker pick the host port with -P
Section titled “Let Docker pick the host port with -P”-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:55002200The 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).
Bind to a specific host IP
Section titled “Bind to a specific host IP”# Only accessible from localhost, not the networkdocker run -d -p 127.0.0.1:8080:80 nginx:1.31-alpineProven 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, loopback192.168.1.86:18010 -> 200 # all interfaces, LAN address: any machine on the LAN could do this127.0.0.1:18011 -> 200 # loopback-only, loopback192.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).
Passing environment variables with -e
Section titled “Passing environment variables with -e”Containers read configuration from environment variables. Pass them with -e:
docker run -d --name db \ -e POSTGRES_PASSWORD='<your-db-password>' \ -e POSTGRES_DB=myapp \ postgres:18-alpineEach -e sets one variable inside the container. The postgres image requires one: with none, the container exits at once:
$ docker run --rm postgres:18-alpineError: 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]).
Using an env file
Section titled “Using an env file”For more than a few variables, keep them in a file and use --env-file:
POSTGRES_PASSWORD=<your-db-password>POSTGRES_DB=myappPOSTGRES_USER=appdocker run -d --name db --env-file .env postgres:18-alpineThe 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 valuePLAIN=abcSPACED=a b # spaces are kept without quotingEMPTY=-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".
Environment variables are not secret
Section titled “Environment variables are not secret”$ 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).
Naming containers with --name
Section titled “Naming containers with --name”Giving a container a predictable name makes every subsequent command easier:
docker run -d \ --name mysite \ -p 8080:80 \ nginx:1.31-alpineNow 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).
Putting it together
Section titled “Putting it together”docker run -d \ --name webserver \ -p 8080:80 \ -e NGINX_HOST=localhost \ nginx:1.31-alpine
# Verifydocker pscurl 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 webserverReading AI-generated code
Section titled “Reading AI-generated code”An AI-written run script for a small Node service that “listens on 3000”:
🐛 Spot the bug
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 apilistening 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 workshelloThe 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:
#!/bin/shset -eu# app/server.js reads HOST; 0.0.0.0 = all container interfacesdocker 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.jsResult: 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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
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, simple | Wide open — risks unintended access if there is no firewall in front of it |
-P / ephemeral | No port conflicts | Number changes every run; must look it up |
-e on the command line | Explicit and visible in scripts | In shell history and ps output |
--env-file | Keeps values out of history, many variables at once | Quotes are literal; still visible in docker inspect |
Common mistakes
Section titled “Common mistakes”- Hardcoding config like a DB password or hostname into the Dockerfile with
ENVinstead of passing it via-e/--env-fileat run time, so every change needs a new image build (and the value is baked into the image history). - Committing a
.envfile with real secrets to git instead of using.env.exampleas a template. - Forgetting to publish a port with
-pand wondering whycurlfrom 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-fileand getting literal quotes in the value.
Common Misconceptions
Section titled “Common Misconceptions”- “
EXPOSEopens the port.” It only documents it. Without-p/-P, nothing is published. - “
docker portprints one line.” On Engine 29 it prints one line per address family, so a plain-pyields0.0.0.0and[::]. - “
--env-fileis a secure way to pass secrets.” It hides the value from history andps, not fromdocker inspect. - “If the container is up and the port is mapped, the app is reachable.” Only if the app listens on
0.0.0.0inside the container (empty reply proven for127.0.0.1).