Bitdoze Logo

Deploy Streamlit on a VPS and Proxy to Cloudflare Tunnels

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.

DragosDragos31 min read
Deploy Streamlit on a VPS and Proxy to Cloudflare Tunnels

Streamlit 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. If you want to see how Streamlit stacks up against another Python UI framework, check the Streamlit vs NiceGUI comparison.

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

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

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:

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

If you don’t have a VPS yet, you can set up a VPS for development 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 for most of my servers. Good performance, EU locations, fair pricing. Hostinger is a solid budget alternative. You can compare VPS providers 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.

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

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.

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 to spot problems early.

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.

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

sudo useradd -m streamlit

Now switch to the streamlit user and create the venv:

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:

streamlit --version

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

If you prefer faster dependency resolution, you can use uv to set up a Python project as an alternative to pip + venv. The venv approach works fine for a single-app deployment, but uv shines when managing multiple Python projects.

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:

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:

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:

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:

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:

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

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

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

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.

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.

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

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:

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

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.

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.

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.

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:

Cloudflare Zero Trust dashboard showing tunnel creation with install command for cloudflared

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

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

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:

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:

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.

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.

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.

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

Verify:

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 on top of UFW.

Troubleshooting common issues

"Connection error" / WebSocket stuck on "Please wait..."

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.

Streamlit loads but widgets don't respond

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.

App crashes on large file uploads

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.

Cloudflared shows "version outdated"

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.

After Streamlit 1.57.0+ upgrade — auth or session issues

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.

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:

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:

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:

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:

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:

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.

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:

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

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

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

Then restart the process manager:

# PM2
pm2 restart my-streamlit-app

# systemd
sudo systemctl restart streamlit

Update Streamlit itself:

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

Restart after upgrading.

Update cloudflared (if using apt repo):

sudo apt update && sudo apt upgrade cloudflared

Monitor logs:

# PM2
pm2 logs my-streamlit-app

# systemd
journalctl -u streamlit -f

Consider setting up Uptime Kuma for monitoring — 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.

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

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.

Deploy Python Apps with Docker