Docker Workbook: Your Comprehensive Guide to Containerization

DevOps

Master containerization with this in-depth Docker workbook. Learn to understand, manage, and optimize containers using Docker and Docker Compose for robust, production-grade applications.

Gmkziz 👓

07 Nov 2025 • 23 min read

Photo by Venti Views / Unsplash

Introduction

Container technology, when properly implemented, offers significant advantages such as enhanced portability, maintainability, scalability, and security for your application ecosystem.

Containers encapsulate software and all its dependencies into a single process designed to run on a Linux host machine. This powerful isolation is made possible by core Linux functionalities like chroot, namespaces, and control groups.

Specifically, namespaces define the resources a container can perceive, while cgroups determine the resources it can utilize and in what quantities. Containers share the Linux host machine's Kernel, directly access host hardware, and maintain isolation from each other.

Container runtimes are the software components responsible for executing containers; prominent examples include Docker, Containerd, and CRI-O. While container technologies existed long before Docker, it was Docker's innovative approach and user-friendly interface that led to their widespread adoption.

How Docker Works

Docker Architecture

When you install Docker, you gain both a client (the docker command-line interface) and a daemon. The Docker Daemon typically runs in the background as a systemd service, receiving instructions from the Docker client to perform tasks such as downloading container images from registries, creating container images, and running containers. The client and daemon do not necessarily have to reside on the same machine. The machine hosting the daemon is referred to as a Docker Host.

For comprehensive details, refer to the official CLI references for the Docker client and Docker daemon.

Additionally, consult the reference documentation for all configuration directives related to the Docker client configuration file and Docker daemon configuration file.

Container Images

The Dockerfile

Dockerfile reference | Dockerfile instructions best practices

A container image is an absolute prerequisite for running containers. The standard file utilized for creating container images with Docker is called a Dockerfile. Within this file, you define a series of instructions that outline the image's contents.

The Common Dockerfile instructions section will introduce you to frequently used Dockerfile instructions and their specific characteristics. You can also initialize a sample Dockerfile for your project.

Once your Dockerfile is ready, you can create/build a container image from it using tools like Docker, Kaniko, or Buildah. To exclude specific files from the build context, you can employ a .dockerignore file.

Furthermore, it is advisable to adhere to best practices when crafting Dockerfile instructions.

For language-specific guidance, explore the Docker language-specific guides: PHP, Python, Java, Ruby, Go, JavaScript, Rust, R.

Lines written within the Dockerfile are processed by Docker parsers. You can customize the parser's behavior using Parser directives, which are written as special comments in the format # directive=value inside the Dockerfile. These can be used, for instance, to select the Dockerfile syntax, define escape characters, or configure how build checks are evaluated.

Container Registries

Docker Hub

Container Registry services provide storage for container images. They are typically accessible remotely and facilitate the sharing of container images.

Docker Container Registry is one of the most widely recognized publicly available container registries. When you run a container with Docker, for example, without specifying a particular container registry for downloading/pulling the image, Docker Hub is used by default.

Creating Container Images from a Dockerfile

Image building best practices

Navigate to the directory containing your Dockerfile and execute the following command. The image tag is optional; if omitted, 'latest' will be used:

# Syntax
docker build -t <image_name>[:<image_tag>] .

# Example
docker build -t myapp:0.1.0 .

If you intend to later push the built image to a remote container registry, the '<image_name>' should correspond to the full URL of the image within that registry. Here's an example:

docker build -t myregistry.example.com/myapp:0.1.0 .

For more options, use 'docker build --help'. Additionally, be aware of these best practices when creating container images.

Pushing Container Images into Registries

Before pushing your locally built images to remote container registries, ensure they have been properly tagged during the build process. The full image URL should follow this format:

# Registry image URL
<registry_url>/<image_name>:<image_tag>

# Examples
myregistry.example.com/tools/myapp:0.1.0
myregistry.example.com/superapp:0.1.0

Once the image is correctly tagged, authenticate with the container registry (if required) using the following command:

# Syntax
docker login <registry_domain_name>

# Example
docker login myregistry.example.com

Then, use the following command to push the image to the registry:

# Syntax
docker push <registry_image_url>

# Examples
docker push myregistry.example.com/tools/myapp:0.1.0
docker push myregistry.example.com/superapp:0.1.0

Builders

Docker builders | BuildKit

Docker internally employs builders when you construct container images. The default builder in Docker is BuildKit, which comes bundled with the Docker Engine and is used by default for image builds.

Buildx can be utilized to extend build capabilities with BuildKit. Through buildx, you can configure various build drivers when creating new builders.

