Docker for Local Development: Stop Wasting Time on Environment Setup

by Marcus Chen

Your new teammate clones the repo, runs the app, and nothing works. Python version mismatch. Wrong Postgres client. Some native library that only compiles on their distro. You spend three hours debugging their laptop instead of shipping. Sound familiar?

This is the exact problem Docker for local development solves — and it solves it completely, not partially. I've been running containerized dev environments since Docker 17.x and I'm not going back. Not because containers are trendy, but because I've lost entire days to environment drift and I'm done with it.

This post covers how I actually structure Docker for local dev work: Compose files, volume mounts, live reload, and the handful of gotchas that'll bite you if you skip them. No theory. Just the setup I use on real projects.

Why Docker for Local Development Beats "Just Use Your Host"

The classic counterargument is: containers add complexity. True. But the complexity is upfront and shared. A broken host environment is complexity that's random and personal.

Here's what you get with Docker for local development:

  • Reproducibility. Your docker-compose.yml is the environment spec. If it runs for you, it runs for everyone.
  • Isolation. Postgres 15 for project A, Postgres 14 for project B. No conflicts, no pg_lsclusters gymnastics.
  • Disposability. Nuke it and start over in 30 seconds. Try that with a manually configured host.
  • Parity with production. Same base image, same OS, same libraries. Fewer "but it worked locally" incidents.

The one real cost is I/O performance on macOS and Windows. We'll deal with that.

The Compose File That Actually Works

