Overview
Project configuration sprawl is one of the easiest ways to bury a clean repo. Six tools, six config files, four of them duplicating each other’s settings. The rules below pick a single source of truth per ecosystem, keep environment overrides predictable, ban machine-specific config from version control, and prefer code over config when both are possible. They build on dotfiles for per-user config.
One source of truth per ecosystem
Modern toolchains gather everything under one manifest. Use it instead of sprinkling per-tool dotfiles.
- Python:
pyproject.tomlholds project metadata, dependencies (PEP 621), and tool config ([tool.ruff],[tool.pytest.ini_options],[tool.mypy]). Skipsetup.cfg,setup.py,pytest.ini,.flake8, andruff.tomlwhenpyproject.tomlwill do. - JavaScript / TypeScript:
package.jsonholds dependencies, scripts, and most tool config ("prettier","eslintConfig","jest"). Move to dedicated files only when the inline form gets unwieldy. - Rust:
Cargo.tomlplus a singlerust-toolchain.toml. Resist scattering build config across multiple files. - Go:
go.mod,go.sum, and onegolangci.yaml.
Consolidate before adding. The next contributor should be able to read one file to understand the project’s tooling, not seven.
Avoid config sprawl: do not duplicate settings
The common failure mode is .editorconfig plus .prettierrc plus .eslintrc plus pyproject.toml, three of them disagreeing about indent width. Pick one place per setting.
- Indent style and width:
.editorconfigonly. Every modern editor reads it. - Code style for one language: that language’s formatter config (
prettier,ruff format,rustfmt.toml). - Lint rules: the linter’s own config, inside the project manifest when possible.
When two configs claim the same setting, the developer who edits one and not the other ships an invisible bug.
Environment overrides go in .env.<env>
Per-environment config (dev, staging, prod) lives in numbered or named dotenv files. Load order is predictable:
.env # committed; default values, no secrets
.env.development # committed; dev-only defaults
.env.production # committed; prod-only defaults
.env.local # gitignored; per-developer overrides
.env.production.local # gitignored; secrets that bypass the vault
Tools that read this layout natively: Next.js, Vite, Nuxt, Astro, FastAPI with pydantic-settings, Django via django-environ. For Node services, use dotenv-flow to get the same cascade.
Rule of thumb: commit defaults that are safe to share; never commit secrets. Use a .env.example file as the canonical list of required keys.
Never commit machine-specific config
A config that works on the author’s laptop and nowhere else is a trap.
- Absolute paths (
/Users/alice/projects/...): forbidden. Use environment variables or repo-relative paths. - IDE configs (
.idea/,.vscode/settings.json): mostly gitignored. The exception is.vscode/extensions.json(recommended extensions) and.vscode/launch.json(shared debug configs), if the team agreed. - OS-specific paths (
C:\Users\...or~/Library/...): never. Detect at runtime viaos.path.expanduserorpath.join(os.homedir(), ...). - Personal git aliases or hooks: belong in dotfiles, not the project repo.
A .gitignore block for these is mandatory. See git for what .gitignore is and is not.
Prefer code over config when both work
A declarative config grows, sprouts conditionals, then becomes a YAML programming language. Switch to code before it gets there.
- Webpack or Vite config past ~50 lines: keep it as TypeScript, not JSON-with-comments.
- Build pipelines: a small TypeScript or Python script is more debuggable than a 200-line
Makefile. - Multi-environment deploy config: a typed config object in code (Pulumi, CDK) beats a stack of YAML templates.
Code can be type-checked, tested, and refactored. Config cannot. Switch when the config has more than one level of conditional or interpolation.
JSON vs YAML vs TOML: pick by audience
- TOML for human-edited project metadata (
pyproject.toml,Cargo.toml). Clear sections, comments, typed values. - YAML for tools that demand it (Kubernetes, GitHub Actions). Watch for indentation footguns.
- JSON for machine-generated config (
package.json, lockfiles). No comments, strict syntax. - JSON with comments (
jsonc) when an ecosystem (TypeScript, VS Code) standardizes on it. Avoid otherwise.
Do not invent a fourth format. Each has tooling; a custom format does not.
Validate config at startup, fail loudly
Config bugs that surface mid-request are the worst kind. Validate at process startup with a schema and crash on failure.
# Python with pydantic-settings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
openai_api_key: str
request_timeout_seconds: int = 30
settings = Settings() # raises on missing required keysTypeScript: zod or valibot to parse process.env into a typed object; see typescript-runtime-validation. Rust: serde + figment. A service that boots with garbage config is harder to debug than one that refuses to boot.
Pair this with a checked-in .env.example listing every required key with a placeholder value. A new contributor copies it to .env.local; CI diffs it against the keys the code reads to catch drift.