Appearance
Lesson 6 — Monitoring, Logging & DevOps Best Practices
Your application is running, but deployment is not the end of the work. Once other people start using an API, you need to know when it is unavailable, understand why a request failed, protect the information stored in its database, and make changes without losing data.
In the previous lessons, you:
- Published a static site using Netlify and an Express API using Render (Lesson 1).
- Added automated tests with Vitest and GitHub Actions (Lesson 2).
- Packaged the API in Docker (Lesson 3).
- Connected the API to MySQL using Docker Compose (Lesson 4).
- Deployed the Dockerized API on Render and compared it with a DigitalOcean Droplet (Lesson 5).
Now you will make the same project easier to operate and recover. You will add structured logging with Winston, distinguish liveness from readiness, investigate problems using logs and metrics, create and test a MySQL backup, and make a versioned database schema change.
Continue your existing project
Work in your deployment-workshop repository. Keep the existing src/app.js, src/server.js, src/db.js, Dockerfile, compose.yaml, .github/workflows/ci.yml, and the Notes API.
The main exercises work locally with Docker Compose. If you still have a running Render deployment from Lesson 5, you can also publish the logging and monitoring changes. You do not need to create a new paid cloud resource to complete the lesson.
Learning outcomes
By the end of this lesson, you should be able to:
- Explain the difference between logging, monitoring, and alerting.
- Configure structured application logs using Winston and choose appropriate log levels.
- Add and test liveness and readiness endpoints in an Express application.
- Inspect and correlate application logs, Docker container logs, and Render deployment information.
- Configure a simple external uptime check and interpret its limitations.
- Create and verify a logical MySQL backup using
mysqldumpand restore it into a separate database. - Apply a versioned SQL schema change without deleting existing notes.
- Describe a safer release, migration, recovery, and troubleshooting process for a deployed application.
Tools and technologies
| Tool or technology | How you will use it |
|---|---|
| Node.js / Express.js | Extend your existing API |
| Winston | Write structured application logs |
| Docker / Docker Compose | Run the API and MySQL, and inspect logs |
| MySQL 8.4 | Store notes and practice backups and schema changes |
mysqldump / mysql | Export and restore a database |
| Insomnia | Test HTTP endpoints and inspect responses |
| Render | Inspect deployment logs, service health, and available metrics |
| UptimeRobot (optional) | Check whether the public API is reachable |
| Vitest / Supertest | Test readiness behavior without a real MySQL server |
| Git / GitHub Actions | Review and validate operational changes |
| Prisma Migrate (conceptual comparison) | Learn how migration tools track schema changes |
What you will build
Your application will gain three operational capabilities:
text
Insomnia / browser
│
GET /health │ GET /ready
▼
Express API (Render)
├── Winston JSON logs ──► Render Logs
├── /health ────────────► process is alive
├── /ready ─────────────► MySQL query works?
└── /notes ─────────────► MySQL
│ │
│ persistent storage
│ │
▼ ▼
Uptime monitor separate SQL backup
(public /health) + restore testYou will retain your existing /db-check route from Lesson 4. The new /ready route will provide a simpler, explicit readiness signal and will return an appropriate HTTP status when the database is unavailable.
The new and updated files will look like this:
text
deployment-workshop/
├── .github/workflows/ci.yml
├── backups/ # Local only; ignored by Git
├── db/
│ ├── init.sql
│ └── migrations/
│ └── 001_add_priority.sql # New: schema change
├── src/
│ ├── app.js # Updated: request logs and /ready
│ ├── db.js # Existing MySQL pool
│ ├── logger.js # New: Winston configuration
│ └── server.js # Updated: startup logging
├── tests/
│ ├── app.test.js # Existing Vitest tests
│ └── readiness.test.js # New: ready/not-ready cases
├── compose.yaml
├── Dockerfile
├── package.json
└── package-lock.jsonProtect your data and accounts
Perform backup, restore, and migration exercises against your own workshop database. Do not use a shared production database or real user records.
- Never publish passwords, environment files, database dumps, or sensitive application logs.
- Do not run a restore into your original database. You will use a different database name for the exercise.
- Avoid
docker compose down --volumes: it deletes your local MySQL volume. - Some Render features and external monitoring plans may have costs or usage limits. Check the current plan before enabling anything new.
Part 1 — Understand Logging, Monitoring, and Alerting
1. What happens after an application is deployed?
Imagine your notes API has been working for several days. A student reports that creating a note now returns 503 Service Unavailable.
The source code alone does not tell you what happened. You need answers to questions such as:
- Is the API process running?
- Can the API reach MySQL?
- Did a recent deployment introduce a bug?
- Did requests start failing before or after a configuration change?
- Is the database out of disk space, restarting, or refusing connections?
Three closely related practices help answer these questions:
| Practice | Main question | Example |
|---|---|---|
| Logging | What happened during execution? | POST /notes returned 503; database connection was refused |
| Monitoring | How is the system behaving over time? | Request failures increased, memory usage grew, or the API stopped responding |
| Alerting | When should someone take action? | Send an email after repeated failed uptime checks |
These are complementary. A monitoring alert tells you there may be a problem; logs and metrics help you investigate it.
2. Understand log levels
Logs should communicate both what happened and how important it is.
| Level | Typical use | Example |
|---|---|---|
error | An operation failed unexpectedly | Unable to query the database |
warn | Something unusual occurred but the service continues | A dependency check failed |
info | Normal, useful operational events | API started; request completed |
debug | Extra details during diagnosis | A non-sensitive decision made by application code |
Winston supports additional levels. For this project, the four above are enough.
Do not log everything
More logs are not always better. Repeated health checks, noisy debugging details, and accidental sensitive information can make logs difficult or unsafe to use.
Never log passwords, bearer tokens, cookies, complete request bodies containing personal data, database connection strings with credentials, or .env contents. Request URL query strings may also contain sensitive information.
3. Structured logs vs. plain-text logs
A plain-text message might look like this:
text
Request failedA structured log includes fields that software and people can filter:
json
{
"level": "error",
"message": "http_request",
"method": "GET",
"route": "/ready",
"statusCode": 503,
"durationMs": 12.4,
"requestId": "example-request-id",
"service": "deployment-api"
}The example is illustrative: your real requestId will be generated for each request, and Winston will also add a timestamp.
Structured logs help you answer questions such as: Which route failed?, How many requests returned an error?, and Which application request does this line belong to?
Check your understanding
- What is the difference between a log entry and an alert?
- Which log level would you use when MySQL cannot answer a database query?
- Why is a request ID helpful?
- Why is logging an entire
Authorizationheader dangerous?
Reveal suggested answers
- A log entry records an event; an alert notifies someone when a condition requires attention.
errorfor a failed application operation;warnmay be appropriate for a failed health/readiness check that is handled without crashing the service.- It lets you connect several messages to a single request.
- The header can contain a token that grants access to the application. Logs are often retained or shared with operations tools.
Part 2 — Add Structured Logging with Winston
4. Check your project before making changes
From your project directory, run:
bash
cd deployment-workshop
npm ci
npm run check
npm test
docker compose up -d --build
docker compose psConfirm the existing endpoints still work:
bash
curl -i http://localhost:3000/health
curl -i http://localhost:3000/db-check
curl -i http://localhost:3000/notesIf you do not have a running local MySQL service, fix that first using your Lesson 4 Compose setup.
5. Install Winston
Run:
bash
npm install winstonWinston must be a production dependency, because it is required while the container is running. Your Lesson 3 Dockerfile uses npm ci --omit=dev, so installing it as a development-only dependency would be a mistake.
6. Create src/logger.js
Add a new file:
js
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
defaultMeta: { service: 'deployment-api' },
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
),
transports: [new winston.transports.Console()],
});
export default logger;What this configuration means:
levelsets the minimum importance that gets logged.defaultMetaadds the service name to every message.timestamp()records when the event happened.json()formats log entries as structured JSON.Console()sends logs to the process's standard output/error streams, which Docker and Render can collect.
Why not write logs to a file inside the API container? A container can be replaced or restarted, and its filesystem is not a reliable long-term log archive. Writing to the console is a useful default for containerized applications; the hosting platform can collect those streams.
Log levels are configurable
You can optionally add LOG_LEVEL=info to the API service's environment on Render. For local Compose, the default is already info; you do not need to change compose.yaml to complete the lesson.
Avoid enabling verbose debug logs on public services unless you have a specific reason and understand the information they may expose.
7. Log the server startup
Open src/server.js and replace the original console.log with Winston:
js
import app from './app.js';
import logger from './logger.js';
const port = Number(process.env.PORT || 3000);
app.listen(port, '0.0.0.0', () => {
logger.info('api_started', { port });
});The application still listens on 0.0.0.0, and the PORT environment variable still works on Render.
8. Log completed HTTP requests
Open src/app.js.
Add these imports near the top, keeping your existing Express and MySQL imports:
js
import { randomUUID } from 'node:crypto';
import logger from './logger.js';Place this middleware immediately after const app = express();, before app.use(express.json()) and your routes. This ensures that requests rejected by the JSON parser, including malformed or oversized bodies, also receive a request ID and a completion log:
js
app.use((req, res, next) => {
const requestId = randomUUID();
const startedAt = process.hrtime.bigint();
req.requestId = requestId;
res.setHeader('X-Request-ID', requestId);
res.on('finish', () => {
const durationMs = Number(
(Number(process.hrtime.bigint() - startedAt) / 1_000_000).toFixed(1),
);
const level =
res.statusCode >= 500 ? 'error' :
res.statusCode >= 400 ? 'warn' : 'info';
logger.log(level, 'http_request', {
requestId,
method: req.method,
route: req.route?.path ?? 'unmatched',
statusCode: res.statusCode,
durationMs,
});
});
next();
});This middleware:
- Creates a unique identifier for each request.
- Adds that identifier to the response's
X-Request-IDheader. - Measures how long the request takes.
- Writes a log when the response finishes.
- Uses
info,warn, orerroraccording to the HTTP status.
It logs the route template when available, rather than the complete URL with query parameters, and does not log the body or authentication headers.
A request log is not the same as an error explanation
A line showing statusCode: 503 helps you detect a failure. It does not tell you why MySQL was unreachable. You should also log a safe, contextual message where the error is handled, as you will do for the readiness endpoint.
This simple example tracks completed responses, not requests that are interrupted before a response finishes. A production system can add more advanced request tracing and transport/error handling.
9. Replace basic console errors in your existing routes (optional improvement)
In Lesson 4, some route handlers used console.error. You can replace those calls with messages such as:
js
logger.error('notes_query_failed', {
operation: 'list',
errorCode: error.code || 'UNKNOWN',
});Or for a failed insert:
js
logger.error('notes_query_failed', {
operation: 'create',
errorCode: error.code || 'UNKNOWN',
});Keep your existing response status codes and JSON bodies. Do not include the raw SQL, user-provided note content, full error objects, or database passwords in the log message.
10. Rebuild and inspect the logs
Because you installed a new runtime dependency and changed source files, rebuild the API image:
bash
docker compose up -d --buildGenerate a few requests using Insomnia or curl:
bash
curl -i http://localhost:3000/health
curl -i http://localhost:3000/notes
curl -i http://localhost:3000/does-not-existNow inspect recent API logs:
bash
docker compose logs --tail=30 apiOr follow incoming logs while you send requests from Insomnia:
bash
docker compose logs -f apiLook for the api_started event and http_request entries. The last request should normally produce a 404 and a log at warn level.
Challenge: investigate a request
Open the /health request in Insomnia and inspect its response headers.
- Find
X-Request-ID. - Find the corresponding log line in the Docker output.
- Record its HTTP method, route, status code, and duration.
- Explain why the request ID is more useful than searching for the message text alone.
Suggested answer: The identifier connects the response seen by the client to the relevant server-side log entry, even when many clients make requests to the same endpoint.
Part 3 — Check Application Health and Readiness
11. Liveness vs. readiness
Health checks are not all intended to answer the same question.
| Check | Question | Example | Result when MySQL is down |
|---|---|---|---|
| Liveness | Is the API process responding? | GET /health | Can still be 200 |
| Readiness | Can the API perform required work? | GET /ready | Should be 503 |
| Dependency check | Is a particular dependency reachable? | Existing GET /db-check | Should be 503 |
A useful liveness route does not have to query every dependency. Otherwise, a temporary database outage could make a healthy API process appear dead and cause unnecessary restarts.
Your original /health implementation should stay as it was:
js
app.get('/health', (_req, res) => {
res.status(200).json({ status: 'ok' });
});12. Add the /ready route
You already import pool from ./db.js in src/app.js. Add this route before export default app;:
js
app.get('/ready', async (req, res) => {
try {
await pool.query('SELECT 1');
return res.status(200).json({ status: 'ready' });
} catch (error) {
logger.warn('readiness_check_failed', {
requestId: req.requestId,
errorCode: error.code || 'UNKNOWN',
});
return res.status(503).json({ status: 'not_ready' });
}
});The code intentionally returns a generic public response. It does not reveal the hostname, credentials, internal network layout, or a stack trace.
Keep health endpoints lightweight
A readiness probe should be fast, have no side effects, and avoid expensive SQL operations. This example performs a simple database query because MySQL is required for the Notes API.
A public readiness route also sends database queries when visitors call it. Do not use an excessively frequent external monitor against it. In larger systems, readiness checks may be restricted to trusted infrastructure or use a carefully designed cached signal.
13. Test both routes with Insomnia
After rebuilding your API:
bash
docker compose up -d --buildCreate two GET requests:
| Request | Expected status | Expected body |
|---|---|---|
http://localhost:3000/health | 200 | {"status":"ok"} |
http://localhost:3000/ready | 200 | {"status":"ready"} |
Use the response headers to compare request IDs, then inspect the Winston logs.
What if MySQL stops responding? In a real incident, /health can continue to respond while /ready returns 503. You do not need to stop the database to complete this lesson; the automated test below will simulate the failure safely.
14. Test readiness with Vitest
You should not need a working database to run a unit test for the readiness route. Instead, mock the MySQL pool.
Create tests/readiness.test.js:
js
import { beforeEach, describe, expect, it, vi } from 'vitest';
import request from 'supertest';
vi.mock('../src/db.js', () => ({
default: { query: vi.fn() },
}));
import app from '../src/app.js';
import pool from '../src/db.js';
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /ready', () => {
it('returns 200 when the database responds', async () => {
pool.query.mockResolvedValueOnce([[{ value: 1 }]]);
const response = await request(app).get('/ready');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'ready' });
expect(response.headers['x-request-id']).toBeTruthy();
});
it('returns 503 when the database fails', async () => {
pool.query.mockRejectedValueOnce(new Error('Database unavailable'));
const response = await request(app).get('/ready');
expect(response.status).toBe(503);
expect(response.body).toEqual({ status: 'not_ready' });
});
});This file uses Supertest and Vitest, which you installed in Lesson 2. Vitest mocks the database module, so these tests do not need Docker, real database credentials, or a live cloud service.
Run your checks:
bash
npm run check
npm testTest the behavior, not the implementation detail
The important result is the HTTP contract: a working dependency produces 200, and a failed dependency produces 503. You can refactor the internals later without changing the behavior that API consumers and monitoring tools rely on.
15. Configure Render's health check
If you still have the Docker web service from Lesson 5:
- Push a tested change through a pull request and let your CI checks pass.
- Make sure Render deploys the updated API.
- Open the API web service in the Render dashboard.
- Find the Health Check Path setting and set it to
/healthfor this exercise. - Save the configuration and verify that the service remains healthy.
- Visit the deployed
/healthand/readyendpoints using Insomnia.
Why choose /health here? This project's /ready depends on MySQL. Using it as a platform restart signal could cause the API to restart repeatedly during a database-only outage. You can still monitor /ready separately when you want to assess dependency availability.
Render health checks
Render supports HTTP health checks for web services. A successful check requires a 2xx or 3xx response; repeated failures can affect routing and trigger restarts. The health-check path and appropriate failure behavior should match the purpose of the endpoint.
Read the current Render health-check documentation before changing a production service.
Your health-check checkpoint
- [ ] I can explain the difference between
/healthand/ready. - [ ] I can see a request ID in the response header and its related log entry.
- [ ] Vitest verifies that
/readyreturns both 200 and 503 in the correct scenarios. - [ ] The API's public response does not reveal database credentials or stack traces.
- [ ] I understand why a database outage should not automatically cause every API instance to restart.
Part 4 — Monitor and Troubleshoot Your Application
16. Observe a service on Render
In your Render web service, inspect the Logs, Events/Deploys, and Metrics pages (where available for your service and plan).
| Information | What it helps you understand |
|---|---|
| Application logs | Did the API start? Which requests failed? |
| Deployment events | Which commit was deployed? Did the build or rollout fail? |
| CPU and memory metrics | Is resource usage changing unexpectedly? |
| Request behavior | Is an endpoint slow or returning more errors? |
| Database service logs/metrics | Is MySQL restarting, out of resources, or failing to initialize? |
Search your application's JSON output for events such as api_started, http_request, and readiness_check_failed.
Dashboard features vary by plan
Your own Winston logs can be written to stdout/stderr and inspected as service logs. Some Render-generated HTTP request logging and advanced metrics/retention features depend on the account plan. Do not assume every dashboard will look identical.
See Render Logs and Render Service Metrics.
17. Add an external uptime check (optional)
An external monitoring service checks your API from outside your cloud provider. UptimeRobot is one option; you can also use another HTTP uptime monitor allowed by your course or organization.
If you have a public Render URL:
Sign in to your chosen uptime-monitoring service.
Create an HTTP(S) monitor.
Use your API's public liveness URL, for example:
texthttps://YOUR-RENDER-SERVICE.onrender.com/healthChoose an allowed check interval and notification destination according to your plan.
Save the monitor and observe its results.
Confirm that your application responds with HTTP 200 when it is available.
Do not point this monitor at localhost, a private MySQL address, or an endpoint requiring authentication. External services must be able to reach the URL publicly.
Interpret alerts in context
On hosting plans that automatically spin down or sleep idle services, the first request after inactivity may be slow. That behavior can create misleading latency spikes or uptime alerts. Network problems between the monitoring provider and Render can also cause isolated failures.
Use a sensible number of consecutive failures before alerting when your monitor supports it, and confirm the condition independently before assuming the application is down.
18. Know the basic monitoring signals
| Signal | Why it matters | First place to investigate |
|---|---|---|
| Availability | Can users reach the API? | Uptime check, Render service state, /health |
| HTTP error rate | Are requests failing more often? | http_request log levels and status codes |
| Response time | Are requests becoming slow? | Request durations, resource metrics |
| CPU / memory | Is the app under resource pressure? | Render Metrics or host metrics |
| Readiness | Can the API use required dependencies? | /ready, MySQL logs |
| Database storage | Is persistent disk space running low? | Database service metrics and alerts |
| Backup freshness | Can data be recovered from a recent copy? | Backup records and scheduled restore tests |
Monitoring becomes useful when you choose a baseline (what is normal) and a response action (what you will do when the signal changes). A number without context does not necessarily mean something is wrong.
19. Work through a troubleshooting scenario
Imagine that Insomnia shows:
text
GET /health → 200 OK
GET /ready → 503 Service Unavailable
GET /notes → 503 Service UnavailableFollow a structured investigation:
- Confirm the incident. Repeat the requests once and record the response codes and request IDs.
- Check the current deployment. Did a new commit, environment-variable change, or database redeploy happen?
- Check the API logs. Search for
readiness_check_failedand failedhttp_requestentries. - Check MySQL's service state. Is the database running and reporting healthy?
- Check networking and configuration. On Render, confirm the internal database hostname, region,
DB_*variables, and access permissions. - Make the smallest safe correction. Fix an incorrect variable or restore a missing service only after identifying the likely problem.
- Verify recovery. Re-test
/health,/ready,/notes, and a safe read/write operation. - Document what happened. Record the symptom, cause, action, and prevention idea.
Do not begin by deleting a database volume, resetting credentials, or rolling back blindly.
Troubleshooting exercise
Choose the best first investigation for each symptom:
| Symptom | First investigation |
|---|---|
/health returns 502/503 and no API logs appear | Check service state, container startup, deployed port, and Render deploy logs |
/health is 200 but /ready is 503 | Check the MySQL service, internal hostname, and credentials |
/health is 200, /ready is 200, but POST /notes fails | Check the notes route, schema, permissions, and application error logs |
| GitHub Actions fails but Render has the old version running | Inspect failing CI checks before deploying |
| A recently deployed API cannot query a new column | Check whether the matching database migration was applied |
Reveal the key lesson
Do not treat every failure as an application crash. A live Express process, a reachable database, and a compatible schema are different conditions. Investigate the failing layer first, then verify the entire user journey after a fix.
Part 5 — Back Up and Restore MySQL Safely
20. Persistence is not backup
Your compose.yaml uses a named volume for MySQL:
yaml
volumes:
- mysql_data:/var/lib/mysqlThis preserves data when containers restart or are recreated. It does not protect you from everything:
- Accidental deletion of a table or row.
- A destructive or incorrect migration.
- Corrupted data.
- Loss of the underlying disk or account.
- A mistake that affects the live database and its attached volume.
A backup is a separate copy you can use to recover data. A restore test demonstrates that the copy is actually usable.
| Term | Meaning |
|---|---|
| Persistent volume | Storage that survives container recreation |
| Logical backup | An export of database schema and data, often SQL statements |
| Restore | Rebuilding a database from a backup |
| RPO | How much recent data loss you can tolerate |
| RTO | How much recovery time you can tolerate |
For example, a team that can tolerate losing at most one day's updates needs a different backup schedule from a team that can tolerate only a few minutes. Backups, recovery plans, and cost should match the application's requirements.
21. Prepare a local backup directory
You will practice using the MySQL Docker container from Lesson 4, not the Render database.
Make sure MySQL is running:
bash
docker compose up -d
docker compose psCreate a directory to store the exercise backup:
bash
mkdir -p backupsAdd it to your .gitignore:
text
backups/
*.sql.gzKeep db/init.sql and db/migrations/*.sql tracked in Git; only the exported database data belongs in backups/.
Database dumps can contain sensitive data
A SQL export may include everything stored in the database. This workshop uses sample notes, but real database backups may contain personal data or secrets.
Backups should be access-restricted, encrypted when appropriate, stored separately from the primary server, retained only as needed, and never pushed to a public repository.
22. Create a logical backup with mysqldump
The official MySQL image provides MySQL client utilities. Run mysqldump inside the db container and redirect its output into a file on your own computer:
bash
docker compose exec -T db sh -c \
'MYSQL_PWD="$MYSQL_PASSWORD" exec mysqldump \
--host=127.0.0.1 \
--user="$MYSQL_USER" \
--single-transaction \
--no-tablespaces \
--set-gtid-purged=OFF \
"$MYSQL_DATABASE"' > backups/notes-backup.sqlThis example assumes a Bash-compatible terminal (macOS, Linux, or Git Bash). You can also use a compatible terminal on Windows. For PowerShell, be careful with redirection and character encoding; use a binary-safe output method if your environment modifies redirected text.
What the options mean:
-Tavoids allocating a pseudo-terminal, which is important when writing SQL to a file.--single-transactiongives a consistent snapshot for transactional tables such as the InnoDBnotestable, provided schema changes are not running concurrently.--no-tablespacesavoids requiring some broader server privileges.--set-gtid-purged=OFFavoids adding GTID state to this portable workshop export.MYSQL_PWDsupplies the demonstration password to the client without putting the password directly into the command-line arguments. This remains an environment-based credential and is not a substitute for a proper production secrets solution.
Check that the command completed successfully. A redirect can create a file even if the command fails; a file's existence alone does not prove the backup worked.
You can inspect the file size:
bash
ls -lh backups/notes-backup.sqlAvoid dumping the entire backup into a shared terminal or chat, especially when it contains real data.
Important backup limitations
This is a small, local, single-database example. For production, plan for access controls, automation, retention, off-server storage, encryption, monitoring of backup failures, and a restore test. Logical backups of large or heavily updated databases need additional operational planning.
Do not run schema changes while taking a --single-transaction backup and expect a consistent schema snapshot.
23. Restore into a separate database
You must not overwrite the application's original database to test a backup. The steps below use a different database named deployment_lab_restore.
First, create the restore target inside your local MySQL server:
bash
docker compose exec -T db sh -c \
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql \
--host=127.0.0.1 --user=root \
-e "CREATE DATABASE IF NOT EXISTS deployment_lab_restore"'Now import your backup into that target:
bash
docker compose exec -T db sh -c \
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql \
--host=127.0.0.1 --user=root deployment_lab_restore' \
< backups/notes-backup.sqlFinally, query the restored table:
bash
docker compose exec -T db sh -c \
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql \
--host=127.0.0.1 --user=root \
-e "SELECT COUNT(*) AS restored_notes FROM deployment_lab_restore.notes"'Compare the count to the original notes shown by GET /notes or by a query against your original database.
The restore target must be disposable
The example import may replace tables that already exist inside deployment_lab_restore. If you ran a previous restore exercise, inspect that target first. Never substitute your real application database for the restore target.
These commands use the local workshop root credentials from the Compose environment only to create and populate the isolated restore database. A production system should use appropriately restricted backup and restore identities.
24. Verify a backup properly
Checking the number of notes is a useful first step, but a complete backup validation also asks:
- Does the database contain the expected tables and columns?
- Are representative records present and readable?
- Can the application start against a restored copy in a separate environment?
- Was the backup created recently enough for your recovery needs?
- Could you restore it if the original host disappeared?
Practice: backup verification record
Create a short record in your notes (do not commit any credentials or exported data):
| Item | Your result |
|---|---|
| Backup filename | notes-backup.sql |
| Source environment | Local Docker Compose |
| Restore database | deployment_lab_restore |
| Backup command completed successfully? | Yes / No |
| Original note count | Your result |
| Restored note count | Your result |
| Can you query the restored notes? | Yes / No |
Success criterion: You can demonstrate that the backup restores into an independent database and contains your expected notes.
Part 6 — Make a Database Migration Without Losing Data
25. Initialization scripts are not migrations
In Lesson 4, db/init.sql created the notes table for a new, empty MySQL data directory:
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
);That initialization file is not automatically rerun when an existing MySQL volume restarts. If you edit db/init.sql today, an existing database will not magically gain new columns.
A migration is an intentional, versioned change to an existing database schema (or, in some cases, its data).
Examples include adding a column, creating an index, renaming a table, or modifying stored values. Some changes are backward-compatible; others can break older application versions.
26. Plan a safe, additive change
You will add a priority column to the notes table. Older notes should remain valid and receive a default value of normal.
Desired schema change:
text
Before After
notes notes
id id
message message
created_at created_at
priority = 'normal'The plan is:
- Confirm your original notes exist.
- Create and verify a backup.
- Write a migration file in Git.
- Apply the migration once to your local workshop database.
- Confirm the original notes are still present.
- Update the API to return the new field.
- Re-run tests before deploying any changed API code.
MySQL schema operations need extra care
Many MySQL DDL statements, including ALTER TABLE, perform implicit commits. Do not assume you can undo a failed schema change with a simple transaction ROLLBACK.
On a real production system, test migrations against a representative staging database, take a verified backup, evaluate potential locking/downtime, and prepare a recovery or forward-fix plan.
27. Create a versioned SQL migration
Create the directory db/migrations, then add db/migrations/001_add_priority.sql:
sql
-- Add a priority value to all notes.
-- Existing rows receive the default 'normal'.
ALTER TABLE notes
ADD COLUMN priority VARCHAR(10) NOT NULL DEFAULT 'normal';Why give the file a number? Numbered migration files are easy to review in order and commit alongside your application code.
This example is intentionally a one-time migration. Re-running it after the column exists will normally fail with a duplicate-column error. Do not run it twice against the same database.
28. Apply the migration to local MySQL
First, confirm the backup from Part 5 completed successfully.
Then, from the project root, run:
bash
docker compose exec -T db sh -c \
'MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \
--host=127.0.0.1 \
--user="$MYSQL_USER" \
"$MYSQL_DATABASE"' \
< db/migrations/001_add_priority.sqlThis sends the SQL file to the running MySQL service. It uses the application database, not the separate restore database.
Confirm the new column exists:
bash
docker compose exec -T db sh -c \
'MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \
--host=127.0.0.1 --user="$MYSQL_USER" \
"$MYSQL_DATABASE" \
-e "SHOW COLUMNS FROM notes LIKE '\''priority'\''"'For a simpler check, use a query against the notes table:
bash
docker compose exec -T db sh -c \
'MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \
--host=127.0.0.1 --user="$MYSQL_USER" \
"$MYSQL_DATABASE" \
-e "SELECT id, message, priority FROM notes LIMIT 5"'You should see existing notes with priority set to normal. If you get a permissions error, do not change to a production root account as a shortcut; check which permissions your workshop database user has.
29. Return the new field from your API
In the GET /notes route in src/app.js, find the existing SQL query:
js
'SELECT id, message, created_at FROM notes ORDER BY id DESC'Change it to:
js
'SELECT id, message, priority, created_at FROM notes ORDER BY id DESC'You do not need to change POST /notes in this exercise: MySQL supplies the default value for priority when inserting a new note.
Rebuild and test locally:
bash
docker compose up -d --build
npm run check
npm test
curl -i http://localhost:3000/notesCreate a new note using Insomnia, then list the notes again. Both old and new records should have a priority value.
Deployment order matters
For this additive, backward-compatible change, apply the database migration before deploying API code that expects the new column. The older API still works while the extra column exists. If you deploy the new API first, its SELECT priority query may fail against the old schema.
In this exercise, you applied the migration locally. Your Render MySQL database is separate. It will not acquire the column just because you pushed this file to GitHub.
30. Understand migration tooling
You manually applied a numbered SQL file to learn what a migration actually does. A mature project generally uses a tool to track which migrations have already been applied and to detect migration-state problems.
For example, Prisma Migrate can manage schema changes and an applied-migration history. The code in this course still uses mysql2, so you do not need to replace the existing API with Prisma to understand the concept.
| Manual SQL file | Migration tool such as Prisma Migrate |
|---|---|
| You decide when/how to execute the SQL | The tool provides commands for creating/applying migrations |
| You must track the applied state yourself | The tool records migration history |
| Easy to demonstrate a single operation | Better for a growing sequence of changes |
| Easy to accidentally rerun the same SQL | Helps detect pending/already-applied migrations |
::: important Existing database? Learn about baselining first Your notes table already existed before Prisma was introduced. You cannot safely install Prisma Migrate and apply a fresh “create notes table” migration to that database as if it were empty.
This requires baselining: telling the migration tool that the current schema already exists. Prisma's setup and CLI can change across versions, so follow the current baselining guide on a disposable copy, not a live course database.
The practical migration in this lesson stays with SQL to avoid mixing a new ORM/toolchain into an established mysql2 project. :::
31. Plan a production migration on Render
Before making a schema change in the Render MySQL service from Lesson 5:
- Prepare a staging or disposable copy that resembles the deployed database.
- Test the migration and application code together.
- Take a verified backup of the cloud database and confirm the recovery approach.
- Ensure the execution method has authorized, secure access to Render's private MySQL network.
- Apply the migration using a controlled job or administrative connection; do not assume a public GitHub Actions runner can reach a private database.
- Validate the schema and representative records.
- Deploy the corresponding API change only when the schema is ready.
- Monitor errors and performance after release.
Access to a database service's shell, private network, or one-off jobs can vary by platform and plan. Do not paste production credentials into a public workflow, enable MySQL's public port, or run an unreviewed migration directly against a live database.
Migration checkpoint
- [ ] My original notes still exist after the schema change.
- [ ] Old and new notes have
priority: "normal"inGET /notes. - [ ] The migration SQL is tracked in Git, but my exported backup is not.
- [ ] I know that
db/init.sqlis not a migration runner. - [ ] I know why running the same
ALTER TABLEtwice is a problem. - [ ] I can explain why cloud migrations require a separate deployment plan.
Part 7 — DevOps Practices for Safer Operations
32. Automate repetitive checks
You already have an important automation from Lesson 2:
text
Commit / Pull Request
│
▼
GitHub Actions
├── npm ci
├── npm run check
└── npm test
│
▼
Review & merge
│
▼
Render deploys
(after successful CI)As the project grows, useful operational automation may also include:
- Automated backup jobs with monitored failures.
- Regular dependency and container-image updates.
- Scheduled availability checks and alerts.
- Consistent log collection and retention.
- Controlled database migration jobs.
- Post-deployment smoke tests against
/healthand important API routes.
Automation reduces repeated manual work, but a scheduled job can also repeat a mistake. Review permissions and test procedures before running automated tasks with database access.
33. Use a release checklist
Before you merge or deploy:
- [ ] Automated checks and Vitest tests pass.
- [ ] No passwords, tokens,
.envfiles, or SQL exports are committed. - [ ] The Docker image builds using the updated lockfile.
- [ ] The API still responds to
/healthand/readyappropriately. - [ ] Database migrations have been tested and ordered safely.
- [ ] Required backups exist and the restore procedure has been tested.
- [ ] You understand which commit and configuration will be deployed.
- [ ] You have a safe way to recover from an application failure.
After deployment:
- [ ] Confirm the expected commit is live.
- [ ] Test
/health,/ready, and an important API operation with Insomnia. - [ ] Review logs and monitoring signals for errors.
- [ ] Verify the database still contains the expected data.
- [ ] Record any incident or unexpected behavior.
34. Distinguish application rollback from database recovery
An application rollback means returning to an earlier application version. For example, Render may be able to deploy an earlier code revision.
A database recovery means restoring data or correcting schema/data changes. Rolling back an API image does not reverse SQL that has already run against MySQL.
A safer release strategy prefers backward-compatible schema changes. Add a new column first, update the application to use it, and remove old columns only after every running version no longer depends on them. This is often called an expand-and-contract approach.
35. Write a small incident note
If an exercise fails, record the incident in your own notes using this template:
text
Incident:
What users observed:
What the health and readiness checks showed:
Relevant request IDs or log events:
Likely root cause:
Action taken:
How you verified recovery:
How you could prevent it next time:This habit makes future incidents easier to investigate, even in small projects.
Final Practical Challenge — Operate Your Deployment
Use your existing deployment-workshop project to demonstrate that you can observe, diagnose, and protect a small application.
Task A — Logging
- Install Winston and create
src/logger.js. - Log application startup and completed HTTP requests.
- Generate requests through Insomnia.
- Find the matching Docker log entries and request IDs.
- Confirm that no secrets or note contents appear in the logs.
Success criterion: You can show a structured JSON log with a route, status code, and request ID.
Task B — Health and monitoring
- Implement the
/readyroute. - Test both success and failure using Vitest and a mocked MySQL pool.
- Verify
/healthand/readyagainst the local Compose application. - If you have a deployed Render service, inspect its logs and health configuration.
- Optionally configure an external uptime monitor for
/health.
Success criterion: You can explain exactly what each endpoint proves—and what it does not prove.
Task C — Backup and restore
- Add a few notes using Insomnia.
- Create
backups/notes-backup.sqlusingmysqldump. - Restore it into
deployment_lab_restore, not the original database. - Compare the restored records with the source records.
- Verify that
backups/is excluded from Git.
Success criterion: You can demonstrate a successful restore without overwriting the live workshop data.
Task D — Migration
- Create
db/migrations/001_add_priority.sql. - Back up the database and apply the SQL once.
- Update the Notes API's list query to include
priority. - Confirm existing and new notes have the expected default.
- Run
npm run checkandnpm test.
Success criterion: Your existing notes survive, the API works, and the migration file is committed to Git.
Task E — Final review
Explain the correct first response for each situation:
- The API is online, but MySQL is unavailable.
- A new API deployment queries a column that does not exist.
- A student accidentally deletes rows from the notes table.
- A backup file exists, but nobody has ever restored it.
- A previously working API begins responding much more slowly.
Reveal suggested answers
- Check
/ready, database health, private networking, and safe application logs. Do not immediately restart every component. - Inspect migration status and release order. Apply the reviewed, tested schema change through an authorized process rather than blindly reverting the database.
- Prevent further damage, assess the incident, and recover from a verified backup according to the recovery plan. Do not experiment on the only remaining copy.
- Treat recovery as unverified; perform an isolated restore test and validate the resulting data.
- Check request durations and error rates, correlate them with recent deployments, and inspect CPU/memory and database health before changing configuration.
Knowledge Check
Answer these questions before opening the suggested answers.
- What are the main differences between logging, monitoring, and alerting?
- Why is structured JSON logging useful in containerized applications?
- Why should you use
Consoleas the primary Winston transport for this exercise? - What is the purpose of
X-Request-ID? - Why should you avoid logging passwords, tokens, and complete request bodies?
- What does
GET /healthprove in this API? - Why can
/healthreturn 200 while/readyreturns 503? - What advantage does an external uptime monitor provide over looking at logs manually?
- Why should you interpret uptime alerts carefully on services that sleep when idle?
- Why is a persistent MySQL volume not a database backup?
- What is the purpose of
mysqldump? - Why did you restore into
deployment_lab_restorerather than your original database? - Does an existing MySQL database automatically rerun
db/init.sqlafter a restart? - Why do schema migrations need to be tracked and applied in a controlled order?
- Why does adding a column with a default value make a safer introductory migration than dropping a column?
- Why might an older application still work after an additive migration?
- Can a Render application rollback undo an already-applied database migration?
- What should you verify after a new deployment even when CI tests passed?
Reveal suggested answers
- Logging records events; monitoring measures system behavior; alerting notifies someone when a condition requires attention.
- Structured fields make log events easier to search, filter, and correlate.
- Docker and Render collect stdout/stderr; a file written inside a container may disappear when the container is replaced.
- It provides a correlation identifier between the HTTP response and server-side log events.
- Logs may be retained, searchable, or accessible to people and systems other than the original request handler.
- The API process is responding to HTTP requests.
- The process is alive but a required dependency, such as MySQL, is unavailable.
- It continuously checks reachability from outside your hosting provider and can notify you without requiring manual inspection.
- A slow cold start can look like a temporary failure; isolated network issues can also create false positives.
- A volume retains live files on the same storage system; it is not an independent, verified recovery copy.
- It exports database schema and data into a logical backup, often a SQL file.
- To prove recovery works without overwriting or damaging the original data.
- No. Initialization scripts run for new, empty database data directories, not on every restart.
- The database must match the application version, and migration history prevents accidentally rerunning or skipping schema changes.
- An additive column with a default can preserve existing rows and old application behavior; dropping a column can destroy data and break code immediately.
- Older queries can usually ignore the new field and still work, provided the schema change remains backward-compatible.
- No. An application rollback and a database recovery are separate processes.
- Check the active commit, health and readiness, logs, important API behavior, and database compatibility.
Final checklist
- [ ] I can explain logging, monitoring, and alerting.
- [ ] My Express app uses Winston to write structured logs.
- [ ] I can find request IDs and HTTP status codes in Docker or Render logs.
- [ ] My
/healthand/readyroutes answer different operational questions. - [ ] I have passing Vitest tests for readiness success and failure.
- [ ] I can interpret application and database health without making destructive changes.
- [ ] I have created a MySQL logical backup and restored it into a separate database.
- [ ] I have added a versioned, additive migration without deleting my notes.
- [ ] I understand why
init.sql, backups, migrations, and rollbacks are not interchangeable. - [ ] I can follow a release checklist and document a simple incident.
Course wrap-up
You started with a small Node.js application and learned how to test, package, deploy, and operate it. Your project now demonstrates an end-to-end workflow:
text
Write code → Test with Vitest → Validate with GitHub Actions
→ Build a Docker image → Deploy to Render
→ Run with MySQL → Log and monitor behavior
→ Back up data → Apply reviewed migrationsThese are foundational DevOps skills. In larger systems, you will expand on them with infrastructure as code, more advanced observability, production-grade secrets management, automated backup retention, and carefully controlled release strategies.
Before you finish
Keep your source code, Dockerfile, Compose file, CI workflow, and SQL migration scripts in Git. Do not commit exported backups, real .env files, production credentials, or confidential logs.
If you created paid Render or DigitalOcean resources solely for the training, review them and remove resources you no longer need after confirming that you have preserved any required data.
Further reading
Logging and health checks
- Winston — Official repository and documentation
- Render — Health checks
- Render — Service logs
- Render — Service metrics
- Express — Health checks and graceful shutdown
Backups and migrations
- MySQL 8.4 —
mysqldump - MySQL 8.4 —
mysqlclient - MySQL — Implicit commits
- Prisma — Baselining an existing database
- Docker — Manage data with volumes