"It works on my machine" is usually said as a joke. In a real product team, it is a signal that an important part of the system exists only in someone's memory.

The application may be in Git. The database schema may be in Git. The deployment workflow may be in Git. Yet the details required to run the project locally often live elsewhere: a particular Node version installed six months ago, a globally installed command-line tool, an undocumented environment variable, an extension configuration, a locally running database, or a workaround that only one developer knows.

That is not merely an onboarding annoyance. It is an architecture problem.

If the route from a clean computer to a working application is unreliable, then the team does not have one development environment. It has several personal variations of an environment. That variability makes failures harder to reproduce, reviews slower, releases riskier, and the project more dependent on individual people.

The remedy is to make the development environment a first-class, versioned part of the product.

The hidden system behind the application

When we say "the application," we often mean source code. In practice, a working application is a combination of several interdependent systems:

  • Runtime and package-manager versions
  • Operating-system packages and command-line tools
  • Application dependencies
  • Databases, caches, queues, and local object storage
  • Environment configuration and safe local secrets
  • Linting, formatting, test, and build commands
  • Schema migration tools
  • The instructions that connect all of the above

The source code can be identical across two laptops while the overall system is not. One developer may have PostgreSQL 16, another PostgreSQL 14. One has a native dependency installed; one does not. One runs a newer Node release than the production target. Each difference may be harmless until it is the reason a migration, test, or build behaves differently.

The issue is not that developers should have identical computers. They should not need identical computers. The issue is that the project needs an explicit, repeatable contract for the parts that affect its behavior.

A versioned environment is an executable agreement

A containerized development environment turns setup from a loose document into an executable definition. The repository can declare the base runtime, dependencies, editor extensions, mounted source directory, supporting services, and setup commands. A new contributor opens the repository, starts the environment, and receives the same intended foundation as the rest of the team.

For a modern web product, the relationship looks like this:

The point is not to put every production component in a local container. It is to ensure that the tools, services, and commands needed to develop and validate the application are deliberate, reviewable, and easy to recreate.

The Dev Container specification provides a portable way to describe this environment. Visual Studio Code, GitHub Codespaces, and other compatible tools can use the same configuration, which is useful because it reduces the difference between a local workspace and a cloud-hosted development workspace.

The architectural consequences of inconsistency

Environment drift often looks small until it compounds.

Imagine a team building a Next.js and PostgreSQL application. A migration works on one laptop because that developer has a local database with an extension enabled. Continuous integration fails because the fresh database used there does not. The fix works in CI but breaks a teammate's machine because their Node version is older. A release is delayed while the team discovers that the deployment image does not include a tool the local workflow assumed was global.

None of those failures is especially dramatic alone. Together, they create a delivery culture where people are hesitant to change shared code, deployment knowledge becomes concentrated, and simple work takes longer than it should.

This is why development environment quality affects the whole architecture:

If the environment is implicitIf the environment is versioned
Setup is a handoff between people.Setup is a repository capability.
Failures vary by machine.Failures are easier to reproduce.
Tooling changes happen informally.Tooling changes are reviewed in pull requests.
New contributors discover prerequisites by failing.New contributors begin from a known baseline.
Delivery depends on personal workarounds.Delivery depends on documented, testable systems.

That last row is the most important. A product team should be able to add a developer, replace a computer, or recover from a bad local state without rebuilding knowledge from scratch.

Containers are useful because they create boundaries

Containers are not valuable because they are fashionable. They are useful because they give the project a clear boundary around runtime behavior.

Inside a well-designed development container, the project can standardize on a Node or Python version, install the command-line tools it needs, and run the same package scripts the continuous-integration pipeline will run. A compose.yaml file can start local PostgreSQL and other dependencies with known ports, health checks, and data volumes.

That is especially helpful for data-backed applications. A developer should be able to run a migration against a local database, seed safe sample data, execute tests, and reset the environment without hunting through a wiki. The infrastructure may be small, but it should be predictable.

