Multi-Container App with Docker Compose
Orchestrate a Node.js API, PostgreSQL, and Redis with Docker Compose.
Introduction
30 Second Summary
Most real-world apps don't run alone. They rely on databases, caches, and other services working together behind the scenes.
In this project, you will build a task tracker API backed by three containers (a Node.js server, a PostgreSQL database, and a Redis cache) all orchestrated from a single Docker Compose file.
What You'll Build
A REST API task tracker running across three Docker containers, defined and managed entirely through Docker Compose.
By the end of this project, you'll have:
- A running 3-service application (Node.js API + PostgreSQL + Redis) orchestrated by a single compose.yaml file.
- Health checks and startup ordering that prevent race conditions between services.
- Named volumes and initialization scripts for persistent, self-configuring databases.
- Secret Mission: Add an Nginx reverse proxy as a 4th service to create a production-style stack.
Are there any prerequisites?
This project assumes you have basic Docker knowledge. You should be comfortable building images, running containers, and writing Dockerfiles.
Set Up the Project
Before you can orchestrate multiple containers with Docker Compose, you need an application to orchestrate.
In this step, you'll create the Node.js application code and a Dockerfile that the Compose stack will build from. By the end, you'll have a complete task tracker API ready to be containerized.
In this step, get ready to:
- Verify Docker Compose is available and create the project directory.
- Write the task tracker API with Express, PostgreSQL, and Redis endpoints.
- Write a Dockerfile to containerize the API.
Verify Docker and create the project directory
Docker Compose V2 ships as a plugin inside Docker Desktop. Let's confirm the CLI is accessible before creating any files.
- Confirm Docker Compose is available by running this command in your terminal:
docker compose version
What does this command do?
This prints the installed version of Docker Compose. If Docker Desktop is running correctly, you'll see a version number like v2.x.x.
Command not found?
Make sure Docker Desktop is running. Look for the Docker whale icon in your system tray (bottom-right on Windows). If it's not there, open Docker Desktop and wait for it to finish starting.
help me fix docker compose not being recognized in my terminal
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and move into it by running these commands:
mkdir task-tracker
cd task-tracker
- Open the project folder in VS Code by running:
code .
VS Code didn't open?
If code . is not recognized, open VS Code manually. Press the Windows key, type VS Code, and press Enter. Then click File > Open Folder and select the task-tracker folder on your Desktop.
help me open VS Code from my terminal on Windows
Create the application code
The task tracker API uses three packages: Express for handling HTTP requests, pg for connecting to PostgreSQL, and ioredis for connecting to Redis.
- In VS Code, create a new file called package.json by pressing Ctrl+N, then Ctrl+S and saving it as package.json.
- Paste the following into package.json:
{
"name": "task-tracker",
"version": "1.0.0",
"description": "Multi-container task tracker with Docker Compose",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "5.2.1",
"pg": "8.21.0",
"ioredis": "5.10.1"
}
}
What does this file do?
The package.json file defines your project's metadata and dependencies. When you run npm install, Node.js reads this file and downloads the exact versions listed under dependencies.
- Save package.json with Ctrl+S.
- Install the dependencies by running this command in your terminal:
npm install
- You should see a node_modules folder appear in the VS Code Explorer sidebar.
npm not recognized?
This means Node.js is not installed on your system. Download and install it from nodejs.org (choose the LTS version), then restart your terminal and try again.
help me install Node.js on Windows so I can run npm commands
Now let's create the API server. This file defines the HTTP endpoints that other services in the stack will interact with.
- Create a new file called server.js in VS Code.
- Paste the following imports and connection setup into server.js:
const express = require('express');
const { Pool } = require('pg');
const Redis = require('ioredis');
const app = express();
app.use(express.json());
// Connect to PostgreSQL using environment variables
const pool = new Pool({
host: process.env.POSTGRES_HOST || 'postgres',
port: parseInt(process.env.POSTGRES_PORT) || 5432,
user: process.env.POSTGRES_USER || 'taskuser',
password: process.env.POSTGRES_PASSWORD || 'taskpass',
database: process.env.POSTGRES_DB || 'taskdb'
});
// Connect to Redis using environment variables
const redis = new Redis({
host: process.env.REDIS_HOST || 'redis',
port: parseInt(process.env.REDIS_PORT) || 6379
});
What does this code do?
This sets up the Express app and creates connections to PostgreSQL and Redis. The connection details come from environment variables (which Docker Compose will provide later).
The fallback values like 'postgres' and 'redis' are Docker Compose service names. Compose creates a network where each service is reachable by its name as a hostname.
- You won't see a change yet. Add the GET and POST route handlers below the Redis connection in server.js:
// GET /tasks - fetch all tasks (checks cache first)
app.get('/tasks', async (req, res) => {
try {
const cached = await redis.get('tasks');
if (cached) {
return res.json({ source: 'cache', tasks: JSON.parse(cached) });
}
const result = await pool.query('SELECT * FROM tasks ORDER BY created_at DESC');
await redis.set('tasks', JSON.stringify(result.rows), 'EX', 60);
res.json({ source: 'database', tasks: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /tasks - create a new task
app.post('/tasks', async (req, res) => {
try {
const { title } = req.body;
if (!title) {
return res.status(400).json({ error: 'Title is required' });
}
const result = await pool.query(
'INSERT INTO tasks (title) VALUES ($1) RETURNING *',
[title]
);
await redis.del('tasks');
res.status(201).json(result.rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
What does this code do?
The GET /tasks endpoint checks Redis for cached results first. If nothing is cached, it queries PostgreSQL and stores the result in Redis for 60 seconds.
The POST /tasks endpoint inserts a new task into PostgreSQL and clears the Redis cache so the next GET returns fresh data.
- Add the DELETE route, health check endpoint, and server start below the POST handler in server.js:
// DELETE /tasks/:id - delete a task by ID
app.delete('/tasks/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query('DELETE FROM tasks WHERE id = $1 RETURNING *', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Task not found' });
}
await redis.del('tasks');
res.json({ deleted: result.rows[0] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /health - check connections to both databases
app.get('/health', async (req, res) => {
try {
await pool.query('SELECT 1');
await redis.ping();
res.json({ status: 'healthy', postgres: 'connected', redis: 'connected' });
} catch (err) {
res.status(500).json({ status: 'unhealthy', error: err.message });
}
});
// Start the server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Task Tracker API running on port ${PORT}`);
});
What does this code do?
The DELETE /tasks/:id endpoint removes a task by ID and clears the cache. It returns a 404 if the task doesn't exist.
The GET /health endpoint pings both PostgreSQL and Redis to confirm the API can reach them. Docker Compose will use this later for health checks.
- Save server.js with Ctrl+S.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const { Pool } = require('pg');
const Redis = require('ioredis');
const app = express();
app.use(express.json());
const pool = new Pool({
host: process.env.POSTGRES_HOST || 'postgres',
port: parseInt(process.env.POSTGRES_PORT) || 5432,
user: process.env.POSTGRES_USER || 'taskuser',
password: process.env.POSTGRES_PASSWORD || 'taskpass',
database: process.env.POSTGRES_DB || 'taskdb'
});
const redis = new Redis({
host: process.env.REDIS_HOST || 'redis',
port: parseInt(process.env.REDIS_PORT) || 6379
});
app.get('/tasks', async (req, res) => {
try {
const cached = await redis.get('tasks');
if (cached) {
return res.json({ source: 'cache', tasks: JSON.parse(cached) });
}
const result = await pool.query('SELECT * FROM tasks ORDER BY created_at DESC');
await redis.set('tasks', JSON.stringify(result.rows), 'EX', 60);
res.json({ source: 'database', tasks: result.rows });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/tasks', async (req, res) => {
try {
const { title } = req.body;
if (!title) {
return res.status(400).json({ error: 'Title is required' });
}
const result = await pool.query(
'INSERT INTO tasks (title) VALUES ($1) RETURNING *',
[title]
);
await redis.del('tasks');
res.status(201).json(result.rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.delete('/tasks/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query('DELETE FROM tasks WHERE id = $1 RETURNING *', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Task not found' });
}
await redis.del('tasks');
res.json({ deleted: result.rows[0] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/health', async (req, res) => {
try {
await pool.query('SELECT 1');
await redis.ping();
res.json({ status: 'healthy', postgres: 'connected', redis: 'connected' });
} catch (err) {
res.status(500).json({ status: 'unhealthy', error: err.message });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Task Tracker API running on port ${PORT}`);
});
Next, create the SQL script that sets up the database table when PostgreSQL starts for the first time.
- Create a new file called init.sql in VS Code and paste the following:
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
What does this script do?
This creates a tasks table with an auto-incrementing id, a required title, and a created_at timestamp. PostgreSQL will run this script automatically when the container starts for the first time.
- Save init.sql with Ctrl+S.
Write the Dockerfile
A Dockerfile tells Docker how to build an image for your API. It packages your code, dependencies, and runtime into a single portable unit.
- Create a new file called Dockerfile (no extension) in VS Code and paste the following:
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
What does this Dockerfile do?
- FROM node:22-alpine starts from a lightweight Node.js 22 image.
- WORKDIR /app sets the working directory inside the container.
- COPY package.json then RUN npm install installs dependencies first (so Docker can cache this layer).
- COPY . . copies the rest of your application code.
- EXPOSE 3000 documents which port the app listens on.
- CMD ["node", "server.js"] starts the server when the container runs.
- Save Dockerfile with Ctrl+S.
- Confirm all your files exist by running this in your terminal:
ls
- You should see Dockerfile, init.sql, node_modules, package-lock.json, package.json, and server.js in the output.
Missing files in the output?
Make sure you saved each file inside the task-tracker folder on your Desktop. Check that your terminal is in the correct directory by running pwd (or cd on Windows) to see your current path.
help me find where my files were saved
Your application code is ready. Next up, you'll write the compose.yaml file that wires these three services together and brings the whole stack to life with a single command.
Define Services and Bring the Stack Up
You have the API code and Dockerfile ready. Right now, starting this app would mean manually running three separate containers and wiring them together yourself.
Docker Compose solves this by letting you declare every service in a single file. In this step, you'll write that file, set up shared configuration, and bring the entire stack online with one command.
In this step, get ready to:
- Create a compose.yaml defining three services.
- Create a .env file for shared configuration.
- Start the stack and test the API endpoints.
Create compose.yaml
The compose.yaml file declares every service your application needs. Each service becomes its own container when you start the stack. Compose also creates a shared network so the containers can talk to each other using service names as hostnames.
- In VS Code, right-click inside the Explorer sidebar.
- Click New File.
- Name the file compose.yaml and press Enter.
- Define your three-service stack by pasting this code into compose.yaml:
# Service definitions for the task tracker stack
services:
# API service - builds from the local Dockerfile
api:
build: .
ports:
- "3000:3000"
environment:
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- REDIS_HOST=redis
- REDIS_PORT=6379
- PORT=3000
# PostgreSQL database service
postgres:
image: postgres:17
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
# Redis cache service
redis:
image: redis:7-alpine
What does this file do?
- api builds an image from your local Dockerfile and maps port 3000 on your machine to port 3000 inside the container. The environment variables tell the app where to find PostgreSQL and Redis.
- postgres pulls the official postgres:17 image from Docker Hub. The ${VARIABLE} syntax reads values from your .env file at startup.
- redis pulls redis:7-alpine, a lightweight Redis image. No extra config needed for basic caching.
Compose automatically creates a network for the stack. The api service reaches PostgreSQL at hostname postgres and Redis at hostname redis because Compose uses service names as DNS entries.
- Save compose.yaml.
Why compose.yaml instead of docker-compose.yaml?
Docker Compose V2 prefers compose.yaml as the default filename. The older docker-compose.yaml still works for backwards compatibility, but compose.yaml is the modern standard.
✔️ Awesome, I've got everything!
Great. Make sure you've saved compose.yaml before moving on.
ⓧ I'd like to double check the full code
services:
api:
build: .
ports:
- "3000:3000"
environment:
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- REDIS_HOST=redis
- REDIS_PORT=6379
- PORT=3000
postgres:
image: postgres:17
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
redis:
image: redis:7-alpine
Create the .env file
The ${POSTGRES_USER} references in your compose.yaml need actual values. Compose automatically reads a file named .env in the project directory and substitutes those variables at startup.
- In VS Code, create a new file called .env in your task-tracker folder.
- Define your database credentials by pasting this into .env:
# Database credentials used by Compose variable interpolation
POSTGRES_USER=taskuser
POSTGRES_PASSWORD=taskpass
POSTGRES_DB=taskdb
What does this file do?
Compose scans .env for key-value pairs and injects them wherever it sees ${VARIABLE_NAME} in compose.yaml. This keeps credentials in one place instead of scattered across multiple service definitions.
- Save .env.
- Confirm both new files exist by running this in your terminal:
ls
You should see compose.yaml and .env alongside your existing Dockerfile, server.js, init.sql, and package.json.
Don't see .env in the list?
Files starting with a dot are hidden by default on some systems. If ls doesn't show it, try ls -a to include hidden files. Make sure the file is named exactly .env with no extra extension.
Help me find my .env file in the project directory.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
POSTGRES_USER=taskuser
POSTGRES_PASSWORD=taskpass
POSTGRES_DB=taskdb
Start the stack and test the API
Everything is defined. One command builds your API image, pulls the PostgreSQL and Redis images, creates the network, and starts all three containers.
- Build and start the entire stack by running this command:
docker compose up --build
What does this command do?
- docker compose up reads your compose.yaml, creates containers for each service, and starts them.
- --build tells Compose to build the api image from your Dockerfile before starting. Without it, Compose uses a cached image if one exists.
Your terminal will show logs from all three services. You'll see PostgreSQL initializing, Redis starting, and eventually your API printing Task Tracker API running on port 3000.
Your terminal is now streaming logs from the running containers. You need a second terminal to test the API.
- In VS Code, open a new terminal panel by pressing Ctrl+Shift+` (backtick).
- Create a new task by running this command:
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d "{\"title\": \"My first task\"}"
You should see a JSON response with your new task, including an id, title, and created_at timestamp.
- Retrieve all tasks by running:
curl http://localhost:3000/tasks
You should see a JSON response with "source": "database" and a tasks array containing the task you just created.
Run GET again within 60 seconds
If you run the same GET request again quickly, the source field changes to "cache". That means Redis served the response instead of hitting PostgreSQL. Your caching layer is already working.
- Verify all three services are healthy by running:
docker compose ps
You should see three services listed: api, postgres, and redis, all with a status of Up.
API container exiting or showing errors?
- If the api container exits immediately, it likely started before PostgreSQL was ready. This is a race condition you'll fix in the next step. For now, try stopping and restarting with Ctrl+C in the logs terminal, then run docker compose up --build again.
- If you see a port conflict error, another process is using port 3000. Close any other servers running locally, then retry.
- On Windows PowerShell, if curl returns HTML instead of JSON, use curl.exe instead. PowerShell aliases curl to a different command.
Help me troubleshoot my Docker Compose stack not starting correctly.
Your three-service stack is running from a single file. Next up, you'll add health checks so the API never starts before its databases are ready.
Add Health Checks and Startup Ordering
Your three containers start simultaneously, but they don't all become ready at the same time. PostgreSQL needs a moment to initialize its database engine, and Redis needs to load its in-memory store.
If the API container starts sending queries before either database is ready, you get connection errors. This is called a startup race condition. In this step, you'll add health checks that let Docker Compose monitor each service and hold back the API until its dependencies are truly ready.
In this step, get ready to:
- Add health checks to the PostgreSQL and Redis services.
- Configure startup ordering so the API waits for healthy dependencies.
- Restart the stack and verify containers start in the correct order.
Add health checks to the database services
A health check is a command that Docker runs periodically inside a container to determine if the service is ready. If the command succeeds, the container is marked healthy. If it fails too many times, the container is marked unhealthy.
- In VS Code, open compose.yaml.
- Find the postgres service. Replace the entire postgres service block with this updated version that includes a health check:
postgres:
image: postgres:17
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
healthcheck:
test: ["CMD", "pg_isready"]
interval: 5s
timeout: 3s
retries: 5
What does this health check do?
- pg_isready is a built-in PostgreSQL utility that checks whether the server is accepting connections.
- interval: 5s tells Docker to run the check every 5 seconds.
- timeout: 3s means each check must complete within 3 seconds or it counts as a failure.
- retries: 5 means the container is marked unhealthy only after 5 consecutive failures.
- Now find the redis service. Replace it with this updated version:
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
What does the Redis health check do?
redis-cli ping sends a PING command to the Redis server. If Redis is ready, it responds with PONG and the check passes.
- Save compose.yaml.
You won't see a change yet. The next substep connects these health checks to the API's startup sequence.
Add startup ordering with depends_on
Health checks alone just report status. To actually make the API wait, you need to tell Compose that the API depends on healthy databases.
- In compose.yaml, find the api service. Locate the end of the environment section (the last line is - PORT=3000).
- Add the following depends_on block directly below the environment section, at the same indentation level as environment::
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
What does condition: service_healthy do?
Without condition: service_healthy, a basic depends_on only waits for the container to start. The container starting is not the same as the service inside it being ready.
With condition: service_healthy, Compose waits until the health check passes before starting the dependent service. This guarantees PostgreSQL and Redis are fully accepting connections before the API boots.
- Save compose.yaml.
You won't see a change yet. The next substep restarts the stack so these configurations take effect.
YAML indentation issues?
YAML is whitespace-sensitive. The depends_on block must be indented at the same level as build:, ports:, and environment: (4 spaces from the left margin). If anything is misaligned, Compose will throw a parse error on the next restart.
help me fix YAML indentation in my compose.yaml
Restart the stack and verify health status
- In your terminal, press Ctrl+C to stop the currently running stack.
- Restart the stack with the updated configuration by running:
docker compose up --build
Watch the terminal output. You should see the postgres and redis containers start first. The api container now waits until both report healthy before it begins.
What should I see in the logs?
The logs show each container starting in sequence. You'll see PostgreSQL reporting database system is ready to accept connections and Redis reporting Ready to accept connections before the API prints Task Tracker API running on port 3000.
- Open a second terminal tab or window.
- Check the health status of all services by running:
docker compose ps
The STATUS column for postgres and redis should show Up (healthy) in parentheses. This confirms your health checks are working.
- Test that the API still works by running:
curl http://localhost:3000/tasks
You should see a JSON response with either cached or database-sourced tasks.
API container keeps restarting?
- Check that the healthcheck blocks are indented under the correct service (not under api).
- Make sure condition: service_healthy is nested under each service name inside depends_on, not at the same level.
- Run docker compose logs api to see if the API is throwing connection errors that point to a misconfigured host or port.
help me debug why my API container won't start with Docker Compose health checks
✔️ Awesome, I've got everything!
Great. Make sure you've saved compose.yaml and confirmed all three services are running with docker compose ps.
ⓧ I'd like to double check the full code
Here is the complete compose.yaml as it should look after this step:
services:
api:
build: .
ports:
- "3000:3000"
environment:
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- REDIS_HOST=redis
- REDIS_PORT=6379
- PORT=3000
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
postgres:
image: postgres:17
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
healthcheck:
test: ["CMD", "pg_isready"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
Your containers now start in a safe, predictable order. Next up, you'll add persistent storage so your tasks survive container restarts.
Add Volumes and Database Initialization
Your stack now starts in the correct order, with the API waiting for healthy database connections before accepting requests. But there is a critical gap: every time you run docker compose down, PostgreSQL loses all its data.
In this step, you will add persistent storage using named volumes and mount an initialization script that creates your database table automatically on first startup.
In this step, get ready to:
- Add a named volume to persist PostgreSQL data across restarts.
- Mount an initialization script for automatic schema creation.
- Test that data survives container restarts and is removed only on request.
Add a named volume for PostgreSQL data
Without a volume, PostgreSQL stores data inside the container's writable layer. When the container is removed, that data disappears.
A named volume lives outside the container on your host machine. Docker Compose manages it for you, and it survives container removal.
- In compose.yaml, find the postgres service.
- Add a volumes: section between the environment: block and the healthcheck: block by copying this code:
volumes:
- pgdata:/var/lib/postgresql/data
- At the very bottom of compose.yaml, after the redis service definition, add a top-level volumes: declaration by copying this code:
volumes:
pgdata:
What does this configuration do?
The line pgdata:/var/lib/postgresql/data maps a named volume called pgdata to the directory where PostgreSQL stores its database files inside the container.
The top-level volumes: section formally declares pgdata so Compose knows to create and manage it. Without this declaration, Compose would reject the file.
You won't see a change yet. The next substep mounts the initialization script that creates the tasks table.
Mount the database initialization script
The official PostgreSQL Docker image automatically runs any .sql files it finds in /docker-entrypoint-initdb.d/ on first startup (when the data directory is empty).
- In compose.yaml, find the volumes: section you just added under the postgres service.
- Find this line:
volumes:
- pgdata:/var/lib/postgresql/data
- Add the init.sql bind mount as a second line, so the section now looks like this:
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
What does this bind mount do?
This mounts your local init.sql file into the container at the path PostgreSQL watches for initialization scripts. On first startup, PostgreSQL runs the script and creates the tasks table automatically.
This only runs when the data directory is empty (a fresh volume). If pgdata already contains data from a previous run, the script is skipped.
- Save compose.yaml.
YAML indentation error?
Make sure the volumes: key is indented at the same level as environment: and healthcheck: (4 spaces). Each volume entry should be indented 6 spaces with a leading dash.
help me fix YAML indentation in my compose.yaml
✔️ Awesome, I've got everything!
Great. Double check you have saved compose.yaml before continuing.
ⓧ I'd like to double check the full code
services:
api:
build: .
ports:
- "3000:3000"
environment:
- POSTGRES_HOST=postgres
- POSTGRES_PORT=5432
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- REDIS_HOST=redis
- REDIS_PORT=6379
- PORT=3000
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
postgres:
image: postgres:17
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD", "pg_isready"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
Test data persistence
The real proof of a volume is surviving a restart. You will bring the stack up fresh, create some tasks, tear it down, and confirm the data is still there.
- Stop and remove the current stack (including any old volume data) by running this command:
docker compose down --volumes
Why use --volumes here?
Your previous postgres container ran without the init.sql mount, so its data directory never got the tasks table. Using --volumes clears the slate so PostgreSQL treats the next startup as a fresh initialization.
- Start the stack fresh by running:
docker compose up --build -d
- Wait a few seconds for the health checks to pass, then create a task by running:
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d "{\"title\": \"Learn Docker Compose\"}"
Using PowerShell?
In Windows PowerShell 5.1, curl is an alias for a different command. Type curl.exe instead to use the actual curl utility. In Command Prompt, curl works as-is.
- Verify the task was saved by running:
curl http://localhost:3000/tasks
You should see a JSON response containing the task you just created.
- Now bring the stack down (without --volumes) by running:
docker compose down
- Start it back up by running:
docker compose up -d
- Wait a few seconds for health checks, then fetch tasks again:
curl http://localhost:3000/tasks
Your task is still there. The named volume kept PostgreSQL's data intact across the restart.
- Finally, test a full cleanup by running:
docker compose down --volumes
- Start the stack one more time:
docker compose up -d
- Wait for health checks, then fetch tasks:
curl http://localhost:3000/tasks
The response now shows an empty tasks array. The --volumes flag removed pgdata, so PostgreSQL re-ran init.sql on a fresh data directory.
What is the difference between down and down --volumes?
Running docker compose down removes containers and the default network, but leaves named volumes untouched. Your data persists.
Adding --volumes also removes named volumes, wiping all stored data. Use this only when you want a completely fresh start.
Tasks missing after restart?
Make sure you used docker compose down without the --volumes flag for the persistence test. If you accidentally included it, the volume was deleted. Re-create a task and try the test again.
Also confirm that the volumes: section under postgres includes pgdata:/var/lib/postgresql/data and that the top-level volumes: declaration exists at the bottom of compose.yaml.
help me debug why my Docker Compose volume is not persisting data
Your data now persists across restarts, and your database schema initializes automatically. Next up, you will learn how to inspect, debug, and manage your running stack using Compose commands.
Inspect, Debug, and Manage the Stack
In the previous step, you set up persistent storage and automatic schema creation so your data survives restarts.
A running multi-container stack is only useful if you can see what each service is doing. When something breaks, you need to read logs, run queries inside containers, and check which services are healthy. In this step, you will use Docker Compose's built-in commands to inspect, debug, and manage your stack's lifecycle.
In this step, get ready to:
- View real-time logs from all services and filter logs to a single service.
- Execute commands inside running containers to query PostgreSQL and Redis directly.
- Manage the stack lifecycle with Compose commands and understand the difference between stopping and removing containers.
Tail logs from all services
Logs are your first debugging tool in a multi-container app. Each service writes its own output, and Compose can aggregate them all into a single stream.
- View a live stream of logs from all three services by running this command:
docker compose logs -f
What does this command do?
The -f flag means "follow." It streams new log output in real time, similar to tail -f on a file.
You will see output from all three services (api, postgres, redis) interleaved together. Each line is prefixed with the service name so you can tell which container produced it.
- You should see log lines prefixed with service names like api-1, postgres-1, and redis-1.
- Press Ctrl+C to stop following the logs and return to your terminal prompt.
Sometimes you only want logs from one service. Compose lets you filter by service name.
- View logs from just the API service by running:
docker compose logs api
Why filter to a single service?
When debugging a specific issue, the logs from other services add noise. Filtering to api shows only what your Node.js application printed, making it easier to spot errors or unexpected behavior.
- You should see the startup message Task Tracker API running on port 3000 along with any request logs from earlier testing.
Not seeing any logs?
Make sure your stack is still running. If you previously stopped it, bring it back up by running docker compose up --build -d before trying the logs commands.
help me troubleshoot why docker compose logs shows no output
Execute commands inside running containers
You can run commands directly inside a running container without stopping it. This is how you query databases, inspect cached data, or troubleshoot from inside the container's environment.
- Query the PostgreSQL database directly from inside the postgres container by running:
docker compose exec postgres psql -U taskuser -d taskdb -c "SELECT * FROM tasks;"
What does this command do?
The docker compose exec command runs a process inside a running container.
- postgres is the service name from your compose.yaml.
- psql -U taskuser -d taskdb launches the PostgreSQL client, connecting as taskuser to the taskdb database.
- -c "SELECT * FROM tasks;" runs a single SQL query and exits.
- You should see a table showing any tasks you created earlier, with columns for id, title, and created_at. If you have not created any tasks yet, you will see an empty result set.
Now check what the Redis cache is holding.
- Inspect the Redis cache by running:
docker compose exec redis redis-cli GET tasks
What does this command do?
This runs redis-cli inside the redis container and retrieves the value stored under the tasks key.
If you recently hit the GET /tasks endpoint (within the last 60 seconds), you will see a JSON string of your cached tasks. If the cache has expired, you will see (nil) instead.
Seeing a connection error?
If you see an error about the container not running, confirm the stack is up with docker compose ps. All three services should show a "running" status.
help me fix a docker compose exec connection error
View container status and stop the stack
Before shutting down, check the current state of all your containers. This shows you which services are running, their health status, and which ports are mapped.
- View the status of all containers by running:
docker compose ps
- You should see a table with all three services listed as running, along with their port mappings. The postgres and redis services should show a health status of "healthy."
What does docker compose ps show?
The output includes each container's name, image, current status, and exposed ports. The health status column confirms that your healthcheck blocks from the previous step are working correctly.
Now it is time to stop the stack. Compose offers two different commands for this, and the difference matters.
- Stop and remove all containers and the network by running:
docker compose down
What is the difference between down and stop?
Docker Compose has two commands for stopping services, and they behave differently:
- docker compose down stops containers AND removes the containers, networks, and default network created by docker compose up. The next time you run up, Compose creates everything fresh.
- docker compose stop stops running containers without removing them. They can be started again quickly with docker compose start because the containers still exist on disk.
Named volumes (like pgdata) are preserved by both commands. Only docker compose down --volumes removes named volumes.
- Confirm the stack is fully stopped by running:
docker compose ps
- You should see no containers listed (or an empty table). This confirms docker compose down removed all containers.
When to use stop vs down
Use docker compose stop when you are taking a quick break and want to resume later without rebuilding. Use docker compose down when you are done for the day or want a clean slate.
Containers still showing up after down?
Make sure you ran docker compose down from the same directory as your compose.yaml file. Compose needs to find the project file to know which containers to remove.
help me understand why docker compose down did not remove my containers
Secret mission
Add an Nginx Reverse Proxy
In production, you rarely expose your API container directly to the internet. Instead, a reverse proxy like Nginx sits in front, handling incoming traffic and forwarding requests to the right backend service. Can you add a 4th service to your Compose stack that does exactly this?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with Docker, so there are no ongoing costs.
Resources you used:
- Docker containers (api, postgres, redis, nginx) running on your machine.
- Named volume pgdata storing PostgreSQL data on your local disk.
- Project files (server.js, compose.yaml, Dockerfile, init.sql, nginx.conf, .env, package.json) in your project folder on the Desktop.
Keep everything running
No action needed. Choose this if you're still actively testing your task tracker API or experimenting with the Compose configuration.
- Your containers stay running in the background and continue using system memory.
- The API remains accessible at http://localhost:3000/tasks and via Nginx at http://localhost:8080/tasks.
- The named volume pgdata continues to persist your task data.
Pause - I'll come back to this later
Shut down running containers to free up memory without removing them. Your data volume and project files stay intact.
- Stop all containers by running:
docker compose stop
- When you're ready to resume, start them again by running:
docker compose start
Your tasks and database schema are preserved in the pgdata volume.
Delete - I don't want to use this again
Remove all containers, networks, and the data volume. Then delete the project files from your machine.
- Stop and remove all containers, networks, and the named volume by running:
docker compose down --volumes
- Navigate up one level and delete the project folder by running:
cd ~/Desktop
rm -rf task-tracker
What about Docker Desktop?
Docker Desktop itself stays installed on your machine. You installed it before this project, and it's useful for future container work. No action needed there.
Nice Work!
Nice Work!
You just built and orchestrated a multi-container application from scratch using Docker Compose. Your task tracker API, backed by PostgreSQL for storage and Redis for caching, proves you can design the kind of service stack that professional teams run in production.
You've learned how to:
- Build a 3-service application (Node.js API + PostgreSQL + Redis) defined in a single compose.yaml with health checks and startup ordering to eliminate race conditions.
- Use named volumes for persistent data and init scripts for automatic database schema creation.
- Manage the full stack lifecycle with Docker Compose commands for building, logging, executing into containers, and tearing down services.
- Secret Mission: Added Nginx as a reverse proxy, creating a 4-service production-style stack.
Ready to quiz yourself?