Skip to content

Lesson 4 — Multi-Container Applications & Configuration Management ​

In Lesson 3, you packaged your Node.js/Express API into a Docker image and ran it as a container. In Lesson 2, you added Vitest tests and GitHub Actions to check your code automatically.

Your application can run inside Docker, but real applications often need more than one service. For example, your API might depend on a relational database. You could start the API and database with separate docker run commands, but you would have to configure their network, storage, and environment variables manually.

Docker Compose lets you describe the entire application in one file and manage its services together.

In this lesson, you will connect the API from earlier lessons to MySQL, store data in a persistent volume, and manage configuration without putting passwords in your source code.

Continue your existing project

Use your deployment-workshop repository from Lessons 1–3. Keep your Netlify site, Render deployment, Dockerfile, Vitest tests, and GitHub Actions workflow.

All containers in this lesson run on your computer. You do not need a DigitalOcean server yet. The existing Render deployment is separate from your local Docker Compose environment.

Learning outcomes ​

By the end of this lesson, you should be able to:

  1. Explain why multi-container applications use Docker Compose and identify its main configuration elements.
  2. Define and run an Express API and a MySQL database as separate services.
  3. Connect services using Docker's internal network and service names.
  4. Configure environment variables and distinguish Compose's .env interpolation from container environment variables.
  5. Store and retrieve data using MySQL and verify that a Docker volume preserves it when containers are recreated.
  6. Handle configuration safely, avoid committing credentials, and recognize when proper secret management is needed.
  7. Inspect, troubleshoot, and stop a multi-container application with Docker Compose commands.

What you need ​

Tool or requirementWhy you need it
Docker Engine / Docker Desktop with Docker Compose v2Run and manage multiple containers
deployment-workshop repositoryContinue your existing Express API
Node.js and npmInstall the MySQL driver and run your existing tests
MySQL (as a Docker image)Store application data; no separate local MySQL installation required
mysql2Connect Node.js to MySQL using promises
dotenvLoad local .env values when running Node.js outside Compose
Insomnia or curlSend HTTP requests to your API
A terminal and code editorEdit files and inspect services

Check that Docker and Compose are available:

bash
docker --version
docker compose version
docker info

Use docker compose, not docker-compose

This lesson uses the Compose v2 command format: docker compose .... The older standalone docker-compose command may still exist on some machines, but it is not the one used in these examples.

What you will build ​

You will run two containers as one application:

text
                    Your computer
                  http://localhost:3000
                           │
                           ▼
              ┌────────────────────────┐
              │  API service           │
              │  Node.js + Express     │
              │  port 3000 published   │
              └───────────┬────────────┘
                          │
                Docker Compose network
                          │
                 db:3306  ▼
              ┌────────────────────────┐
              │  db service            │
              │  MySQL                 │
              │  port 3306 internal    │
              └───────────┬────────────┘
                          │
                          ▼
                   Named volume
                   mysql_data

Your API will gain a small Notes API to demonstrate database writes and reads. You will use Insomnia to add a note, restart the containers, and verify that the note still exists.

The completed project will look approximately like this:

text
deployment-workshop/
├── .github/
│   └── workflows/
│       └── ci.yml
├── db/
│   └── init.sql             ← New: creates the notes table for a fresh database
├── site/
│   └── index.html
├── src/
│   ├── app.js               ← Updated: new database-backed routes
│   ├── db.js                ← New: MySQL connection pool
│   └── server.js
├── tests/
│   └── app.test.js
├── .dockerignore
├── .env                    ← New: local configuration; do not commit
├── .env.example            ← New: safe template; commit this
├── .gitignore
├── compose.yaml            ← New: API and database services
├── Dockerfile
├── package.json
└── package-lock.json

Part 1 — Understand Docker Compose ​

1. Why do applications need multiple containers? ​

Consider an API that stores notes in a database.

The API and database have different jobs:

  • The API receives HTTP requests, validates input, and returns responses.
  • MySQL stores and retrieves data.

You could install both inside one container, but keeping them separate usually makes development and maintenance easier. Each service can have its own image, configuration, lifecycle, and storage.