Here are sample commands for managing Docker builders with buildx:

List builders:

$ docker buildx ls
NAME/NODE     DRIVER/ENDPOINT   STATUS    BUILDKIT   PLATFORMS
default*      docker
 \_ default    \_ default       running   v0.21.0    linux/amd64 (+4), linux/386

Create a new builder that uses the 'docker-container' build driver. This will run a BuildKit container for each build instead of leveraging the BuildKit instance bundled inside the Docker Engine:

# Create the new builder
$ docker buildx create \
  --name container-builder \
  --driver docker-container

# Verify
$ docker buildx ls
NAME/NODE                DRIVER/ENDPOINT                   STATUS     BUILDKIT   PLATFORMS
container-builder        docker-container
 \_ container-builder0    \_ unix:///var/run/docker.sock   inactive
default*                 docker
 \_ default               \_ default                       running    v0.21.0    linux/amd64 (+4), linux/386

Set the newly created builder called 'container-builder' as the default builder. The builder to use can also be selected by setting its name in the BUILDX_BUILDER environment variable or by using the --builder flag when running the docker build or docker buildx build command.

# Set the newly created builder as the default one
$ docker buildx use container-builder

# Verify
$ docker buildx ls
NAME/NODE                DRIVER/ENDPOINT                   STATUS     BUILDKIT   PLATFORMS
container-builder*       docker-container
 \_ container-builder0    \_ unix:///var/run/docker.sock   inactive
default                  docker
 \_ default               \_ default                       running    v0.21.0    linux/amd64 (+4), linux/386
 
# The asterix (*) indicates
# the default builder

Image Layering / Caching

Each instruction within a Dockerfile generates a new layer on top of the current image. Understanding how Docker build caches operate can significantly accelerate container image builds.

Common Dockerfile Instructions

Dockerfile reference | Dockerfile instructions best practices

FROM

Instruction documentation

The FROM instruction initializes a new build stage and sets the base image for subsequent instructions. Using multiple FROM instructions within the same Dockerfile initiates a multi-stage build. Image assets from previous build stages can then be reused in subsequent stages.

# Syntax
FROM [--platform=<platform>] <image>[:<tag> | @digest] [AS <name>]

The platform is specified as 'os/arch', for example: 'linux/amd64' or 'windows/arm64'.

The name assigned to a stage can then be used as follows:

# Create a new build stage using the previous stage image as the base image
FROM <name>

# Copy a file from a specific named build stage into this current stage image
COPY --from=<name> <src> ... <dest>

# Inject files or directories from a specific named 
# build stage into this current stage image
RUN --mount=type=bind,from=<name>,target=<target_path>

COPY

Instruction documentation

COPY is used to transfer files from the build context or from a multi-stage build stage to the image's filesystem. These copied files persist in the final image. To temporarily add files from the build context—for a RUN command, for instance—use bind mounts as shown below:

RUN --mount=type=bind,source=requirements.txt,target=/tmp/requirements.txt \
    pip install --requirement /tmp/requirements.txt

Bind mounts offer greater efficiency than COPY. Here are examples using COPY:

# Copy from the build context
COPY --chown=www-data:www-data --chmod=755 conf/php-config.ini "$PHP_INI_DIR/conf.d/"

# Copy from a build stage (in multi-stage build)
COPY --chown=www-data:www-data --chmod=755 --from=replace conf/nginx.conf $NGINX_CONF_PATH

ADD

Instruction documentation

In addition to copying files from the build context to the image's filesystem, ADD can also download files from remote HTTPS and Git URLs, verify file checksums, and more. It can automatically extract tar files when adding them into image filesystems from the build context.

Here are examples of using ADD:

# Add a file from a remote HTTPS URL into the image
ADD https://example.com/myfile.zip /files/

# Add files from a remote Git repository into the image
ADD git@my.repo.example:web/app.git /app

ARG

Instruction documentation

ARG facilitates the use of build-time variables, which are exclusively available during the image build process and are not persisted in the final image. It's important to note that the ARG instruction is unsuitable for storing sensitive information (e.g., API keys, passwords) because these values will be visible when using the docker history command.

Here are examples of using ARG:

# Declare one or many build-time variables
ARG myvar1=myvalue1 MYVAR2=myvalue2 MYVAR3

During the build process, you can also set or override previously declared build-time variables as follows:

$ docker build -t myapp:1.0.1 \
   --build-arg myvar1=myvalue2 \
   --build-arg MYVAR3=myvalue3 .

If you need to inject sensitive data into the build context for a specific command, use RUN --mount=type=secret.

Here are examples of injecting secret data into the build context:

Here are examples to illustrate the scopes of build-time variables:

