Docker and monolithic architecture

Docker Daemon and CLI

Docker is powered by two central subsystems:

  • Docker Daemon is a server running in the background. It listens to requests from the CLI and manages the container lifecycle.

  • Docker CLI - the Docker command line interface. A command to bring up, start, or stop containers is issued via a string.

Docker CLI gives commands to Docker Daemon to execute a specific command. The CLI can be installed on the host system or set up remotely - communication with Daemon takes place via REST API.

The functionality of Docker Daemon is not limited to starting or stopping containers: the system regulates networks and ports, logs containers. Below are the most frequent commands to Docker Daemon.

Docker CLI Cheat Sheet.

Basic commands:

# start a container based on the specified image
docker run <image_name> 

# show list of active containers
docker ps 

# show all containers, including stopped ones
docker ps -a 

# stop container
docker stop <container_identifier> 

# delete container
docker rm <container_identifier> 

# show a list of all local images
docker images 

# download image from Docker Hub
docker pull <image_name> 

# remove local image
docker rmi <image_identifier>

Creating and working with images:

Creating and working with images:# build an image based on Dockerfile
docker build -t <image_name>:<tag> <path_to_Dockerfile> 

# tag the image with a new tag
docker tag <old_tag> <new_tag> 

# rename and tag the image for uploading to another repository
docker tag <image_name>:<old_tag> <new_repository>/<new_tag> 

# send the image to Docker Hub or another registry
docker push <repository_name>/<image_name>:<tag>

Networks and ports:

# show list of networks
docker network ls

# determine port matching when running the container
docker run -p <local_port>:<container_port> <image_name>

Working with Docker Compose:

# start the services defined in the `docker-compose.yml` file
docker-compose up 

# stop and remove the services described in `docker-compose.yml` file
docker-compose down

Working with Docker Volumes:

# create Docker Volume
docker volume create <volume_name> 

# start the container by connecting the Volume
docker run -v <volume_name>:<path_in_container> <image_name>

Logging and monitoring:

# show container logs
docker logs <container_identifier>

# display container resource utilization statistics
docker stats <container identifier>


The image creation commands are captured in a raw text document - a Dockerfile:

INSTRUCTION argument(s)

where INSTRUCTION is an instruction for Docker Daemon, and argument(s) is the argument itself or the specific values that are passed to INSTRUCTION.

Instructions are case insensitive, but it is common to write them in "capsize" to visually distinguish them from arguments.

The instructions explain what the Docker Daemon should do before, during, or after running the container from the image.

The basic instructions for a Dockerfile are.

FROM specifies the base image from which to create a new image. Most often FROM is used for images with an operating system and pre-installed components.

RUN specifies what commands should be executed inside the container when building the image. This is how you can install dependencies or upgrade packages to the correct version.

COPY and ADD copies files from the local file system to the container. Most often copies the source code of an application.

WORKDIR sets the working directory for subsequent instructions. This way, files in different directories can be worked on sequentially.

CMD defines the default arguments when the container is started.

ENTRYPOINT specifies the command to be executed when the container is started.

An example Docker file for a Python application:

Using the base image with Python
FROM python:3.8

# Install dependencies
RUN pip install flask

# Copy the source code into the image
COPY . /app

# Specify the working directory

# Define the command to run the application
CMD ["python", ""]

Docker Image (Docker Image)

In order to create an image from a Dockerfile and run the container, you need to:

  1. Go to the directory where the dockerfile is located.

  2. Use the docker build command to create an image from the file.

  3. If necessary, verify the images with the docker images command.

  4. Run the container from the image with the docker run command.

When working with images, you can use tags to specify the version of the images. By default, Docker assigns the tag latest when building.

#example of building an image with explicit tagging
docker build -t my-python-app:v1.0

The following commands are used to send the image to the Docker Hub registry:

docker tag my-python-app:v1.0 username/my-python-app:v1.0
docker push username/my-python-app:v1.0

To load an image from the registry, use the command:

docker pull username/my-python-app:v1.0

