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.

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.txtThe 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)
services:
web:
container_name: python-server
command: python main.py
build:
context: .
dockerfile: Dockerfile
volumes:
- ./my-app:/app
ports:
- "5021:8080"
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10sservices:
web:
container_name: python-server
command: streamlit run main.py --server.headless true
build:
context: .
dockerfile: Dockerfile
volumes:
- ./my-app:/app
ports:
- "5021:8501"
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8501')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30sKey compose directives explained
services:: Starts directly. Noversion: "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’sCMD. For NiceGUI it’spython 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 athttp://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’surllibinstead ofcurlbecausecurlis not installed inpython:3.12-slim. Once the healthcheck passes,docker compose psshows 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.txtThen 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 olddocker-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:
-
Add the package to
my-app/requirements.txt. -
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 5021This 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 xyzIf 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 webRead 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:
- Enter the container:
docker compose exec web bash - Test the healthcheck manually:
python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080')" - If that fails, check what port the app is actually listening on:
python -c "import socket; print(socket.gethostname())" - Make sure the healthcheck port in
compose.ymlmatches 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, andPYTHONUNBUFFERED. - Use
docker compose(space, not hyphen) — the olddocker-composeV1 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

