Running Containers Deliberately

The run flags that matter — detached, interactive, published ports, restart policies and resource limits — plus why your container exits immediately and how to prove it.

beginner 22 min lesson hands-on task included

docker run has over a hundred flags. Eight of them account for nearly everything you will do. This lesson is those eight, plus the failure mode that costs beginners the most time: the container that starts and immediately exits.


Topic 1: The Three Running Modes

Every container runs in one of three modes, and they are just flag combinations.

Attached is the default. The container runs in the foreground and its stdout/stderr are wired to your terminal. Ctrl-C stops it.

docker run nginx:alpine

Detached (-d) runs it in the background. Docker prints the container ID and returns your prompt.

docker run -d nginx:alpine

Interactive (-it) gives you a terminal inside the container. -i keeps stdin open, -t allocates a pseudo-TTY. You almost always want both together.

docker run -it alpine /bin/sh
docker run -it ubuntu bash

Note what the last argument does: it replaces the image’s default command. alpine’s default is /bin/sh anyway, but on an image whose default is a server, appending /bin/sh means you get a shell instead of the server. That is a debugging tool, not a way to run the app.

To leave an interactive container without stopping it, detach with Ctrl-P Ctrl-Q. Typing exit ends the shell, and if the shell was PID 1, ending it ends the container.


Topic 2: Why Your Container Exited Immediately

This is the single most common beginner question, and the rule behind it is one sentence:

A container lives exactly as long as its main process (CMD/ENTRYPOINT) runs.

Not “as long as something is installed in it”. Not “until you stop it”. As long as PID 1 inside it is alive.

docker run alpine echo hello        # prints hello, PID 1 exits, container exits. Correct behaviour.
docker run -d nginx:alpine          # nginx runs in the foreground forever, so the container stays up
docker run -d ubuntu                # exits instantly: ubuntu's default command is bash with no TTY

The trap that catches everyone once:

docker run -d spc:1.0 echo hello

You expect the app plus an echo. What you get is only the echo, because your trailing command replaced the image’s CMD. Docker ran echo hello, it finished, the container exited. The application never started.

The same rule explains why background daemons are wrong inside containers. If your entrypoint script starts nginx with nginx (which daemonises and returns) rather than nginx -g 'daemon off;', PID 1 exits immediately and the container dies with a perfectly healthy nginx that lived for four milliseconds. Every containerised process must run in the foreground.

Diagnosis is always the same three commands:

docker ps -a                     # confirm it exited and see the exit code in STATUS
docker logs <name>               # what it said before it died
docker inspect <name> --format '{{.State.ExitCode}} {{.State.Error}}'

Topic 3: Naming, Because IDs Are Unusable

Every container gets a unique name and ID. If you do not supply a name, the daemon invents one like optimistic_germain. Those are fine for throwaways and terrible for anything you will refer to twice.

docker run -d --name api nginx:alpine
docker logs api
docker exec -it api sh
docker stop api && docker rm api

Two properties worth knowing: names must be unique on the host (a stopped container still holds its name — docker rm it before reusing), and everywhere a container ID is accepted, a name is too.

docker run -d --rm --name scratch alpine sleep 60    # --rm auto-removes on exit

--rm is the right default for anything interactive or one-shot. It is how you avoid accumulating hundreds of exited containers.


Topic 4: Publishing Ports

A container gets its own network namespace and its own IP. Nothing outside the host reaches it until you publish a port.

docker run -d -p 8080:80 nginx:alpine        # host 8080 → container 80
docker run -d -p 127.0.0.1:8080:80 nginx     # bind to loopback only — not the world
docker run -d -P nginx:alpine                # publish every EXPOSEd port to a random high port

The order is -p host:container. Getting it backwards is a rite of passage. Read it as “traffic arriving at host port goes to container port”.

-P (capital) publishes all ports the image declared with EXPOSE, each to an arbitrary free host port. Useful when you want ten copies of a service without picking ports by hand; useless when something needs to know where to connect.

docker port api                  # 80/tcp -> 0.0.0.0:32790
docker ps                        # the PORTS column shows the same mapping

