Building a full-stack application often means orchestrating multiple services: a backend API, a database, and potentially a frontend. Managing these components, especially across different environments, can introduce significant complexity. This chapter addresses that challenge by demonstrating how to containerize a Node.js API that interacts with a PostgreSQL database, all orchestrated with Docker Compose.

By the end of this project, you will have a fully functional, containerized “Todo” API, complete with robust data persistence. You will gain practical experience in defining multi-service applications, configuring inter-container communication, and ensuring your data remains safe and accessible across container lifecycles. This project is a critical step towards understanding more complex microservice architectures and preparing applications for production.

Project Overview: The Todo API

This project focuses on building a simple but complete “Todo” application backend. The core problem it solves is providing a persistent storage layer and an API interface for managing user tasks.

Problem Statement: Develop a backend service that can store, retrieve, and manage a list of todo items, ensuring data persistence even if the application components are restarted or scaled. The service should be easy to deploy and manage in a containerized environment.

Solution: We will build a Node.js Express API that connects to a PostgreSQL database. Both services will be containerized using Docker and orchestrated with Docker Compose. Data persistence for PostgreSQL will be handled by a Docker named volume.

Project Outcome: A fully functional, containerized RESTful API for managing todo items, demonstrating robust inter-service communication and data persistence within a Docker Compose setup. This system will be ready for integration with a separate frontend or for use by other services.

Core Technologies

To achieve our project goals, we will leverage the following primary technologies:

  • Docker Engine (v29.1.x, checked 2026-08-06): The core platform for building, shipping, and running containerized applications.
  • Docker Compose (v2.40.3, checked 2026-08-06): A tool for defining and running multi-container Docker applications. It uses YAML files to configure application services.
  • Node.js (LTS v20.10.0, checked 2026-08-06): A JavaScript runtime environment for building our backend API.
  • Express.js (v4.x, checked 2026-08-06): A fast, unopinionated, minimalist web framework for Node.js, used to build our API endpoints.
  • PostgreSQL (v16.2, checked 2026-08-06): A powerful, open-source relational database system chosen for its reliability and advanced features, used for data persistence.
  • pg (v8.x, checked 2026-08-06): The non-blocking PostgreSQL client for Node.js.

Build Plan and Milestones

We’ll tackle this project in logical, verifiable steps:

  1. Project Setup: Initialize the project directory and the Node.js API application structure.
  2. Node.js API Development: Implement the core Express API logic for managing todo items and connecting to PostgreSQL.
  3. PostgreSQL Service Definition: Create the docker-compose.yaml configuration for our PostgreSQL database, including data persistence.
  4. Database Verification: Start and test the PostgreSQL service in isolation, including schema initialization.
  5. Node.js API Containerization: Create a Dockerfile for the Node.js API, focusing on multi-stage builds and security.
  6. Full Stack Orchestration: Integrate the Node.js API service into docker-compose.yaml with inter-service dependencies and health checks.
  7. End-to-End Verification: Start the complete application stack and test all API endpoints.

Project Architecture

Our full-stack application adopts a simple microservices-like architecture, separating the API from its database. Docker Compose provides the network and volume management to connect these services seamlessly.

flowchart TD Client[Client] -->|HTTP Requests| API_Service[API Service] API_Service --> DockerNetwork[Docker Internal Network] DockerNetwork --> DB_Service[PostgreSQL Database Service] DB_Service --> DataVolume[Docker Volume for Data]
  • Client: Your development machine’s browser or command-line tools (like curl) will interact with the Node.js API.
  • Node.js API Service: This container hosts our Express.js application. It listens for HTTP requests and communicates with the database. It is designed to be stateless.
  • Docker Internal Network: Docker Compose automatically creates a private network. Services within this network can communicate using their service names as hostnames, ensuring secure and simplified routing.
  • PostgreSQL Database Service: This container runs the PostgreSQL database, storing all todo item data. It is a stateful service.
  • Docker Volume for Data: A named Docker volume (e.g., postgres_data) is mounted into the PostgreSQL container. This ensures that the database’s data persists across container restarts, removals, or upgrades.

Step-by-Step Implementation

Let’s get started by setting up our project and building the individual components.

1. Initialize Project Structure and Node.js API

