Migrating from Docker Compose to DevContainers
You already run your app with Docker Compose and want the devcontainer experience without rewriting your stack. This page migrates in place: keep your existing docker-compose.yml, add a thin devcontainer layer that attaches to a workspace service, and layer Features and IDE config on top — so your production-like stack becomes your dev environment.
The reason this matters is that a Compose file is usually the single most accurate description of how your services actually fit together: the database image and its version, the network aliases each service answers to, the named volumes that hold data between runs, and the environment variables that wire secrets and connection strings into place. Rewriting all of that into a hand-authored Dockerfile and a from-scratch devcontainer.json throws away months of accumulated, working knowledge and invites drift the moment production and development disagree on a detail. Referencing the same docker-compose.yml keeps one source of truth, so a bump to the Postgres tag or a new environment variable lands in both worlds at once.
Reach for this in-place migration whenever the Compose file predates any interest in devcontainers and still runs the app faithfully — a typical multi-service web app with an application container plus a database, cache, and maybe a message broker. The mental model is a layer cake: the base docker-compose.yml stays at the bottom as the authoritative stack, a small docker-compose.devcontainer.yml override sits above it carrying only dev-time concerns, and devcontainer.json sits on top choosing which service the editor lives inside and what tooling gets injected. Nothing below a layer knows or cares that the layer above exists, which is exactly why the same file can serve both docker compose up in CI and an attached editor session on your laptop.
Prerequisites
You need a working Compose project and a service the editor can attach to.
- An existing
docker-compose.ymlthat runs your app. - A service suitable as the workspace (or a small override to add one).
- The Dev Containers extension or CLI.
The prerequisite people underestimate is the "service suitable as the workspace" bullet. A backing service such as a bare Postgres or Redis container is a poor attach target: its image has no shell tooling, no compiler, and no copy of your source, so the editor lands in a container that cannot run your code. The workspace service is whichever container holds — or can be made to hold — your application code and its language runtime. If your Compose file only defines infrastructure and your app runs on the host today, add a thin application service first, even one built FROM your language's base image; that container becomes the home the editor attaches to. Confirm your Dev Containers extension or the devcontainer CLI can already reach the Docker socket before you start, because every step below shells out to Compose through that same daemon.
Step-by-Step Implementation
- Keep your Compose file and add a devcontainer override for dev-only concerns.
# docker-compose.devcontainer.yml
services:
app:
command: sleep infinity
volumes:
- ../..:/workspace:cached
This override redefines only two keys on the app service, and Compose merges them onto the base definition rather than replacing it, so the image, ports, environment, and depends_on from docker-compose.yml all survive untouched. The command: sleep infinity is the load-bearing line: your production entrypoint probably starts a server and exits or blocks in a way the editor cannot inhabit, whereas an idle sleep keeps the container alive with a shell the Dev Containers agent can enter and start processes inside on demand. The ../..:/workspace:cached bind mount maps your repository into the container so edits on the host appear instantly inside it; the cached flag relaxes host-to-container consistency, which noticeably speeds up large trees on macOS and Windows without changing behaviour on Linux. Keeping this file separate is what prevents a sleep infinity from ever leaking into the stack you ship.
- Reference both files from
devcontainer.jsonand attach to the workspace service.
{
"name": "Migrated Stack",
"dockerComposeFile": ["../docker-compose.yml", "docker-compose.devcontainer.yml"],
"service": "app",
"workspaceFolder": "/workspace",
"remoteUser": "node"
}
The order of the dockerComposeFile array is significant: Compose applies files left to right, so the base ../docker-compose.yml is read first and docker-compose.devcontainer.yml overlays it, giving the override the last word on the two keys it touches. The service key names which of the merged services the editor attaches to — here app, the workspace container — while every other service in the stack still comes up as its dependency, so the database and cache are running and reachable by name from inside the session. workspaceFolder must match the container-side path of the bind mount you defined in the override (/workspace), otherwise the editor opens an empty directory even though the code is mounted a few paths away. remoteUser: node runs your shell and tools as a non-root account, which keeps files you create owned by a normal user and avoids the root-owned artifacts that plague careless mounts.
- Layer Features and IDE config without touching the production Compose file.
{
"features": { "ghcr.io/devcontainers/features/node:1": { "version": "20" } },
"customizations": { "vscode": { "extensions": ["dbaeumer.vscode-eslint"] } }
}
Features and customizations live in devcontainer.json rather than in either Compose file on purpose: they are editor- and developer-specific, so putting them here keeps the production image lean and lets each contributor's tooling evolve without a stack rebuild. The node:1 Feature layers a pinned Node 20 toolchain onto the attached container after it starts, which is how you add a language runtime the base image lacks without editing the Dockerfile behind your app service. Pinning "version": "20" rather than accepting a floating latest is what keeps the toolchain reproducible; the :1 on the Feature reference pins the Feature's own major version so its install script does not change shape underneath you. The extensions array pre-installs the linter for anyone who opens the project, so a new contributor gets the same editor setup as everyone else on first launch instead of hunting through a setup document.
- Bring it up and confirm the app and its services run as before.
devcontainer up --workspace-folder .
This command reads devcontainer.json, resolves the merged Compose files, and brings the whole stack up, then leaves the workspace service running under sleep infinity for the editor to attach to. Running the CLI form rather than clicking "Reopen in Container" is worth doing at least once because its output shows exactly which Compose files were combined and which service was chosen, so a mis-typed path or wrong service value surfaces as a readable error instead of a silent attach to the wrong container. The --workspace-folder . argument points the CLI at the current directory as the project root, from which it locates .devcontainer/devcontainer.json; once it reports the container is up, a docker compose ps from inside confirms the database and cache came along and are healthy, which is the real proof the migration preserved the stack rather than just booting the app container alone.
Common Pitfalls
Migration snags come from overwriting production config or attaching to the wrong service.
The most common ownership trap surfaces the first time you write a file from inside the container. If you skip remoteUser and let the workspace service run as root, every file the editor creates through the /workspace bind mount is written back to the host as root-owned, and your host user then cannot edit or delete it without sudo. The fix is the remoteUser: node you already set, paired with a base image whose UID matches your host account; when they line up, files created in the container and files created on the host share ownership and neither side steps on the other. The same care applies to any named volume the override introduces for caches — package manager or build caches mounted as root will silently defeat themselves the moment a non-root process tries to write to them.
The subtler pitfall is an override that quietly diverges from production. It is tempting to add an extra port publish, a mounted config file, or a tweaked environment variable to the override "just for dev," but every such addition widens the gap between the stack you develop in and the stack you ship, which is the exact drift this migration set out to avoid. Keep docker-compose.devcontainer.yml to the two dev-only truths — the long-running command and the workspace mount — and push anything that changes application behaviour back into the base file where both worlds inherit it. When a dev-only difference is genuinely unavoidable, comment it in the override so the next reader knows it is deliberate rather than accidental drift.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Production Compose got dev-only changes | Edited base file directly | Put dev tweaks in an override file |
| Editor attaches to the wrong service | Wrong service value | Set service to the workspace container |
| Source not editable in container | No workspace bind mount | Mount the repo in the override |
| Services differ from production | Override changed more than intended | Keep the override minimal and dev-only |
Conclusion
Migrate by addition, not rewrite: keep the existing Compose file authoritative, add a minimal dev override for the workspace mount and long-running command, and let devcontainer.json attach and layer IDE config. The same stack you ship now becomes the stack you develop in.
The strategic payoff is a single, honest source of truth for how your services relate. Because the base docker-compose.yml is never edited for dev-only reasons, a change to the database version, a new backing service, or a corrected network alias lands in production and in every developer's environment from one commit, and the two can no longer silently disagree. That is the same reproducibility principle that underlies pinning a Feature to node:1 at version 20 and pinning image tags: name the exact thing you depend on once, in one place, and let every consumer inherit it rather than re-deriving it. The override and devcontainer.json are thin, purpose-built layers on top of that pinned base, not a parallel definition competing with it.
Seen that way, migrating in place is less a one-off chore than an ongoing contract. The workspace mount keeps your source editable, the sleep infinity command keeps the container inhabitable, and the attach-and-layer split keeps editor tooling out of the shipped image — three small guarantees that hold as the underlying stack grows. When a teammate adds a queue or swaps a cache in the base Compose file months from now, your devcontainer picks it up on the next devcontainer up with no extra work, because you invested in referencing the stack rather than copying it.
FAQ
Do I have to rewrite my docker-compose.yml?
No — that is the point of migrating in place. Keep your existing Compose file authoritative and add a small docker-compose.devcontainer.yml override for dev-only concerns (the workspace mount and a sleep infinity command). devcontainer.json references both, so production config stays untouched. Compose merges the override onto the base by service name, so you are adding two keys to the app service, not restating it. If you ever find yourself copying whole service blocks into the override, stop — that is a sign a change belongs in the base file instead.
Which service should the editor attach to?
Attach to your application/workspace service — the one holding your source and toolchain. Backing services (databases, caches) remain separate Compose services you reach by name. The service key in devcontainer.json names the attach target. Choosing a database or cache container by mistake lands the editor somewhere with no source and no compiler, which is the usual cause of an empty or broken workspace. If no existing service fits, add a thin application service to the stack first and point service at that.
Can I still run the stack the old way with plain Compose?
Yes. Because the base Compose file is unchanged, docker compose up still works exactly as before. The devcontainer layer is additive — it references the same file plus a dev override, so both workflows coexist. CI that runs docker compose up never sees docker-compose.devcontainer.yml because CI does not read devcontainer.json, so the sleep infinity and workspace mount stay entirely out of your pipelines and production.
Where should the override and devcontainer.json live?
Keep devcontainer.json and docker-compose.devcontainer.yml inside a .devcontainer/ directory, and note the relative paths that implies: the base Compose file sits a level up at ../docker-compose.yml, while the override is a sibling referenced as docker-compose.devcontainer.yml. Those are exactly the paths in the dockerComposeFile array above. Getting the ../ wrong is the most frequent reason devcontainer up fails to find the base file.
How do I connect to the database from inside the migrated container?
Use the service's Compose name as the hostname, just as your app does in production. Because devcontainer.json brings the whole stack up, the workspace container joins the same Compose network, so a connection string pointing at db:5432 resolves to the database service without any port publishing. You only need published ports when a tool on the host, rather than code in the container, must reach the service.
Will rebuilding the container wipe my database?
No, as long as your data lives in a named volume declared in the base Compose file. Rebuilding the workspace container replaces only that container; named volumes persist across rebuilds and are owned by the base stack, not the devcontainer layer. This is another reason to leave volume declarations in docker-compose.yml rather than moving them into the override.
Related
- Up to Docker Compose Integration for Multi-Service Apps — the parent guide on Compose stacks.
- Configuring a Postgres Service in a DevContainer Compose Stack — a backing service to keep after migrating.
- Resolving DNS Failures in Compose Networks — networking after the move.