Two clarifications that save arguments:

  • EXPOSE in a Dockerfile opens nothing. It is metadata — documentation that -P happens to read. Only -p/-P at run time actually publishes.
  • Containers on the same user-defined network reach each other directly, on the container port, without any publishing. Publishing is for traffic from outside the Docker host. Lesson 9 makes this precise.

Topic 5: Restart Policies

By default a container that exits stays exited. Nothing restarts it. If you want otherwise, say so:

PolicyBehaviour
noThe default. Never restart
on-failure[:N]Restart only on a non-zero exit, optionally at most N times
alwaysAlways restart, including after a daemon restart or host reboot
unless-stoppedLike always, but does not come back if you stopped it deliberately
docker run -d --restart unless-stopped --name api nginx:alpine
docker update --restart=always api            # change it on a running container

unless-stopped is usually the policy you want on a single host: it survives reboots, but respects an operator who deliberately stopped the container. always will helpfully restart the thing you stopped on purpose at 2am, which is memorable in the wrong way.

Note the interaction with the exit-code rule from Topic 2: a restart policy on a container whose command genuinely completes turns an exit into a restart loop. The container is not broken; your command is not a long-running process.


Topic 6: Resource Limits

If you set no limits, a container may use every CPU and all the memory on the host. There is no default cap. One runaway container starves everything else on that machine, including the daemon.

docker run -d --name gol --cpus 1 --memory 128m --memory-swap 128m gol:1.0
docker update --memory 256m --memory-swap 256m gol      # adjust a running container
FlagEffect
--cpus 1.5Cap at 1.5 cores’ worth of CPU time (cgroup quota)
--cpuset-cpus 0,1Pin to specific physical cores
--memory 512mHard memory limit; exceeding it gets the process OOM-killed
--memory-swapMemory + swap total. Set equal to --memory to disable swap
--pids-limit 200Cap process count — cheap protection against a fork bomb

The memory limit behaves differently from the CPU limit and the difference matters. Exceeding CPU makes you slow: the kernel throttles you and your latency graph gets ugly with no error anywhere. Exceeding memory makes you dead: the kernel OOM-killer terminates the process, and you see exit code 137 with OOMKilled: true in docker inspect.

docker stats                     # live view — this is where a throttled container shows up
docker inspect api --format '{{.State.OOMKilled}} {{.State.ExitCode}}'

Set limits on everything you run in production. A container without limits is a promise that your application will never have a bad day.


Topic 7: Getting Inside a Running Container

docker exec -it api /bin/bash             # new process in the container's namespaces
docker exec -it api sh                    # alpine and distroless-ish images: sh, not bash
docker exec api ls /var/log               # run one command without an interactive shell
docker attach api                         # attach to PID 1's stdin/stdout — different thing

exec starts a new process alongside the app. attach connects you to the existing PID 1 — and Ctrl-C there sends SIGINT to your application. Use exec. Reach for attach only when you specifically want PID 1’s stream.

Two more that pull data across the boundary:

docker cp api:/app/config.yaml ./config.yaml     # out of the container
docker cp ./patch.conf api:/etc/nginx/conf.d/    # into the container
docker logs --since 2024-11-06T05:00 --tail 100 -f api

docker logs shows whatever the main process wrote to stdout and stderr — which is exactly why containerised applications should log to stdout rather than to a file inside the container. A log file inside a container is a log file nobody will ever read.

Try it yourself: Run a container with --memory 32m and inside it run something that allocates aggressively (docker run --rm -it --memory 32m alpine sh -c 'yes | head -c 100M > /dev/null' will not do it, but python -c "a='x'*100_000_000" in a python image will). Then check docker inspect for OOMKilled: true and note the 137 exit code. Recognising 137 on sight is a genuinely useful reflex.

Common mistake: Running docker run again when you meant docker start. docker run always creates a new container from the image — so the file you edited, the package you installed and the data you generated inside the old one are not there. If you want the container you already had, it is docker start <name>, and if you want your changes to persist across containers, that is lesson 8.