# These variables have a global scope. They will be available for all 
# the FROM instructions but not inside the build stages themselves.
ARG IMAGE_STAGE1=ubuntu
ARG IMAGE_STAGE2=debian

FROM ${IMAGE_STAGE1} AS stage1 # variable accessible here
# Should be redeclared inside this stage in order to use it
ARG IMAGE_STAGE1
RUN echo "Hello from ${IMAGE_STAGE1}"

FROM ${IMAGE_STAGE2} AS stage2 # variable accessible here
# Should be redeclared inside this stage in order to use it
ARG IMAGE_STAGE2
RUN echo "Hello from ${IMAGE_STAGE2}"

ENV

Instruction documentation

The ENV instruction sets environment variables that are available for subsequent instructions within the Dockerfile (similar to ARG). However, unlike ARG, these variables will also be accessible within the environment of containers created from the resulting image.

# Syntax
ENV <varname>=<value> [<key>=<value>...]

Such variables can be overridden when launching containers with the docker run command, using the --env varname=value or -E varname=value flags.

RUN

Instruction documentation

The RUN instruction is used to execute commands essential for constructing the image. Each executed RUN command creates a new layer on top of the current image, which is then utilized in the subsequent Dockerfile step.

Here is the syntax:

# Shell form:
RUN [OPTIONS] <command> ...

# Exec form:
RUN [OPTIONS] [ "<command>", ... ]

Here are some usage examples:

# Using heredocs

RUN <<EOF
apt update
apt install -y curl zip
EOF

# Using escape

RUN apt update && \
    apt install -y \
    curl \
    zip

ENTRYPOINT and CMD

ENTRYPOINT | CMD

These two instructions define the container's initial command, which executes during startup and creates a process with PID 1. The following examples clarify their behavior.

Let's build the example1 image, run a container using it, and observe the outcome:

Example 1 Dockerfile content:

FROM ubuntu
CMD ["echo", "hello"]

Build the example1 image:

$ docker build -t example1 .
[+] Building 0.2s (5/5) FINISHED                                                                                                                                             docker:default
 => [internal] load build definition from Dockerfile                                                                                                                                   0.0s
 => => transferring dockerfile: 198B                                                                                                                                                   0.0s
 => [internal] load metadata for docker.io/library/ubuntu:latest                                                                                                                       0.1s
 => [internal] load .dockerignore                                                                                                                                                      0.0s
 => => transferring context: 2B                                                                                                                                                        0.0s
 => CACHED [example1 1/1] FROM docker.io/library/ubuntu:latest@sha256:66460d557b25769b102175144d538d88219c077c678a49af4afca6fbfc1b5252                                                 0.0s
 => exporting to image                                                                                                                                                                 0.0s
 => => exporting layers                                                                                                                                                                0.0s
 => => writing image sha256:36423e8273020c1fc9186f698397a8d4524b46ecd0b638c4a3315ce1028ffcd6                                                                                           0.0s
 => => naming to docker.io/library/example1                                                                                                                                            0.0s

Launch a container using that image:

$ docker run example1
hello

The container runs, outputs 'hello' as expected, and exits because its command has terminated. If we launch the container again and specify another command, that new command will override the one defined by the Dockerfile's CMD instruction:

$ docker run example1 cat /etc/issue
Ubuntu 24.04.3 LTS 
 \l

Now, let's build and run the example2 image using ENTRYPOINT instead of CMD to observe the difference:

Example 2 Dockerfile content:

FROM ubuntu
ENTRYPOINT ["echo", "hello"]

Build the example2 image:

$ docker build -t example2 .
[+] Building 0.2s (5/5) FINISHED                                                                                                                                             docker:default
 => [internal] load build definition from Dockerfile                                                                                                                                   0.0s
 => => transferring dockerfile: 198B    
(...)

Launch a container using that image:

$ docker run example2
hello

Again, the container runs, outputs 'hello', and exits. However, if we launch it again and specify another command, this new command will not override the Dockerfile's ENTRYPOINT instruction. Instead, it will be appended as an argument to the ENTRYPOINT command:

$ docker run example2 cat /etc/issue
hello cat /etc/issue

The example2 container executes echo hello cat /etc/issue rather than replacing echo hello with cat /etc/issue, as seen in example1. Nevertheless, it's possible to reset the ENTRYPOINT during container launch to run a completely new command if needed:

$ docker run --entrypoint="" example2 cat /etc/issue
Ubuntu 24.04.3 LTS 
 \l

By resetting the ENTRYPOINT, we can run the cat /etc/issue command, mirroring the behavior of example1.

