The previous lesson gave you strong isolation at a high price: every virtual machine carries its own kernel, takes half a minute to start, occupies gigabytes on disk and reserves memory simply by existing. For a test environment that is reasonable. For deploying an application and being able to recreate it in seconds, it is extremely expensive.

This lesson builds the alternative, and it starts with the idea you have to be clear about before writing your first docker run, because getting it wrong conditions everything that comes after:

A container is not a small machine. It is an isolated process.

There is no operating system inside. There is no kernel. There is no boot. There is an ordinary host process, running with a restricted view of the filesystem, the network and the process table, and with a resource limit. Everything you will see — images, volumes, networks, Compose — is machinery built around that idea.

And you already know the three kernel pieces that make it possible, separately: cgroups are literally the ones behind the MemoryMax=512M you set in 05-05, capabilities are the ones from 05-02, and mount namespaces are the evolution of the chroot you used to repair GRUB in 07-01.

Contents

  1. The three primitives: namespaces, cgroups and capabilities
  2. User namespaces and the sysctl you left commented out
  3. Installing Docker, and why the docker group is equivalent to root
  4. Images, containers and registries
  5. docker run and the options that matter
  6. Dockerfile: building the Tramontana image
  7. Multi-stage builds and choosing a base image
  8. Data: volumes, bind mounts and tmpfs
  9. Networking and publishing ports
  10. Docker Compose
  11. Container security
  12. Alternatives, and when not to use containers

The three primitives: namespaces, cgroups and capabilities

A container is not a kernel feature. There is no create_container() system call. It is a convention: a process launched with certain restrictions enabled all at once. Seeing that directly, with no Docker in the way, is what makes everything else fall into place.

Namespaces: isolating what a process sees

A namespace isolates one class of global kernel resource, so that the processes inside it see their own instance of it. There are seven:

Namespace Isolates Visible effect
mnt Mount points Its own file tree
pid Process identifiers Its main process is PID 1
net Interfaces, routes, ports, firewall Its own network stack
uts Machine name and domain A hostname of its own
ipc Shared memory, message queues No communication with the outside
user UID and GID mappings Being root inside without being root outside
cgroup The view of the cgroup hierarchy It does not see the host's
$ lsns
        NS TYPE   NPROCS   PID USER   COMMAND
4026531834 time      184     1 root   /sbin/init
4026531835 cgroup    184     1 root   /sbin/init
4026531836 pid       184     1 root   /sbin/init
4026531837 user      184     1 root   /sbin/init
4026531838 uts       181     1 root   /sbin/init
4026531839 ipc       184     1 root   /sbin/init
4026531840 net       184     1 root   /sbin/init
4026531841 mnt       172     1 root   /sbin/init

Every process shares the same namespaces, because there is no container running. Let us create one by hand, without Docker:

# A process with its own PID, UTS and mount namespaces
$ sudo unshare --pid --fork --mount-proc --uts --mount bash

root@srv-tramontana:/# hostname manual-container
root@manual-container:/# hostname
manual-container

root@manual-container:/# ps aux
USER  PID %CPU %MEM    VSZ   RSS TTY  STAT START   TIME COMMAND
root    1  0.0  0.1   9788  5124 pts/0 S   19:04   0:00 bash
root   12  0.0  0.0  11492  3684 pts/0 R+  19:04   0:00 ps aux

There is the effect: bash is PID 1 and sees only two processes. From the outside, that same process has an ordinary PID:

# In another terminal on the host
$ pgrep -a -f 'unshare --pid'
8412 sudo unshare --pid --fork --mount-proc --uts --mount bash
$ ps -ef | grep -c bash
14

The process is the same. The only thing that changes is what it sees. And this explains why a container starts in milliseconds: there is nothing to start, only a clone() with a few flags.

# Every process exposes its namespaces as links under /proc
$ ls -l /proc/1/ns/
lrwxrwxrwx 1 root root 0 Aug 18 19:06 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Aug 18 19:06 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 Aug 18 19:06 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 root root 0 Aug 18 19:06 net -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0 Aug 18 19:06 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0 Aug 18 19:06 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Aug 18 19:06 uts -> 'uts:[4026531838]'

# Compare two processes: if the numbers match, they share the namespace
$ sudo readlink /proc/8412/ns/pid /proc/1/ns/pid
pid:[4026532198]
pid:[4026531836]

The network namespace is especially illustrative, because the isolation is total:

$ sudo unshare --net bash
root@srv-tramontana:/# ip addr
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
root@srv-tramontana:/# ss -tulpn
# (empty)
root@srv-tramontana:/# exit

No enp0s3, no IP, no routes, not a single socket. And lo is DOWN. That is the starting point of any Docker container before a virtual interface is attached to it.

The chroot from 07-01, and why it is not enough

In 07-01 you used chroot to enter an installed system and repair GRUB. chroot changes the root of the filesystem for a process — that is, it does part of what a mount namespace does.

But only that part, and that is why chroot is not a security mechanism:

$ sudo chroot /mnt /bin/bash
bash-5.2# ls /
bin boot dev etc home ...          # another root: that part does work

bash-5.2# ps aux | wc -l           # BUT it sees every host process
185
bash-5.2# ip addr | grep enp0s3    # and the host's entire network
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> ...
bash-5.2# hostname                 # and its name
srv-tramontana

A privileged process inside a chroot can escape from it fairly easily — there are known, documented techniques — and even if it did not escape, it has full access to the host's network, processes and devices. The mount namespace solves the escape; the other six namespaces solve the rest.

cgroups: limiting what a process consumes

Namespaces control what a process sees; cgroups control what it consumes. And you have used this already:

$ systemctl show tramontana.service -p MemoryMax -p TasksMax -p CPUQuotaPerSecUSec
MemoryMax=536870912
TasksMax=64
CPUQuotaPerSecUSec=infinity

# And underneath, the cgroups v2 hierarchy in /sys/fs/cgroup
$ cat /sys/fs/cgroup/system.slice/tramontana.service/memory.max
536870912
$ cat /sys/fs/cgroup/system.slice/tramontana.service/memory.current
187432960
$ cat /sys/fs/cgroup/system.slice/tramontana.service/pids.max
64

It is exactly the same technology. When you set MemoryMax=512M in the systemd unit in 05-05, you were creating a cgroup with memory.max. Docker does the same thing with --memory 512m. The wrapper is what differs, not the mechanism.