First, we’ll create the project directories and set up the basic Node.js application.

  1. Create the project root directory: Open your terminal and create the main project folder.

    mkdir project-2-fullstack
    cd project-2-fullstack
  2. Create the Node.js application directory: This directory will house our API’s source code.

    mkdir api
    cd api
  3. Initialize Node.js project and install dependencies: We need express for the web server and pg to interact with PostgreSQL.

    npm init -y
    npm install express [email protected] # Using pg v8.11.3, a stable release as of 2026-08-06

    npm init -y creates a default package.json file. npm install adds the required libraries.

  4. Create api/app.js - The Node.js API: This file contains our Express.js application, defining the API endpoints and database interactions.

    // api/app.js
    const express = require('express');
    const { Pool } = require('pg');
    
    const app = express();
    const port = process.env.PORT || 3000;
    
    // Middleware to parse JSON request bodies
    app.use(express.json());
    
    // PostgreSQL connection pool configuration.
    // Connection details are loaded from environment variables.
    const pool = new Pool({
      user: process.env.DB_USER,
      host: process.env.DB_HOST,
      database: process.env.DB_NAME,
      password: process.env.DB_PASSWORD,
      port: process.env.DB_PORT,
      // Increase connection timeout to allow DB to start up
      connectionTimeoutMillis: 10000, // 10 seconds
    });
    
    // Test database connection on startup
    // This provides early feedback if the API cannot reach the DB.
    pool.connect((err, client, release) => {
      if (err) {
        return console.error('Error acquiring PostgreSQL client on startup:', err.stack);
      }
      client.query('SELECT NOW()', (err, result) => {
        release(); // Release the client back to the pool
        if (err) {
          return console.error('Error executing initial database query:', err.stack);
        }
        console.log('PostgreSQL database connected successfully at:', result.rows[0].now);
      });
    });
    
    // Basic health check endpoint
    app.get('/', (req, res) => {
      res.send('Node.js API is running!');
    });
    
    // GET /todos: Fetch all todo items
    app.get('/todos', async (req, res) => {
      try {
        const result = await pool.query('SELECT id, title, completed FROM todos ORDER BY id ASC');
        res.json(result.rows);
      } catch (err) {
        console.error('Error fetching todos:', err.message);
        res.status(500).send('Server Error fetching todos');
      }
    });
    
    // POST /todos: Add a new todo item
    app.post('/todos', async (req, res) => {
      const { title } = req.body;
      if (!title) {
        return res.status(400).send('Title is required to add a todo item.');
      }
      try {
        const result = await pool.query(
          'INSERT INTO todos (title) VALUES ($1) RETURNING id, title, completed',
          [title]
        );
        res.status(201).json(result.rows[0]);
      } catch (err) {
        console.error('Error adding todo:', err.message);
        res.status(500).send('Server Error adding todo');
      }
    });
    
    // Start the API server
    app.listen(port, () => {
      console.log(`Node.js API listening on port ${port}`);
    });

    Explanation:

    • This Express application defines three routes: a root health check (/), a GET endpoint to retrieve all todos (/todos), and a POST endpoint to add a new todo (/todos).
    • Database connection parameters are read from environment variables, which Docker Compose will provide.
    • A connectionTimeoutMillis is added to the pg pool configuration to give the database more time to start up before the API gives up trying to connect.
    • An initial pool.connect call verifies the database connection when the API starts, providing immediate feedback.
  5. Create api/.dockerignore: This file tells Docker which files and directories to exclude when building the image. It’s crucial for keeping image sizes small and build times fast. Place it in the api/ directory.

    # api/.dockerignore
    node_modules
    npm-debug.log
    .env
    Dockerfile

    Explanation: We exclude node_modules because we’ll install them inside the Docker container. .env contains sensitive information and should never be copied into the image.

  6. Navigate back to the project root:

    cd ..

2. Define PostgreSQL Service with Docker Compose

