---
title: "How to Run Any Python App in Docker with Docker Compose"
description: "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."
date: 2026-07-28
categories: ["self-hosting"]
tags: ["docker","python","docker-compose"]
---

import YouTubeEmbed from "../../components/widgets/YouTubeEmbed.astro";
import Notice from "../../components/widgets/Notice.astro";
import ListCheck from "../../components/widgets/ListCheck.astro";
import Tabs from "../../components/widgets/Tabs.astro";
import Tab from "../../components/widgets/Tab.astro";
import Accordion from "../../components/widgets/Accordion.astro";
import Button from "../../components/widgets/Button.astro";

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

<ListCheck>
<ul>
<li>A VPS running Linux (Ubuntu or Debian). A Hetzner CX23 (EUR 4/mo) can run 3-5 small Python apps. [Hetzner Cloud VPS](https://go.bitdoze.com/hetzner) is my default. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) works as a budget alternative.</li>
<li>Docker and Docker Compose V2 installed. If you need a guide: [How To Install Docker & Docker Compose for Ubuntu ARM Systems](https://www.bitdoze.com/install-docker-ubuntu-arm/)</li>
<li>Basic familiarity with the terminal and a text editor.</li>
<li>Recommended: [Dockge](https://www.bitdoze.com/dockge-install/) for GUI-based Docker Compose management. Makes starting, stopping, and editing compose files much easier.</li>
<li>Recommended: A Cloudflare account with a domain (for free SSL via Tunnels).</li>
</ul>
</ListCheck>

<YouTubeEmbed
  url="https://www.youtube.com/embed/7Nu7r8y_bDA"
  label="How To Run Any Python App in Docker with Docker Compose"
/>

## 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:

```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.

<Notice type="warning" title="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.
</Notice>

**`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](https://www.bitdoze.com/docker-env-vars/).

**`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.

<Notice type="info" title="Want Faster Builds?">
<a href="https://go.bitdoze.com/uv-get-start">uv</a> 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:

```dockerfile
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](https://www.bitdoze.com/uv-get-start/).
</Notice>

## 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`:

```python
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:
- [NiceGUI For Beginners: Build a UI to Python App in 5 Minutes](https://www.bitdoze.com/nicegui-get-started/)
- [How To Add Multiple Pages to NiceGUI](https://www.bitdoze.com/nicegui-pages/)

### Streamlit example

Create `my-app/requirements.txt`:

```
streamlit
```

Create `my-app/main.py`:

```python
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](https://www.bitdoze.com/streamlit-deploy-vps-cloudflare/).

<Notice type="info" title="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](https://www.bitdoze.com/streamlit-vs-nicegui/).
</Notice>

## Create a Docker Compose file (`compose.yml`)

<Tabs>
<Tab name="NiceGUI">
```yaml
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: 10s
```
</Tab>
<Tab name="Streamlit">
```yaml
services:
  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: 30s
```
</Tab>
</Tabs>

### 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".

<Notice type="info" title="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`:

```yaml
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.
</Notice>

## Start the Docker Compose stack

```sh
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](https://www.bitdoze.com/updating-container-docker-compose/).

## Verify it works

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

**1. Check container status:**

```sh
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:**

```sh
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:**

```sh
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`.

<Notice type="success" title="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.
</Notice>

For more Docker commands you'll use regularly, see [Top 50+ Docker Commands You MUST Know](https://www.bitdoze.com/docker-commands/).

## Add new PIP packages

When your app needs a new dependency:

1. Add the package to `my-app/requirements.txt`.
2. Rebuild:

   ```sh
   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](https://www.bitdoze.com/updating-container-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.

<Notice type="info" title="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.
</Notice>

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

**Step 2: Create a tunnel:**

```sh
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`:

```yaml
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:**

```sh
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](https://www.bitdoze.com/cloudpanel-setup-dockge/). If you're using Streamlit specifically, [Deploy Streamlit on a VPS and Proxy to Cloudflare Tunnels](https://www.bitdoze.com/streamlit-deploy-vps-cloudflare/) has a more detailed walkthrough.

## Troubleshooting common issues

<Accordion label="Port already in use" group="troubleshooting" expanded="true">

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

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

**Fix:**

```sh
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"`).

</Accordion>

<Accordion label="Module not found" group="troubleshooting">

**Error:** `ModuleNotFoundError: No module named 'xyz'`

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

**Fix:**

```sh
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`.

</Accordion>

<Accordion label="Permission denied on volume mounts" group="troubleshooting">

**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:

```sh
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.

</Accordion>

<Accordion label="Container exits immediately" group="troubleshooting">

**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:**

```sh
docker compose logs web
```

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

</Accordion>

<Accordion label="Healthcheck always unhealthy" group="troubleshooting">

**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.

</Accordion>

For general cleanup commands when things go wrong, see [How to Cleanup All Docker Things](https://www.bitdoze.com/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:

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

Add this under the `web` service in `compose.yml`.

<Notice type="warning" title="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.
</Notice>

**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](https://www.bitdoze.com/docker-containers-home-server/).

<Button text="Explore More Docker Tutorials" link="/tag/docker/" variant="solid" color="blue" size="md" icon="arrow-right" />