Deploying a Python AI Service with Docker
What a Container Actually Solves
"It works on my machine" is a real engineering problem, not a joke: your laptop has a specific Python version, specific system libraries, and specific package versions installed. The server that eventually runs your OpenAI SDK application may have none of those things in the same configuration. A container packages your application together with its exact runtime environment — the Python interpreter, system libraries, and pinned dependencies from Lesson 1 — into a single artifact that runs identically wherever a container runtime is available.
It helps to be precise about two terms that are often used interchangeably. An image is the packaged, immutable artifact: a set of filesystem layers plus metadata describing how to run it. A container is a running instance of an image — the same relationship as a class and an object. You build one image and can run many containers from it, each an isolated process with its own filesystem view, but all sharing the same underlying code and dependencies.
This matters for AI services specifically because OpenAI SDK applications typically have few system-level dependencies (mostly pure-Python packages plus a TLS-capable HTTP stack), which makes them straightforward to containerize compared to, say, a service with heavy native extensions — but the same discipline around reproducibility from Lesson 1 (pinned versions) is what makes the resulting image trustworthy.
Writing a Dockerfile
A Dockerfile is a plain-text set of instructions describing how to build an image, layer by layer. Here is a realistic Dockerfile for a FastAPI application that wraps the OpenAI SDK:
# --- build stage ---
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# --- runtime stage ---
FROM python:3.12-slim
RUN groupadd --system app && useradd --system --gid app app
WORKDIR /app
COPY --from=builder /root/.local /home/app/.local
COPY . .
ENV PATH=/home/app/.local/bin:$PATH \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz', timeout=3)" || exit 1
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
Reading this line by line explains why each part is there:
FROM python:3.12-slim AS builder starts a build stage from a specific, pinned Python base image. The slim variant is a smaller Debian-based image with just enough to run Python — you generally do not want the full python:3.12 image (with build tools and documentation you will never use) in something you deploy repeatedly. Pinning 3.12 rather than using python:latest is the same reproducibility principle from Lesson 1: latest can silently change to a newer Python version on a future build and break something.
COPY requirements.txt . followed by RUN pip install ... before COPY . . copies the rest of the application is deliberate, not accidental ordering. Docker caches each layer, and a layer is only rebuilt if its inputs change. Because requirements.txt changes far less often than application code, this ordering means that editing a Python file and rebuilding the image reuses the cached dependency-installation layer instead of reinstalling every package from scratch — turning a multi-minute rebuild into a few seconds.
The multi-stage build — a builder stage followed by a second, clean FROM python:3.12-slim stage that only copies the installed packages (COPY --from=builder /root/.local /home/app/.local) — exists to keep the final image small and clean. The builder stage may pull in compiler toolchains for packages with native extensions; none of that needs to exist in the image that actually runs in production. Only the installed packages and your application code make it into the final image.
RUN groupadd ... && useradd ... followed later by USER app switches the container to run as a non-root, unprivileged user instead of the default root user. If an attacker manages to exploit a vulnerability in your application or one of its dependencies, running as a non-root user limits what they can do inside the container — this is a defense-in-depth measure, not a guarantee, but it costs nothing and is considered a baseline requirement for production containers.
ENV PYTHONUNBUFFERED=1 disables Python's stdout buffering. Without it, print() output and logging can be held in a buffer and not flushed to the container's log stream promptly, which makes logs appear delayed or, in a crash, appear not at all. This one line is a very common cause of "my container's logs are empty" confusion when omitted.
EXPOSE 8000 documents which port the container listens on; it does not by itself publish the port to the host — that happens with docker run -p 8000:8000 ... or the equivalent orchestrator configuration. It is metadata that tools and humans reading the Dockerfile rely on.
HEALTHCHECK tells the container runtime how to determine whether the running container is actually healthy, beyond just "the process is still running." Lesson 4 in this unit covers health checks in much more depth — including why the endpoint it hits (/healthz) needs to be cheap and side-effect-free.
CMD [...] is the default command run when a container starts from this image, written in exec form (a JSON array) rather than as a plain shell string. Exec form runs the command directly as PID 1 without an intermediate shell, which means signals like SIGTERM (sent when the orchestrator wants to stop the container gracefully) reach your application directly instead of being swallowed by a shell process wrapping it.
The .dockerignore File
Just as .gitignore excludes files from version control, .dockerignore excludes files from the build context — everything sent to the Docker daemon when you run docker build. Without it, a COPY . . instruction copies everything in your project directory into the image, including things that should never be there.
.git
.env
.env.*
__pycache__/
*.pyc
.pytest_cache/
venv/
.venv/
*.md
tests/
Excluding .env and .env.* here is not optional — it is a direct consequence of Lesson 2's rule that secrets belong in environment variables injected at runtime, never baked into an image. An image is typically stored in a registry that multiple people and systems can pull; anything copied into it should be treated as effectively public within your organization. Excluding .git and tests/ keeps the image smaller and avoids shipping your commit history or test fixtures into a production artifact that has no use for them.
Building and Running the Image
docker build -t my-ai-service:1.0.0 .
docker run --rm -p 8000:8000 \
-e OPENAI_API_KEY="$OPENAI_API_KEY" \
-e APP_ENV=production \
my-ai-service:1.0.0
docker build -t my-ai-service:1.0.0 . builds the image and tags it with a name and version. Tagging with a specific version (1.0.0) rather than only latest is what makes rollbacks possible later — if a new version misbehaves in production, you can redeploy the exact previous image by tag rather than hoping "latest" still points at something known-good.
docker run ... -e OPENAI_API_KEY="$OPENAI_API_KEY" passes the secret in as an environment variable at run time, read from the host's environment, not written anywhere inside the Dockerfile or the image. This is the concrete mechanism behind the principle from Lesson 1 and Lesson 2: the image itself contains no secrets and is safe to store in a registry; the secret is supplied only at the moment a container starts. Lesson 5 covers how this looks in an actual cloud deployment, where the secret usually comes from a managed secret store rather than a shell variable.
Common Mistakes
Using FROM python:latest or omitting a version tag entirely. This makes builds non-reproducible: the same Dockerfile can produce a different Python version next month, silently. Always pin the base image tag.
Baking secrets into the image with ENV OPENAI_API_KEY=sk-... in the Dockerfile. Anyone who can pull the image (or inspect its layer history) can extract the secret, and it persists in the image even if a later layer overwrites the environment variable. Secrets belong in docker run -e or a platform secret store, never in the Dockerfile.
Running the application as root inside the container. This is the default if you never add a USER instruction, and it is unnecessary risk for essentially zero benefit in the vast majority of applications, including OpenAI SDK services with no need for elevated privileges.
Best Practices
Use multi-stage builds to keep the final runtime image free of build-only tooling, reducing both image size and attack surface.
Order Dockerfile instructions from least to most frequently changing (base image, then dependencies, then application code) so Docker's layer cache does the most work for you on incremental rebuilds.
Pin both the base image tag and your Python dependency versions, consistent with the reproducibility principle established in Lesson 1 — a container is only as reproducible as the artifacts it is built from.
Always define a HEALTHCHECK (or the equivalent orchestrator-level probe) rather than relying solely on "the process hasn't crashed" — Lesson 4 explains why a running process is not the same thing as a healthy one.