docker · devops · deployment
Docker: A Pocket Companion
From 'works on my machine' to a container running in production — learned by packaging and deploying one small API.
Docker’s promise is simple: package your app and everything it needs to run into one artifact that behaves the same on your laptop, in CI, and on a server. This guide earns that promise by taking one small API from a folder of files to a container running in production — the same path we take shipping real software.
$ docker build -t todo-api .
$ docker run -p 8000:8000 todo-api
Images vs containers
Two words you’ll use constantly:
- An image is the frozen artifact — your code, the Python runtime, the OS libraries — built once, never changing. Think class.
- A container is a running instance of an image. Think object. You can start, stop, and throw away many containers from one image.
You build an image, then run containers from it.
The app
A minimal FastAPI service, app.py:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
…and its requirements.txt:
fastapi==0.115.6
uvicorn[standard]==0.34.0
That runs on your machine. Now let’s make it run anywhere.
The Dockerfile
A Dockerfile is the recipe for the image — a sequence of steps, each producing a
cached layer:
FROM python:3.12-slim
WORKDIR /app
# Copy deps first so this layer is cached unless requirements change.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Then the code (changes often — kept after the slow dep install).
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
Order matters — layer caching
Each line is a cached layer; Docker reuses a layer if nothing it depends on changed.
Copying requirements.txt and installing before copying your code means editing
app.py doesn’t re-run the slow pip install. Put what changes least at the top.
Bind to 0.0.0.0, not 127.0.0.1
Inside the container, 127.0.0.1 is the container’s loopback — unreachable from
outside. Servers must listen on 0.0.0.0 to accept traffic forwarded into the
container.
Build and run
# build an image, tagging it 'todo-api'
$ docker build -t todo-api .
# run a container; map host :8000 to the container's :8000
$ docker run -p 8000:8000 todo-api
# in another shell:
$ curl localhost:8000/health
{"status":"ok"}
-p HOST:CONTAINER is the bridge between your machine and the container’s network. Add
-d to run detached (in the background), and --rm to auto-remove the container when it
stops.
$ docker ps # what's running
$ docker logs -f <name> # follow its logs
$ docker exec -it <name> sh # open a shell *inside* the container
$ docker stop <name> # stop it
Trim the build with .dockerignore
Don’t ship your .git, virtualenv, or local database into the image. A
.dockerignore keeps the build context small and fast:
.git
.venv
__pycache__
*.db
.env
More than one service: Compose
Real apps are rarely one container. docker-compose.yml describes a stack —
here the API plus a Postgres database — and wires them together:
services:
api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://app:secret@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
$ docker compose up -d # build + start the whole stack
$ docker compose logs -f api # tail one service
$ docker compose down # stop everything (keeps named volumes)
Services find each other by name
The API reaches the database at the hostname db — Compose puts both on a private
network where the service name is the DNS name. No IP addresses, no localhost.
State and config: volumes and env
Containers are ephemeral — delete one and its filesystem is gone. Two rules keep you out of trouble:
- Persist data in volumes. The
db-datavolume above outlives the container, so your database survives restarts and redeploys. Anything you can’t lose goes on a volume. - Inject config through the environment. Never bake secrets into the image. Pass
DATABASE_URL, API keys, and the like as environment variables (environment:in Compose, or-eondocker run).
Ship it: registries and deploy
To run the image on a server, push it to a registry (Docker Hub, GitHub Container Registry, etc.), then pull it on the other side:
# tag for a registry and push
$ docker build -t ghcr.io/you/todo-api:v1 .
$ docker push ghcr.io/you/todo-api:v1
# on the server (often over SSH — see our SSH companion):
$ docker pull ghcr.io/you/todo-api:v1
$ docker run -d -p 8000:8000 ghcr.io/you/todo-api:v1
Tagging with a version (:v1, not just :latest) is what makes deploys reproducible
and rollbacks trivial: to roll back, run the previous tag. In CI, a pushed git tag builds
and publishes the image automatically — the same pipeline that ships
this very platform.
Behind a reverse proxy
In production you don’t expose container ports to the world directly. A reverse proxy
(Caddy, nginx, Traefik) terminates TLS on :443 and routes by hostname to your
containers over a shared private network — so one server can host many apps behind one
HTTPS entry point. Your API container publishes no host ports at all; only the proxy
does. That’s exactly how delxium.com, api.delxium.com, and admin.delxium.com all
reach different containers on one droplet.
Pocket cheat-sheet
| Do | Command |
|---|---|
| Build an image | docker build -t name . |
| Run a container | docker run -p 8000:8000 name |
| Run detached / auto-remove | docker run -d --rm name |
| List running / all | docker ps / docker ps -a |
| Logs / shell in | docker logs -f c / docker exec -it c sh |
| Stop / remove | docker stop c / docker rm c |
| Compose up / down / logs | docker compose up -d / down / logs -f |
| Push / pull | docker push img:tag / docker pull img:tag |
| Reclaim disk | docker system prune |
Containers turn “it works on my machine” into “it works on every machine.” Package one
small API this way and the pattern scales straight to a fleet — docker --help and the
Dockerfile reference fill in the rest when you outgrow this card.
Get new field notes by email
Occasional, practical write-ups on building and shipping software. No spam — unsubscribe anytime.