40 Containerized Node Application

Build and run a simple Node.js application using Docker and Docker Compose.

In this lab, you will take a small Node.js application and run it inside containers. You will start by building and running the application image, then introduce a database and orchestrate everything with Docker Compose.

Prerequisites

You should have:

All changes must be committed and pushed to your fork.

Explore the application structure

Navigate to the lab directory in the work repository.

Terminal window
cd labs/40-containerized-node-app

Inspect the directory structure.

Terminal window
ls

Pay particular attention to:

  • app/ — the Node.js application
  • db/init.sql — database initialization script
  • docker/Dockerfile — application image definition
  • docker/compose.yaml — multi-container setup

Build the application image

Build the Docker image for the application.

Terminal window
docker build -t quote-app -f docker/Dockerfile .

Observe the build output and identify the main steps:

  • base image selection
  • dependency installation
  • source code copy
  • exposed port

Run the application container

Run the application container without a database.

Terminal window
docker run --rm -it -p 3000:3000 quote-app

Open a browser and navigate to:

http://localhost:3000

Confirm that the application starts and renders placeholder data.

Stop the container with Ctrl+C.

Optional challenge: explore the container from the inside

Run the container with an interactive shell instead of starting the server.

Terminal window
docker run --rm -it quote-app sh

Inside the container:

  • Inspect the filesystem layout
  • Locate the application files
  • Check the installed Node.js version

Exit the container when done.

Inspect environment-based configuration

Review how the application reads configuration from environment variables.

Open app/server.js and locate the database configuration.

Verify that the application can start even if the database is not available.

Start the database with Docker

Run a PostgreSQL container manually.

Terminal window
docker run --rm \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=postgres \
-p 5432:5432 \
postgres:18

Observe the logs and confirm that the database initializes successfully.

Optional: initialize the database manually (for debugging)

By default, this database is empty and temporary.

If you want to initialize it with the schema and seed data without using Docker Compose, you can mount the initialization script manually:

Terminal window
docker run --rm \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=postgres \
-p 5432:5432 \
-v "$(pwd)/db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro" \
postgres:18

This is useful for debugging, but Docker Compose already handles database initialization automatically.

Optional challenge: connect to PostgreSQL manually

From another terminal, connect to the running database using psql.

Terminal window
psql -h localhost -U postgres -d postgres

Inside the prompt:

  • List tables
  • Query existing data
  • Exit the session

This helps validate that the database is running independently of the application.

Stop the container with Ctrl+C.

Orchestrate services with Docker Compose

Start both the application and database using Docker Compose.

Terminal window
docker compose -f docker/compose.yaml up --build

Wait until both services are running and healthy.

Open the application again in your browser and refresh the page.

Observe the difference in behavior compared to running the application alone.

Enable database queries in the application

Open app/server.js.

Uncomment the SELECT query in the root route handler so that data is fetched from the database.

Save the file and rebuild the application image.

Terminal window
docker compose -f docker/compose.yaml build
docker compose -f docker/compose.yaml up

Refresh the page and confirm that data now comes from the database.

Optional challenge: break and fix the application

Intentionally introduce a small error in the SQL query.

Examples:

  • Misspell a column name
  • Change the table name
  • Remove a parameter

Rebuild and restart the services.

Observe:

  • Application logs
  • Database error messages
  • Container exit behavior (if any)

Fix the issue and confirm the application recovers.

Insert data using the application

Use the form in the web interface to submit a new quote.

Observe the application logs in the terminal.

Refresh the page and confirm that the new data is persisted.

Optional challenge: add a new route

Add a new route to the application that returns raw JSON instead of HTML.

Ideas:

  • /health endpoint
  • /api/quotes endpoint

Rebuild the image and confirm the route works in the browser.

Optional challenge: improve the look and feel

Improve the appearance of the application interface.

Options:

  • Add basic styling directly in the Handlebars templates
  • Serve a small CSS file using Fastify’s static file plugin
  • Adjust layout, spacing, or typography for readability

Keep changes minimal and focus on clarity rather than design complexity.

Rebuild the image and confirm that the updated styling appears in the browser.

Observe container behavior

In a separate terminal, list running containers.

Terminal window
docker ps

Inspect logs for a specific service.

Terminal window
docker compose -f docker/compose.yaml logs app

Stop the environment cleanly.

Terminal window
docker compose -f docker/compose.yaml down

Troubleshooting

Application does not start

Confirm the image builds successfully and that port 3000 is not already in use.

Terminal window
docker images
docker ps

Database connection errors

Confirm the database service is healthy before the application starts.

Terminal window
docker compose -f docker/compose.yaml ps

Review the database logs if needed.

Terminal window
docker compose -f docker/compose.yaml logs db
Bonus challenge: add automated tests

Use Node.js built-in testing and Fastify’s inject method to test a route.

Steps:

  • Create a simple test file
  • Start the app in memory
  • Assert on the response status and body

This is intentionally open-ended and meant for advanced exploration.

Example starting point:

tests/app.test.js
import test from 'node:test'
import assert from 'node:assert'
import buildApp from '../app/app.js'
test('GET / responds with a page', async () => {
const app = buildApp()
const response = await app.inject({
method: 'GET',
url: '/'
})
assert.strictEqual(response.statusCode, 200)
// TODO:
// - inspect response.body
// - assert on returned content
// - add more tests for other routes
})

This example only tests an in-memory instance of the application. Extending it to test database-backed routes is intentionally left as an exercise.

To run the tests locally:

Terminal window
node --test

If you add a test script to package.json, you may also run:

Terminal window
npm test

Wrap-up

You have built and run a containerized Node.js application and orchestrated it with Docker Compose.

This setup mirrors how real services are developed, tested, and prepared for deployment. In the next session, you will build on this foundation with automation and Continuous Integration.