Docker Compose — REST API + Postgres + Redis

3 minute read

Build a Node.js REST API backed by PostgreSQL for persistence and Redis for caching — a modern three-tier application stack.

Architecture

┌───────────────────────────────────────────────────┐
│              Docker Network: api-net               │
│                                                    │
│  ┌──────────┐  ┌──────────┐  ┌──────────────────┐ │
│  │ Node.js  │  │ Postgres │  │      Redis       │ │
│  │   API    │→ │  :5432   │  │      :6379       │ │
│  │  :3000   │→ │          │  │                  │ │
│  └──────────┘  └──────────┘  └──────────────────┘ │
│       │             │               │              │
│  ./app/         pg-data          redis-data        │
│  (build ctx)   (named vol)      (named vol)        │
└───────────────────────────────────────────────────┘

Project Setup

mkdir api-compose && cd api-compose
mkdir app

Step 1 — Create the API Application

Create app/package.json:

{
  "name": "compose-api",
  "version": "1.0.0",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "dependencies": {
    "express": "^4.18.0",
    "pg": "^8.11.0",
    "redis": "^4.6.0"
  }
}

Create app/server.js:

const express = require('express');
const { Pool } = require('pg');
const { createClient } = require('redis');

const app = express();
app.use(express.json());

// PostgreSQL connection
const pool = new Pool({
  host: process.env.PGHOST || 'postgres',
  port: process.env.PGPORT || 5432,
  database: process.env.PGDATABASE || 'apidb',
  user: process.env.PGUSER || 'apiuser',
  password: process.env.PGPASSWORD || 'apipass',
});

// Redis connection
const redis = createClient({ url: 'redis://redis:6379' });
redis.connect().catch(console.error);

// Health endpoint
app.get('/health', async (req, res) => {
  const pgOk = await pool.query('SELECT 1').then(() => true).catch(() => false);
  const redisOk = redis.isReady;
  res.json({ status: pgOk && redisOk ? 'healthy' : 'degraded', pg: pgOk, redis: redisOk });
});

// Items CRUD with Redis caching
app.get('/items', async (req, res) => {
  const cached = await redis.get('items').catch(() => null);
  if (cached) return res.json({ source: 'cache', data: JSON.parse(cached) });

  const { rows } = await pool.query('SELECT * FROM items ORDER BY id');
  await redis.setEx('items', 60, JSON.stringify(rows));
  res.json({ source: 'database', data: rows });
});

app.post('/items', async (req, res) => {
  const { name } = req.body;
  const { rows } = await pool.query('INSERT INTO items(name) VALUES($1) RETURNING *', [name]);
  await redis.del('items'); // invalidate cache
  res.status(201).json(rows[0]);
});

app.listen(3000, () => console.log('API running on port 3000'));

Create app/init.sql:

CREATE TABLE IF NOT EXISTS items (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

Step 2 — Create the Dockerfile

Create app/Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

Step 3 — The Compose File

Create compose.yml:

services:
  api:
    build:
      context: ./app
      dockerfile: Dockerfile
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "3000:3000"
    environment:
      PGHOST: postgres
      PGDATABASE: apidb
      PGUSER: apiuser
      PGPASSWORD: apipass
    networks:
      - api-net

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: apidb
      POSTGRES_USER: apiuser
      POSTGRES_PASSWORD: apipass
    volumes:
      - pg-data:/var/lib/postgresql/data
      - ./app/init.sql:/docker-entrypoint-initdb.d/init.sql
    networks:
      - api-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U apiuser -d apidb"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis-data:/data
    networks:
      - api-net
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pg-data:
  redis-data:

networks:
  api-net:
    driver: bridge

Step 4 — Build and Run

docker compose up -d --build

Expected output:

[+] Building 15.2s (10/10) FINISHED
[+] Running 4/4
 ✔ Network api-compose_api-net      Created
 ✔ Volume "api-compose_pg-data"     Created
 ✔ Volume "api-compose_redis-data"  Created
 ✔ Container api-compose-postgres-1 Healthy
 ✔ Container api-compose-redis-1    Healthy
 ✔ Container api-compose-api-1      Started

Step 5 — Test the API

Check health:

curl http://localhost:3000/health

Expected output:

{"status":"healthy","pg":true,"redis":true}

Create an item:

curl -X POST http://localhost:3000/items \
  -H "Content-Type: application/json" \
  -d '{"name": "first item"}'

Expected output:

{"id":1,"name":"first item","created_at":"2024-01-15T12:00:00.000Z"}

Fetch items (first call — from database):

curl http://localhost:3000/items
{"source":"database","data":[{"id":1,"name":"first item","created_at":"2024-01-15T12:00:00.000Z"}]}

Fetch again (second call — from Redis cache):

curl http://localhost:3000/items
{"source":"cache","data":[{"id":1,"name":"first item","created_at":"2024-01-15T12:00:00.000Z"}]}

Step 6 — View Logs

docker compose logs api --tail 10
api-1  | API running on port 3000

Enhancements to Try

  1. Add Compose Watch for hot-reload during development
  2. Add Adminer as a database admin UI on a debug profile
  3. Add rate limiting with Redis
  4. Add a frontend service (React/Vue) that consumes the API

Quick Reference

Item Detail
API port 3000:3000
Postgres image postgres:16-alpine
Redis image redis:7-alpine
PG health check pg_isready -U apiuser -d apidb
Redis health check redis-cli ping
Build context ./app