Overview
Config sprawl hides how a project is built: six tools, six files, four of them repeating each other. The rules below pick one source of truth per setting, keep environment overrides predictable, keep machine-specific values out of git, and fail fast on bad config. Per-user config is in dotfiles.
Use one manifest per ecosystem
- Python:
pyproject.tomlholds project metadata (PEP 621[project]), dependencies, and tool tables such as[tool.ruff],[tool.pytest.ini_options], and[tool.mypy]. Dropsetup.cfg,pytest.ini, and.flake8when it covers them. - JavaScript and TypeScript:
package.jsonholds dependencies and scripts, and can hold Prettier ("prettier") and Jest ("jest") config. ESLint cannot: ESLint 10 (February 2026) removed the eslintrc system, so config lives ineslint.config.js(flat config) only. Source: ESLint v10 release. - Rust:
Cargo.tomlplus onerust-toolchain.toml. - Go:
go.mod,go.sum, and one.golangci.yml. golangci-lint v2 requiresversion: "2"at the top and rejects v1 files.
A newcomer should learn the tooling from one or two files.
Assign each setting to one place
- Indent style and width:
.editorconfigonly. - Code style for a language: that formatter’s config (
prettier,ruff format,rustfmt.toml). - Lint rules: the linter’s config, inside the manifest when it allows.
When two files claim a setting, someone edits one and ships a silent disagreement.
Layer environment files, and keep production secrets out of them
.env # committed: safe defaults, no secrets
.env.development # committed: dev defaults
.env.production # committed: prod defaults, no secrets
.env.local # gitignored: per-developer overrides
.env.production.local # gitignored: per-machine production overrides
Next.js and Vite load this cascade natively. Elsewhere, use dotenv-flow in Node, or pass a file list such as env_file=(".env", ".env.local") in pydantic-settings, where later files win. Production secrets come from the host’s secret store, not a file on disk. Commit a .env.example listing every required key with a placeholder, and have CI diff it against the keys the code reads.
Never commit machine-specific config
- Absolute paths such as
/Users/alice/projects/...: use repo-relative paths or environment variables, resolved at runtime withos.path.expanduseroros.homedir(). - IDE state (
.idea/,.vscode/settings.json): gitignore it. Share.vscode/extensions.jsonandlaunch.jsononly if the team agreed. - Personal git aliases and hooks: belong in dotfiles.
A .gitignore block for these is mandatory; see git.
Move to code when config grows logic
A declarative file that sprouts conditionals and interpolation becomes a poor programming language. Switch to code once it needs more than one level of either: a TypeScript bundler config instead of JSON, a typed Pulumi or CDK object instead of stacked YAML templates, a short script instead of a long Makefile. Code can be type-checked, tested, and refactored.
Pick the format by audience
- TOML for human-edited project metadata (
pyproject.toml,Cargo.toml). - YAML where the tool requires it (Kubernetes, GitHub Actions).
- JSON for machine-generated files (
package.json, lockfiles). - JSONC where the ecosystem standardizes on it (
tsconfig.json, VS Code).
Do not invent a format; each of these has tooling.
Validate at startup and fail loudly
A config bug that surfaces mid-request is the worst kind. Parse and validate when the process starts.
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
request_timeout_seconds: int = 30
settings = Settings() # raises on a missing required keyIn TypeScript, parse process.env with zod or valibot (typescript-runtime-validation). In Rust, use serde with figment.