$ systemd-cgtop --iterations=1 -m | head -6
Control Group                    Tasks   %CPU   Memory  Input/s Output/s
/                                  184    2.1     1.2G        -        -
system.slice                       102    1.4   842.1M        -        -
system.slice/postgresql@16-main.…    18    0.8   412.4M        -        -
system.slice/tramontana.service      12    0.4   178.7M        -        -

capabilities: breaking root's privileges into pieces

From 05-02: instead of "root or not root", the kernel divides privileges into some forty independent capabilities.

$ capsh --print | head -3
Current: =ep
Bounding set: cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,...

This is what allows a container's main process to be "root" and still be unable to do almost anything dangerous: nearly all of its capabilities are taken away. Docker leaves about fourteen of the forty by default.

And with the three primitives together, you can now define what a container is with no magic involved:

A container is a process launched with namespaces of its own, inside a cgroup with limits, with a reduced set of capabilities, and with the root of the filesystem pointing at the image.

User namespaces and the sysctl you left commented out

The user namespace is the most recent one and the one with the most security consequences. It allows a UID inside to map to a different UID outside:

# As a NORMAL user, no sudo
$ unshare --user --map-root-user bash
root@srv-tramontana:~# id
uid=0(root) gid=0(root) groups=0(root)
root@srv-tramontana:~# cat /proc/self/uid_map
         0       1000          1
root@srv-tramontana:~# touch /etc/test
touch: cannot touch '/etc/test': Permission denied
root@srv-tramontana:~# exit

Read that slowly, because it is counterintuitive: id says uid=0(root), but it cannot write to /etc. The uid_map explains why — UID 0 inside is UID 1000 outside. It is root of its own namespace and of nothing else.

That is what makes unprivileged (rootless) containers possible: an ordinary user can create namespaces, mount filesystems inside them and be root within their container, without being root on the host.

And now, the parameter you left commented out in 06-06:

$ grep -A3 unprivileged_userns /etc/sysctl.d/60-hardening.conf
# Do not allow unprivileged users to create user namespaces.
# WARNING: this breaks unprivileged containers. Commented out because you
# will need it in 07-05; uncomment only on servers with no containers.
#kernel.unprivileged_userns_clone = 0

The tension is real and it is worth understanding in both directions:

In favour of disabling it (= 0) In favour of leaving it enabled
User namespaces have been the route for numerous privilege escalations They are the basis of unprivileged containers and of sandboxing
They give an ordinary user access to kernel surface that used to be root-only Without them, every container needs a privileged daemon
On a server that runs no containers, nothing is lost Flatpak, Snap, bwrap and browsers use them too

The decision for srv-tramontana: leave it enabled, because it is going to run containers. And here is the note added to the file, because the reasoning is what has to be documented:

$ sudo tee -a /etc/sysctl.d/60-hardening.conf >/dev/null <<'EOF'

# DECISION 2026-08-18: kernel.unprivileged_userns_clone is left ENABLED
# (default value 1). Reason: it is a requirement of unprivileged containers
# (rootless podman) and of Snap sandboxing. Disabling it would reduce the
# attack surface, but it would prevent the containment that containers
# provide, which is the greater protection.
# Review if this machine stops running containers.
EOF
$ sysctl kernel.unprivileged_userns_clone
kernel.unprivileged_userns_clone = 1

A hardening measure ruled out with its reason written down is knowledge; ruled out in silence it is an omission.

Installing Docker, and why the docker group is equivalent to root

Docker is not in the Ubuntu repositories at the current version, so it is installed from the official repository applying what you learned in 05-03: the key in /etc/apt/keyrings/ and signed-by.

$ sudo apt install ca-certificates curl
$ sudo install -m 0755 -d /etc/apt/keyrings
$ sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
      -o /etc/apt/keyrings/docker.asc
$ sudo chmod a+r /etc/apt/keyrings/docker.asc

$ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

$ sudo apt update
$ sudo apt install docker-ce docker-ce-cli containerd.io \
      docker-buildx-plugin docker-compose-plugin

$ sudo docker version --format '{{.Server.Version}}'
27.1.2
$ sudo systemctl is-enabled docker
enabled

Note that adding a third-party repository is a security decision, as was said in 05-03: from now on, whoever controls that repository can install packages on your server. signed-by restricts the key to that specific repository, which is the bare minimum you should demand.

The docker group warning

$ sudo usermod -aG docker operator   # <- READ THE WARNING BEFORE DOING THIS

That command turns up in every tutorial as a convenience, and its real implication is almost never explained:

Belonging to the docker group is equivalent to having root with no password, and without leaving the trail that sudo leaves.

That is not an exaggeration. The demonstration fits on one line:

# As a member of the docker group, with NO sudo:
$ docker run --rm -v /:/host -it alpine \
      cat /host/etc/shadow | head -2
root:$y$j9T$K2p...:20318:0:99999:7:::
daemon:*:20318:0:99999:7:::

The Docker daemon runs as root, and -v /:/host asks it to mount the host's root inside the container. Anybody in the docker group can read /etc/shadow, write to /etc/sudoers.d/, or modify /etc/tramontana/secrets/. And they do it without appearing in auth.log.

The practical consequences, which have to be applied:

  1. The docker group is treated like the sudo group, with the same control over who joins it and why. On srv-tramontana only operator, and it is recorded in the 06-06 checklist.
  2. It is never added to a service account or an application user. If svc-tramontana were in docker, a compromise of the application would be instant root.
  3. The right alternative when several people are involved is sudo docker, with a rule in sudoers.d that records who runs what — consistent with what you did in 05-02 with deploy-safe.
  4. Or use podman, which has no privileged daemon and solves the problem at the root. It is covered at the end of the lesson.
$ sudo tee /etc/sudoers.d/docker-tramontana >/dev/null <<'EOF'
# Docker requires effective root privileges. It is channelled through sudo so
# that auth.log keeps a trace of who runs what.
Cmnd_Alias TRAMO_DOCKER = /usr/bin/docker, /usr/bin/docker compose
operator ALL=(root) TRAMO_DOCKER
EOF
$ sudo visudo -c -f /etc/sudoers.d/docker-tramontana
/etc/sudoers.d/docker-tramontana: parsed OK

Images, containers and registries

Three concepts that get confused and that are worth separating once and for all:

Concept What it is Analogy
Image An immutable read-only template, in layers The executable file
Container A running instance, with a writable layer on top The process
Registry A server where images are published and downloaded from The package repository

The most important property of an image is that it is made of layers, each one a set of changes on top of the previous one, identified by its cryptographic checksum:

$ sudo docker pull postgres:16-alpine
16-alpine: Pulling from library/postgres
c6a83fedfae6: Pull complete
a2e5cb2d5c74: Pull complete
...
Status: Downloaded newer image for postgres:16-alpine

