Dev Containers: Put the Developer Setup in the Repository

How .devcontainer/devcontainer.json turns a written onboarding guide into an executable, versioned workspace that any editor can open.

The previous article in this series described a lean local stack: a multi-stage Dockerfile, a compose.yaml for PostgreSQL, and a clear line between what runs locally and what belongs to production. That stack solves service consistency. It does not yet solve editor consistency. Two developers can run the same containers and still have different Node versions on their host machines, different globally installed CLIs, and different extension sets shaping how they write and lint code.

A dev container closes that remaining gap. It moves the developer's actual workspace—not just the application's dependent services—into a definition the repository owns.

From compose.yaml to a full developer workspace

compose.yaml answers "what services does this application depend on locally?" A dev container answers a different question: "what does it mean to develop inside this project?" That includes the runtime, the shell tools, the editor extensions, the formatting rules, and the commands that should run automatically when the workspace opens.

The Dev Container specification is deliberately editor-agnostic, but the most common entry point is Visual Studio Code's Dev Containers extension, followed closely by GitHub Codespaces. Both read the same .devcontainer/devcontainer.json file, which means the configuration is not "VS Code config" so much as "workspace config that VS Code happens to understand."

.
├── .devcontainer/
│   └── devcontainer.json
├── compose.yaml
├── Dockerfile
├── package.json
└── prisma/
    └── schema.prisma

Nothing here is exotic. The dev container simply becomes one more file the team reviews in pull requests, the same as a migration or a workflow file.

Anatomy of devcontainer.json

A practical configuration for a Next.js, TypeScript, and PostgreSQL project usually references the same compose.yaml already used for local development, rather than duplicating service definitions in a second place.

{
  "name": "web-app",
  "dockerComposeFile": ["../compose.yaml"],
  "service": "app",
  "workspaceFolder": "/workspace",
  "shutdownAction": "stopCompose",
  "features": {
    "ghcr.io/devcontainers/features/github-cli:1": {},
    "ghcr.io/devcontainers/features/node:1": {
      "version": "20"
    }
  },
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "prisma.prisma",
        "ms-azuretools.vscode-docker"
      ],
      "settings": {
        "editor.formatOnSave": true,
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "typescript.tsdk": "node_modules/typescript/lib"
      }
    }
  },
  "forwardPorts": [3000, 5432],
  "postCreateCommand": "npm install",
  "postStartCommand": "npx prisma generate",
  "remoteUser": "node"
}

A few choices are doing real work here:

  • dockerComposeFile and service attach the editor to the same app service already defined for local development, instead of maintaining a parallel image.
  • features pull in small, composable installers—the GitHub CLI, a specific Node version—without hand-rolling shell scripts inside the Dockerfile.
  • forwardPorts documents, in one place, which ports a developer should expect to reach from their host browser.
  • remoteUser keeps the container running as a non-root user, which avoids file-permission surprises on bind-mounted source directories.

Choosing a base image and features deliberately

It is tempting to reach for the heaviest, most complete base image available "just in case." Resist that. A dev container should contain what the project actually needs to lint, test, build, and run migrations—nothing more. Extra tooling means a slower rebuild, a larger image to pull in Codespaces, and more surface area to keep patched.

A useful default is to build the dev container from the same base as the application's Dockerfile, then layer development-only tools on top with features or a short RUN block. That way the two images stay close enough that "it built in the dev container" is meaningful evidence about how the production build will behave.

Lifecycle commands: onCreate, postCreate, postStart

Dev containers expose several lifecycle hooks, and choosing the right one matters more than it first appears:

HookRuns whenGood for
onCreateCommandOnce, when the container is first builtInstalling OS-level packages baked into the image layer
postCreateCommandOnce, after the workspace is creatednpm install, initial database setup
postStartCommandEvery time the container startsnpx prisma generate, warming a cache
postAttachCommandEvery time an editor attachesPrinting a welcome message or status check

Putting a slow, one-time step in postStartCommand means paying that cost on every restart. Putting a step that depends on a fresh node_modules in onCreateCommand means it can run before dependencies exist. Matching the hook to its actual frequency is what keeps the workspace fast to resume, not just fast to create.

Editor settings and extensions as code

The customizations.vscode block is easy to under-value because it looks cosmetic. It is not. Format-on-save rules, the pinned ESLint and Prettier extensions, and the TypeScript SDK path all affect whether two developers produce identical diffs for identical intent. Without this block, "run the formatter" is a personal habit; with it, the formatter is part of the workspace a new contributor receives automatically.

This is also where a Prisma extension, a Docker extension, or a REST client belongs—not because every developer must use them, but because recommending them in one reviewed file is cheaper than repeating the advice in a wiki page nobody updates.

Connecting to the compose services already in the repo

Because the dev container attaches to the existing compose.yaml, the application's DATABASE_URL can point at the db service by its Compose network name rather than localhost. This is a small detail that saves real confusion: a developer working outside the container connects to PostgreSQL through a forwarded localhost port, while a developer working inside the container connects through the internal service name. Documenting both in .env.example prevents the "why won't my database connect" question from resurfacing every few months.

Codespaces and local Docker Desktop: same file, two entry points

Because the specification is portable, the same devcontainer.json supports two distinct workflows without extra configuration. A developer with Docker Desktop installed opens the repository locally and reopens it in the container. A developer without local Docker—or one working from an iPad, a loaner laptop, or a fresh machine mid-incident—opens a GitHub Codespace and gets the identical environment, hosted remotely. Neither path requires bespoke setup instructions, because the instructions already live in the file both paths read.

This matters more than it sounds. It means "onboarding" stops being a scheduled task someone performs for a new hire and becomes a button a new contributor can press themselves, at any hour, without waiting on a colleague's calendar.

Common mistakes

  • Reinventing services the compose file already defines. If compose.yaml starts PostgreSQL, the dev container should reference it, not spin up a second, disconnected database.
  • Overloading postCreateCommand with slow, rarely-changing setup. Bake stable OS packages into the image instead of installing them on every fresh container.
  • Forgetting to forward a port the application actually needs, which surfaces as "it works but I can't see it in the browser."
  • Pinning nothing. Unpinned feature versions can quietly change the workspace between two contributors who built their containers weeks apart.
  • Treating the dev container as optional documentation. If it drifts from what the Dockerfile actually installs, it stops being trustworthy and developers route around it.

Practical checklist

  • .devcontainer/devcontainer.json exists and is committed to the repository.
  • The dev container attaches to the same compose.yaml used for local development, rather than duplicating services.
  • Lifecycle commands are matched to their actual frequency (onCreate vs. postCreate vs. postStart).
  • Required ports are explicitly forwarded.
  • Recommended extensions and formatter settings are defined in the file, not in a wiki.
  • A remoteUser is set so the container does not run as root by default.
  • The configuration has been verified to work in both a local Docker Desktop session and a Codespace.

Previous: Build a Real Dev Environment: Next.js, TypeScript, PostgreSQL, and Docker

Next: Designing a GitHub Workflow That a Small Team Will Actually Use. With the workspace itself now versioned, the series turns to the process wrapped around it: branching, review, and the repository conventions that keep a small team moving without friction.