Docker and Podman in Practice
Linux Containers and Namespaces and cgroups covered what a container actually is at the kernel level — a process wrapped in namespaces and cgroups, created according to the OCI runtime spec. This article is where that theory turns into daily practice: building an image, running a container, persisting its data, and doing all of it with the two tools an administrator is most likely to meet in the field — Docker and its daemonless alternative, Podman.
What You Will Learn
By the end of this article you will be able to:
- start, inspect, and stop a container with
docker run,docker ps, anddocker logs; - write a minimal
Dockerfileand build an image from it; - persist container data across restarts with named volumes and bind mounts;
- explain the architectural difference between Docker's daemon model and Podman's daemonless, rootless model;
- migrate common Docker commands to Podman with minimal changes.
Docker: The Daemon-Based Model
Docker runs as a long-lived background process called dockerd. The docker command itself is only a client that talks to this daemon over a Unix socket — every container Docker creates is actually started, tracked, and torn down by the daemon, not by the docker binary you type at the terminal.
Installing Docker on Ubuntu
The convenience script adds Docker's official repository and installs the engine; adding your user to the docker group lets you run docker without sudo afterward. A new login session (or newgrp docker) is required for the group membership to take effect.
Warning
Membership in the docker group is effectively equivalent to root access on the host. The daemon runs as root, and a container started with -v /:/host can mount the entire host filesystem inside itself, read any file, and write back to it — there is no additional confirmation step. Only add trusted users to this group; do not treat it as an ordinary, low-privilege group membership.
Your First Container
--rm removes the container automatically once it exits — useful for one-off tests, since without it, stopped containers accumulate on disk indefinitely.
-ikeeps stdin open for interactive input;-tallocates a pseudo-terminal, so the shell prompt and line editing behave normally.
Managing Containers
docker ps lists only running containers; -a includes stopped ones as well — a container that exited is not gone, it still exists as an object on disk until explicitly removed.
stop sends SIGTERM (then SIGKILL after a grace period) to end the running process; rm deletes the now-stopped container object itself, including its writable layer. Running docker rm on a still-running container fails unless -f is added, which is worth pausing on before using — it force-kills the process with no graceful shutdown.
Running in the Background and Exposing a Port
-druns the container detached, in the background;-p 8080:80maps port8080on the host to port80inside the container — traffic to the host's8080is forwarded to the container's80.
docker logs reads the container's stdout/stderr — the same output you would see if the process were running directly in a foreground terminal, which is why a containerized application should always log to stdout/stderr rather than to an internal file, the same principle Linux Logs covers from the application side.
Debugging a Container That Won't Stay Up
The single most common Docker problem in practice — and the most common Docker troubleshooting question in interviews — is a container that starts and immediately exits. The diagnosis is systematic, not guesswork, and it never starts with re-running docker run and hoping:
An empty docker ps means the container is already gone. docker ps -a shows it with its exit status, which is the first real clue:
The Exited (1) is the container's main process exit code, read exactly like any shell command's exit code from Exit Codes and Error Handling: 0 means it ran to completion and simply had nothing left to do (a batch job that finished — not an error), any non-zero value means the process failed, and Exited (137) specifically means the process received SIGKILL (128 + 9) — most often the kernel's OOM killer terminating it for exceeding a memory limit, cross-referenced with the memory.events oom_kill counter from Namespaces and cgroups.
The exit code tells you that it failed; docker logs tells you why, even on a container that has already exited:
Traceback (most recent call last):
File "/app/app.py", line 3, in <module>
ModuleNotFoundError: No module named 'requests'
For a container that is still running but misbehaving, docker exec opens an interactive shell inside its existing namespaces — the everyday debugging entry point, and the same nsenter-based mechanism Namespaces and cgroups shows underneath:
docker exec -it web sh
# now inside the container: check its filesystem, environment, and network view
env | grep -i path
cat /etc/resolv.conf
The discipline to carry into an interview: docker ps -a for the exit code, docker logs for the failure output, docker exec -it to look inside a live one — in that order — turns "the container won't start" from a shrug into a three-command diagnosis.
Building an Image with a Dockerfile
FROMsets the base image every subsequent instruction builds on top of;WORKDIRsets the working directory inside the image for the instructions that follow, creating it if it does not exist;COPYcopies a file from the build context (the host) into the image;CMDsets the default command executed when a container starts from this image, overridable atdocker runtime.
mkdir -p ~/lab/docker-demo && cd ~/lab/docker-demo
printf 'print("hello from a container")\n' > app.py
cat <<'EOF' > Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
CMD ["python", "app.py"]
EOF
docker build -t demo-app:1.0 .
docker run --rm demo-app:1.0
The trailing . in docker build -t demo-app:1.0 . is the build context — the directory whose contents are sent to the build process and available to COPY/ADD instructions. A large or irrelevant build context (an accidentally included .git directory, node_modules, or log files) slows every build down measurably; a .dockerignore file, working exactly like .gitignore, excludes such paths from the context before the build even starts.
Volumes: Persisting Data Beyond a Container's Lifetime
A container's writable layer disappears the moment the container is removed — anything an application wrote inside the container filesystem is gone with it, unless that data lives in a volume.
docker volume create demo-data
docker run --rm -v demo-data:/data alpine sh -c 'echo "persisted" > /data/note.txt'
docker run --rm -v demo-data:/data alpine cat /data/note.txt
Each docker run above is a completely separate, short-lived container — the second one starts with no memory of the first — yet the file written by the first is still readable by the second, because both mounted the same demo-data volume, which Docker stores and manages independently of any single container's lifecycle.
A bind mount achieves the same persistence by pointing directly at a host directory instead of a Docker-managed volume:
mkdir -p ~/lab/docker-demo/data
docker run --rm -v "$HOME/lab/docker-demo/data:/data" alpine \
sh -c 'echo "written from inside the container" > /data/note.txt'
cat ~/lab/docker-demo/data/note.txt
Named volumes are the better default for application data in production — Docker manages their location and lifecycle, and they work identically across hosts. Bind mounts are most useful in development, where editing a file on the host and seeing the change reflected inside a running container immediately (source code mounted into a dev container, for example) is the point.
Podman: A Daemonless Alternative
Podman implements almost the same command surface as Docker, but with a fundamentally different architecture: there is no long-lived daemon. Each podman invocation is its own independent process, and — critically — Podman can run containers rootless by default, using exactly the user-namespace UID mapping covered in Namespaces and cgroups.
docker: docker (client) ──socket──> dockerd (persistent daemon, root)
podman: podman (standalone process, rootless by default) ──directly──> container
Installing and Basic Use
Most Docker commands work unchanged with podman substituted for docker:
podman run -d --name web -p 8080:80 nginx:alpine
podman ps
podman logs web
podman stop web && podman rm web
Docker vs. Podman
| Docker | Podman | |
|---|---|---|
| Background daemon | Required (dockerd) |
Not required |
| Rootless by default | No — requires explicit rootless mode setup | Yes |
| Command syntax | docker <command> |
Nearly identical, podman <command> |
| systemd integration | Requires additional tooling | Native, via podman generate systemd |
| Compose file support | Native (docker compose) |
Via podman-compose, or Kubernetes YAML generation |
The rootless default is the meaningful security difference, not a minor implementation detail: because Podman does not need a privileged daemon to create and manage containers, a compromised process inside a rootless Podman container maps back to an unprivileged host user rather than to root, which is exactly the user-namespace protection Namespaces and cgroups demonstrated by hand. Docker's daemon, by contrast, runs as root by default, which is precisely why membership in the docker group is treated as equivalent to root access.
Interview angle: why does Podman not need a daemon?
Docker's daemon historically existed to solve problems that don't require root today: managing container lifecycle centrally, providing a stable API for tools like Docker Compose, and (originally) working around a lack of user-namespace support in the kernel. Modern kernels support user namespaces well enough that Podman can create a container directly, as a child of the podman process itself, with no separate privileged process required to supervise it. The trade-off is that a container started by one podman invocation is not automatically supervised the way a daemon would — this is exactly the gap podman generate systemd closes, handing container supervision to systemd instead of a container-specific daemon.
Common Mistakes
- Assuming container data is persistent by default. Without a volume or bind mount, everything written inside a container's writable layer is lost the moment
docker rm(orpodman rm) removes it. - Adding a user to the
dockergroup without weighing the consequence. As covered above, this grants effective root access to the host, not just "the ability to run containers" — treat it the same as grantingsudowith no restriction. - Confusing
EXPOSEin aDockerfilewith actually publishing a port.EXPOSEis documentation only; the flag that actually forwards a host port to a container is-pondocker run. - Never cleaning up unused images and containers. Over time, old images accumulate and consume disk space silently:
docker system df shows how much space images, containers, and volumes are consuming; docker image prune removes dangling (untagged, unreferenced) images — a routine worth running periodically on any host that builds images regularly.
Exercises
- Start
nginx:alpinewith-d -p 8080:80, confirm it responds withcurl localhost:8080, inspect its output withdocker logs, then clean up withdocker stopanddocker rm. - Containerize a small script of your own — Python or Bash — following the
Dockerfilepattern shown above, and build an image from it. - Create a named volume, write a file to it from one
docker runinvocation, and confirm the file persists when read by a second, independentdocker runinvocation. - Run the same sequence of commands with
dockerand then withpodman, and compare the output ofdocker psversuspodman ps. Note any differences you observe.
Summary
Docker's daemon-based model means the docker command is a client talking to a privileged, always-running background process; Podman's daemonless, typically rootless model runs each container as its own process tree, mapped to an unprivileged host user by default. Both expose nearly identical command syntax, and docker run, docker build, and volumes/bind mounts remain the core daily-use tools regardless of which engine sits underneath. Linux CI/CD picks this up next, showing how the same images get built and tested automatically inside a pipeline rather than run by hand.