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:
- Docker installed and running
- Git working on your machine
- Your fork of the course work repository cloned locally
- Ubuntu vm with the tools and ssh installed
All changes must be committed and pushed to your fork.
Explore the application structure
Navigate to the lab directory in the work repository.
cd labs/40-containerized-node-appInspect the directory structure.
lsPay particular attention to:
app/— the Node.js applicationdb/init.sql— database initialization scriptdocker/Dockerfile— application image definitiondocker/compose.yaml— multi-container setup
Build the application image
Build the Docker image for the application.
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.
docker run --rm -it -p 3000:3000 quote-appOpen a browser and navigate to:
http://localhost:3000Confirm 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.
docker run --rm -it quote-app shInside 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.
docker run --rm \ -e POSTGRES_USER=postgres \ -e POSTGRES_PASSWORD=postgres \ -e POSTGRES_DB=postgres \ -p 5432:5432 \ postgres:18Observe 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:
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:18This 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.
psql -h localhost -U postgres -d postgresInside 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.
docker compose -f docker/compose.yaml up --buildWait 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.
docker compose -f docker/compose.yaml builddocker compose -f docker/compose.yaml upRefresh 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:
/healthendpoint/api/quotesendpoint
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.
docker psInspect logs for a specific service.
docker compose -f docker/compose.yaml logs appStop the environment cleanly.
docker compose -f docker/compose.yaml downTroubleshooting
Application does not start
Confirm the image builds successfully and that port 3000 is not already in use.
docker imagesdocker psDatabase connection errors
Confirm the database service is healthy before the application starts.
docker compose -f docker/compose.yaml psReview the database logs if needed.
docker compose -f docker/compose.yaml logs dbBonus 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:
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:
node --testIf you add a test script to package.json, you may also run:
npm testWrap-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.