Docker & Kubernetes

Volumes, Networks and Running Containers Safely

Persisting data, bind mounts, networks and ports, environment variables and secrets, non-root users and other safety habits.

Lesson 0214 min read#docker#volumes#networking#security

On this page

  1. Words used in this lesson
  2. Part 1: storage
  3. Part 2: networking
  4. Part 3: secrets and settings
  5. Part 4: running containers safely
  6. Part 5: a clean-up routine
  7. Try it
  8. Interview questions
  9. Key takeaways
  10. What's next

In one line: a container's own files disappear with it, so anything you want to keep goes into a volume; containers talk to each other by name on a user-defined network, and only reach your laptop through a published port; and a few flags (non-root user, dropped capabilities, read-only file system, memory limit) turn a risky container into a boring one.

Your image is built and it is small. Now it has to live in the real world: it needs a database that keeps its data, it needs to talk to other containers, you have to open the right port (and not too many), it needs settings and passwords, and it should not be a gift to anyone who finds a bug in your code.

This lesson covers those four things in order: storage, networking, configuration, safety. Each section has a small test you can run yourself, and I paste what I got. The topics come from the Docker documentation, but the outputs come from my own runs.

This is lesson 2 of 10. It builds on Writing a Dockerfile. The next lesson, Docker Compose, writes all of this in one file so you do not have to type long docker run commands. The safety habits here are the container version of the ideas in Security: Lesson 0 (least privilege and defence in depth). Kubernetes has its own versions of volumes, networks and secrets in lesson 5 and lesson 6.

What I ran / checked. Same machine as before: Docker Engine 29.8.2, kernel 6.18.44, cgroup v1, with busybox:1.36, alpine:3.20, postgres:16-alpine and my own notes-api image from lesson 1. Everything pasted below is real output. IP addresses, container IDs and times differ on every run. Sources I read (own words): the Docker docs on Volumes, Bind mounts, the Networking overview, the page on packet filtering and firewalls (which has a section on ufw), the Engine security page, and the "Building best practices" page (the USER and VOLUME sections), plus the Node.js best practices project (goldbergyoni/nodebestpractices), which has Docker items on secrets, memory limits and graceful shutdown. What I could not test: reaching a published port from another computer on the network. I have only one machine, so I show what Docker configured (which address it bound to with docker port), not an attack from outside. Rootless Docker and user-namespace remapping are described from the docs, not run. I did not verify any GitHub star counts.

Words used in this lesson

  • Writable layer is the thin layer on top of an image where a container writes its own files. It is deleted with the container.
  • Volume is storage managed by Docker that lives outside any one container and survives its removal.
  • Named volume is a volume with a name you choose, such as pgdata.
  • Bind mount is a folder from the host mounted into the container at a path you choose.
  • tmpfs mount is a memory-only folder. It vanishes when the container stops.
  • Bridge network is a private virtual network on the host that containers join. The default one is called bridge.
  • User-defined network is a bridge network you create yourself. It adds name lookup between containers.
  • Published port is a host port that Docker forwards to a port inside a container, set with -p.
  • Environment variable is a named setting passed to a process, such as PORT=4000.
  • Capability is one small slice of root's power, such as "bind low ports" or "change file owners". Containers get a reduced set.
  • Non-root user is a user that is not UID 0, so it has no special power over files and devices.
  • Docker socket is the file /var/run/docker.sock, the door to the Docker daemon. Whoever can use it can control Docker.

Part 1: storage

The problem: a container's files are temporary

In lesson 0 we saw that files written inside a container live in its writable layer, which is deleted with it. Here it is in the plainest form:

Shell
docker run --name v1 busybox:1.36 sh -c 'echo note1 > /data.txt; cat /data.txt'docker rm v1docker run --rm busybox:1.36 ls /data.txt
TEXT
note1ls: /data.txt: No such file or directory