When containerizing a CLI program, for instance, ENTRYPOINT is generally preferred over CMD as it offers a more intuitive user experience:

# Running a containerized version of mycli program
docker run mycli --help
docker run mycli <ARGS>

Users can directly interact with the CLI program's flags and arguments without needing to explicitly call the program within the docker run command.

Another scenario involves combining ENTRYPOINT and CMD within a Dockerfile:

Example 3 Dockerfile content:

FROM ubuntu
ENTRYPOINT ["echo"]
CMD ["hello"]

Build the example3 image:

docker build -t example3 .
[+] Building 3.3s (5/5) FINISHED                                                                                                                                             docker:default
 => [internal] load build definition from Dockerfile                                                                                                                                   0.0s
 => => transferring dockerfile: 83B                                                                                                                                                    0.0s
 => [internal] load metadata for docker.io/library/ubuntu:latest                                                                                                                       0.6s
 => [internal] load .dockerignore                                                                                                                                                      0.0s
 => => transferring context: 2B                                                                                                                                                        0.0s
 => [1/1] FROM docker.io/library/ubuntu:latest@sha256:66460d557b25769b102175144d538d88219c077c678a49af4afca6fbfc1b5252                                                                 2.5s
 => => resolve docker.io/library/ubuntu:latest@sha256:66460d557b25769b102175144d538d88219c077c678a49af4afca6fbfc1b5252                                                                 0.0s
 => => sha256:d22e4fb389065efa4a61bb36416768698ef6d955fe8a7e0cdb3cd6de80fa7eec 424B / 424B                                                                                             0.0s
 => => sha256:97bed23a34971024aa8d254abbe67b7168772340d1f494034773bc464e8dd5b6 2.30kB / 2.30kB                                                                                         0.0s
 => => sha256:4b3ffd8ccb5201a0fc03585952effb4ed2d1ea5e704d2e7330212fb8b16c86a3 29.72MB / 29.72MB                                                                                       0.5s
 => => sha256:66460d557b25769b102175144d538d88219c077c678a49af4afca6fbfc1b5252 6.69kB / 6.69kB                                                                                         0.0s
 => => extracting sha256:4b3ffd8ccb5201a0fc03585952effb4ed2d1ea5e704d2e7330212fb8b16c86a3                                                                                              1.8s
 => exporting to image                                                                                                                                                                 0.0s
 => => exporting layers                                                                                                                                                                0.0s
 => => writing image sha256:16805ffadd43c7f568fb26de593c3baf2b4c756c0be65af397c7ec5a2581681c                                                                                           0.0s
 => => naming to docker.io/library/example3

Launch a container using that image:

$ docker run example3
hello

In this example, 'hello' is an argument to the echo command, defined by CMD, not ENTRYPOINT itself. This configuration sets 'hello' as the default argument for the command defined by the ENTRYPOINT instruction. Consequently, running the example3 container yields the same output: 'hello'. Users can easily override this default argument:

$ docker run example3 Welcome to hackerstack.org
Welcome to hackerstack.org

Finally, let's explore how the container's first command's state and execution mode (foreground/background) influence the container's own state.

We will use the following Dockerfile for all subsequent examples, modifying only the content of script.sh:

FROM ubuntu
COPY script.sh /opt
ENTRYPOINT ["/opt/script.sh"]

Scenario 1: The container's first command exits successfully.

The container's status, as shown in the 'STATUS' column of docker ps, will be 'Exited (0)'.

Content of script.sh:

#!/bin/bash

echo "Exiting with exit code 0"
exit 0

After building an example image with this script:

$ docker run example
Exiting with exit code 0

$ docker ps -a
CONTAINER ID   IMAGE          COMMAND             CREATED          STATUS                        PORTS     NAMES
fd33afbaf973   example        "/opt/script.sh"    8 seconds ago    Exited (0) 8 seconds ago                clever_northcutt

Scenario 2: The container's first command exits unsuccessfully.

The container's status will be 'Exited (1)'.

Content of script.sh:

#!/bin/bash

echo "Exiting with exit code 1"
exit 1

After building an example image with this script:

$ docker run example
Exiting with exit code 1

$ docker ps -a
CONTAINER ID   IMAGE          COMMAND             CREATED          STATUS                        PORTS     NAMES
84fc7e7854ed   example        "/opt/script.sh"    4 seconds ago    Exited (1) 3 seconds ago                angry_lichterman

Scenario 3: The container's first command keeps running in the foreground.

The container's status will be 'Up' followed by an uptime duration.

Content of script.sh:

#!/bin/bash

echo "Keeping a command running in foreground"
sleep 300 # 300 seconds

