Skip to content

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, and docker logs;
  • write a minimal Dockerfile and 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.

docker (client) ──unix socket──> dockerd (daemon, runs as root) ──> containers

Installing Docker on Ubuntu

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"

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

docker run --rm hello-world

--rm removes the container automatically once it exits — useful for one-off tests, since without it, stopped containers accumulate on disk indefinitely.

docker run -it --rm ubuntu:24.04 bash
  • -i keeps stdin open for interactive input;
  • -t allocates a pseudo-terminal, so the shell prompt and line editing behave normally.

Managing Containers

docker ps
docker ps -a

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.

docker stop web
docker rm web

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

docker run -d --name web -p 8080:80 nginx:alpine
curl -s http://localhost:8080 | head -n 5
  • -d runs the container detached, in the background;
  • -p 8080:80 maps port 8080 on the host to port 80 inside the container — traffic to the host's 8080 is forwarded to the container's 80.
docker logs web
docker stop web && docker rm web

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:

docker run -d --name api myapp:latest
docker ps
CONTAINER ID   IMAGE          STATUS   NAMES

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:

docker ps -a
CONTAINER ID   IMAGE          STATUS                       NAMES
b2f19c4d7e88   myapp:latest   Exited (1) 3 seconds ago     api

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:

docker logs api
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

FROM python:3.12-slim
WORKDIR /app
COPY app.py .
CMD ["python", "app.py"]
  • FROM sets the base image every subsequent instruction builds on top of;
  • WORKDIR sets the working directory inside the image for the instructions that follow, creating it if it does not exist;
  • COPY copies a file from the build context (the host) into the image;
  • CMD sets the default command executed when a container starts from this image, overridable at docker run time.
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
hello from a container

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
persisted

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

sudo apt install podman
podman run --rm hello-world

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
podman build -t demo-app:1.0 .
podman run --rm demo-app:1.0

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 (or podman rm) removes it.
  • Adding a user to the docker group 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 granting sudo with no restriction.
  • Confusing EXPOSE in a Dockerfile with actually publishing a port. EXPOSE is documentation only; the flag that actually forwards a host port to a container is -p on docker run.
  • Never cleaning up unused images and containers. Over time, old images accumulate and consume disk space silently:
docker system df
docker image prune

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

  1. Start nginx:alpine with -d -p 8080:80, confirm it responds with curl localhost:8080, inspect its output with docker logs, then clean up with docker stop and docker rm.
  2. Containerize a small script of your own — Python or Bash — following the Dockerfile pattern shown above, and build an image from it.
  3. Create a named volume, write a file to it from one docker run invocation, and confirm the file persists when read by a second, independent docker run invocation.
  4. Run the same sequence of commands with docker and then with podman, and compare the output of docker ps versus podman 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.

References