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 sh gives you an interactive shell (-i keeps standard input open, -t allocates a terminal) in a throwaway container; --rm removes it when you exit the shell.
  • docker exec -it kestrel-meter-api sh opens 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>
A laptop at 198.51.100.23 connects to host 192.0.2.10 on port 8080, which is published on all addresses with -p 8080:80. A second published port, 127.0.0.1:18080 from -p 127.0.0.1:18080:80, can only be reached from the host itself. Docker forwards both to port 80 of the container at 172.17.0.3 (with NAT rules, or the docker-proxy process for the loopback address). office laptop 198.51.100.23 host 192.0.2.10 0.0.0.0:8080, [::]:8080 -p 8080:80 reachable from anywhere 127.0.0.1:18080 -p 127.0.0.1:18080:80 reachable from the host only Docker's forwarding container 172.17.0.3 port 80
The host IP in -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.

States of a container. Created goes to running with start. Running goes to exited with stop, kill or when the process exits. Exited goes back to running with start or through a restart policy, and to removed with rm. Running goes to paused with pause and back with unpause. docker run is create plus start. created running exited removed paused start stop, kill or process exits rm start, or a restart policy pause unpause docker run = create + start
The main states. While a restart policy is bringing a container back, its status reads 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.