---
title: "Deploy Streamlit on a VPS and Proxy to Cloudflare Tunnels"
description: "Deploy Streamlit on a VPS and proxy it through Cloudflare Tunnels. Step-by-step guide with config.toml, PM2/systemd, venv setup, and troubleshooting for production."
date: 2026-07-16
categories: ["web-development"]
tags: ["streamlit","python","cloudflare"]
---

import Button from "../../components/widgets/Button.astro";
import { Picture } from "astro:assets";
import Notice from "../../components/widgets/Notice.astro";
import ListCheck from "../../components/widgets/ListCheck.astro";
import Accordion from "../../components/widgets/Accordion.astro";
import Tabs from "../../components/widgets/Tabs.astro";
import Tab from "../../components/widgets/Tab.astro";
import imag1 from "../../assets/images/24/01/cloudflare_tunnels.png";
import imag2 from "../../assets/images/24/01/cloudflare-strimlit-tunnel.jpeg";

import YouTubeEmbed from "../../components/widgets/YouTubeEmbed.astro";

[Streamlit](https://streamlit.io/) is a Python framework for building interactive web apps with minimal code. You can use it to build data dashboards, machine learning demos, internal tools, anything where a Python backend and a reactive UI make sense. I've used it for quick data exploration apps and internal dashboards where spinning up a full React frontend would be overkill.

Streamlit has a simple syntax: Python functions and decorators define your layout and logic. `st.title` adds a heading, `st.dataframe` renders a pandas table, `st.slider` adds interactive controls. The hot-reloading feature updates your app as you edit code, no browser refresh needed. You write Python, and Streamlit handles the web server, the WebSocket communication, and the reactive UI updates.

> In case you are interested in checking the best Python web frameworks see: [Best Python Web Frameworks](https://www.bitdoze.com/best-python-web-frameworks/).
> If you want to see how Streamlit stacks up against another Python UI framework, check the [Streamlit vs NiceGUI comparison](https://www.bitdoze.com/streamlit-vs-nicegui/).

If you want to deploy Streamlit or any Python app to Docker, you can check: [How To Run Any Python App in Docker with Docker Compose](https://www.bitdoze.com/docker-run-python/)

This guide walks you through how to deploy Streamlit on a VPS and proxy it through Cloudflare Tunnels. The total cost is around $4 to $7/month for the VPS. Cloudflare Tunnels are free. You get HTTPS, DDoS protection, and no inbound ports open on your server.

## Streamlit Cloud free deployment

[Streamlit Community Cloud](https://streamlit.io/cloud) is the zero-config option. Connect a GitHub repo, and it deploys your app on a `*.streamlit.io` URL. No server to manage, no SSH keys, no process managers. You push to GitHub, and the app updates automatically.



<YouTubeEmbed
  url="https://www.youtube.com/embed/M7ZMSX6DA7E"
  label="Streamlit Deploy video"
/>
It's fine for demos and prototypes, but the limits are real:

- 1 GB of memory. Complex or data-heavy apps will hit this ceiling fast.
- No custom domains. Your app lives on a random `*.streamlit.io` URL.
- No scaling. You get one container with 1 GB, take it or leave it.
- Paid tiers are permanently gone. Streamlit is developing a joint product with Snowflake instead.
- The free tier now allows one private repo (previously it was public repos only).
- Cold starts: Community Cloud spins down idle apps. First load after inactivity can take 10 to 30 seconds.

If you need a custom domain, more memory, faster cold starts, or production reliability, read on.

## How to deploy Streamlit on your VPS and proxy through Cloudflare Tunnels

The architecture is straightforward:

1. Your VPS runs Streamlit locally on `localhost:8501`
2. `cloudflared` creates an encrypted tunnel to Cloudflare's edge network
3. Your domain resolves through Cloudflare. You get HTTPS, DDoS protection, firewall, and no inbound ports open on the VPS

The tunnel connects outward from your server. Nobody can probe your VPS ports because nothing is listening on a public interface. That's the main security win over a traditional reverse proxy setup.

## Prerequisites

Before you start, make sure you have:

<ListCheck>
<ul>
  <li>A VPS running Ubuntu 22.04 or 24.04 (minimum 1 vCPU, 2 GB RAM recommended)</li>
  <li>A domain name added to your Cloudflare account (free plan works)</li>
  <li>A GitHub repository containing your Streamlit app with a <code>requirements.txt</code></li>
  <li>SSH access to your VPS (root or sudo user)</li>
  <li>Python 3.10+ (comes with Ubuntu 22.04+)</li>
</ul>
</ListCheck>

If you don't have a VPS yet, you can [set up a VPS for development](https://www.bitdoze.com/vps-ai-coding-setup/) or check the section below.

### 1. Create a VPS

Choose a VPS with at least 2 GB RAM. Ubuntu 22.04 or 24.04 as the OS. Cloudflare Tunnels is free. The only recurring cost is the VPS itself, which starts around $4 to $5/month.

I use [Hetzner](https://go.bitdoze.com/hetzner) for most of my servers. Good performance, EU locations, fair pricing. [Hostinger](https://go.bitdoze.com/hostinger-vps) is a solid budget alternative. You can [compare VPS providers](https://www.bitdoze.com/digitalocean-vs-vultr-vs-hetzner/) to see what fits your needs.

### 2. Update and add swap to the VPS

Once you have SSH access, update the system and add swap space as a safety net. Swap acts as virtual memory when your physical RAM is full. It won't save a badly written app, but it prevents an OOM kill when your Streamlit app loads a large dataframe or model into memory.

```bash
# Update the system
sudo apt update && sudo apt -y upgrade

# Add 2 GB of swap space
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
```

Verify swap is active:

```bash
sudo swapon --show
```

You should see `/swapfile` listed with its size. If you don't see it, check that `fallocate` created the file correctly. Some VPS providers or filesystems don't support `fallocate`, in which case use `dd if=/dev/zero of=/swapfile bs=1M count=2048` instead.

<Notice type="info">
Swap prevents OOM kills on small VPS instances, but it's 100x slower than RAM. Monitor with `htop` the first few days. If your app consistently uses swap, upgrade the VPS. Swap is not a substitute for RAM. You can [check which processes use swap](https://www.bitdoze.com/swap-usage-linux/) to spot problems early.
</Notice>

### 3. Set up a Python virtual environment and install Streamlit

Don't install Streamlit globally with `pip3 install streamlit`. It can conflict with system Python packages and makes dependency management painful. Use a virtual environment instead.

```bash
# Install Python venv support (as root)
sudo apt install python3-pip python3-venv
```

Create a dedicated user for the app (we'll lock it down further in the next step):

```bash
sudo useradd -m streamlit
```

Now switch to the `streamlit` user and create the venv:

```bash
sudo su - streamlit

# Create and activate a virtual environment
python3 -m venv /home/streamlit/venv
source /home/streamlit/venv/bin/activate

# Install Streamlit inside the venv
pip install streamlit
```

Verify the installation:

```bash
streamlit --version
```

You should see version 1.59.x or later. Streamlit requires Python 3.10+ since version 1.51.0.

<Notice type="info">
If you prefer faster dependency resolution, you can use [uv to set up a Python project](https://www.bitdoze.com/uv-get-start/) as an alternative to pip + venv. The venv approach works fine for a single-app deployment, but uv shines when managing multiple Python projects.
</Notice>

### 4. Create a dedicated user for your Streamlit app

We already created the `streamlit` user in the previous step. Running the app under a dedicated non-root user means that if the app is ever compromised, the attacker doesn't have root access to the server. This is basic least-privilege. No reason to skip it.

The user's home is `/home/streamlit`, and that's where everything will live: the virtual environment, the app code, and the config files. Keeping it all under one user's home directory makes backups and permissions straightforward.

If you need to grant another admin access to manage the app, add them to the `streamlit` group rather than sharing the user's password:

```bash
sudo usermod -aG streamlit your-username
```

### 5. Get the Streamlit app on your VPS

You need to get your app code onto the VPS. The easiest way is `git clone` from your GitHub repository. If your repo is private, you'll need to set up an SSH key or a personal access token. GitHub has docs for both.

Clone your repository as the `streamlit` user:

```bash
sudo su - streamlit

# Create a directory for the app
mkdir -p $HOME/streamlit-app && cd $HOME/streamlit-app

# Clone your repository
git clone https://github.com/username/repo-name

cd repo-name
```

Verify the clone worked and your app file is there:

```bash
ls -la
# You should see app.py (or whatever your main file is called)
```

If your repo is private and you used HTTPS, Git will prompt for credentials. For automated deploys later, set up SSH keys instead:

```bash
ssh-keygen -t ed25519 -C "deploy-key"
cat ~/.ssh/id_ed25519.pub
# Add this as a deploy key in your GitHub repo settings
```

### 6. Install requirements with pip

Your app likely depends on packages beyond Streamlit itself: pandas, matplotlib, plotly, scikit-learn, etc. These should be listed in a `requirements.txt` file in your repo. If your repo doesn't have one, create it first:

```bash
# On your local machine (not the VPS), in your project directory:
pip freeze > requirements.txt
```

Make sure your venv is activated (you should see `(venv)` in your prompt), then install:

```bash
# With the venv activated:
pip install -r requirements.txt
```

This installs everything your app needs in the isolated venv. If a dependency fails to build (common with packages that need C libraries like `psycopg2` or `lxml`), install the system library first:

```bash
# Example: for psycopg2
sudo apt install libpq-dev

# Example: for lxml
sudo apt install libxml2-dev libxslt1-dev
```

Verify streamlit and your key dependencies are installed:

```bash
pip list | grep streamlit
pip list | grep pandas
```

### 7. Configure Streamlit for production — `.streamlit/config.toml`

This is the step most guides skip, and it's the reason deployments behind Cloudflare Tunnels break.

<Notice type="warning">
Without `enableCORS = false` and `enableXsrfProtection = false`, your Streamlit app will show "Connection error" or hang on "Please wait..." behind Cloudflare Tunnels. This is the #1 deployment mistake.
</Notice>

Create a `.streamlit/config.toml` file inside your app directory:

```bash
mkdir -p /home/streamlit/streamlit-app/repo-name/.streamlit
cat > /home/streamlit/streamlit-app/repo-name/.streamlit/config.toml << 'EOF'
[server]
# Run headless (no browser auto-open, no email prompt)
headless = true

# Bind to localhost only — Cloudflare Tunnel connects locally
address = "localhost"
port = 8501

# Disable CORS/XSRF when behind a reverse proxy/tunnel
# Cloudflare Tunnel handles HTTPS; the connection is local
enableCORS = false
enableXsrfProtection = false

# WebSocket keep-alive — prevents "Connection error" disconnects
websocketPingInterval = 30

[browser]
# Set to your actual domain so Streamlit generates correct URLs
serverAddress = "app.example.com"
serverPort = 443
EOF
```

Replace `app.example.com` with your actual domain name. The `[browser]` section tells Streamlit what URL to generate for the client — without it, the browser may try to connect to `localhost` instead of your domain.

Here's what each setting does:

- **`headless = true`** — Disables the browser auto-open and the email prompt that Streamlit shows by default. Essential for server deployments where there's no desktop browser.
- **`address = "localhost"`** — Streamlit only listens on the loopback interface. This is important: even without UFW (step 11), the app isn't accessible from the internet. Cloudflare Tunnel connects locally, so this is all you need.
- **`enableCORS = false`** — Cross-Origin Resource Sharing. When your domain is `app.example.com` but Streamlit thinks it's running on `localhost`, the browser's CORS policy blocks the WebSocket connection. Setting this to `false` tells Streamlit to trust the proxy.
- **`enableXsrfProtection = false`** — Same reasoning. The XSRF token validation fails when the request comes through the tunnel because the origin doesn't match. Disabling it is safe here because Cloudflare Tunnel already provides the security layer.
- **`websocketPingInterval = 30`** — Sends a ping every 30 seconds to keep the WebSocket connection alive through the tunnel. Without this, Cloudflare may close idle connections, causing the "Connection error" message.

These settings work with both the legacy Tornado backend and the new Starlette/Uvicorn backend (Streamlit 1.57.0+). The Starlette migration was a major change under the hood, but the config flags are the same.

### 8. Run and verify the app

Before wiring up a process manager, test that Streamlit starts correctly:

```bash
# As the streamlit user, with venv activated:
source /home/streamlit/venv/bin/activate
streamlit run /home/streamlit/streamlit-app/repo-name/app.py
```

In another terminal, verify the health endpoint:

```bash
curl -s http://localhost:8501/_stcore/health
```

You should get `ok` back. If you get "Connection refused", check that `address = "localhost"` and `port = 8501` match in your `config.toml`.

<Notice type="info">
Streamlit exposes `/_stcore/health` which returns HTTP 200 when the app is running. Bookmark this endpoint — it's your go-to verification command for any deployment.
</Notice>

Once verified, stop the running app (`Ctrl+C`) and move on to setting up a process manager.

### 9. Set up a process manager — PM2 or systemd

Your SSH session will end, and without a process manager, Streamlit dies with it. You need something that keeps it running in the background and restarts it on crash.

Two good options: PM2 (feature-rich, requires Node.js) or systemd (already on your Linux system, zero extra dependencies). Pick whichever you prefer — both work.

<Tabs>
<Tab name="Option A: PM2">

<Notice type="info">
PM2 is a Node.js tool managing a Python process. If you'd rather not install Node.js just for this, switch to the systemd tab — it has fewer moving parts.
</Notice>

**Install Node.js 22 LTS** (as root):

```bash
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
```

**Install PM2 globally:**

```bash
sudo npm install pm2@latest -g
```

For more PM2 details, see the [complete guide on managing applications with PM2](https://www.bitdoze.com/pm2-manage-apps/).

**Start Streamlit with PM2** (as the `streamlit` user):

```bash
sudo su - streamlit
source /home/streamlit/venv/bin/activate

pm2 start '/home/streamlit/venv/bin/streamlit run /home/streamlit/streamlit-app/repo-name/app.py' \
  --name my-streamlit-app
```

Or use a PM2 ecosystem file for cleaner config:

```bash
cat > /home/streamlit/ecosystem.config.js << 'EOF'
module.exports = {
  apps: [{
    name: 'my-streamlit-app',
    script: '/home/streamlit/venv/bin/streamlit',
    args: 'run /home/streamlit/streamlit-app/repo-name/app.py',
    interpreter: 'none',
    env: {
      PATH: '/home/streamlit/venv/bin:' + process.env.PATH
    }
  }]
};
EOF

pm2 start /home/streamlit/ecosystem.config.js
```

**Set PM2 to start on boot:**

```bash
# As the streamlit user:
pm2 startup systemd
# Copy and run the command it prints (this runs as root)
pm2 save
```

**Verify:**

```bash
pm2 list
```

Your app should show as "online". Check logs with `pm2 logs my-streamlit-app`.

PM2 stores logs in `~/.pm2/logs/`. Over time these can grow — set up log rotation:

```bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7
```

</Tab>
<Tab name="Option B: systemd">

systemd is already on every Ubuntu system. No extra packages to install, no Node.js dependency. Fewer moving parts.

**Create a service file** (as root):

```bash
sudo cat > /etc/systemd/system/streamlit.service << 'EOF'
[Unit]
Description=Streamlit App
After=network.target

[Service]
User=streamlit
WorkingDirectory=/home/streamlit/streamlit-app/repo-name
ExecStart=/home/streamlit/venv/bin/streamlit run app.py --server.port=8501 --server.address=localhost --server.headless=true
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF
```

**Enable and start the service:**

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now streamlit
```

**Verify:**

```bash
sudo systemctl status streamlit
```

You should see `active (running)`. If it says `failed`, check the logs for the error:

```bash
journalctl -u streamlit -n 50 --no-pager
```

Common issues: wrong path to the venv's streamlit binary, or the app file doesn't exist at the specified `WorkingDirectory`.

To follow logs in real time:

```bash
journalctl -u streamlit -f
```

systemd handles log rotation automatically via journald, so you don't need to set that up separately.

</Tab>
</Tabs>

### 10. Create a Cloudflare Tunnel and install cloudflared

Cloudflare Tunnels expose your app to the internet without opening any ports. The tunnel connects outward from your VPS to Cloudflare's edge. You get HTTPS, DDoS protection, and WAF rules for free.

**Sign up for Cloudflare** and add your domain name. Point your domain's nameservers to Cloudflare's DNS. Follow the instructions on Cloudflare's dashboard.

Go to **Zero Trust** > **Networks** > **Tunnels** and create a tunnel. After you give it a name, you'll get an install command:

<Picture
  src={imag1}
  alt="Cloudflare Zero Trust dashboard showing tunnel creation with install command for cloudflared"
/>

**Option 1: Install from Cloudflare dashboard** (copy the command shown):

```bash
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb &&
sudo dpkg -i cloudflared.deb &&
sudo cloudflared service install <token>
```

Replace `<token>` with the actual token from the Cloudflare dashboard.

**Option 2: Install from Cloudflare's apt repository** (better for ongoing updates):

```bash
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install cloudflared
```

With the apt repository, updating is just `sudo apt update && sudo apt upgrade cloudflared`. The `.deb` method works but requires manually downloading new versions each time.

After installing cloudflared with the dashboard token, verify the service is running:

```bash
sudo systemctl status cloudflared
sudo systemctl enable cloudflared
```

The `service install` command from the dashboard already registers cloudflared as a systemd service, but `enable` ensures it starts on boot.

Now configure the public hostname in the Cloudflare dashboard. Point your domain (e.g., `app.example.com`) to `http://localhost:8501`:

<Picture
  src={imag2}
  alt="Cloudflare Tunnel public hostname configuration pointing domain to localhost port 8501 for Streamlit"
/>

After saving, visit `https://app.example.com` in your browser. If you see the Streamlit app loading, the tunnel is working. The Cloudflare dashboard should show the tunnel as "Healthy."

For more on self-hosting with Cloudflare Tunnels, see [how to self-host Cloudreve with Docker and Cloudflare Tunnels](https://www.bitdoze.com/cloudreve-docker-setup/).

### 11. Configure UFW firewall (defense in depth)

Since Cloudflare Tunnel means no inbound ports are needed, lock down the VPS with UFW. Even if Streamlit accidentally binds to a public interface instead of localhost, UFW blocks external access. Defense in depth — don't rely on a single layer.

<Notice type="warning">
Make sure SSH (port 22) is allowed before enabling UFW, or you'll lock yourself out of the VPS. Double-check with `sudo ufw status` before `sudo ufw enable`.
</Notice>

```bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw enable
```

Verify:

```bash
sudo ufw status verbose
```

Only port 22 should be listed as allowed. Everything else is denied. If you later need to expose another service (say, a second Streamlit app on port 8502 through a separate tunnel), you still don't need to open that port — the tunnel connects locally.

For a more robust setup with intrusion detection and automatic banning of brute-force IPs, you can [secure your VPS with CrowdSec](https://www.bitdoze.com/crowdsec-secure-server/) on top of UFW.

## Troubleshooting common issues

<Accordion label='"Connection error" / WebSocket stuck on "Please wait..."' group="troubleshooting" expanded="true">
**Cause:** Missing `enableCORS = false` and `enableXsrfProtection = false` in `.streamlit/config.toml`.

**Fix:** Add both settings to your `config.toml` (see step 7), restart Streamlit, and clear your browser cache. Also verify that `browser.serverAddress` is set to your actual domain, not `localhost`.
</Accordion>

<Accordion label="Streamlit loads but widgets don't respond" group="troubleshooting">
**Cause:** WebSocket proxy issue. Cloudflare Tunnel supports WebSockets by default, but the ping interval may need tuning.

**Fix:** Set `server.websocketPingInterval = 30` in `config.toml`. In the Cloudflare dashboard, under your tunnel's public hostname HTTP settings, set `connectionTimeout` to 300 seconds for long-running operations.
</Accordion>

<Accordion label="App crashes on large file uploads" group="troubleshooting">
**Cause:** Default upload limit is 200 MB, but the VPS may not have enough memory.

**Fix:** Increase `server.maxUploadSize` in `config.toml`. Ensure swap is enabled (step 2). For very large files, consider S3-based upload instead of in-memory processing.
</Accordion>

<Accordion label='Cloudflared shows "version outdated"' group="troubleshooting">
**Cause:** Installed via `.deb` download instead of the apt repository.

**Fix:** If using the apt repo: `sudo apt update && sudo apt upgrade cloudflared`. If using the `.deb` method: re-download and `sudo dpkg -i cloudflared.deb`.
</Accordion>

<Accordion label="After Streamlit 1.57.0+ upgrade — auth or session issues" group="troubleshooting">
**Cause:** Streamlit 1.57.0 migrated from Tornado to Starlette/Uvicorn. Some regressions in auth cookie persistence and CORS behavior were reported in 1.57.x.

**Fix:** Ensure you're on Streamlit 1.58.0+ which fixed most regressions. If using `st.login()` for OIDC authentication, test after any Streamlit version upgrade.
</Accordion>

## Alternative: Docker deployment

Docker is now the officially recommended deployment method by Streamlit. If you already use Docker for other services on your VPS, this is the cleaner path — it handles dependency isolation, restart policies, and removes the need for PM2 or systemd. The tradeoff is Docker itself as a dependency and a bit more complexity for debugging (container logs instead of local files).

Here's a minimal Dockerfile:

```dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y build-essential curl git && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip3 install -r requirements.txt
COPY . .
EXPOSE 8501
HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
```

Build and run:

```bash
docker build -t my-streamlit-app .
docker run -d -p 8501:8501 --name my-streamlit-app --restart unless-stopped my-streamlit-app
```

You still need the `.streamlit/config.toml` with CORS/XSRF disabled if proxying through Cloudflare Tunnels. Either copy it into the Docker image during build or mount it as a volume:

```bash
docker run -d -p 8501:8501 \
  -v /home/streamlit/streamlit-app/repo-name/.streamlit:/app/.streamlit \
  --name my-streamlit-app \
  --restart unless-stopped \
  my-streamlit-app
```

With Docker Compose, you can pin the version and add resource limits:

```yaml
version: "3.8"
services:
  streamlit:
    build: .
    ports:
      - "8501:8501"
    volumes:
      - ./.streamlit:/app/.streamlit
    restart: unless-stopped
    mem_limit: 1g
```

The `mem_limit` prevents a runaway app from eating all your VPS RAM — something to watch for if your app loads large datasets.

Verify the container is healthy:

```bash
docker ps
curl -s http://localhost:8501/_stcore/health
```

For a complete Docker + Python guide, see [How To Run Any Python App in Docker with Docker Compose](https://www.bitdoze.com/docker-run-python/).

## Keeping your deployment updated

Once the app is running, you'll need to update three things over time: the app code, Streamlit itself, and cloudflared. Here's the routine:

**Update the app code:**

```bash
sudo su - streamlit
cd /home/streamlit/streamlit-app/repo-name
git pull
```

If your `requirements.txt` changed, activate the venv and reinstall:

```bash
source /home/streamlit/venv/bin/activate
pip install -r requirements.txt
```

Then restart the process manager:

```bash
# PM2
pm2 restart my-streamlit-app

# systemd
sudo systemctl restart streamlit
```

**Update Streamlit itself:**

```bash
sudo su - streamlit
source /home/streamlit/venv/bin/activate
pip install --upgrade streamlit
```

Restart after upgrading.

**Update cloudflared** (if using apt repo):

```bash
sudo apt update && sudo apt upgrade cloudflared
```

**Monitor logs:**

```bash
# PM2
pm2 logs my-streamlit-app

# systemd
journalctl -u streamlit -f
```

Consider setting up [Uptime Kuma for monitoring](https://www.bitdoze.com/install-uptime-kuma/) — point it at `https://app.example.com/_stcore/health` to get alerted if the app goes down.

**Backup:** Your app code lives in Git (safe). But `.streamlit/config.toml` and any data files on the VPS should be backed up separately. If you're running multiple apps, each needs a different port (8501, 8502, etc.) and separate Cloudflare Tunnel hostname entries.

<Notice type="info">
Cloudflare Tunnels are free. Your only recurring cost is the VPS (~$4–7/month). No bandwidth charges, no per-request fees.
</Notice>

## Conclusion

You now have a production-ready Streamlit deployment: a dedicated user with a virtual environment, a proper `config.toml` for Cloudflare Tunnel compatibility, a process manager to keep it alive, and a locked-down firewall. The total cost is $4–7/month for the VPS — Cloudflare Tunnels, HTTPS, DDoS protection, and the WAF are all free.

The same pattern works for other Python web frameworks like Flask, FastAPI, or Gradio. The key pieces are always the same: bind to localhost, use a process manager, proxy through Cloudflare Tunnel, lock down the firewall.

If you need to restrict access to your app, Streamlit 1.42.0+ added `st.login()` and `st.logout()` for native OIDC authentication. You can use this with Google or any OIDC provider to gate access without building a custom auth layer. For internal tools that shouldn't be public, this is the first thing I'd set up after the basic deployment is working.

<Button text="Deploy Python Apps with Docker" link="/docker-run-python/" variant="outline" color="blue" size="md" icon="arrow-right" />