Docker Compose is a tool for defining and running a group of related containers. Instead of remembering several long docker run commands, you write their configuration in a YAML file.

2. Important Compose concepts ​

ConceptMeaningExample
Compose fileYAML file that describes your applicationcompose.yaml
ServiceConfiguration for one type of containerapi or db
ImagePackaged software used by a servicemysql:8.4
BuildInstructions for building an image from your sourcebuild: .
NetworkLets containers communicate by service nameapi connects to db
VolumeStorage managed independently of a container's writable layermysql_data
EnvironmentConfiguration passed to a container at runtimeDB_HOST=db
Health checkA test of whether a service is ready to serve requestsMySQL responds to a query
DependencyA startup relationship between servicesAPI waits for healthy MySQL

A minimal Compose file might look like this:

yaml
services:
  api:
    build: .
    ports:
      - "127.0.0.1:3000:3000"

This is similar to building and running your Dockerfile from Lesson 3, but with a declarative configuration. You will expand it to include MySQL.

3. Service names are network addresses ​

Docker Compose creates a project network by default. Services on that network can reach one another using their service names.

Inside the API container, the database hostname will be:

text
db

The database connection target will be:

text
db:3306

Do not use localhost to connect from the API container to the MySQL container. Inside a container, localhost refers to that same container, not another service.

Where the request startsTargetAddress
Browser or Insomnia on your computerExpress APIhttp://localhost:3000
Express API containerMySQL containerdb:3306
MySQL containerIts own MySQL server127.0.0.1:3306

You will not publish MySQL's port to your computer. The API can reach MySQL through the internal Compose network, and the database is not unnecessarily exposed through a host port.

Check your understanding ​

  1. Why do we keep the API and MySQL in different containers?
  2. Why can't the API use localhost:3306 to reach the database container?
  3. What is the difference between build: . and image: mysql:8.4?
Reveal suggested answers
  1. Each service has a separate responsibility and can be configured, updated, and managed independently.
  2. localhost means the current container. Docker's service name db identifies the database container on the internal network.
  3. build: . tells Compose to build an image from the Dockerfile in the current directory. image: mysql:8.4 tells Compose to use that published image.

Part 2 — Prepare the API for MySQL ​

4. Start from the project you already have ​

Open your repository and check the existing code:

bash
cd deployment-workshop
npm ci
npm run check
npm test

Make sure your tests from Lesson 2 pass before adding a database.

If the container from Lesson 3 is still running on port 3000, stop it:

bash
docker stop deployment-api

If Docker says that the container does not exist, that is fine. You only need to make sure port 3000 is available.

5. Install the database dependencies ​

Install the MySQL client library and the local environment loader:

bash
npm install mysql2 dotenv

These belong in dependencies, not devDependencies, because your application needs mysql2 while running in Docker.

The Dockerfile from Lesson 3 already runs npm ci --omit=dev. After updating package-lock.json, Docker will install the new production dependencies when you rebuild the image.

6. Create the MySQL connection pool ​

Create src/db.js:

js
import 'dotenv/config';
import mysql from 'mysql2/promise';

const pool = mysql.createPool({
  host: process.env.DB_HOST || '127.0.0.1',
  port: Number(process.env.DB_PORT || 3306),
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 5,
});

export default pool;

A connection pool manages a small group of database connections so multiple requests can reuse them.

What this file does:

  • mysql2/promise lets you use async/await for SQL queries.
  • process.env reads configuration provided to the application at runtime.
  • dotenv/config reads a local .env file if it exists. Inside Compose, the container environment is already configured, so an .env file does not need to be copied into the image.
  • DB_HOST defaults to 127.0.0.1 for standalone local Node.js execution; Compose will override it with db.

Never hard-code real passwords

Do not write an actual database password inside src/db.js, Dockerfile, or your test files. Keep credentials outside committed source code.

7. Add database-backed endpoints ​

Open src/app.js. Keep the routes you already have, including GET / and GET /health.

Add this import at the top:

js
import pool from './db.js';

Then add the following routes before export default app;:

js
// Check whether the API can reach MySQL.
app.get('/db-check', async (_req, res) => {
  try {
    await pool.query('SELECT 1');
    res.json({ database: 'connected' });
  } catch (error) {
    console.error('Database check failed:', error.message);
    res.status(503).json({ database: 'unavailable' });
  }
});

// List saved notes.
app.get('/notes', async (_req, res) => {
  try {
    const [rows] = await pool.query(
      'SELECT id, message, created_at FROM notes ORDER BY id DESC',
    );

    res.json(rows);
  } catch (error) {
    console.error('Could not list notes:', error.message);
    res.status(503).json({ error: 'Database unavailable' });
  }
});

// Save a new note.
app.post('/notes', async (req, res) => {
  const message = req.body?.message;

  if (typeof message !== 'string' || !message.trim() || message.length > 255) {
    return res.status(400).json({
      error: 'message must be a non-empty string of up to 255 characters',
    });
  }

  try {
    const [result] = await pool.execute(
      'INSERT INTO notes (message) VALUES (?)',
      [message.trim()],
    );

    return res.status(201).json({
      id: result.insertId,
      message: message.trim(),
    });
  } catch (error) {
    console.error('Could not save note:', error.message);
    return res.status(503).json({ error: 'Database unavailable' });
  }
});

You now have three new endpoints:

MethodEndpointPurpose
GET/db-checkVerify that the API can query MySQL
GET/notesRead saved notes
POST/notesSave a note using a JSON request body

Notice the ? placeholder in INSERT INTO notes (message) VALUES (?). The value is sent separately through pool.execute(), which is safer than building SQL by concatenating user input into a query string.

The original GET /health endpoint still reports whether the API process is responding. The new GET /db-check endpoint checks an additional dependency. These are different questions: an API can be running while its database is unavailable.

Your existing Vitest tests should still pass

The new database-backed routes only query MySQL when they are called. Your existing tests for / and /health should not require a database container.

Check them again:

bash
npm run check
npm test

If the tests fail, check that src/app.js still exports app and that you did not change the original route responses.

8. Add SQL for a fresh database ​

Create a directory named db and a file named db/init.sql:

sql
CREATE TABLE IF NOT EXISTS notes (
  id INT AUTO_INCREMENT PRIMARY KEY,
  message VARCHAR(255) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

This SQL creates a simple table that will store your notes.

The official MySQL Docker image can run SQL files placed in /docker-entrypoint-initdb.d/ when it initializes a new, empty data directory. You will mount this file into that directory using Compose.

Initial setup is not a migration system

This initialization script runs when MySQL creates a new database volume. Editing db/init.sql later will not automatically modify a database that already has data. Database migrations are a separate topic in Lesson 6.


Part 3 — Configure and Run Docker Compose ​

9. Create a local configuration template ​

Create .env.example in the project root:

dotenv
DB_NAME=deployment_lab
DB_USER=lab_user
DB_PASSWORD=choose-a-long-local-password
MYSQL_ROOT_PASSWORD=choose-a-different-local-root-password

These are example values for local development, not passwords you should reuse for real services. Copy the file to .env and replace both password placeholders with values you choose.

On macOS or Linux:

bash
cp .env.example .env

On PowerShell:

powershell
Copy-Item .env.example .env

Edit .env before continuing. Use simple, non-empty passwords for this workshop and avoid sharing the file.

Add these lines to .gitignore (keep the existing entries):

text
.env
.env.*
!.env.example

If you accidentally committed an environment file before, adding .gitignore does not remove it from Git history. Replace any exposed real credentials and remove the tracked file from the repository.

Two different uses of .env

Compose interpolation: Docker Compose reads .env next to compose.yaml and substitutes values referenced using ${VARIABLE}.

Container environment: A variable is passed into a specific container only when Compose configures it through environment: or env_file: (or another supported mechanism). Merely having .env on your computer does not automatically make every variable available inside every container.

Node.js local loading: The dotenv package in src/db.js can read .env for a Node.js process started outside Compose. This is separate from how Compose interpolates its YAML file.

10. Create compose.yaml ​

Create compose.yaml in the project root:

yaml
services:
  api:
    build: .
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      NODE_ENV: production
      PORT: "3000"
      DB_HOST: db
      DB_PORT: "3306"
      DB_NAME: ${DB_NAME:?Set DB_NAME in .env}
      DB_USER: ${DB_USER:?Set DB_USER in .env}
      DB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD in .env}
      MYSQL_DATABASE: ${DB_NAME:?Set DB_NAME in .env}
      MYSQL_USER: ${DB_USER:?Set DB_USER in .env}
      MYSQL_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env}
    volumes:
      - mysql_data:/var/lib/mysql
      - ./db/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro
    healthcheck:
      test:
        - CMD-SHELL
        - >-
          MYSQL_PWD="$$MYSQL_PASSWORD" mysql --host=127.0.0.1
          --user="$$MYSQL_USER" --database="$$MYSQL_DATABASE"
          --execute='SELECT 1' >/dev/null 2>&1
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s
    restart: unless-stopped