Now, we’ll configure our docker-compose.yaml file to define the PostgreSQL database service and its associated named volume for data persistence.

  1. Create project-2-fullstack/docker-compose.yaml: This file orchestrates our services.

    # project-2-fullstack/docker-compose.yaml
    version: '3.8'
    
    services:
      postgresql:
        image: postgres:16.2-alpine # Using specific, stable PostgreSQL v16.2 (checked 2026-08-06)
        restart: always
        environment:
          POSTGRES_USER: ${DB_USER}
          POSTGRES_PASSWORD: ${DB_PASSWORD}
          POSTGRES_DB: ${DB_NAME}
        volumes:
          - postgres_data:/var/lib/postgresql/data # Mount a named volume for data persistence
        ports:
          - "5432:5432" # Optional: Expose PostgreSQL port to the host for direct access
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
          interval: 10s
          timeout: 5s
          retries: 5
          start_period: 10s # Give the database time to initialize before health checks start
    
    volumes:
      postgres_data: # Define the named volume, managed by Docker

    Explanation:

    • version: '3.8': Specifies the Docker Compose file format.
    • postgresql: This is the service name. Our API will use this name to connect.
    • image: postgres:16.2-alpine: Uses the official PostgreSQL 16.2 image. The alpine variant is chosen for its minimal size, which reduces download times and image attack surface.
    • restart: always: Ensures the container automatically restarts if it crashes or Docker restarts.
    • environment: These variables (POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB) configure the PostgreSQL instance. Values are read from a .env file.
    • volumes: - postgres_data:/var/lib/postgresql/data: Crucial for data persistence. postgres_data is a named volume managed by Docker. /var/lib/postgresql/data is PostgreSQL’s default data directory. This mapping ensures your data is saved on the host and survives container lifecycle events.
    • ports: - "5432:5432": Maps the container’s PostgreSQL port (5432) to the host’s port 5432. This is useful for connecting with external tools like DBeaver or psql from your host, but not strictly required for inter-container communication.
    • healthcheck: Defines how Docker Compose determines if the postgresql service is healthy. pg_isready is a utility provided by PostgreSQL to check its readiness. start_period gives the database enough time to fully initialize before the health checks begin, preventing false negatives.
    • volumes: postgres_data:: This top-level block explicitly declares the postgres_data named volume.
  2. Create project-2-fullstack/.env: This file holds environment variables for our services. Docker Compose automatically reads this file when docker compose up is executed.

    🧠 Important: For production, never commit this .env file to version control as it contains sensitive credentials.

    # project-2-fullstack/.env
    DB_USER=devuser
    DB_PASSWORD=devpassword
    DB_NAME=todos_db
    DB_HOST=postgresql # This is the service name in docker-compose.yaml
    DB_PORT=5432

    Explanation:

    • DB_HOST=postgresql: This is vital. Inside the Docker Compose network, services resolve each other by their service names. So, our Node.js API will connect to the hostname postgresql, not localhost or an IP address.

3. Verification: Starting and Initializing PostgreSQL

Let’s start just the PostgreSQL service to ensure it’s functioning and then set up our database schema.

  1. Start the PostgreSQL service: Ensure you are in the project-2-fullstack directory.

    docker compose up -d postgresql

    The -d flag runs the container in detached mode (background).

  2. Check container status:

    docker compose ps

    You should see postgresql listed with a healthy status eventually. It might take a few seconds for the health check to pass as start_period is 10s.

  3. View logs for the database service:

    docker compose logs postgresql

    Look for messages indicating PostgreSQL has started successfully and is ready for connections, such as database system is ready to accept connections.

  4. Connect to the database and initialize schema: We’ll use docker compose exec to run a command inside the running postgresql container.

    docker compose exec postgresql psql -U devuser -d todos_db

    Once inside the psql prompt, create the todos table:

    CREATE TABLE todos (
        id SERIAL PRIMARY KEY,
        title VARCHAR(255) NOT NULL,
        completed BOOLEAN DEFAULT FALSE
    );
    \dt -- List tables to confirm 'todos' exists
    \q  -- Quit psql

    Verification: This step confirms that our PostgreSQL container is running, accessible, and we can successfully execute SQL commands to create our application’s schema.

4. Containerize Node.js API Service

