Why I stopped using Docker Compose for local development
Native tooling and package managers are faster and more reliable than containerizing every development dependency.
Why I stopped using Docker Compose for local development
I used to containerize everything for local development. Database, Redis, message queues, even Node.js itself -- all running in Docker containers orchestrated by a massive docker-compose.yml file. It felt professional, reproducible, and "production-like". Then I spent three hours debugging why my API responses were taking 2 seconds locally when they took 200ms in production, all because of Docker's networking overhead on macOS.
The breaking point came during a tight deadline when Docker Desktop crashed, corrupted my volumes, and I lost a day's worth of local database state. While my teammates were coding, I was reinstalling Docker, rebuilding images, and trying to restore data from backups. That's when I realized I'd traded simplicity for complexity without getting the benefits I thought I was getting.
The Docker Compose Illusion
Here's what my old setup looked like:
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
depends_on:
- postgres
- redis
environment:
DATABASE_URL: postgres://user:pass@postgres:5432/myapp
REDIS_URL: redis://redis:6379
postgres:
image: postgres:15
environment:
POSTGRES_DB: myapp
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7
ports:
- "6379:6379"This felt clean and isolated. Everything was versioned, everything was reproducible. But the reality was more painful:
- Slow file watching: Hot reload took 3-5 seconds instead of milliseconds
- Network latency: Database queries had an extra 10-50ms overhead
- Memory usage: Docker Desktop consumed 4GB+ just to run a Postgres instance
- Platform issues: Different behavior between Intel and M1 Macs
- Debugging complexity: Logs buried in container output, harder to attach debuggers
What I Use Instead
Now my local development setup looks like this:
# Install dependencies natively
brew install postgresql@15 redis
brew services start postgresql@15
brew services start redis
# Simple npm scripts
npm run dev # starts the app with nodemon
npm run db:up # runs migrations
npm run db:seed # seeds test dataAnd my package.json development scripts:
{
"scripts": {
"dev": "nodemon --exec tsx src/server.ts",
"db:up": "drizzle-kit migrate",
"db:seed": "tsx scripts/seed.ts",
"test": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts"
},
"devDependencies": {
"nodemon": "^3.0.0",
"tsx": "^4.0.0",
"drizzle-kit": "^0.20.0"
}
}The benefits are immediate:
- Native performance: No virtualization overhead
- Simpler debugging: Direct access to processes and logs
- Better IDE integration: Debugger attaches directly to Node.js
- Faster startup: Services start in seconds, not minutes
- Less resource usage: Native processes use less memory than containers
When I Still Use Docker
I have not abandoned Docker entirely. I still use it for:
- Production deployment: Containers are perfect for consistent production environments
- CI/CD pipelines: Isolated test environments that mirror production
- Complex dependencies: Services that are difficult to install natively (Elasticsearch, Kafka)
- Team onboarding: When new developers need to get up and running quickly
But for day-to-day development with standard databases and caches, native tooling wins.
The Setup That Actually Works
Here's my current approach for new projects:
Use native package managers first:
- Database:
brew install postgresqlorasdf install postgres latest - Cache:
brew install redis - Node.js:
fnm installorasdf install nodejs latest
Create simple management scripts:
// scripts/dev-setup.ts
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
async function setupDev() {
console.log('Starting development services...');
// Start services
await execAsync('brew services start postgresql@15');
await execAsync('brew services start redis');
// Run migrations
await execAsync('npm run db:up');
// Seed data if database is empty
const { stdout } = await execAsync('psql myapp -c "SELECT COUNT(*) FROM users"');
if (stdout.includes('0')) {
await execAsync('npm run db:seed');
}
console.log('✅ Development environment ready');
}
setupDev().catch(console.error);Document the simple way:
## Development Setup
1. Install dependencies: `brew install postgresql@15 redis`
2. Install Node.js: `fnm install`
3. Install packages: `npm install`
4. Setup database: `npm run dev:setup`
5. Start developing: `npm run dev`The Trade-offs I Made
This approach is not perfect. Here's what I gave up:
- Perfect isolation: Developers might have different database versions
- Easy cleanup: Native services persist between sessions
- Zero-config onboarding: New team members need to install dependencies
But what I gained was more valuable:
- Development speed: Fast feedback loops and native performance
- Debugging simplicity: Direct access to all processes
- Resource efficiency: Lower memory and CPU usage
- Reliability: Fewer moving parts means fewer things break
The key insight is that local development and production deployment have different requirements. Production needs isolation, consistency, and security. Local development needs speed, simplicity, and debugging capabilities.
Docker Compose tried to make local development "production-like", but that created more problems than it solved. Sometimes the best tool for the job is the simplest one that works.