How to deploy a Streamlit app without Docker
Streamlit is the fastest way to put a UI on a Python script, and it is genuinely good at it. It is also opinionated in ways that only become visible when you move it off your laptop: it binds to localhost by default, it holds a WebSocket open for the entire session, and it keeps all state in the process's memory.
None of those are problems. They are just facts you need to know before the first deploy, because each one produces a distinctive and confusing failure if you do not. Here is the whole path, in order.
Step 1: the start command, which is where most deploys fail
Streamlit defaults to binding on localhost and picking port 8501. Behind any hosting platform, both of those are wrong: the platform gives you a port to listen on, and it needs you reachable from outside your own loopback interface.
# The command that works. Both flags matter.
streamlit run app.py \
--server.port $PORT \
--server.address 0.0.0.0 \
--server.headless true
# --server.address 0.0.0.0 : listen on all interfaces, not just loopback.
# Without it, the health check cannot reach you
# and the deploy fails as "app never came up".
# --server.port $PORT : use the port the platform assigned.
# --server.headless true : do not try to open a browser or print the
# "collecting usage stats" prompt on a server.Step 2: deploy from the repo
Python apps are commands-first: the dependency install is inferred from your repository — requirements.txt, a Poetry or uv lockfile, a Pipfile — but the start command is never guessed, because uvicorn, gunicorn, a plain script, and Streamlit are too different to assume one. You provide it.
pandastack app create \
--name sales-dashboard \
--git-url https://github.com/acme/sales-dashboard \
--git-branch main \
--start-command "streamlit run app.py --server.port \$PORT --server.address 0.0.0.0 --server.headless true"
pandastack app deploy <app-id> --followPin your Python version in the repo rather than hoping the default matches your laptop. A .python-version file is read by the runtime installer, so the version you develop against is the version you deploy on.
# .python-version
3.12
# requirements.txt — pin, or a transitive upgrade will break a build
# on a Tuesday for no reason you can see.
streamlit==1.41.1
pandas==2.2.3
plotly==5.24.1
psycopg[binary]==3.2.3Step 3: the WebSocket, and what it means for your hosting
Streamlit's whole interaction model is a persistent WebSocket. When you move a slider, the browser sends an event over that socket, your script reruns top to bottom on the server, and the new UI is pushed back. This has three practical consequences.
- Your host must support WebSockets, and so must anything in front of it. A proxy that only speaks HTTP will produce an app that loads and then does nothing when you click — the most confusing possible symptom, because the page renders fine.
- Idle timeouts disconnect users. A proxy that closes idle connections after sixty seconds will drop a user who is reading a chart, and Streamlit will show its reconnecting message. Check the idle timeout on every hop.
- A user's session is pinned to one process. There is no shared state between replicas, so load balancing across two instances without sticky sessions means a user's socket can land somewhere that has never heard of them.
Step 4: state lives in memory, and memory goes away
st.session_state is a dictionary in the server process. It survives reruns and it does not survive anything else — not a redeploy, not a restart, not the process being recycled. Anything a user would be upset to lose has to go somewhere durable.
import os
import streamlit as st
import psycopg
# Wrong for anything that matters: this is gone on the next deploy.
if "saved_reports" not in st.session_state:
st.session_state.saved_reports = []
# Right: session_state for UI state, a database for facts.
@st.cache_resource
def get_conn():
"""One connection per process, reused across reruns.
cache_resource is the important decorator here — without it, every
slider move opens a new connection, and you exhaust the pool in
about a minute of ordinary clicking.
"""
return psycopg.connect(os.environ["DATABASE_URL"], autocommit=True)
@st.cache_data(ttl=300)
def load_sales(region: str):
"""Cached by argument value for five minutes. Reruns are constant in
Streamlit, so an uncached query here means a database round trip on
every single interaction."""
with get_conn().cursor() as cur:
cur.execute("SELECT day, revenue FROM sales WHERE region = %s", (region,))
return cur.fetchall()
region = st.selectbox("Region", ["EMEA", "AMER", "APAC"])
st.line_chart(load_sales(region))The distinction between the two cache decorators is worth learning properly, because getting it wrong is the usual cause of a Streamlit app that is mysteriously slow or that runs out of database connections. cache_resource is for things you want exactly one of per process — connections, clients, loaded models. cache_data is for return values keyed by arguments, and it copies the result so callers cannot mutate the cache.
Step 5: attaching a database
Most useful Streamlit apps read from somewhere. The connection string goes in as an environment variable, and is read at runtime rather than baked into anything.
# Create a database and read its connection URL.
pandastack database create --label dashboard-db
pandastack database connection <database-id>
# Attach it to the app. Updating an env var takes effect on the next deploy.
pandastack app create \
--name sales-dashboard \
--git-url https://github.com/acme/sales-dashboard \
--env DATABASE_URL="postgres://..." \
--start-command "streamlit run app.py --server.port \$PORT --server.address 0.0.0.0 --server.headless true"The four failures you will actually hit
- Deploy fails the health check, logs look fine. Missing --server.address 0.0.0.0. Nothing outside the process can reach loopback.
- App loads but clicking does nothing. The WebSocket is not getting through. Check every proxy hop for WebSocket support and for an upgrade header being stripped.
- Users get disconnected after a minute of reading. An idle timeout somewhere in front of the app. Raise it, or accept the reconnect.
- Everything is slow and the database runs out of connections. Uncached work in the script body. Every interaction reruns the whole file top to bottom — wrap connections in cache_resource and queries in cache_data.
The short version
Deploying Streamlit is four flags and two decorators. Bind to 0.0.0.0 on the platform's port with headless mode on, make sure WebSockets get through every proxy between the browser and your process, keep durable state in a database rather than session_state, and cache your connections and queries so that constant reruns do not become constant load.
Then add authentication before it points at production data. Streamlit will not do that for you and the failure mode is not a broken app — it is a working one, on the internet, with your numbers in it.
Frequently asked questions
Why does my Streamlit deployment fail the health check?
Nine times out of ten the app is bound to localhost. Streamlit defaults to 127.0.0.1, which means the process is listening only on its own loopback interface, so the platform's health check gets a connection refused even though the logs show a perfectly happy startup banner. Adding --server.address 0.0.0.0 to the start command fixes it. The second most common cause is the port: platforms assign one and expect you to use it, so hardcoding 8501 means the health check knocks on a door nobody is behind. Pass --server.port $PORT and make sure the variable is actually expanded by the shell running the command rather than passed through as a literal string, which is easy to get wrong when the command is quoted inside a config file.
Does Streamlit need WebSockets to work?
Yes, and this is not optional or degradable. Streamlit's entire interaction model is a persistent WebSocket connection: a widget interaction sends an event to the server, your script reruns from the top, and the resulting UI is pushed back over the same socket. If anything between the browser and the process cannot handle a WebSocket upgrade — an older proxy, a load balancer configured for plain HTTP, a CDN rule that strips the upgrade header — the page will load and render, and then nothing will respond to clicks. That symptom is genuinely confusing because the app looks alive. Check the browser console for a failed upgrade, and check every hop in front of the app, not just the one closest to it.
Can I run multiple replicas of a Streamlit app?
Only with sticky sessions, and even then it is a partial answer. Each user's session lives in one process's memory, tied to a WebSocket held open by that process, so a request routed to a different replica arrives somewhere that has never heard of that session. With sticky sessions at the load balancer you can horizontally scale for more concurrent users, but every replica keeps its own separate copy of anything in st.session_state and its own caches, so they will drift. If you need genuinely shared state across replicas, it has to live in a database or a cache that all of them talk to. For most internal dashboards, one appropriately sized instance handles far more users than people expect, and scaling vertically first is the simpler path.
How do I add authentication to a Streamlit app?
Streamlit ships no authentication, so it has to come from somewhere else, and there are three workable places. Put it in front at the platform layer — an authenticating proxy, an identity-aware access rule, or a private network — which is the most robust option because unauthenticated traffic never reaches your process at all. Or use one of the community authentication components, which is quick and acceptable for internal tools where the threat model is casual. Or implement it in the app by gating on a value in session_state, which is the weakest option and easy to get wrong. Whichever you choose, do it before the app touches production data: an unauthenticated dashboard on a public URL is not a hardening task for later, it is a live data exposure whose only protection is that nobody has guessed the hostname yet.
Why is my Streamlit app slow?
Almost always because the script reruns from top to bottom on every single interaction, and something expensive is sitting in the body without a cache. Move a slider and Streamlit re-executes the whole file, so an uncached database query runs again, a model reloads again, a file is re-parsed again. The fix is the two cache decorators, used for different things: cache_resource for objects you want exactly one of per process, such as a database connection or a loaded model, and cache_data with a sensible TTL for the return values of expensive computations keyed by their arguments. Opening a connection in the script body without cache_resource is the specific mistake that produces both slowness and connection-pool exhaustion, because a minute of ordinary clicking opens dozens of connections that nothing ever closes.
Keep reading
- The best Streamlit hosting platforms in 2026
- How to deploy a Flask app without Docker
- Hosting WebSocket apps and persistent connections
- Connecting an app to managed Postgres
- PandaStack Apps — Python deploys from a Git repo, no Dockerfile
49ms p50 cold start. Fork, snapshot, and scale to zero.