Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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".
.claude/skills/jeremylongshore-langchain-multi-env-setup/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | -3% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 122% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 31% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 169% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 192% | 0% |
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:
python# BAD — dotenv populates os.environ; any process with container access sees it from 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 Manager from pydantic import SecretStr from pydantic_settings import BaseSettings class Settings(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 not own the full secrets lifecycle (rotation, revocation, scope) — that belongs to langchain-security-basics.
Pin: langchain-core 1.0.x, langchain-anthropic 1.0.x, pydantic >= 2.5, pydantic-settings >= 2.1. Pain anchors: P37 (primary), P20 (checkpointer schema — cross-ref langchain-langgraph-checkpointing).
Two numbers: smoke test < 10 seconds; env-var count ~15-30 (more than 30 means Settings is absorbing feature flags and should split).
Literal and StrEnum ergonomics)langchain-core >= 1.0, < 2.0pydantic >= 2.5, pydantic-settings >= 2.1google-cloud-secret-manager),AWS Secrets Manager (boto3), or HashiCorp Vault (hvac)
langchain-sdk-patterns — the Settings object is injected intothe chain factories from that skill
Run these six steps in order — each adds one invariant the next step depends on:
Settings class with SecretStr keys, Literal env, and fail-fast validation.model_id, prompt_commit_hash, and vector_index_name per env.pythonfrom typing import Literal from pydantic import SecretStr, HttpUrl, Field, ValidationError from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=None, # see Step 2 — loader picks the file env_file_encoding="utf-8", case_sensitive=False, extra="forbid", # reject unknown env vars — typo detection ) # --- env switch (drives everything else) --- env: Literal["dev", "staging", "prod"] = Field(..., alias="LANGCHAIN_ENV") # --- secrets (always SecretStr — never str) --- anthropic_api_key: SecretStr = Field(..., alias="ANTHROPIC_API_KEY") openai_api_key: SecretStr = Field(..., alias="OPENAI_API_KEY") langsmith_api_key: SecretStr = Field(..., alias="LANGSMITH_API_KEY") # --- per-env pinning (see Step 4) --- model_id: str = Field(..., alias="LANGCHAIN_MODEL_ID") prompt_commit_hash: str = Field(..., alias="LANGCHAIN_PROMPT_COMMIT") vector_index_name: str = Field(..., alias="LANGCHAIN_VECTOR_INDEX") # --- endpoints (validated URLs — typo caught at startup) --- checkpointer_url: HttpUrl | None = Field(None, alias="LANGCHAIN_CHECKPOINTER_URL") otel_endpoint: HttpUrl = Field(..., alias="OTEL_EXPORTER_OTLP_ENDPOINT") # --- budget guards (per-env) --- max_cost_usd_per_day: float = Field(10.0, alias="LANGCHAIN_DAILY_BUDGET_USD") max_rpm: int = Field(60, alias="LANGCHAIN_MAX_RPM")
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.
See Settings Skeleton for the full class.
pythonimport os from pathlib import Path def build_settings() -> Settings: env = os.environ.get("LANGCHAIN_ENV", "dev") if env == "dev": # Local dev: .env.dev file, values checked into 1Password not git return 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 directly if 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.
pythonfrom google.cloud import secretmanager def pull_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.
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):
pythonfrom langsmith import Client ls = Client(api_key=settings.langsmith_api_key.get_secret_value()) def get_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.
Checkpointer choice is per-env too:
pythonfrom langgraph.checkpoint.memory import MemorySaver from langgraph.checkpoint.postgres import PostgresSaver def build_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 migration return 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.
pythonimport time from anthropic import Anthropic def validate_integrations(settings: Settings) -> None: t0 = time.monotonic() # 1. Model reachable (1-token ping ~ $0.00001) anthropic = Anthropic(api_key=settings.anthropic_api_key.get_secret_value()) anthropic.messages.create( model=settings.model_id, max_tokens=1, messages=[{"role": "user", "content": "hi"}], ) # 2. Checkpointer reachable if settings.env != "dev": checkpointer = build_checkpointer(settings) checkpointer.setup() # runs SELECT 1 + schema check # 3. Vector store reachable (see langchain-embeddings-search) # ... describe_index call here ... # 4. Observability endpoint reachable (OTLP HTTP health) # ... requests.get(f"{settings.otel_endpoint}/health", timeout=2) ... elapsed = time.monotonic() - t0 if elapsed > 10.0: raise RuntimeError( f"startup smoke test took {elapsed:.1f}s (budget 10s)" )
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.
Settings class on pydantic-settings with SecretStr for keys, Literal env, HttpUrl endpoints, extra="forbid"Settings only, never os.environmodel_id, prompt_commit_hash, vector_index_name, checkpointer_urlMemorySaver dev, PostgresSaver on isolated DBs staging/prod)| 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 | 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) |
.env-in-dev service to prodStart: 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.
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.
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.
SecretStrboto3hvaclangchain-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)docs/pain-catalog.md (entries P37 primary, P20 cross-ref)| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 47,127 | 22,208 | -53% | 1 | 1 | 0% | 8,071 | 7,863 | -3% | 0 | 0 | — |
case-02 | fail→pass | 20,631 | 26,814 | +30% | 1 | 1 | 0% | 4,139 | 9,180 | +122% | 0 | 0 | — |
case-03 | fail→pass | 34,611 | 22,935 | -34% | 1 | 1 | 0% | 6,169 | 8,060 | +31% | 0 | 0 | — |
case-04 | fail→pass | 12,014 | 13,004 | +8% | 1 | 1 | 0% | 2,190 | 5,881 | +169% | 0 | 0 | — |
case-05 | pass→pass | 17,172 | 8,348 | -51% | 1 | 1 | 0% | 2,154 | 6,015 | +179% | 0 | 0 | — |
case-06 | fail→pass | 19,961 | 21,228 | +6% | 1 | 1 | 0% | 2,588 | 7,560 | +192% | 0 | 0 | — |
case-07 | fail→pass | 21,688 | 9,277 | -57% | 1 | 1 | 0% | 3,034 | 5,869 | +93% | 0 | 0 | — |
case-08 | pass→pass | 14,922 | 11,634 | -22% | 1 | 1 | 0% | 2,772 | 6,652 | +140% | 0 | 0 | — |
case-09 | fail→pass | 33,515 | 20,721 | -38% | 1 | 1 | 0% | 2,564 | 6,760 | +164% | 0 | 0 | — |
case-10 | fail→pass | 21,859 | 12,983 | -41% | 1 | 1 | 0% | 3,010 | 5,910 | +96% | 0 | 0 | — |
case-11 | pass→pass | 10,891 | 7,115 | -35% | 1 | 1 | 0% | 2,062 | 5,718 | +177% | 0 | 0 | — |
case-12 | pass→pass | 26,558 | 17,576 | -34% | 1 | 1 | 0% | 3,144 | 7,035 | +124% | 0 | 0 | — |
case-13 | pass→pass | 22,298 | 17,852 | -20% | 1 | 1 | 0% | 3,529 | 6,712 | +90% | 0 | 0 | — |
case-14 | fail→fail | 16,276 | 16,274 | -0% | 1 | 1 | 0% | 2,061 | 6,704 | +225% | 0 | 0 | — |
case-15 | fail→pass | 21,090 | 17,946 | -15% | 1 | 1 | 0% | 3,252 | 6,542 | +101% | 0 | 0 | — |
case-16 | pass→pass | 22,149 | 14,345 | -35% | 1 | 1 | 0% | 2,289 | 6,575 | +187% | 0 | 0 | — |
case-17 | pass→pass | 19,716 | 15,052 | -24% | 1 | 1 | 0% | 3,526 | 7,246 | +106% | 0 | 0 | — |
case-18 | fail→pass | 14,424 | 16,795 | +16% | 1 | 1 | 0% | 2,367 | 6,671 | +182% | 0 | 0 | — |
case-19 | fail→pass | 17,766 | 12,652 | -29% | 1 | 1 | 0% | 2,865 | 6,565 | +129% | 0 | 0 | — |
case-20 | pass→pass | 14,813 | 15,777 | +7% | 1 | 1 | 0% | 2,346 | 6,256 | +167% | 0 | 0 | — |
case-21 | pass→pass | 31,274 | 27,624 | -12% | 1 | 1 | 0% | 5,203 | 8,970 | +72% | 0 | 0 | — |
case-22 | pass→pass | 27,156 | 22,079 | -19% | 1 | 1 | 0% | 4,339 | 7,891 | +82% | 0 | 0 | — |
case-23 | pass→pass | 16,151 | 19,987 | +24% | 1 | 1 | 0% | 2,978 | 6,822 | +129% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +48 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.