Skip to content

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:

  1. Explain the difference between logging, monitoring, and alerting.
  2. Configure structured application logs using Winston and choose appropriate log levels.
  3. Add and test liveness and readiness endpoints in an Express application.
  4. Inspect and correlate application logs, Docker container logs, and Render deployment information.
  5. Configure a simple external uptime check and interpret its limitations.
  6. Create and verify a logical MySQL backup using mysqldump and restore it into a separate database.
  7. Apply a versioned SQL schema change without deleting existing notes.
  8. Describe a safer release, migration, recovery, and troubleshooting process for a deployed application.

Tools and technologies ​

Tool or technologyHow you will use it
Node.js / Express.jsExtend your existing API
WinstonWrite structured application logs
Docker / Docker ComposeRun the API and MySQL, and inspect logs
MySQL 8.4Store notes and practice backups and schema changes
mysqldump / mysqlExport and restore a database
InsomniaTest HTTP endpoints and inspect responses
RenderInspect deployment logs, service health, and available metrics
UptimeRobot (optional)Check whether the public API is reachable
Vitest / SupertestTest readiness behavior without a real MySQL server
Git / GitHub ActionsReview 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 test

You 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.json

Protect 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:

PracticeMain questionExample
LoggingWhat happened during execution?POST /notes returned 503; database connection was refused
MonitoringHow is the system behaving over time?Request failures increased, memory usage grew, or the API stopped responding
AlertingWhen 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.

LevelTypical useExample
errorAn operation failed unexpectedlyUnable to query the database
warnSomething unusual occurred but the service continuesA dependency check failed
infoNormal, useful operational eventsAPI started; request completed
debugExtra details during diagnosisA 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 failed

A 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 ​

  1. What is the difference between a log entry and an alert?
  2. Which log level would you use when MySQL cannot answer a database query?
  3. Why is a request ID helpful?
  4. Why is logging an entire Authorization header dangerous?
Reveal suggested answers
  1. A log entry records an event; an alert notifies someone when a condition requires attention.
  2. error for a failed application operation; warn may be appropriate for a failed health/readiness check that is handled without crashing the service.
  3. It lets you connect several messages to a single request.
  4. 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 ps

Confirm the existing endpoints still work:

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

If 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 winston

Winston 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:

  • level sets the minimum importance that gets logged.
  • defaultMeta adds 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:

  1. Creates a unique identifier for each request.
  2. Adds that identifier to the response's X-Request-ID header.
  3. Measures how long the request takes.
  4. Writes a log when the response finishes.
  5. Uses info, warn, or error according 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 --build

Generate 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-exist

Now inspect recent API logs:

bash
docker compose logs --tail=30 api

Or follow incoming logs while you send requests from Insomnia:

bash
docker compose logs -f api

Look 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.

  1. Find X-Request-ID.
  2. Find the corresponding log line in the Docker output.
  3. Record its HTTP method, route, status code, and duration.
  4. 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.

CheckQuestionExampleResult when MySQL is down
LivenessIs the API process responding?GET /healthCan still be 200
ReadinessCan the API perform required work?GET /readyShould be 503
Dependency checkIs a particular dependency reachable?Existing GET /db-checkShould 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 --build

Create two GET requests:

RequestExpected statusExpected body
http://localhost:3000/health200{"status":"ok"}
http://localhost:3000/ready200{"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 test

Test 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:

  1. Push a tested change through a pull request and let your CI checks pass.
  2. Make sure Render deploys the updated API.
  3. Open the API web service in the Render dashboard.
  4. Find the Health Check Path setting and set it to /health for this exercise.
  5. Save the configuration and verify that the service remains healthy.
  6. Visit the deployed /health and /ready endpoints 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 /health and /ready.
  • [ ] I can see a request ID in the response header and its related log entry.
  • [ ] Vitest verifies that /ready returns 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).

InformationWhat it helps you understand
Application logsDid the API start? Which requests failed?
Deployment eventsWhich commit was deployed? Did the build or rollout fail?
CPU and memory metricsIs resource usage changing unexpectedly?
Request behaviorIs an endpoint slow or returning more errors?
Database service logs/metricsIs 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:

  1. Sign in to your chosen uptime-monitoring service.

  2. Create an HTTP(S) monitor.

  3. Use your API's public liveness URL, for example:

    text
    https://YOUR-RENDER-SERVICE.onrender.com/health
  4. Choose an allowed check interval and notification destination according to your plan.

  5. Save the monitor and observe its results.

  6. 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 ​

