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.txt or package.json first 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 -v only 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 .env to 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.