●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Running Streamlit + Gemini as a Production BI Dashboard — Auth, Cost, Caching, Rate Limits, Observability
A design memo for promoting a Streamlit + Gemini data analysis app into a real multi-user internal BI dashboard — authentication, cost optimization, result caching, per-user rate limits, and observability, all from production experience.
I have been shipping personal apps since 2014, and as the indie developer behind 50 million cumulative downloads across a handful of mobile apps, I find myself reaching for Streamlit + Gemini more and more whenever I need a small internal dashboard — even though the "team" is just me. Watching AdMob revenue across six wallpaper apps, scanning App Store and Google Play reviews, eyeballing publishing throughput across two blogs (Lacrima and Mystery) — the first Streamlit version of that BI broke in roughly three weeks.
What broke: no login meant anyone with the URL could see the data; the Gemini bill quietly climbed past ¥4,000 a month on queries no one even read; the same CSV uploaded twice burned the API twice; and when something errored, I learned about it a week later. A Streamlit script you write for a personal experiment and a Streamlit BI you actually rely on share maybe 30–40% of the code. This article is the design memo for the rewritten side, based on the stack I actually run.
Why personal-experiment Streamlit and production BI are different beasts
Five layers that exist in the latter and not the former:
Auth layer — who can access. A bare Streamlit app has none.
Cost layer — something to break the linear relationship between user clicks and Gemini billing.
Cache layer — never pay twice for the same question.
Rate-limit layer — Gemini's project quota is one thing, per-user throttling is another.
Observability layer — being able to ask later "who ran what, how many tokens did it burn, did we hit cache".
Personal experiments survive without any of the five. The moment a teammate or even a second device of yours starts hitting the URL daily, all five become non-negotiable.
The auth layer: streamlit-authenticator vs Auth0 vs Cloudflare Access
For the kind of indie BI I build, the team is 1–5 people. At that scale the realistic options collapse to three.
A Python package where you point a YAML file at hashed passwords and you get a login screen. This is what I started with. No additional infra, deployable to Streamlit Community Cloud in minutes.
# auth.pyimport streamlit_authenticator as stauthimport yamlfrom yaml.loader import SafeLoaderimport streamlit as stdef get_authenticator(): config = yaml.load(st.secrets["AUTH_CONFIG"], Loader=SafeLoader) return stauth.Authenticate( config["credentials"], config["cookie"]["name"], config["cookie"]["key"], config["cookie"]["expiry_days"], )def login_gate(): auth = get_authenticator() auth.login(location="main") if st.session_state.get("authentication_status") is not True: st.warning("Login required") st.stop() return st.session_state["username"]
Three gotchas you'll hit. First, expiry_days=30 gives long-lived cookie sessions with no real revocation; I keep mine at 7 and rotate hashes weekly via cron. Second, if that YAML ever lands in a public repo, you're done — keep it in Streamlit Secrets or an external KV. Third, the package does not provide MFA, so it's a poor fit for sensitive data.
Auth0 (10–30 users, MFA required)
When the headcount climbs and you need to revoke access cleanly, Auth0 is the next step up. The free tier covers 25,000 MAU, so an indie BI runs at zero cost. About 30 lines with Authlib:
# auth_oidc.pyfrom authlib.integrations.requests_client import OAuth2Sessionimport streamlit as stAUTH0_DOMAIN = st.secrets["AUTH0_DOMAIN"]CLIENT_ID = st.secrets["AUTH0_CLIENT_ID"]CLIENT_SECRET = st.secrets["AUTH0_CLIENT_SECRET"]REDIRECT_URI = st.secrets["AUTH0_REDIRECT_URI"]def login_gate(): if "user" in st.session_state: return st.session_state["user"] code = st.query_params.get("code") if code is None: sess = OAuth2Session(CLIENT_ID, CLIENT_SECRET, redirect_uri=REDIRECT_URI) url, _ = sess.create_authorization_url( f"https://{AUTH0_DOMAIN}/authorize", scope="openid profile email" ) st.markdown(f"[Sign in]({url})") st.stop() sess = OAuth2Session(CLIENT_ID, CLIENT_SECRET, redirect_uri=REDIRECT_URI) token = sess.fetch_token(f"https://{AUTH0_DOMAIN}/oauth/token", code=code) user = sess.get(f"https://{AUTH0_DOMAIN}/userinfo").json() st.session_state["user"] = user st.query_params.clear() return user
If you go this route, always set up a Rule to restrict allowed email domains. Forget it and Auth0 will happily accept open signups from anywhere.
Cloudflare Access (my current default for indie BI)
The pattern I have settled on. Cloudflare Access does authentication at the edge, so the Streamlit code stays almost auth-free. You publish Streamlit via Cloud Run or a Cloudflare Tunnel, register the host as a Zero Trust Application, and Cloudflare authenticates with Google one-time PIN or GitHub before any request reaches your origin.
Three reasons I prefer it. First, auth lives outside Streamlit, so Streamlit upgrades cannot break login. Second, the free tier covers 50 users — generously beyond indie scale. Third, every request carries a Cf-Access-Authenticated-User-Email header, which makes user identification trivial.
# auth_cf.pyimport streamlit as stdef login_gate(): headers = st.context.headers if hasattr(st, "context") else {} email = headers.get("Cf-Access-Authenticated-User-Email") if not email: st.error("Missing auth header — access via Cloudflare Access") st.stop() return email
I recommend streamlit-authenticator for under-5-person internal BI, Cloudflare Access for everything else. Auth0 fits when you must onboard external collaborators with IdPs beyond Google/GitHub.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Five layers that change between a personal experiment and a real internal BI — each with concrete code you can lift into your own app
✦Token pricing and cache hit rates measured on a working Gemini 2.5 Flash vs 2.5 Pro vs 3.x Pro stack, in JPY/USD
✦When to pick streamlit-authenticator vs Auth0 vs Cloudflare Access, with team-size thresholds and concrete gotchas
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This is where the article earns its keep. The first thing that bites you in production is "the Gemini bill creeps up and you cannot tell whose queries caused it."
As of May 2026, the relevant model tiers for BI workloads, with prices I measured on my own usage:
Gemini 2.5 Flash — about ¥45 per 1M input tokens, ¥190 per 1M output. Schema summaries and single-shot analysis queries are fine here. Averaging 1,200 tokens per call, that's roughly ¥0.4 per call.
Gemini 2.5 Pro — about 8x the Flash cost. Worth it only for long-context summarization or multi-step reasoning where the quality gap is visible. ¥3–¥4 per call.
Gemini 3.x Pro family — reserve for "derive strategy from data" depth. ¥10–¥15 per call, so trigger it carefully.
My operating rule: default is always Flash. Pro tiers fire only when the user explicitly clicks a "Deep analysis" button. Switching to this dropped my monthly Gemini bill from ¥4,000 to ¥600.
Keeping tiers as string keys lets you expose them as st.radio in the sidebar. Default the radio to Flash; only let the user opt into Pro when they actually need it. That single UX choice keeps billing predictable.
Result caching for analysis queries
Cutting the "uploaded the same CSV twice, paid twice" line. I run a three-layer cache.
Layer 1: in-process memory cache via @st.cache_data. Standard Streamlit. Same-session repeat queries hit this. A 1-hour TTL is my sweet spot.
Layer 2: SHA-256-keyed persistent cache. Survives process restarts. Key on the SHA-256 of (schema_summary, question, tier), store the result in Cloudflare KV or SQLite. SQLite is plenty at indie scale. Critical detail: hash the schema summary, never raw CSV cells. Raw CSV contains PII, and a hash-collision-based lookup would leak across users.
import hashlib, sqlite3, jsondef cache_key(schema_summary: str, question: str, tier: str) -> str: payload = f"{tier}|{schema_summary}|{question}".encode() return hashlib.sha256(payload).hexdigest()def get_or_run(schema_summary: str, question: str, tier: str): key = cache_key(schema_summary, question, tier) conn = sqlite3.connect("cache.db") row = conn.execute( "SELECT result FROM cache WHERE key=? AND ts > strftime('%s','now')-86400", (key,), ).fetchone() if row: return json.loads(row[0]), True # hit result = route(prompt=question + "\n\n" + schema_summary, tier=tier).text conn.execute( "INSERT OR REPLACE INTO cache(key, ts, result) VALUES(?, strftime('%s','now'), ?)", (key, json.dumps(result)), ) conn.commit() return result, False # miss
Layer 3: Gemini API context caching. Only when the schema summary is large (over 5,000 tokens) and you ask many questions against the same schema. Using cachedContents brings input token billing down to roughly a quarter. BI dashboards where "the same morning CSV gets queried daily" benefit most.
In my own BI the realized hit rate is 42%. That number reflects the fact that I look at the same Lacrima and Mystery KPIs every morning — a more general-purpose BI will land lower, but 20–30% is a reasonable expectation.
Concurrency control and per-user rate limits
Gemini's free tier sits at 15 RPM, paid plans commonly land at 60 or 360. Once multiple users share a BI, even normal click rates start triggering 429.
Two-stage defense.
Stage 1: global exponential backoff against project quota.tenacity is non-optional. Without it the Streamlit UI freezes the moment Gemini stutters.
Stage 2: per-user token bucket. Prevents one user mashing buttons from blowing the project quota and 429-ing everyone else. Ten requests per minute per user, kept in SQLite.
def user_rate_check(email: str, limit_per_min: int = 10) -> bool: conn = sqlite3.connect("rate.db") cutoff = int(time.time()) - 60 conn.execute("DELETE FROM hits WHERE ts < ?", (cutoff,)) count = conn.execute("SELECT COUNT(*) FROM hits WHERE email=?", (email,)).fetchone()[0] if count >= limit_per_min: return False conn.execute("INSERT INTO hits(email, ts) VALUES(?, ?)", (email, int(time.time()))) conn.commit() return True
Since adding both stages I cannot recall the last 429-driven error screen on my BI.
Prompt injection for BI workloads
If you let Gemini generate SQL or Plotly code, the moment a user smuggles "; DROP TABLE ..." into a CSV cell, injection becomes real. Three layers of defense in my stack.
First, never let Gemini return free-form SQL. Force structured output with response_schema like {"chart_type": "bar"|"line", "x_column": str, "y_column": str}.
Second, sanitize CSV cell values before passing them in — drop anything over 200 chars or containing control characters from the sample rows you send to Gemini.
Third, parse any generated SQL or code through a whitelist-only AST. I use sqlglot and reject anything that isn't a SELECT against allowed tables.
import sqlglotfrom sqlglot import expALLOWED_TABLES = {"orders", "users", "events"}def validate_sql(sql: str) -> bool: try: tree = sqlglot.parse_one(sql) except Exception: return False if not isinstance(tree, exp.Select): return False tables = {t.name for t in tree.find_all(exp.Table)} return tables.issubset(ALLOWED_TABLES)
Observability: knowing who asked what, costing what
Without this layer, you find out about a bad week from the next invoice. At minimum, log three things.
One week after enabling logging I caught a bug where I had defaulted to Pro by accident. That month alone would have been an extra ¥3,000.
Deploy target comparison: Cloud Run vs Streamlit Community Cloud vs Cloudflare Tunnel
My conclusions from running all three:
Streamlit Community Cloud — free, fastest deploy, but no custom domain and a cold start on every wake. Best for personal experiments and sub-5-user internal use.
Cloud Run — ¥300–¥1,500 a month, custom domain, integrates cleanly with Cloudflare Access. Auto-scales. My pick for production BI.
Cloudflare Tunnel + your own box — exposes an existing VPS or home Mac mini. Cheapest learning curve, availability is on you. Good for validation.
I keep two stacks: a production BI on Cloud Run + Cloudflare Access, and a personal validation BI on Cloudflare Tunnel + Mac mini.
The exact stack I run, and the gotchas I hit
Sharing the BI I currently run as a one-person operation while keeping the AdMob and App Store side of things alive:
Auth: Cloudflare Access with Google OTP, restricted to my Google account.
Hosting: Cloud Run, min instances=1, max=2.
Models: Gemini 2.5 Flash default, 2.5 Pro behind a "Deep" toggle.
Observability: SQLite logging + Discord ping when any user exceeds ¥500/day.
Three gotchas to flag. First, min instances=0 saves a few hundred yen a month but every first morning request waits 8 seconds — I bumped it to 1. Second, passing a raw DataFrame into @st.cache_data hashes so slowly that caching is slower than not caching; pass the schema-summary string instead. Third, deeply nested response_schema definitions sometimes return 400 — I keep schemas at most two levels deep.
Next action
The single best next move after reading this is to write one sentence describing your current Streamlit + Gemini app's auth layer. If you're still sharing a bare Streamlit URL inside your team, install streamlit-authenticator first (30 minutes), then layer 2 through 5 on top in order. That sequence is the lowest-risk path I've found.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.