Setting Up Docker for Local Development
- docker
- docker-compose
- nodejs
- nestjs
- devops
- backend
- typescript
- developer-experience
The Prisma or TypeORM article settled how the API talks to Postgres. What's still missing is the thing that makes all of it reproducible. A way for anyone on the team, including future you on a new laptop, to run the whole stack with one command and get the exact same result.
That's what this article is about. We already reached for Docker to run Postgres in the PostgreSQL setup, so the container habit is half-formed already. Here's what we add: a docker-compose.yml that runs the API, the web app, Postgres, and Redis together. Dockerfiles that support fast local iteration without turning into a production liability. And the handful of pitfalls that waste the most time when a team first containerizes its dev environment.
This is part six of the Full Stack SaaS Masterclass, continuing from the data-access layer we picked last time.
Why containerize local development at all
Docker in local dev doesn't need to match production infrastructure. Your production deployment (ECS, Kubernetes, whatever you land on later in the series) will look different from a laptop compose file regardless. The real case for it is narrower and more practical: it removes an entire category of setup problems.
Without containers, "clone the repo and run it" quietly depends on whatever's already installed on that machine. A specific Node version. A specific Postgres major version. A Redis binary that may or may not exist. Whatever environment quirks have accumulated over years on that developer's laptop. Every one of those is a source of "works on my machine" bugs that have nothing to do with the code.
With containers, the dependency list shrinks to Docker itself. The API doesn't care whether the host has Node 18 or Node 22 installed, because the container brings its own. A new hire runs docker compose up and has a working Postgres, Redis, API, and frontend in minutes, without touching a version manager or an install guide.
The tradeoff is real and worth naming honestly. Containers add a layer of indirection: file changes go through a bind mount, networking goes through Docker's internal DNS instead of localhost in the way you're used to, and debugging occasionally means reasoning about the container boundary instead of just your code. For a small team building fast, that cost is worth paying once setup pain starts showing up as time lost to environment drift rather than actual bugs.
The docker-compose.yml for the whole stack
The postgres service from the previous article stays as it was. Here's the full file with Redis and both apps added:
# docker-compose.yml
services:
postgres:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: saas
POSTGRES_PASSWORD: local_dev_only
POSTGRES_DB: saas_dev
ports:
- '5432:5432'
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U saas']
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- '6379:6379'
volumes:
- redisdata:/data
api:
build:
context: .
dockerfile: apps/api/Dockerfile
target: development
env_file:
- apps/api/.env
ports:
- '3000:3000'
volumes:
- ./apps/api:/app/apps/api
- ./packages:/app/packages
- api_node_modules:/app/node_modules
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
command: npm run start:dev
web:
build:
context: .
dockerfile: apps/web/Dockerfile
target: development
env_file:
- apps/web/.env
ports:
- '3001:3000'
volumes:
- ./apps/web:/app/apps/web
- ./packages:/app/packages
- web_node_modules:/app/node_modules
depends_on:
- api
command: npm run dev
volumes:
pgdata:
redisdata:
api_node_modules:
web_node_modules:
A few decisions in there are doing real work. The healthcheck on Postgres plus depends_on.condition: service_healthy on the API means the API waits for Postgres to actually accept connections, not just for the container to start. Those are different things, and the gap between them is a classic source of flaky first-run errors. The named volumes for node_modules exist specifically to avoid mounting the host's node_modules into the container, which we'll get to next.
Dockerfiles built for iteration, not just shipping
The same Dockerfile ends up serving two different jobs: fast local iteration and a lean production image. Multi-stage builds let one file do both without compromise.
# apps/api/Dockerfile
FROM node:20-slim AS base
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages ./packages
COPY apps/api/package.json ./apps/api/package.json
FROM base AS development
RUN npm install
COPY apps/api ./apps/api
EXPOSE 3000
CMD ["npm", "run", "start:dev"]
FROM base AS build
RUN npm ci
COPY apps/api ./apps/api
RUN npm run build --workspace=apps/api
FROM node:20-slim AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/apps/api/dist ./dist
COPY --from=build /app/apps/api/node_modules ./node_modules
COPY --from=build /app/apps/api/package.json ./package.json
EXPOSE 3000
CMD ["node", "dist/main.js"]
The development target intentionally uses npm install instead of npm ci and skips the build step entirely. It's optimized for the compose file overwriting /app/apps/api with a live bind mount and running start:dev with a file watcher. The production target does the opposite: a clean npm ci, a real build, and only the compiled output copied into a fresh, smaller base image. Development speed and production leanness pull in different directions, so give them different targets rather than compromising on one file that does neither well.
The node_modules volume trick, and why it matters
Bind-mounting your whole app directory into a container is what gives you hot reload: edit a file on the host, the container sees the change instantly. The problem is node_modules. If you bind-mount the project root as-is, the container's node_modules (built for the container's OS and architecture) gets shadowed by whatever node_modules exists on the host, which was likely installed for a different platform, especially painful on Apple Silicon Macs installing packages with native bindings.
The fix is the named volume already in the compose file above: mount the source directory, then mount an anonymous or named volume specifically over /app/node_modules so it takes precedence over anything the bind mount would otherwise put there. Docker resolves the more specific mount path last, so node_modules keeps living inside the container's own filesystem while everything else stays live-editable from the host.
Skipping this step is the single most common reason a team's "just run docker compose up" onboarding breaks on the first new machine that tries it. The error is usually a cryptic native-module message that has nothing to do with the actual code change someone made.
Networking, environment variables, and the localhost trap
Inside the compose network, services reach each other by service name, not localhost. The API's DATABASE_URL inside the container should point at postgres:5432, not localhost:5432, because localhost inside a container refers to the container itself, and there's no Postgres running there.
# apps/api/.env used by the api service
DATABASE_URL=postgresql://saas:local_dev_only@postgres:5432/saas_dev
REDIS_URL=redis://redis:6379
This trips people up constantly the first time, especially since the exact same .env file, when running the API directly on the host without Docker, needs localhost instead. Keep a .env.docker and a .env.local (or similar) if the team moves between both modes, and be explicit in the README about which one applies where. The failure mode otherwise is a connection refused error that looks like a Postgres problem and is actually a networking-context problem.
Production pitfalls to avoid
A few mistakes are easy to make once Docker is in the daily workflow, and none of them show up until later.
Don't reuse the development compose file for anything resembling production. It bind-mounts source code, runs a file watcher, and has no resource limits. Production needs its own compose file or, more likely by the time you get there, a real orchestrator, which we cover later in the Cloud & DevOps phase of the series.
Don't let the image grow unbounded. A .dockerignore that excludes node_modules, .git, and build artifacts keeps build context small and build times sane; forgetting it is a quiet tax on every single build.
Don't skip depends_on conditions and assume startup order is deterministic. Compose starts containers in dependency order, but "started" and "ready to accept connections" are different states. Skip the healthcheck and you get an API that crash-loops for the first few seconds of every docker compose up.
Finally, watch Docker Desktop's resource allocation on macOS and Windows. A stack running Postgres, Redis, and two Node apps with file watchers active can genuinely strain the default memory and CPU limits. The symptom is usually "everything feels slow" rather than an obvious error, which makes it easy to blame the wrong thing.
Key takeaways
- Containerizing local dev removes environment drift, not because it mirrors production exactly, but because it collapses the dependency list down to Docker itself.
- One compose file can run Postgres, Redis, the API, and the frontend together, with healthchecks controlling real startup order rather than container-start order.
- Multi-stage Dockerfiles let one file serve fast local iteration and a lean production build without compromising either.
- Mount a named volume over `node_modules` inside the container so a host-installed `node_modules` never shadows the container's own dependencies.
- Inside the compose network, services address each other by service name, not `localhost`; keep separate env files for in-container and on-host runs.
- Never point the dev compose file at production. Its bind mounts and lack of resource limits make it unsafe for anything but local iteration.
Frequently asked questions
Should I run Postgres and Redis in Docker even if I develop the API directly on my host machine?
Yes, that's a common and reasonable split. Running the stateful services (Postgres, Redis) in containers while running the API directly on the host with `npm run start:dev` gives you faster debugger attachment and native tooling, while still avoiding the pain of installing and version-matching database servers by hand. Just remember your `DATABASE_URL` needs `localhost` in that mode, not the service name.
Why does my API container fail to connect to Postgres even though both are running?
Two common causes. Either the connection string still says `localhost` instead of the Postgres service name, which only works when both processes run on the host, or the API started before Postgres finished initializing. A `depends_on` healthcheck condition on the Postgres service fixes the second case.
Do I need Docker Compose if I'm the only developer on the project?
It's still worth it for consistency between your machine and CI, and for making onboarding trivial if the team grows. If the project stays genuinely solo and short-lived, running services natively is a fair simplification; add Compose back once a second person or a CI pipeline needs the same setup.
Why is file watching so slow inside a Docker container on macOS?
Bind mounts on macOS go through a virtualization layer that's slower than native filesystem access, and file-watching mechanisms like `inotify` don't cross that boundary as efficiently as on Linux. Docker Desktop's newer virtualization backends (VirtioFS on macOS) reduce this significantly, so keeping Docker Desktop current matters more here than most people expect.
Can I use the same Dockerfile for development and production?
Yes, through multi-stage builds with separate targets, as shown above. Trying to use one undifferentiated stage for both usually means either a slow production image full of dev dependencies, or a development setup that requires a full rebuild on every code change, since it lacks the bind-mount and watch behavior a dev loop needs.
What's the difference between depends_on and a healthcheck?
`depends_on` alone only guarantees Docker starts one container before another; it says nothing about whether the first service is actually ready to accept traffic. Adding a `healthcheck` to the dependency and a `condition: service_healthy` on the dependent service makes Compose wait for real readiness, which matters most for databases that take a moment to accept connections after the process starts.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.