Recommended resources
The freely available resources this lesson will direct you to.
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.

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 officialpostgresimage, 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.envfile.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 orpsql.volumes: Mounts a named volume calledpostgres_datato the directory where PostgreSQL stores its data. This ensures your data persists even if thedbcontainer is removed and recreated.healthcheck: This is a crucial feature for reliability. Docker will periodically run thepg_isreadycommand inside the container. This doesn't just check if the container is running, but if the PostgreSQL process itself is ready to accept connections. Thestart_periodgives 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 animage, we tell Compose to build an image from theDockerfilein the current directory (context: .).env_file: This line is important. It tells Compose to load the variables from our.envfile directly into the container's environment, making them available toprocess.envfor 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./srcon your machine, it's instantly reflected inside the container, allowing tools likenodemonto 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_healthytells Compose to wait until thedbservice's healthcheck passes before starting theappservice. 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 thepostgres_datavolume.networks: This creates a custom bridge network namedagentic_network. When services are on the same custom network, they can communicate with each other using their service names as hostnames. For example, ourappcan connect to the database using the connection stringpostgresql://user:password@db:5432/agentic_db.dbis 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 --buildThis command will build the
appimage (if it's changed) and then start both theappanddbcontainers. You will see the logs from both services interleaved in your terminal. You'll see thedbstart, report it's healthy, and only then will theappcontainer start. -
Start in detached mode:
docker compose up -dThis 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 psThis shows the status of the containers managed by Compose.
-
Stop the application:
docker compose downThis stops and removes the containers and the network. By default, it does not remove named volumes, so your
postgres_datawill 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.ymlfile. - 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
healthcheckanddepends_on: condition: service_healthyguarantees 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.