"Build reliable dev / staging / prod isolation for LangChain 1.0 services\
Scanned 9/2/2026
Install to Claude Code
npx -y skills add jeremylongshore/tons-of-skills-marketplace --skill langchain-multi-env-setup --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Langchain Multi Env Setup?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/jeremylongshore-langchain-multi-env-setup-a48cf063)More formats (shields.io, HTML) on the badges page.
---
name: langchain-multi-env-setup
description: "Build reliable dev / staging / prod isolation for LangChain 1.0 services\
\ \u2014\nPydantic Settings + SecretStr, cloud Secret Manager in prod, per-env\n\
prompt and model version pinning, env-specific checkpointer and observability.\n\
Use when graduating from .env-in-dev to real prod infra, or debugging a\nconfig\
\ that loaded the wrong values in the wrong env.\nTrigger with \"langchain multi-env\"\
, \"langchain pydantic settings\",\n\"langchain secret manager\", \"langchain env\
\ config\", \"langchain prod setup\".\n"
allowed-tools: Read, Write, Edit, Bash(python:*), Bash(gcloud:*)
version: 2.5.0
license: MIT
author: Jeremy Longshore <jeremy@intentsolutions.io>
tags:
- saas
- langchain
- langgraph
- python
- langchain-1.0
- config
- pydantic
- multi-env
- secrets
compatibility: Designed for Claude Code
---
# 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:
```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).
## Prerequisites
- Python 3.10+ (3.11+ recommended for `Literal` and `StrEnum` ergonomics)
- `langchain-core >= 1.0, < 2.0`
- `pydantic >= 2.5`, `pydantic-settings >= 2.1`
- One secret backend: GCP Secret Manager (`google-cloud-secret-manager`),
AWS Secrets Manager (`boto3`), or HashiCorp Vault (`hvac`)
- Completed `langchain-sdk-patterns` — the `Settings` object is injected into
the chain factories from that skill
## Instructions
Run these six steps in order — each adds one invariant the next step depends on:
1. Define a `Settings` class with `SecretStr` keys, `Literal` env, and fail-fast validation.
2. Add a per-env loader — file in dev, env vars in staging, Secret Manager in prod.
3. Use the cloud Secret Manager client to pull keys into memory only.
4. Pin `model_id`, `prompt_commit_hash`, and `vector_index_name` per env.
5. Configure the checkpointer per env — memory in dev, Postgres elsewhere.
6. Run a startup smoke test under 10 seconds before the HTTP server binds.
### Step 1 — Create a Settings class with SecretStr and fail-fast validation
```python
from 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](references/settings-skeleton.md) for the full class.
### Step 2 — Per-env config loading (file OR Secret Manager, never both)
```python
import 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.
### Step 3 — Secret Manager pull (GCP example; AWS / Vault in reference)
```python
from 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](references/secret-manager-integration.md).
### 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`):
```python
from 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](references/per-env-pinning.md).
### Step 5 — Per-env checkpointer selection
Checkpointer choice is per-env too:
```python
from 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.
### Step 6 — Startup smoke test (< 10 seconds budget)
```python
import 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](references/startup-smoke-test.md).
## 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](references/settings-skeleton.md) and
[Secret Manager Integration](references/secret-manager-integration.md).
### 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](references/per-env-pinning.md).
### 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](references/startup-smoke-test.md).
## Resources
- [Pydantic Settings docs](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
- [Pydantic `SecretStr`](https://docs.pydantic.dev/latest/api/types/#pydantic.types.SecretStr)
- [GCP Secret Manager client](https://cloud.google.com/secret-manager/docs/reference/libraries)
- [AWS Secrets Manager `boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/secretsmanager.html)
- [HashiCorp Vault `hvac`](https://hvac.readthedocs.io/)
- [LangChain 1.0 release notes](https://blog.langchain.com/langchain-langgraph-1dot0/)
- 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)
- Pack pain catalog: `docs/pain-catalog.md` (entries P37 primary, P20 cross-ref)
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!