Streamlit is the fastest way I know to turn a Python script into a data app, and that speed comes from an unusual execution model: your entire script re-runs top to bottom on every user interaction. That model is exactly why deployment questions that are trivial for a normal web framework — where do I open my database connection? what happens when the server restarts? — need real answers for Streamlit. Here's how to deploy one properly, with a managed Postgres behind it.
The execution model, and why it changes your deploy
When a user moves a slider, Streamlit re-executes your script. Every line. If your script opens a database connection at the top, you're opening a fresh connection per interaction per user — and a managed database's connection limit will end that experiment quickly.
Streamlit's answer is its caching decorators, and in production they're not optional:
import os
import pandas as pd
import streamlit as st
from sqlalchemy import create_engine
@st.cache_resource
def get_engine():
url = os.environ["DATABASE_URL"]
# SQLAlchemy dropped the postgres:// scheme; normalize defensively
if url.startswith("postgres://"):
url = url.replace("postgres://", "postgresql://", 1)
return create_engine(url, pool_size=5, pool_pre_ping=True)
@st.cache_data(ttl=300)
def load_orders() -> pd.DataFrame:
return pd.read_sql("SELECT * FROM orders ORDER BY created_at DESC", get_engine())
st.title("Orders dashboard")
st.dataframe(load_orders())The split matters. st.cache_resource holds one engine for the whole process, shared across every user session — that's your connection pool. st.cache_data caches the query result itself, with a TTL, so a dashboard refreshed by twenty people doesn't hammer the database twenty times. Get these two right and Streamlit is a well-behaved database citizen; get them wrong and it's a connection-leak generator.
The arithmetic against a free-tier managed Postgres: the connection cap is 50, the pool above holds at most 5. One cached engine per process means one pool total, no matter how many users are connected. That's the whole point.
Server configuration
Streamlit reads config from .streamlit/config.toml, environment variables, or CLI flags. For a container deploy, this is the config that matters:
# .streamlit/config.toml
[server]
headless = true
address = "0.0.0.0"
port = 8501
[browser]
gatherUsageStats = falseheadless = true stops Streamlit from trying to open a browser on launch — pointless in a container and noisy in logs. address = "0.0.0.0" is the one everyone forgets: bind to localhost and the platform's router can't reach your process, so you get a green build and a dead URL. Port 8501 is Streamlit's default; keep it and tell the platform, or override it with --server.port.
Every one of these has an environment-variable form (STREAMLIT_SERVER_PORT, etc.) if you'd rather configure at deploy time than in the repo.
One more production note: Streamlit talks to the browser over a WebSocket, not plain request/response. Your deployment target has to pass WebSocket traffic through end to end — most container platforms do, but it's the first thing to check if the app loads a blank page and then says "Please wait" forever.
Secrets: env vars, not secrets.toml
Streamlit has a native secrets mechanism (.streamlit/secrets.toml, read via st.secrets). It's convenient locally, but it's a file — which means in production it either gets committed (don't) or needs to be templated into the image (awkward). Environment variables are the cleaner path on any container platform: read them with os.environ, set them in the dashboard, keep the repo clean. The code above already works this way via DATABASE_URL.
For local development, a .env loaded by your shell or a secrets.toml in .gitignore — either is fine. Just make sure production reads the environment.
The Dockerfile
Streamlit has no build step; the container is Python plus your dependencies:
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8501
HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8501/_stcore/health')"
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]The health check hits /_stcore/health, Streamlit's built-in liveness endpoint — it returns ok once the server is actually serving, which is more honest than checking whether the process exists. The [official Streamlit Docker guide](https://docs.streamlit.io/deploy/tutorials/docker) uses the same endpoint.
Pin your dependencies. Streamlit moves fast and minor versions occasionally change rendering behavior; streamlit==1.37.* in requirements.txt beats discovering a layout change in production.
Deploying on PandaStack
- 1Create the database. Provision a managed PostgreSQL (or MySQL — SQLAlchemy doesn't care) from the dashboard.
- 2Connect the repo. Add the project as a container app. With the Dockerfile above, PandaStack builds it with rootless BuildKit in an ephemeral build pod and streams the build logs live — useful the first time, because a fat
requirements.txt(pandas, pyarrow, plotly) makes Streamlit images slow to build and you want to see where the time goes. - 3Attach the database.
DATABASE_URLis injected into the container automatically — theget_engine()function above picks it up with zero configuration. No credentials copied anywhere. - 4Push. From now on
git pushrebuilds and redeploys. Custom domain and SSL are handled from the dashboard when you're ready to share it beyond the team.
What scale-to-zero means for a Streamlit app
On the free tier, an idle app scales to zero and cold-starts on the next visit. Two consequences specific to Streamlit:
Session state doesn't survive restarts. st.session_state lives in server memory. When the app scales to zero — or a free-tier preemptible node recycles — in-flight sessions are gone and the user's next interaction starts a fresh script run. For dashboards this is a shrug; the data reloads. For multi-step wizard-style apps, persist anything that matters to the database, not session state.
The first visitor pays the import bill. Streamlit apps often import pandas, plotly, and friends, and heavy imports dominate cold-start time. Keep truly heavy work (model loading, big file reads) behind st.cache_resource so it happens once per process — after the cold start, subsequent users get the cached version.
For an internal dashboard that gets looked at a few times a day, scale-to-zero is a feature: it costs nothing while nobody's looking. For a customer-facing app where a cold start is unacceptable, a paid compute tier keeps the process warm on stable nodes.
The checklist
Bind to 0.0.0.0, run headless, cache the engine with st.cache_resource, cache queries with st.cache_data and a TTL, read secrets from the environment, health-check /_stcore/health, and pin your versions. That's the entire distance between "works on my laptop" and "works when twenty people open it at once."
The git-push-to-live-dashboard loop, with Postgres attached automatically, is worth experiencing once — try it at [https://pandastack.io](https://pandastack.io).