Skip to content

Docker Compose: Building a Real Application

Create a Node.js application that connects to PostgreSQL and understand how Docker Compose services communicate

• 5 min read

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 app folder into the container at /app
  • Sets DATABASE_URL pointing to the db service

The Magic: Service Names as Hostnames

Notice @db:5432 in the DATABASE_URL? Docker Compose automatically:

  1. Creates a network for your services
  2. Lets services reach each other using service names
  3. 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

  1. Edit app/index.js and change the welcome message
  2. In your terminal, press Ctrl+C to stop
  3. Run docker compose up again
  4. 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:

  1. Wrote a Node.js application
  2. Configured a PostgreSQL database
  3. Connected them together
  4. 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 .env files 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.

Share Twitter LinkedIn
That's the idea.
← All posts