Learning paths / Containers / Containerize a real app

Recipe: containerize a Python app

Reading · 6 min · Module 8, lesson 3 of 647 min left in this module

Module 8 · Containerize a real appLesson 3 of 6

Goal: Containerize a Python app with a multi-stage, non-root Dockerfile and a .dockerignore, then prove it serves /healthz.

Key idea

Same checklist as the Node recipe. For Python, the build stage installs the dependencies into a virtual environment, one folder the runtime stage copies across whole. The app runs under gunicorn, a production server, as a user you create.

Get the app

git clone https://github.com/computesphere-samples/learn.git
cd learn/labs/containerize-python

A small Flask app with / and /healthz. Flask and gunicorn are pinned in requirements.txt. Already cloned it for the Node recipe? Run cd ../containerize-python instead.

.dockerignore

.venv
__pycache__
*.pyc
.env*
.git
Dockerfile
.dockerignore

A local .venv was built for your machine, and .env files hold secrets (lesson 3.4.4).

Dockerfile

# Stage 1: install the dependencies into a virtual environment.
FROM python:3.13-slim AS build
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Stage 2: a clean Python with the finished environment copied in.
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 app
COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" PYTHONUNBUFFERED=1 PORT=3000
WORKDIR /app
COPY app.py ./
USER app
EXPOSE 3000
CMD ["sh", "-c", "exec gunicorn --bind 0.0.0.0:${PORT} app:app"]

What each choice does:

  • The virtual environment at /opt/venv holds every installed package, so one COPY --from=build moves them all. Putting it first on PATH means gunicorn is found.
  • --no-cache-dir stops pip keeping its download cache in the layer.
  • Both stages use the same base, so packages built in stage 1 run in stage 2.
  • useradd creates user app, and USER app runs as it (lesson 3.4.3). The slim image has no such user ready-made.
  • PYTHONUNBUFFERED=1 makes log lines appear straight away in docker logs.
  • The shell form of CMD lets ${PORT} be filled in when the container starts, and exec hands over to gunicorn so it receives stop signals.

Build, run and check

docker build -t containerize-python:1.0.0 .
docker run -d --rm --name containerize-python -p 3000:3000 containerize-python:1.0.0
curl -i localhost:3000/healthz
HTTP/1.1 200 OK
Server: gunicorn
Content-Type: application/json
...
{"status":"ok"}

Then confirm the user, read the logs, and stop it:

docker image inspect containerize-python:1.0.0 --format '{{.Config.User}}'
docker logs containerize-python
docker stop containerize-python

The first prints app, and the logs include Listening at: http://0.0.0.0:3000. The image comes out at about 230 MB, most of it Python itself.

Check yourself

You change CMD to gunicorn --bind 127.0.0.1:3000 app:app. The container runs, but curl from your machine gets no answer. Why?