There is a healthy boundary to keep in mind: do not pretend local is production. Production has different credentials, network policies, scale, backup responsibilities, and failure modes. The objective is behavioral alignment, not a miniature copy of the cloud. Local development should faithfully exercise the application's important interfaces and delivery commands while remaining fast and safe for daily work.

The simplest useful baseline

For a small TypeScript application, a strong baseline is often surprisingly modest:

.
├── .devcontainer/
│   └── devcontainer.json
├── .github/workflows/
│   └── ci.yml
├── compose.yaml
├── Dockerfile
├── package.json
├── prisma/
│   └── schema.prisma
└── README.md

Each file has a clear role:

  • devcontainer.json defines the developer experience: image or Dockerfile, editor extensions, container settings, and setup lifecycle commands.
  • compose.yaml defines dependent services used locally, such as PostgreSQL.
  • Dockerfile expresses the application runtime and build assumptions.
  • package.json exposes standard commands such as lint, typecheck, test, build, and database operations.
  • ci.yml proves the project can perform its required checks in a clean environment.

This is not ceremony. It is an inventory of the minimum system required to work on the product responsibly.

GitHub is where environment decisions become team decisions

Versioning the container configuration is only half of the pattern. The other half is making the repository's expected checks run in GitHub on every pull request.

When the developer runs npm run lint, npm run typecheck, npm test, and npm run build locally, those commands should have corresponding jobs in continuous integration. The tools do not need to be byte-for-byte identical in every situation, but they should test the same important assumptions.

This closes the loop. A pull request is no longer just a code-review request; it is an artifact that has traveled a defined route through validation. Failures are discovered in a clean runner, not during a production release or on a reviewer's laptop.

GitHub's workflow documentation describes a workflow as a configurable automated process made of one or more jobs. That is a useful way to think about it: the workflow is part of the product's operating design, not an afterthought added after development is "done." GitHub Actions workflow syntax

What this approach does not solve automatically

Containerization can become its own kind of complexity if it is treated as a badge of sophistication. It will not fix unclear requirements, weak tests, or a deployment process nobody owns. It also does not eliminate the need for carefully managed secrets, production backups, and thoughtful database migration planning.

Some practical mistakes to avoid:

  • Installing everything globally in the container. Use the project's package manifest and lockfile so dependencies remain explicit.
  • Making the local setup more elaborate than the product. Start with the services the application truly needs.
  • Hiding initialization in undocumented scripts. Put the command in a versioned lifecycle step or document why it remains manual.
  • Treating CI as a different world. Keep scripts and runtime versions aligned enough that a local pass means something.
  • Putting real production credentials in local configuration. Use local-only values, controlled secrets, and the minimum access required.

The design should lower cognitive load. If a new contributor cannot understand the starting point, the environment is not yet doing its job.

A better definition of "done"

For a small team, a healthy standard is not "every developer has the project working." It is:

A clean machine can open the repository, start the documented environment, run the required checks, and contribute a validated pull request without relying on private setup knowledge.

That is a much stronger capability. It protects the team from turnover, speeds up experimentation, and lays the groundwork for preview environments, staging deployments, and repeatable production releases.

The development environment is part of the system you are building. Version it, review it, test it, and improve it like any other part of the product.

Practical checklist

  • The repository declares the application runtime version.
  • A dev container or equivalent reproducible workspace opens from a clean machine.
  • Local dependencies such as PostgreSQL are declared, not manually installed.
  • One command runs each required check: lint, typecheck, test, build, and migration validation where appropriate.
  • The same core checks run on every pull request.
  • Local configuration never requires production credentials.
  • A new contributor can follow the README without a private support session.

Next in the series: Build a Real Dev Environment: Next.js, TypeScript, PostgreSQL, and Docker. We will turn this principle into a concrete project structure and explain the local-versus-production boundaries that keep it practical.