Docker Compose: Building a Real Application
Create a Node.js application that connects to PostgreSQL and understand how Docker Compose services communicate
On this page
In Part 2, you ran a static HTML site. Now let’s build something real: a Node.js application that reads from a PostgreSQL database.
What We’re Building
A simple API that:
- Connects to a PostgreSQL database
- Returns data when you visit a URL
- Both services run with
docker compose up
No manual database setup. No version conflicts. Just working code.
Step 1: Create the Project Structure
mkdir node-postgres-app
cd node-postgres-app
mkdir app
Step 2: Create the docker-compose.yml
Create docker-compose.yml in your project root:
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_PASSWORD: mysecretpassword
POSTGRES_DB: myapp
POSTGRES_USER: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
web:
image: node:18-alpine
working_dir: /app
volumes:
- ./app:/app
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:mysecretpassword@db:5432/myapp
depends_on:
db:
condition: service_healthy
command: sh -c "npm install && npm start"
volumes:
postgres_data:
Step 3: Create the Node.js Application
Create app/package.json:
{
"name": "docker-compose-demo",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "^4.18.2",
"pg": "^8.11.0"
}
}
Create app/index.js:
const express = require('express');
const { Pool } = require('pg');
const app = express();
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
// Initialize database on startup
async function initDatabase() {
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS visits (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
console.log('Database initialized');
} catch (err) {
console.error('Database init error:', err);
}
}
initDatabase();
app.get('/', async (req, res) => {
try {
// Record this visit
await pool.query('INSERT INTO visits (timestamp) VALUES (NOW())');
// Get total visit count
const result = await pool.query('SELECT COUNT(*) FROM visits');
const count = result.rows[0].count;
res.json({
message: 'Welcome to Docker Compose!',
totalVisits: count,
database: 'PostgreSQL',
node: process.version
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
Understanding What You Built
Let’s break down the key parts:
The Database Service
db:
image: postgres:15-alpine
environment:
POSTGRES_PASSWORD: mysecretpassword
POSTGRES_DB: myapp
- Uses PostgreSQL 15 (Alpine Linux version for smaller size)
- Sets up a database named “myapp” with a password
- Stores data in a volume so it persists between restarts
The Web Service
web:
image: node:18-alpine
volumes:
- ./app:/app
environment:
DATABASE_URL: postgresql://postgres:mysecretpassword@db:5432/myapp
- Uses Node.js 18
- Mounts your local
appfolder into the container at/app - Sets
DATABASE_URLpointing to thedbservice
The Magic: Service Names as Hostnames
Notice @db:5432 in the DATABASE_URL? Docker Compose automatically:
- Creates a network for your services
- Lets services reach each other using service names
- Handles DNS resolution
Your web service can connect to db as if it were a hostname. No IP addresses needed.
Health Checks
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
This ensures the database is actually ready before the web service tries to connect. Without it, your app might try to connect before PostgreSQL finishes starting.
Step 4: Run Your Application
From your project root:
docker compose up
You’ll see:
[+] Running 2/2
✔ Container node-postgres-app-db-1 Healthy
✔ Container node-postgres-app-web-1 Started
Then logs from both services:
db-1 | database system is ready to accept connections
web-1 | Database initialized
web-1 | Server running on http://localhost:3000
Step 5: Test It
Open your browser and visit http://localhost:3000
You should see:
{
"message": "Welcome to Docker Compose!",
"totalVisits": 1,
"database": "PostgreSQL",
"node": "v18.x.x"
}
Refresh the page. Watch totalVisits increment. Your Node.js app is reading from and writing to PostgreSQL!
Try This: Make Changes
- Edit
app/index.jsand change the welcome message - In your terminal, press
Ctrl+Cto stop - Run
docker compose upagain - Refresh your browser—see your changes
Your code changes persist because of this line in docker-compose.yml:
volumes:
- ./app:/app
This mounts your local app folder into the container. Changes to your code are immediately available.
Essential Commands
# Start everything (foreground)
docker compose up
# Start in background
docker compose up -d
# Stop everything
docker compose down
# Stop and remove volumes (fresh start)
docker compose down -v
# View logs
docker compose logs
# Follow logs in real-time
docker compose logs -f
# See what's running
docker compose ps
Inspecting the Database
Want to see your data? Access the PostgreSQL container:
docker compose exec db psql -U postgres -d myapp
Then run SQL:
SELECT * FROM visits;
Type \q to exit.
Common Issues and Solutions
Connection refused: The database might not be ready yet. The health check helps, but on slow machines you might need to wait a few seconds longer.
Port 3000 already in use: Change the port mapping in docker-compose.yml:
ports:
- "3001:3000"
Then visit http://localhost:3001
Changes not showing: Make sure you saved your files and restarted with docker compose up
What Makes This Powerful
Think about what you just did:
- Wrote a Node.js application
- Configured a PostgreSQL database
- Connected them together
- Started everything with one command
No PostgreSQL installation. No Node.js version management. No configuration files scattered around your system.
And your teammate? They clone your repo and run docker compose up. It just works.
Key Concepts Recap
Services: The applications in your docker-compose.yml (web, db)
Volumes: Persist data between restarts and share code with containers
Networks: Docker Compose creates a network automatically, letting services find each other by name
Environment Variables: Configure services without changing code
Health Checks: Ensure dependencies are ready before starting dependent services
What’s Next?
In Part 4, we’ll cover production-ready patterns:
- Environment-specific configurations
- Using
.envfiles for secrets - Adding more services (Redis, Nginx)
- Building custom Docker images
- Security best practices
You’ve learned the fundamentals. Now let’s learn how professionals use Docker Compose in real projects.