Running Containers
Starting and managing containers: docker run and its most important options, publishing ports, environment variables, the container lifecycle, restart policies and resource limits.
What docker run does
docker run is the
command that creates a new container from an image and starts it.
Behind the one command are several steps: find the image locally or
pull it from its registry, create the container (its writable layer,
namespaces, cgroup and network connection), start its main process
and, unless told otherwise, attach your terminal to that process's
output. The
Cloud page
shows the basic form; this page goes through the options that matter
in operations. The general shape is:
docker run [OPTIONS] IMAGE [COMMAND] [ARG...]
Options go before the image name; anything after it is the command to run inside the container and replaces the image's default command. A complete example for the Kestrel meter-api, with every option explained in the sections below:
$ docker run -d --name kestrel-meter-api \
-p 127.0.0.1:18000:8000 \
-e LOG_LEVEL=info \
--memory 256m --cpus 0.5 \
--restart unless-stopped \
registry.example.com/kestrel/meter-api:2.3.0
| Option | Meaning |
|---|---|
-d |
Run in the background (detached) |
--name |
A fixed, readable name instead of a random one |
-p host:container |
Publish a container port on the host |
-e, --env-file |
Set environment variables |
--memory, --cpus |
Resource limits |
--restart |
What to do when the container stops |
--rm |
Remove the container automatically when it exits |
-it |
Interactive with a terminal, for a shell session |
-v, --mount |
Attach storage; see Volumes and Bind Mounts |
--network |
Attach to a network; see Container Networking |
A real run of an nginx web server on the recording machine, then the list of running containers:
$ docker run -d --pull never --name web -p 127.0.0.1:18080:80 nginx:alpine
$ docker ps --filter name=web
a03d67becdeef6e47253a97fa1adf111328b4f000895b8beba7348aad3c57161 CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES a03d67becdee nginx:alpine "/docker-entrypoint.…" 2 seconds ago Up 2 seconds 127.0.0.1:18080->80/tcp web
docker run -d prints the full container ID and returns;
docker ps shows the first 12 characters, which are
enough for every command, just like the name. (--pull never
made sure no image was downloaded, because nginx:alpine was already
present; the default, missing, pulls only when the
image is not there.)
Foreground, detached and interactive
Without -d, docker run stays attached: the
container's output appears in your terminal and the command ends
when the container does. That suits one-off jobs, such as a test run
of the billing export. For services it is a trap: closing the
terminal or pressing Ctrl+C stops the
container.
Detached mode
(-d) starts the container in the background and gives
the terminal back at once; the container keeps running after you log
out. Its output is not lost: Docker collects everything the main
process writes to
standard output
and standard error, and docker logs shows it later.
Two other combinations are common:
-
docker run --rm -it alpine:3.22 shgives you an interactive shell (-ikeeps standard input open,-tallocates a terminal) in a throwaway container;--rmremoves it when you exit the shell. -
docker exec -it kestrel-meter-api shopens a shell in a container that is already running, next to its main process. Use it to look, not to fix: changes made there are lost with the next container (see Debugging Containers).
Publishing ports
A container has its own network namespace and address (on the
default bridge network something like 172.17.0.3), which other
machines cannot reach. A
published port is a port on
the host that Docker forwards to a port of the container, much like
port forwarding
on a router. -p 127.0.0.1:18080:80 reads as "host
address 127.0.0.1, host
port
18080, to container port 80":
$ docker port web
$ curl -si http://127.0.0.1:18080/ | head -12
80/tcp -> 127.0.0.1:18080 HTTP/1.1 200 OK Server: nginx/1.27.5 Date: Tue, 06 Oct 2026 12:18:47 GMT Content-Type: text/html Content-Length: 615 Last-Modified: Wed, 16 Apr 2025 12:55:34 GMT Connection: keep-alive ETag: "67ffa8c6-267" Accept-Ranges: bytes <!DOCTYPE html> <html>
-p decides who can connect. Without
one, Docker listens on every address of the host.
Without a host address (-p 8080:80), Docker publishes
the port on all host addresses, IPv4 and IPv6, and
docker ps shows
0.0.0.0:8080->80/tcp, [::]:8080->80/tcp. That is
right for a public web server and wrong for almost everything else:
a database, an admin interface or a metrics endpoint should be
published on the
loopback address
only, or not at all. Containers on the same Docker network reach
each other without any published port.
A quick audit of a host: list the containers as JSON and flag every
mapping on all interfaces. The input below is sample output of
docker ps --format '{{json .}}', trimmed to two fields:
import json
# Output of: docker ps --format '{{json .}}' (trimmed to two fields)
lines = """\
{"Names":"kestrel-portal","Ports":"127.0.0.1:18080->8080/tcp"}
{"Names":"kestrel-db","Ports":"0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp"}
{"Names":"kestrel-meter-api","Ports":"8000/tcp"}
{"Names":"kestrel-metrics","Ports":"0.0.0.0:9100->9100/tcp"}"""
for line in lines.splitlines():
container = json.loads(line)
for mapping in container["Ports"].split(", "):
if "->" not in mapping:
print(f"{container['Names']:18} {mapping:26} not published")
elif mapping.startswith(("0.0.0.0:", "[::]:")):
print(f"{container['Names']:18} {mapping:26} ALL interfaces")
else:
print(f"{container['Names']:18} {mapping:26} local only")
kestrel-portal 127.0.0.1:18080->8080/tcp local only kestrel-db 0.0.0.0:5432->5432/tcp ALL interfaces kestrel-db [::]:5432->5432/tcp ALL interfaces kestrel-meter-api 8000/tcp not published kestrel-metrics 0.0.0.0:9100->9100/tcp ALL interfaces
8000/tcp without an arrow is a port the image declares
(with EXPOSE) but nobody published: it is reachable
from other containers on the same network, not from outside.
Environment variables
The same image runs in test and in production; what differs is
configuration, and the usual way to pass it is
environment variables. -e NAME=value sets one, -e NAME copies
the value from your current shell, and
--env-file meter-api.env reads
NAME=value lines from a file. Values set at
docker run override the defaults baked into the image
with ENV.
$ docker run -d --name kestrel-meter-api \
-e LOG_LEVEL=info \
-e DATABASE_HOST=kestrel-db \
--env-file meter-api.env \
registry.example.com/kestrel/meter-api:2.3.0
$ docker inspect --format '{{json .Config.Env}}' kestrel-meter-api
["LOG_LEVEL=info","DATABASE_HOST=kestrel-db","DATABASE_PASSWORD=example-only","PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","LANG=C.UTF-8","PYTHON_VERSION=3.13.16"]
Look at the third value. Everything passed as an environment
variable is readable by anyone who can run
docker inspect on the host, ends up in shell history
when typed with -e, and is visible to every process in
the container. That makes environment variables fine for settings
and a poor place for passwords and API keys. Better options are a
secret mounted as a file, which many images support through a
*_FILE variable that points to it (see
Docker Compose and
Container Security).
The container lifecycle
The container lifecycle
is the series of states a container goes through between creation
and removal, and the commands that move it from one to the next.
docker ps -a shows the state in the STATUS column.
Restarting.
| Command | Effect |
|---|---|
docker create |
Create the container without starting it |
docker start |
Start an existing container again, with its writable layer and settings intact |
docker stop |
SIGTERM, then SIGKILL after the timeout (default 10 s) |
docker kill |
SIGKILL at once (or another signal with -s)
|
docker restart |
Stop, then start |
docker pause / unpause |
Freeze and thaw all processes (using cgroups) |
docker rm |
Remove a stopped container and its writable layer;
rm -f also stops a running one
|
$ docker stop web
$ docker ps -a --filter name=web
$ docker rm web
$ docker rm web
web CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES a03d67becdee nginx:alpine "/docker-entrypoint.…" 13 seconds ago Exited (0) Less than a second ago web web Error response from daemon: No such container: web
After the stop the container still exists (Exited (0))
and its published port is gone from the PORTS column. The first
rm removes it; the second fails because there is
nothing left to remove.
Stopping, signals and PID 1
docker stop is polite first: it sends the main process
SIGTERM
so it can finish its requests and close files, waits up to 10
seconds (on Linux; change the wait per stop with
-t/--timeout, or per container with
docker run --stop-timeout; an image can choose a
different first signal with STOPSIGNAL), and then sends
SIGKILL, which cannot be refused. The main process runs as PID 1 in
its PID namespace, and the kernel gives PID 1 no default signal
handlers: a process there that does not handle SIGTERM itself simply
ignores it. Three stops, timed:
$ docker run -d --name pid1 alpine:3.22 sleep 300
$ time docker stop pid1
72aed9076dd4b031c859177b8b19231eddf9e537650f8cc3b939f9f2a9463002 pid1 real 0m10.241s user 0m0.008s sys 0m0.007s
sleep as PID 1 ignored SIGTERM, so Docker waited the
full 10 seconds and killed it. With --init, Docker puts
its small init program in front as PID 1; it passes SIGTERM on, and
sleep, no longer PID 1, ends at once:
$ docker run -d --name term --init alpine:3.22 sleep 300
$ time docker stop term
ad0fa3b2057d82fe1deb132177988be5babe6a5f5c52ffbc7a1c9f4d6a07db86 term real 0m0.158s user 0m0.007s sys 0m0.007s
The nginx container earlier stopped in 0.36 seconds without
--init, because nginx handles signals itself (its image
even sets STOPSIGNAL SIGQUIT, nginx's signal for a
graceful shutdown). Ten-second stops on every deployment are a sign
that the application never sees SIGTERM, often because the
Dockerfile starts it through a shell; see
CMD and ENTRYPOINT.
Exit codes
When the main process ends, the container stops and keeps that
process's
exit status.
Without -d, docker run returns the same
code to your shell, so scripts can test it. A series of deliberate
failures, each followed by echo "exit code: $?":
$ docker run --name ok alpine:3.22 sh -c 'echo meter reading accepted; exit 0'; echo "exit code: $?"
$ docker run --name fail alpine:3.22 cat /etc/kestrel/meter.conf; echo "exit code: $?"
$ docker run --rm alpine:3.22 meter-api; echo "exit code: $?"
$ docker run --rm alpine:3.22 /etc/hostname; echo "exit code: $?"
$ docker run --rm --memroy 16m alpine:3.22 true; echo "exit code: $?"
meter reading accepted exit code: 0 cat: can't open '/etc/kestrel/meter.conf': No such file or directory exit code: 1 docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "meter-api": executable file not found in $PATH: unknown Run 'docker run --help' for more information exit code: 127 docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "/etc/hostname": permission denied: unknown Run 'docker run --help' for more information exit code: 126 unknown flag: --memroy Usage: docker run [OPTIONS] IMAGE [COMMAND] [ARG...] Run 'docker run --help' for more information exit code: 125
The long error lines read from left to right through the stack on
What Are Containers?: the daemon asked containerd to create a task, containerd asked
runc, and runc could not start the program. The useful part is
always at the end. Two more codes come from signals: a
docker kill gave 137 and a SIGTERM that the process
obeyed gave 143, as docker ps -a showed afterward:
$ docker ps -a --filter name=pid1 --filter name=term --filter name=kill --filter name=fail --filter name=ok --format 'table {{.Names}}\t{{.Status}}'
NAMES STATUS pid1 Exited (137) Less than a second ago term Exited (143) 10 seconds ago kill Exited (137) 10 seconds ago fail Exited (1) 11 seconds ago ok Exited (0) 11 seconds ago
Codes above 128 follow the shell convention: 128 plus the signal
number. Python's signal module knows the names:
import signal
def explain(code):
if code == 0:
return "success"
if code == 125:
return "docker run itself failed (bad option, daemon error)"
if code == 126:
return "command found but could not be executed"
if code == 127:
return "command not found in the image"
if code > 128:
name = signal.Signals(code - 128).name
return f"ended by signal {code - 128} ({name})"
return "the application reported an error"
for code in [0, 1, 125, 126, 127, 137, 143]:
print(f"{code:3} {explain(code)}")
0 success 1 the application reported an error 125 docker run itself failed (bad option, daemon error) 126 command found but could not be executed 127 command not found in the image 137 ended by signal 9 (SIGKILL) 143 ended by signal 15 (SIGTERM)
Exit code 137 has two common causes that look identical in
docker ps: someone or something sent SIGKILL (a
docker kill, or a docker stop that ran out
of time, like pid1 above), or the kernel's
out-of-memory killer ended the process because the container hit its
memory limit.
Debugging Containers shows
how docker inspect tells them apart.
Restart policies
A restart policy tells the
Docker daemon what to do when a container's main process exits:
leave it stopped, or start it again. It is set with
--restart and is the single-host counterpart of
Restart= in a
systemd
unit.
| Policy | Restarts when | Typical use |
|---|---|---|
no (default) |
Never | One-off jobs, tests |
on-failure[:N] |
The exit code is not 0; with :N at most N times
|
Batch jobs that may hit a temporary error |
always |
Always; after a manual docker stop only when
the daemon restarts (for example after a reboot)
|
Services that must come back after a reboot |
unless-stopped |
Like always, but a container you stopped stays
stopped, even after a reboot
|
Long-running services on a single host |
A "meter-api" that cannot reach its database and exits with code 1,
under on-failure:3:
$ docker run -d --name flaky --restart on-failure:3 alpine:3.22 sh -c 'echo "meter-api starting $(date +%T)"; sleep 1; echo "cannot reach database"; exit 1'
$ docker inspect --format 'restarts={{.RestartCount}} exit={{.State.ExitCode}} policy={{.HostConfig.RestartPolicy.Name}}:{{.HostConfig.RestartPolicy.MaximumRetryCount}}' flaky
$ docker logs flaky
30cbaa41dfa0e08c51dd6e9bb70c53f65296b4e32ce4eec2e0c7c3fa4003ddeb restarts=3 exit=1 policy=on-failure:3 meter-api starting 12:19:41 cannot reach database meter-api starting 12:19:43 cannot reach database meter-api starting 12:19:44 cannot reach database meter-api starting 12:19:46 cannot reach database
One start plus three restarts, then the container stays
Exited (1). Docker waits a little longer before each
new attempt, so a crashing container does not flood the host: it
doubles the delay between restart attempts, starting at 100 ms, and
a run of 10 seconds or more resets it. The same container with
unless-stopped would keep restarting forever; in the
recording, a container that exited every 2 seconds had
restarts=3 after about 9 seconds and was still counting
until it was removed with docker rm -f. Docker's
documentation adds that a policy only counts a start as successful
once the container has been up for at least 10 seconds; a container
that crashes faster, as here, is still restarted.
The decision rules, as a small function:
def restarts(policy, event, max_retries=0, restarts_so_far=0):
"""Does Docker start the container again after this event?"""
if event == "docker stop":
return False # your own stop is respected
if event == "stop + reboot": # stopped by you, then the daemon restarted
return policy == "always"
code = event # the main process exited with this code
if policy in ("always", "unless-stopped"):
return True
if policy == "on-failure":
return code != 0 and (max_retries == 0 or restarts_so_far < max_retries)
return False # policy "no", the default
cases = [
("no", 1), ("on-failure", 0), ("on-failure", 1), ("always", 0),
("always", "docker stop"), ("always", "stop + reboot"),
("unless-stopped", "stop + reboot"),
]
for policy, event in cases:
verdict = "restart" if restarts(policy, event) else "stay stopped"
print(f"{policy:15} after {str(event):14} -> {verdict}")
print("on-failure:3, failures 1-4:",
[restarts("on-failure", 1, max_retries=3, restarts_so_far=n) for n in range(4)])
no after 1 -> stay stopped on-failure after 0 -> stay stopped on-failure after 1 -> restart always after 0 -> restart always after docker stop -> stay stopped always after stop + reboot -> restart unless-stopped after stop + reboot -> stay stopped on-failure:3, failures 1-4: [True, True, True, False]
Resource limits
By default a container has no resource limits: it may use every CPU core and all the memory of the host. One leaking process can then push the host's own services into swapping or out of memory. Limits are cgroup settings:
| Option | Effect |
|---|---|
--memory 256m |
Hard memory limit (minimum 6m). Above it, the kernel ends a process in the container. |
--memory-swap 256m |
Memory plus swap. Equal to --memory means: no
swap at all.
|
--cpus 0.5 |
At most half a CPU core's worth of time |
--cpu-shares 512 |
Relative weight (default 1024), only applied when CPUs are busy |
--pids-limit 200 |
At most 200 processes, against runaway forking |
docker stats shows usage against the limits. On a
normal host the MEM USAGE / LIMIT column of the meter-api would read
something like 38MiB / 256MiB. On the recording
machine, whose kernel runs without the memory controller, Docker
could not apply the limit and said so:
$ docker run --rm --pull never --name mem --memory 16m nginx:alpine sh -c 'cat /sys/fs/cgroup/memory.max 2>&1; ls /sys/fs/cgroup | head -5'
WARNING: Your kernel does not support memory limit capabilities or the cgroup is not mounted. Limitation discarded. cat: can't open '/sys/fs/cgroup/memory.max': No such file or directory cgroup.controllers cgroup.events cgroup.freeze cgroup.kill cgroup.max.depth
"Limitation discarded": the container ran without any memory limit,
and on a working host the file memory.max would have
contained the limit in bytes. It is a warning, not an error, so a
deployment script carries on. Read warnings in the output of
docker run and docker info; they are the
only sign that a limit you set does not exist.
Logs and disk space
docker logs shows what the main process wrote to
standard output and standard error; --tail 2 shows only
the last two lines, -f follows new lines,
--since 10m limits by time:
$ docker logs --tail 2 web
2026/10/06 12:18:38 [error] 31#31: *2 open() "/usr/share/nginx/html/missing" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /missing HTTP/1.1", host: "127.0.0.1:18080" 172.17.0.1 - - [06/Oct/2026:12:18:38 +0000] "GET /missing HTTP/1.1" 404 153 "-" "curl/7.88.1" "-"
The client address 172.17.0.1 is the bridge network's gateway: from the container's point of view, a request from the host comes from there. Reading logs well is part of Debugging Containers.
Where those lines are stored depends on the logging driver. The
default, json-file, writes them to a file per container
on the host and does not rotate it: a chatty
container fills the disk over weeks, and the logs of a removed
container disappear with it. The local driver rotates
by default. Set a default for new containers in
/etc/docker/daemon.json:
{
"log-driver": "local",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
The daemon reads this file at start, and the setting applies only to
containers created afterward; existing containers keep their driver
until they are recreated. Containers can also get their own options
with --log-opt max-size=10m. For the general principle,
see
log rotation; for a host that already ran full, see
disk full.