Forget docker run with 15 flags. docker-compose.yml (or compose.yaml if you're on Compose v2, which you should be) is the unit of configuration. Here's a realistic starting point for a Python/FastAPI app with Postgres:

# compose.yaml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    volumes:
      - .:/app
      - /app/__pycache__
      - /app/.venv
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql://dev:dev@db:5432/devdb
      - DEBUG=true
    depends_on:
      db:
        condition: service_healthy
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload

  db:
    image: postgres:15.6-alpine
    environment:
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: devdb
    volumes:
      - pg_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U dev"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  pg_data:

A few things worth calling out:

The Dockerfile.dev is separate from your production Dockerfile. Don't try to make one file serve both. Your dev image should have hot-reload tools, debuggers, and dev dependencies. Your prod image should have none of that.

Anonymous volumes for __pycache__ and .venv. This is the trick most tutorials skip. If you mount your whole project into /app, Python will try to write bytecode and virtualenv files back to your host. That's slow and messy. The anonymous volume lines (- /app/__pycache__) tell Docker to use a container-local volume for those paths, keeping them out of your host mount.

condition: service_healthy on depends_on. Plain depends_on only waits for the container to start, not for Postgres to be ready to accept connections. The healthcheck + condition combo actually works.

The Dev Dockerfile

# Dockerfile.dev
FROM python:3.12-slim

WORKDIR /app

# Install system deps first — cached layer
RUN apt-get update && apt-get install -y \
    build-essential \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Install Python deps — cached layer
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Don't copy source here — it comes in via volume mount
EXPOSE 8000

Notice I'm not doing COPY . . in the dev Dockerfile. The source code comes in through the volume mount in Compose. This means the image only rebuilds when your dependencies change, not every time you edit a file. Build times drop from 30 seconds to 2 seconds once the cache is warm.

Handling the macOS/Windows I/O Problem

If you're on Linux, skip this section. Your mounts are fast.

On macOS (and Windows with WSL2), bind mounts go through a filesystem translation layer that's slow. For a Node project with node_modules, this can make your app feel genuinely broken.

The solution is to not bind-mount the hot paths. Put node_modules (or your equivalent) in a named volume:

services:
  app:
    volumes:
      - .:/app
      - node_modules:/app/node_modules  # named volume, not host mount

volumes:
  node_modules:

For macOS specifically, Docker Desktop 4.x introduced virtiofs as the default file sharing implementation (replacing gRPC-FUSE). It's meaningfully faster. Make sure you're on Docker Desktop 4.6+ and that VirtioFS is enabled in Settings → General. It won't fix everything, but it cuts the pain significantly.

If you're on Apple Silicon and still hitting I/O issues, consider switching to OrbStack. It's $8/month after the free trial, but the performance difference on M-series chips is real. I run it on my M2 MacBook and the I/O overhead is nearly gone.

Live Reload Without Rebuilding the Image

The whole point of the volume mount is that code changes are reflected immediately. But your app process needs to actually pick them up.

For Python/FastAPI: uvicorn --reload watches for file changes and restarts the ASGI server. Already in the Compose command above.

For Node/Express:

// package.json
"scripts": {
  "dev": "nodemon src/index.js"
}
# compose.yaml
command: npm run dev

For Go, I use air (v1.49.0 as of early 2024):

command: air -c .air.toml

The pattern is the same everywhere: a file watcher runs inside the container, sees the change via the volume mount, restarts the process. You edit on your host, the container reacts. No rebuild, no restart of the container itself.

Managing Secrets and Environment Variables

Hardcoding dev/dev credentials in Compose is fine for local work — nobody's shipping that to prod. But you still want a clean way to handle variables that differ between developers.

I use a .env file at the project root (gitignored) and an .env.example that's committed:

# .env.example
DATABASE_URL=postgresql://dev:dev@db:5432/devdb
DEBUG=true
SECRET_KEY=change-me

Compose automatically loads .env from the project directory. So in compose.yaml:

services:
  app:
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - SECRET_KEY=${SECRET_KEY}

New developer clones the repo, copies .env.example to .env, runs docker compose up. Done. No "what env vars do I need" Slack messages.

Useful Compose Commands You Should Internalize

I see engineers who've used Docker for years still typing docker-compose (v1, deprecated). Use docker compose (v2, space not hyphen). It ships with Docker Desktop and Docker Engine 20.10+.

# Start everything, rebuild if Dockerfile changed
docker compose up --build

# Start in background
docker compose up -d

# Tail logs for a specific service
docker compose logs -f app

# Run a one-off command (migrations, shell, etc.)
docker compose exec app python manage.py migrate
docker compose exec db psql -U dev devdb

# Nuke everything including volumes (fresh start)
docker compose down -v

# Rebuild just one service without restarting others
docker compose up -d --build app

The exec command is the one I use most. Need a Django shell? docker compose exec app python manage.py shell. Need to inspect the database? docker compose exec db psql -U dev devdb. You never need a local Postgres client installed.

Comparing Common Local Dev Approaches

Approach Reproducibility Setup time (new dev) Prod parity I/O perf
Host machine (manual) Low 2-4 hours Poor Native
asdf / mise + local DBs Medium 30-60 min Partial Native
Docker Compose (this post) High 5-10 min Good Slight overhead
Dev containers (VS Code) High 10-20 min Good Same as Compose
Full VM / Vagrant High 30+ min Excellent Overhead

Dev containers (the VS Code / GitHub Codespaces spec) are worth knowing about if your team is VS Code-heavy. They sit on top of Docker and add IDE integration. I've written about setting up dev containers for VS Code if you want to go that route. For pure terminal workflows, plain Compose is simpler.

The One Thing Most Tutorials Get Wrong

They treat Docker for local development as a mirror of production deployment. It isn't. Your dev container should be optimized for developer experience, not for minimal image size or security hardening.

That means:

  • Install debugging tools (pdb, dlv, node --inspect)
  • Mount your source code, don't copy it
  • Use CMD in Compose, not baked into the dev Dockerfile, so you can override it easily
  • Don't run as a non-root user in dev unless you have a specific reason — it just causes permission headaches with volume mounts

Save the hardening for your production image. Keep them separate. Your future self will thank you.

Conclusion

Docker for local development isn't about following best practices for their own sake. It's about eliminating a whole category of wasted time — time spent debugging environments instead of building things. With a solid compose.yaml, a dedicated dev Dockerfile, and the volume mount patterns above, you can get any new contributor running in under 10 minutes.

Here's what to do tomorrow: pick one project that currently requires a README full of "first install X, then configure Y" steps. Write a compose.yaml and a Dockerfile.dev for it. Run docker compose up. Fix the three things that break. You'll never go back.

If you want to take this further, check out this guide on self-hosted CI with Gitea and Woodpecker — the same Docker Compose patterns apply, and you can run your entire dev pipeline locally.