volumes:
  mysql_data:

A few details matter:

  • build: . reuses the Dockerfile from Lesson 3 to create the API image.
  • image: mysql:8.4 uses the MySQL 8.4 image. A specified major/minor version is more predictable than using mysql:latest.
  • ports publishes only the API on your computer's loopback interface. MySQL is reachable by the API on the Compose network without publishing port 3306.
  • DB_HOST: db uses the service name as the database hostname.
  • ${DB_PASSWORD:?...} means Compose must find a non-empty value or stop with an explanatory error.
  • mysql_data is a named volume for database files.
  • The SQL file is mounted read-only using :ro.
  • depends_on: ... condition: service_healthy tells Compose to wait until the database health check succeeds before creating the API container. It does not replace application-level reconnect and error handling.
  • In the health check, $$ tells Compose to preserve $ for the shell inside the database container rather than substituting it on your computer. The check executes a real SQL query instead of just checking that the container process exists.

This environment-variable approach is for your local workshop

In this basic example, database passwords are passed as container environment variables. This is convenient for learning, but variables can be visible through tooling and diagnostics. Never commit .env, paste secrets into screenshots, or use these demonstration credentials for production.

Later in this lesson, you will see how Compose secrets offer a safer alternative. For a production deployment, use an appropriate secrets manager and review your platform's security model.

11. Validate and start your application ​

Before starting the containers, validate the Compose configuration:

bash
docker compose config --quiet

A successful command exits without printing the fully expanded configuration. Avoid sharing the output of plain docker compose config because it may contain environment-variable values, including passwords.

Start both services:

bash
docker compose up -d --build
  • up creates and starts the application's containers and other resources.
  • -d runs them in the background.
  • --build rebuilds your API image so it includes mysql2 and your new source files.

Check their status:

bash
docker compose ps

Inspect the logs if needed:

bash
docker compose logs --tail=50 api
docker compose logs --tail=50 db

MySQL can take a little longer to initialize on a new volume. Compose waits for the health check before starting the API service, but if a service fails, the logs usually explain why.

12. Test the database connection ​

In Insomnia, create a GET request:

text
http://localhost:3000/db-check

Expected HTTP status: 200 OK.

Expected JSON:

json
{
  "database": "connected"
}

You can also use curl:

bash
curl -i http://localhost:3000/db-check

Now verify that the original API endpoint still works:

bash
curl -i http://localhost:3000/health

It should continue returning {"status":"ok"}.

13. Create and read a note with Insomnia ​

Create a POST request in Insomnia:

  • URL: http://localhost:3000/notes
  • Body type: JSON
  • Body:
json
{
  "message": "My first note stored in MySQL"
}

Send the request. The API should respond with 201 Created and a body similar to:

json
{
  "id": 1,
  "message": "My first note stored in MySQL"
}

The generated id may differ if your database already contains notes.

Next, create a GET request:

text
http://localhost:3000/notes

You should receive an array containing your saved note. The response includes an id, message, and created_at timestamp stored by MySQL.

Optional: use curl instead of Insomnia

