Part VI of this series concludes the discussion on file sharing via Samba in Chapter 24 and NFS in Chapter 25. Chapter 26 opens Part VII with a topic that transforms how Developers package and deploy applications to servers: containerization via Docker. A common scenario Sysadmins encounter in the field usually goes like this: a Developer has tested a Node.js application on their laptop using Node 22 as in Chapter 15, but when deployed to a production server still running another version of Node, the application suddenly throws errors due to previously unseen dependency version differences. This classic "it works on my laptop" issue is the primary reason Docker has become an industry standard: wrapping an application along with all its dependencies into a single package that runs identically anywhere, whether on a Developer's laptop or on our production server. This chapter begins with the basic concepts of containers versus virtual machines, installing Docker Engine 29 using the official installer script, practicing basic commands like docker run, ps, stop, and rm, understanding the difference between images and containers via docker pull and docker build, and concludes with volumes and basic networking so that container data is not lost easily and containers can communicate with one another.
26.1 Container Concepts vs Virtual Machines
Before installing Docker, we first need to understand what actually differentiates a container from a virtual machine (VM), which might be more familiar to some Sysadmins, especially those accustomed to VirtualBox or KVM.
26.1.1 Full Virtualization vs Containerization: Architectural Differences
Virtual machines, which will be discussed in greater detail in Chapter 28 via KVM/QEMU, work by running a hypervisor that emulates complete hardware, upon which a complete guest operating system (guest OS) runs with its own kernel. This approach provides extremely strong isolation, but it is also heavy: each VM carries a full kernel, requires boot times ranging from tens of seconds to minutes, and consumes significant RAM and storage resources just to run its own operating system before the actual application even has a chance to execute.
Containers take a much lighter approach. Instead of emulating hardware and running a separate kernel, containers share a single Linux kernel with the host, using built-in kernel isolation features to make each container feel like an independent system when it is actually just a regular process subjected to strict isolation. Because there is no need to boot a separate kernel, containers are typically ready in seconds or even fractions of a second, and their footprint is drastically smaller because they only carry the application and the libraries truly needed, rather than a full operating system.
| Aspect | Virtual Machine | Container |
|---|---|---|
| Isolation | Hypervisor + separate kernel per VM | Namespaces and cgroups on the same host kernel |
| Boot time | Tens of seconds to minutes | Generally less than a second |
| Image size | Gigabytes, carries a complete OS | Megabytes, only application and dependencies |
| Density per host | Limited due to full OS overhead | Much higher due to no duplicate kernel overhead |
| Ideal use case | Maximum security isolation, different OS from host | Application packaging, microservices, CI/CD |
This difference does not mean containers are always superior to VMs. Containers still share the same kernel with the host, meaning their theoretical isolation is not as strict as VMs that possess a completely separate kernel. For requirements demanding maximum security isolation or running a different operating system from the host, VMs remain the more suitable choice. However, for application packaging and distribution needs like the Developer scenario at the beginning of this chapter, containers are far more efficient and practical.
26.1.2 Namespaces and cgroups: The Container Foundations in Linux Kernel
Docker is not a technology that created isolation mechanisms from scratch. Docker is actually a tooling layer that simplifies the usage of two long-standing Linux kernel features: namespaces and cgroups. Namespaces are responsible for isolating what a process can "see", ranging from the pid namespace (processes inside a container only see their own processes, not other processes on the host), net namespace (each container has its own network stack and IP address), mnt namespace (each container has its own filesystem layout), to uts and ipc namespaces for hostname and inter-process communication. Meanwhile, cgroups (control groups) limit how much resources such as CPU, memory, and I/O can be used by a group of processes, as discussed in depth in Section 5.4 during our discussion on migrating from cgroup v1 to cgroup v2.
The good news is, because Ubuntu Server 26.04 LTS runs entirely on the unified cgroup v2 by default (cgroup v1 has been completely removed as discussed in Section 1.4), the Docker Engine we install later will use cgroup v2 out of the box without requiring any additional configuration. This differs from experiences on older Ubuntu generations, where Sysadmins sometimes had to manually reconfigure the cgroup driver to prevent Docker and systemd from conflicting over resource control.
As a quick illustration, we can view the list of currently active namespaces for our current shell, even if Docker is not installed at all.
ls -la /proc/self/ns/The output resembles the following list of symlinks, each representing a type of namespace currently used by our shell process.
lrwxrwxrwx 1 root root 0 Aug 27 10:00 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Aug 27 10:00 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 Aug 27 10:00 mnt -> 'mnt:[4026531840]'
lrwxrwxrwx 1 root root 0 Aug 27 10:00 net -> 'net:[4026531841]'
lrwxrwxrwx 1 root root 0 Aug 27 10:00 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Aug 27 10:00 uts -> 'uts:[4026531838]'The numbers inside the square brackets are unique inode numbers identifying that namespace instance, and their values will vary on every server. Once Docker is installed and a container runs, the process inside that container will point to a different namespace inode number than the processes on the host. That is the concrete realization of the isolation commonly referred to as a "container".
26.2 Installing Docker Engine with the Official Installer Script
In addition to manual installation via GPG keys and APT repositories as practiced for MongoDB in Section 21.2, Docker provides an official shortcut in the form of a convenience script: a single shell script that automatically detects the Linux distribution in use, registers the official Docker repository, and installs Docker Engine along with all supporting components using just one command. This script is hosted by Docker at get.docker.com and is officially documented as the fastest way to get started, making it ideal for development, testing, or lab environments like the one used throughout this course.
26.2.1 Running the Installer Script
Practical Steps
- Download and execute the official Docker installer script in a single command.
This script detects that the server is running Ubuntu, then automatically completes all steps that previously had to be done manually: adding the GPG key, registering the official Docker APT repository, runningsudo sh -c "curl -fsSL https://get.docker.com/ | sh"apt update, and installing five packages at once:docker-ce(the maindockerddaemon),docker-ce-cli(thedockerterminal command),containerd.io(the low-level container runtime that actually runs containers in the kernel),docker-buildx-plugin, anddocker-compose-pluginwhich provides thedocker composecommand for Chapter 27. - Ensure the
dockerservice is active and automatically starts on boot. The installer script usually enables it automatically, but verifying does no harm.sudo systemctl enable --now docker
Verification and Troubleshooting
- Confirm the installed Docker Engine version.
docker --version - Run the official test container from Docker to ensure the entire installation chain from the daemon, networking, to image pulling capabilities from the internet is functioning correctly.
Expected output begins with the phrasesudo docker run hello-worldHello from Docker!accompanied by a brief explanation that Docker successfully pulled the image, created the container, executed it, and that the container then exited on its own because its only task was printing that message. - Inspect the Docker environment details, including verifying that the active cgroup driver is set to
systemdand the active cgroup version is version 2, matching the discussion in Section 26.1.2.
The linessudo docker info | grep -i cgroupCgroup Driver: systemdandCgroup Version: 2indicate Docker is aligned with systemd as the primary resource manager on the server, without configuration conflicts that occasionally occurred in older Ubuntu generations. - If installation fails with a message such as
Unsupported distribution, the Ubuntu 26.04 codename might not yet be recognized by the script due to the release being relatively new, similar to the note in Section 21.5 for MongoDB. As a temporary workaround, wait for an update from Docker or install manually via the official Docker APT repository using the previous LTS codename as a fallback.
26.2.2 Running Docker Without sudo
The docker command requires root access by default because the dockerd daemon runs as root and communicates via the Unix socket /var/run/docker.sock, which is only accessible by root. To prevent Sysadmins from having to type sudo before every docker command, we can add our user to the docker group created automatically during installation.
Practical Steps
- Add the currently logged-in user to the
dockergroup.sudo usermod -aG docker $USER - Apply the new group membership without logging out, simply by restarting the shell group session.
newgrp docker
Verification and Troubleshooting
- Re-run the test container without
sudo, which should execute without permission errors.docker run hello-world - If the error
permission denied while trying to connect to the Docker daemon socketstill appears, the group membership has not been fully applied to the current shell session. The most reliable solution is to log out of the SSH session entirely and log back in, rather than relying solely onnewgrp. - An honest security note that every Sysadmin must understand: membership in the
dockergroup is practically equivalent to full root access on the server. Anyone in this group can mount the entire host filesystem into a container and modify it freely, for example viadocker run -v /:/host alpine chroot /host. Never add arbitrary users to this group on production servers, and treat this membership as granting fullsudoaccess.
26.3 Basic Docker Commands: run, ps, stop, rm
Docker Engine is now active. This section practices the most basic container lifecycle commands used daily: running, inspecting, stopping, and removing containers.
26.3.1 Running Your First Container with docker run
Practical Steps
- Run a simple Nginx container in the background, mapping port 8080 on the host to port 80 inside the container.
The three flags above are each important to understand. Thedocker run -d --name web-test -p 8080:80 nginx:stable-d(detached) flag runs the container in the background so the terminal is not blocked waiting for the container process to complete. The--nameflag provides an easily recognizable name, as without this flag Docker generates a random name likefervent_mendeleev. The-p 8080:80flag maps a host port to a container port in the formathost_port:container_port, since ports inside containers are not directly accessible from outside the host by default without explicit mapping.
Verification and Troubleshooting
- Access the web server newly running inside the container.
Output showing the default Nginx HTML page indicates the container is genuinely serving traffic, not merely in a cosmetically "running" state.curl http://localhost:8080 - If the error
port is already allocatedappears, port 8080 on the host is already in use by another process or container. Check withsudo ss -tlnp | grep 8080, then adjust the port mapping in your nextdocker runcommand.
26.3.2 Viewing, Stopping, and Removing Containers
Practical Steps
- View the list of running containers.
docker ps - View all containers, including stopped ones, while comparing them against the list displaying active containers only.
docker ps -a - View the log output of the
web-testcontainer, the most common debugging approach when an application inside a container behaves unexpectedly.docker logs web-test - Interactively access the running container, useful for inspecting filesystem contents or executing diagnostic commands directly from within.
Thedocker exec -it web-test sh-itflag combines interactive mode (-i, keeping standard input open) and pseudo-terminal allocation (-t), a combination always required whenever opening an interactive shell inside a container. Typeexitto leave the shell and return to the host terminal; the container itself remains running in the background. Another form ofdocker execwithout-itcan also be used to run a single command without entering an interactive session, as will be practiced in Section 26.5.1. - Stop the
web-testcontainer. This command sends aSIGTERMsignal to the primary process inside the container to shut down gracefully, waiting up to 10 seconds before forcing a shutdown withSIGKILLif the container does not stop.docker stop web-test - Remove the stopped container. Docker prevents deleting running containers without a force flag, serving as a safety mechanism so Sysadmins do not accidentally remove active production containers.
docker rm web-test
Verification and Troubleshooting
- Confirm that
web-testhas completely vanished from the list, even from the list of stopped containers.docker ps -a - If you are in a rush and want to stop and remove in a single step, the
-f(force) flag indocker rm -f container_nameimmediately sends aSIGKILLwithout a graceful shutdown waiting period. This step is handy for temporary test containers, but should be avoided for production containers handling active connections because unwritten disk data risks being lost.
26.4 Image vs Container: Pull and Build
This section breaks down two terms often confused by beginners: images and containers. We will pull an official image from a public registry, then build our own custom image using a Dockerfile.
26.4.1 Images Are Blueprints, Containers Are Instances
An image is a read-only package containing the full filesystem of an application: program code, runtime, libraries, and configuration files required for the app to run. For Developers familiar with object-oriented programming, the easiest analogy is that an image is equivalent to a class, while a container is a running instance of that class. The same image can be used to launch many containers simultaneously, each running isolated from one another via the namespaces discussed in Section 26.1.2.
Technically, an image is constructed from several layers stacked using a union filesystem (modern Docker Engine defaults to the overlay2 driver). Every instruction in a Dockerfile, discussed in Section 26.4.3, creates a new read-only layer. Once an image is instantiated into a container, Docker adds a single writable layer on top, known as the container layer. Any changes occurring during container execution, such as new files created by the application, are stored solely in this write layer, while the underlying image layers remain intact and unmodified. Crucially, once the container is deleted, this writable layer is completely lost. This is why we require volumes, discussed in Section 26.5, for data that must outlive an individual container.
26.4.2 Pulling Images with docker pull and Understanding Image Layers
Practical Steps
- Explicitly pull the stable version of the Nginx image from Docker Hub, Docker's default public registry.
docker pull nginx:stable - View the list of images currently stored locally on the server.
docker images
Verification and Troubleshooting
- Inspect the history of layers making up the image, along with the size of each layer.
docker history nginx:stable - Note the format of the image name
nginx:stable. The part before the colon is the repository name, and the part after is the tag. If no tag is specified, Docker automatically uses thelatesttag. This habit should be avoided on production servers because the contents oflatestcan change at any time following upstream updates, making deployments difficult to reproduce consistently.
26.4.3 Building Your Own Image with Dockerfile and docker build
A Dockerfile is a text file containing step-by-step instructions to build a custom image. This section builds a simple image containing a static page, representing a concise overview of the exact workflow Developers use when wrapping Node.js or PHP applications into Docker images.
Practical Steps
- Create a project directory along with a simple HTML page to be packaged into the image.
mkdir -p ~/static-site && cd ~/static-site echo "<h1>Hello from a custom Docker image</h1>" > index.html - Create a
Dockerfilein the same directory.nano Dockerfile
TheFROM nginx:stable COPY index.html /usr/share/nginx/html/index.htmlFROMinstruction specifies the base image used as a foundation, in this case the official Nginx image we pulled earlier. TheCOPYinstruction copies files from the host build directory into the image filesystem, overwriting the default Nginx page with our custom page. - Build the image from the
Dockerfile, naming itstatic-sitewith tag1.0. The dot at the end of the command points to the current directory as the build context, which is the set of files sent to the Docker daemon during the build process.docker build -t static-site:1.0 . - Run a container from the newly built image.
docker run -d --name static-site -p 8081:80 static-site:1.0
Verification and Troubleshooting
- Access the newly built custom page.
Output should displaycurl http://localhost:8081<h1>Hello from a custom Docker image</h1>, no longer the default Nginx page. - Confirm the
static-site:1.0image appears in the local image list along with its size.docker images | grep static-site - The error
failed to solve: nginx:stable: failed to resolve source metadataduring build usually indicates that the server lacks outbound internet connectivity to pull the base image specified inFROM. Verify network connectivity and server DNS resolution as covered in Chapter 9.
26.5 Basic Volumes and Networking in Docker
The final two essential topics to master before operating Docker on production servers: ensuring data persistence even when containers are destroyed, and enabling containers to connect to one another like separate servers on an internal network.
26.5.1 Data Persistence with Docker Volumes
As discussed in Section 26.4.1, all changes during container execution are stored in the container layer, which is deleted alongside the container. Volumes are Docker's official mechanism for persisting data outside a container's lifecycle, managed directly by Docker Engine and stored in /var/lib/docker/volumes/ on the host.
Practical Steps
- Create a new volume named
web-data.docker volume create web-data - Run an Nginx container with this volume mounted to the web content directory inside the container.
docker run -d --name web1 -v web-data:/usr/share/nginx/html -p 8082:80 nginx:stable - Write a new file into the volume using
docker exec, utilizing a single command execution format as mentioned in Section 26.3.2 without opening an interactive session.docker exec web1 sh -c 'echo "This data is stored in a volume" > /usr/share/nginx/html/index.html' - Forcefully remove the
web1container entirely, simulating a total loss of the container.docker rm -f web1 - Run a new container under a different name while attaching the exact same
web-datavolume.docker run -d --name web2 -v web-data:/usr/share/nginx/html -p 8082:80 nginx:stable
Verification and Troubleshooting
- Re-access the same port and confirm that the file contents written via
web1remain present even though the original container was deleted.
Output readingcurl http://localhost:8082This data is stored in a volumeproves that volumes exist independently of individual container lifecycles. - Inspect the physical location details of the volume on the host, useful when needing direct backups from the filesystem side.
docker volume inspect web-data
26.5.2 Bind Mounts vs Named Volumes
In addition to named volumes like web-data above, Docker also supports bind mounts, which directly mount a path from the host filesystem into a container without going through Docker's volume abstraction.
Practical Steps
- Prepare a directory on the host along with a simple HTML file.
mkdir -p ~/bind-site echo "<h1>Edited directly from the host</h1>" > ~/bind-site/index.html - Run a container using a bind mount pointing directly to an absolute path on the host.
docker run -d --name web-bind -v ~/bind-site:/usr/share/nginx/html -p 8083:80 nginx:stable - Edit the file directly on the host without entering the container.
echo "<h1>Direct changes without rebuilding</h1>" > ~/bind-site/index.html
Verification and Troubleshooting
- Reload the page; changes made on the host should immediately be reflected without restarting the container.
curl http://localhost:8083
| Aspect | Named Volume | Bind Mount |
|---|---|---|
| Data location | Managed by Docker in /var/lib/docker/volumes/ | Any arbitrary path on the host filesystem |
| Portability | High, independent of host directory layout | Low, tied to specific host paths |
| Backup ease | Via docker volume commands or helper containers | Directly using standard filesystem backup tools |
| Ideal use case | Production data: databases, user uploads | Development: live code reloading while editing |
Field notes: bind mounts are convenient for Developers during local development because code updates are instantly visible without rebuilding images. However, for production data requiring portability and standardized backup capabilities, named volumes remain the recommended approach.
26.5.3 Docker Networking and Inter-Container Communication
Whenever Docker is installed, several default networks are made available and can be viewed directly.
Practical Steps
- Display the list of currently available networks.
The three default networks present aredocker network lsbridge(the default network where containers run unless specified otherwise),host(containers share the network stack directly with the host, without network isolation), andnone(containers have no network access). - Create a custom bridge network used to allow two containers to discover each other by name.
docker network create app-net - Run a PostgreSQL 18 container, consistent with the version covered in Chapter 18, attached to the
app-netnetwork.docker run -d --name db-demo --network app-net \ -e POSTGRES_PASSWORD=secret123 postgres:18 - From a separate container also connected to
app-net, attempt to reachdb-demodirectly using its name instead of an IP address.docker run -it --rm --network app-net -e PGPASSWORD=secret123 \ postgres:18 psql -h db-demo -U postgres -c "SELECT version();"
Verification and Troubleshooting
- The
psqlcommand above should successfully connect and output the PostgreSQL version, despite not manually specifying the IP address ofdb-demo. This occurs because every custom network created viadocker network createincludes an embedded DNS server provided by Docker, resolving container names to internal IP addresses automatically. This functionality is intentionally disabled on the defaultbridgenetwork, serving as a primary reason why custom networks likeapp-netare preferred over relying on default networking. - Clean up remaining test containers from this chapter before moving to Chapter 27, as several container names above will be reused with different configurations there. Containers
web-testandweb1do not need to be specified as both were removed in previous steps.docker rm -f web2 web-bind static-site db-demo docker network rm app-net
At this point, our server is running Docker Engine 29 complete with our first container, a custom image produced from our own Dockerfile, alongside an understanding of basic volumes and networking to keep data safe and enable inter-container communication. However, running web applications and databases using individual docker run commands, as practiced separately with web-test and db-demo, is impractical for production applications consisting of multiple services. Chapter 27 solves this problem through Docker Compose, defining an entire multi-container application inside a single docker-compose.yml file.

