Understanding Docker and Using it with Docker Compose
To be honest, docker looked like an alien tools for me at the beginning, Scary. But as I started using it, it became a simple to use and very important tool. Docker helped to solve one of the biggest problem in tech world, "it works on my machine". In this article we will learn docker by building a single repo(single folder) project with a FastAPI backend, React frontend, PostgreSQL, Redis and Nginx, all of these running together with Docker.
Before Docker:
- someone set up a project, it works fine,
- then his/her friend clones it and something breaks because they have a different python version, or PostgreSQL is not installed, or some environment variable is missing.
Docker was created to solve this issue.
Introduction to Docker
Simply, we can think of Docker as a way to box up our entire application, our code, our dependencies, our environment settings, everything into a single unit that runs the same way everywhere(on our laptop, on a server, on our co-worker's pc).
Traditionally, people used Virtual Machines to create mini-environments.
Physical Machine
|
|-- Host OS (Linux)
|
|-- VM 1
| |-- Guest OS
| |-- App A
|
|-- VM 2
| |-- Guest OS
| |-- App B
Using Docker, Docker shares the host OS kernel, instead of creating a whole new OS.
Physical Machine
|
|-- Host OS (Linux)
|
|-- Docker Engine
|
|-- Container A
| |-- Java 17
| |-- App A
|
|-- Container B
| |-- Java 21
| |-- App B
|
|-- Container C
|-- Python 3.12
|-- App C
Containers share the same Linux kernel but have separate environments.
Major concepts:
- Image: A blueprint, a snapshot of our app, or simply a certain version of our app and everything it needs.
- Container: A running instance of an image. We can run many docker containers from one image.
- Docker Compose: A docker tool to run multiple containers together and make it easy for them talk to each other.
Single Repo(Monorepo) with Docker
A monorepo means all our services live in one repository.
Example:
my-project/
|-- backend/ # FastAPI + Python
|-- frontend/ # React
|-- nginx/ # Reverse proxy config
|-- docker-compose.yml
|-- .env
The frontend talks to the backend. The backend talks to PostgreSQL and Redis. Celery runs background tasks using the same backend code. Nginx sits in front of everything and routes traffic.
Without Docker, getting all of this running locally requires installing PostgreSQL, Redis, configuring ports, running four separate terminal windows, and praying nothing conflicts. But with docker, it is easier as we can run all these service with a single command.
docker compose up
Docker setup
We follow steps below to run a complete system that use different techs to complete the system.
Creating Dockerfile
The first to do is to create Dockerfile, a file that tells Docker how to build that service. We create Dockerfile for each service.
Here's a simple one for a FastAPI backend
# Start from an official Python image
FROM python:3.11-slim
# Set working directory inside the container
WORKDIR /app
# Copy and install dependencies first
COPY requirements.txt .
RUN pip install -r requirements.txt
# Copy the rest of the code
COPY . .
# Run the app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
And one for a React/Vite frontend:
FROM node:20-slim
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
CMD ["npm", "run", "dev", "--", "--host"]
Important tips: I kept copying everything before installing dependencies. This meant every tiny code change forced Docker to reinstall all packages. Copying
requirements.txtorpackage.jsonfirst and installing before copying the rest is the key. Docker caches each layer, so unchanged layers don't get rebuilt.
Creating Docker Compose file
Individual Dockerfiles build services in isolation. Docker Compose is a file that binds them together.
docker-compose.yml
services:
backend:
build: ./backend
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://user:password@db:5432/mydb
- REDIS_URL=redis://redis:6379
depends_on:
- db
- redis
frontend:
build: ./frontend
ports:
- "5173:5173"
depends_on:
- backend
db:
image: postgres:15
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
depends_on:
- backend
- frontend
volumes:
postgres_data:
DATABASE_URL uses db as the hostname, not localhost.
Start Docker services
We use this command to run every service that our app requires. For example, frontend, backend, database, redis, nginx, etc. The command is
# First run
docker compose up --build
# if your docker compose file name is other than docker-compose.yml
docker compose -f <filename> up --build
# Stop containers
docker compose down
# Start again later
docker compose up
# Rebuild images (Dockerfile or dependencies changed)
docker compose up --build
Useful Docker Commands
Some useful docker commands are listed below:
# Start containers in background
docker compose up -d
# remove associated volumns
docker compose down -v
# See running containers
docker compose ps
# View logs
docker compose logs backend
docker compose logs -f backend # Follow/live logs
# Shell into a container
docker compose exec backend bash
docker compose exec db psql -U user -d mydb
# Rebuild a specific service
docker compose up --build backend
Important Lessons
Some important lessons are listed:
Containers don't use "localhost" to talk to each Other
Inside Docker Compose, each service has its own network. When the backend tries to connect to PostgreSQL, it can't use localhost, because localhost inside the backend container refers to the backend container itself, not the database. Instead, use the service name as the hostname.
# Wrong
DATABASE_URL = "postgresql://user:password@localhost:5432/mydb"
# Correct
DATABASE_URL = "postgresql://user:password@db:5432/mydb"
Docker Compose automatically creates a network where services can reach each other by service name. db, redis, backend, these are all valid hostnames within that network.
Volumes are means that helps data survive
Containers are disposable. When we stop and remove a container, everything inside it is gone, including our database data. Volumes solve this. In the Compose file above:
volumes:
postgres_data:
# And in the db service:
volumes:
- postgres_data:/var/lib/postgresql/data
This tells Docker to store the PostgreSQL data in a named volume that persists even when the container is destroyed. Using volume, our data survives restarts, rebuilds, everything.
The difference between the two down commands:
docker compose down # Stops containers, keeps volumes
docker compose down -v # Stops containers, DELETES volumes
Tips: Use
-vonly when you actually want to wipe everything and start fresh.
Environment variables should be stored in a .env file
Hardcoding passwords and secrets in docker-compose.yml is a bad habit that can cause irreversible harms when codes are pushed on GitHub or other similar platforms.
Create a .env file at the root:
POSTGRES_USER=myuser
POSTGRES_PASSWORD=supersecret
POSTGRES_DB=mydb
DATABASE_URL=postgresql://myuser:supersecret@db:5432/mydb
REDIS_URL=redis://redis:6379
SECRET_KEY=my-secret-key
Docker Compose automatically picks up .env files. When Docker Compose starts the containers, it replaces placeholders with values from .env file.
environment:
- DATABASE_URL=${DATABASE_URL}
- SECRET_KEY=${SECRET_KEY}
Important tips: And add
.envto your.gitignore.
depends_on doesn't mean "Wait Until Ready"
This one is subtle and frustrating.
depends_on tells Docker to start one container before another. But it doesn't wait for the service inside to actually be ready. PostgreSQL takes a few seconds to initialize after the container starts, and if your backend tries to connect during that window, it fails.
The fix is to add a health check. Now, Docker waits until PostgreSQL is actually accepting connections before starting the backend.
db:
image: postgres:15
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 5s
timeout: 5s
retries: 5
backend:
depends_on:
db:
condition: service_healthy
For complete beginners, start small. Dockerize a project, a Django app with PostgreSQL, OR, whatever tech you are using. Practice & Experiment with your demo projects. With practice, one can be good at docker within a week.
Leave a comment
Comments