Docker Essentials

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 Hub
  • latest - avoid using latest because 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 ones
  • s - display the file size, for example 4.19MB (virtual 516MB)
    • 4.19MB: the writable layer size, meaning how much data has been written inside this specific container
    • 516MB: 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_panini is 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:

Docker Image Layer

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:trixie pulled locally, and one of nginx’s layers happens to match debian’s topmost layer exactly (same content hash), docker image history can actually resolve and show that real image ID instead of <missing> — because now there is a locally tracked image pointing at that layer.
  • 4e5db4761e0f is 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:

  1. Docker fetches the image manifest - a list of layer digests (content hashes) that make up the image.
  2. 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.
  3. 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:

  • Dockerfile is NOT a shell script file.
  • The -f option can be used to specify the file name if Dockerfile is 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 if statement checks against "".
  • On Windows, the EOF keystroke is Ctrl-Z (followed by Enter).

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.json nor those in the systemd service work, because during docker image build, the daemon asks the CLI to fetch an anonymous pull token, and the CLI does NOT refer to daemon.json or the systemd service for proxy settings. Instead, it refers to environment variables.
  • docker image build -t mirror ./ builds the image from the Dockerfile in the current directory and tags it as mirror.

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 ENTRYPOINT is 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> → executes hugo --help
  • running docker run <image> server → the server you pass on the CLI replaces CMD entirely (not ENTRYPOINT), so it executes hugo server

Suppose a Dockerfile only has CMD ["nginx", "-g", "daemon off;"] and no ENTRYPOINT, then:

  • docker run <image> → runs nginx -g daemon off;
  • docker run <image> cat /etc/os-releasecompletely replaces CMD, runs cat /etc/os-release directly

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_app for mysql and php/apache containers
    • network my_api for mongo and nodejs containers
  • 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@ssw0rd is specific to postgres and sets the superuser password. Without it, the container cannot run.
  • 2345:5432 means 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 port only 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 --format accepts 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:

  • bridge or docker0 - is the default network for containers. When we launch a new container with docker run, the container automatically connects to this bridge network. We cannot remove this default bridge network.
    • docker0 is the network interface used on the host machine, and the IP address usually assigned to the host is 172.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 the localhost interface inside the container. (in the container, you can probably only do ping 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

  • docker0 does 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 ping program, but busybox is.

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/path is 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 by VOLUME.
  • If you don’t explicitly mount a volume or bind mount to the VOLUME directory, 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 -v option 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/host indicates 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 containers
  • docker 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.yml file under a your_cms directory. 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 up will build the image.
  • docker compose build can rebuild the image.
Built with Hugo
Theme Stack designed by Jimmy