Now we’ll create a Dockerfile for our Node.js API and integrate it into docker-compose.yaml.

  1. Create api/Dockerfile: This Dockerfile will use a multi-stage build to create a small, secure production image. Place it in the api/ directory.

    # api/Dockerfile
    # Stage 1: Build the application dependencies
    FROM node:20.10.0-alpine AS builder # Using specific Node.js LTS v20.10.0 (checked 2026-08-06)
    
    WORKDIR /app
    
    # Copy package.json and package-lock.json first to leverage Docker cache
    # This layer only changes if dependencies change, speeding up rebuilds.
    COPY package*.json ./
    RUN npm install --omit=dev # Install production dependencies only
    
    # Copy the rest of the application files
    COPY . .
    
    # Stage 2: Create the final, lightweight, and secure production image
    FROM node:20.10.0-alpine
    
    WORKDIR /app
    
    # Create a non-root user for security best practices
    # Run application with least privileges.
    RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
    USER appuser
    
    # Install curl for health checks defined in docker-compose.yaml
    # Alpine uses 'apk' for package management. --no-cache optimizes image size.
    RUN apk add --no-cache curl
    
    # Copy only necessary artifacts from the builder stage
    # This keeps the final image minimal and free of build tools/dev dependencies.
    COPY --from=builder /app/node_modules ./node_modules
    COPY --from=builder /app/app.js .
    
    EXPOSE 3000 # Inform Docker that the container listens on port 3000
    CMD ["node", "app.js"] # Command to run when the container starts

    Explanation:

    • Multi-stage build (FROM ... AS builder then FROM ... again): This is a best practice. The first stage (builder) handles dependency installation. The second stage then copies only the essential node_modules and application code from the builder stage, resulting in a significantly smaller and more secure final image. It prevents build tools and development dependencies from ending up in the production image.
    • FROM node:20.10.0-alpine: Uses the official Node.js 20.10.0 LTS image, specifically the alpine variant for its small size.
    • WORKDIR /app: Sets the working directory inside the container.
    • COPY package*.json ./: Copies the package files early. If these don’t change, Docker can cache the npm install step, speeding up subsequent builds.
    • RUN npm install --omit=dev: Installs only production dependencies, further reducing image size and potential vulnerabilities.
    • USER appuser: This is a critical security measure. The application will run as a non-root user (appuser), minimizing the impact if the container is compromised.
    • RUN apk add --no-cache curl: Installs curl (Alpine’s package manager is apk) for the health check we’ll define in docker-compose.yaml. --no-cache prevents caching package indexes, saving space.
    • EXPOSE 3000: Documents that the container listens on port 3000. It doesn’t publish the port; ports in docker-compose.yaml does that.
    • CMD ["node", "app.js"]: The default command to execute when the container starts, launching our Node.js API.
  2. Update project-2-fullstack/docker-compose.yaml to include the API service: Add the api service definition alongside the postgresql service.

    # project-2-fullstack/docker-compose.yaml (updated)
    version: '3.8'
    
    services:
      postgresql:
        image: postgres:16.2-alpine
        restart: always
        environment:
          POSTGRES_USER: ${DB_USER}
          POSTGRES_PASSWORD: ${DB_PASSWORD}
          POSTGRES_DB: ${DB_NAME}
        volumes:
          - postgres_data:/var/lib/postgresql/data
        ports:
          - "5432:5432"
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
          interval: 10s
          timeout: 5s
          retries: 5
          start_period: 10s
    
      api:
        build: ./api # Instruct Docker Compose to build the image from the Dockerfile in the 'api' directory
        restart: always
        environment:
          PORT: ${API_PORT}
          DB_USER: ${DB_USER}
          DB_PASSWORD: ${DB_PASSWORD}
          DB_NAME: ${DB_NAME}
          DB_HOST: postgresql # Crucial: connects to the 'postgresql' service by its service name
          DB_PORT: ${DB_PORT}
        ports:
          - "${API_PORT}:${API_PORT}" # Map API port from container to host
        depends_on:
          postgresql:
            condition: service_healthy # Ensure PostgreSQL is healthy before starting the API
        healthcheck:
          test: ["CMD", "curl", "-f", "http://localhost:${API_PORT}/"] # Use curl to check API health
          interval: 30s
          timeout: 10s
          retries: 3
          start_period: 20s # Give the API more time to start up before checking health
    
    volumes:
      postgres_data:

    Explanation:

    • api: The service name for our Node.js application.
    • build: ./api: Tells Docker Compose to build the image for this service using the Dockerfile located in the api directory.
    • environment: Passes environment variables from our .env file to the Node.js container. DB_HOST is set to postgresql, leveraging Docker’s internal DNS.
    • ports: - "${API_PORT}:${API_PORT}": Maps the container’s API port (e.g., 3000) to the host’s API_PORT (defined in .env).
    • depends_on: postgresql: condition: service_healthy: This is a powerful orchestration feature. It ensures the api service only starts after the postgresql service is reported as healthy by its health check. This prevents the API from crashing while waiting for the database to become available.
    • healthcheck: Defines a health check for the API using curl to hit its root endpoint. start_period is important here, giving the Node.js app sufficient time to boot up before the health checks begin.
  3. Update project-2-fullstack/.env with API port: Add the API_PORT variable to your .env file.

    # project-2-fullstack/.env (updated)
    DB_USER=devuser
    DB_PASSWORD=devpassword
    DB_NAME=todos_db
    DB_HOST=postgresql
    DB_PORT=5432
    API_PORT=3000 # New: Port for our Node.js API

