~$ delxium

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-data volume 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 -e on docker 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.

← all field notes