To begin, why did I choose to use Docker?
A question I ask whenever I intend to start learning a new tool is: what problem does it solve, what are the alternatives, and how would this problem be solved without it?
This way, the purpose of the tool becomes clear — as well as whether it actually makes sense to use it.
Docker was widely adopted because it solves a universal pain point: standardizing the environment in which an application runs. Without it, we fall into the well-known phrase: “it works on my machine.”
In the second post of this blog, LearningSea Architecture and Decisions (Overview), I briefly mention the story of a coworker who faced recurring difficulties with development environments:
Some time ago, a coworker mentioned recurring difficulties with development environments. Dependencies that wouldn’t install correctly, configurations that frequently broke, and inconsistencies between machines.
The problems involved database versions, port conflicts, and differences in language versions.
I wanted my project to run independently of the development environment, with a practical approach and well-defined responsibilities.
Docker provided that — let’s understand how.
How Docker solves this problem
With Docker, the application infrastructure becomes defined as explicit code. Instead of manually installing all the required software on your machine, you install only Docker — and everything else is described and executed by it: base system, dependencies, configurations, and entrypoint.
A useful analogy to understand Docker is virtual machines — with important caveats. Just like them, you have an isolated environment with its own filesystem, internal network, and DNS. The difference is that Docker is significantly lighter and does not virtualize an entire operating system.
The trade-off
It is important to understand that Docker does not eliminate complexity — it shifts it. By adopting the tool, you start dealing with a new layer, which does indeed add complexity.
In return, you gain a predictable, explicit, and consistent environment that behaves the same way on any system that has Docker.
What does it look like in practice?
In practice, there are two central files in configuring an environment with Docker: the Dockerfile and the docker-compose.yml.
Entrypoint scripts are also common — they are entry points that define commands executed during container startup, such as database connection checks to avoid race conditions (when one service depends on another that is not yet ready).
Image & Dockerfile
An image is a set of read-only layers that defines the state of an environment. From it, containers are created as running instances.
The Dockerfile defines how an image is built. This image describes everything the application needs to run: the base system (such as a Python image), dependencies, configurations, and commands required for execution.
Ready-made images can be found in Docker’s official repository, Docker Hub
Example:
Dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && apt-get install -y \
libpq-dev \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*
COPY src/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ .
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
FROM python:3.12-slim: defines the base image with Python on a minimal Linux distroWORKDIR /app: sets the default directory for command executionRUN apt-get update && apt-get install -y ...: installs system dependencies required by the applicationlibpq-dev: headers required to compile PostgreSQL driversnetcat-openbsd: tool used to check service availability (e.g., DB port)rm -rf /var/lib...: removes apt cache to reduce image sizeCOPY src/requirements.txt: copies the Python dependencies list into the containerRUN pip install --no-cache-dir -r requirements.txt: installs dependencies without cacheCOPY src/ .: copies the application source code into the imageCOPY docker/entrypoint.sh /entrypoint.sh: copies the entrypoint scriptRUN chmod +x /entrypoint.sh: makes the script executableENTRYPOINT ["/entrypoint.sh"]: sets it as the container’s main process
Containers & Docker Compose
A container is a running instance of an image, with a writable layer on top of it.
Because of this, the image is immutable, while the container represents an ephemeral layer above it.
When a container is stopped, its processes are terminated, and any non-persisted data is lost. Persistence is achieved through volumes, which will be explained next.
A bit of historical context: Docker initially focused on individual containers, and Compose emerged as a layer on top to orchestrate multiple services — initially separate, but eventually integrated as it became essential to the workflow.
Docker Compose is a subcommand responsible for initializing and managing the containers defined in the docker-compose.yml file.
A practical way to understand containers is as isolated running environments, each with its own filesystem, network, and processes. An important detail is that they share the host operating system’s kernel.
Example:
docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: app
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 5s
retries: 5
web:
build: .
environment:
DB_HOST: db
DB_NAME: app
DB_USER: app
DB_PASSWORD: app
depends_on:
db:
condition: service_healthy
volumes:
- static_files:/app/staticfiles
- media_files:/app/media
caddy:
image: caddy:2-alpine
ports:
- "80:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- static_files:/srv/static
- media_files:/srv/media
depends_on:
- web
volumes:
db_data:
static_files:
media_files:
servicesdefine the containers orchestrated by Compose- db
image: uses the official PostgreSQL image based on Alpineenvironment: injects configuration variables into the containervolumes: defines persistence outside the container lifecyclehealthcheck: defines periodic service health checks
- web
build: builds the image from the local Dockerfileenvironment: variables used by the application to connect to the databasedepends_on: defines dependency and startup order between servicescondition: waits for the database healthcheck before startingvolumes: mounts volumes for persistence and data sharingstatic_files:/app/staticfiles: directory where the application writes static filesmedia_files:/app/media: directory where the application stores media files
- caddy
image: lightweight web server / reverse proxyports: maps host ports to the container (host:container)volumes: mounts configuration files and shared volumes into the containerstatic_files:/srv/static: access to static files generated by the backendmedia_files:/srv/media: access to media files for direct servingdepends_on: defines startup order
Volumes
Volumes are what enable data persistence in Docker. They are also the standard way to share data between containers.
These volumes are mounted at specific paths inside containers, allowing read and write access.
In the flow above (docker-compose.yml):
- The
webcontainer writes files to/app/staticfilesand/app/media - The
caddycontainer accesses the same data at/srv/staticand/srv/media
In other words, multiple containers can mount the same volume at different paths while accessing the same content.
Containers do not share state directly — volumes are the mechanism for data exchange between services.
The backend writes files, and the proxy (Caddy) serves them directly (configured in the Caddyfile), avoiding passing through the application.
Entrypoint
The entrypoint defines the container’s base command, acting as the starting point for execution. It is always executed when the container starts and can be extended with arguments.
Example:
entrypoint.sh
#!/bin/sh
while ! nc -z $DB_HOST 5432; do
sleep 1
done
echo "Database is up"
python manage.py migrate
exec gunicorn config.wsgi:application --bind 0.0.0.0:8000
#!/bin/sh: defines the script interpreter (shebang)nc -z $DB_HOST 5432: tests TCP connection to the databasewhile ! ...; do sleep 1; done: wait loop until the service is availablepython manage.py migrate: runs migrations before starting the applicationexec gunicorn ...: replaces the shell process with the WSGI server (PID 1)--bind 0.0.0.0:8000: exposes the service on all container interfaces
What do we have so far?
So far, we have defined:
- the application environment explicitly (
Dockerfile) - the services that compose the infrastructure (
docker-compose.yml) - the application entrypoint (
entrypoint.sh)
Startup flow
The complete flow starting from running docker compose up --build is as follows:
- Docker builds the application image based on the
Dockerfile - The
dbservice starts from the PostgreSQL image - The database healthcheck begins running
- The
webservice waits until the database is healthy (service_healthy) and then starts entrypoint.shis executed as the main process- The script waits for database availability (port check)
- Migrations are applied
- Gunicorn starts and begins serving the application
- The
caddyservice starts and acts as the reverse proxy
Conclusion
With Docker, this entire process stops being implicit and manual, and becomes declarative and reproducible.
These examples are approximate versions of what I use in LearningSea. However, several parts were removed or simplified to make the content more didactic and less extensive.