Introduction
Some basic concepts:
- Images - an ordered collection of root filesystem changes and corresponding execution parameters for use, acting as the basis for containers
- Containers - a running instance of an image, acting as the operating environment
- Docker Engine - a daemon process on the host working as the runtime for containers
- Docker Desktop - a desktop toolkit including an embedded Docker Engine and other tools
Docker, Kubernetes, and VMs are all related to virtualization, but they have different common use cases:
- Docker - a local dev environment to remove the “it works on my machine” issue
- Kubernetes - for container orchestration, usually in a production environment
- VMs - a more full-fledged environment, including a kernel and kernel modules
Work with Containers
Run Containers
docker run <image> pulls the image if needed and starts up a container. Notice that the image is identified in the format:
- official images -
repo:tag - personal images -
user_ns/repo:tag
Example: run a container against the hello-world:latest image
$ docker run --rm hello-world:latest
Hello from Docker!
This message shows that your installation appears to be working correctly.
To generate this message, Docker took the following steps:
1. The Docker client contacted the Docker daemon.
2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
(amd64)
3. The Docker daemon created a new container from that image which runs the
executable that produces the output you are currently reading.
4. The Docker daemon streamed that output to the Docker client, which sent it
to your terminal.
To try something more ambitious, you can run an Ubuntu container with:
$ docker run -it ubuntu bash
Share images, automate workflows, and more with a free Docker ID:
https://hub.docker.com/
For more examples and ideas, visit:
https://docs.docker.com/get-started/
Notes:
hello-world- an official image on Docker Hublatest- avoid usinglatestbecause it may change when a newer version is published- Docker Hub - one of the main resources we pull images from
List Containers
To list all containers, including the stopped ones, use the following command:
docker container ls -as
# alternatively
docker ps -as
Explanation
a- display all containers, including the stopped oness- display the file size, for example4.19MB (virtual 516MB)4.19MB: the writable layer size, meaning how much data has been written inside this specific container516MB: the total size of the container’s filesystem, including all the read-only image layers plus the writable layer
Inspect a Container
To inspect a container, use docker container inspect <container>
$ docker container inspect my-postgres-16
[
{
"Id": "5f61f69143c4058721441059b1a620fd18b2450fc9fb53b1e2d5a68e881c5388",
"Created": "2026-09-04T11:28:45.999713761Z",
"Path": "docker-entrypoint.sh",
"Args": [
"postgres"
],
...
]
Go’s text/template is supported, so specific information can be queried
$ docker container inspect --format "{{json .Mounts}}" my-postgres-16 | jq .
[
{
"Type": "volume",
"Name": "6d61519596fd358dd8e0b5b091da97174f5c75ed79bcd03a723c86e5ac17eced",
"Source": "/var/lib/docker/volumes/6d61519596fd358dd8e0b5b091da97174f5c75ed79bcd03a723c86e5ac17eced/_data",
"Destination": "/var/lib/postgresql/data",
"Driver": "local",
"Mode": "",
"RW": true,
"Propagation": ""
}
]
$ docker container inspect --format "{{json .Config.Env}}" my-postgres-16 | jq .
[
"NO_PROXY=*.test.example.com,.example.org,127.0.0.0/8",
"no_proxy=*.test.example.com,.example.org,127.0.0.0/8",
"POSTGRES_PASSWORD=p@ssw0rd",
"HTTP_PROXY=http://172.17.0.1:10809",
"http_proxy=http://172.17.0.1:10809",
"HTTPS_PROXY=http://172.17.0.1:10809",
"https_proxy=http://172.17.0.1:10809",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/postgresql/16/bin",
"GOSU_VERSION=1.19",
"LANG=en_US.utf8",
"PG_MAJOR=16",
"PG_VERSION=16.15-1.pgdg13+2",
"PGDATA=/var/lib/postgresql/data"
]
To inspect a running container, another way is to get the tty of the running container with docker exec -it <container> <shell>
$ docker exec -it my-postgres-16 sh
# cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
NAME="Debian GNU/Linux"
VERSION_ID="13"
VERSION="13 (trixie)"
VERSION_CODENAME=trixie
DEBIAN_VERSION_FULL=13.6
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
Remove a Container
Remove a specific container with docker container remove <container_name>. For example:
$ docker container remove gifted_panini
Explanation:
gifted_paniniis the name of the hello-world container
Images
What is an image?
- App binaries and dependencies
- Not a complete operating system, because it does NOT contain a kernel or kernel modules
- Can be as small as a single file, like a static Go binary
- Can be as large as an Ubuntu distro with
apt, Apache, PHP, etc.
Browse images on Docker Hub. Typically, use official images and always identify a specific tag. Don’t use the latest tag since it may change to point to the newest one. Instead, indicate a concrete tag like 5.2.
To list all the images on the local computer, use the following command:
docker image ls
Image Layers
Images are built using a layered structure, as shown below:

To show the image layers, use the docker image history <image> command
$ docker image history nginx:1.31
IMAGE CREATED CREATED BY SIZE COMMENT
4e5db4761e0f 5 weeks ago CMD ["nginx" "-g" "daemon off;"] 0B buildkit.dockerfile.v0
<missing> 5 weeks ago STOPSIGNAL SIGQUIT 0B buildkit.dockerfile.v0
<missing> 5 weeks ago EXPOSE map[80/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 5 weeks ago ENTRYPOINT ["/docker-entrypoint.sh"] 0B buildkit.dockerfile.v0
<missing> 5 weeks ago COPY 30-tune-worker-processes.sh /docker-ent… 4.62kB buildkit.dockerfile.v0
<missing> 5 weeks ago COPY 20-envsubst-on-templates.sh /docker-ent… 3.03kB buildkit.dockerfile.v0
<missing> 5 weeks ago COPY 15-local-resolvers.envsh /docker-entryp… 389B buildkit.dockerfile.v0
<missing> 5 weeks ago COPY 10-listen-on-ipv6-by-default.sh /docker… 2.12kB buildkit.dockerfile.v0
<missing> 5 weeks ago COPY docker-entrypoint.sh / # buildkit 1.62kB buildkit.dockerfile.v0
<missing> 5 weeks ago RUN /bin/sh -c set -x && groupadd --syst… 82.7MB buildkit.dockerfile.v0
<missing> 5 weeks ago ENV DYNPKG_RELEASE=1~trixie 0B buildkit.dockerfile.v0
<missing> 5 weeks ago ENV PKG_RELEASE=1~trixie 0B buildkit.dockerfile.v0
<missing> 5 weeks ago ENV ACME_VERSION=0.4.1 0B buildkit.dockerfile.v0
<missing> 5 weeks ago ENV NJS_RELEASE=1~trixie 0B buildkit.dockerfile.v0
<missing> 5 weeks ago ENV NJS_VERSION=1.0.0 0B buildkit.dockerfile.v0
<missing> 5 weeks ago ENV NGINX_VERSION=1.31.3 0B buildkit.dockerfile.v0
<missing> 5 weeks ago LABEL maintainer=NGINX Docker Maintainers <d… 0B buildkit.dockerfile.v0
<missing> 6 weeks ago # debian.sh --arch 'amd64' out/ 'trixie' '@1… 78.6MB debuerreotype 0.17
Explanation
- View the layers from bottom to top. The
<missing>layer at the bottom, with a size of 78.6MB, is the first layer, created based on the base image. - The
<missing>value indicates that a layer is based on an image or another layer — it does NOT mean something is actually missing.<missing>means Docker has no local image metadata (ID) associated with that specific point in the chain.- If we separately have
debian:trixiepulled locally, and one of nginx’s layers happens to match debian’s topmost layer exactly (same content hash),docker image historycan actually resolve and show that real image ID instead of<missing>— because now there is a locally tracked image pointing at that layer.
- If we separately have
4e5db4761e0fis the final/topmost layer, and by convention the ID of an image is the ID of its topmost layer.
$ docker image list -f reference='nginx:*'
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
nginx:1.31 4e5db4761e0f 161MB 0B U
Reuse Layers
With the layering structure above, layers can be cached and reused. For example, when we run docker pull nginx:1.31, the following happens:
- Docker fetches the image manifest - a list of layer digests (content hashes) that make up the image.
- For each layer digest, Docker checks if it has a layer with this exact digest in local storage.
- If yes, it skips downloading and reuses the layer.
- If no, it downloads that layer blob.
- Once all layers are present locally, Docker creates the image record (config + tag,
nginx:1.31) pointing at that chain of layers.
Build a Custom Image
Images are built on top of base images using the FROM command in a Dockerfile. To prove that, check the Dockerfile of the official image nginx:1.31.
#
# NOTE: THIS DOCKERFILE IS GENERATED VIA "update.sh"
#
# PLEASE DO NOT EDIT IT DIRECTLY.
#
FROM debian:trixie-slim
# other definition
One trick to view the base image is to run the cat /etc/os-release command directly inside the running container.
$ docker run --rm --entrypoint cat nginx:1.31 /etc/os-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
NAME="Debian GNU/Linux"
VERSION_ID="13"
VERSION="13 (trixie)"
VERSION_CODENAME=trixie
DEBIAN_VERSION_FULL=13.6
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
Now let’s build our ‘mirror’ image. Below is the Dockerfile
FROM debian:trixie-slim
# install python
RUN apt-get update && apt-get install -y python3
# create program working dir
RUN mkdir -p /usr/src/app
# change working dir
WORKDIR /usr/src/app
# copy mirror program to container
COPY ./mirror.py ./
# run mirror program
CMD ["python3", "mirror.py"]
Notes:
Dockerfileis NOT a shell script file.- The
-foption can be used to specify the file name ifDockerfileis not the name of the file.
Place the Dockerfile in the same directory as mirror.py. Below is the content of mirror.py.
import sys
def mirror():
print("You: ", end='', flush=True)
while (msg := sys.stdin.readline()):
if msg == "":
break
print(f"Mirror: {reverse_msg(msg.strip())}\n")
print("You: ", end='', flush=True)
def reverse_msg(msg):
return f"{msg[::-1]}"
if __name__ == "__main__":
mirror()
Notes:
- EOF causes an empty line, so the
ifstatement checks against"". - On Windows, the EOF keystroke is
Ctrl-Z(followed byEnter).
Build the image according to the Dockerfile with the command docker image build -t <tag> <dir_containing_Dockerfile>
$ export HTTP_PROXY=http://127.0.0.1:10809
$ export HTTPS_PROXY=http://127.0.0.1:10809
$ export NO_PROXY=localhost,127.0.0.1
$ docker image build -t mirror ./
[+] Building 104.5s (10/10) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.5s
=> => transferring dockerfile: 336B 0.0s
=> [internal] load metadata for docker.io/library/debian:trixie-slim 4.8s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/5] FROM docker.io/library/debian:trixie-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc 25.9s
=> => resolve docker.io/library/debian:trixie-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4 0.0s
=> => sha256:e426a54f50cc4cf82dd5cab8ba8426ed02c391840cb5a62dfd987542dbabea3b 451B / 451B 0.0s
=> => sha256:6310eb16bf4251731feab01e8f633bf5e2d75a657ccad97f420b1f83cce457be 29.79MB / 29.79MB 24.9s
=> => sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132 8.97kB / 8.97kB 0.0s
=> => sha256:abc9cb88a5587630d7f915f47b23b0668fe250fbfc6457aa4d52b534c1bbf73f 1.02kB / 1.02kB 0.0s
=> => extracting sha256:6310eb16bf4251731feab01e8f633bf5e2d75a657ccad97f420b1f83cce457be 0.8s
=> [internal] load build context 0.0s
=> => transferring context: 378B 0.0s
=> [2/5] RUN apt-get update && apt-get install -y python3 72.1s
=> [3/5] RUN mkdir -p /usr/src/app 0.3s
=> [4/5] WORKDIR /usr/src/app 0.0s
=> [5/5] COPY ./mirror.py ./ 0.1s
=> exporting to image 0.5s
=> => exporting layers 0.5s
=> => writing image sha256:b1b4248eb439f90b513f16140a5341338b452dcf6e84ee5a85930aadc9b673dc 0.0s
=> => naming to docker.io/library/mirror 0.0s
Notes:
- If a proxy is needed, set the environment variables.
- Neither the proxy settings in
/etc/docker/daemon.jsonnor those in the systemd service work, because duringdocker image build, the daemon asks the CLI to fetch an anonymous pull token, and the CLI does NOT refer todaemon.jsonor the systemd service for proxy settings. Instead, it refers to environment variables.
- Neither the proxy settings in
docker image build -t mirror ./builds the image from theDockerfilein the current directory and tags it asmirror.
Try starting a container using the custom image
$ docker container run --rm -it mirror
You: Hello
Mirror: olleH
You: World
Mirror: dlroW
ENTRYPOINT and CMD
ENTRYPOINT and CMD are related but distinct Dockerfile instructions:
- ENTRYPOINT: defines the fixed, main executable that always runs when the container starts. It’s meant to be the “this container’s whole purpose” command.
- CMD: defines default arguments (or a default command, if no
ENTRYPOINTis set) — meant to be easily overridden by whoever runs the container.
When both are present, CMD’s contents get appended as arguments to ENTRYPOINT. For example, given the following Dockerfile definition:
ENTRYPOINT ["hugo"]
CMD ["--help"]
Usage:
- running
docker run <image>→ executeshugo --help - running
docker run <image> server→ theserveryou pass on the CLI replaces CMD entirely (not ENTRYPOINT), so it executeshugo server
Suppose a Dockerfile only has CMD ["nginx", "-g", "daemon off;"] and no ENTRYPOINT, then:
docker run <image>→ runsnginx -g daemon off;docker run <image> cat /etc/os-release→ completely replaces CMD, runscat /etc/os-releasedirectly
To override the ENTRYPOINT defined in the Dockerfile, use the --entrypoint option of docker run
$ docker run --rm --entrypoint cat nginx:1.31 /etc/os-release
# alternatively we can override the ENTRYPOINT by entering a shell tty
$ docker run --rm -it --entrypoint sh ghcr.io/gohugoio/hugo:v0.145.0
Notice that the command to use is placed right after --entrypoint, unlike the normal docker run <container> <cmd> syntax.
Docker Networks
Basic concepts:
- Each container is connected to the private virtual network
bridge. - Each virtual network routes through a NAT firewall on the host IP.
- All containers on a virtual network can talk to each other.
- Best practice is to create a new virtual network for each app group. For example:
- network
my_web_appfor mysql and php/apache containers - network
my_apifor mongo and nodejs containers
- network
- Containers can be attached to more than one virtual network.
- Containers can skip virtual networks and use the host IP via
--net=host.
Create a postgres container for demonstration purposes
$ docker container run -d --name my-postgres-16 -p 2345:5432 -e POSTGRES_PASSWORD=p@ssw0rd postgres:16
5f61f69143c4058721441059b1a620fd18b2450fc9fb53b1e2d5a68e881c5388
Notes:
-e POSTGRES_PASSWORD=p@ssw0rdis specific to postgres and sets the superuser password. Without it, the container cannot run.2345:5432means to route traffic on the host’s 2345 port to the container’s 5432 port.- To reach the container’s 5432 port, on which postgres is listening, you can visit the host’s 2345 port.
Inspect the network with the command below:
$ docker container port my-postgres-16
5432/tcp -> 0.0.0.0:2345
5432/tcp -> [::]:2345
$ docker container inspect --format "{{json .NetworkSettings }}" my-postgres-16 | jq .
{
"SandboxID": "4f58f408b32bb90873a30730a21b56e82077299eb315433ee938d585fd3eb40f",
"SandboxKey": "/var/run/docker/netns/4f58f408b32b",
"Ports": {
"5432/tcp": [
{
"HostIp": "0.0.0.0",
"HostPort": "2345"
},
{
"HostIp": "::",
"HostPort": "2345"
}
]
},
"Networks": {
"bridge": {
"IPAMConfig": null,
"Links": null,
"Aliases": null,
"DriverOpts": null,
"GwPriority": 0,
"NetworkID": "e32aa7bf189c397ee25ee6863ffbdd3f31c60021140d7dc1ac89901a3b584a8c",
"EndpointID": "d3e042a233b600da6afa554a3e5490ffa75c8194065af680799e6bdd7559fb6e",
"Gateway": "172.17.0.1",
"IPAddress": "172.17.0.2",
"MacAddress": "d2:89:fb:8d:df:68",
"IPPrefixLen": 16,
"IPv6Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"DNSNames": null
}
}
}
Notes:
docker container portonly works with running containers.- In the form
5432/tcp -> 0.0.0.0:2345, the left side of the arrow is the container. docker container inspect --formataccepts Go’s text/template syntax.
Network Operations
To show networks, use docker network ls
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
fc4441078ba1 1-dimensional-data-modeling_default bridge local
e32aa7bf189c bridge bridge local
ecf83f26709e host host local
0825c9fcbeb4 kind bridge local
f8770c7e8ed3 none null local
982a9344a9af spark-cluster_default bridge local
c27ddea3424b spark-essentials_default bridge local
To inspect a network, use docker network inspect <network_id/network_name>
$ docker network inspect 1-dimensional-data-modeling_default
[
{
"Name": "1-dimensional-data-modeling_default",
"Id": "fc4441078ba1d808255aad2438c33395dfff784b8b6c41e8489af3caf69745d0",
"Created": "2026-07-18T09:47:39.10768521+08:00",
"Scope": "local",
"Driver": "bridge",
"EnableIPv4": true,
"EnableIPv6": false,
"IPAM": {
"Driver": "default",
"Options": null,
"Config": [
{
"Subnet": "172.21.0.0/16",
"IPRange": "",
"Gateway": "172.21.0.1"
}
]
},
"Internal": false,
"Attachable": false,
"Ingress": false,
"ConfigFrom": {
"Network": ""
},
"ConfigOnly": false,
"Options": {},
"Labels": {
"com.docker.compose.config-hash": "2cbb2e262bf5fe26ec10ac03a575a90f8f80dabc3cee805e9d21428064a6d98f",
"com.docker.compose.network": "default",
"com.docker.compose.project": "1-dimensional-data-modeling",
"com.docker.compose.version": "5.0.2"
},
"Containers": {
"2bcd191a1a0b451b0576c0332c28320037cef44aae301c891f8b06fd0acf72f2": {
"Name": "my-postgres-container",
"EndpointID": "ac8ca61f1892634e29fff5b948b62bc7ab4194e8243b1ac7fb391ca6386ec0c9",
"MacAddress": "4e:ce:98:d5:33:1c",
"IPv4Address": "172.21.0.3/16",
"IPv6Address": ""
},
"2fd442178af0c717c9cf74e3b6f29ccb983120197dd0a76abc7d4943fb9c397e": {
"Name": "pgadmin",
"EndpointID": "bb8a62d2546ef6ff283331dc03b77a1699772f388c054f844969057aa400034d",
"MacAddress": "da:a2:5e:59:54:97",
"IPv4Address": "172.21.0.2/16",
"IPv6Address": ""
}
},
"Status": {
"IPAM": {
"Subnets": {
"172.21.0.0/16": {
"IPsInUse": 5,
"DynamicIPsAvailable": 65531
}
}
}
}
}
]
To create a network, use docker network create [--driver <driver>] <network_name>
$ docker network create --driver bridge data_group
c094e7cbd0173e09a205b28e325e68c1ce9eb87a9f6acf913d5acaa959e04309
Different driver types:
bridgeordocker0- is the default network for containers. When we launch a new container withdocker run, the container automatically connects to this bridge network. We cannot remove this default bridge network.docker0is the network interface used on the host machine, and the IP address usually assigned to the host is172.17.0.1. However, it does NOT provide DNS resolution.$ ifconfig docker0 docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500 inet 172.17.0.1 netmask 255.255.0.0 broadcast 172.17.255.255 inet6 fe80::5016:68ff:fe8a:eeda prefixlen 64 scopeid 0x20<link> ether 52:16:68:8a:ee:da txqueuelen 0 (Ethernet) RX packets 17 bytes 476 (476.0 B) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 50 bytes 7572 (7.5 KB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
host- directly attached to the host’s physical network. It gains performance by skipping virtual networks but sacrifices the security of the container model.none/null- not attached to anything. It removes eth0 and leaves you with only thelocalhostinterface inside the container. (in the container, you can probably only doping localhost)
To attach a network to a container, use docker network connect <network> <container>
$ docker network connect data_group my-postgres-16
$ docker network connect data_group my-postgres-container
$ docker network inspect --format "{{json .Containers}}" data_group | jq .
{
"2bcd191a1a0b451b0576c0332c28320037cef44aae301c891f8b06fd0acf72f2": {
"Name": "my-postgres-container",
"EndpointID": "43157c47b41cdd162a7dd0f8635d41d75b3b2ba7f53c7b1d84904f3479b67266",
"MacAddress": "da:9a:89:cd:6e:41",
"IPv4Address": "172.22.0.3/16",
"IPv6Address": ""
},
"5f61f69143c4058721441059b1a620fd18b2450fc9fb53b1e2d5a68e881c5388": {
"Name": "my-postgres-16",
"EndpointID": "451e71b01a4c62e3ddf4b530f62a900071b68a99742cbd7bfd059b7ff8fc4539",
"MacAddress": "96:6a:e4:ff:d1:d9",
"IPv4Address": "172.22.0.2/16",
"IPv6Address": ""
}
}
To detach a network from a container, use docker network disconnect <network> <container>
$ docker network disconnect data_group my-postgres-container
$ docker network inspect --format "{{json .Containers}}" data_group | jq .
{
"5f61f69143c4058721441059b1a620fd18b2450fc9fb53b1e2d5a68e881c5388": {
"Name": "my-postgres-16",
"EndpointID": "451e71b01a4c62e3ddf4b530f62a900071b68a99742cbd7bfd059b7ff8fc4539",
"MacAddress": "96:6a:e4:ff:d1:d9",
"IPv4Address": "172.22.0.2/16",
"IPv6Address": ""
}
}
DNS Naming
Talking to containers using IPs is an anti-pattern. Instead, use DNS naming. The Docker daemon has a built-in DNS server, and Docker defaults the container’s hostname to its name.
$ docker image pull busybox:1.38
1.38: Pulling from library/busybox
b05093807bb0: Pull complete
Digest: sha256:dc2d74b28e4cf8984fa52af1f39bc7c3d9c73760b41a74d629f5d11b1ab28616
Status: Downloaded newer image for busybox:1.38
docker.io/library/busybox:1.38$ docker network connect data_group my-nginx
$ docker run --rm --network=data_group -it busybox:1.38 ping -c4 my-postgres-16
PING my-postgres-16 (172.22.0.2): 56 data bytes
64 bytes from 172.22.0.2: seq=0 ttl=64 time=0.088 ms
64 bytes from 172.22.0.2: seq=1 ttl=64 time=0.090 ms
64 bytes from 172.22.0.2: seq=2 ttl=64 time=0.125 ms
64 bytes from 172.22.0.2: seq=3 ttl=64 time=0.256 ms
--- my-postgres-16 ping statistics ---
4 packets transmitted, 4 packets received, 0% packet loss
round-trip min/avg/max = 0.088/0.139/0.256 ms
Notes
docker0does NOT provide DNS capability, so it’s recommended to add containers to a user-defined network.- Containers cannot be reached by their hostnames from the host, but can be reached by IP from the host.
- Many images are not shipped with the
pingprogram, butbusyboxis.
Persistent Storage with Volumes
Containers are usually immutable and ephemeral, but normally data is mutable. Docker provides features to ensure separation of concerns with the two approaches below:
- Data Volumes - binary files that reside on the host
- Bind Mounts - a host path mounted to a container path
In a Dockerfile, VOLUME declares the mount points where containers expect external persistent data.
$ docker image inspect postgres:14 --format='{{json .Config.Volumes}}'
{"/var/lib/postgresql/data":{}}
Notes:
- It’s common to use either a named volume or bind mounts to “inject” storage from the host into the container later, at run time.
VOLUME /some/pathis a hint from the image author saying “this path holds data and should persist”, but we can arbitrarily “shadow” directories other than the one specified byVOLUME.- If you don’t explicitly mount a volume or bind mount to the
VOLUMEdirectory, Docker will automatically create an anonymous volume for it when the container starts.
Inspect the Volumes setting of postgres:16.
$ docker image inspect postgres:16 --format '{{json .Config.Volumes}}' | jq
{
"/var/lib/postgresql/data": {}
}
Inspect the Mounts of a postgres:16 container
$ docker container inspect my-postgres-16 --format '{{json .Mounts}}' | jq
[
{
"Type": "volume",
"Name": "6d61519596fd358dd8e0b5b091da97174f5c75ed79bcd03a723c86e5ac17eced",
"Source": "/var/lib/docker/volumes/6d61519596fd358dd8e0b5b091da97174f5c75ed79bcd03a723c86e5ac17eced/_data",
"Destination": "/var/lib/postgresql/data",
"Driver": "local",
"Mode": "",
"RW": true,
"Propagation": ""
}
]
Data Volumes
List all data volumes with docker volume list
$ docker volume list
DRIVER VOLUME NAME
local 1-dimensional-data-modeling_pgadmin-data
local 1-dimensional-data-modeling_postgres-data
local 1dbece87659b1ea45fd59805e39156d8c57b1f05781e55032335aa6e35e07d07
local 3eb2dc4d2b3d9fbebedbcf052017854f012fcb58c16b82aea584331c5ee7dac7
local 6d61519596fd358dd8e0b5b091da97174f5c75ed79bcd03a723c86e5ac17eced
Inspect a specific volume with docker volume inspect <volume_name>
$ docker volume inspect 6d61519596fd358dd8e0b5b091da97174f5c75ed79bcd03a723c86e5ac17eced
[
{
"CreatedAt": "2026-09-04T19:28:46+08:00",
"Driver": "local",
"Labels": {
"com.docker.volume.anonymous": ""
},
"Mountpoint": "/var/lib/docker/volumes/6d61519596fd358dd8e0b5b091da97174f5c75ed79bcd03a723c86e5ac17eced/_data",
"Name": "6d61519596fd358dd8e0b5b091da97174f5c75ed79bcd03a723c86e5ac17eced",
"Options": null,
"Scope": "local"
}
]
The automatically generated anonymous volumes have dummy names that are not user-friendly. In practice, when creating a volume, it’s recommended to provide a meaningful name.
$ docker container run -d --name your-postgres-16 -v your-pg16:/var/lib/postgresql/data -e POSTGRES_PASSWORD=helloworld postgres:16
3bf304a4bebf29ceae0fc539dfb375083d438835e362b963eca44180e7c5315a
$ docker volume ls
DRIVER VOLUME NAME
...
local your-pg16
Notes:
- The
-voption lets you create a volume and mount it to the specified path. - Anonymous volumes are basically an artifact of the early days of Docker, before volumes could be named. You would normally specify the volume name when creating the container.
- One best practice is to name the volume the same as your project, so that it intuitively shows what the volume is for.
Removing the container will NOT delete the volume automatically, because you may later create another container to continue using the data in the volume.
Collision Pitfall
Technically, it’s possible to share a volume across different containers for reading, and that makes sense for a web server and an ftp server. However, for writing, containers may collide with each other. An edge case is that collision can occur even within a single container. Suppose we have the following mount “map” for a drupal container:
volumes:
- drupal_postgres_cms:/var/www/html/modules
- drupal_postgres_cms:/var/www/html/profiles
- drupal_postgres_cms:/var/www/html/sites
- drupal_postgres_cms:/var/www/html/themes
Docker will not create separate sub-directories for ‘modules’, ‘profiles’, ‘sites’ and ’themes’. By default, mounting a named volume attaches the volume’s entire root at that container path. That means ‘/var/www/html/modules’ shows you the whole volume root, and all of the other three show you the exact same whole volume root.
Using a subpath can resolve the collision above, but an easier approach is to use different volumes.
Bind Mounts
With bind mounts, a file or a directory can be mapped from the host to the container, which will shadow the corresponding path inside the container.
$ docker container run -v /some/path/or/file/in/host:/path/or/file/in/container <image>
Notes
- The leading slash
/in/some/path/or/file/in/hostindicates that it is a bind mount, instead of a named volume.
This technique is widely used in local development. One practical example is bind mounting an nginx configuration file from host to container
$ docker run --name my-custom-nginx-container -v /host/path/nginx.conf:/etc/nginx/nginx.conf:ro -d nginx
Data Volumes vs. Bind Mounts
| Feature | Named Data Volume | Bind Mount |
|---|---|---|
| Primary Use Case | Production, database storage, persistence | Local development, source code sync |
| Host Directory Control | Managed by Docker | Specified explicitly by user |
| Cross-Platform Compatibility | High | Low (path dependent) |
| Performance on macOS/Win | High | Slower |
Docker Compose
In practice, a product or solution may involve several containers, e.g. one for web hosting, another for ftp service, and a third for the database. Instead of creating and running those containers with separate docker container run commands, Docker Compose can better configure the collaboration among those containers. The core of Docker Compose is a YAML file, usually named docker-compose.yml, which contains configuration for:
- containers
- networks
- volumes
version: '3.1' # v1 is used if not specified. Recommend v2 minimum
services: # this actually means to treat different containers as different service providers. Think of one providing the db service, another providing the web service, and a third...
<servicename>: # a meaningful name. This is also the **DNS name** inside the network
image: # optional if you use build:
command: # optional, **replaces the default CMD** specified by the image
environment: # optional, same as -e in 'docker run'
volumes: # optional, same as -v in 'docker run'
<servicename2>: # another service ...
volumes: # optional, same as 'docker volume create'
networks: # optional, same as 'docker network create'
Here is a concrete example of docker-compose.yml
version: '2'
services:
wordpress:
image: wordpress
ports:
- 8080:80
environment:
WORDPRESS_DB_PASSWORD: example
volumes:
- ./wordpress-data:/var/www/html # the leading dot means the current directory
mysql:
image: mariadb
environment:
MYSQL_ROOT_PASSWORD: example
volumes:
- ./mysql-data:/var/lib/mysql
With Docker Compose, the two most commonly used commands are:
docker compose up- set up networks and volumes, and then start all containersdocker compose down- stop all containers and remove networks; by default, volumes and images are kept
Example - Drupal CMS
Suppose we’d like to use Drupal CMS with Docker Compose. Two components are needed:
- Drupal instance
- Postgres DB instance
Here is the docker-compose.yml file
services:
drupal:
image: drupal:11.3.16-php8.5-apache
ports:
- 8080:80
depends_on:
- postgres
volumes:
- drupal_storage_modules:/var/www/html/modules
- drupal_storage_profiles:/var/www/html/profiles
- drupal_storage_sites:/var/www/html/sites
- drupal_storage_themes:/var/www/html/themes
networks:
- network
postgres:
image: postgres:16
environment:
POSTGRES_DB: drupal
POSTGRES_USER: user
POSTGRES_PASSWORD: pwd_523
volumes:
- postgres_storage:/var/lib/postgresql/data
networks:
- network
volumes:
drupal_storage_modules:
drupal_storage_profiles:
drupal_storage_sites:
drupal_storage_themes:
postgres_storage:
networks:
network:
driver: bridge
Notes:
- Place the
docker-compose.ymlfile under ayour_cmsdirectory. By default, the directory name becomes the project name, and all resources — including containers, networks, and volumes — are generated under that namespace on the host, so the directory name appears as the leading characters of those generated resources. - Containers still talk to each other using the service name on the network, not including the “namespace” characters.
Navigate to the directory and execute docker compose up, or docker compose up -d for detached mode.
$ docker compose up
[+] up 8/8
✔ Network your_cms_network Created 0.4s
✔ Volume your_cms_drupal_storage_modules Created 0.1s
✔ Volume your_cms_drupal_storage_profiles Created 0.1s
✔ Volume your_cms_drupal_storage_sites Created 0.2s
✔ Volume your_cms_drupal_storage_themes Created 0.3s
✔ Volume your_cms_postgres_storage Created 0.3s
✔ Container your_cms-postgres-1 Created 1.6s
✔ Container your_cms-drupal-1 Created 1.1s
Attaching to drupal-1, postgres-1
...
To stop the containers, execute docker compose down
$ docker compose down
[+] down 3/3
✔ Container your_cms-drupal-1 Removed 3.0s
✔ Container your_cms-postgres-1 Removed 3.3s
✔ Network your_cms_network Removed 0.9s
Compose with Build
In some cases, instead of using someone else’s image, we may want to build our own image. Below is the docker-compose.yml file
version: '2'
services:
proxy:
build:
context: . # to find the docker file under the current working dir
dockerfile: nginx.Dockerfile # explicitly define the Dockerfile
image: nginx-custom # give the built image a custom name/tag
ports:
- 80:80
web:
image: httpd
nginx.Dockerfile file
FROM nginx:1.11
COPY nginx.conf /etc/nginx/conf.d/default.conf
nginx.conf file
server {
listen 80;
location / {
proxy_pass http://web;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}
}
Notes
- It’s a great way to handle complex builds that involve lots of variables or build args.
- If the image is not found in the cache,
docker compose upwill build the image. docker compose buildcan rebuild the image.