After building an example image with this script:

$ docker run example
Keeping a command running in foreground
# the docker run command also runs in the 
# foreground and blocks shell interaction

# To run the docker command in background, we can
# use the -d flag as follows

$ docker run -d example
129bab01dc1a1b3529aa620943d432fcd7ce7c5b882cc0575afb01f5bb9f8e05

# Show running containers

$ docker ps
CONTAINER ID   IMAGE     COMMAND            CREATED         STATUS         PORTS     NAMES
129bab01dc1a   example   "/opt/script.sh"   2 minutes ago   Up 2 minutes             admiring_shamir

Scenario 4: The container's first command runs in the background.

Even though the script keeps a process running, the container's first process exits immediately, leading to a status of 'Exited (0)'.

Content of script.sh:

#!/bin/bash

echo "Keeping a command running in foreground"
sleep 300 &
$ docker run -d example
638bb58dd066da0cc43921bf0739eb9763f1ff14e46b85d06eb2fc6f66f5ea64

$ docker ps
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

$ docker ps -a
CONTAINER ID   IMAGE          COMMAND            CREATED          STATUS                        PORTS     NAMES
638bb58dd066   example        "/opt/script.sh"   7 seconds ago    Exited (0) 6 seconds ago                crazy_nash

In all the previous examples, any text messages sent to standard output (stdout) or standard error (stderr) via the echo command or similar will be visible in the container's logs:

$ docker logs 638bb58dd066
Keeping a command running in foreground

ENTRYPOINT | CMD

To conclude this section, here are crucial rules to remember when working with ENTRYPOINT and CMD:

  • At least one ENTRYPOINT or CMD instruction is mandatory within a Dockerfile.
  • If multiple CMD instructions are present, only the last one will be executed.
  • ENTRYPOINT can establish a default executable for the container. Any command executed through that container will serve as an argument to the executable defined by ENTRYPOINT.
  • An ENTRYPOINT instruction can be followed by a CMD instruction to define default arguments for the executable specified by ENTRYPOINT. Users can override these default CMD arguments by providing new ones when launching the container.
  • The state of a container is directly tied to the state of its first process (the process with PID 1).
  • If no ENTRYPOINT instruction exists, the process with PID 1 inside the container will be created from the executable specified in the last CMD instruction.
  • If an ENTRYPOINT instruction is present, the process with PID 1 inside the container will be created from the executable specified by the ENTRYPOINT instruction.
  • Container logs (retrieved with docker logs or similar) originate from the container's standard output (stdout) and standard error (stderr).
  • The 'foreground' concept for the container's first process is critical, as it determines whether your container remains running. If the container's PID 1 process (created by ENTRYPOINT or CMD) runs in the foreground, the container continues to run; otherwise, the container starts and then exits.
  • A common method for running daemon services within containers is to use a process control system like supervisord, as further explained here.

WORKDIR

Instruction documentation

WORKDIR specifies the working directory for subsequent instructions in the Dockerfile, such as RUN, COPY, CMD, ENTRYPOINT, and ADD. The directory will be created if it does not already exist. Usage example:

WORKDIR /app

USER

Instruction documentation

USER sets the user (and optionally the group) to be used for RUN instructions that follow it in the Dockerfile. This will also be the default user inside the container and will execute scripts/commands specified by subsequent ENTRYPOINT or CMD instructions. Usage examples:

# Using username:groupname
# groupname is optional
USER gmkziz:admin

# Using UID:GID
# GID is optional
USER 1001:900

EXPOSE

Instruction documentation

The EXPOSE instruction serves as informational metadata, highlighting a specific port on which the container is expected to listen. It does not actually publish or open the port on the host system.

Here is an example usage:

(...)

EXPOSE <port>

To publish the exposed port on the host when running the container, use the -p <host_port>:<container_port> flag with the docker run command.

Dockerfile Examples and Tips

Replacing variables inside config files by environment variables

This example utilizes Docker multi-stage builds to keep the final image size minimal.

Imagine you need to inject a configuration file into the image, where its content includes a variable declared within the Dockerfile for use across different build stages.

ARG APP_BASE_DIR=/app
FROM myimage
ARG APP_BASE_DIR
COPY conf/myfile.conf /etc/nginx/http.d/default.conf
(...)

The APP_BASE_DIR variable is declared once in the Dockerfile and holds the path to the application's base directory. The configuration file, myfile.conf, that we copy into the container, should also reflect the value of this APP_BASE_DIR variable.

To achieve this, we can create a template of the configuration file locally. Here's an example content:

File: myfile.conf.tpl

(...)
root {{ env "APP_BASE_DIR" }};
(...)