The file was written and read, then the container was removed, and a new container from the same image never sees it. For a web server this is fine. For a database it would be a disaster. The Docker docs say the same: if data must outlive the container, it should not live in the container's layer.

There are three places to put it:

TypeWhere the data livesBest for
VolumeA folder managed by Docker on the hostDatabases, uploads, anything that must persist
Bind mountA host folder you chooseDevelopment (edit code on host, see it in container)
tmpfsMemory onlyTemporary or secret data you do not want on disk

Named volumes

A volume is created by Docker and has a name. You give the name when you run the container, with -v name:/path:

Shell
docker volume create notes-datadocker run --rm -v notes-data:/data busybox:1.36 sh -c 'echo note1 > /data/n.txt'docker run --rm -v notes-data:/data busybox:1.36 cat /data/n.txtdocker volume inspect notes-data --format '{{.Driver}} {{.Mountpoint}}'
TEXT
notes-datanote1local /var/lib/docker/volumes/notes-data/_data

The first container wrote the file and was deleted (--rm). A second, different container read it back. The data lives in /var/lib/docker/volumes/notes-data/_data on the host. I listed that folder directly as root, and n.txt was there with the text note1. So a volume is, underneath, an ordinary folder. What Docker adds is a name, a life cycle separate from containers, and a driver system (so a volume can also be on network storage).

The Docker docs list reasons to prefer volumes over bind mounts for persistent data: they are easier to back up and move, they work the same on Linux and Windows hosts, and Docker manages them completely.

A real test: Postgres keeps its data

The standard example. Run Postgres with a volume on its data folder, create a table, delete the container, start a new one on the same volume, and read the table:

Shell
docker volume create pgdatadocker run -d --name pg1 -e POSTGRES_PASSWORD=devonly \  -v pgdata:/var/lib/postgresql/data postgres:16-alpine# wait until ready, then:docker exec pg1 psql -U postgres -c "create table notes(id serial, text text); insert into notes(text) values ('survives');"docker rm -f pg1docker run -d --name pg2 -e POSTGRES_PASSWORD=devonly \  -v pgdata:/var/lib/postgresql/data postgres:16-alpinedocker exec pg2 psql -U postgres -c "select * from notes;"
TEXT
CREATE TABLEINSERT 0 1 id |   text   ----+----------  1 | survives(1 row)

The container pg1 was force-removed, and pg2 is a brand-new container. The row is still there. This is the whole idea: the container is disposable, the volume is not. (In my script I waited with a loop on pg_isready, which comes up again in lesson 3.)

docker rm does not remove volumes you named. They stay until you run docker volume rm or docker volume prune. That is good for safety and bad for disk space. Anonymous volumes (created by a VOLUME line in an image without a name) pile up silently. Run docker volume ls now and then.

Bind mounts

A bind mount maps a host folder into the container. Both sides see the same files:

Shell
echo from-host > /tmp/bind/h.txtdocker run --rm -v /tmp/bind:/b busybox:1.36 sh -c 'cat /b/h.txt; echo from-container > /b/c.txt'cat /tmp/bind/c.txt
TEXT
from-hostfrom-container

The container read the host's file and wrote a new one that the host sees at once. This is perfect in development: you edit your code in your editor, and the container sees the change without a rebuild. In production, avoid bind mounts for app code, because the container then depends on the host's folder layout (and that breaks the "same everywhere" promise).

Mount things read-only when the container only needs to read them. Add :ro:

TEXT
$ docker run --rm -v /tmp/bind:/b:ro busybox:1.36 sh -c 'echo x > /b/z.txt'sh: can't create /b/z.txt: Read-only file system

The newer --mount flag spells out the type and is less error-prone than -v, which guesses from the shape of the text. I ran it with --mount type=volume,src=notes-data,dst=/data,readonly, and writing a file gave the same "Read-only file system" error while reading worked. The Docker docs say --mount is the more explicit way, and one small difference to know: with -v, if the host folder does not exist, Docker creates it as root (the docs mention this), but --mount type=bind gives an error instead. The error is the better behaviour.