$ sudo docker image ls
REPOSITORY   TAG          IMAGE ID       CREATED       SIZE
postgres     16-alpine    8b4c1f2a9e33   2 weeks ago   274MB
alpine       3.20         a606584aa9aa   3 weeks ago   7.8MB

$ sudo docker history postgres:16-alpine --format 'table {{.Size}}\t{{.CreatedBy}}' | head -6
SIZE      CREATED BY
0B        CMD ["postgres"]
0B        EXPOSE map[5432/tcp:{}]
0B        ENTRYPOINT ["docker-entrypoint.sh"]
12.4MB    RUN /bin/sh -c set -eux; ...
188MB     RUN /bin/sh -c set -eux; apk add --no-cache postgresql16 ...
7.8MB     /bin/sh -c #(nop) ADD file:... in /

Layers are shared between images: if ten images are based on alpine:3.20, those 7.8 MB are stored only once. It is the same idea as qcow2 backing files in 07-04, applied to the filesystem.

And the security implication of layers, which you have to keep in mind when writing a Dockerfile: a layer never disappears. If in one layer you copy a file containing a password and in the next one you delete it, the file is still in the image and can be extracted. We will come back to this.

# Tags are MUTABLE: latest today is not latest tomorrow
$ sudo docker image inspect postgres:16-alpine --format '{{index .RepoDigests 0}}'
postgres@sha256:4f2b8c1e...

# The digest IS immutable, and it is what you pin in production
$ sudo docker pull postgres@sha256:4f2b8c1e...

docker run and the options that matter

$ sudo docker run --rm alpine:3.20 echo "hello from the container"
hello from the container

$ sudo docker run -d --name test-nginx -p 8081:80 nginx:1.27-alpine
a3f1c88e2d4b...

$ sudo docker ps
CONTAINER ID   IMAGE               COMMAND                  STATUS         PORTS                  NAMES
a3f1c88e2d4b   nginx:1.27-alpine   "/docker-entrypoint.…"   Up 4 seconds   0.0.0.0:8081->80/tcp   test-nginx

And now the check that closes the first section. From the host:

$ pgrep -a nginx
9204 nginx: master process nginx -g daemon off;
9251 nginx: worker process

$ sudo ls -l /proc/9204/ns/ | awk '{print $9, $11}'
mnt mnt:[4026532412]
net net:[4026532475]
pid pid:[4026532413]
uts uts:[4026532410]

$ cat /sys/fs/cgroup/system.slice/docker-a3f1c88e2d4b*.scope/memory.current
8912896

The nginx process is in the host's process table, with an ordinary PID, and pgrep finds it. All it has is namespaces of its own and a cgroup. There is no machine, no kernel, no boot. It is a process.

The docker run options that actually get used:

Option What it does Note
-d In the background
--name A name instead of a random identifier Needed for name resolution
-p 8081:80 Publishes the port: host:container -p 127.0.0.1:8081:80 restricts it to local
-v Mounts a volume or a directory See the data section
-e An environment variable Not for secrets (06-05)
--rm Deletes the container when it finishes For testing
--restart unless-stopped Restarts after a failure or a reboot Production
--user 1000:1000 Runs as that UID Not as root inside
--read-only A read-only filesystem With --tmpfs for what has to be writable
--cap-drop ALL Removes every capability And --cap-add only the ones needed
--security-opt no-new-privileges Prevents escalation through SUID Equivalent to systemd's NoNewPrivileges
--memory / --cpus Cgroup limits The ones from 05-05, under another name
--health-cmd A health check The same as health_check.sh

The lifecycle management ones:

$ sudo docker logs -f --tail 20 test-nginx
$ sudo docker exec -it test-nginx sh
$ sudo docker inspect test-nginx --format '{{.State.Status}} {{.NetworkSettings.IPAddress}}'
running 172.17.0.2
$ sudo docker stats --no-stream
$ sudo docker stop test-nginx && sudo docker rm test-nginx
$ sudo docker system df
$ sudo docker system prune -a --volumes   # CAREFUL: deletes anything unused

docker stop sends SIGTERM, waits 10 seconds and then sends SIGKILL — exactly the signal discipline from 03-06. If your application needs more time to shut down cleanly, --time 30.

Dockerfile: building the Tramontana image

A Dockerfile is an image's recipe. Every instruction that modifies the filesystem creates a layer.

# Dockerfile — Tramontana Bookings
# Two-stage build: the first one compiles, the second one only runs.

# ---------- Stage 1: build ----------
FROM golang:1.23-alpine AS builder

WORKDIR /src

# First ONLY the dependency files. Reason: this layer is reused from the
# cache as long as they do not change, even when the code does.
COPY go.mod go.sum ./
RUN go mod download

# And then the code, which changes on every commit.
COPY . .

# CGO_ENABLED=0 produces a static binary: it needs no system libraries,
# so the final image can be minimal.
RUN CGO_ENABLED=0 GOOS=linux go build \
        -ldflags='-s -w -X main.version=3.2.1' \
        -o /tramontana ./cmd/server

# ---------- Stage 2: runtime ----------
FROM alpine:3.20

# Security updates and root certificates for validating TLS.
# --no-cache avoids leaving the package index in the image.
RUN apk add --no-cache ca-certificates tzdata curl \
    && addgroup -g 1002 tramontana \
    && adduser -u 997 -G tramontana -s /sbin/nologin -D svc-tramontana

WORKDIR /opt/tramontana

# Only the binary from the previous stage. The Go compiler, the source
# code and the dependencies do NOT reach the final image.
COPY --from=builder /tramontana /opt/tramontana/tramontana
COPY --chown=svc-tramontana:tramontana templates/ /opt/tramontana/templates/

# The same numeric identifiers as on the server: volume permissions are
# only consistent if they match.
USER svc-tramontana

ENV TRAMONTANA_PORT=8080 \
    TRAMONTANA_LOG_LEVEL=info

EXPOSE 8080

# The health check, equivalent to health_check.sh
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD curl -fsS "http://127.0.0.1:${TRAMONTANA_PORT}/health" || exit 1

# ENTRYPOINT: the executable, always. CMD: default arguments, which can be
# replaced from the command line.
ENTRYPOINT ["/opt/tramontana/tramontana"]
CMD ["--config", "/etc/tramontana/app.conf"]

The instructions, and what you need to know about each one:

Instruction What it does Watch out for
FROM The base image Pin the version, never latest
WORKDIR The working directory Better than RUN cd, which does not persist
COPY Copies files Preferable to ADD, which does magic with URLs and tar
RUN Runs at build time Chain with &&: each RUN is a layer
ENV An environment variable It stays in the image: never secrets
USER The runtime user Without this, the process runs as root
EXPOSE Documents the port It publishes nothing: that is -p
HEALTHCHECK A periodic check Compose's depends_on uses it
ENTRYPOINT The executable
CMD Default arguments Replaceable at run time

The layer cache and how to order the instructions

Docker reuses a layer if the instruction and its context have not changed. As soon as one layer is invalidated, every one after it is rebuilt. Hence the rule:

Order the instructions from least to most frequently changing.

# WRONG: any change to the code invalidates the dependency download
COPY . .
RUN go mod download
RUN go build -o /tramontana ./cmd/server

# RIGHT: the dependencies are only downloaded again if go.mod or go.sum change
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /tramontana ./cmd/server

In the first version, changing one line of code forces every dependency to be downloaded again: minutes on every build. In the second, seconds.

And the other layer rule, the chaining one:

# WRONG: three layers, and the apk index stays inside the image
RUN apk update
RUN apk add curl
RUN rm -rf /var/cache/apk/*

# RIGHT: one layer, and the deletion happens BEFORE the layer is closed
RUN apk add --no-cache curl

The rm in a later RUN does not reduce the size: the previous layer already contains the files, and layers are immutable. It is the same reason a secret that is copied and then deleted can still be extracted from the image.

# .dockerignore: what is NOT sent to the build context
$ cat .dockerignore
.git
.gitignore
*.md
Dockerfile
compose.yaml
/data
/tests
*.bak-*
.env
secrets/

.dockerignore matters for two reasons: speed — the whole context is sent to the daemon — and security, because without it a COPY . . can put the .git directory with the entire history into the image, or a .env with credentials.

$ sudo docker build -t tramontana:3.2.1 .
[+] Building 24.1s (16/16) FINISHED
$ sudo docker image ls tramontana
REPOSITORY   TAG     IMAGE ID       CREATED         SIZE
tramontana   3.2.1   f4a1c88e2d4b   8 seconds ago   19.4MB

Multi-stage builds and choosing a base image

19.4 MB. The comparison with a single-stage build explains where that comes from:

Build Contents Size
One stage on golang:1.23 Compiler, source code, dependencies, binary ~850 MB
One stage on golang:1.23-alpine The same, with a smaller base ~380 MB
Two stages on alpine:3.20 Only the binary and the certificates 19.4 MB
Two stages on scratch Only the binary ~11 MB

And the benefit is not only disk. The 850 MB image contains the Go compiler, git, make and dozens of libraries: every one of them is attack surface and a potential source of CVEs. The 19 MB one has none of that. It is the minimum-surface principle from 06-06 applied to an image.

The usual bases:

Base Size Package manager When
ubuntu:24.04 ~78 MB apt When you need the complete ecosystem
debian:12-slim ~29 MB apt A reasonable compromise
alpine:3.20 ~7.8 MB apk Static binaries, tools
gcr.io/distroless/static ~2 MB none Maximum security; no shell
scratch 0 B none A pure static binary

Alpine, which was mentioned in 01-03 when discussing distributions, has a peculiarity you need to know about: it uses musl instead of glibc. A binary compiled against glibc does not work on Alpine, and some applications — notably Python with native extensions — have subtle performance problems with musl's memory allocator. With Go and CGO_ENABLED=0 there is no problem, because the binary is static.

Distroless images go one step further: they have no shell, no ls, no package manager. An attacker who gains execution has nothing to work with. In exchange, debugging requires docker debug or a parallel image with tools in it.

Data: volumes, bind mounts and tmpfs

A container's filesystem is ephemeral: when you delete it, the writable layer disappears. Data that has to survive is mounted from outside, and there are three ways of doing it:

Mechanism Where it lives When
Volume Managed by Docker under /var/lib/docker/volumes/ Application data: databases, uploaded files
Bind mount A specific path on the host Configuration, development, integrating with the system
tmpfs The host's memory Temporary files and secrets: they never touch disk
# Volume: Docker decides where, and manages it
$ sudo docker volume create tramontana-db-data
$ sudo docker volume inspect tramontana-db-data --format '{{.Mountpoint}}'
/var/lib/docker/volumes/tramontana-db-data/_data

$ sudo docker run -d --name db \
    -v tramontana-db-data:/var/lib/postgresql/data \
    -e POSTGRES_PASSWORD_FILE=/run/secrets/db_pass \
    postgres:16-alpine

# Bind mount: a specific path, read-only
$ sudo docker run -d --name app \
    -v /etc/tramontana/app.conf:/etc/tramontana/app.conf:ro \
    -v /opt/tramontana/shared/uploads:/opt/tramontana/shared/uploads \
    tramontana:3.2.1

# tmpfs: in memory, does not persist, never touches disk
$ sudo docker run -d --name app-read-only \
    --read-only \
    --tmpfs /tmp:rw,noexec,nosuid,size=64m \
    tramontana:3.2.1

That noexec,nosuid on the tmpfs is the same criterion you applied to /tmp in 06-06.

The preference for volumes over bind mounts when it comes to data has concrete reasons: Docker manages them (creation, permissions, backing them up with docker run --volumes-from), they are portable between hosts, and they do not depend on a specific path existing. Bind mounts are better for configuration — because you want to edit it from the host with your own tools — and for development.

And the permissions warning, which is the number one source of frustration with volumes: UIDs are numeric and they are not translated. A file owned by UID 997 on the host belongs to UID 997 inside the container, whatever that user happens to be called in there. That is why Tramontana's Dockerfile creates svc-tramontana with UID 997 and the group with GID 1002: so that they match the server.

$ sudo docker run --rm -v /opt/tramontana/shared/uploads:/data alpine:3.20 \
      ls -ln /data
total 4
drwxrws--- 2 997 1002 4096 Aug 18 17:12 photos

Networking and publishing ports

$ sudo docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
f2a1c88e2d4b   bridge    bridge    local
a3c1f88e2d4c   host      host      local
b4d1e88e2d4d   none      null      local
Mode Isolation Name resolution When
bridge (the default) Its own virtual network with NAT No on the default network Rarely used directly
A user network The same, but with internal DNS Yes The right choice almost always
host None: it uses the host's N/A Extreme performance; loses the isolation
none Total: only lo N/A Processes with no networking

The difference between the default bridge network and a user network is the one that decides the design:

$ sudo docker network create tramontana-net
$ sudo docker run -d --name db --network tramontana-net postgres:16-alpine
$ sudo docker run -d --name app --network tramontana-net tramontana:3.2.1

# Resolution by SERVICE NAME: no IP addresses needed in the configuration
$ sudo docker exec app getent hosts db
172.19.0.2       db

That is what allows app.conf to say db_host=db instead of an IP that changes on every start. On the default bridge network it does not work.

And publishing ports, with an important security detail:

# WRONG: 0.0.0.0, reachable from the entire network
$ sudo docker run -d -p 8080:8080 tramontana:3.2.1

# RIGHT: only from the host itself, consistent with listen=127.0.0.1
$ sudo docker run -d -p 127.0.0.1:8080:8080 tramontana:3.2.1
$ sudo ss -tulpn | grep 8080
tcp LISTEN 127.0.0.1:8080  users:(("docker-proxy",pid=9841,fd=4))

A serious warning: Docker writes nftables rules and bypasses ufw. A -p 8080:8080 inserts a rule into the DOCKER chain that is evaluated before ufw's, so the port is reachable from the Internet even though ufw status says it is blocked.

It can be checked, and it has to be checked:

$ sudo ufw status | grep 8080
# (nothing: ufw thinks it is closed)
$ sudo nft list chain ip nat DOCKER 2>/dev/null | head -4
$ sudo nmap -p 8080 10.0.2.15 -Pn | grep 8080
8080/tcp open  http-proxy      # <- OPEN in spite of ufw

The two solutions: always publish with 127.0.0.1: in front — which is the right thing when there is a reverse proxy in front, as there will be in 08-01 — or disable Docker's rule manipulation:

$ sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
  "iptables": false,
  "log-driver": "journald",
  "log-opts": { "tag": "{{.Name}}" },
  "live-restore": true
}
EOF
$ sudo systemctl restart docker

Careful: with "iptables": false the containers lose their route out to the Internet unless you write the NAT rules yourself. The practical option in most cases is the first one.

Note "log-driver": "journald" as well: it sends the containers' output to the journal, so journalctl and the logrotate from 05-06 apply to them too. By default, Docker writes to JSON files that grow without limit and are a common cause of full disks.

Docker Compose

A compose.yaml describes the set of services, networks and volumes in one versionable file:

# compose.yaml — Tramontana Bookings
name: tramontana

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: tramontana_bookings
      POSTGRES_USER: tramontana
      # The secret does NOT go here: it is read from a mounted file.
      POSTGRES_PASSWORD_FILE: /run/secrets/db_pass
    secrets:
      - db_pass
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - internal
    # The health check is what makes the ordered startup possible
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U tramontana -d tramontana_bookings"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    # The same limits as the systemd unit from 05-05
    deploy:
      resources:
        limits:
          memory: 512M
    security_opt:
      - no-new-privileges:true

  app:
    build:
      context: .
      dockerfile: Dockerfile
    image: tramontana:3.2.1
    restart: unless-stopped
    # Only reachable from the host: the reverse proxy from 08-01 will go in front
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      TRAMONTANA_DB_HOST: db          # resolution by service name
      TRAMONTANA_DB_PORT: "5432"
      TRAMONTANA_LOG_LEVEL: info
    secrets:
      - db_pass
    volumes:
      - ./config/app.conf:/etc/tramontana/app.conf:ro
      - uploads:/opt/tramontana/shared/uploads
    networks:
      - internal
    # It does NOT start until the database really answers.
    # Without 'condition', depends_on only waits for the container to exist.
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 10s
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          memory: 512M

volumes:
  db-data:
  uploads:

networks:
  internal:
    driver: bridge

secrets:
  db_pass:
    file: ./secrets/db_pass    # a 0600 file, kept out of git
$ sudo docker compose config --quiet && echo "syntax correct"
syntax correct
$ sudo docker compose up -d
[+] Running 4/4
 ✔ Network tramontana_internal  Created
 ✔ Volume "tramontana_db-data"  Created
 ✔ Container tramontana-db-1    Healthy
 ✔ Container tramontana-app-1   Started

$ sudo docker compose ps
NAME               IMAGE               STATUS                   PORTS
tramontana-app-1   tramontana:3.2.1    Up 12 seconds (healthy)  127.0.0.1:8080->8080/tcp
tramontana-db-1    postgres:16-alpine  Up 45 seconds (healthy)  5432/tcp

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/houses
200

depends_on with condition: service_healthy is the piece that solves a real problem: without it, depends_on only waits for the container to exist, not for the service inside it to be ready. The application would start, fail to find the database and die. With the condition, Compose waits for the healthcheck.

$ sudo docker compose logs -f app
$ sudo docker compose exec db psql -U tramontana -d tramontana_bookings -c '\dt'
$ sudo docker compose down            # stops and deletes containers and network
$ sudo docker compose down -v         # ...AND THE VOLUMES: destroys the data

That -v deserves the warning: docker compose down -v deletes the volumes, which is to say the database. It is an rm -rf under another name, and the discipline from 02-04 applies just the same.

Container security

A container's isolation is weaker than a VM's, so the measures matter more. The ones to apply always:

Measure Why
Do not run as root inside USER in the Dockerfile or --user. Without this, an escape means root on the host
--read-only + tmpfs An attacker cannot write their tool to the filesystem
--cap-drop=ALL and add back only what is needed The process does not need 14 capabilities
no-new-privileges Prevents escalation through a SUID binary
Pin the image version latest changes without warning: there is no reproducibility
Memory and CPU limits A container with no limit can bring the host down
Scan the images Base images accumulate CVEs over time
Never put secrets in ENV or in layers They stay in the image for ever

On secrets, here is the demonstration of why ENV is a bad idea:

# WRONG: visible to anybody who can inspect the image or the container
$ sudo docker inspect tramontana-app-1 --format '{{json .Config.Env}}' | tr ',' '\n'
"TRAMONTANA_DB_PASSWORD=Zx9K2pQ7vLm4RtWn"

# And in /proc as well, as you already saw in 06-05
$ sudo tr '\0' '\n' < /proc/$(sudo docker inspect -f '{{.State.Pid}}' tramontana-app-1)/environ

It is exactly the problem you solved in 06-05 with LoadCredentialEncrypted. The Docker equivalent is the secrets section of compose.yaml, which mounts the file on a tmpfs inside the container:

$ sudo docker compose exec app ls -l /run/secrets/
-r--r----- 1 svc-tramontana tramontana 17 Aug 18 19:41 db_pass

And image scanning:

$ sudo docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
      aquasec/trivy:latest image --severity HIGH,CRITICAL tramontana:3.2.1

tramontana:3.2.1 (alpine 3.20.2)
Total: 0 (HIGH: 0, CRITICAL: 0)

$ sudo docker run --rm aquasec/trivy:latest image --severity HIGH,CRITICAL \
      postgres:16-alpine | tail -4
Total: 2 (HIGH: 2, CRITICAL: 0)

Notice a detail that illustrates the docker group warning: that command mounts /var/run/docker.sock inside a container, which gives it complete control of the Docker daemon and therefore of the host. You do it with images you trust, and knowing what it implies.

Two more measures that close the circle with earlier modules:

# AppArmor: Docker applies a default profile (06-06)
$ sudo docker inspect tramontana-app-1 --format '{{.AppArmorProfile}}'
docker-default

# seccomp: a system call filter, like SystemCallFilter from 05-05
$ sudo docker inspect tramontana-app-1 --format '{{.HostConfig.SecurityOpt}}'
[no-new-privileges:true]

AppArmor's docker-default profile and the default seccomp filter block some forty dangerous system calls. Disabling them with --privileged or --security-opt seccomp=unconfined is what turns a container into a process with almost total access to the host, and --privileged must never appear in production.

Alternatives, and when not to use containers

Tool Model Advantage
Docker A privileged daemon Ecosystem, documentation, tooling
Podman No daemon, rootless No privileged process; Docker-compatible
containerd A low-level engine It is what Kubernetes uses underneath
LXC/LXD System containers They resemble a lightweight VM: a full init inside

Podman deserves attention for what it solves:

$ sudo apt install podman
$ podman run --rm alpine:3.20 echo "no daemon, no sudo"
no daemon, no sudo

# And with no root: thanks to the user namespaces from the second section
$ podman unshare cat /proc/self/uid_map
         0       1000          1
         1     100000      65536

podman has no daemon: every container is a child process of whoever launched it. In rootless mode it uses user namespaces, so the docker group problem does not exist. Its commands are compatible (alias docker=podman works almost always) and it integrates with systemd by generating units, which fits well with everything from Module 5. For a server where several people run containers, it is the more defensible option.

LXC is different in concept: system containers, with a full init inside, several processes and sessions. It resembles a lightweight VM more than an isolated process.

When NOT to use containers

The honesty that most guides are missing:

Situation Why not
Strong security isolation between tenants They share a kernel. That is what the VMs from 07-04 are for
A database with critical state It can be done, but persistent storage adds complexity with no clear benefit on a single server
Applications that need deep kernel access Modules, full systemd, specific hardware
An application on a single server that already works Containerising adds a layer that has to be learned and maintained in exchange for very little
Workloads with extreme latency requirements The network layer adds microseconds
Systems that must last ten years untouched The ecosystem changes fast

That fourth point applies to Tramontana right now: srv-tramontana works, it is hardened, monitored and documented. Containerising it today would add complexity without solving a single outstanding problem. Containers shine when there are several services, several environments or several servers — which is precisely the territory of the next two lessons.

Common Mistakes and Tips

  • Thinking of a container as a small VM. It leads to putting systemd, sshd and cron inside, and to treating the container as a machine to be administered. A container runs one process and is replaced, not administered.
  • Adding somebody to the docker group without understanding what it means. It is root with no password and no trace. Treat it like the sudo group.
  • Using latest. The image changes without warning, and a rebuild next week does not produce the same thing. Pin the version, and in production the digest.
  • Putting secrets in ENV or in a layer. They stay in the image for ever, even if you delete them afterwards: layers are immutable. Use secrets or a mounted file.
  • Copying the code before the dependencies in the Dockerfile. It invalidates the cache on every change and turns a build of seconds into one of minutes.
  • Using rm in a later RUN to reduce the size. It does not work: the previous layer already contains the files. Chain with && inside the same RUN.
  • Forgetting .dockerignore. A COPY . . can put the whole of .git, or a .env with credentials, into the image.
  • Running as root inside the container. It is the default and it is the first thing to change with USER.
  • Publishing with -p 8080:8080 and believing ufw protects you. Docker writes rules that are evaluated first. Use -p 127.0.0.1:8080:8080 and verify it from outside with nmap.
  • Leaving the default logging driver. The JSON files grow without limit. "log-driver": "journald" integrates them with journalctl and logrotate.
  • Running docker compose down -v without thinking. It deletes the volumes, which is to say the data.
  • Reaching for --privileged "to make it work". It disables seccomp, AppArmor and the capabilities in one go. Never in production; find the specific capability that is missing.
  • A tip on method. A container must be able to die and be reborn without anything being lost. If yours has state inside its writable layer, something is mounted wrong: everything that matters goes in a volume.

Exercises

Exercise 1

Demonstrate, without using Docker, that a container is an isolated process. Create a "container" by hand with unshare that has its own machine name, its own process table and its own network, and check from the host that it is still an ordinary process. Explain what your construction is missing in order to be a real container.

Exercise 2

Review this Dockerfile that Luis has written and correct it, explaining every problem:

FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y python3 python3-pip curl git
COPY . /app
WORKDIR /app
RUN pip3 install -r requirements.txt
ENV DB_PASSWORD=Zx9K2pQ7vLm4RtWn
EXPOSE 8080
CMD python3 server.py

Exercise 3

Marta asks whether it would be worth migrating Tramontana Bookings to containers. Write the analysis, taking into account the server's real state and what has been covered in the last two lessons.

Solutions

Solution 1

# --- The hand-made "container" ---
# Each unshare flag creates a namespace. Together they give most of a
# container's isolation.
$ sudo unshare --pid --fork --mount-proc \
               --uts --ipc --mount --net \
               --root=/srv/minicontainer \
               /bin/bash

First you need a minimal root, which is exactly what an image provides:

$ sudo mkdir -p /srv/minicontainer
$ sudo docker export $(sudo docker create alpine:3.20) \
    | sudo tar -x -C /srv/minicontainer
$ ls /srv/minicontainer
bin  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

And now, inside:

$ sudo unshare --pid --fork --mount-proc --uts --ipc --mount --net \
      chroot /srv/minicontainer /bin/sh

/ # hostname minicontainer
/ # hostname
minicontainer

/ # ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh
    8 root      0:00 ps aux

/ # ip addr
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

/ # cat /etc/os-release | head -2
NAME="Alpine Linux"
ID=alpine

A name of its own, PID 1, two visible processes, no network, and a different distribution from the host's — all without starting a single kernel.

# --- From the host, the check that proves the thesis ---
$ pgrep -a -f 'unshare --pid'
10412 sudo unshare --pid --fork --mount-proc --uts --ipc --mount --net chroot ...
$ pid=$(pgrep -f 'chroot /srv/minicontainer' | head -1)

$ ps -o pid,ppid,user,comm -p "$pid"
    PID    PPID USER     COMMAND
  10415   10412 root     sh

$ sudo cat /proc/$pid/status | grep -E '^Name|^Pid|^NSpid'
Name:	sh
Pid:	10415
NSpid:	10415	1

$ sudo readlink /proc/$pid/ns/pid /proc/1/ns/pid
pid:[4026532398]
pid:[4026531836]

$ sudo readlink /proc/$pid/root
/srv/minicontainer

The line NSpid: 10415 1 is the exact demonstration: the same process has PID 10415 on the host and PID 1 inside its namespace. And uname -r gives the same kernel on both sides:

$ uname -r
6.8.0-41-generic
$ sudo nsenter -t "$pid" -a uname -r
6.8.0-41-generic

What it is missing in order to be a real container:

Missing Why it matters How Docker does it
cgroups With no limits, the process can consume all the host's memory and CPU Creates a cgroup with memory.max, cpu.max, pids.max
Reduced capabilities Here the process keeps root's: it could load modules or mount anything Removes ~26 of the 40 capabilities
seccomp With no filter, it can invoke any system call, the vulnerable ones included Applies a profile that blocks ~44 calls
AppArmor / MAC With no profile, DAC is the only barrier Applies docker-default
pivot_root instead of chroot chroot can be escaped; pivot_root unmounts the old root Uses pivot_root
A usable network The network namespace is empty: only lo, and it is down Creates a veth, connects it to a bridge and configures NAT
A layered filesystem Here the root is an ordinary directory, and every "container" needs its own full copy Uses overlayfs: read-only layers plus one writable layer
User namespace The root inside is the root outside Optional in Docker; the default in rootless podman
Lifecycle management There are no images, no versions, no way of reproducing this Registry, tags, digests

The conclusion the exercise asks for: Docker contributes no new primitive. The isolation has been in the kernel for over a decade. What it contributes is the packaging: an image format with shared layers, a registry to publish them to, a reproducible way of building them, and the consistent application of the eight restrictions in the table, which by hand would be a long script that is easy to get wrong. Understanding this explains why compatible alternatives such as podman exist: if the primitives belong to the kernel, anybody can orchestrate them.

Solution 2

Eleven problems. The corrected version first, and then the reason for each one:

# --- Stage 1: dependencies ---
# 1. A PINNED version, not latest. 2. A slim base instead of the full one.
FROM python:3.12-slim-bookworm AS builder

WORKDIR /app

# 3. The dependencies BEFORE the code: the cache is reused as long as
#    requirements.txt does not change.
COPY requirements.txt .
# 4. A single chained RUN, without leaving the pip cache in the layer.
RUN pip install --no-cache-dir --prefix=/installed -r requirements.txt

# --- Stage 2: runtime ---
FROM python:3.12-slim-bookworm

# 5. A single RUN, with the cleanup INSIDE the same layer.
#    No git and no pip: they are build tools, not runtime ones.
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/* \
    && groupadd -g 1002 tramontana \
    && useradd -u 997 -g tramontana -s /usr/sbin/nologin -M svc-tramontana

WORKDIR /app

# 6. Only the installed dependencies, without the build tree
COPY --from=builder /installed /usr/local
# 7. Correct ownership from the start
COPY --chown=svc-tramontana:tramontana . /app

# 8. Do NOT run as root
USER svc-tramontana

# 9. No secrets. Only non-sensitive configuration.
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    TRAMONTANA_PORT=8080

EXPOSE 8080

# 10. A health check, which also enables depends_on in Compose
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD curl -fsS "http://127.0.0.1:${TRAMONTANA_PORT}/health" || exit 1

# 11. Exec form, not shell form: the process is PID 1 and receives the signals
ENTRYPOINT ["python3", "server.py"]

And the .dockerignore that was missing entirely:

.git
.gitignore
.env
secrets/
*.bak-*
__pycache__/
*.pyc
Dockerfile
compose.yaml
README.md

The eleven problems, in order of severity:

# Problem Consequence
1 ENV DB_PASSWORD=... The most serious one. The password stays in the image for ever, visible with docker history or docker inspect, and in any registry it is published to. And even if it is deleted in a later layer, the original layer keeps it
2 FROM ubuntu:latest No reproducibility: today's build and the one a month from now produce different images. And the full ubuntu is 78 MB against slim's 29
3 It runs as root Without USER, the process is root inside. Combined with a container escape, it is root on the host
4 git in the final image A build tool left in production: attack surface and CVEs. pip is surplus too
5 COPY . /app before installing the dependencies Any code change invalidates the cache and forces everything to be reinstalled: minutes on every build
6 Three separate RUNs Three layers, and the apt index (~40 MB) stays inside the image because it is never deleted in the same layer
7 apt-get update without install in the same RUN The cache can reuse the old update and the install can then bring in out-of-date packages. It is the cache busting problem
8 No --no-install-recommends It drags in dozens of packages nobody asked for
9 CMD python3 server.py in shell form It runs as /bin/sh -c "python3 server.py", so PID 1 is the shell and it does not propagate SIGTERM to Python: docker stop ends up killing the process after 10 seconds with SIGKILL, with no clean shutdown
10 No HEALTHCHECK Docker does not know whether the service works, and depends_on: condition: service_healthy cannot be used
11 No .dockerignore COPY . /app puts the whole of .git in — with the entire history, including already-deleted secrets — and any .env that happens to be there

The measured result:

$ sudo docker build -t tramontana-luis:bad -f Dockerfile.original .
$ sudo docker build -t tramontana:good .
$ sudo docker image ls | grep -E 'tramontana'
tramontana         good   f4a1c88e2d4b   14 seconds ago   142MB
tramontana-luis    bad    a3c1f88e2d4c   2 minutes ago    684MB

$ sudo docker history tramontana-luis:bad | grep -i password
<missing>  2 minutes ago  ENV DB_PASSWORD=Zx9K2pQ7vLm4RtWn   0B

684 MB against 142, and the password recoverable with a single command by anybody who has access to the image.

The note Luis has to be given: the password that appears in that Dockerfile is the production one, so — following the rule from 06-05 — it is considered compromised from the moment it was written, and it has to be rotated with the create-apply-verify-retire procedure. Correcting the file is not enough.

Solution 3

Analysis: migrating Tramontana Bookings to containers To: Marta Vidal · From: Systems Operations · 18 August 2026

Short recommendation: not yet, but do prepare for it. Containerising srv-tramontana today would add complexity without solving a single outstanding problem. I propose an intermediate path with immediate benefit and no risk.


The starting point matters. This is not an application that was deployed any old how. srv-tramontana has atomic deployment with automatic rollback, a hardened systemd service with the best security score the tool gives, encrypted secrets, logs centralised in the journal with rotation, backups with restoration tested, change detection and an allowlist firewall. It works, it is documented and it is measured. That is the benchmark to compare against.

What we would gain

  1. Deployments reproducible bit for bit. Today deploy.sh moves a symbolic link to a new directory, and the environment — system libraries, versions — is whatever happens to be on the machine. With an image, what you test is exactly what you deploy. Remember that release 3.3.0 failed in July and we did not know why until this week: a failure of that kind is less likely with images, because the environment travels with the application.
  2. An even faster rollback. Going back to the previous image means changing a tag.
  3. Parity between environments. The same image in testing and in production, without the drift that appears when two machines are configured separately.
  4. Additional isolation of the application. A compromise would be better contained: no useful shell, no access to the rest of the filesystem, no capabilities.
  5. Readiness to grow. If at some point two or three instances behind a load balancer are needed, with containers it is a matter of minutes.

What it would cost

  1. Redoing work that is already done and validated. The systemd hardening, the AppArmor profile, the secrets management and the journal integration would all have to be rebuilt with the container equivalent. It is not impossible, but it is weeks of work to arrive where we already are.
  2. One more layer to learn and maintain. Images, registries, virtual networks, volumes, and their own failure modes — which are different from and less familiar than the system's.
  3. A new and specific security risk. The Docker daemon runs as root, and belonging to the docker group is equivalent to having root with no trace left behind. On top of that, Docker writes firewall rules that bypass ufw: a published port is left open even though the firewall says otherwise. I have verified this in the lab. It would have to be managed explicitly.
  4. The database is the difficult part. PostgreSQL in a container is possible, but persistent storage adds complexity and contributes nothing on a single server. My recommendation would be to leave it outside in any case.
  5. It does not solve our real problem. Our open problem is that srv-tramontana is a single point of failure. Containers do not solve it: if the machine goes down, the containers go down with it. That is redundancy, and it is the subject I will be bringing you a proposal on.

What I propose, in three steps

Step 1 — Now: containerise the test environment only. Immediate benefit, zero risk. It lets us bring up the application and a clean database in seconds to test a change, and learn the tool without exposing production. It is already working on srv-tramontana-test.

Step 2 — Next: build the image on every release, without deploying it. Each version additionally produces a versioned and scanned image. We gain reproducibility and vulnerability detection in the dependencies, without changing anything in production. It is a reversible step.

Step 3 — When one of the conditions is met: migrate. The conditions that would justify it:

Condition Why it changes the decision
Two or more instances of the application are needed That is where containers really start to pay off
More services appear (an API, a task processor) Managing five services by hand is where Compose wins
There is more than one environment to keep in sync Parity stops being a luxury
We have another failure caused by environment differences It would be the second time; once is coincidence

And an observation about the alternative. If we take the step, I propose evaluating podman instead of Docker: it has no privileged daemon, it works without root, it solves the docker group problem at the root, and it integrates with systemd by generating units — that is, it fits with everything we already have in place rather than replacing it. Its commands are compatible, so nothing that has been learned is lost.

Summary. Containers are the right answer to a problem of scale and reproducibility between environments. Today we have one server, one environment and an application that works, so the benefit does not justify the cost. We start with testing and with building the images, which is where the benefit is immediate, and we reassess when one of the conditions in the table changes.

Conclusion

You have the right idea, which is the most valuable thing in this lesson: a container is an isolated process, not a small machine. You have seen it with no intermediaries — a bash that is PID 1 inside and has an ordinary PID outside, with NSpid: 10415 1 in /proc as proof — and you know that Docker invents no primitive: namespaces, cgroups and capabilities are in the kernel, and they are literally the same ones as the MemoryMax=512M from 05-05 and the chroot from 07-01. What Docker contributes is the packaging: layered images with shared layers, a registry, reproducible builds, and the consistent application of eight restrictions that by hand would be a long and fragile script.

You know how to build an image that weighs 19 MB instead of 850, ordering the instructions to take advantage of the cache and using two stages so that the compiler never reaches production — which is the minimum-surface principle from 06-06 applied to an image. You know that a secret in a layer is never deleted, that -p 8080:8080 bypasses ufw and has to be verified from outside with nmap, that USER is not optional, and that docker compose down -v destroys the data. And you have resolved the kernel.unprivileged_userns_clone you left commented out in 06-06: it stays enabled, with the decision and its reason written down, because the containment that containers provide is worth more than the surface it would close.

Notice now where you stand. You have srv-tramontana in production, srv-tramontana-test recreatable with cloud-init, and containers that bring up the application and its database with one command. Three ways of creating machines and services. And all three share a problem: the production configuration is still a runbook in prose plus the administrator's memory. Every user, every sudoers rule, every systemd unit, every ufw line, every sysctl and every AppArmor profile from Modules 5 and 6 was applied by hand. The 8-hour RTO that Marta approved depends entirely on somebody remembering the right order, and the rule from 06-04 — "a compromised server is reinstalled, not cleaned" — is easy to say and expensive to honour.

In lesson 07-06: Automation with Ansible that becomes code. You will see the difference between an imperative script such as deploy.sh and an idempotent desired state, you will understand why Ansible needs no agent — it works over the SSH you hardened in 06-02 — and you will write inventories, playbooks with handlers and tags, Jinja2 templates that generate app.conf with per-environment variables, and roles that organise the whole thing into reusable pieces. You will use --check and --diff, which are the course's --dry-run taken to the entire configuration, you will keep the secrets in Vault alongside your pass, and you will test every change against srv-tramontana-test before touching production. And at the end you will take the measurement that matters: how long the server takes to be rebuilt from scratch. That number — from eight hours to under one — is what turns a security rule into a procedure that can genuinely be followed.

Linux Course: From Beginner to System Administrator

Module 1: Introduction to Linux

Module 2: Basic Linux Commands

Module 3: Advanced Command-Line Skills

Module 4: Shell Scripting

Module 5: System Administration

Module 6: Networking and Security

Module 7: Advanced Topics

Module 8: Practical Projects

© Copyright 2026. All rights reserved