Bitdoze Logo

How to Run Any Python App in Docker with Docker Compose

Learn how to run any Python app in Docker with Docker Compose. Updated for 2026 with modern best practices, health checks, non-root users, and Cloudflare Tunnel SSL setup.

DragosDragos23 min read
How to Run Any Python App in Docker with Docker Compose

Running Python apps in Docker with Docker Compose is the quickest way to get from main.py to a reliable deployment on any VPS. This guide covers the full pipeline: .dockerignore, Dockerfile, compose.yml, start, verify, plus Cloudflare Tunnel SSL for free HTTPS. Updated for 2026 with Docker Compose V2 (no more hyphen), Python 3.13/3.14 availability, uv as a faster pip alternative, and Compose Watch for live reloading.

Whether you’re containerizing a NiceGUI dashboard, a Streamlit app, or any Python project, the pattern is the same. I use this setup on Hetzner VPS boxes running Dokploy for multiple small Python apps, and it works reliably.

Prerequisites

  • A VPS running Linux (Ubuntu or Debian). A Hetzner CX23 (EUR 4/mo) can run 3-5 small Python apps. Hetzner Cloud VPS is my default. Hostinger VPS works as a budget alternative.
  • Docker and Docker Compose V2 installed. If you need a guide: How To Install Docker & Docker Compose for Ubuntu ARM Systems
  • Basic familiarity with the terminal and a text editor.
  • Recommended: Dockge for GUI-based Docker Compose management. Makes starting, stopping, and editing compose files much easier.
  • Recommended: A Cloudflare account with a domain (for free SSL via Tunnels).

Project structure overview

Before creating any files, here’s what the final directory layout looks like:

my-python-app/
├── .dockerignore
├── Dockerfile
├── compose.yml
└── my-app/
    ├── requirements.txt
    └── main.py

Everything lives in one directory. The my-app/ subfolder contains your actual Python code and dependencies. The Docker files sit at the project root.

Create a .dockerignore file

Without a .dockerignore, Docker sends your entire project directory (including .git, __pycache__, .venv, .env) to the Docker daemon as build context. This slows builds and can leak secrets into the image.

Create .dockerignore in the project root:

**/__pycache__
**/.venv
**/.git
**/.env
**/.DS_Store
Dockerfile
compose.yml
README.md

That’s it. Five lines prevents most common context-bloat issues.

Create a Dockerfile

Here’s the updated, production-ready Dockerfile:

FROM python:3.12-slim

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

COPY ./my-app/requirements.txt /app
RUN pip install --no-cache-dir -r requirements.txt

COPY ./my-app /app

RUN addgroup --system app && adduser --system --group app
USER app

CMD ["python", "main.py"]

Understanding the Dockerfile instructions

FROM python:3.12-slim: Uses the slim variant (~130MB) instead of the full image (~1GB). For most Python apps, slim has everything you need. The full image is only necessary if you need build tools like gcc for native extensions.

Don't Use Alpine for Python

You might see python:3.12-alpine in other tutorials. Skip it. Alpine uses musl libc instead of glibc, which causes obscure build failures with Python packages that have C extensions (numpy, pandas, Pillow, cryptography). Build times are also longer because many packages need to compile from source. The extra ~80MB for slim saves hours of debugging.

WORKDIR /app: Sets the working directory inside the container. All subsequent commands run from here.

ENV PYTHONDONTWRITEBYTECODE=1: Prevents Python from writing .pyc files to disk. No reason to cache bytecode in a container image.

ENV PYTHONUNBUFFERED=1: Forces stdout and stderr to be unbuffered. Without this, docker compose logs might show delayed or missing output from your app. For more on Docker environment variables, see How to Use Environment Variables ARG and ENV in Docker.

COPY ./my-app/requirements.txt /app then RUN pip install --no-cache-dir -r requirements.txt: Copies requirements first, then installs dependencies. This ordering is intentional: Docker caches layers, so if only your application code changes (not dependencies), the pip install layer is reused from cache and rebuilds are fast. The --no-cache-dir flag keeps pip’s download cache out of the image.

COPY ./my-app /app: Copies the rest of your application code. This comes after pip install so code changes don’t trigger a full dependency reinstall.

RUN addgroup --system app && adduser --system --group app then USER app: Creates a non-root user and switches to it. Running containers as root is a security anti-pattern. If an attacker breaks out of the process, they have root on the container. The app user has minimal privileges.

CMD ["python", "main.py"]: The default command. Overridable in compose.yml if you need a different entrypoint for different services.

Want Faster Builds?

uv is a drop-in pip replacement that’s 10-100x faster for dependency resolution and installation. To use it in your Dockerfile, replace the pip install line with:

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN --mount=type=cache,target=/root/.cache/uv \
    uv pip install --system --no-cache -r requirements.txt

The cache mount means repeat builds skip downloading already-installed packages. For a full uv walkthrough, see Getting Started with uv: Setting Up Your Python Project in 2026.

Create a my-app directory with your Python scripts

Now create the application code. Here are two common examples.

NiceGUI example