5. End-to-End Verification: Starting the Full Stack

Now we can bring up both services together and test the full application.

  1. Stop and remove previous containers and volumes: It’s good practice to start clean, especially when making significant configuration changes. Ensure you are in the project-2-fullstack directory.

    docker compose down --volumes

    This command stops and removes all containers, networks, and the postgres_data volume, ensuring a fresh start with an empty database. If you wished to preserve your data, you would omit the --volumes flag.

  2. Start all services defined in docker-compose.yaml:

    docker compose up -d

    This command will build the api image (if not already built), then start both postgresql and api services in detached mode. Docker Compose will respect the depends_on condition, ensuring PostgreSQL is healthy before the API starts.

  3. Check container status:

    docker compose ps

    You should see both postgresql and api services running and eventually transitioning to a healthy status. This might take a moment due to start_period and interval settings for the health checks.

  4. View logs for both services:

    docker compose logs -f

    You should observe the PostgreSQL startup messages, followed by the Node.js API connecting to the database and logging PostgreSQL database connected successfully.

  5. Test the API: Once both services are healthy, you can interact with your API using curl from your terminal.

    • Health Check: Verify the API is responsive.

      curl http://localhost:3000/

      Expected output: Node.js API is running!

    • Add a Todo: Send a POST request to create a new todo item.

      curl -X POST -H "Content-Type: application/json" -d '{"title": "Learn Docker Compose"}' http://localhost:3000/todos

      Expected output: {"id":1,"title":"Learn Docker Compose","completed":false}

    • Add another Todo:

      curl -X POST -H "Content-Type: application/json" -d '{"title": "Build a Dockerized App"}' http://localhost:3000/todos

      Expected output: {"id":2,"title":"Build a Dockerized App","completed":false}

    • List Todos: Retrieve all todo items.

      curl http://localhost:3000/todos

      Expected output: [{"id":1,"title":"Learn Docker Compose","completed":false},{"id":2,"title":"Build a Dockerized App","completed":false}]

Congratulations! You’ve successfully built and verified a full-stack application with a Node.js API and PostgreSQL database, all containerized and orchestrated by Docker Compose. This is a significant milestone in your Docker journey.

Production Considerations: Hardening and Operations

As a project mentor, I always emphasize incorporating production thinking early in the development cycle. Here’s what we’ve already considered and what more you might add.

Non-Root User in Dockerfile

Running processes as root inside containers is a significant security risk. If an attacker compromises your application, they immediately gain root privileges within the container, potentially enabling them to escape the container sandbox or cause more extensive damage to the host system.

  • Our Solution: In api/Dockerfile, we explicitly create a non-root user (appuser) and switch to it using the USER appuser instruction. This strictly adheres to the principle of least privilege, reducing the severity of a potential compromise.
  • Impact: If your application needs to write to specific directories, ensure appuser has the necessary permissions for those directories within the container image.

Resource Limits

In a production environment, it’s crucial to prevent one container from consuming all available host resources, which could destabilize other services or the host itself.

  • Implementation (Example in docker-compose.yaml): You can add deploy.resources.limits to your services in docker-compose.yaml (under the api and postgresql service definitions) to set CPU and memory constraints.

    # ... inside a service definition, e.g., 'api'
        deploy:
          resources:
            limits:
              cpus: '0.5' # Limit to 50% of one CPU core
              memory: 512M # Limit to 512 MB of RAM
            reservations: # Minimum resources guaranteed for the service
              cpus: '0.25'
              memory: 128M
    • Trade-offs: Setting resource limits too low can cause performance bottlenecks, Out Of Memory errors, or service crashes. It’s essential to monitor your application’s actual resource usage in a representative environment to find optimal and safe values.

Health Checks and depends_on

Health checks (as implemented in docker-compose.yaml for both services) are vital for reliable deployments and robust service orchestration.

  • Purpose: They go beyond simply checking if a container has started; they tell Docker when a service is truly ready to receive traffic and, crucially, if it remains functional over time.
  • Orchestration: The depends_on: condition: service_healthy feature leverages these checks to ensure services start in the correct order, preventing common startup failures (e.g., the API trying to connect to a database that is still initializing).
  • Deployment: In more advanced production orchestration systems like Kubernetes, similar health checks (liveness and readiness probes) are extensively used for rolling updates, auto-healing of failing services, and intelligent traffic routing.

