Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Docker Compose for Node.js and Databases

Welcome to the final lesson in our module on containerization and deployment. In our previous session, you built a multi-stage Dockerfile to produce a lean, secure image of our Node.js application and implemented a robust, type-safe system for managing environment variables. You now have a portable application artifact and a clean way to separate configuration from code.

This lesson takes the final step to tie everything together for local development and prepare for production deployment. We will use Docker Compose to define and run our complete multi-container application stack—the Node.js service and its PostgreSQL database. Your goal is to create a docker-compose file to orchestrate the Node.js service and its database dependencies. By the end of this lesson, you will have a single command to spin up, manage, and shut down your entire development environment, creating a workflow that is both efficient and easily reproducible.

Why Docker Compose?

As applications grow, they often depend on other services, such as databases, caches, or message queues. Our agentic system will rely on a PostgreSQL database for its tool and prompt stores. While you could manage these services by running separate docker run commands for each, this quickly becomes complex. You would have to manually manage networks for them to communicate, handle startup order, and remember long, complex commands.

Docker Compose solves this by allowing you to define your entire application stack in a single, declarative YAML file.

This diagram illustrates how Docker Compose can manage a multi-service application, including a web server (like our Node.js app), a database (PostgreSQL), and a reverse proxy (Nginx). It orchestrates the networking, dependencies, and configuration for all components.

Anatomy of a docker-compose.yml File

The docker-compose.yml (or compose.yml) file is the heart of Docker Compose. It describes the services, networks, and volumes that make up your application. Let's explore its core components by building one for our project.

A modern compose.yml file typically has three main top-level keys:

  • services: Defines the individual containers that form your application stack (e.g., your Node.js app, a database).
  • volumes: Defines persistent data storage that can be used by your services.
  • networks: Defines the networks that allow your services to communicate with each other.

Let's begin by defining our services. We need one for our Node.js application and one for the PostgreSQL database.

Docker with Node.js: Build, Run, and Manage Containers

The video "Docker with Node.js" from Better Stack provides an excellent walkthrough of creating a docker-compose.yml file for a very similar stack. It clearly explains the transition from individual docker commands to a Compose file.

Watch from the introduction to understand the motivation behind using Docker Compose. Then, review the sections where the presenter defines the application service and the PostgreSQL service. Pay close attention to how depends_on, healthcheck, volumes, and networks are configured.

Building on the concepts from the video, let's look at a comprehensive, production-aware compose.yml file.

Containerize a Node.js application

The official Docker documentation provides a fantastic, well-commented example of a compose.yml file that is perfect for our needs. It even includes separate service definitions for development and production, which is a common best practice.

First, examine the full compose.yml configuration. Don't worry about memorizing every line; focus on the structure: The app-dev and app-prod services for our Node.js application. The db service for PostgreSQL. The top-level volumes and networks definitions. Next, see how the .env file is used to provide configuration values. Docker Compose automatically substitutes variables like ${APP_PORT} with values from a .env file in the same directory. Finally, review the commands for running and stopping the application stack.

Let's break down the key sections from that example and adapt them for our project.

Crafting the compose.yml File

Create a file named compose.yml in the root of your project.

1. The Database Service (db)

We'll start with the database, as our application depends on it.

services:
  db:
    image: postgres:16-alpine # Use a specific, lightweight image
    container_name: agentic_db
    environment:
      POSTGRES_DB: '${POSTGRES_DB:-agentic_db}'
      POSTGRES_USER: '${POSTGRES_USER:-user}'
      POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-password}'
    ports:
      - '${DB_PORT:-5432}:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - agentic_network
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-user} -d ${POSTGRES_DB:-agentic_db}']
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    restart: unless-stopped

Key directives here:

  • image: Pulls the official postgres image, version 16, based on Alpine Linux.
  • environment: Configures the database by setting the database name, user, and password. It uses the ${VAR:-default} syntax to allow overriding these from your .env file.
  • ports: Maps port 5432 inside the container to a port on your host machine, allowing you to connect to the DB with an external client like TablePlus or psql.
  • volumes: Mounts a named volume called postgres_data to the directory where PostgreSQL stores its data. This ensures your data persists even if the db container is removed and recreated.
  • healthcheck: This is a crucial feature for reliability. Docker will periodically run the pg_isready command inside the container. This doesn't just check if the container is running, but if the PostgreSQL process itself is ready to accept connections. The start_period gives the database 30 seconds to initialize before health checks start failing.
  • restart: unless-stopped: Ensures the database container automatically restarts if it crashes, unless you manually stop it.

2. The Application Service (app)

