Build reliable dev / staging / prod isolation for LangChain 1.0 services —
Pydantic `Settings` + `SecretStr`, cloud Secret Manager in prod, per-env
prompt and model version pinning, env-specific checkpointer and observability.
Use when graduating from `.env`-in-dev to real prod infra, or debugging a
config that loaded the wrong values in the wrong env.
Trigger with "langchain multi-env", "langchain pydantic settings",
"langchain secret manager", "langchain env config", "langchain prod setup".
Build reliable dev / staging / prod isolation for LangChain 1.0 services —
Pydantic `Settings` + `SecretStr`, cloud Secret Manager in prod, per-env
prompt and model version pinning, env-specific checkpointer and observability.
Use when graduating from `.env`-in-dev to real prod infra, or debugging a
config that loaded the wrong values in the wrong env.
Trigger with "langchain multi-env", "langchain pydantic settings",
"langchain secret manager", "langchain env config", "langchain prod setup".
Designed for Claude Code, also compatible with Codex
LangChain Multi-Env Setup (Python)
Overview
A team ships a LangChain 1.0 service to staging with python-dotenv loading
.env.staging into os.environ. Security audits —
docker exec STAGING-POD env prints ANTHROPIC_API_KEY=sk-ant-api03-... in
plain text. Anyone with kubectl exec, any sidecar, any core dump, any
error tracker that auto-captures process env sees the key. This is pain
P37: secrets loaded from .env in production containers leak via env.
A second failure chains. A developer runs the staging deploy from a shell
where LANGCHAIN_ENV=production was set hours earlier. The loader picks
the prod .env, staging answers with a prompt commit tuned only for the
prod model tier, latency doubles. Two root causes: no type-safe env gate,
no startup validation that would have caught the mismatched model id.
Both are one refactor:
# BAD — dotenv populates os.environ; any process with container access sees itfrom dotenv import load_dotenv
load_dotenv(".env.production")
api_key = os.environ["ANTHROPIC_API_KEY"] # P37: leaks via `docker exec env`# GOOD — SecretStr in a validated Settings object, pulled from Secret Managerfrom pydantic import SecretStr
from pydantic_settings import BaseSettings
classSettings(BaseSettings):
env: Literal["dev", "staging", "prod"]
anthropic_api_key: SecretStr
settings = build_settings() # pulls from GCP Secret Manager in prod
api_key = settings.anthropic_api_key.get_secret_value()
# repr(settings) prints `SecretStr('**********')` — safe to log
This skill owns the per-env config plumbing — Settings skeleton,
Secret Manager integration, per-env pinning, startup smoke test. It does
own the full secrets lifecycle (rotation, revocation, scope) —
that belongs to .
SecretStr masks repr(settings) to SecretStr('**********') — a routine
logger.info(settings) cannot leak the key. The only way to read plaintext
is .get_secret_value(), which greps like a sore thumb in review.
extra="forbid" catches typos (LANGCHIN_MODEL_ID) at import time.
HttpUrl rejects http:/otel:4318 before the exporter wastes 60s on DNS.
Step 2 — Per-env config loading (file OR Secret Manager, never both)
import os
from pathlib import Path
defbuild_settings() -> Settings:
env = os.environ.get("LANGCHAIN_ENV", "dev")
if env == "dev":
# Local dev: .env.dev file, values checked into 1Password not gitreturn Settings(_env_file=Path(".env.dev"))
if env == "staging":
# CI / staging: env vars injected by the orchestrator# (GitHub Actions secrets, k8s envFrom: secretRef, etc.)return Settings() # reads os.environ directlyif env == "prod":
# Prod: pull from Secret Manager into memory ONLY
values = pull_from_secret_manager()
return Settings(**values)
raise ValueError(f"unknown LANGCHAIN_ENV: {env!r}")
Three loaders, one class. Dev touches a file on disk. Staging inherits env
vars from the orchestrator — envFrom: secretRef is readable via
docker exec env, but the blast radius is bounded and rotation is weekly.
Prod is the P37 fix: pull_from_secret_manager() builds a dict and passes
kwargs to Settings(...). Values land in the instance attribute and
never touch os.environ. A subprocess will not inherit them.
from google.cloud import secretmanager
defpull_from_secret_manager() -> dict[str, str]:
client = secretmanager.SecretManagerServiceClient()
project = os.environ["GCP_PROJECT_ID"]
secret_names = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "LANGSMITH_API_KEY"]
out: dict[str, str] = {}
for name in secret_names:
resource = f"projects/{project}/secrets/{name}/versions/latest"
response = client.access_secret_version(request={"name": resource})
out[name] = response.payload.data.decode("utf-8")
# Non-secret passthrough (model id, prompt hash, endpoints)for key in ["LANGCHAIN_ENV", "LANGCHAIN_MODEL_ID", "LANGCHAIN_PROMPT_COMMIT",
"LANGCHAIN_VECTOR_INDEX", "LANGCHAIN_CHECKPOINTER_URL",
"OTEL_EXPORTER_OTLP_ENDPOINT"]:
if key in os.environ:
out[key] = os.environ[key]
return out
No os.environ[k] = v line. The dict goes straight into
Settings(**values). Workload-identity IAM handles auth; no static key on
disk. For AWS / Vault see Secret Manager Integration.
Step 4 — Per-env model and prompt pinning
Dev, staging, and prod run different model ids and different prompt
commit hashes. Pinning happens at env-var level so app code is env-agnostic
(see the Env Matrix below for values). One function reads
settings.prompt_commit_hash and pulls from LangSmith
(cross-ref langchain-prompt-engineering):
from langsmith import Client
ls = Client(api_key=settings.langsmith_api_key.get_secret_value())
defget_prompt(settings: Settings) -> ChatPromptTemplate:
return ls.pull_prompt(f"triage-prompt:{settings.prompt_commit_hash}")
Prevents: staging loading a prod prompt commit. Pinning per env makes
promotion explicit — dev → staging → prod moves one hash at a time. See
Per-Env Pinning.
Step 5 — Per-env checkpointer selection
Checkpointer choice is per-env too:
from langgraph.checkpoint.memory import MemorySaver
from langgraph.checkpoint.postgres import PostgresSaver
defbuild_checkpointer(settings: Settings):
if settings.env == "dev":
return MemorySaver() # ephemeral, resets on restart# staging + prod: Postgres with env-isolated schema# cross-ref langchain-langgraph-checkpointing (P20) for schema migrationreturn PostgresSaver.from_conn_string(
str(settings.checkpointer_url)
)
Dev uses MemorySaver — no infra dependency, no state between runs.
Staging and prod use PostgresSaver against separate databases (or
separate schemas). Never share a checkpointer DB between envs; P20 explains
— schema migrations on a version bump corrupt cross-env threads.
Call validate_integrations(settings)before the HTTP server binds.
Failure aborts the deploy — the readiness probe never goes green, the
rollout halts, the bad version takes no traffic. Budget: 10 seconds.
Past 10s an integration is degraded — fail loudly rather than ship a 30s
cold start. See Startup Smoke Test.
Output
Settings class on pydantic-settings with SecretStr for keys, Literal env, HttpUrl endpoints, extra="forbid"
Env-specific loader (file → dev; env vars → staging; Secret Manager → prod); values land in Settings only, never os.environ
Cloud Secret Manager integration (GCP / AWS / Vault) with IAM-bound auth; no static keys on disk
Per-env pinning for model_id, prompt_commit_hash, vector_index_name, checkpointer_url
Per-env checkpointer (MemorySaver dev, PostgresSaver on isolated DBs staging/prod)
Startup smoke test — model / vector / checkpointer / observability under 10-second budget
Env Matrix
Dimension
dev
staging
prod
Secret backend
.env.dev file (git-ignored)
orchestrator env vars
cloud Secret Manager, memory only
os.environ holds keys
yes (local)
yes (sidecar visible)
no (P37 fix)
model_id
claude-haiku-4-6
claude-sonnet-4-6
claude-sonnet-4-6
prompt_commit_hash
WIP
canary
stable (1 week old)
temperature
0.7
0.2
0.2
Checkpointer
MemorySaver
PostgresSaver (staging DB)
PostgresSaver (prod DB)
Vector index
dev-index
staging-index
prod-index
OTEL sample rate
1.0
1.0
0.1
RPM limit
10
60
provider tier
Daily budget
$1
$10
$500-$5000
Smoke probes
model
model + checkpointer + OTEL
all four
Error Handling
Error
Cause
Fix
docker exec POD env shows ANTHROPIC_API_KEY=... in prod (P37)
dotenv / plain env injection in prod
Pull from Secret Manager into Settings(**values); never write to os.environ
Staging answers with prod prompts / wrong model
Loader defaulted or picked stale LANGCHAIN_ENV
Literal["dev","staging","prod"] on env; raise on unknown; no default
ValidationError: extra fields forbidden at startup
Typo (LANGCHIN_MODEL_ID)
Fix the typo — extra="forbid" working as intended
Startup takes 30s before first request
Serialized probes or degraded integration
Enforce 10s budget; parallelize probes; fail the deploy
repr(settings) in a log leaks the API key
Plain str used, not SecretStr
Change field to SecretStr; repr masks to '**********'
Prod silently using MemorySaver
build_checkpointer defaulted when checkpointer_url was None
Require checkpointer_url in staging/prod via a model validator
Secret Manager auth fails in CI
SA not bound; google.auth fell back to ADC
Bind SA with roles/secretmanager.secretAccessor
Prompt hash rolled forward in staging without dev validation
Promotion skipped the dev gate
Enforce dev → staging → prod order in CI (see per-env pinning ref)
Examples
Graduating a .env-in-dev service to prod
Start: a single .env committed (or leaked via docker exec env). End:
Settings class, three loaders, Secret Manager in prod, smoke test under
10s. Three PRs — (1) introduce Settings without changing loader behavior,
(2) add SecretStr and migrate call sites to .get_secret_value(),
(3) swap prod to Secret Manager and remove the prod .env from the image.
See Settings Skeleton and
Secret Manager Integration.
Wrong-env prompt loaded in staging — postmortem
Staging inherited LANGCHAIN_ENV=production from a stale shell. The
Literal["dev","staging","prod"] field rejects production; CI promotion
sets LANGCHAIN_ENV explicitly; direnv pins it per-project. See
Per-Env Pinning.
Smoke test blocked a bad model id
A prod deploy went out with LANGCHAIN_MODEL_ID=claude-sonnet-4-7 (not yet
rolled out). The 1-token ping failed with model not found,
validate_integrations raised, the container crash-looped, the rollout
halted, the previous version kept taking traffic. Zero user impact; failure
budget stayed under 3s. See Startup Smoke Test.
Related skills in pack: langchain-security-basics (secrets lifecycle, owns rotation and revocation — not duplicated here); langchain-langgraph-checkpointing (P20 schema migration); langchain-prompt-engineering (prompt pin / LangSmith pull workflow); langchain-reference-architecture (where Settings fits in the DI layer)