Skip to content
Module 6 · Production MasteryAdvanced4 min read · 853 words

Lesson 27: Configuration & Environment Variables

"Every environment variable this backend reads, and the one file that does not exist to hold them centrally."

Every os.getenv Call, Catalogued

VariableDefault if unsetRead in
OPENAI_API_KEY(none — required)openai_service.py, document_service.py, multi_agent_email_service.py
OPENAI_MODEL"gpt-4o-mini"openai_service.py, multi_agent_email_service.py, and directly inline (3x) in routers/agents.py
OPENAI_EMBEDDING_MODEL"text-embedding-3-small"document_service.py

That's the complete list — three distinct environment variables read across the whole backend. DATABASE_URL is not actually read from the environment despite the name suggesting configurability — it's a hardcoded string literal in database.py:

python
DATABASE_URL = "sqlite:///./agentops.db"

Same for the Chroma path — path="./chroma_db" is a literal string in document_service.py, not sourced from an environment variable. So while this backend has some configurability via .env, the database location and the vector store location are not among the things you can change without editing source code.

load_dotenv() Is Called Three Separate Times

python
# openai_service.py
from dotenv import load_dotenv
load_dotenv()
 
# document_service.py
from dotenv import load_dotenv
load_dotenv()
 
# multi_agent_email_service.py
from dotenv import load_dotenv
load_dotenv()

Each of the three service modules independently loads the .env file the moment it's imported. load_dotenv() is idempotent and cheap, so calling it three times isn't a correctness bug — but it is a maintainability smell: there's no single, obvious "this is where configuration is loaded" file. A new service file added later needs to remember to call load_dotenv() itself, or it silently won't see .env values (it would still see variables set directly in the shell environment, since os.getenv reads from the process environment regardless of .env file loading — the risk is specifically for values that only exist in .env and not the shell).

What a Centralized config.py Would Look Like

The dependency (python-dotenv) is already installed; the missing piece is a single settings module every other file imports from, instead of each file calling os.getenv and load_dotenv() independently:

python
# config.py (does not currently exist)
import os
from dotenv import load_dotenv
 
load_dotenv()
 
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
OPENAI_EMBEDDING_MODEL = os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./agentops.db")
CHROMA_DB_PATH = os.getenv("CHROMA_DB_PATH", "./chroma_db")

This would also close the "hardcoded DATABASE_URL/Chroma path" gap noted above — both would become genuinely configurable per environment (e.g., a real path for production vs. ./chroma_db for local development) without touching source code.

AGENTOPS_API_BASE_URL — the Variable That Lives in the Other Project

Per CLAUDE.md: the frontend reads AGENTOPS_API_BASE_URL (defaulting to http://localhost:8001) to know where the FastAPI backend is. This variable is not read anywhere in backend/, only in frontend/app/api/**/route.ts. It's included here because it's the one piece of "backend configuration" that actually lives on the frontend side of the monorepo — worth knowing when debugging a "frontend can't reach backend" issue, since the fix is a frontend environment variable, not anything in backend/.env.

.env Handling — What's Actually Safe

backend/.env exists on disk (confirmed) and is correctly excluded from git via .gitignore (.env, .env.*, with !.env.example explicitly allowed back in) and backend/.gitignore (.env again). No .env.example template currently exists in backend/ — a new contributor cloning this repository has no committed reference for which variables they need to set, only what can be reverse-engineered from reading the three service files' os.getenv calls (which is, in effect, exactly what the table at the top of this lesson does).

Common Mistakes

  • Assuming DATABASE_URL as an environment variable changes where the database lives. It doesn't — the value is hardcoded in database.py and ignores any environment variable of that name.
  • Adding a new service file and forgetting load_dotenv(). Without it, .env-only values (not also set in the shell) won't be visible via os.getenv in that file.
  • Confusing AGENTOPS_API_BASE_URL for a backend setting. It's read exclusively by the frontend.

Exercises

  1. Create backend/.env.example listing OPENAI_API_KEY, OPENAI_MODEL, and OPENAI_EMBEDDING_MODEL with placeholder values and short comments, and confirm it's not excluded by .gitignore (the !.env.example rule should already allow it).
  2. Build the config.py sketched above, and refactor openai_service.py, document_service.py, and multi_agent_email_service.py to import from it instead of each calling os.getenv/load_dotenv() independently. Confirm behavior is unchanged.
  3. Make DATABASE_URL and the Chroma path actually configurable via environment variables (with the current hardcoded values as defaults), and explain what test you'd run to confirm you haven't broken run_startup_migrations's SQLite-only guard clause (if not DATABASE_URL.startswith("sqlite"): return).

Key Takeaways

🎯 Exactly three environment variables are actually read: OPENAI_API_KEY, OPENAI_MODEL, OPENAI_EMBEDDING_MODEL. 🎯 DATABASE_URL and the ChromaDB path are hardcoded literals, not environment-configurable, despite the naming suggesting otherwise. 🎯 load_dotenv() is called independently in three files — works today, but has no single source of truth. 🎯 AGENTOPS_API_BASE_URL is a frontend-side variable, not read anywhere in the backend. 🎯 No .env.example currently exists to document required variables for new contributors.

Next Steps

👉 Lesson 28: Complete Backend Flow →


Module 6 · Production Mastery