Docker Compose lets you define a multi-container application in one compose.yaml file and manage the stack with commands such as docker compose up, docker compose ps, and docker compose down. In this tutorial, you will deploy a small Node.js API with PostgreSQL and Redis on a Raff Linux VM running Ubuntu 24.04. The database and cache stay on the internal Compose network, PostgreSQL uses a named volume for persistent data, the database password is mounted as a Compose secret instead of being hardcoded in YAML, and the application port is bound to localhost rather than exposed directly to the internet.
This tutorial focuses on a single-host Docker Compose deployment. Docker's current documentation supports health-check-aware startup ordering through depends_on with condition: service_healthy, named volumes for persistent state, and the Compose plugin through the docker compose command. Docker also recommends using secrets rather than ordinary environment variables for sensitive values. See the Docker Compose plugin installation guide, startup-order documentation, and environment-variable guidance for the upstream behavior used here.
You need Ubuntu 24.04, Docker Engine with the Compose plugin, SSH access with sudo privileges, and enough capacity for the application, PostgreSQL, and Redis you intend to run.
Step 1 — Verify Docker Engine and the Compose Plugin
Confirm Docker and Compose are installed:
docker --version docker compose version
If docker compose is missing but Docker Engine is already installed from Docker's official repository, install the plugin:
sudo apt update sudo apt install -y docker-compose-plugin
Docker's current Linux documentation treats docker compose as the supported plugin command. The old standalone docker-compose installation is retained only for backward compatibility.
If Docker Engine itself is not installed, follow How to Install Docker on Ubuntu 24.04 instead of mixing package sources.
Verify: docker info should complete successfully and docker compose version should report an installed Compose plugin.
Step 2 — Create the Project and Secret Directories
Create a project directory:
mkdir -p ~/task-api/src ~/task-api/secrets cd ~/task-api
Generate a random PostgreSQL password directly into a local secret file:
openssl rand -hex 32 > secrets/postgres_password chmod 600 secrets/postgres_password
Prevent local secret material from being committed to Git:
cat > .gitignore <<'EOF' secrets/ .env EOF
Do not paste the generated password into compose.yaml, shell history, screenshots, or support tickets.
Verify: stat -c '%a %n' secrets/postgres_password should show mode 600, and wc -c secrets/postgres_password should confirm the file is non-empty without printing the secret.
Step 3 — Create the Node.js API
Create the application package file:
cat > src/package.json <<'EOF' { "name": "compose-task-api", "version": "1.0.0", "private": true, "main": "index.js", "dependencies": { "express": "^4.21.0", "pg": "^8.13.0", "redis": "^4.7.0" } } EOF
Create the API:
cat > src/index.js <<'EOF' const fs = require('fs'); const express = require('express'); const { Pool } = require('pg'); const { createClient } = require('redis'); const app = express(); app.use(express.json()); const dbPassword = fs.readFileSync( process.env.POSTGRES_PASSWORD_FILE || '/run/secrets/postgres_password', 'utf8' ).trim(); const pool = new Pool({ host: process.env.POSTGRES_HOST || 'db', port: 5432, user: process.env.POSTGRES_USER || 'taskuser', password: dbPassword, database: process.env.POSTGRES_DB || 'taskdb' }); const redis = createClient({ url: `redis://${process.env.REDIS_HOST || 'cache'}:6379` }); redis.on('error', (err) => console.error('Redis error:', err.message)); app.get('/health', async (req, res) => { try { await pool.query('SELECT 1'); await redis.ping(); res.json({ status: 'ok' }); } catch (err) { res.status(503).json({ status: 'error' }); } }); app.get('/tasks', async (req, res) => { const cached = await redis.get('tasks'); if (cached) return res.json(JSON.parse(cached)); const { rows } = await pool.query( 'SELECT id, title, done, created_at FROM tasks ORDER BY id DESC' ); await redis.setEx('tasks', 30, JSON.stringify(rows)); res.json(rows); }); app.post('/tasks', async (req, res) => { const title = String(req.body.title || '').trim(); if (!title || title.length > 255) { return res.status(400).json({ error: 'title must contain 1-255 characters' }); } const { rows } = await pool.query( 'INSERT INTO tasks (title) VALUES ($1) RETURNING id, title, done, created_at', [title] ); await redis.del('tasks'); res.status(201).json(rows[0]); }); async function start() { await redis.connect(); await pool.query(` CREATE TABLE IF NOT EXISTS tasks ( id SERIAL PRIMARY KEY, title VARCHAR(255) NOT NULL, done BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); app.listen(3000, '0.0.0.0', () => { console.log('Task API listening on port 3000'); }); } start().catch((err) => { console.error(err); process.exit(1); }); EOF
The API verifies both PostgreSQL and Redis on /health, uses parameterized SQL for task creation, and reads the database password from a mounted secret file.
Verify: Run node --check src/index.js if Node.js is installed on the host. If it is not, continue to the container build and use the container logs as the syntax/runtime check.
Step 4 — Create the Application Dockerfile
Create src/Dockerfile:
cat > src/Dockerfile <<'EOF' FROM node:22-slim WORKDIR /app COPY package.json ./ RUN npm install --omit=dev && npm cache clean --force COPY index.js ./ EXPOSE 3000 USER node CMD ["node", "index.js"] EOF
The application runs as the image's non-root node user. The Dockerfile does not copy your project-level secrets/ directory into the image.
Create a Docker build ignore file:
cat > src/.dockerignore <<'EOF' node_modules npm-debug.log EOF
Verify: Run docker build -t task-api-check ./src. The image build should finish successfully without copying secret files into the build context.
Step 5 — Define the Multi-Container Compose Stack
Create compose.yaml:
cat > compose.yaml <<'EOF' services: app: build: ./src restart: unless-stopped environment: POSTGRES_HOST: db POSTGRES_USER: taskuser POSTGRES_DB: taskdb POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password REDIS_HOST: cache secrets: - postgres_password ports: - "127.0.0.1:3000:3000" depends_on: db: condition: service_healthy restart: true cache: condition: service_healthy restart: true networks: - backend db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: taskuser POSTGRES_DB: taskdb POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password secrets: - postgres_password volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U taskuser -d taskdb"] interval: 10s timeout: 5s retries: 5 start_period: 20s networks: - backend cache: image: redis:7-alpine restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] volumes: - redisdata:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 start_period: 10s networks: - backend secrets: postgres_password: file: ./secrets/postgres_password volumes: pgdata: redisdata: networks: backend: driver: bridge EOF
The database and Redis services have no ports section, so they are not published on the host. The API is published only on 127.0.0.1:3000. Docker Compose waits for the PostgreSQL and Redis health checks because both dependencies use condition: service_healthy.
Verify: Run docker compose config -q and docker compose config --services. Validation should pass and the service list should contain app, db, and cache.
Step 6 — Start the Stack and Wait for Healthy Dependencies
Build and start the application:
cd ~/task-api docker compose up -d --build
Check the containers:
docker compose ps
Review the application logs:
docker compose logs --tail=100 app
Compose starts dependencies in order, but simple startup ordering alone does not mean a database is ready to accept connections. The health checks in this tutorial provide the readiness condition before the app is started.
Verify: PostgreSQL and Redis should report healthy status, the app should be running, and the app log should contain Task API listening on port 3000 without a repeating database or Redis connection error.
Step 7 — Test the Application End to End
Check the health endpoint from the VM:
curl -fsS http://127.0.0.1:3000/health
Expected result:
{"status":"ok"}
Create a task:
curl -fsS -X POST http://127.0.0.1:3000/tasks \ -H 'Content-Type: application/json' \ -d '{"title":"Deploy with Docker Compose"}'
Retrieve tasks:
curl -fsS http://127.0.0.1:3000/tasks
The request path now exercises the application container, PostgreSQL service, Redis service, Compose network, secret mount, and database volume together.
Verify: /health should return status: ok, the POST should return the created task, and the GET should return that task.
Step 8 — Confirm Internal Services Are Not Published Publicly
List listening host ports:
sudo ss -lntp | grep -E ':(3000|5432|6379)\b' || true
Only the API's localhost mapping should appear:
127.0.0.1:3000
PostgreSQL port 5432 and Redis port 6379 should not be listening on the host merely because the containers use those ports internally.
Inspect Compose port mappings:
docker compose ps
For a public application, place Nginx or Caddy in front of 127.0.0.1:3000 and terminate HTTPS there instead of changing the API mapping to 0.0.0.0:3000. See How to Install Caddy on Ubuntu 24.04 as a Reverse Proxy or How to Secure Nginx with Let's Encrypt.
Verify: The host should not expose 5432 or 6379, and port 3000 should remain bound to 127.0.0.1.
Step 9 — Inspect Logs, Health, and Resource Usage
Check service state:
docker compose ps
View recent logs without following indefinitely:
docker compose logs --tail=100 app docker compose logs --tail=100 db docker compose logs --tail=100 cache
View a current resource snapshot:
docker compose stats --no-stream
Treat the output as a measurement of your current workload, not as a universal sizing rule. CPU and memory use vary with image versions, database state, traffic, query patterns, and application behavior.
Verify: Services should remain running, PostgreSQL and Redis should remain healthy, and logs should not show unresolved repeating errors.