tmpfs

A tmpfs mount lives in RAM only:

TEXT
$ docker run --rm --tmpfs /scratch:size=1m busybox:1.36 sh -c 'df /scratch | tail -1; echo hi > /scratch/a; cat /scratch/a'tmpfs                     1024         0      1024   0% /scratchhi

A 1 MB memory disk. Use it for temporary files, or for anything sensitive that must never touch the disk. We will use it again when we make the root file system read-only.

The permission trap

Here is a trap that bites almost everyone the first time they run as non-root. The folder you mount must be writable by the user inside the container. I tried a container running as user 1000 against a host folder owned by root:

TEXT
$ ls -ld /tmp/rootdir      -> owner root$ docker run --rm --user 1000:1000 -v /tmp/rootdir:/data busybox:1.36 sh -c 'echo hi > /data/f'sh: can't create /data/f: Permission denied

The same happens with a brand-new named volume if the folder does not already exist in the image. A fresh volume is created owned by root:

TEXT
$ docker run --rm -v vol2:/other --user 1000:1000 busybox:1.36 sh -c 'ls -ld /other; echo x > /other/f'drwxr-xr-x    2 root     root          4096 Oct 12 03:29 /othersh: can't create /other/f: Permission denied

But there is a nice feature: if the image already has that folder, with an owner, then an empty named volume mounted there copies the folder's owner and contents on first use. The Docker docs describe this ("mounting a volume over existing data" and populating a new volume). I proved it with an image that did mkdir /data && chown 1000:1000 /data and USER 1000:

TEXT
drwxr-xr-x    2 1000     1000          4096 Oct 12 03:29 /datawrote

So the fix in a Dockerfile is: create the folder and give it to the app user before you declare the volume or run the container. For bind mounts, fix the permissions on the host, or pass --user with a matching UID.

The VOLUME instruction

In a Dockerfile, VOLUME /var/lib/data says "this path holds data, make a volume for it". If you do not name one at run time, Docker creates an anonymous volume with a random name. The Docker best practices page recommends using VOLUME for database storage, configuration, or user-made files. My advice: it is optional, and many teams prefer to name volumes explicitly in docker run or Compose. The danger of VOLUME in a base image is that it makes anonymous volumes appear, and any later RUN that changes that folder is lost.

Part 2: networking

What containers see

