Environment Setup Standardisation
Consistent configuration across developer machines and production environments.
Overview
Environment inconsistency is a silent tax on engineering productivity. It produces a class of problems that look like bugs but aren't: a test that fails on one machine and passes on another, a service that works locally but breaks in CI, an onboarding that takes two days because nobody documented what version of Node is required.
Standardised environments make every machine equivalent. Not similar — equivalent. The same tool versions, the same environment variables documented in the same place, the same local services running the same way. When environments are equivalent, "it works on my machine" becomes a meaningful statement.
Why It Matters
"Works on my machine" wastes real engineering time. When a problem is environment-specific, the first question is always "is this a bug or is something wrong with my setup?" Answering it costs time from two engineers. Reproducible environments answer it instantly.
Inconsistent environments produce unreproducible bugs. A service tested against SQLite locally and Postgres in production will behave differently in ways that only surface in production. A Node version mismatch can change how certain APIs behave. These bugs are the hardest to debug because they cannot be reproduced on the machine where the debugging happens.
Onboarding speed reflects environment discipline. How long it takes a new engineer to go from zero to a running development server is a direct measure of how well the team has documented and standardised its environment. A well-standardised project should take under an hour. A poorly standardised one can take days.
Dev/prod parity prevents late surprises. The environment where code is developed should resemble the environment where it runs in production. The closer the resemblance, the fewer the surprises at deploy time.
Standards & Best Practices
.env.example is mandatory
Every environment variable required by the application must be documented in .env.example. The file lives in the repository root, is committed to version control, and contains no secret values — only documented variable names with example values and explanations.
# .env.example
# Database connection
DATABASE_URL=postgres://user:password@localhost:5432/myapp_dev
# Get the production value from 1Password: "MyApp > Database > DATABASE_URL"
# External API
STRIPE_SECRET_KEY=sk_test_placeholder
# Use the test key from the Stripe dashboard for local development
# Feature flags
ENABLE_NEW_CHECKOUT=false
# Set to true to enable the new checkout flow locally.env itself is in .gitignore and never committed. If a developer copies .env.example to .env and the application starts without modification, the setup is well-designed.
Pin all tool versions explicitly
Do not specify minimum versions — specify exact versions. "Requires Node 20" produces a team on 20.0.0, 20.9.1, 20.11.0, and 20.18.0. Some of those have security vulnerabilities; some have different behavior. Pinning means everyone runs the same version.
# .tool-versions (asdf)
nodejs 20.11.0
python 3.11.7
terraform 1.7.2# .nvmrc (nvm — Node only)
20.11.0Pin the package manager version too. Add "packageManager": "pnpm@9.1.0" to package.json. This is enforced by Corepack.
Lockfiles are committed and respected
The lockfile (pnpm-lock.yaml, package-lock.json, Pipfile.lock, go.sum) is not optional. It is the precise record of every dependency's version. Without it, two engineers running pnpm install on the same day may get different results.
In CI: always use --frozen-lockfile (pnpm) or --ci (npm). Never run an unfrozen install in CI — it silently upgrades dependencies and defeats the purpose of a lockfile.
Local setup is documented in the README
The README should contain a setup section that covers the full path from a clean machine to a running development server. It should list prerequisites, installation steps, and a verification step:
## Getting started
### Prerequisites
- asdf (https://asdf-vm.com/) — installs tool versions from .tool-versions
- Docker Desktop — for local services (Postgres, Redis)
### Setup
1. Install tools: `asdf install`
2. Install dependencies: `pnpm install`
3. Start local services: `docker compose up -d`
4. Copy env file: `cp .env.example .env`
5. Run migrations: `pnpm db:migrate`
6. Start the dev server: `pnpm dev`
### Verify
Open http://localhost:3000 — you should see the app home page.If any step requires a team member's help or a document that isn't linked from the README, the setup is not fully documented.
Dev environment mirrors production topology
The development environment should use the same database engine, queue system, and cache layer as production. If production uses Postgres, local development uses Postgres — not SQLite. If production uses Redis, local development uses Redis — not an in-memory fallback.
Differences between environments are where the bugs live. Minimise differences.
No global tool installations in setup scripts
Setup scripts that run npm install -g or brew install globally create machine-specific state that cannot be reliably reproduced. Use local installations (pnpm add -D), version managers (asdf, nvm), or containers. Every dependency should be capturable in a file that can be committed.
How to Implement
Step 1 — Create and maintain .env.example
Audit every environment variable the application reads. For each one, add an entry to .env.example with a comment explaining what it does, a safe example value, and where to get the real value for local development. Delete entries for variables that no longer exist.
Add a CI check that fails if .env is ever committed:
- name: Check for committed .env files
run: |
if git ls-files | grep -E '^\.env$'; then
echo ".env file is committed — remove it and add to .gitignore"
exit 1
fiStep 2 — Pin runtime versions
Add .tool-versions to the repository root and commit it. If the team uses nvm instead of asdf, use .nvmrc. The goal is that asdf install (or nvm use) resolves to the exact version with no ambiguity.
Add the package manager field to package.json and enable Corepack:
{
"packageManager": "pnpm@9.1.0"
}corepack enableCorepack intercepts pnpm/npm/yarn calls and enforces the declared version. Engineers who run the wrong version of pnpm get an error, not silent wrong behavior.
Step 3 — Write a setup section in the README
Every repository should have a "Getting started" or "Setup" section. Write it from the perspective of a new engineer who has never seen the codebase. Test it by asking a new team member to follow it and noting every point they needed to ask for help.
Step 4 — Use Docker Compose for local services
Dependent services (databases, queues, caches, third-party service emulators) should be declared in a docker-compose.yml and started with a single command. This avoids the "I have Postgres installed globally at version 14, you need version 15" problem.
# docker-compose.yml
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp_dev
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- '6379:6379'
volumes:
postgres_data:docker compose up -d # Start services in the background
docker compose down # Stop and remove containersStep 5 — Validate environment variables at startup
The application should fail fast and clearly if a required environment variable is missing. A cryptic error deep in the stack trace is much harder to diagnose than "ERROR: DATABASE_URL is required but not set."
// lib/env.ts
const required = ['DATABASE_URL', 'STRIPE_SECRET_KEY', 'NEXTAUTH_SECRET'];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}Run this validation at application startup, not on first use. Fail immediately on boot, not during the first request.
Tools & Templates
.env.example template
# Application
NODE_ENV=development
PORT=3000
APP_URL=http://localhost:3000
# Database
DATABASE_URL=postgres://user:password@localhost:5432/myapp_dev
# Authentication
NEXTAUTH_SECRET=change-me-to-a-random-32-char-string
# Generate with: openssl rand -base64 32
# External services (use test/sandbox keys locally)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Feature flags
ENABLE_FEATURE_X=false.tool-versions (asdf)
nodejs 20.11.0
python 3.11.7
pnpm 9.1.0
terraform 1.7.2
awscli 2.15.10Docker Compose with health checks
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: myapp_dev
ports:
- '5432:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U user']
interval: 5s
timeout: 5s
retries: 5The healthcheck block means docker compose up --wait will only return once the service is ready to accept connections — not just once the container has started.
VS Code Dev Container (devcontainer.json)
For teams who want fully containerised development environments:
{
"name": "MyApp Dev",
"image": "mcr.microsoft.com/devcontainers/javascript-node:20",
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
},
"postCreateCommand": "pnpm install",
"customizations": {
"vscode": {
"extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
}
}Common Pitfalls
Committing .env files. Secrets in .env committed to a repository are compromised — even if you delete the file, they remain in git history. Add .env to .gitignore immediately, and if it was ever committed, rotate all secrets it contained.
Undocumented environment variables. When a new engineer clones the repository and the app fails to start with no useful error message, the problem is almost always a missing environment variable that nobody documented. Every variable in the application must have a corresponding entry in .env.example.
Assuming global tool versions. "It works if you have Node 20 installed" is not a guarantee — it is a hope. Version managers + .tool-versions or .nvmrc make the version explicit and automatically enforced.
Different database engines in dev and production. SQLite in development + Postgres in production introduces an entire class of bugs that are invisible locally but appear in production: different SQL dialects, different transaction behavior, different constraint enforcement. Use the same database everywhere.
No startup validation. An application that starts successfully despite missing configuration, then crashes on the first database query with a connection error, is harder to debug than one that refuses to start with "DATABASE_URL is required." Validate required variables at boot.