Create my-app/requirements.txt:

nicegui

Create my-app/main.py:

from nicegui import ui

ui.label('Hello NiceGUI!')

ui.run()

NiceGUI defaults to port 8080. You can change it with ui.run(port=9000) if needed. Just make sure the port mapping in compose.yml matches.

For more on NiceGUI:

Streamlit example

Create my-app/requirements.txt:

streamlit

Create my-app/main.py:

import streamlit as st

st.title('Hello Streamlit!')
st.write('This is a minimal Streamlit app running in Docker.')

Streamlit uses port 8501 by default. Note: streamlit hello is a built-in demo command; real apps use streamlit run main.py.

For a full Streamlit + Cloudflare Tunnel deployment, see Deploy Streamlit on a VPS and Proxy to Cloudflare Tunnels.

NiceGUI vs Streamlit?

Both are solid for building Python web UIs quickly. NiceGUI gives you more control over layout and events; Streamlit is faster for data dashboards. Compare them in Streamlit vs. NiceGUI: Choose the Best Python Web Framework.

Create a Docker Compose file (compose.yml)

Key compose directives explained

  • services:: Starts directly. No version: "3" needed (that key is obsolete and ignored in Compose V2).
  • container_name: python-server: Gives the container a fixed name instead of an auto-generated one. Easier to reference in commands.
  • command:: Overrides the Dockerfile’s CMD. For NiceGUI it’s python main.py; for Streamlit, streamlit run main.py --server.headless true.
  • build:: Tells Compose to build the image from the local Dockerfile.
  • volumes: ./my-app:/app: Bind-mounts your source code into the container. Code changes on the host are reflected immediately without rebuilding.
  • ports: "5021:8080": Maps host port 5021 to the container port (8080 for NiceGUI, 8501 for Streamlit). Access the app at http://localhost:5021.
  • restart: unless-stopped: Restarts the container if it crashes, but not if you manually stop it. Good default for VPS-hosted apps.
  • healthcheck:: Tells Docker to periodically check if the app is actually responding. Uses Python’s urllib instead of curl because curl is not installed in python:3.12-slim. Once the healthcheck passes, docker compose ps shows the container as “healthy”.

Compose Watch for Development

Instead of bind mounts, you can use Docker Compose Watch for more granular file syncing. Add this to your compose.yml:

develop:
  watch:
    - action: sync
      path: ./my-app
      target: /app
      ignore:
        - __pycache__/
        - "*.pyc"
    - action: rebuild
      path: ./requirements.txt

Then run docker compose watch. Code changes sync instantly; dependency changes trigger an automatic rebuild. It’s optional — bind mounts still work fine — but Compose Watch gives you ignore patterns and different actions per path.

Start the Docker Compose stack

docker compose up -d --build
  • docker compose (with a space): The V2 command. The old docker-compose (hyphen) was removed in April 2025.
  • up -d: Starts containers in detached mode (background).
  • --build: Rebuilds the image if the Dockerfile changed.

To stop the stack: docker compose down.

For a full reference on managing containers after initial setup, see How To Update A Container With Docker Compose.

Verify it works

Don’t just assume it’s running. Verify:

1. Check container status:

docker compose ps

Expect to see Up in the STATUS column. After 10-30 seconds (once the healthcheck passes), it should also show healthy.

NAME             STATUS                   PORTS
python-server    Up (healthy)             0.0.0.0:5021->8080/tcp

2. Check logs:

docker compose logs -f web

For NiceGUI, look for something like NiceGUI is on http://0.0.0.0:8080. For Streamlit, You can now view your Streamlit app in your browser. Press Ctrl+C to stop following.

3. Test HTTP access:

curl -s -o /dev/null -w "%{http_code}" http://localhost:5021

Expect 200. If you get 000 or a connection refused, the app isn’t listening on the expected port.

4. Open in browser: navigate to http://<your-vps-ip>:5021.

Healthy!

Once the healthcheck passes, Docker marks the container as “healthy.” This matters because Docker’s restart policy and tools like Dokploy use health status to decide whether a container is actually working vs just running. An unhealthy container can be automatically restarted.

For more Docker commands you’ll use regularly, see Top 50+ Docker Commands You MUST Know.

Add new PIP packages

When your app needs a new dependency:

  1. Add the package to my-app/requirements.txt.

  2. Rebuild:

    docker compose up -d --build

The --build flag rebuilds the image with the updated requirements. If you’re using bind mounts and only the requirements changed, Docker’s layer cache means only the pip install step re-runs.

For a deeper dive on updating running containers, see How To Update A Container With Docker Compose.

Add a domain with SSL using Cloudflare Tunnels

Cloudflare Tunnels give you free SSL without opening ports on your firewall or buying a certificate. Your app stays behind the tunnel; only Cloudflare’s edge is exposed.

Already Have a Cloudflare Tunnel?

If you already have cloudflared running on your VPS, you just need to add a new hostname in the Cloudflare dashboard (Access → Tunnels → your tunnel → Configure) that points to http://localhost:5021. Skip to step 4.