By default, each container gets its own network namespace (lesson 0). Docker connects it to a bridge network, a virtual switch on the host. The container gets its own IP, a gateway, a routing table and DNS settings. It can reach the outside world (Docker does address translation, called masquerading, so the host's internet connection is shared). Nothing from outside can reach it unless you publish a port.

Docker has a few built-in network types:

DriverWhat it does
bridgeA private virtual network on the host. The default.
hostNo network isolation: the container uses the host's network directly.
noneNo network except loopback.

I ran docker network ls:

TEXT
NAME      DRIVERbridge    bridgehost      hostnone      null

(Plus a leftover kind network from an earlier experiment.) In host mode the container saw the host's real interfaces. I checked: hostname printed the host's name vm, and the list of interfaces included docker0 and eth0. Host mode is fast but gives up isolation of the network, and ports can clash. Use it rarely.

The default bridge versus a user-defined network

This is the most important networking fact for beginners. There are two kinds of bridge, and only one gives you name lookup. I started a web server on each and tried to reach it by name from a second container:

Shell
docker network create appnetdocker run -d --name web1 --network appnet busybox:1.36 sh -c 'httpd -f -p 8080 -h /etc'docker run -d --name web0 busybox:1.36 sh -c 'httpd -f -p 8080 -h /etc'      # default bridge# on the user-defined network, by name:docker run --rm --network appnet busybox:1.36 wget -qO- http://web1:8080/hostname# on the default bridge, by name:docker run --rm busybox:1.36 wget -qO- -T 3 http://web0:8080/hostname
TEXT
26d448a3cc2ewget: bad address 'web0:8080'

On appnet, the name web1 worked (the output is the server's own container ID, which /etc/hostname contains). On the default bridge, the name web0 was not found. Only the IP address works there:

TEXT
$ docker inspect -f '{{.NetworkSettings.Networks.bridge.IPAddress}}' web0172.17.0.2$ docker run --rm busybox:1.36 wget -qO- -T 3 http://172.17.0.2:8080/hostname724d90925c82

A user-defined network has an embedded DNS server that maps container names (and network aliases) to IPs. The default bridge does not. And IPs change every time a container is recreated, so you do not want IPs in config. The rule: always create a network for your app and put your containers on it. Compose does this for you (lesson 3).

In newer Docker versions the old field NetworkSettings.IPAddress is empty. I got a template error when I used it. Use NetworkSettings.Networks.<name>.IPAddress, as above.

Networks are walls

Two different user-defined networks cannot talk to each other by default. I tried to reach a container on the default bridge from a container on appnet:

TEXT
$ docker run --rm --network appnet busybox:1.36 wget -qO- -T 3 http://172.17.0.2:8080/hostnamewget: download timed out

The packet never arrived. This is useful: put your database on a network that only your API is on, and nothing else can reach it. That is the same idea as the Kubernetes NetworkPolicy in lesson 5, but built into the container network.

You can also see who is on a network:

TEXT
$ docker network inspect appnet -f '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{end}}'web1 172.30.0.2/24

Publishing ports

Containers on a bridge are not reachable from the host's network unless you publish a port. -p HOST:CONTAINER forwards a host port to a container port. There is a small detail that matters a lot for safety: which host address it binds to. I ran two:

Shell
docker run -d --name pub  -p 127.0.0.1:8099:8080 busybox:1.36 ...docker run -d --name pub2 -p 8098:8080           busybox:1.36 ...docker port pubdocker port pub2
TEXT
8080/tcp -> 127.0.0.1:80998080/tcp -> 0.0.0.0:8098

The first is bound to 127.0.0.1, so only programs on this machine can use it. The second is bound to 0.0.0.0, which means every network interface, so anyone who can reach this machine's IP can connect. (I did not test from another computer, since I had one machine. The docker port output shows the address Docker configured.)

-p 5432:5432 is open to the whole network. If you publish a database port like that on a laptop at a café or a server on a public IP, the database is exposed. Docker's published ports are set up in the firewall by Docker itself, so a host firewall such as ufw may not block them. For anything that only you need, write -p 127.0.0.1:5432:5432. For container-to-container traffic, do not publish at all: put both on the same user-defined network and use the name.

Which port numbers are "inside" and "outside" is the second mix-up:

TEXT
-p 8080:3000    host port 8080  ->  container port 3000

Left is the host, right is the container. Remember it as "outside:inside".

Reading app settings from the environment

Your app should not have a database address or a port baked into the image. Use environment variables. My notes-api reads PORT. I ran it with -e PORT=4977 -p 127.0.0.1:4977:4977, and curl localhost:4977/notes returned the JSON, and the logs said listening on 4977. Same image, different settings: the Twelve-Factor idea of keeping configuration in the environment, not in the code.

You can set variables three ways:

Shell
docker run -e PORT=4000 -e LOG_LEVEL=debug notes-api         # one by onedocker run --env-file app.env notes-api                       # from a file, one KEY=value per line

and in the image with ENV (a default). --env-file read API_KEY=abc123 correctly in my test.

Part 3: secrets and settings

Passwords and API keys are settings, but they are sensitive ones. Where you put them matters.

I started a container with -e API_KEY=abc123 and asked Docker to show its configuration:

TEXT
$ docker inspect -f '{{.Config.Env}}' envtest[API_KEY=abc123 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin]

Anyone who can run docker inspect (anyone with access to the Docker daemon) sees the value. Environment variables can also leak into logs, crash reports and child processes. The Node best practices say never store plain secrets in config files or source code, and use a secrets manager or the platform's secret feature. In plain Docker the practical options, from weak to better, are:

MethodVisible in docker inspect?Notes
In the image (ENV, COPY .env)Yes, and in image historyNever. Anyone who pulls the image has it.
-e KEY=valueYesFine for non-secret settings and for local dev.
--env-fileYes (after it is loaded)Keep the file out of Git and out of the build context.
Mounted secret fileNoMount a file read-only at /run/secrets/name, and read it in the app.
Orchestrator secrets (Swarm, Kubernetes)NoDelivered in memory or as files. Lesson 6.

The file approach in a quick test:

TEXT
$ docker run --rm --mount type=bind,src=app.env,dst=/run/secrets/app,readonly alpine:3.20 cat /run/secrets/appAPI_KEY=abc123

The app reads the file at start-up. The secret is not in docker inspect's Env list, not in the image, and the mount is read-only. (A process in the container can still read it, of course. A secret has to be readable by the app that uses it. The goal is to avoid extra copies.)

Also remember lesson 1: .dockerignore your .env file, and use --mount=type=secret for build-time secrets.

Part 4: running containers safely

A container is a process on the host kernel. If an attacker takes over your app, the next question is: what can they do from there? Each habit below shrinks the answer. They match the least-privilege idea from the Security series.

1. Do not run as root

By default, a process in a container is UID 0 (root) unless the image says otherwise. That root is limited (it has fewer powers than a host root), but it is still more than your app needs. In lesson 1 we set USER node. You can also force it at run time:

TEXT
$ docker run --rm alpine:3.20 iduid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),...$ docker run --rm --user 1000:1000 alpine:3.20 iduid=1000 gid=1000 groups=1000

The Docker best practices page says: if a service can run without privileges, use USER. It also says to assign an explicit UID/GID if it matters, because the numbers Docker picks for new users are not stable between builds.

2. Drop capabilities

Root is not one power. The Linux kernel splits it into about forty capabilities, such as changing file owners, binding to low ports, using raw sockets. Docker gives containers a reduced default set. I read the effective capability mask from /proc:

TEXT
default:         CapEff: 00000000a80425fb     (14 capabilities set)--cap-drop ALL:  CapEff: 0000000000000000     (none)--privileged:    CapEff: 000001fffeffffff     (nearly all)

(I counted the 14 bits in the default mask myself.) A web app almost never needs any of them. So --cap-drop ALL is a free win. If a particular one is needed, add only that one with --cap-add.

Here is how it feels. As container-root with the default set, chown is allowed. With all capabilities dropped it is not, even though the user is still root:

TEXT
$ docker run --rm --cap-drop ALL alpine:3.20 sh -c 'chown 5:5 /etc/hostname'chown: /etc/hostname: Operation not permitted

People worry that dropping all capabilities stops a web server using port 80. In my test it did not. A non-root user with every capability dropped could still bind port 80, because modern Docker sets the kernel setting net.ipv4.ip_unprivileged_port_start to 0 inside containers (I read the value: 0). So you do not need NET_BIND_SERVICE for it. That setting is Docker-specific, and Kubernetes behaviour depends on your runtime, so test it.

3. Make the file system read-only

Most apps do not need to write to their own folders. If an attacker cannot write files, they cannot easily plant tools or change your code. Add --read-only, and give writable places only where needed with --tmpfs or a volume:

TEXT
$ docker run --rm --read-only --tmpfs /tmp alpine:3.20 sh -c 'echo ok > /tmp/y && cat /tmp/y; touch /etc/x'oktouch: /etc/x: Read-only file system

/tmp (a memory disk) works, /etc does not. Without --tmpfs /tmp, writing to /tmp also failed in my test. Frameworks sometimes need a temp folder, so you will find out quickly.

4. No new privileges

--security-opt no-new-privileges stops a process from gaining more rights than it started with (for example through a setuid program). It is cheap and rarely breaks anything.

5. Limit resources

We met this in lesson 0. A runaway or hijacked process should not be able to starve the host. Limit memory, CPU and the number of processes:

TEXT
$ docker run -d --name st --memory 64m --cpus 0.5 busybox:1.36 sleep 60$ docker stats --no-streamNAME      MEM USAGE / LIMIT   CPU %st        696KiB / 64MiB      0.00%

docker stats shows usage against the limit. The --pids-limit flag blocks fork bombs. For Node, the Node best practices say to set both the Docker memory limit and V8's own heap setting (--max-old-space-size), so the garbage collector starts before the container is killed.

Putting it together

Here is my notes-api image, run with all of these:

Shell
docker run -d --name hard \  --read-only --cap-drop ALL --security-opt no-new-privileges \  --memory 128m --pids-limit 100 \  -e PORT=4977 -p 127.0.0.1:4977:4977 \  notes-api:good

I checked that it still worked and that the settings were applied:

TEXT
$ curl -s localhost:4977/notes[{"id":1,"text":"hello 3"}]$ docker logs hardlistening on 4977$ docker exec hard sh -c 'id; grep CapEff /proc/self/status; touch /app/x'uid=1000(node) gid=1000(node) groups=1000(node)CapEff:	0000000000000000touch: /app/x: Read-only file system$ docker inspect hard -f 'RO={{.HostConfig.ReadonlyRootfs}} Mem={{.HostConfig.Memory}} Pids={{.HostConfig.PidsLimit}} CapDrop={{.HostConfig.CapDrop}} SecOpt={{.HostConfig.SecurityOpt}}'RO=true Mem=134217728 Pids=100 CapDrop=[ALL] SecOpt=[no-new-privileges]

The app serves traffic, runs as UID 1000, has zero capabilities, cannot write to its own files, and is held to 128 MB and 100 processes. That costs nothing. If your app does need a writable folder, add --tmpfs or a named volume for that one path.

Things you should never do

Two flags turn the walls off:

--privileged. It gives the container nearly all capabilities (I read 000001fffeffffff) and access to the host's devices. I counted entries in /dev: 111 with --privileged against 14 without. A privileged container is close to being root on the host. Use it only when you really must (for example, docker-in-docker for a CI worker you trust), never for an ordinary app.

Mounting the Docker socket. Many tutorials do -v /var/run/docker.sock:/var/run/docker.sock so a container can run Docker commands. Be very careful. I ran a container with the socket mounted and the Docker CLI inside, and asked it to list the host's containers:

TEXT
$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:cli docker ps --format '{{.Names}}'l8-crashdetermined_ardinghellidk-control-plane

It saw other containers on the host. Worse, I asked that container to start a new container that mounts the host's whole file system and read a host file:

TEXT
$ ... sh -c 'docker run --rm -v /:/host busybox:1.36 cat /host/etc/hostname'vm

That printed the host's real hostname from the host's /etc. Anything that can use the Docker socket can read and write the entire host. Mounting the socket is giving root on the host. The Docker security docs make the same point: the daemon itself needs root, so access to it must be controlled, and only trusted users should be allowed to control it. If you need CI to build images, use a build service, rootless builders, or a separate machine.

Other habits

  • Rootless Docker runs the daemon and containers without root on the host. It is a mode described in the Docker docs and is a strong extra layer. I did not run it here.
  • User namespaces can map the container's root to an unprivileged host user. By default Docker does not (we saw user namespace numbers were equal in lesson 0).
  • Update images. Rebuild often to get fixed base layers. Scan with a vulnerability scanner in CI.
  • Small images. Fewer files, fewer tools for an attacker (no curl, no shell if you can avoid it).
  • One process per container. The Docker docs recommend decoupling: a web server, a database and a cache are separate containers, each easy to scale and replace.
  • Do not keep state or secrets inside. Stateless containers are easier to secure and to replace.

Part 5: a clean-up routine

Containers, images and volumes pile up. These are the safe commands:

Shell
docker ps -a                    # see stopped containers toodocker rm NAME                  # remove a stopped containerdocker image prune              # remove unused dangling imagesdocker volume ls                # list volumes (check before deleting!)docker volume rm NAME           # remove one volume (the data is gone for good)docker system df                # how much space Docker uses

docker system prune --volumes deletes all unused volumes, which can include a database's data if its container happens to be stopped. Read what it lists before you say yes, and never run it on a machine with data you care about without a backup.

Try it

  1. Run a container with -v data1:/data, write a file, remove the container. Does data1 still exist? How do you list and remove it?
  2. You run docker run -p 5432:5432 postgres. Who can connect to it? Write the safer command for a database only you use.
  3. Two containers on the default bridge: can one reach the other by name? How do you fix it?
  4. Your app runs as user 1000 and fails to write to a named volume. Give two reasons and a fix.
  5. Name three flags that make a container less dangerous if the app is hacked.
  6. A colleague wants to mount /var/run/docker.sock into a monitoring container "just to read stats". What do you say?
  7. In -p 8080:3000, which number is the host port?

Answers

  1. Yes, it remains. docker volume ls lists it and docker volume rm data1 deletes it (once no container uses it).
  2. Anyone who can reach the machine's network address, because -p 5432:5432 binds to all interfaces. Safer: -p 127.0.0.1:5432:5432, or better, no -p at all and put the app and database on one user-defined network so the app reaches db:5432.
  3. No. The default bridge has no name lookup. Create a user-defined network (docker network create appnet) and run both containers with --network appnet, or use Compose.
  4. The volume or its folder is owned by root and the user is 1000, or the folder does not exist in the image so the new volume starts root-owned. Fix: in the Dockerfile create the folder and chown it to the app user before the volume is first used, or fix the host folder's owner for a bind mount.
  5. For example --user (non-root), --cap-drop ALL, --read-only, --security-opt no-new-privileges, --memory and --pids-limit.
  6. No. Access to the socket is the same as root on the host: the container can start another container with the whole host mounted. If stats are all that is needed, use a purpose-built exporter, a read-only socket proxy, or the metrics endpoint of the tool.
  7. 8080 is the host port (outside). 3000 is the container port (inside).

Interview questions

What is the difference between a volume and a bind mount? (Must know)

A volume is storage created and managed by Docker, with its own name and life cycle, kept in Docker's own area on the host. A bind mount maps a specific host path into the container. I use named volumes for persistent data like databases, because they are portable and easy to back up, and bind mounts in development to share source code with the container. In my test, Postgres kept its table after the container was deleted because the data was on a named volume.

Where is data stored in a container, and what happens when it is removed? (Must know)

Files written without a mount go into the container's writable layer on top of the image. That layer is deleted with the container. A new container from the same image starts clean. In my test a file written in one container was gone in the next. Anything that must survive goes into a volume or an outside service.

Why can containers on a user-defined network reach each other by name, but not on the default bridge? (Must know)

User-defined bridge networks have an embedded DNS service that resolves container names and aliases. The default bridge has none, so only IP addresses work, and those change when containers are recreated. I tested it: http://web1:8080 worked on my own network and http://web0:8080 gave bad address on the default bridge. So I always create a network per app, and Compose does that automatically.

What does -p 8080:3000 do, and what is the security risk? (Must know)

It forwards host port 8080 to port 3000 in the container. Without an address it binds on all interfaces (0.0.0.0), which I confirmed with docker port, so anyone who can reach the host can reach it. For private services I use -p 127.0.0.1:8080:3000, or I skip publishing and use a shared network. Docker writes its own firewall rules, so a host firewall may not block the published port.

How do you handle secrets with plain Docker? (Senior signal)

I keep them out of the image and out of Git. -e and --env-file work but the value shows up in docker inspect, so I treat them as dev conveniences. A better way is to mount the secret as a read-only file and read it in the app. In an orchestrator I use its secret feature, and for builds I use --mount=type=secret. I also avoid logging environment variables.

What makes a container run as root, and why is it a problem? (Must know)

If the image has no USER, the process runs as UID 0 inside the container. Docker limits that root with capabilities and other defaults, but it is still more power than a web app needs, and a kernel or runtime bug turns it into host root more easily. I set USER in the Dockerfile, with an explicit UID, and I make sure mounted folders are writable by that user.

What are Linux capabilities, and what does --cap-drop ALL do? (Senior signal)

Capabilities split root's power into small pieces such as changing file owners or opening raw sockets. Docker gives containers a reduced default set (I counted 14 bits set in the mask). --cap-drop ALL removes all of them, and I can add back only what is needed with --cap-add. I tested it: even as root, chown failed with "Operation not permitted", and my Node API still worked with zero capabilities.

Why is mounting /var/run/docker.sock dangerous? (Trap)

The socket is the API to the Docker daemon, which runs as root. Any container with it can start a new container with the host's whole file system mounted and read or change anything. I demonstrated it by reading the host's /etc/hostname through a container that had only the socket. It is effectively root on the host, so I avoid it, or use a restricted proxy or a different build setup.

What flags would you use to harden an ordinary web container? (Senior signal)

A non-root user, --cap-drop ALL, --read-only with --tmpfs for temp folders, --security-opt no-new-privileges, and limits for memory and processes (--memory, --pids-limit). Also a published port bound to localhost if only a local proxy needs it. I ran my API with all of these and it served traffic normally: UID 1000, no capabilities, read-only root, 128 MB.

What does --privileged do? (Trap)

It gives the container almost all capabilities and access to host devices, and relaxes the default security filters. In my test the mask was 000001fffeffffff and /dev had 111 entries against 14 normally. It is close to running directly on the host as root. I only accept it for trusted tooling that truly needs it.

My non-root container can't write to a mounted folder. Why? (Trap)

The folder is owned by another user, usually root. A new named volume copies the owner and files from the image's folder if one exists, otherwise it starts as root. A bind mount keeps the host folder's owner. I fix it by creating and chowning the folder in the Dockerfile before the volume is first used, or by fixing the host folder or setting --user to match.

How should containers talk to a database container? (Senior signal)

Put both on the same user-defined network and connect by service name, for example db:5432. Do not publish the database port unless something on the host needs it, and if so bind it to 127.0.0.1. Credentials come from environment variables or files, never from the image. And the data goes in a named volume.

Key takeaways

  • A container's own files vanish with it. Use a named volume for data that must live on. My Postgres table survived docker rm.
  • Bind mounts share host folders (good for development). Add :ro when the container only needs to read.
  • Mounted folders must be writable by the container user. Create and chown them in the image.
  • Use a user-defined network so containers reach each other by name. The default bridge has no name lookup.
  • -p HOST:CONTAINER binds to all interfaces by default. Use 127.0.0.1: for private services, or do not publish.
  • -e values show up in docker inspect. Prefer mounted secret files and platform secrets.
  • Safe defaults to add: non-root USER, --cap-drop ALL, --read-only, no-new-privileges, memory and process limits.
  • Never mount the Docker socket into an app container, and avoid --privileged.

What's next

Next: Docker Compose: Running a Whole App Locally, Lesson 3. You have now typed many long docker run commands. Compose writes the same things (network, volumes, ports, environment, health checks) in one YAML file, and starts your API, Postgres and Redis with one command. I will also show a start-up order bug I hit for real, and its fix. Practise now: run your own app container with the hardened flags above and see which ones it needs you to relax.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series