SignalWhy it mattersFirst place to investigate
AvailabilityCan users reach the API?Uptime check, Render service state, /health
HTTP error rateAre requests failing more often?http_request log levels and status codes
Response timeAre requests becoming slow?Request durations, resource metrics
CPU / memoryIs the app under resource pressure?Render Metrics or host metrics
ReadinessCan the API use required dependencies?/ready, MySQL logs
Database storageIs persistent disk space running low?Database service metrics and alerts
Backup freshnessCan 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 Unavailable

Follow a structured investigation:

  1. Confirm the incident. Repeat the requests once and record the response codes and request IDs.
  2. Check the current deployment. Did a new commit, environment-variable change, or database redeploy happen?
  3. Check the API logs. Search for readiness_check_failed and failed http_request entries.
  4. Check MySQL's service state. Is the database running and reporting healthy?
  5. Check networking and configuration. On Render, confirm the internal database hostname, region, DB_* variables, and access permissions.
  6. Make the smallest safe correction. Fix an incorrect variable or restore a missing service only after identifying the likely problem.
  7. Verify recovery. Re-test /health, /ready, /notes, and a safe read/write operation.
  8. 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:

SymptomFirst investigation
/health returns 502/503 and no API logs appearCheck service state, container startup, deployed port, and Render deploy logs
/health is 200 but /ready is 503Check the MySQL service, internal hostname, and credentials
/health is 200, /ready is 200, but POST /notes failsCheck the notes route, schema, permissions, and application error logs
GitHub Actions fails but Render has the old version runningInspect failing CI checks before deploying
A recently deployed API cannot query a new columnCheck 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/mysql

This 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.

TermMeaning
Persistent volumeStorage that survives container recreation
Logical backupAn export of database schema and data, often SQL statements
RestoreRebuilding a database from a backup
RPOHow much recent data loss you can tolerate
RTOHow 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 ps

Create a directory to store the exercise backup:

bash
mkdir -p backups

Add it to your .gitignore:

text
backups/
*.sql.gz

Keep 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.sql

This 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:

  • -T avoids allocating a pseudo-terminal, which is important when writing SQL to a file.
  • --single-transaction gives a consistent snapshot for transactional tables such as the InnoDB notes table, provided schema changes are not running concurrently.
  • --no-tablespaces avoids requiring some broader server privileges.
  • --set-gtid-purged=OFF avoids adding GTID state to this portable workshop export.
  • MYSQL_PWD supplies 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.sql

Avoid 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.sql

Finally, 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):

ItemYour result
Backup filenamenotes-backup.sql
Source environmentLocal Docker Compose
Restore databasedeployment_lab_restore
Backup command completed successfully?Yes / No
Original note countYour result
Restored note countYour 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:

  1. Confirm your original notes exist.
  2. Create and verify a backup.
  3. Write a migration file in Git.
  4. Apply the migration once to your local workshop database.
  5. Confirm the original notes are still present.
  6. Update the API to return the new field.
  7. 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.sql

This 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/notes

Create 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 fileMigration tool such as Prisma Migrate
You decide when/how to execute the SQLThe tool provides commands for creating/applying migrations
You must track the applied state yourselfThe tool records migration history
Easy to demonstrate a single operationBetter for a growing sequence of changes
Easy to accidentally rerun the same SQLHelps 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:

  1. Prepare a staging or disposable copy that resembles the deployed database.
  2. Test the migration and application code together.
  3. Take a verified backup of the cloud database and confirm the recovery approach.
  4. Ensure the execution method has authorized, secure access to Render's private MySQL network.
  5. Apply the migration using a controlled job or administrative connection; do not assume a public GitHub Actions runner can reach a private database.
  6. Validate the schema and representative records.
  7. Deploy the corresponding API change only when the schema is ready.
  8. 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" in GET /notes.
  • [ ] The migration SQL is tracked in Git, but my exported backup is not.
  • [ ] I know that db/init.sql is not a migration runner.
  • [ ] I know why running the same ALTER TABLE twice 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 /health and 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, .env files, or SQL exports are committed.
  • [ ] The Docker image builds using the updated lockfile.
  • [ ] The API still responds to /health and /ready appropriately.
  • [ ] 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 ​

  1. Install Winston and create src/logger.js.
  2. Log application startup and completed HTTP requests.
  3. Generate requests through Insomnia.
  4. Find the matching Docker log entries and request IDs.
  5. 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 ​

  1. Implement the /ready route.
  2. Test both success and failure using Vitest and a mocked MySQL pool.
  3. Verify /health and /ready against the local Compose application.
  4. If you have a deployed Render service, inspect its logs and health configuration.
  5. 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 ​

  1. Add a few notes using Insomnia.
  2. Create backups/notes-backup.sql using mysqldump.
  3. Restore it into deployment_lab_restore, not the original database.
  4. Compare the restored records with the source records.
  5. Verify that backups/ is excluded from Git.