Docker images are static. Containers, on the other hand, are changeable. To "update" an image, you can start a container from it, make changes, and save the state to the new image. This is done using the docker commit command:

docker commit -m "Added changes" -a "Author" container_id username/my-python-app:v1.1

A Docker image is a standard format, which means that Docker Daemon can work with it on any platform. This allows for painless porting of projects from one system to another - containers are packed into images and ported. And isolation of all dependencies and components inside the image guarantees that the project will exactly "stand up" on the target platform with Docker without additional customization.

Docker Container.

A container is an image instance (instance) running in an isolated environment. One container "packs" one running server process.

You can, of course, put several processes, even a whole monolith - there are no strict limitations on the part of the tool. But this is considered a mistake of microservice architecture design. Docker allows you to customize the interaction of containers with the external environment and other containers, as well as regulate resource consumption. So there is no good reason to try to fit everything in one.

Additional features

If it is necessary for a container to work with its own data instance without modifying the original, we can mount a directory from the host system into the container itself. This is done with the command:

docker run -v /path/to/host-directory:/path/in/container image_name

Docker Volumes are repositories that are associated with a container, but are not tied to its lifecycle. This means that any data that the container sends to Volumes will persist even if the container is stopped or destroyed.

# command to create a Volume in a container
 docker run -v my_volume:/path/in/container image_name

To pass environment variables to the container, the -e flag is used in conjunction with the RUN command:

docker run -e MY_VARIABLE=value image_name

The container can export ports to communicate with the "outside world". This is especially relevant for web applications where ports can be used to access a web server.

docker run -p 8080:80 image_name

You can impose limits on the resources used by the container, such as the amount of RAM or the number of CPU cores.

docker run --memory 512m --cpus 0.5 image_name

Docker Registry

The Docker Registry is a publicly available image repository. The service helps to:

  • centrally store images and their versions;

  • speed up deployment - images are downloaded immediately to the target system and are ready to work;

  • automate the processes of building, testing, and deploying containers.

Docker Hub is a public registry that stores publicly available images (of Linux distributions, databases, languages, etc.). Organizations can create their own private Docker registries to store sensitive data.

Creating a Docker private registry

Installing Docker Distribution

Docker Distribution is the official implementation of the Docker Registry protocol. Let's install it on the server that will serve as the private registry.

docker run -d -p 5000:5000 --restart=always --name registry registry:2

This command starts a private registry on port 5000. You can optionally configure HTTPS using an SSL certificate.

Using the Private Registry

You can now use the registry to store and distribute private Docker images.

# tag the image   
docker tag my-image localhost:5000/my-image
# send the image to the private registry
docker push localhost:5000/my-image

Run Monolith on a server without Docker

In the README of an application you can find detailed instructions on how to deploy it on a server. For our example, let's take the README of the monolithic application, which we have intentionally shortened.

Package Installation:

sudo apt-get install -y build-essential libssl-dev zlib1g-dev libbz2-dev \
       libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev libncursesw5-dev \
       xz-utils tk-dev libffi-dev liblzma-dev python-openssl git npm redis-server vim ffmpeg

Installing pyenv:

$ curl | bash
$ echo 'export PATH="$HOME/.pyenv/bin:$PATH"' >> ~/.bashrc && \
echo 'eval "$(pyenv init -)"' >> ~/.bashrc && \
echo 'eval "$(pyenv virtualenv-init -)"' >> ~/.bashrc && source ~/.bashrc

Installing Python 3.6.9:

pyenv install 3.6.9

If Ubuntu version 20+ is installed and an error occurs, apply the following commands:

$ sudo apt install clang -y
$ CC=clang pyenv install 3.6.9

Creating a virtual environment:

$ pyenv virtualenv 3.6.9 cpa-project

Virtual environment activation:

$ pyenv activate cpa-project

Installing NodeJS 8.11.3:

$ npm i n -g
$ sudo n install 8.11.3
$ sudo n # in the window that appears, select the version 8.11.3

Project Cloning:

$ git clone

Let's go to the project:

$ cd cpa-project

