“Works on my machine” is the oldest joke in software, and Docker exists largely to kill it. Before containers, onboarding a new developer meant a day of installing the right database version, the right language runtime, the right message queue, and then debugging why your install of Postgres behaved differently from everyone else’s. Multiply that by every service in a modern application and the local environment becomes a fragile, undocumented artifact that only its owner understands. Containerizing local development trades that fragility for a definition file you can check into the repo—one that spins up the entire stack with a single command and behaves the same on every laptop.
This is not the same problem as containerizing for production, though the tools overlap. Local development has its own priorities: fast feedback, hot reload, easy debugging. Getting those right is what separates a Docker setup developers love from one they quietly work around. Here’s how to build one worth keeping.
Why containerize local development at all?
The case for containers locally rests on three concrete wins, and it’s worth being specific about them because “Docker is good” is not a reason.
Reproducibility. When your environment is defined in a Dockerfile and a compose file committed to the repo, every developer runs the identical stack: same database version, same runtime, same dependencies. The class of bugs caused by “my Postgres is 14, yours is 16” simply disappears. New hires go from git clone to a running application in minutes instead of a day of setup instructions that are always slightly out of date.
Isolation. Containers keep project dependencies from colliding. Project A needs Node 18 and Postgres 14; Project B needs Node 22 and Postgres 16. Without containers you’re juggling version managers and hoping nothing leaks. With them, each project’s stack is sealed in its own containers and your host machine stays clean—no global installs, no conflicts, no residue when you delete the project.
Parity with production. If production runs your app in a container, running the same container locally means you’re testing something close to what actually ships. The gap between “works locally” and “works in prod” narrows dramatically when both are the same image built from the same definition.
The official Docker documentation is the authoritative reference throughout, and it’s genuinely good—worth reading rather than piecing setup together from scattered blog posts.
How do you use Docker Compose for services?
A single container is easy. Real applications are several containers—your app, a database, a cache, maybe a queue—that need to talk to each other. Docker Compose is the tool for defining and running that multi-container set as one unit, described in a single YAML file.
A minimal compose.yaml for a web app with a Postgres database looks like this:
services:
app:
build: .
ports:
- "3000:3000"
depends_on:
- db
environment:
DATABASE_URL: postgres://dev:dev@db:5432/app
db:
image: postgres:16
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Run docker compose up and both containers start, networked together. Note that app reaches the database at the hostname db—Compose creates an internal network where each service is addressable by its name, so there’s no hardcoding of IP addresses. depends_on controls start order, and the named pgdata volume keeps your database contents alive across restarts.
That single file is your environment documentation. When someone asks “what does this app need to run?”, the answer is the compose file, and it can’t drift out of date because it’s the thing that actually runs.
How do hot reload and volumes work together?
The first thing developers notice about a naive Docker setup is that it feels slow: change a line of code, and you have to rebuild the image to see it. That’s fatal for a local workflow, and the fix is bind mounts.
A bind mount maps a directory on your host straight into the container, so the container sees your source files live. Combined with a file-watching dev server, saving a file on your host triggers a reload inside the container instantly—the same fast feedback loop you’d get running natively:
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
command: npm run dev
There’s a subtle but important trick in that second volume line. Mounting .:/app overlays your host directory onto the container, which would also clobber the node_modules the image installed during build. The anonymous volume /app/node_modules shadows that path so the container keeps its own dependencies—built for the container’s OS and architecture—rather than inheriting your host’s, which may be compiled for a different platform.
Distinguish the two volume types clearly: bind mounts are for source code you edit constantly, giving you live reload. Named volumes are for persistent data like database files, which you want to survive but never edit by hand. Mixing them up—bind-mounting your database directory, for instance—is a common source of permission errors and corruption.
Does local Docker actually match production?
Parity is Docker’s headline promise, and it’s mostly true—but “mostly” hides some traps worth naming, because a false sense of parity is worse than none.
The genuine parity is real: same base image, same runtime version, same system libraries, same way of injecting configuration through environment variables. That eliminates an entire category of environment drift. But your local setup deliberately differs from production in ways that can bite you. The bind mounts and dev servers you added for fast reload don’t exist in production. Your local database has trivial credentials and no replication. Resource limits, networking, and secrets management look nothing like the real thing.
The practical guidance is to make production parity a goal you’re honest about, not an illusion. Use the same base images and versions—that’s the high-value, low-cost win. Use multi-stage builds so your production image is lean while your dev image carries the extra tooling. But don’t assume that because it runs in a local container it will run identically in production; the differences you introduced for developer ergonomics are exactly the places to test carefully before shipping. This is the sort of thing worth flagging explicitly in a code review when a change touches the container setup, so reviewers check the prod-facing image and not just the local one.
What are the common pitfalls?
Every team that adopts Docker for local development hits the same wall of gotchas. Knowing them in advance saves days.
Filesystem performance on macOS and Windows. This is the big one. Docker runs Linux containers, and on macOS and Windows that means a virtualization layer. Bind-mounting a large directory—especially one with thousands of files like node_modules—can make file access dramatically slower than native. The mitigations: keep dependency directories out of bind mounts (the node_modules volume trick above), consider mount consistency options, and check whether your Docker setup offers a faster file-sharing backend. On Linux hosts this problem largely doesn’t exist, which sometimes surprises teams with mixed operating systems.
Secrets in the wrong place. It’s tempting to hardcode credentials in the compose file for convenience, and for throwaway local passwords that’s acceptable. But never commit real secrets—API keys, production credentials—into a compose file or Dockerfile, because they’ll live in your Git history forever. Keep them in a .env file that is gitignored, and have Compose read from it. Treat the boundary between “fine to commit local dev password” and “never commit real secret” as bright and non-negotiable.
Stale images and orphaned volumes. Docker caches aggressively. When a dependency changes but your image doesn’t rebuild, you get baffling bugs from running old code. Learn docker compose build --no-cache for when you suspect staleness, and docker compose down -v to wipe volumes when your database gets into a bad state. Disk fills up quietly from accumulated images and volumes—docker system prune reclaims it.
Over-containerizing. Not everything needs to be in a container locally. If a tool is genuinely simpler to run natively, run it natively. Docker is a means to reproducibility, not a religion.
For teams also weighing their language toolchain—say whether the containerized service should be TypeScript or plain JavaScript—the TypeScript vs JavaScript trade-offs interact with build steps inside the image, since a compile step is one more thing your Dockerfile has to handle correctly.
A local Docker setup and a CI/CD pipeline should ideally build from the same base images and dependency-lock files. Divergence between the two is a common source of “works locally, fails in CI” reports that waste far more time than keeping the two environments genuinely aligned from the start.
Frequently Asked Questions
Do I need Docker Compose or is plain Docker enough?
For a single container, plain docker run is fine. But real applications involve several services—app, database, cache—that must network together, and coordinating those with individual docker run commands is painful and error-prone. Compose defines the whole stack in one YAML file that doubles as living documentation and starts everything with a single command. For multi-service local development, use Compose.
Why is my Dockerized dev environment so slow on macOS?
Docker runs Linux containers, so on macOS a virtualization layer sits between the container and your files. Bind-mounting large directories—especially node_modules with thousands of files—makes file access far slower than native. Keep dependency directories in named volumes rather than bind mounts, use available file-sharing performance options, and know that Linux hosts don’t suffer this because they run containers natively.
How do I get hot reload to work inside a container?
Use a bind mount to map your source directory into the container so it sees file changes live, and run your framework’s file-watching dev server as the container command. Add an anonymous volume over the dependency directory (like /app/node_modules) so the container keeps its own platform-correct dependencies instead of inheriting your host’s. Save a file and the in-container watcher reloads instantly.
Where should I put secrets in a Docker dev setup?
Trivial throwaway local passwords can live in the compose file, but never commit real secrets—API keys or production credentials—because they persist in Git history permanently. Put real values in a gitignored .env file and have Compose read from it. Keep a bright, non-negotiable line between “acceptable local dev password” and “real secret that must never be committed.”
Does running locally in Docker guarantee it works in production?
No. You get real parity on base images, runtime versions, and system libraries, which eliminates a lot of drift. But your local setup deliberately differs—bind mounts, dev servers, trivial credentials, no resource limits—none of which exist in production. Use the same images and versions for genuine parity, but test the production-facing image carefully rather than assuming local success transfers.
