Docker for Development Teams: Beyond the Basics
I’ve been working with Docker for years now across several development teams, and I’ve got to say—there’s a world of difference between knowing basic Docker commands and actually integrating Docker deeply into your development workflow.
After countless hours of troubleshooting environment issues and hearing “but it works on my machine” one too many times, I finally decided to overhaul how our teams use Docker. The results have been nothing short of transformative, and I wanted to share what we’ve learned along the way.
The Problem with Traditional Development Environments
Let’s be honest—before we got serious about Docker, our development environments were a mess. We had developers running different versions of Node, Python, and databases. New team members would spend their first week just trying to get their environment working. Our dev environments barely resembled production, and the cross-platform issues between Mac, Windows, and Linux were driving us all crazy.
I still remember the day our lead developer spent eight hours tracking down a bug that only happened on his machine because of some obscure dependency version mismatch. That was the last straw.
Setting Up a Multi-Stage Development Environment
The game-changer for us was implementing a proper multi-stage Docker setup. We created separate but related configurations for development, testing, and production.
The Three-Stage Approach
Our approach isn’t rocket science, but it works incredibly well:
- Development: We optimized this for fast feedback loops and developer experience
- Testing: This mimics production but includes all our testing tools
- Production: Stripped down, optimized, and secure
Here’s what our development Dockerfile looks like:
# Development Dockerfile
FROM node:18
WORKDIR /app
# Install development dependencies and tools
COPY package*.json ./
RUN npm install
# Install nodemon for hot reloading
RUN npm install -g nodemon
# Set environment to development
ENV NODE_ENV=development
# Mount source code at runtime (don't copy it into the image)
# This allows for hot reloading
EXPOSE 3000
# Use nodemon for hot reloading
CMD ["nodemon", "--legacy-watch", "src/index.js"]
The key differences from our production version are pretty straightforward—we include all dev dependencies, use nodemon for hot reloading, don’t copy the source code (it’s mounted as a volume), and set the environment to development.
Docker Compose for Development
Docker Compose is where the magic really happens. Here’s our setup:
version: ‘3.8’
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
volumes:
- ./src:/app/src
- ./public:/app/public
- ./nodemon.json:/app/nodemon.json
- /app/node_modules
environment:
- NODE_ENV=development
- DEBUG=app:*
depends_on:
- db
- redis
db:
image: postgres:14
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d
environment:
- POSTGRES_PASSWORD=devpassword
- POSTGRES_USER=devuser
- POSTGRES_DB=devdb
redis:
image: redis:6
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:
This setup has been a lifesaver. We get volume mounting for hot reloading, node modules isolation (which was a huge pain point before), database persistence between container restarts, and initialization scripts that ensure everyone’s database is set up identically.
Implementing Hot Reloading for Rapid Development
Hot reloading was a must-have for us. Nothing kills productivity like having to manually restart your app every time you make a change.
Configuring Nodemon
For our Node.js apps, nodemon has been fantastic. Here’s our config:
{
"watch": ["src/", "public/"],
"ext": "js,json,html,css",
"ignore": ["src/tests/"],
"legacyWatch": true,
"delay": "500"
}
That legacyWatch option is crucial—we discovered the hard way that without it, file change detection inside Docker containers can be flaky at best.
Volume Mounting Strategies
Getting volume mounting right took some trial and error. We ended up with a strategy that uses different types of mounts for different purposes:
- Source code directories: These are mounted directly so changes are reflected instantly
- Configuration files: We mount these individually to avoid rebuilding the whole container
- Node modules: We use a named volume here—this was key to preventing the host machine’s node_modules from overwriting the container’s
- Data persistence volumes: For databases and caches
This approach has given us both performance and a great developer experience. I can’t tell you how satisfying it is to make a change and see it reflected immediately without any manual steps.
Debugging Containerized Applications
Debugging was initially our biggest concern when moving to Docker. How would we set breakpoints? How would we inspect variables? It turned out to be easier than we expected.
Remote Debugging with VS Code
VS Code’s remote debugging capabilities are incredible. We added this to our .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "Attach to Docker",
"port": 9229,
"address": "localhost",
"localRoot": "${workspaceFolder}",
"remoteRoot": "/app",
"protocol": "inspector"
}
]
}
Then we modified our docker-compose.yml to expose the debug port:
command: ["nodemon", "--inspect=0.0.0.0:9229", "src/index.js"]
And added a port mapping:
ports:
- "3000:3000"
- "9229:9229"
Now our developers can set breakpoints right in VS Code, start the Docker environment, attach the debugger, and debug as if the code was running locally. It’s honestly better than our old debugging setup.
Log Management
We’ve also put a lot of thought into logging. Our approach includes:
- Centralized logging with different verbosity levels per environment
- Color-coded output so you can quickly distinguish between services
- Log persistence for debugging issues that happened in the past
I can’t count how many hours this has saved us when tracking down tricky bugs.
Managing Dependencies Across Environments
Dependency management used to be our biggest source of “works on my machine” problems. Our approach now completely eliminates this.
Leveraging Multi-Stage Builds
For production, we use multi-stage builds to keep our images lean:
# Build stage
FROM node:18 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:18-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
RUN npm ci --only=production
EXPOSE 3000
CMD ["node", "dist/index.js"]
This approach ensures that development dependencies never bloat our production images, build artifacts are created in a consistent environment, and our production images stay as small as possible.
Package Management Strategies
We’ve also established some team practices around package management:
- Lockfiles are sacred—they get committed and respected
- Dependency updates happen through a controlled process, not ad-hoc
- Version pinning for critical dependencies
These practices have virtually eliminated dependency-related issues.
Optimizing Docker for Different Development Scenarios
Different projects have different needs, so we’ve created specialized setups for various scenarios.
Frontend Development
For frontend-heavy applications, we have a configuration with Webpack Dev Server, hot module replacement, browser auto-reloading, and HTTPS development with self-signed certificates.
API Development
Our API development setup includes automatic API documentation updates, request validation in development, and mock services for external dependencies.
Full-Stack Development
For full-stack applications, we coordinate frontend and backend hot reloading, share environment variables, and unify logging.
Team Collaboration with Docker
Docker really shines as a collaboration tool. It’s transformed how our team works together.
Standardized Development Commands
We use a Makefile to standardize commands:
.PHONY: up down build test lint clean
up:docker-compose up -d
down:docker-compose down
build:docker-compose build
test:docker-compose run --rm app npm test
lint:docker-compose run --rm app npm run lint
clean:docker-compose down -v rm -rf node_modules
This gives us consistent commands across the team, self-documenting operations, and simplified onboarding.
Docker Compose Overrides for Personal Preferences
While standardization is important, we also respect that developers have personal preferences. We use Docker Compose overrides to accommodate this:
# docker-compose.override.yml (not committed to repository)
version: '3.8'
services:
app:
environment:
- PERSONAL_ENV_VAR=value
volumes:
- ./personal-config.json:/app/config.json
This lets developers customize their environment without affecting others, experiment with different configurations, and add personal debugging tools.
Real-World Case Study: Onboarding Time Reduction
The proof is in the pudding, as they say. Here’s what happened when we implemented these changes:
Before Docker Workflow Implementation
- Average onboarding time for new developers: 2.5 days
- Common issues: 15+ environment-specific bugs per month
- Developer satisfaction with environment: 5.8/10
After Docker Workflow Implementation
- Average onboarding time: 2 hours
- Environment-specific bugs: 2 per month
- Developer satisfaction: 8.7/10
The biggest improvements came from eliminating dependency conflicts, standardizing database setup, and providing consistent debugging tools.
I still remember our newest developer’s reaction when she was able to start contributing code on her first day. “This is the smoothest onboarding I’ve ever experienced,” she said. That made all the effort worthwhile.
Best Practices and Common Pitfalls
Based on our experience, here are some best practices we follow:
Do’s
- Document your Docker setup thoroughly—future you will thank you
- Start simple and add complexity as needed
- Optimize volume mounting for performance
- Use Docker Compose for local development
- Include example configurations for common scenarios
Don’ts
- Don’t commit sensitive information in Docker files (I learned this one the hard way)
- Don’t ignore Docker performance on developer machines
- Don’t overcomplicate your initial setup
- Don’t forget about cleanup (those dangling images and volumes add up)
- Don’t neglect security even in development
Conclusion
Docker has completely changed how we develop software. It’s no longer just a deployment tool—it’s the foundation of our entire development workflow.
By implementing the techniques I’ve described, we’ve achieved faster onboarding, consistent environments, closer production parity, improved collaboration, and virtually eliminated “works on my machine” issues.
I’ve shared our complete implementation in a GitHub repository that you can adapt to your own projects. Moving beyond basic Docker usage to a comprehensive development workflow has been one of the best investments we’ve made in our development process.
Author Bio:
Mahitha Adapa is a principal architect with over 10+ years of experience designing scalable platforms including managing large scale cloud and database migration. She is also experienced in all things devops including using docker to manage enterprise grade projects.