Install python dependencies (make sure the virtual environment is active):

$ pip install -U pip
$ pip install -r requirements.txt

Installing nodejs dependencies

$ npm install

Perform migrations for the database and create test data:

$ python migrate
$ python generate_test_data

Building the client side:

$ npm run watch # For development with automatic rebuilds
$ npm run build # For production

Building the same project in Docker

Let's see how the same application can be deployed in a Docker environment. First, let's allocate services for the compose file and give them names:

version: '3'
   container_name: {stage}-project-ex--app
     context: ..
     dockerfile: Dockerfile
     - ".env.{stage}"
     - stage_project-ex_network
     - {stage}-project-ex--redis
     - {stage}-project-ex--clickhouse
     - {stage}-project-ex--postgres
     - {stage}-project-ex--mailhog
     - ..:/app/
     - ./crontab.docker:/etc/cron.d/crontab.docker
   command: /start
     - "traefik.enable=true"
     - "traefik.http.routers.{stage}_fp_app.rule=Host(`web.{stage}`)"
     - "{stage}_fp_app.loadbalancer.server.port=8000"
     - "traefik.http.routers.{stage}_fp_app.entrypoints=websecure"
     - "traefik.http.routers.{stage}_fp_app.tls.certresolver=stage_project-ex_app"

   container_name: {stage}-project-ex--app-cron
     context: ..
     dockerfile: Dockerfile
     - ".env.{stage}"
     - stage_project-ex_network
     - {stage}-project-ex--redis
     - {stage}-project-ex--clickhouse
     - {stage}-project-ex--postgres
     - {stage}-project-ex--mailhog
     - ..:/app/
     - ./crontab.docker:/etc/cron.d/crontab.docker
   command: sh -c "printenv >> /etc/environment && crontab /etc/cron.d/crontab.docker && cron -f"

   container_name: {stage}-project-ex--front
   build: ./frontend-builder
     - ".env.{stage}"
     - stage_project-ex_network
     - {stage}-project-ex--app
     - ..:/app/

   container_name: {stage}-project-ex--clickhouse
   image: yandex/clickhouse-server:
     - ".env.{stage}"
     - stage_project-ex_network
     - /home/project-ex/stands/{stage}/docker_data/clickhouse/data:/var/lib/clickhouse
     - ./docker_data/clickhouse/schema:/var/lib/clickhouse/schema
     - ./docker_data/clickhouse/users.xml:/etc/clickhouse-server/users.xml
     - ./docker_data/clickhouse/project-ex.xml:/etc/clickhouse-server/users.d/default-user.xml
     - "traefik.enable=true"
     - "traefik.tcp.routers.{stage}_fp_clickhouse.rule=HostSNI(`*`)"
     - "traefik.tcp.routers.{stage}_fp_clickhouse.entryPoints=clickhouse"
     - "traefik.tcp.routers.{stage}_fp_clickhouse.service={stage}_fp_clickhouse"
     - "{stage}_fp_clickhouse.loadbalancer.server.port=8123"

   container_name: {stage}-project-ex--postgres
   image: postgres:13.11-alpine
     - ".env.{stage}"
     - stage_project-ex_network
   stdin_open: true
   tty: true
     - {stage}-project-ex--postgres:/var/lib/postgresql
     - "traefik.enable=true"
     - "traefik.tcp.routers.postgres.rule=HostSNI(`*`)"
     - "traefik.tcp.routers.postgres.entryPoints=postgres"
     - "traefik.tcp.routers.postgres.service=postgres"
     - ""

   container_name: {stage}-project-ex--redis
   image: redis:alpine
     - ".env.{stage}"
     - stage_project-ex_network
     - {stage}-project-ex--redis:/data

   container_name: {stage}-project-ex--mailhog
   image: mailhog/mailhog:v1.0.1
     - ".env.{stage}"
     - stage_project-ex_network
     - "traefik.enable=true"
     - "traefik.http.routers.{stage}_fp_mailhog.rule=Host(`mail.{stage}`)"
     - "{stage}_fp_mailhog.loadbalancer.server.port=8025"
     - "traefik.http.routers.{stage}_fp_mailhog.entrypoints=websecure"
     - "traefik.http.routers.{stage}_fp_mailhog.tls.certresolver=stage_project-ex_app"

   name: {stage}-project-ex--postgres
   driver: local
   name: {stage}-project-ex--project-ex
   driver: local

   external: true
   name: stage_project-ex_network