Environment Variable Management with .env

The .env file is an excellent and convenient solution for local development to manage configuration. However, for production deployments:

  • Security: As mentioned, never commit .env files to version control repositories. They often contain sensitive credentials and API keys.
  • Production Deployment Best Practices: In production environments, you should use more secure and robust methods for managing secrets:
    • Container Orchestrators: Kubernetes secrets, Docker Swarm secrets.
    • Cloud Secret Management Services: AWS Secrets Manager, Azure Key Vault, Google Secret Manager.
    • CI/CD Pipelines: Inject variables directly into the build/deploy process as secure pipeline variables.

Troubleshooting Common Issues

Even with careful planning, issues can arise when working with multi-service containerized applications. Here are some common pitfalls and effective debugging strategies.

  1. Database Connection Refused/Timeout:

    • Symptom: Your Node.js API logs show errors like Error acquiring PostgreSQL client: or connect ECONNREFUSED.
    • Cause: The PostgreSQL service might not be running, isn’t yet healthy, or the DB_HOST configuration in your API service is incorrect.
    • Solution:
      • Run docker compose ps to check the postgresql service’s status. Is it healthy?
      • Inspect docker compose logs postgresql for any startup errors or indications that the database is not ready.
      • Verify that DB_HOST in your project-2-fullstack/.env file is correctly set to postgresql.
      • Ensure depends_on: postgresql: condition: service_healthy is correctly configured for the api service in docker-compose.yaml to guarantee proper startup order.
  2. Port Conflicts:

    • Symptom: docker compose up fails with an error message like Bind for 0.0.0.0:3000 failed: port is already allocated.
    • Cause: Another process on your host machine is already using the specified port (e.g., port 3000 for the API or 5432 for PostgreSQL).
    • Solution:
      • Identify and stop the conflicting process. On Linux/macOS, use lsof -i :<PORT_NUMBER>. On Windows, use netstat -ano | findstr :<PORT_NUMBER> followed by taskkill /PID <PID> /F.
      • Alternatively, change API_PORT (or DB_PORT) in your .env file to an unused port (e.g., 3001 for the API) and restart Docker Compose.
  3. Volume Permission Issues (less common with official images):

    • Symptom: PostgreSQL logs show errors indicating could not open file "/var/lib/postgresql/data/..." or permission denied.
    • Cause: The user running PostgreSQL inside the container (often postgres with UID/GID 999 in official images) does not have the necessary write permissions to the mounted volume directory on the host. This issue is more common with bind mounts or when using custom Docker images that don’t correctly set user permissions.
    • Solution:
      • For named volumes, Docker usually manages permissions correctly. If an issue occurs, it might indicate a deeper Docker daemon configuration problem or host filesystem permissions.
      • For bind mounts, you might need to manually adjust permissions on the host directory that is mounted into the container. For example, sudo chown -R 999:999 /path/to/host/data (where 999 is a common UID/GID for the postgres user in official images) might resolve it.

Summary & Next Steps

In this chapter, you’ve significantly advanced your Docker skills by moving beyond single-container applications to orchestrating a multi-service full-stack system. You now have:

  • A Node.js API container capable of interacting with a PostgreSQL database.
  • A PostgreSQL database container with robust, persistent data storage via Docker volumes.
  • A docker-compose.yaml file that effectively defines and orchestrates both services, their network, and crucial startup dependencies using health checks.
  • A practical understanding of multi-stage Dockerfiles for creating secure and efficient production-ready images.
  • Hands-on experience with essential Docker Compose commands like up, down, ps, and logs.

This project is now a runnable, self-contained full-stack application. For further refinement and to continue your learning, consider these next steps:

  • Frontend Integration: Add a simple frontend application (e.g., built with React, Vue, or even a static HTML file) in its own container. You could serve it using Nginx (similar to what you learned in Project 1) and configure it to connect to your Node.js API.
  • Database Migrations: Integrate a database migration tool like Knex.js or TypeORM to manage schema changes systematically and version-control your database structure.
  • Automated Testing: Implement unit and integration tests for your Node.js API, and configure them to run within a Docker container as part of your development workflow.
  • Environment-Specific Configuration: Explore using multiple Docker Compose files (e.g., docker-compose.dev.yaml for development, docker-compose.prod.yaml for production) to manage different configurations and optimize for each environment.

Your ability to containerize and orchestrate such systems is a critical and highly sought-after skill in modern software development and DevOps practices.


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.

References