Then, inside the Dockerfile, we create a temporary stage to perform the variable replacement using the go-replace tool:

(...)
FROM webdevops/go-replace AS replace
ARG APP_BASE_DIR
WORKDIR /conf
RUN --mount=type=bind,source=./myfile.conf.tpl,target=myfile.conf.tpl,rw \
    --mount=type=cache,target=/tmp/cache \
    go-replace --mode=template myfile.conf.tpl:myfile.conf

This process will generate the /conf/myfile.conf file within that stage by replacing {{ env "APP_BASE_DIR" }} with /app.

You can then copy this finalized configuration file into the image during subsequent stages as follows:

COPY --from=replace conf/myfile.conf /etc/nginx/http.d/default.conf

Running multiple services inside the same container using supervisord

A common and reliable method for running multiple services within containers is to use a process control system like supervisord. supervisord will run as the ENTRYPOINT (PID 1) and launch the required services (e.g., Nginx and PHP-FPM).

Here is a sample Dockerfile snippet demonstrating the use of supervisord to start multiple services via an ENTRYPOINT script:

(...)
RUN apk update && apk add --no-cache supervisor
COPY --chown=www-data:www-data --chmod=755 conf/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY --chown=www-data:www-data --chmod=755 --from=replace conf/entrypoint.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]

Here is an example content for the ENTRYPOINT script:

File: /usr/local/bin/entrypoint.sh

#!/bin/sh
set -e

# Start supervisord and pass it the configuration file
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf

In a container running, for instance, PHP-FPM and Nginx, supervisord can be configured to launch PHP-FPM in the background and Nginx in the foreground. It can also be configured to prevent saving its logs directly to the container's filesystem:

File: /etc/supervisor/conf.d/supervisord.conf