Success criterion: You can demonstrate a successful restore without overwriting the live workshop data.

Task D — Migration ​

  1. Create db/migrations/001_add_priority.sql.
  2. Back up the database and apply the SQL once.
  3. Update the Notes API's list query to include priority.
  4. Confirm existing and new notes have the expected default.
  5. Run npm run check and npm 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:

  1. The API is online, but MySQL is unavailable.
  2. A new API deployment queries a column that does not exist.
  3. A student accidentally deletes rows from the notes table.
  4. A backup file exists, but nobody has ever restored it.
  5. A previously working API begins responding much more slowly.
Reveal suggested answers
  1. Check /ready, database health, private networking, and safe application logs. Do not immediately restart every component.
  2. Inspect migration status and release order. Apply the reviewed, tested schema change through an authorized process rather than blindly reverting the database.
  3. 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.
  4. Treat recovery as unverified; perform an isolated restore test and validate the resulting data.
  5. 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.

  1. What are the main differences between logging, monitoring, and alerting?
  2. Why is structured JSON logging useful in containerized applications?
  3. Why should you use Console as the primary Winston transport for this exercise?
  4. What is the purpose of X-Request-ID?
  5. Why should you avoid logging passwords, tokens, and complete request bodies?
  6. What does GET /health prove in this API?
  7. Why can /health return 200 while /ready returns 503?
  8. What advantage does an external uptime monitor provide over looking at logs manually?
  9. Why should you interpret uptime alerts carefully on services that sleep when idle?
  10. Why is a persistent MySQL volume not a database backup?
  11. What is the purpose of mysqldump?
  12. Why did you restore into deployment_lab_restore rather than your original database?
  13. Does an existing MySQL database automatically rerun db/init.sql after a restart?
  14. Why do schema migrations need to be tracked and applied in a controlled order?
  15. Why does adding a column with a default value make a safer introductory migration than dropping a column?
  16. Why might an older application still work after an additive migration?
  17. Can a Render application rollback undo an already-applied database migration?
  18. What should you verify after a new deployment even when CI tests passed?
Reveal suggested answers
  1. Logging records events; monitoring measures system behavior; alerting notifies someone when a condition requires attention.
  2. Structured fields make log events easier to search, filter, and correlate.
  3. Docker and Render collect stdout/stderr; a file written inside a container may disappear when the container is replaced.
  4. It provides a correlation identifier between the HTTP response and server-side log events.
  5. Logs may be retained, searchable, or accessible to people and systems other than the original request handler.
  6. The API process is responding to HTTP requests.
  7. The process is alive but a required dependency, such as MySQL, is unavailable.
  8. It continuously checks reachability from outside your hosting provider and can notify you without requiring manual inspection.
  9. A slow cold start can look like a temporary failure; isolated network issues can also create false positives.
  10. A volume retains live files on the same storage system; it is not an independent, verified recovery copy.
  11. It exports database schema and data into a logical backup, often a SQL file.
  12. To prove recovery works without overwriting or damaging the original data.
  13. No. Initialization scripts run for new, empty database data directories, not on every restart.
  14. The database must match the application version, and migration history prevents accidentally rerunning or skipping schema changes.
  15. An additive column with a default can preserve existing rows and old application behavior; dropping a column can destroy data and break code immediately.
  16. Older queries can usually ignore the new field and still work, provided the schema change remains backward-compatible.
  17. No. An application rollback and a database recovery are separate processes.
  18. 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 /health and /ready routes 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 migrations

These 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 ​

Backups and migrations ​

Testing and operations ​