bash
curl -i -X POST http://localhost:3000/notes \
  -H 'Content-Type: application/json' \
  -d '{"message":"My first note stored in MySQL"}'

curl -i http://localhost:3000/notes

The multiline commands above use a Bash-compatible shell. On PowerShell, use Insomnia or enter an equivalent command in one line.

Your first Compose checkpoint ​

  • [ ] docker compose ps shows the API and MySQL services running.
  • [ ] GET /health returns HTTP 200.
  • [ ] GET /db-check returns {"database":"connected"}.
  • [ ] POST /notes creates a note and returns HTTP 201.
  • [ ] GET /notes returns the saved note.
  • [ ] MySQL is not published to a port on your host computer.

Part 4 — Understand Networking, Volumes, and Configuration ​

14. Inspect the services and their network ​

Run:

bash
docker compose ps
docker compose logs api
docker compose exec api node --version

You can examine which hostname the API received:

bash
docker compose exec api node -e 'console.log(process.env.DB_HOST)'

Expected output:

text
db

The API uses the database's service name rather than a hard-coded container IP. Container IP addresses can change when services are recreated, but Compose's service discovery handles the service name.

Avoid printing credentials

You can inspect non-sensitive variables such as DB_HOST, but avoid using commands that print your entire process environment or resolved Compose configuration when passwords are present.

15. Verify data persistence ​

Your database writes data under /var/lib/mysql, which is mapped to the named volume mysql_data.

First, make sure you have saved a note through POST /notes.

Stop and remove the application containers without deleting the volume:

bash
docker compose down

Start the application again:

bash
docker compose up -d --build

Then request your notes:

bash
curl http://localhost:3000/notes

Your note should still exist. The containers may have been removed and recreated, but the named volume preserves the database files.

Compare these commands:

CommandWhat happens to the database volume?
docker compose stopStops containers; volume remains
docker compose downRemoves Compose containers and network; named volume remains
docker compose up -dStarts/recreates services and uses the existing named volume
docker compose down --volumesRemoves the named volume and its stored data

Do not delete your data accidentally

Do not run docker compose down --volumes as part of the persistence exercise. That command removes the database volume. Use it only when you intentionally want to discard the workshop database.

A Docker volume helps data survive container recreation, but a volume is not a backup. You will explore database backups in Lesson 6.

16. Understand how MySQL initialization behaves ​

The first time you started Compose with an empty mysql_data volume:

  1. MySQL created the deployment_lab database using your configured database name.
  2. MySQL created the application user using your configured username and password.
  3. MySQL executed db/init.sql and created the notes table.

When you restart or recreate the container with the same existing volume, those initialization steps are not rerun.

This has two important consequences:

  • Changing db/init.sql does not update an existing database automatically.
  • Changing the MySQL initialization password variables does not automatically change credentials already stored in an initialized database.

For persistent databases, use proper SQL changes or migration tools rather than deleting the volume to force initialization.

17. Manage different types of configuration ​

You will encounter several different configuration mechanisms:

MechanismTypical useImportant limitation
Dockerfile ENVDefaults baked into the imageNot suitable for secrets
Project .envValues used by Compose interpolationNot automatically passed to every container
Compose environment:Runtime variables for a serviceValues may be visible in container configuration
Compose env_file:Load many variables into a serviceFile must be protected and not committed if sensitive
Node.js dotenvRead .env in a locally started Node.js processDoesn't secure the contents of the file
Compose secrets:Mount sensitive values as files for authorized servicesFile-backed local secrets still require secure storage and permissions

For this workshop:

  • Use .env.example to document what teammates need to configure.
  • Use .env only on your computer for local values.
  • Use Compose environment: to pass configuration to each container.
  • Do not put secrets in your image or commit them to Git.

18. Explore Compose secrets ​

A more careful approach is to mount a password as a file using Docker Compose secrets instead of providing the password directly in a container environment variable.

For example, imagine a local file named secrets/db_password.txt. You can give it to the db service like this:

yaml
services:
  db:
    image: mysql:8.4
    environment:
      MYSQL_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

