Picture this. You have a Python script. It needs specific versions: Python 3.11, pandas 2.1.3, and a Postgres database. It runs perfectly on your laptop. You send it to your colleague Blue. She spends two hours installing the right Python version, debugging why pandas won’t install, and figuring out why Postgres won’t start. Eventually she gets it working. Then she sends it to Red, and the process starts again. BTW we’re going full Reservoir Dogs with this one (Excuse my calculated clumsiness).
This is the problem Docker solves. It packages your application with all its dependencies into a single unit called a container. That container runs exactly the same way on any computer with Docker installed.
A container includes:
Everything the application needs to run is inside the container. The host computer only needs Docker installed.
The Dockerfile is a text file that describes how to build your container. It’s a list of instructions. Each instruction creates a layer in the container.
Here’s a simple Dockerfile for a Python application:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Let’s break down the build process line by line:
FROM python:3.11-slim - Starts the build with a Python 3.11 base image. The slim variant excludes unnecessary standard libraries, keeping the final image size small and secure.
WORKDIR /app - Sets the working directory inside the container. All subsequent commands (like COPY and RUN) will be executed relative to this folder.
COPY requirements.txt . - Copies the dependency file first. This is a best practice for layer caching; it ensures dependencies aren’t re-installed unless this specific file changes.
RUN pip install --no-cache-dir -r requirements.txt - Installs the necessary Python packages. The --no-cache-dir flag prevents Docker from storing the temporary installer files, further reducing image size.
COPY . . - Copies the remaining source code from your local directory into the container’s /app folder.
CMD ["python", "app.py"] - Defines the default command to run when the container launches. Unlike RUN, which happens during the build, CMD happens at runtime.
Save this as Dockerfile in your project directory. Make sure you have a requirements.txt file with your dependencies and an app.py file.
Build the image:
docker build -t my-python-app .
The -t flag tags the image with a name (my-python-app). The . tells Docker to look for the Dockerfile in the current directory.
Run the container:
docker run -p 5000:5000 my-python-app
The -p 5000:5000 maps port 5000 inside the container to port 5000 on your computer. If your app listens on port 5000 inside the container, you can access it at http://localhost:5000.
Run this same container on your laptop, a coworker’s computer, a testing server, or a production server. It behaves identically everywhere. No more “works on my machine” discussions.
Most applications need more than one service. A web app might need a web server, a database, and a cache. Docker Compose lets you define and run multiple containers together.
Create a docker-compose.yml file:
version: '3.8'
services:
web:
build: .
ports:
- "8000:8000"
depends_on:
- db
db:
image: postgres:15
environment:
POSTGRES_PASSWORD: secretpassword
POSTGRES_DB: mydatabase
This file defines two services: web (your application) and db (a PostgreSQL database).
The web service is built from the Dockerfile in the current directory (.). It exposes port 8000. The depends_on line ensures the database starts before the web application.
The db service uses the official PostgreSQL 15 image. It sets environment variables for the database password and name.
Run everything with one command:
docker-compose up
Docker Compose starts both containers and sets up networking between them. Your web application can connect to the database using the hostname db (the service name in the YAML file).
When you run containers with Docker Compose, they join a virtual network. Each service can reach the others using the service name as the hostname.
In your application code, connect to the database like this:
import psycopg2
# 'db' is the service name from docker-compose.yml
conn = psycopg2.connect(
host="db",
database="mydatabase",
user="postgres",
password="secretpassword"
)
This works because Docker sets up DNS resolution between containers. The hostname db resolves to the database container’s IP address.
Containers are ephemeral by default. When a container stops, all data inside it is lost. For databases, you need to persist data outside the container.
Docker volumes solve this. They store data on the host machine and mount it into the container.
Update your docker-compose.yml:
services:
db:
image: postgres:15
environment:
POSTGRES_PASSWORD: secretpassword
POSTGRES_DB: mydatabase
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
The volumes section creates a named volume called postgres_data. It’s mounted at /var/lib/postgresql/data inside the container, where PostgreSQL stores its data.
Now when you stop and restart your containers, the database data persists.
docker ps - List running containers
docker stop container_name - Stop a running container
docker rm container_name - Remove a stopped container
docker images - List downloaded images
docker rmi image_name - Remove an image
docker-compose down - Stop and remove all containers defined in docker-compose.yml
docker logs container_name - View container output
docker exec -it container_name bash - Open a shell inside a running container
Let’s containerize a simple Flask application with a PostgreSQL database.
Project structure:
flask-app/
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── app.py
└── init.sql
requirements.txt:
Flask==3.0.0
psycopg2-binary==2.9.9
app.py:
from flask import Flask, jsonify
import psycopg2
import os
app = Flask(__name__)
def get_db_connection():
conn = psycopg2.connect(
host="db",
database="mydatabase",
user="postgres",
password=os.getenv("DB_PASSWORD")
)
return conn
@app.route('/')
def index():
conn = get_db_connection()
cur = conn.cursor()
cur.execute('SELECT version()')
version = cur.fetchone()[0]
cur.close()
conn.close()
return jsonify({'postgres_version': version})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)
Dockerfile:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
docker-compose.yml:
version: '3.8'
services:
web:
build: .
ports:
- "8000:8000"
environment:
DB_PASSWORD: mysecretpassword
depends_on:
- db
db:
image: postgres:15
environment:
POSTGRES_PASSWORD: mysecretpassword
POSTGRES_DB: mydatabase
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Run it:
docker-compose up
Open http://localhost:8000 in your browser. You’ll see the PostgreSQL version returned as JSON. Every computer that runs these commands gets exactly the same result.
This covers the basics of running applications with Docker. The next step is learning to optimize your Docker images for size and speed, and understanding how to deploy containers to production environments.
The key takeaway: Docker eliminates environment differences as a source of bugs. You define the environment once in the Dockerfile, and it works consistently everywhere.
This is the first article in a Docker series. Next: making Docker images smaller and faster.
If this made you nod, laugh, or have butterflies in the stomach or elsewhere — tell me about it.