Docker Compose is a tool for running multi-container applications in Docker. The .yaml file specifies all necessary settings and commands. Starting containers from the compose file is done with the docker-compose up command.

In the .yaml file we can see from which containers container_name (and which versions) our previously monolithic application is launched. And the {stage} keyword is the branch in GitLab from which the container will be lifted. Optionally, we can run containers from different branches on the same server.

The fact that we have broken the application into microservices does not make it monolithic. Microserviceability of the product is laid down at the stage of its design and creation, when each task is allocated to a separate service.

The line in compose dockerfile: Dockerfile builds our container. The file contains instructions such as:

FROM python:3.6.9-buster

ENV DJANGO_SETTINGS=advgame.local_settings

ARG DEBIAN_FRONTEND=noninteractive

RUN apt-get update \
 # dependencies for building Python packages
 && apt-get install -y build-essential \
 # psycopg2 dependencies
 && apt-get install -y libpq-dev \
 # Translations dependencies
 && apt-get install -y gettext \
 # Cron
 && apt-get install -y cron \
 # Vim
 && apt-get install -y vim \
 # cleaning up unused files
 && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
 && rm -rf /var/lib/apt/lists/*

# Set timezone
ENV TZ=Europe/Moscow
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

# Have to invalidate cache here because Docker is bugged and doesn't invalidate cache
# even if requirements.txt did change

ADD ../requirements.txt /requirements.txt
RUN pip install -r /requirements.txt

COPY ./docker-compose/ /start
RUN chmod +x /start

# Copy hello-cron file to the cron.d directory
COPY ./docker-compose/crontab.docker /etc/cron.d/crontab.docker
# Give execution rights on the cron job
RUN chmod 0644 /etc/cron.d/crontab.docker
# Apply cron job
RUN crontab /etc/cron.d/crontab.docker

COPY . /app


Next, we wrote a make-file that will allow us to manage the configuration of the project:

interactive:=$(shell [ -t 0 ] && echo 1)
ifneq ($(interactive),1)

uid=$(shell id -u)
gid=$(shell id -g)
# Команда для выполнения docker-compose
# Параметр для docker-compose exec

  @docker-compose -f ./docker-compose/$(env).yml --env-file=./docker-compose/.env.$(env) $(cmd)

  @make dc cmd="logs" env="$(env)"

  [ -f ./docker-compose/.env.$(env) ] && echo ".env.$(env) file exists" || cp ./docker-compose/.env.example ./docker-compose/.env.$(env)
  sed -i "s/{stage}/$(env)/g" ./docker-compose/.env.$(env)
  @if [ "$(env)" = "local" ] ; then \
     sed -i "s/{domain}/ma.local/g" ./docker-compose/.env.$(env) ; \
  @if [ "$(env)" = "dev" ] ; then \
     sed -i "s/{domain}/" ./docker-compose/.env.$(env) ; \

  @if [ ! "$(env)" = "local" ] ; then \
     [ -f ./docker-compose/$(env).yml ] && echo "$(env).yml file exists" || cp ./docker-compose/stage.example.yml ./docker-compose/$(env).yml ; \
     sed -i "s/{stage}/$(env)/g" ./docker-compose/$(env).yml; \

  docker network ls | grep stage_project-ex_network > /dev/null || docker network create stage_project-ex_network
  @make cp-env
  @make cp-yml
  [ -f ./docker-compose/.env.$(env) ] && echo ".env.$(env) file exists" || cp ./docker-compose/.env.$(env).example ./docker-compose/.env.$(env)
  @make dc cmd="up -d"
  @make dc cmd="start $(env)-$(project)--postgres" env="$(env)"
  sleep 5 && cat ./docker-compose/docker_data/pgsql/data/init_dump.sql | docker exec -i $(env)-$(project)--postgres psql -U project-ex
  @make dc cmd="exec $(env)-$(project)--app python ./ migrate" env="$(env)"
  @make ch-restore env="$(env)"
  @make build-front env="$(env)"
  @make collect-static env="$(env)"

  @make dc cmd="exec $(env)-$(project)--postgres dropdb --if-exists -U project-ex project-ex_test" env="$(env)" > /dev/null
  @make dc cmd="exec $(env)-$(project)--postgres createdb -U project-ex project-ex_test" env="$(env)"
  cat ./docker-compose/docker_data/pgsql/data/init_dump.sql | docker exec -i $(env)-$(project)--postgres psql -U project-ex project-ex_test

  @make dc cmd="exec $(env)-$(project)--front sh" env="$(env)"


Make creates a kind of short command alias for service management. In it, you can initialize the project, recreate the base, build the front end, and so on. These are the same commands we will use in the GitLab CI file.

Next, we run the make init command to initialize the project.

version: '3'
   image: "traefik:v3.0.0-beta2"
   container_name: "stage-project-ex--traefik"
     - "--log.level=DEBUG"
     - "--providers.docker=true"
     - "--providers.docker.exposedbydefault=false"
     - "--entrypoints.web.address=:80"
     - ""
     - "--entrypoints.websecure.address=:443"
     - "--entrypoints.postgres.address=:5432"
     - "--entrypoints.clickhouse.address=:8123"
     - "--entrypoints.mongo.address=:27017"
     - "--certificatesresolvers.stage_project-ex_app.acme.httpchallenge=true"
     - "--certificatesresolvers.stage_project-ex_app.acme.httpchallenge.entrypoint=web"
     - ""
     - ""
   restart: always
     - 80:80
     - 443:443
     - 5432:5432
     - 8123:8123
     - 27017:27017
     - stage_project-ex_network
     - "/opt/letsencrypt:/letsencrypt"
     - "/var/run/docker.sock:/var/run/docker.sock:ro"

   external: true
   name: stage_project-ex_network

Configuring Ports

The compose file doesn't say anything about ports. The main reason is that we will access the project by domain name using Traefik. Applications work separately from compose-files and project versions: Traefik learns about new containers from Docker Daemon, and the configuration for the application is written in the compose-file after the keyword .

Traefik proxies traffic to the container based on hostname (not only HTTP/HTTPS), requests LE-certificate, renews it itself. You do not need to specify which IP or hostname to proxy to or change Traefik config.

If we raise local containers with local domain name, we cannot request LE-certificate. Therefore, we have to communicate with the web via HTTP, and disable redirect to HTTPS in Traefik

The version of traefik image:v3.0.0-beta2 is chosen for a reason, it supports different domain names for routing to PostgreSQL containers. In the example above, using beta2 is not necessary, as any request on port 5432 will be proxied to a single PostgreSQL container.

When there are multiple Postgres containers

Working with multiple PostgreSQL containers and routing to them based on domain names requires creating a self-signed Wildcard certificate of the local domain and adding information about it to the Traefik config.

This is done only so that PostgreSQL containers can be accessed "from the outside" to work with the database directly. In case the containers are running on the same Docker network, Traefik is not needed.

In compose traefik add:

      - "--providers.file.filename=/conf/dynamic-conf.yml"
      - "./tls:/tls"
      - "./conf:/conf"

In conf/dynamic-conf.yml write the certificate files

    - certFile: /tls/
      keyFile: /tls/

In the tls/ directory, put the Wildcard certificate files created by the Bash script

#!/usr/bin/env bash
set -euo pipefail


if [ ! -f $1.key ]; then

  if [ -n "$1" ]; then
    echo "You supplied domain $1"
    SAN_LIST="[SAN]\nsubjectAltName=DNS:localhost, DNS:*.localhost, DNS:*.$DOMAIN_NAME, DNS:$DOMAIN_NAME"
    printf $SAN_LIST
    echo "No additional domains will be added to cert"
    SAN_LIST="[SAN]\nsubjectAltName=DNS:localhost, DNS:*.localhost"
    printf $SAN_LIST

  openssl req \
    -newkey rsa:2048 \
    -x509 \
    -nodes \
    -keyout "$1.key" \
    -new \
    -out "$1.crt" \
    -subj "/CN=compose-dev-tls Self-Signed" \
    -reqexts SAN \
    -extensions SAN \
    -config <(cat /etc/ssl/openssl.cnf <(printf $SAN_LIST)) \
    -sha256 \
    -days 3650

  echo "new TLS self-signed certificate created"


  echo "certificate files already exist. Skipping"


Правим labels контейнера postgres в compose-файле

      - "traefik.enable=true"
      - "traefik.tcp.routers.qa222_postgres.rule=HostSNI(``)"
      - "traefik.tcp.routers.qa222_postgres.entryPoints=postgres"
      - "traefik.tcp.routers.qa222_postgres.service=qa222_postgres"
      - ""
      - "traefik.tcp.routers.qa222_postgres.tls=true"

CI/CD project

Below we have attached our GitLab CI file, in it we can see the previously mentioned make commands

 APP4_ENV: "gitlab"

   #gtilab runner tag
   - dev-project-ex-1

 - ci
 - delivery
 - build
 - deploy

.before_script_template: &build_test-integration
 - echo "Prepare job"
 - sed -i "s!env=local!env=${APP4_ENV}!" ./Makefile
 - make cp-env
 - make cp-yml
 - make up

.verify-code: &config_template
 stage: ci
 <<: *build_test-integration
     - merge_requests
     - develop
     - master

 <<: *config_template
   - make build
   - make linter

 <<: *config_template
   - make tests

 stage: delivery
   - echo "Rsync from $CI_PROJECT_DIR"
   - sudo rm -rf "/home/project-ex/stands/dev/project-ex/!\(static|node_modules\)"
   - sed -i "s!env=local!env=dev!" ./Makefile
   - rsync -av --delete-before --no-perms --no-owner --no-group
     --exclude "node_modules/"
     --exclude "__pycache__/"
     --exclude "logs/"
     --exclude "docker-compose/docker_data/clickhouse/data/"
     $CI_PROJECT_DIR/ /home/project-ex/stands/dev/project-ex
   - develop
   - master

 stage: build
   - echo "cd /home/project-ex/stands/dev/project-ex"
   - cd /home/project-ex/stands/dev/project-ex
   - echo "make cp-env"
   - make cp-env
   - echo "cp-yml"
   - make cp-yml
   - echo "build"
   - make build
   - develop
   - master

 stage: build
   - echo "cd /home/project-ex/stands/dev/project-ex"
   - cd /home/project-ex/stands/dev/project-ex
   - echo "build-front"
   - make build-front
     - '*.js'
     - '*.css'
     - '*.less'
     - develop
     - master

 stage: deploy
   - cd /home/project-ex/stands/dev/project-ex
   - mkdir -p logs
   - make restart
   - make migrate
   - make collect-static
   - develop
   - master

Pros and cons of Docker

Docker is designed for server applications and does not always support GUIs.

On top of that, Docker requires the developer to be precise and accurate. Incorrect container configuration or insufficient security measures can jeopardize the whole system.

As you can see, the tool has its disadvantages too. But the advantages are undoubtedly greater: Docker isolates applications using namespaces and cgroups - there is no need to start a separate virtual machine for each task. It also optimizes resource allocation across containers and is able to manage the application lifecycle - start, stop, scale and update containers. And the biggest plus is the ecosystem and live community. There are hundreds of ready-made images lying around in Docker Hub, and in the community you can ask questions or find a ready-made solution.

Most importantly, remember: a monolithic architecture does not mean a "bad", "outdated" or "unfashionable" approach. You should design an application based on common sense and assessment of your own capabilities. Because implementing microservices with CI/CD requires more skills and competencies than automating a monolithic application.