This is a conceptual excerpt, not a replacement for your complete compose.yaml. The official MySQL image supports the _FILE convention for several configuration values, including MYSQL_PASSWORD_FILE. The application would need to read the secret file as well if it used file-based credentials.

Your Express app could read a file using Node.js, for example:

js
import { readFileSync } from 'node:fs';

const password = process.env.DB_PASSWORD_FILE
  ? readFileSync(process.env.DB_PASSWORD_FILE, 'utf8').trimEnd()
  : process.env.DB_PASSWORD;

You would then use password in your MySQL connection configuration, instead of directly reading process.env.DB_PASSWORD.

Protect the source secret file too

Compose file-backed secrets are mounted only into authorized containers, but the source files on your computer still contain sensitive data. Add secrets/ to .gitignore, restrict file access where possible, and do not commit or share those files.

A local Compose secret is not automatically an encrypted, centrally managed production secret. On cloud platforms, prefer the provider's secrets management features.

Think about it ​

  1. What happens if you put DB_HOST=localhost in the API container environment?
  2. Why should the MySQL service not publish port 3306 in this exercise?
  3. Why is .env.example safe to commit while .env usually is not?
  4. Why doesn't editing db/init.sql automatically update an existing database?
  5. Does depends_on: condition: service_healthy guarantee that MySQL will never disconnect after the API starts?
Reveal suggested answers
  1. The API would try to connect to its own container rather than the MySQL service.
  2. The API already communicates with MySQL over the internal Compose network. Publishing the database port would create unnecessary exposure.
  3. .env.example documents placeholders and non-sensitive defaults; .env contains actual environment-specific values and may contain credentials.
  4. The MySQL image runs initialization scripts only when creating a new database in an empty data directory.
  5. No. It helps with initial startup ordering, but applications must still handle dependency failures and disconnections at runtime.

Part 5 — Troubleshooting and Good Practices ​

19. Common problems ​

ProblemLikely causeWhat to check
docker compose is not recognizedCompose plugin not installedCheck Docker/Compose installation
Port 3000 is already in useAnother API process or container is listeningStop the old process/container or change the host port
API cannot connect to MySQLWrong hostname or credentialsVerify DB_HOST=db and the matching database variables
MySQL is unhealthyDatabase is still initializing or startup failedInspect docker compose logs db
Table '...notes' doesn't existDatabase volume was initialized before init.sql was addedCreate the missing table with SQL; do not delete valuable data
Changing .env does not fix database loginExisting MySQL volume retains its initialized database usersChange credentials inside MySQL or reconcile them with the original values
Data disappearsDatabase stored only in a container layer or volume was removedVerify the mysql_data mount and avoid down --volumes
Vitest tests fail unexpectedlyOriginal routes were changed or app startup logic movedKeep app.js exportable without starting the HTTP listener

Useful commands:

bash
docker compose ps
docker compose logs --tail=100 api
docker compose logs --tail=100 db
docker compose config --quiet
docker compose up -d --build

If you change application source code or dependencies, use --build to rebuild the API image. If you only change a runtime environment variable, recreate the affected service so it receives the new configuration.

20. Good multi-container habits ​

  • Keep one main responsibility per service: API and database belong in separate containers.
  • Use service names, not fixed container IP addresses, for internal connections.
  • Publish only the ports you need on the host.
  • Use named volumes for persistent database data.
  • Use health checks to improve initial startup coordination; still handle runtime failures in application code.
  • Keep database credentials outside Git and use actual secrets mechanisms for sensitive deployments.
  • Make your API image rebuildable from its Dockerfile and lockfile.
  • Test changes with Vitest before committing and let GitHub Actions check your branch.
  • Treat initialization, migration, and backup as different operations.

Final Exercise — Run and Verify a Multi-Container Application ​

