A new hire spent his entire first two days just trying to get our app running locally, chasing a mismatched local Postgres version, a Redis install he didn't have, and a Node version manager that behaved differently on his machine than mine, all because our setup instructions amounted to a loose list of "just install these things yourself." That onboarding disaster is the actual reason I finally sat down and containerized our local development environment properly.
Rather than trying to containerize everything at once, I started with just our Postgres database, since it was the single biggest source of "works on my machine" version mismatches across the team.
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: localdev
POSTGRES_DB: app_dev
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Running docker compose up db gave every teammate the exact same Postgres version and configuration, immediately eliminating the specific mismatch that had cost the new hire half a day on its own.
My first Dockerfile for the application copied the entire project directory before installing dependencies, meaning any single code change invalidated the dependency install layer and forced a full reinstall on every rebuild, turning what should have been a quick restart into a genuinely frustrating multi-minute wait.
# slow version, copies everything before installing
COPY . .
RUN npm install
# faster version, installs before copying the rest
COPY package*.json ./
RUN npm install
COPY . .
Reordering those two steps meant Docker's layer caching could reuse the dependency install step across rebuilds whenever only application code changed, cutting rebuild time dramatically once I understood how the layer cache actually worked.
Rebuilding the entire image on every code change was still too slow for genuine day-to-day development, so I added a volume mount pointing the container at my local source folder directly, letting changes reflect immediately without a rebuild at all, closer to how local development felt before containerizing anything.
services:
app:
build: .
volumes:
- .:/app
- /app/node_modules
ports:
- "3000:3000"
depends_on:
- db
The separate anonymous volume for node_modules specifically prevented my local, host-installed node_modules folder, built for a different operating system inside the container, from overwriting the one actually installed inside the container itself, a mistake that broke the app entirely the first time I tried this without it.
I initially hardcoded local development secrets directly into the compose file, then caught myself before committing it, moving anything sensitive into a separate .env file excluded from version control, referenced by the compose file rather than embedded in it.
services:
app:
env_file:
- .env
The app container sometimes started before Postgres was actually ready to accept connections, causing a confusing startup crash that looked like a real bug but was actually just a timing issue. Adding a proper health check to the database service and having the app wait on it specifically, rather than just starting immediately, resolved the flaky startup failures completely.
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 2s
timeout: 3s
retries: 5
Our next new hire had the full stack running within roughly twenty minutes of cloning the repository, a single docker compose up command replacing what had previously been a genuinely miserable two-day scavenger hunt through mismatched local installs. Watching that onboarding go smoothly was a far more convincing argument for the time I'd spent building this than any abstract case I could have made beforehand.
My original Dockerfile installed both development and production dependencies into the same final image, meaning tools like our test runner and linter, never actually needed at runtime, shipped in the production container anyway, bloating its size and slightly widening the attack surface. Switching to a multi-stage build let me install everything needed for building and testing in one stage, then copy only the final compiled output and production dependencies into a clean, minimal final image.
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
Before I fixed my layer ordering, I noticed build context uploads to the Docker daemon were taking longer than they should, eventually tracing it to my local node_modules and .git folders both getting copied into the build context on every single build despite never actually being needed there. Adding a .dockerignore file excluding both cut build context size dramatically and sped up every subsequent build, a small file that took thirty seconds to write and fixed a slowdown I'd been quietly tolerating for weeks.
node_modules
.git
.env
*.log
Every environment mismatch our new hire hit had a real, specific root cause once I traced it, and containerizing the stack didn't just fix his onboarding, it quietly eliminated a whole category of "works on my machine" bugs the rest of the team had been individually working around for months without ever tracing them back to their actual source.