[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0

[program:php-fpm]
command=php-fpm
autostart=true
autorestart=true
priority=5
stDOUT_logfile=/dev/stdout
stDOUT_logfile_maxbytes=0
stDERR_logfile=/dev/stderr
stDERR_logfile_maxbytes=0

[program:nginx]
command=nginx -g 'daemon off;'
autostart=true
autorestart=true
priority=10
stDOUT_logfile=/dev/stdout
stDOUT_logfile_maxbytes=0
stDERR_logfile=/dev/stderr
stDERR_logfile_maxbytes=0

With this configuration, the container will be considered 'running' as long as Nginx is active. Nginx logs sent to stdout will be visible when viewing the container's logs with the docker logs command. Here is a sample Nginx configuration file:

upstream php {
    server localhost:9000;
}

server {
        listen 80; 
        server_name localhost;
        root /app;
        server_tokens off;

        location / { 
                try_files $uri /index.php$is_args$args;
        }

        location ~ \.php(/|$) {
                include fastcgi_params;
                fastcgi_pass php;
                fastcgi_index index.php;
                fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
                error_log /dev/stdout;
                access_log /dev/stderr;
        }
}

Most importantly, supervisord will automatically restart PHP-FPM or Nginx processes in the event of a failure.

Using multi-stage builds

Here is an example for a PHP application where development and production dependencies are built within distinct stages. The outputs from these stages are then reused in subsequent development and production stages, from which the final app development or production image will be constructed.

Both development and production stages will originate from a common 'base' stage containing elements required for both environments (e.g., PHP and PHP extensions, application source code). This methodology allows for independent building of development and production images, each containing only the necessary dependencies, resulting in smaller image sizes.

Here is the stage for development dependencies:

FROM composer:lts AS dev-deps
WORKDIR /app
RUN --mount=type=bind,source=./src/composer.lock,target=composer.lock \
    --mount=type=bind,source=./src/composer.json,target=composer.json \
    --mount=type=cache,target=/tmp/cache \
    composer install --ansi --no-scripts --no-progress

And the stage for production dependencies:

FROM composer:lts AS prod-deps
WORKDIR /app
RUN --mount=type=bind,source=./src/composer.lock,target=composer.lock \
    --mount=type=bind,source=./src/composer.json,target=composer.json \
    --mount=type=cache,target=/tmp/cache \
    composer install --ansi --no-scripts --no-progress --no-dev

These two preceding stages will create a 'vendor' directory in /app, containing the required dependency files. We will then inject the corresponding 'vendor' directory into the final stages for development and production images.

Now, here is the 'base' stage that is common to both development and production environment images:

(...)
FROM php:8.4.14-fpm-alpine3.21 AS base
ARG APP_BASE_DIR
RUN apk update && apk add --no-cache \
      build-base \
      git \
      (...)
RUN docker-php-ext-install \
      gd \
      xml \
      (...)
COPY --chown=www-data:www-data --chmod=755 ./src "$APP_BASE_DIR"
(...)
WORKDIR $APP_BASE_DIR

And finally, the stage that will be used to build the development image:

FROM base AS development
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
COPY --chown=www-data:www-data --chmod=755 --from=dev-deps app/vendor /app/includes/vendor
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 80

And the one for the production image:

FROM base AS production
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --chown=www-data:www-data --chmod=755 --from=prod-deps app/vendor /app/includes/vendor
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 80

Managing containers with Docker

Launching containers

For detailed syntax and options, use:

docker run --help

Run a container using the busybox image to test connectivity to 'google.com' with the nc command:

docker run busybox nc -vz google.fr 80

Ensure the container is automatically removed from the list (shown with docker ps) after execution:

docker run --rm busybox nc -vz google.fr 80

Assign a name to the container:

docker run --name debug busybox nc -vz google.fr 80

Pass environment variables to the container:

$ docker run -e MYVAR=VALUE busybox env | grep MYVAR
MYVAR=VALUE

Mount a volume into the container:

$ docker run -v "$PWD/Test:/test" busybox ls /test
hello

Launch a container and access a shell inside it. To do this, simply run the desired shell command (e.g., bash, sh) when launching the container and use the -it flag:

# -i, --interactive: Keep STDIN open even if not attached
# -t, --tty: Allocate a pseudo-TTY

$ docker run -it busybox sh
/ # env
HOSTNAME=841da4299bb8
SHLVL=1
HOME=/root
TERM=xterm
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PWD=/

Executing commands inside running containers

For detailed syntax and options, use:

docker exec --help

Execute the ls command inside a running container named myapp:

docker exec myapp ls

Get a shell inside a running container named myapp:

# Sh
docker exec -it myapp sh

# Bash
docker exec -it myapp bash

Listing containers and images

For detailed command syntax and options, use:

# Help on listing containers
docker ps --help

# Help on listing container images
docker images --help

List running containers:

docker ps

List all containers (running or not):

docker ps -a

List container images:

docker images

Inspecting containers

For detailed syntax and options, use:

docker inspect --help

Inspect the container named myapp:

docker inspect myapp

The docker inspect command is not limited to containers; it can also be used for other Docker objects like images, volumes, etc.:

# Inspecting a docker image
docker inspect myimage

# Inspecting a docker volume
docker inspect myvolume

Viewing containers logs

For detailed syntax and options, use:

docker logs --help

Show all available log lines for the container named myapp:

docker logs myapp

Show only the last 10 lines of logs for the container named myapp:

docker logs -n 10 myapp

Follow the logs output stream for the container named myapp, starting from the last 10 output lines:

docker logs -n 10 -f myapp

Limit containers output logs file size

The output of the docker logs command is, by default, sourced from a file stored at: /var/lib/docker/containers/<container_id>/<container_id>-json.log.

To control the size of this file on the Docker host/machine filesystem, the following directives can be added to the Docker daemon configuration file:

File: /etc/docker/daemon.json

{
  (...)
  "log-opts": {
    "max-size": "<max_size_of_log_files>",
    "max-file": "<max_number_of_log_files>"
  }
  (...)
}

Refer to Configure docker default logging driver for more details.

Viewing containers resources consumption

For detailed syntax and options, use:

docker stats --help

Display a live stream of containers' resource usage statistics:

docker stats

System info and unused data cleanup

# Info about the Docker client (version, mode, plugins, etc) and the Docker daemon
docker system info

# Info about data size used by images,
# containers, volumes, build caches
docker system df

# Remove unused data: images, containers,
# volumes, build caches
docker system prune [--all, --force]

# Cleanup only unused images, containers, volumes, etc
docker image|container|volume prune [--force]

Use a proxy for pulling docker images

You must configure the proxy at the Docker daemon level. We will demonstrate one method for doing this if you are using systemd as your system services manager.

Create the following drop-in configuration file:

File: /etc/systemd/system/docker.service.d/docker-service-override.conf

[Service]
Environment="https_proxy=<proxy_ip>:<proxy_port>"

Then, make the systemd daemon aware of the Docker service configuration file change by running:

systemctl daemon-reload

Finally, restart the Docker daemon using:

systemctl restart docker

Configure a proxy for all docker containers

This configuration ensures that all your running Docker containers utilize the specified proxy for their HTTP/HTTPS requests.

Add the following to the Docker client configuration file:

File: $HOME/.docker/config.json

{
  "proxies": {
    "default": {
      "httpProxy": "<proxy_ip>:<proxy_port>",
      "httpsProxy": "<proxy_ip>:<proxy_port>"
    }
  }
}

Managing containers with Docker Compose

What is Docker Compose?

Docker Compose

Docker Compose is a supplementary tool designed for declaratively managing Docker containers. Instead of executing individual commands to run Docker containers, you define a configuration file that declares the containers you wish to run, along with their parameters (e.g., volumes, environment variables).

To install Docker Compose on Linux, refer to this guide. It functions as a plugin for Docker and is invoked via the docker compose command.

Creating a Docker Compose file

Docker Compose file reference

The most commonly used top-level section of a Docker Compose file is services. This is where you declare your containers and their associated parameters. Here's an example:

services:
  myapp-frontend:
    image: myapp
    container_name: myapp-frontend
    volumes:
      - source: $PWD/.env
        type: bind
        read_only: true
        target: /app/config/.env
    environment:
      DEBUG: false
      DATABASE_HOST: myapp-database
    ports:
      - "8000:80"

  myapp-database:
    image: mysql:8.0.43-bookworm
    container_name: myapp-database
    environment:
      MYSQL_ROOT_PASSWORD: "mysuperpass"
      MYSQL_DATABASE: "myapp"
    volumes:
      - source: /var/tmp/myapp-db-data
        type: bind
        read_only: false
        target: /var/lib/mysql
    depends_on:
      - myapp-frontend

Within the services top-level section, here are some other directives worth highlighting:

  • deploy: Configure resource requests and limits, number of replicas, etc.
  • volumes: Mount volumes inside containers using bind mounts, Docker volumes, or data from container images.
  • healthcheck: Configure health checks for your containers.
  • dns: Configure DNS server addresses to use for DNS resolution within your containers.
  • depends_on: Declare dependencies between your Docker Compose services.
  • build: Build container images at launch from local source code directories.
  • develop: Useful for local development to keep containers synchronized with application source code changes.

Development Docker Compose file

The primary objective of a local Docker Compose file is to simplify the process of launching an application locally for bug fixing or adding new features. Here's what we aim to achieve with a local development Docker Compose file:

  • Automatically build the development image from local application source code and run it.
  • Ensure local application source code changes are synchronized into the Docker container.
  • Expose the relevant application ports to the local machine for application access and testing during development.
  • Automatically run the required database and inject necessary data for the application.
services:
  myapp-frontend:
    # Build image from source code and Dockerfile 
    # present inside the current directory (.)
    # Use 'dev' as the Dockerfile target to build the image
    build:
      context: .
      target: dev
    container_name: myapp-frontend
    # Mount required config files inside the container
    volumes:
      - source: $PWD/.env
        type: bind
        read_only: true
        target: /app/config/.env
    # Set environment variables for the container
    environment:
      DEBUG: false
      DATABASE_HOST: myapp-database
    # Expose the container port 80 on local machine port 8080
    ports:
      - "8000:80"
    # Make this service run only when the database service is ready
    depends_on:
      - myapp-database
    # Keep the /app directory inside the container in sync
    # with the local src directory containing the app source code
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app

  myapp-database:
    image: mysql:8.0.43-bookworm
    container_name: myapp-database
    environment:
      MYSQL_ROOT_PASSWORD: "mysuperpass"
      MYSQL_DATABASE: "myapp"
    volumes:
      # Initialize the database with a dump file
      # when running for the first time
      - source: /var/tmp/dump.sql
        type: bind
        read_only: false
        target: /docker-entrypoint-initdb.d/dump.sql
      # Make database data persist on the local machine disk
      - source: /var/tmp/myapp-db-data
        type: bind
        read_only: false
        target: /var/lib/mysql

Running containers with Docker Compose

Start containers using Docker Compose:

# If the docker-compose.yaml configuration is 
# located inside the current directory

# Start in the foreground
docker compose up

# When using the docker-compose.yaml
# develop.watch configuration directive
# you should use the --watch flag
docker compose up --watch

# Start in the background
docker compose up -d

# If the docker-compose.yaml configuration 
# is located elsewhere, use the -f option to 
# specify the path to the configuration file
docker compose -f $compose-config-file up
docker compose -f $compose-config-file up -d

Stop and restart containers using Docker Compose:

# Stop
docker compose [-f $compose-config-file] stop

# Restart
docker compose [-f $compose-config-file] restart

Should you wish to report a mistake, ask questions, or suggest improvements, please feel free to email me at gmkziz@hackerstack.org.

If you find my articles valuable, consider subscribing to my newsletter to receive the latest posts as soon as they are published.

Take care, keep learning, and I look forward to connecting in the next post! 🚀