Now, let's define the service for our Node.js application.

  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: agentic_app
    ports:
      - '${APP_PORT:-8000}:8000'
    env_file:
      - ./.env # Passes the .env file to the container for our app to use
    volumes:
      - ./src:/app/src:ro # Mount source code for hot-reloading (read-only)
      - ./package.json:/app/package.json
      - ./tsconfig.json:/app/tsconfig.json
    networks:
      - agentic_network
    depends_on:
      db:
        condition: service_healthy # The magic!
    restart: unless-stopped

Key directives:

  • build: Instead of an image, we tell Compose to build an image from the Dockerfile in the current directory (context: .).
  • env_file: This line is important. It tells Compose to load the variables from our .env file directly into the container's environment, making them available to process.env for our Zod validation script from the last lesson.
  • volumes: Here, we use bind mounts to map files and directories from our host machine into the container. This is a common development pattern. When you change a file in ./src on your machine, it's instantly reflected inside the container, allowing tools like nodemon to restart the server automatically (hot-reloading). We mount it as :ro (read-only) as a good practice, since the container shouldn't modify our source code.
  • depends_on: This directive controls the startup order. Specifically, condition: service_healthy tells Compose to wait until the db service's healthcheck passes before starting the app service. This completely eliminates the race condition where your app tries to connect to a database that isn't ready yet.

How to Use Docker Compose for Production Workloads

This article from freeCodeCamp provides a deeper dive into modern Compose features for production.

Read the section on health checks and startup ordering. It reinforces the importance of the condition: service_healthy pattern. Also, review the "Practical Adoption Path" section, which outlines an incremental approach to implementing these features.

3. Top-Level Volumes and Networks

Finally, we declare the named volume and custom network at the top level.

volumes:
  postgres_data:
    driver: local

networks:
  agentic_network:
    driver: bridge
  • volumes: This formally declares the postgres_data volume.
  • networks: This creates a custom bridge network named agentic_network. When services are on the same custom network, they can communicate with each other using their service names as hostnames. For example, our app can connect to the database using the connection string postgresql://user:password@db:5432/agentic_db. db is resolved by Docker's internal DNS to the IP address of the database container.

Your .env File

Create a .env file in your project root to provide the configuration values:




# Application Configuration
NODE_ENV=development
APP_PORT=8000
LOG_LEVEL=debug




# Database Configuration
POSTGRES_DB=agentic_db
POSTGRES_USER=user
POSTGRES_PASSWORD=password
DB_PORT=5432
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${DB_PORT}/${POSTGRES_DB}
POSTGRES_HOST=db # Important: Use the service name from compose.yml




# AI Service Configuration
OPENAI_API_KEY=your_openai_api_key_here

Note that POSTGRES_HOST is set to db, the service name of our database in compose.yml.

Running the Stack

With the compose.yml and .env files in place, managing your entire stack is now trivial.

Docker with Node.js: Build, Run, and Manage Containers

This final clip from the Better Stack video demonstrates the essential docker compose commands.

Watch how the presenter uses docker compose up --build to start the services, how to run them in detached mode with -d, and how to view logs. This covers the commands from this section.

Here are the most common commands you'll use:

  • Start the application:

    docker compose up --build
    

    This command will build the app image (if it's changed) and then start both the app and db containers. You will see the logs from both services interleaved in your terminal. You'll see the db start, report it's healthy, and only then will the app container start.

  • Start in detached mode:

    docker compose up -d
    

    This starts the containers in the background and returns you to your shell.

  • View logs:

    docker compose logs -f app # Tails the logs for the 'app' service
    
  • Check status:

    docker compose ps
    

    This shows the status of the containers managed by Compose.

  • Stop the application:

    docker compose down
    

    This stops and removes the containers and the network. By default, it does not remove named volumes, so your postgres_data will be safe.

Conclusion

Congratulations! You have successfully containerized your entire application stack. You now have a single source of truth (compose.yml) for your development environment's architecture and a simple set of commands to manage it. This setup is not only efficient for local development but also serves as a blueprint for your production deployment.

Key Takeaways:

  • Declarative Stack Definition: Docker Compose allows you to define your multi-service application in a single compose.yml file.
  • Service Communication: Custom networks enable services to communicate reliably using their service names.
  • Data Persistence: Named volumes ensure that data for stateful services like your database is preserved across container restarts.
  • Reliable Startup: The combination of healthcheck and depends_on: condition: service_healthy guarantees that services start in the correct order, preventing common race conditions.
  • Unified Workflow: You now have a simple, powerful workflow (docker compose up, docker compose down) to manage your full development environment.

This lesson concludes our module on containerization. You've journeyed from writing code to building a secure, optimized Docker image, managing configuration safely, and finally, orchestrating a complete multi-container environment. With these skills, your sophisticated AI agentic system is now packaged and ready to be deployed to any platform that can run Docker containers. This foundation of infrastructure-as-code is invaluable for building robust, scalable, and maintainable production systems.

Can't find a good explanation? Sign up and we'll make it for you

Sign up