Dockerize a Python AI app¶
Beginner · 15 min · Docker Desktop or Docker Engine
"Works on my machine" stops being a problem when your machine ships with the app. A Docker image bundles your code, the exact Python version and every dependency, and runs the same on your laptop, a teammate's Mac and any cloud. It's also what most hosting platforms (Railway, Render, Fly.io, Cloud Run, ECS) deploy.
Install Docker Desktop (Windows/macOS) or Docker Engine (Linux),
start it, and check: docker --version.
1. The app¶
A minimal FastAPI app that calls an LLM. Any app works the same way:
chat-api/
├── app/
│ └── main.py
├── requirements.txt
├── .env # your keys: never goes into the image
├── .dockerignore
└── Dockerfile
import os
from fastapi import FastAPI
from openai import OpenAI
app = FastAPI()
client = OpenAI() # reads OPENAI_API_KEY from the environment
MODEL = os.getenv("MODEL", "gpt-4o-mini")
@app.get("/health")
def health():
return {"ok": True}
@app.post("/ask")
def ask(question: str):
reply = client.chat.completions.create(model=MODEL, messages=[{"role": "user", "content": question}])
return {"answer": reply.choices[0].message.content}
Pin exact versions (copy them from pip freeze): an image built next month should contain the same packages as
one built today.
2. The Dockerfile¶
# 1. Small official Python base image
FROM python:3.12-slim
# 2. Sensible Python defaults for containers
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
# 3. Dependencies first: this layer is cached until requirements.txt changes
COPY requirements.txt .
RUN pip install -r requirements.txt
# 4. Then your code (changes often, so it comes last)
COPY app/ ./app/
# 5. Don't run as root
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Why each part matters:
-slimimages are a few hundred MB smaller than the full ones and have everything most AI apps need.- Copy
requirements.txtbefore the code. Docker caches each step; editingmain.pythen only re-runs the last steps, so rebuilds take seconds instead of reinstalling every package. PYTHONUNBUFFERED=1makesprintand logs appear immediately indocker logs.--host 0.0.0.0is required:127.0.0.1inside a container is unreachable from outside it.- Non-root user: if the app is ever compromised, the attacker isn't root inside the container.
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY app/ ./app/
ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Pin the uv image tag (for example uv:0.8) in real projects. See
Manage GenAI projects with uv.
3. Keep secrets and junk out: .dockerignore¶
COPY copies everything it's pointed at. Without a .dockerignore, your .env (with API keys), your 2 GB virtual
environment and your Git history can end up inside the image, where anyone who pulls it can read them:
.env
.env.*
.venv/
venv/
__pycache__/
*.pyc
.git/
.pytest_cache/
.ipynb_checkpoints/
data/
models/
chroma_db/
Never put API keys in the Dockerfile (ENV OPENAI_API_KEY=...) either: they're stored in the image layers.
4. Build and run¶
-t chat-apinames the image.-p 8000:8000maps port 8000 on your machine to port 8000 in the container.--env-file .envpasses your keys at run time, so they never touch the image.
Then open http://localhost:8000/docs or:
Useful commands:
| Command | Does |
|---|---|
docker ps |
Running containers |
docker logs -f <id> |
Follow a container's output |
docker exec -it <id> bash |
A shell inside a running container (debugging) |
docker images |
Images and their sizes |
docker system prune |
Free disk space from old images and containers |
5. Add a vector database with Docker Compose¶
Real RAG apps need more than one container. Docker Compose starts them together from one file:
services:
api:
build: .
ports: ["8000:8000"]
env_file: .env
environment:
DATABASE_URL: postgresql://rag:rag@db:5432/rag # "db" is the service name below
depends_on:
db:
condition: service_healthy
db:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: rag
POSTGRES_PASSWORD: rag
POSTGRES_DB: rag
volumes: ["pgdata:/var/lib/postgresql/data"] # data survives restarts
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rag"]
interval: 5s
retries: 10
volumes:
pgdata:
docker compose up --build # build and start both; Ctrl+C to stop
docker compose up -d # in the background
docker compose logs -f api
docker compose down # stop (add -v to also delete the database volume)
Containers reach each other by service name (db:5432), not localhost. For Chroma instead, use the
chromadb/chroma image and connect with chromadb.HttpClient(host="chroma", port=8000).
6. Common errors¶
| Error | Cause | Fix |
|---|---|---|
| Browser can't reach the app | Server bound to 127.0.0.1, or no -p |
--host 0.0.0.0 and -p 8000:8000 |
ModuleNotFoundError in the container |
Package missing from requirements.txt |
Add it (pinned), rebuild |
AuthenticationError / key is None |
Keys not passed in | --env-file .env (or set variables on the platform) |
| Every build reinstalls everything | Code copied before requirements | Copy and install requirements first |
| Image is 3+ GB | torch with CUDA, or .venv copied |
.dockerignore; CPU-only torch wheels if you don't need a GPU |
| Can't reach Ollama on the host | localhost means the container itself |
Use http://host.docker.internal:11434 |
exec format error on a server |
Built on Apple Silicon (ARM), running on x86 | docker build --platform linux/amd64 ... |
Checklist¶
-
.dockerignoreexcludes.env, venvs, data and Git history. - Requirements are pinned and installed before the code is copied.
- Keys come from the environment at run time, never from the image.
- The app listens on
0.0.0.0and has a/healthendpoint. - The container runs as a non-root user.
More in Docker and deployment.