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.

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.ioURL. - 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:
- Your VPS runs Streamlit locally on
localhost:8501 cloudflaredcreates an encrypted tunnel to Cloudflare’s edge network- 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 isapp.example.combut Streamlit thinks it’s running onlocalhost, the browser’s CORS policy blocks the WebSocket connection. Setting this tofalsetells 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.
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.
Install Node.js 22 LTS (as root):
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejsInstall PM2 globally:
sudo npm install pm2@latest -gFor more PM2 details, see the complete guide on managing applications with PM2.
Start Streamlit with PM2 (as the streamlit user):
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-appOr use a PM2 ecosystem file for cleaner config:
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.jsSet PM2 to start on boot:
# As the streamlit user:
pm2 startup systemd
# Copy and run the command it prints (this runs as root)
pm2 saveVerify:
pm2 listYour 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:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7systemd is already on every Ubuntu system. No extra packages to install, no Node.js dependency. Fewer moving parts.
Create a service file (as root):
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
EOFEnable and start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now streamlitVerify:
sudo systemctl status streamlitYou should see active (running). If it says failed, check the logs for the error:
journalctl -u streamlit -n 50 --no-pagerCommon 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:
journalctl -u streamlit -fsystemd handles log rotation automatically via journald, so you don’t need to set that up separately.
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:

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:

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.


