Chapter 15 — The Local-Dev Tax and How Typed Configs Cancel It
The
.env.localfile is a confession. Every team that has one is admitting that their pipeline depends on configuration the codebase doesn't name.
The previous chapter built the proposed profile mechanism. This chapter applies it to a specific operational symptom: the local-dev tax — the accumulated friction every developer pays on a fresh checkout to get the project actually running. Every piece of that friction is a missing typed configuration. This chapter walks through the common pieces, names what each one is hiding, and shows the migration path under the chapter-14 profile system.
The end state the chapter describes is the boring fresh checkout: clone the repository, run one command, the test suite passes. No tribal knowledge, no README's "you might also need to..." section, no half-day onboarding ritual. Just code, profile, results.
The shape of the tax
A fresh checkout of a moderately-mature Python project typically exposes a developer to seven or eight distinct sources of local-dev tax. Each has its own recognisable name and its own characteristic failure mode.
.env.local files
The most common form. A file (committed or not) named .env,
.env.local, .env.example that contains a list of export NAME=value lines. The application reads them via a library
(python-dotenv, direnv's shell hook, the developer's manual
source .env.local).
# .env.example
DATABASE_URL=postgresql://localhost:5432/dev
REDIS_URL=redis://localhost:6379/0
SECRET_KEY=changeme
LOG_LEVEL=DEBUG
GITHUB_TOKEN=
SLACK_WEBHOOK_URL=
What the file is hiding:
- A landscape entry for the local-dev database (
localhost:5432). - A landscape entry for the local-dev Redis.
- A credential for the database (the password, currently empty because the local Postgres is set up with trust auth).
- A runtime configuration for the log level.
- A credential reference for
GITHUB_TOKEN(with no resolution discipline — the developer is expected to paste their PAT into the file). - An infrastructure-service entry for Slack notifications.
The migration: each piece of content moves into the appropriate typed configuration file from chapters 4–9. The file disappears.
direnv and .envrc
A more sophisticated cousin of .env.local. The developer
installs direnv; when they cd into the project directory, a
.envrc script auto-runs and exports a set of environment
variables for the shell:
# .envrc
export DATABASE_URL=postgresql://localhost:5432/dev
export REDIS_URL=redis://localhost:6379/0
source_up_if_exists # cascade up the directory tree
use python 3.11 # auto-activate the project's Python
The same content as .env.local, plus a Python-version selection
on top. The migration is the same: every export becomes a typed
configuration entry. The Python version is a runtime configuration
field (not currently shipped in the runtime YAML schema, but a
natural addition).
.tool-versions (asdf, mise)
A file declaring which versions of which tools the project uses:
# .tool-versions
python 3.11.7
nodejs 20.10.0
poetry 1.7.1
Tool-version selection is legitimately a local-dev concern — it is about how the developer's machine is set up, not about how the orchestration runs. Most teams should keep this file checked in.
What it is not hiding: nothing significant. A .tool-versions
file is closer to a pyproject.toml's Python-version constraint
than to an environment-variable export — it informs the developer
of the expected toolchain rather than parameterising the
orchestration.
The migration in this case is partial: keep the file as a
toolchain manifest; do not let it grow into an environment-variable
substitute. A .tool-versions file with five tool versions is
fine. A .tool-versions file with DATABASE_URL in it is the
same .env.local mistake in fresh costume.
make setup
An imperative setup script the developer runs once after checkout:
# Makefile
setup:
./scripts/install-system-deps.sh
poetry install
./scripts/create-local-db.sh
./scripts/seed-test-data.sh
./scripts/generate-ssh-keys.sh
cp .env.example .env.local
@echo "Now edit .env.local with your GitHub token"
What make setup is hiding:
- A specification for the local environment (which system packages, which Python version, which database, which seed data).
- A landscape entry that says "the local-dev landscape includes these databases, these mock services."
- A credential bootstrap (the SSH keys, the
.env.localtemplate). - A README the developer is expected to read after the imperative script completes.
The migration: the system-package and Python-version steps stay imperative (they're machine-setup concerns). Everything else becomes typed configuration plus the chapter-14 profile machinery.
The README's "you might also need to..." section
The plaintext resting-place of every workaround the team has accumulated:
Fresh checkout? You'll need: — Python 3.11 (use pyenv or asdf) — Postgres running on port 5432 (we use Postgres.app on macOS; Linux folks use Docker) — A GitHub PAT with
read:reposcope — An entry in/etc/hostsfordev.local— Trust the local SSL cert (instructions below) — Runmake setup(might fail on Apple Silicon; if so, see issue #234) — Edit.env.localto add your PAT — Sign in to AWS via SSO (token expires daily) — Connect the VPN (only required if you're testing the payments service)
This section is the manifest of tribal knowledge. Every line is a piece of state the codebase requires but doesn't name. A new hire works through it for a half-day; a returning developer works through it on each major OS upgrade; a senior engineer swears at it when the seventh dependency in the list has shifted without anyone updating the README.
Each line is a missing typed configuration:
| Line | What it really is |
|---|---|
| Python 3.11 (use pyenv) | Toolchain manifest (.tool-versions) |
| Postgres on port 5432 | Landscape entry: a local-dev staging-db infrastructure service |
GitHub PAT with read:repo |
Credential entry: local-github-pat, scope tag environment:local-dev |
/etc/hosts entry for dev.local |
Specification: the local-dev environment requires this name resolution; provisioning step ensures it |
| Trust local SSL cert | Specification: the local-dev environment requires this cert to be in the system trust store |
make setup (might fail on Apple Silicon) |
Specification: machine-setup imperative; bug to fix in issue #234 |
Edit .env.local |
Migrating already; remove this step |
| AWS SSO sign-in (daily) | Credential resolution: the local-dev AWS credential reference resolves to a fetched-on-demand AWS SSO token |
| VPN if testing payments | Topology: the payments topology requires VPN connectivity; document the dependency in the topology file |
Once each line has a typed home, the README's section empties out. The remaining entries are the genuine machine-setup imperatives (toolchain, system packages) — short, stable, and honest about being one-time work.
The cumulative cost
Every piece of local-dev tax has the same general failure mode: it works on the developer who set it up; it fails on the next developer.
The cumulative cost across a team:
- Onboarding time. A new hire spends a half-day to a full day getting the project actually running. Multiply by team growth rate.
- OS-upgrade churn. When macOS releases a new major version,
every developer pays the local-dev tax again because some part
of
make setupdoesn't work on the new OS. - Debugging time. When a test fails locally but passes in CI, the developer's first few hours of debugging are spent ruling out local-dev environmental differences. When the difference is in the README's tribal knowledge, the bug is only reproducible by people who set up their environment the same way.
- CI ↔ local divergence. As the README grows, local-dev diverges from CI. Eventually some classes of failure can only be reproduced in CI; the developer has to push commits to iterate. The inner-loop velocity collapses.
The discipline of this chapter cancels each of these costs by
moving the configuration into the codebase. Once the local-dev
profile names every piece of state the project depends on, a
fresh checkout is clone + ccai profile activate local-dev.
The migration walk-through
The walk-through that follows takes the fictitious project from the section above through migration. Each step adds one typed configuration; each step is independently committable; each step makes the README's section shorter.
Step 1 — Inventory
Walk every file that participates in local-dev configuration:
.env.local, .envrc, the README, the Makefile, the project's
docker-compose.yaml, every setup-*.sh. Catalogue every
distinct configuration value. Group them by category:
| Value | Source | Category |
|---|---|---|
DATABASE_URL=postgresql://localhost:5432/dev |
.env.local |
Data Profile + Credential + Landscape |
REDIS_URL=redis://localhost:6379/0 |
.env.local |
Data Profile + Landscape |
SECRET_KEY=changeme |
.env.local |
Credential |
LOG_LEVEL=DEBUG |
.env.local |
Runtime |
GITHUB_TOKEN=... |
.env.local |
Credential |
SLACK_WEBHOOK_URL=... |
.env.local |
Credential + Landscape (infrastructure service) |
python 3.11 |
.tool-versions |
Toolchain manifest (stays) |
| Postgres on port 5432 | README | Landscape (infrastructure service) |
/etc/hosts for dev.local |
README | Specification |
| Local SSL cert trusted | README | Specification |
| AWS SSO daily token | README | Credential |
| VPN for payments | README | Topology |
The inventory is the chapter's most important step. An inventory that misses something will leave the README with a residual line, which is the same kind of tribal knowledge the discipline aims to eliminate.
Step 2 — Author the local-dev configurations
For each typed configuration, author the file:
config/landscape/landscape-local-dev.yaml — the local-dev
landscape:
environment:
label: local-dev
apod:
clients:
- deviceType: network/client-localhost
name: developer-laptop
host: 127.0.0.1
role: runner
credentials:
- local-runner
features:
parallelism_max: 4
skip: false
infrastructure:
services:
- serviceType: database/postgres
name: staging-db # same identifier as production
host: 127.0.0.1
port: 5432
database: dev
credentials:
- local-postgres
- serviceType: cache/redis
name: staging-redis # same identifier as production
host: 127.0.0.1
port: 6379
credentials:
- local-redis
- serviceType: web/webhook
name: slack-notifications # same identifier as production
endpoint: ${PROMPT:Slack webhook URL}
credentials:
- local-slack-webhook
config/dataprofiles/dataprofiles-local-dev.yaml — the
profiles:
dataprofiles:
- identifier: staging-db-rw # same identifier as production
category: basic-tcp
host: 127.0.0.1
port: 5432
dbtype: postgres
dbname: dev
credential: local-postgres
- identifier: staging-redis # same identifier as production
category: basic-tcp
host: 127.0.0.1
port: 6379
dbtype: redis
credential: local-redis
config/credentials/credentials.yaml — local-dev section
appended:
credentials:
# ... existing CI / staging / prod entries ...
- identifier: local-postgres
categories: [basic, environment:local-dev]
username: dev
password: ${KEYCHAIN:com.company.app/local-postgres}
- identifier: local-redis
categories: [basic, environment:local-dev]
username: default
password: ${PROMPT:Local Redis password}
- identifier: local-runner
categories: [ssh, environment:local-dev]
username: $USER
keyfile: ${FILE:~/.ssh/id_ed25519}
keypasswd: ${KEYCHAIN:com.company.app/ssh-id-ed25519}
- identifier: local-github-pat
categories: [github-token, environment:local-dev]
username: $USER
token: ${KEYCHAIN:com.company.app/github-pat}
- identifier: local-aws-sso
categories: [aws-access-key, environment:local-dev, ttl:3600]
keyid: ${AWSSSO:role/Developer/AccessKey#keyid}
secret: ${AWSSSO:role/Developer/AccessKey#secret}
- identifier: local-slack-webhook
categories: [api-token, environment:local-dev]
token: ${KEYCHAIN:com.company.app/slack-webhook}
- identifier: local-app-secret-key
categories: [api-token, environment:local-dev]
token: ${LITERAL:changeme} # explicit; not a real secret
config/runtime/runtime-local-dev.yaml — the runtime
configuration:
breakpoints: []
debugger: debugpy
logging:
levels:
console: DEBUG
logfile: DEBUG
parallelism:
max_workers: 4
timeouts:
default_seconds: 300 # generous for breakpoints
property_test_caps:
max_examples: 5 # fast feedback
paths:
output: ./.output
version: 1.0.0
config/specs/spec-local-dev.yaml — the specification for the
local environment:
environment:
venues:
- name: localhost
provider: local
credential: null
groups:
- name: local-dev
description: "Local-dev environment requirements"
profiles:
- name: local-postgres
description: "Local Postgres install"
parameters:
provider: brew
formula: postgresql@15
port: 5432
database: dev
- name: local-redis
description: "Local Redis install"
parameters:
provider: brew
formula: redis
port: 6379
- name: local-hosts-entry
description: "Local /etc/hosts entries"
parameters:
entries:
- hostname: dev.local
address: 127.0.0.1
- name: local-trust-cert
description: "Local SSL cert in system trust store"
parameters:
cert_path: ./certs/local-dev.crt
apod:
devices:
- identifier: local-postgres
category: brew-service
group: local-dev
profile: local-postgres
role: database
- identifier: local-redis
category: brew-service
group: local-dev
profile: local-redis
role: cache
- identifier: local-hosts
category: hosts-file
group: local-dev
profile: local-hosts-entry
role: dns
- identifier: local-cert
category: trust-store
group: local-dev
profile: local-trust-cert
role: tls
The specification is consumed by ccai provisioning apply,
which (per chapter 4's discipline) is idempotent. The first run
installs Postgres, Redis, edits /etc/hosts, and trusts the
cert. Subsequent runs are no-ops if the state is already correct.
config/profiles/local-dev.yaml — the profile that ties
everything together:
profile:
extends: base
name: local-dev
description: Single-host developer workstation
select:
landscape: local-dev
topology: local-dev
runtime: local-dev
credentials: local-dev
dataprofiles: local-dev
Step 3 — Wire the application code
Code that previously read environment variables now reads typed configurations:
# Before
import os
DATABASE_URL = os.environ["DATABASE_URL"]
# After
from ccai.dataprofiles.wellknown import DataProfileManagerSingleton
from ccai.credentials.wellknown import CredentialManagerSingleton
profile = DataProfileManagerSingleton().lookup_profile("staging-db-rw")
cred = CredentialManagerSingleton().lookup_credential(profile.credential)
DATABASE_URL = f"postgresql://{cred.username}:{cred.password}@{profile.host}:{profile.port}/{profile.dbname}"
The migration is incremental: one configuration value at a time.
Until every reader has migrated, the team can keep the env-var
fallback in place via the chapter-10 EnvBackend (which lets a
credential reference resolve from $DATABASE_URL if it isn't
otherwise resolvable). This is the same migration shim from
chapter 7.
Step 4 — Replace make setup with ccai provisioning apply
The imperative setup script becomes a wrapper around the spec:
# Makefile (after migration)
setup:
# Toolchain (the legitimate machine-setup imperative)
asdf install
poetry install
# Everything else is declarative
AM_PROFILE=local-dev ccai provisioning apply --spec config/specs/spec-local-dev.yaml
Two lines of toolchain installation; one line of declarative
provisioning. The previous five-line imperative chain — install
deps, create db, seed data, generate keys, copy .env.example —
is gone, replaced by the spec-driven equivalent.
Step 5 — Activate the profile
The new fresh-checkout sequence is two commands:
git clone <repo>
cd <repo>
make setup
AM_PROFILE=local-dev ccai testbase test run
That's it. No .env.local, no README scavenger hunt, no
half-day onboarding ritual. The developer's machine has the
toolchain (from asdf install and poetry install); the local
infrastructure is provisioned (from the spec); the configurations
are typed and per-environment (from the profile); the test suite
runs.
Step 6 — Empty the README
The README's "you might also need to..." section, after migration:
Fresh checkout? — Install asdf or mise (one-time machine setup) — Run
make setup— Runccai testbase test run --profile local-dev
Three lines. None of them are tribal knowledge. None of them will silently rot when the seventh dependency shifts. Each line is small enough to keep correct.
The pattern is universal
The migration above is concrete to one project, but the pattern generalises:
- Inventory every piece of configuration state the project
currently reads from environment variables,
.envfiles, shell scripts, the README, or imperative setup logic. - Group the inventory by category (Specifications, Landscapes, Topologies, Runtimes, Credentials, Data Profiles).
- Author the local-dev configuration for each category
under
config/<category>/<file>-local-dev.yaml. - Author the local-dev profile that selects them as a bundle.
- Author the local-dev specification that captures the one-time machine-setup imperatives.
- Migrate application code to read typed configurations
instead of environment variables. Use the chapter-10
EnvBackendas a transitional shim during the migration. - Replace
make setup's imperative steps with the specification-driven equivalent. - Empty the README of tribal knowledge; what remains is the
genuine one-time machine-setup imperative (toolchain plus
make setup).
The pattern is the same regardless of project size. A small project might compress steps 3 and 4 into one combined configuration; a large project might split each category into multiple files for clarity. The discipline is the inventory and the elimination of tribal-knowledge prose, not the file-count pedantry.
What about existing developers' workflows?
A common objection: "the migration breaks every developer's existing setup." Three responses to this objection.
Existing setups keep working during migration
The chapter-10 EnvBackend and the chapter-14 profile system are
both opt-in. When AM_PROFILE is unset, the system runs with
the existing shipped behaviour — every existing os.environ.get(...)
in the codebase still works. The migration can run alongside
the existing setup for as long as needed. Each developer migrates
to the profile system on their own schedule.
The migration produces parity, not divergence
The local-dev profile replaces the .env.local content. The
values in the profile are the same values the developer had in
.env.local (or in their direnv, or in their shell history). No
behaviour change — only a behaviour naming change. The first
run of AM_PROFILE=local-dev ccai testbase test run produces
the same results as the previous pytest invocation against
.env.local.
The benefits accrue to new developers immediately
Existing developers see incremental improvement as the migration progresses. New developers see the full benefit on day one — they never set up the old way at all. A team that's hiring rapidly sees the steepest payoff: every new hire's onboarding time drops by a half-day, and the team's senior engineers stop being asked the same setup questions every week.
What this discipline is not
Three boundaries to keep clear:
Not a replacement for tools that work
The chapter is critical of .env.local as a content mechanism.
It is not critical of file-based configuration as a concept —
the typed configuration files this chapter proposes are
themselves files in version control. The difference is
structure: .env.local is untyped key-value pairs; a typed
configuration file is a structured document with a known schema.
Tools like direnv and asdf that solve real local-dev problems
(automatic Python-version selection, project-scoped tool
shimming) remain useful and complementary. The discipline asks
those tools to stop carrying application configuration in their
files; the toolchain-management uses are unaffected.
Not "one tool to rule them all"
The chapter has not proposed that every team adopt
codecraftai-* packages exclusively. The discipline of typed
configuration is portable; teams that prefer different
implementations of the same pattern (Pydantic Settings, dynaconf,
Hydra) can apply the same discipline using their preferred
toolkit. What matters is that configuration is structured, named,
and per-environment-scoped — not whose YAML loader does the
loading.
Not a security boundary
The local-dev profile loads credentials and runs them through the
chapter-10 resolver. This is not a security boundary against a
compromised developer machine. A developer whose laptop has been
compromised can read every secret that resolves into their local
profile; the chapter's ${KEYCHAIN:...} and ${PROMPT:...}
backends are operational conveniences, not security defences.
For real protection of production secrets, the discipline of chapter 12 applies: production secrets are never resolvable from local-dev profiles, because the resolver in local-dev does not have the production Vault token. The local-dev profile exists so developers don't accidentally try to use production credentials; it is not a fence around them if the developer attempts to.
Summary
- The local-dev tax is the friction every developer pays on
fresh checkout. It manifests as
.env.localfiles,direnvconfigs,make setupscripts, and the README's "you might also need to..." section. - Every piece of the tax is a missing typed configuration. The inventory groups each piece into one of the six categories.
- Migration produces a fresh-checkout flow of three commands:
install the toolchain, run
make setup(now declarative), activate the local-dev profile. No tribal knowledge, no scavenger hunt, no half-day ritual. - The migration is incremental: each configuration value can
migrate independently using the chapter-10
EnvBackendas a transitional shim. Existing developer workflows keep working during the migration; new developers see the full benefit on day one. - The pattern is universal: inventory, group, author per category, author the profile, replace imperative setup with declarative specification, migrate code, empty the README.
- The discipline is not a replacement for tools that work (direnv, asdf for what they're good at), not "one toolkit exclusively," and not a security boundary against a compromised local machine.
The next chapter is the operational payoff of this discipline: when a CI failure shows up that doesn't reproduce locally, the captured profile from the failing CI run lets the developer replay the exact configuration on their laptop and debug the difference in their IDE.