Complete the following tasks using your own deployment-workshop repository.

  1. Add mysql2 and dotenv as application dependencies.
  2. Create src/db.js with a MySQL connection pool.
  3. Keep your existing / and /health routes and add /db-check, GET /notes, and POST /notes.
  4. Create db/init.sql with the notes table.
  5. Create .env.example, copy it to .env, and configure your own local passwords.
  6. Make sure .env and any actual secret files are ignored by Git.
  7. Create compose.yaml with separate api and db services.
  8. Start the services and verify that MySQL becomes healthy.
  9. Add two notes using Insomnia and list them through GET /notes.
  10. Run docker compose down, start the application again, and confirm both notes still exist.
  11. Run npm run check and npm test and confirm your existing automated tests still pass.
  12. Commit your source code and configuration without committing .env or other secrets.
Example verification commands

Run these from the project root:

bash
npm run check
npm test

docker compose config --quiet
docker compose up -d --build
docker compose ps

curl -i http://localhost:3000/health
curl -i http://localhost:3000/db-check
curl -i http://localhost:3000/notes

docker compose down
docker compose up -d
curl -i http://localhost:3000/notes

When everything works, commit only the files that belong in Git:

bash
git add src/app.js src/db.js db/init.sql compose.yaml \
  .env.example .gitignore package.json package-lock.json

git commit -m "Add MySQL and Docker Compose"

You can run git status first to confirm that .env is not staged. If your work is on a feature branch, open a pull request and allow GitHub Actions to check it.

Final checkpoint ​

  • [ ] I can explain what a Compose service is and read a basic Compose file.
  • [ ] I can run an Express API and MySQL together using Docker Compose.
  • [ ] I know why the API connects to the hostname db instead of localhost.
  • [ ] I can use Insomnia to save and retrieve MySQL data through the API.
  • [ ] I can demonstrate that a named volume preserves data across container recreation.
  • [ ] I understand the difference between .env, environment:, dotenv, and Compose secrets.
  • [ ] I understand why initialization scripts do not replace migrations.
  • [ ] I can inspect Compose logs, troubleshoot failures, and stop the application without deleting its data.

Knowledge Check ​

Try answering these questions before revealing the suggested answers.

  1. What problem does Docker Compose solve compared with using multiple docker run commands?
  2. What are the two services in this lesson, and what does each do?
  3. Why is db a valid hostname from the API container?
  4. What is the difference between a container port and a published host port?
  5. Why does the API service have a depends_on health condition?
  6. Which configuration tells MySQL where its persistent data is stored?
  7. Which command removes the running services but normally keeps their named volumes?
  8. Why is docker compose down --volumes dangerous for a database?
  9. Does creating .env automatically inject all its variables into every service?
  10. What is the purpose of the ? placeholder in the SQL INSERT statement?
  11. When does MySQL run scripts in /docker-entrypoint-initdb.d/?
  12. What is one benefit of using a Compose secret instead of a plaintext password environment variable?
Reveal suggested answers
  1. It provides one declarative YAML configuration for related services, networks, storage, and runtime settings.
  2. The api service handles HTTP requests; the db service stores application data in MySQL.
  3. Both services join the default Compose network, where Docker resolves service names through its internal DNS.
  4. A container port is where a process listens inside its container; a published host port allows access from the host through Docker's port mapping.
  5. To wait for MySQL's readiness check before starting the API container and reduce startup race conditions.
  6. The mysql_data:/var/lib/mysql volume mount.
  7. docker compose down.
  8. It deletes named volumes and the database data they contain.
  9. No. Compose uses .env for interpolation; variables reach a container only when configured for that service.
  10. It allows the SQL driver to bind user input as a value instead of concatenating it into SQL text, reducing SQL injection risk.
  11. During first-time initialization of a new, empty MySQL data directory.
  12. Secrets can be exposed to specific services as files instead of being included directly in general container environment variables.

What's next? ​

In Lesson 5 — Deploying Docker Applications to the Cloud, you will deploy the Dockerized API to Render, connect a private MySQL service with persistent storage, and use GitHub Actions checks to gate deployments. An optional DigitalOcean Droplet demonstration compares this managed platform with a server you administer yourself.

Keep your Dockerfile, compose.yaml, and .env.example. Do not upload your local .env file to GitHub. You will configure cloud credentials and environment variables separately when you deploy.

Further reading ​