Step 1: Install cloudflared on your VPS (or run it as a Docker container).

Step 2: Create a tunnel:

cloudflared tunnel create my-python-app

This generates a tunnel ID and credentials file.

Step 3: Configure the ingress rule. Create or edit ~/.cloudflared/config.yml:

tunnel: my-python-app
credentials-file: /root/.cloudflared/<tunnel-id>.json

ingress:
  - hostname: app.example.com
    service: http://localhost:5021
  - service: http_status:404

Replace <tunnel-id> with the actual ID from step 2. Replace app.example.com with your domain.

Step 4: Add the DNS CNAME. In the Cloudflare dashboard, add a CNAME record for app.example.com pointing to <tunnel-id>.cfargotunnel.com.

Step 5: Run the tunnel:

cloudflared tunnel run my-python-app

Your Python app is now accessible at https://app.example.com with a valid SSL certificate managed by Cloudflare.

For an alternative reverse-proxy approach, see Setup CloudPanel as Reverse Proxy with Docker and Dockge. If you’re using Streamlit specifically, Deploy Streamlit on a VPS and Proxy to Cloudflare Tunnels has a more detailed walkthrough.

Troubleshooting common issues

Port already in use

Error: bind: address already in use or port is already allocated

Cause: Another process is using port 5021 on the host.

Fix:

ss -tlnp | grep 5021

This shows what’s using the port. Either stop that process or change the host port in compose.yml (e.g., "5022:8080").

Module not found

Error: ModuleNotFoundError: No module named 'xyz'

Cause: The package isn’t in requirements.txt, or the image wasn’t rebuilt after adding it.

Fix:

docker compose exec web bash
pip list | grep xyz

If the package is missing, add it to requirements.txt and rebuild with docker compose up -d --build.

Permission denied on volume mounts

Error: PermissionError: [Errno 13] Permission denied

Cause: UID/GID mismatch between the host user and the container’s app user.

Fix: Check ownership on the host:

ls -ln my-app/

If files are owned by a UID other than 1000 (the app user’s UID in the Dockerfile), either change ownership on the host (chown -R 1000:1000 my-app/) or adjust the Dockerfile to match your host’s UID.

Container exits immediately

Error: Container shows Exited (1) in docker compose ps

Cause: Python traceback on startup — usually a syntax error in main.py or a missing dependency.

Fix:

docker compose logs web

Read the traceback. Common culprits: import errors, missing files (check volume mount paths), wrong command in compose.yml.

Healthcheck always unhealthy

Error: Container stays “starting” then goes to “unhealthy”

Cause: The healthcheck URL or port doesn’t match the app’s listening port.

Fix:

  1. Enter the container: docker compose exec web bash
  2. Test the healthcheck manually: python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080')"
  3. If that fails, check what port the app is actually listening on: python -c "import socket; print(socket.gethostname())"
  4. Make sure the healthcheck port in compose.yml matches the app’s port.

For general cleanup commands when things go wrong, see How to Cleanup All Docker Things.

Production hardening tips

Once your app works, a few extra steps make it production-ready:

Resource limits — prevent one container from starving others on a shared VPS:

deploy:
  resources:
    limits:
      cpus: '1'
      memory: 512M

Add this under the web service in compose.yml.

Running Multiple Containers?

Without resource limits, a Python app with a memory leak can consume all VPS RAM and crash other containers (or the host). Always set deploy.resources.limits on production VPS boxes. A Hetzner CX22 with 4GB RAM comfortably runs 3–5 small Python apps with 512MB limits each.

Non-root user — already covered in the Dockerfile section. Don’t skip it.

Healthcheck — already covered. Essential for Docker’s restart policy to work correctly. Without it, Docker only knows if the process is running, not if the app is actually serving requests.

Backups — if your app uses a database or writes persistent data, mount a dedicated volume for it and back it up to S3-compatible storage. The bind mount (./my-app:/app) is for source code; don’t store data there.

Multi-stage builds — if your app needs gcc or other build tools for native extensions (numpy, cryptography, etc.), use a multi-stage build to compile in one stage and copy only the runtime artifacts to the final slim image. Keeps the production image lean.

Conclusions

Running Python apps in Docker with Docker Compose is straightforward once you have the right patterns. Here’s what to take away:

  • Use python:3.12-slim (not full, not Alpine) as your base image.
  • Always include .dockerignore, non-root user, PYTHONDONTWRITEBYTECODE, and PYTHONUNBUFFERED.
  • Use docker compose (space, not hyphen) — the old docker-compose V1 is gone.
  • Drop the version: "3" from your compose files — it’s obsolete.
  • Add healthchecks so Docker knows if your app is actually working.
  • Cloudflare Tunnels give you free SSL without opening firewall ports.
  • Set resource limits when running multiple containers on one VPS.

Start with the simple NiceGUI example above, verify it works, then iterate. The total cost for a VPS + Docker + Cloudflare Tunnels stack is under €5/month — hard to beat for a self-hosted Python app with HTTPS.

For more Docker projects to self-host, see Docker Containers for Your Home Server.

Explore More Docker Tutorials