Appearance
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:
- Explain why multi-container applications use Docker Compose and identify its main configuration elements.
- Define and run an Express API and a MySQL database as separate services.
- Connect services using Docker's internal network and service names.
- Configure environment variables and distinguish Compose's
.envinterpolation from container environment variables. - Store and retrieve data using MySQL and verify that a Docker volume preserves it when containers are recreated.
- Handle configuration safely, avoid committing credentials, and recognize when proper secret management is needed.
- Inspect, troubleshoot, and stop a multi-container application with Docker Compose commands.
What you need
| Tool or requirement | Why you need it |
|---|---|
| Docker Engine / Docker Desktop with Docker Compose v2 | Run and manage multiple containers |
deployment-workshop repository | Continue your existing Express API |
| Node.js and npm | Install the MySQL driver and run your existing tests |
| MySQL (as a Docker image) | Store application data; no separate local MySQL installation required |
mysql2 | Connect Node.js to MySQL using promises |
dotenv | Load local .env values when running Node.js outside Compose |
Insomnia or curl | Send HTTP requests to your API |
| A terminal and code editor | Edit files and inspect services |
Check that Docker and Compose are available:
bash
docker --version
docker compose version
docker infoUse 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_dataYour 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.jsonPart 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
| Concept | Meaning | Example |
|---|---|---|
| Compose file | YAML file that describes your application | compose.yaml |
| Service | Configuration for one type of container | api or db |
| Image | Packaged software used by a service | mysql:8.4 |
| Build | Instructions for building an image from your source | build: . |
| Network | Lets containers communicate by service name | api connects to db |
| Volume | Storage managed independently of a container's writable layer | mysql_data |
| Environment | Configuration passed to a container at runtime | DB_HOST=db |
| Health check | A test of whether a service is ready to serve requests | MySQL responds to a query |
| Dependency | A startup relationship between services | API 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
dbThe database connection target will be:
text
db:3306Do 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 starts | Target | Address |
|---|---|---|
| Browser or Insomnia on your computer | Express API | http://localhost:3000 |
| Express API container | MySQL container | db:3306 |
| MySQL container | Its own MySQL server | 127.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
- Why do we keep the API and MySQL in different containers?
- Why can't the API use
localhost:3306to reach the database container? - What is the difference between
build: .andimage: mysql:8.4?
Reveal suggested answers
- Each service has a separate responsibility and can be configured, updated, and managed independently.
localhostmeans the current container. Docker's service namedbidentifies the database container on the internal network.build: .tells Compose to build an image from the Dockerfile in the current directory.image: mysql:8.4tells 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 testMake 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-apiIf 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 dotenvThese 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/promiselets you useasync/awaitfor SQL queries.process.envreads configuration provided to the application at runtime.dotenv/configreads a local.envfile if it exists. Inside Compose, the container environment is already configured, so an.envfile does not need to be copied into the image.DB_HOSTdefaults to127.0.0.1for standalone local Node.js execution; Compose will override it withdb.
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:
| Method | Endpoint | Purpose |
|---|---|---|
GET | /db-check | Verify that the API can query MySQL |
GET | /notes | Read saved notes |
POST | /notes | Save 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 testIf 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-passwordThese 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 .envOn PowerShell:
powershell
Copy-Item .env.example .envEdit .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.exampleIf 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.4uses the MySQL 8.4 image. A specified major/minor version is more predictable than usingmysql:latest.portspublishes 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: dbuses the service name as the database hostname.${DB_PASSWORD:?...}means Compose must find a non-empty value or stop with an explanatory error.mysql_datais a named volume for database files.- The SQL file is mounted read-only using
:ro. depends_on: ... condition: service_healthytells 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 --quietA 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 --buildupcreates and starts the application's containers and other resources.-druns them in the background.--buildrebuilds your API image so it includesmysql2and your new source files.
Check their status:
bash
docker compose psInspect the logs if needed:
bash
docker compose logs --tail=50 api
docker compose logs --tail=50 dbMySQL 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-checkExpected HTTP status: 200 OK.
Expected JSON:
json
{
"database": "connected"
}You can also use curl:
bash
curl -i http://localhost:3000/db-checkNow verify that the original API endpoint still works:
bash
curl -i http://localhost:3000/healthIt 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/notesYou 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/notesThe 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 psshows the API and MySQL services running. - [ ]
GET /healthreturns HTTP 200. - [ ]
GET /db-checkreturns{"database":"connected"}. - [ ]
POST /notescreates a note and returns HTTP 201. - [ ]
GET /notesreturns 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 --versionYou can examine which hostname the API received:
bash
docker compose exec api node -e 'console.log(process.env.DB_HOST)'Expected output:
text
dbThe 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 downStart the application again:
bash
docker compose up -d --buildThen request your notes:
bash
curl http://localhost:3000/notesYour note should still exist. The containers may have been removed and recreated, but the named volume preserves the database files.
Compare these commands:
| Command | What happens to the database volume? |
|---|---|
docker compose stop | Stops containers; volume remains |
docker compose down | Removes Compose containers and network; named volume remains |
docker compose up -d | Starts/recreates services and uses the existing named volume |
docker compose down --volumes | Removes 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:
- MySQL created the
deployment_labdatabase using your configured database name. - MySQL created the application user using your configured username and password.
- MySQL executed
db/init.sqland created thenotestable.
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.sqldoes 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:
| Mechanism | Typical use | Important limitation |
|---|---|---|
Dockerfile ENV | Defaults baked into the image | Not suitable for secrets |
Project .env | Values used by Compose interpolation | Not automatically passed to every container |
Compose environment: | Runtime variables for a service | Values may be visible in container configuration |
Compose env_file: | Load many variables into a service | File must be protected and not committed if sensitive |
Node.js dotenv | Read .env in a locally started Node.js process | Doesn't secure the contents of the file |
Compose secrets: | Mount sensitive values as files for authorized services | File-backed local secrets still require secure storage and permissions |
For this workshop:
- Use
.env.exampleto document what teammates need to configure. - Use
.envonly 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.txtThis 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
- What happens if you put
DB_HOST=localhostin the API container environment? - Why should the MySQL service not publish port 3306 in this exercise?
- Why is
.env.examplesafe to commit while.envusually is not? - Why doesn't editing
db/init.sqlautomatically update an existing database? - Does
depends_on: condition: service_healthyguarantee that MySQL will never disconnect after the API starts?
Reveal suggested answers
- The API would try to connect to its own container rather than the MySQL service.
- The API already communicates with MySQL over the internal Compose network. Publishing the database port would create unnecessary exposure.
.env.exampledocuments placeholders and non-sensitive defaults;.envcontains actual environment-specific values and may contain credentials.- The MySQL image runs initialization scripts only when creating a new database in an empty data directory.
- 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
| Problem | Likely cause | What to check |
|---|---|---|
docker compose is not recognized | Compose plugin not installed | Check Docker/Compose installation |
| Port 3000 is already in use | Another API process or container is listening | Stop the old process/container or change the host port |
| API cannot connect to MySQL | Wrong hostname or credentials | Verify DB_HOST=db and the matching database variables |
| MySQL is unhealthy | Database is still initializing or startup failed | Inspect docker compose logs db |
Table '...notes' doesn't exist | Database volume was initialized before init.sql was added | Create the missing table with SQL; do not delete valuable data |
Changing .env does not fix database login | Existing MySQL volume retains its initialized database users | Change credentials inside MySQL or reconcile them with the original values |
| Data disappears | Database stored only in a container layer or volume was removed | Verify the mysql_data mount and avoid down --volumes |
| Vitest tests fail unexpectedly | Original routes were changed or app startup logic moved | Keep 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 --buildIf 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.
- Add
mysql2anddotenvas application dependencies. - Create
src/db.jswith a MySQL connection pool. - Keep your existing
/and/healthroutes and add/db-check,GET /notes, andPOST /notes. - Create
db/init.sqlwith thenotestable. - Create
.env.example, copy it to.env, and configure your own local passwords. - Make sure
.envand any actual secret files are ignored by Git. - Create
compose.yamlwith separateapianddbservices. - Start the services and verify that MySQL becomes healthy.
- Add two notes using Insomnia and list them through
GET /notes. - Run
docker compose down, start the application again, and confirm both notes still exist. - Run
npm run checkandnpm testand confirm your existing automated tests still pass. - Commit your source code and configuration without committing
.envor 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/notesWhen 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
dbinstead oflocalhost. - [ ] 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.
- What problem does Docker Compose solve compared with using multiple
docker runcommands? - What are the two services in this lesson, and what does each do?
- Why is
dba valid hostname from the API container? - What is the difference between a container port and a published host port?
- Why does the API service have a
depends_onhealth condition? - Which configuration tells MySQL where its persistent data is stored?
- Which command removes the running services but normally keeps their named volumes?
- Why is
docker compose down --volumesdangerous for a database? - Does creating
.envautomatically inject all its variables into every service? - What is the purpose of the
?placeholder in the SQLINSERTstatement? - When does MySQL run scripts in
/docker-entrypoint-initdb.d/? - What is one benefit of using a Compose secret instead of a plaintext password environment variable?
Reveal suggested answers
- It provides one declarative YAML configuration for related services, networks, storage, and runtime settings.
- The
apiservice handles HTTP requests; thedbservice stores application data in MySQL. - Both services join the default Compose network, where Docker resolves service names through its internal DNS.
- 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.
- To wait for MySQL's readiness check before starting the API container and reduce startup race conditions.
- The
mysql_data:/var/lib/mysqlvolume mount. docker compose down.- It deletes named volumes and the database data they contain.
- No. Compose uses
.envfor interpolation; variables reach a container only when configured for that service. - It allows the SQL driver to bind user input as a value instead of concatenating it into SQL text, reducing SQL injection risk.
- During first-time initialization of a new, empty MySQL data directory.
- 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.