How to Use Docker Compose for Local Dev (Node.js + PostgreSQL)
Set up a full local dev environment with Docker Compose, Node.js, PostgreSQL, and pgAdmin. Includes .env config, named volumes, healthchecks, and common error fixes.
Get more content like this on Telegram!
Daily AI tips, notes & resources ā free
The first time I tried to set up a local development environment with Docker Compose, I spent three hours on a problem that had a four-line fix. The Node.js app kept crashing because it tried to connect to Postgres before Postgres finished starting. Nobody told me about healthchecks.
This guide is what I wish I'd had that day. We'll build a complete docker-compose.yml for a Node.js + PostgreSQL + pgAdmin development stack ā with working healthchecks, named volumes, .env integration, and a table of common errors and their fixes.
Why Docker Compose for Local Dev
The classic alternative is installing Postgres directly on your machine. That works until you have two projects needing different Postgres versions, or your teammate's Mac has a different Postgres config than your Linux box, or you switch laptops and forget to document the setup.
Docker Compose solves all of this. The entire environment is defined in one file, committed to version control, and runs identically on every machine. docker compose up and you're running ā no installation guide, no "it works on my machine" debugging.
Before diving in, if you're new to containers generally, Docker tutorial for beginners and Docker beginners containers cover the fundamentals. This guide assumes you've got Docker Desktop installed and understand basic container concepts.
For backend-specific Docker context, Docker for backend developers covers patterns for production images ā relevant once you graduate from local dev.
Project Structure
We'll build this structure:
my-app/
āāā docker-compose.yml
āāā .env
āāā .env.example
āāā src/
ā āāā index.js
ā āāā db.js
āāā package.json
āāā Dockerfile.dev
The .env.example should be committed to git. The .env file should not ā add it to .gitignore.
The .env File
Keep all configuration in .env. No database passwords in docker-compose.yml, no hardcoded connection strings in application code.
# .env
POSTGRES_USER=appuser
POSTGRES_PASSWORD=localdevpassword123
POSTGRES_DB=myappdb
POSTGRES_PORT=5432
PGADMIN_EMAIL=admin@local.dev
PGADMIN_PASSWORD=pgadminpassword
APP_PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://appuser:localdevpassword123@postgres:5432/myappdb
Note the hostname in DATABASE_URL: it's postgres, not localhost. Inside Docker's network, services refer to each other by their service name, not by localhost. This trips up almost everyone the first time.
The .env.example file commits to git with dummy values:
# .env.example
POSTGRES_USER=appuser
POSTGRES_PASSWORD=changeme
POSTGRES_DB=myappdb
POSTGRES_PORT=5432
PGADMIN_EMAIL=admin@local.dev
PGADMIN_PASSWORD=changeme
APP_PORT=3000
NODE_ENV=development
DATABASE_URL=postgresql://appuser:changeme@postgres:5432/myappdb
The Development Dockerfile
We need a separate Dockerfile for development that mounts source code and uses nodemon for hot reloading:
# Dockerfile.dev
FROM node:20-alpine
WORKDIR /app
# Install dependencies first (separate layer for better caching)
COPY package*.json ./
RUN npm install
# The source code gets mounted as a volume, not copied
# So we don't COPY src/ here
EXPOSE 3000
CMD ["npx", "nodemon", "src/index.js"]
The key point: we don't COPY the source code into the image. Instead, we mount it as a volume in docker-compose.yml. This way, edits you make locally appear instantly inside the container without rebuilding the image.
The Full docker-compose.yml
Here's the complete configuration with comments explaining each decision:
# docker-compose.yml
version: '3.9'
services:
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
# PostgreSQL Database
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
postgres:
image: postgres:16-alpine
container_name: myapp-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
# Named volume for data persistence
- postgres-data:/var/lib/postgresql/data
# Optional: run init scripts on first startup
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
networks:
- app-network
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
# Node.js Application
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
app:
build:
context: .
dockerfile: Dockerfile.dev
container_name: myapp-node
restart: unless-stopped
env_file:
- .env
ports:
- "${APP_PORT:-3000}:3000"
volumes:
# Mount source code for hot reloading
- ./src:/app/src
# Mount package.json so you can see it, but use container's node_modules
- ./package.json:/app/package.json:ro
# Anonymous volume prevents host node_modules from overwriting container's
- /app/node_modules
depends_on:
postgres:
condition: service_healthy
networks:
- app-network
command: npx nodemon --watch src src/index.js
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
# pgAdmin (Database GUI)
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
pgadmin:
image: dpage/pgadmin4:8
container_name: myapp-pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
ports:
- "5050:80"
volumes:
- pgadmin-data:/var/lib/pgadmin
depends_on:
postgres:
condition: service_healthy
networks:
- app-network
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
# Named Volumes
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
volumes:
postgres-data:
driver: local
pgadmin-data:
driver: local
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
# Networks
# āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
networks:
app-network:
driver: bridge
A few things worth calling out:
The depends_on with condition: service_healthy ā this is the healthcheck fix. It tells Compose to wait until postgres's healthcheck passes before starting the app. Without this, your app starts, tries to connect to Postgres, fails, and crashes.
The /app/node_modules anonymous volume ā this is the classic gotcha. If you mount ./:/app, your host's node_modules overwrites the container's node_modules (which was built for Linux, not Mac or Windows). The anonymous volume at /app/node_modules takes precedence and keeps the container's dependencies intact.
restart: unless-stopped ā containers restart automatically if they crash, but stop cleanly when you run docker compose down. Good for development.
Connecting from Node.js
A minimal src/db.js using the pg library:
// src/db.js
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
// Connection pool config
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
// Test connection on startup
pool.connect((err, client, release) => {
if (err) {
console.error('Error connecting to PostgreSQL:', err.message);
process.exit(1);
}
console.log('PostgreSQL connected successfully');
release();
});
module.exports = pool;
And a basic src/index.js:
// src/index.js
const express = require('express');
const pool = require('./db');
const app = express();
app.use(express.json());
app.get('/health', async (req, res) => {
try {
const result = await pool.query('SELECT NOW() as time');
res.json({ status: 'ok', dbTime: result.rows[0].time });
} catch (err) {
res.status(500).json({ status: 'error', message: err.message });
}
});
app.get('/users', async (req, res) => {
const result = await pool.query('SELECT * FROM users ORDER BY created_at DESC');
res.json(result.rows);
});
const PORT = process.env.APP_PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Database Init Scripts
The ./db/init directory (mounted at /docker-entrypoint-initdb.d) runs .sql files automatically on first container start ā but only when the data directory is empty (first time only).
-- db/init/01_schema.sql
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS posts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- db/init/02_seed.sql
INSERT INTO users (email, name) VALUES
('alice@example.com', 'Alice'),
('bob@example.com', 'Bob')
ON CONFLICT (email) DO NOTHING;
Scripts run alphabetically, so prefix them with numbers to control order.
Running the Stack
# Start everything (detached mode)
docker compose up -d
# Watch logs from all services
docker compose logs -f
# Watch logs from just the app
docker compose logs -f app
# Run a one-off command in the postgres container
docker compose exec postgres psql -U appuser -d myappdb
# Stop everything (keeps volumes)
docker compose down
# Stop and delete all data (use with caution)
docker compose down -v
# Rebuild the app image after package.json changes
docker compose build app
docker compose up -d app
Access your services:
- App: http://localhost:3000
- pgAdmin: http://localhost:5050
- Postgres direct: localhost:5432
In pgAdmin, add a new server with host postgres, port 5432, and the credentials from your .env. The hostname is postgres because pgAdmin is inside the same Docker network as the database.
Common Errors and Fixes
| Error | Cause | Fix |
|---|---|---|
ECONNREFUSED 127.0.0.1:5432 | Using localhost instead of service name | Change DATABASE_URL host to postgres |
| App starts before DB is ready | Missing healthcheck dependency | Add depends_on: postgres: condition: service_healthy |
node_modules not found | Host directory overwriting container's | Add - /app/node_modules anonymous volume |
| Changes not reflected | Source not mounted | Check ./src:/app/src volume in compose |
| pgAdmin can't connect to DB | Wrong host in pgAdmin connection | Use postgres as hostname, not localhost |
password authentication failed | .env not loaded | Ensure env_file: - .env is in service config |
| Port already in use | Another service on same port | Change port in .env (e.g., APP_PORT=3001) |
| Data lost after compose down | Not using named volume | Use named volume, not bind mount for DB data |
The most common by far is the localhost vs service name issue. When you're inside Docker's network, localhost refers to the current container, not the Postgres container. Always use the service name (postgres) as the hostname.
Handling Multiple Environments
For teams, it's useful to have a docker-compose.override.yml for per-developer customizations that doesn't get committed:
# docker-compose.override.yml (gitignored)
services:
app:
ports:
- "3001:3000" # Different port to avoid conflicts
environment:
DEBUG: "app:*"
Compose automatically merges docker-compose.yml and docker-compose.override.yml. This lets each developer customize their local setup without touching the shared config.
For production deployment patterns ā building a proper multi-stage Dockerfile, using environment-specific configs ā the CI/CD pipeline best practices post covers the next steps. If you're moving toward Kubernetes for production, deploy Node.js Kubernetes walks through taking this app to a cluster.
For the database side ā schema management, migrations, and using an ORM ā this setup pairs naturally with the patterns in Prisma ORM PostgreSQL.
Conclusion
A properly configured docker-compose.yml makes the "works on my machine" problem someone else's problem ā specifically, a problem from the past. The key pieces that make this setup production-quality for local dev are: named volumes for data persistence, healthchecks with service_healthy conditions, the anonymous /app/node_modules volume trick, and keeping all configuration in .env.
Copy this setup into your next project, swap the init scripts for your schema, and you have a fully reproducible dev environment that any teammate can clone and run in under two minutes.
Run docker compose up -d and go build something.
Frequently Asked Questions
Why does my Node.js app start before PostgreSQL is ready?
Docker Compose's depends_on only waits for the container to start, not for the service inside to be ready. PostgreSQL takes a few seconds to initialize its data directory on first run. The fix is a healthcheck on the postgres service combined with depends_on condition: service_healthy in your app service. This tells Compose to wait until postgres passes its health check before starting the app container.
How do I persist PostgreSQL data between docker compose down and up?
Use a named volume, not a bind mount. With a named volume (e.g., postgres-data:/var/lib/postgresql/data), Docker manages the storage and it persists across container restarts and removals. If you run docker compose down without the -v flag, volumes are preserved. Only docker compose down -v deletes named volumes. Avoid using docker compose down -v unless you actually want to reset your database.
Can I use this Docker Compose setup for production?
This setup is designed for local development only. For production, you'd use managed database services (RDS, Cloud SQL, Supabase) rather than running Postgres in a container ā data durability and backups are handled for you. For the Node.js app, you'd build a production Docker image (no nodemon, no volume mounts for source code) and deploy via Kubernetes or a managed container service. The docker-compose.yml here is intentionally configured for developer experience, not production reliability.
Frequently Asked Questions
AiTechWorlds Team
ā Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
5 GraphQL Resolver Best Practices (DataLoader, Error Handling)
Write efficient GraphQL resolvers that don't hammer your database. DataLoader N+1 fix, error handling patterns, auth in context, and resolver performance comparison.
10 SQL Query Optimization Techniques (Indexes, EXPLAIN, Joins)
Speed up slow database queries with 10 proven SQL optimization techniques. Covers EXPLAIN ANALYZE, index types, N+1 in SQL, slow query log setup, and real before/after examples.
7 Common API Security Vulnerabilities (and How to Fix Them)
Real API security vulnerabilities from the OWASP API Top 10 ā with working code fixes, risk levels, and testing tools so you can protect your APIs today.
How to Use AutoGen with Docker (Containerized Agents)
Learn how to run AutoGen agents in Docker containers for isolated, reproducible code execution. Covers DockerCommandLineCodeExecutor, docker-compose, and custom images.