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.toml holds project metadata (PEP 621 [project]), dependencies, and tool tables such as [tool.ruff], [tool.pytest.ini_options], and [tool.mypy]. Drop setup.cfg, pytest.ini, and .flake8 when it covers them.
  • JavaScript and TypeScript: package.json holds 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 in eslint.config.js (flat config) only. Source: ESLint v10 release.
  • Rust: Cargo.toml plus one rust-toolchain.toml.
  • Go: go.mod, go.sum, and one .golangci.yml. golangci-lint v2 requires version: "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: .editorconfig only.
  • 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 with os.path.expanduser or os.homedir().
  • IDE state (.idea/, .vscode/settings.json): gitignore it. Share .vscode/extensions.json and launch.json only 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 key

In TypeScript, parse process.env with zod or valibot (typescript-runtime-validation). In Rust, use serde with figment.