Skip to content

Lesson 5 — Deploying Docker Applications to the Cloud ​

In Lesson 1, you published a simple Express API on Render. In Lesson 2, you added Vitest tests and a GitHub Actions CI workflow. In Lesson 3, you packaged your API in Docker. In Lesson 4, you used Docker Compose to run the API and MySQL together on your computer.

Now you will bring these pieces together. Instead of running the application only on your own computer, you will deploy the Dockerized Express API to Render, connect it to a separate MySQL service, and configure deployments to wait for successful CI checks.

You will also explore a short comparison using a DigitalOcean Droplet. The objective of that comparison is to understand what changes when you manage the server yourself—not to set up a second production environment.

Continue your existing project

Keep using your deployment-workshop repository. You should already have src/app.js, src/server.js, a Dockerfile, compose.yaml, db/init.sql, and .github/workflows/ci.yml.

You do not need to replace your existing Render service from Lesson 1. You will create a new Docker-based web service, so you can compare the original Node.js deployment with this container-based one.

Learning outcomes ​

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

  1. Explain the differences between deploying an application locally, on a managed application platform, and on a virtual private server (VPS).
  2. Deploy an existing Dockerized Node.js/Express API to Render from a GitHub repository.
  3. Configure a private MySQL service with persistent storage and connect it to the API using environment variables.
  4. Distinguish local Docker Compose service discovery from Render's private service networking.
  5. Use GitHub Actions results as a gate for automatic Render deployments.
  6. Validate and troubleshoot a live application using deployment logs, health endpoints, and Insomnia.
  7. Describe what you would manage yourself when running the same application on a DigitalOcean Droplet.
  8. Identify basic production concerns, including secrets, costs, database persistence, rollbacks, and deployment verification.

Tools and technologies ​

Tool or platformHow you will use it
RenderHost the public API and its private MySQL service
GitHubStore application source code and connect it to Render
GitHub ActionsRun tests and checks before deployment
Docker / DockerfileBuild the API and database service images
Docker ComposeContinue running the complete application locally
Node.js / Express.jsServe HTTP requests
MySQL 8.4Store the application's notes
VitestCheck that API behavior remains correct
InsomniaVerify the deployed API and database-backed endpoints
DigitalOceanCompare a managed platform with a server you administer
SSH / UbuntuAccess and inspect the optional DigitalOcean server

Before you begin: accounts, costs, and safety ​

You need a GitHub account with access to your deployment-workshop repository and a Render account. A DigitalOcean account is optional for the comparison activity.

Read this before creating cloud resources

A persistent MySQL database on Render is not a free, managed MySQL product. The approach in this lesson uses a private service with a persistent disk, which requires paid resources. The API may be eligible for a free web-service plan, but the complete database-backed deployment has a cost. DigitalOcean Droplets also incur charges while provisioned.

Before you create anything:

  • Review the provider's current pricing, billing requirements, and available plans.
  • Check with your course instructor if the account or billing should be shared or demonstrated instead of created individually.
  • Do not create paid resources without permission or a budget.
  • Remove unneeded services and Droplets after the exercise; simply closing the browser does not stop billing.

No paid account? You can still complete the Docker API deployment, CI-gating, and /health verification sections. Your instructor can demonstrate the paid MySQL setup. The /db-check and /notes exercises require a running, reachable MySQL database.

Never publish credentials

Use different passwords for local development and the cloud. Do not commit .env files, paste credentials into screenshots, put secrets in Dockerfiles, or copy real passwords into public issue comments. Use Render's environment-variable settings for the values your application needs at runtime.

What you will build ​

Your deployed architecture will look like this:

text
                        GitHub repository
                         deployment-workshop
                                 │
                     Push / merge to main
                                 │
                                 ▼
                      GitHub Actions (CI)
                     npm ci → check → Vitest
                                 │
                        checks pass?
                        /          \
                      No            Yes
                      │              │
                No automatic         ▼
                   deploy      Render builds
                                the Dockerfile
                                      │
                                      ▼
             Internet       Render Web Service
      Insomnia / browser ──► Express API
                HTTPS          /health
                               /db-check
                               /notes
                                      │
                               private network
                                      │
                                      ▼
                             Render Private Service
                                   MySQL
                                      │
                                      ▼
                              Persistent disk
                                /var/lib/mysql

Important: You will not deploy compose.yaml directly to Render. Docker Compose runs your two services together locally. On Render, each containerized component is created as a separate platform service, with networking and storage configured in Render's dashboard.

For the optional DigitalOcean comparison, you will return to the Compose-based architecture you already know.


Part 1 — Prepare Your Application for Cloud Deployment ​

1. Check that your local application still works ​

Open a terminal in your application repository:

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

Start your local multi-container environment:

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

Use Insomnia or curl to verify the endpoints:

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

Expected results:

EndpointExpected behavior
GET /healthHTTP 200, {"status":"ok"}
GET /db-checkHTTP 200, {"database":"connected"}
GET /notesHTTP 200, a JSON array of saved notes (possibly empty)

If these fail locally, fix them before deploying. Cloud deployment makes an existing problem harder to debug; it does not fix it.

Your local data will not automatically move to the cloud

Your local MySQL data is stored in the Docker volume you created in Lesson 4. Render will use new database storage. The cloud database will initially be empty, even if you already saved notes locally.

Moving existing data would require an intentional database export and import, which you will explore in Lesson 6.

2. Confirm the API listens on the correct interface ​

Open src/server.js. It should contain the same server code from Lesson 2:

js
import app from './app.js';

const port = Number(process.env.PORT || 3000);

app.listen(port, '0.0.0.0', () => {
  console.log(`API listening on port ${port}`);
});

Two details matter:

  • process.env.PORT allows the hosting platform to configure the HTTP port.
  • 0.0.0.0 makes the API listen on the container's network interface, not only on its own loopback address.

If a web service listens only on 127.0.0.1, Render cannot route incoming HTTP requests to it correctly.

Your existing Dockerfile from Lesson 3 should remain in the project root:

dockerfile
FROM node:22-alpine

WORKDIR /app

ENV NODE_ENV=production

COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY --chown=node:node src ./src

USER node

EXPOSE 3000

CMD ["node", "src/server.js"]

Your existing .dockerignore should exclude .env, .env.*, .git, node_modules, and other files that do not belong in a production image.

Port 3000 vs. Render's default port

A Dockerfile's EXPOSE 3000 documents the application's expected port; it does not force the hosting platform to use it. In this lesson, you will set PORT=3000 in the Render web-service configuration to match the app's existing configuration. Render will route HTTPS traffic to your service.

3. Understand why DB_HOST=db will change ​

In Lesson 4, your Docker Compose file contained:

yaml
services:
  api:
    environment:
      DB_HOST: db

That works because db is a Docker Compose service name on the same local network.

On Render, your API and MySQL run as separate Render services. Your API must use the internal hostname supplied by Render for the MySQL private service; it must not assume the hostname is db.

Your src/db.js already supports this because it reads DB_HOST from process.env. You do not need to change or hard-code the database hostname in your source code.

4. Push your latest tested code ​

The commands in this section assume you are working on local main. If your previous exercises are on a feature branch, commit and push that work, merge its passing pull request, then update your local deployment branch:

bash
git switch main
git pull --ff-only origin main

Do not use git push origin main to publish changes that are still only on a feature branch.

Check what will be committed:

bash
git status

Confirm that .env is ignored:

bash
git check-ignore .env

If that command prints .env, Git is ignoring it. If it prints nothing, inspect .gitignore and whether the file was previously tracked before continuing.

Commit any remaining application changes, using only the files you intend to share:

bash
git add src db compose.yaml Dockerfile .dockerignore .env.example \
  package.json package-lock.json .github/workflows/ci.yml

git commit -m "Prepare containerized API for cloud deployment"
git push origin main

If Git reports that there is nothing to commit, that's fine. Make sure GitHub contains the latest version of your application and that the Actions tab shows successful checks.

Review staged files

The git add command assumes that the listed files are part of your project and that none contains real secrets. Run git status and review your staged changes before committing. Never stage your local .env file.


Part 2 — Deploy the API to Render ​

5. Create a Docker-based web service ​

Visit the Render Dashboard and sign in.

  1. Choose New → Web Service.
  2. Connect the GitHub account that contains deployment-workshop if it is not already connected.
  3. Select your deployment-workshop repository.
  4. Give the service a recognizable name such as deployment-api-docker. Render may require a unique name.
  5. Choose a region. Remember which region you choose: the MySQL service must be in the same region and workspace to communicate over Render's private network.
  6. Select Docker as the language/runtime, not the native Node.js option.
  7. Keep the repository root as the root directory and the root Dockerfile as the Dockerfile path.
  8. Choose an instance type that fits your account and budget.
  9. Add the environment variables below.
  10. Start the deployment.

For this first deployment, configure:

VariableValueWhy
NODE_ENVproductionProduction runtime mode
PORT3000Matches the port used by your Express server

Do not add fake MySQL credentials. You will configure the real DB_* variables after creating the database service.

You may see a message that the deployment has completed successfully. Copy the generated public address; it will have an onrender.com domain, for example:

text
https://YOUR-RENDER-SERVICE.onrender.com

The address above is a placeholder. Use the URL assigned to your own service.

6. Check the first public deployment ​

Open:

text
https://YOUR-RENDER-SERVICE.onrender.com/health

Expected response:

json
{
  "status": "ok"
}

You can also use a terminal:

bash
curl -i https://YOUR-RENDER-SERVICE.onrender.com/health

Replace YOUR-RENDER-SERVICE with your actual Render service subdomain.

Next, try:

text
https://YOUR-RENDER-SERVICE.onrender.com/db-check

Without cloud database configuration, you should not expect this check to succeed. The API may return 503 Service Unavailable, because its MySQL connection is not configured yet. This is useful feedback: the application is running, but one of its dependencies is unavailable.

This is a useful distinction

GET /health checks whether the Express process can answer a request. GET /db-check checks whether your API can successfully query MySQL.

An application can be available while some of its features are unhealthy. Monitoring needs to consider both situations.

7. Read the Render deployment logs ​

Open your web service in the Render Dashboard and find its Logs and Events/Deploys views.

Look for information such as:

  • Docker image build progress.
  • Package installation or build failures.
  • The command that starts your API.
  • API listening on port 3000.
  • Errors from MySQL connection attempts.
  • A successful deploy event and the active commit.

Keep in mind that build logs and application logs answer different questions:

Log typeHelps answer
Build logsDid Render build the Docker image?
Deployment eventsWhich commit is deployed? Did it become live?
Runtime logsIs Express running? Did a request fail?

Logs are not a safe place for secrets

Do not print database passwords, complete connection strings with credentials, or environment dumps in your logs. Your application should report the kind of failure without exposing sensitive values.

Checkpoint — Public API ​

  • [ ] A new Docker-based web service exists on Render.
  • [ ] It uses the Dockerfile from the GitHub repository.
  • [ ] The public HTTPS URL is accessible.
  • [ ] GET /health returns 200 and {"status":"ok"}.
  • [ ] You can identify the deployed commit and inspect its logs.
  • [ ] You understand why database-backed endpoints do not work yet.

Part 3 — Add MySQL with Persistent Storage ​

8. Why not deploy the Compose file directly? ​

Your local compose.yaml describes both services and their network:

text
Local computer
  compose.yaml
     ├── api container
     └── db container + named volume

Render takes a different approach:

text
Render
  Web Service (public) ────► Private Service (MySQL)
                                   │
                                   ▼
                              Persistent disk

In both cases, the Node.js API talks to MySQL over a private network. But the service definitions are managed by different systems:

FeatureLocal Docker ComposeRender
Start servicesdocker compose upCreate services in dashboard
Define API imagebuild: .Docker runtime + root Dockerfile
Define database imagemysql:8.4Docker-based private service
Database hostnamedbRender-provided internal hostname
Public API accesslocalhost:3000Public HTTPS onrender.com URL
Database persistencemysql_data named volumeRender persistent disk mounted at /var/lib/mysql
Environment variablesCompose environment: and .envEnvironment settings per Render service

9. Prepare a Dockerfile for the database service ​

Your Lesson 4 project already has this file:

db/init.sql

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

You used it to initialize an empty local MySQL volume. You can reuse it when creating a cloud MySQL instance.

Create another Dockerfile at db/Dockerfile.render:

dockerfile
FROM mysql:8.4

COPY db/init.sql /docker-entrypoint-initdb.d/001-init.sql

EXPOSE 3306

This Dockerfile:

  1. Starts from the MySQL 8.4 Docker image.
  2. Copies your existing table-creation script into the image's initialization directory.
  3. Documents the MySQL network port.

MySQL will execute the SQL file only when it initializes a fresh, empty data directory. It will not re-run this file on every deployment or restart.

Why a second Dockerfile?

Your repository now contains two images for two different responsibilities:

  • Dockerfile — builds the Express API.
  • db/Dockerfile.render — builds a MySQL service with its initialization SQL.

This does not replace the mysql:8.4 service in your local compose.yaml. Local Compose will continue to work exactly as before.

10. Build the database image locally (optional but useful) ​

You can validate the new database Dockerfile without starting another MySQL server:

bash
docker build -f db/Dockerfile.render -t deployment-mysql:lesson5 .

The final . is the repository root as the Docker build context. This matters because the Dockerfile copies db/init.sql, whose path is relative to that build context.

If the build fails with COPY failed or not found, check that:

  • You ran the command from the project root.
  • The file really exists as db/init.sql.
  • .dockerignore does not exclude the db directory.

Commit and push the new Dockerfile:

bash
git add db/Dockerfile.render
git commit -m "Add MySQL image for Render private service"
git push origin main

11. Create a MySQL private service on Render ​

Paid resources required

The steps below require an account that can create a private service and attach a persistent disk. Verify prices and permissions before proceeding. A database without persistent storage can lose its data when the service restarts or redeploys and is not suitable for this exercise.

Return to the Render Dashboard:

  1. Choose New → Private Service.
  2. Connect the same deployment-workshop GitHub repository.
  3. Name the service something recognizable, such as deployment-mysql.
  4. Select the same region and workspace as your public API.
  5. Choose the Docker runtime.
  6. Set the Dockerfile Path to db/Dockerfile.render. Keep the repository root as the build context/root directory.
  7. Choose a suitable paid instance type.
  8. Under the service's environment settings, configure the MySQL variables shown below.
  9. Under the disk/storage configuration, add a persistent disk mounted at exactly /var/lib/mysql. Select an appropriate available capacity.
  10. Create the service and inspect its deployment logs.

Configure these environment variables on the MySQL private service:

VariableExample valueNotes
MYSQL_DATABASEdeployment_labName of your application database
MYSQL_USERlab_userNon-root application database user
MYSQL_PASSWORDA new strong secretDo not reuse the local .env password
MYSQL_ROOT_PASSWORDAnother new strong secretMust differ from the application-user password

Do not copy example passwords from a tutorial into a real deployment. Use strong, unique values and keep them somewhere safe that is appropriate for your course environment.

Storage mount path:

text
/var/lib/mysql

That path is essential: it is where this MySQL image stores its database files. A disk mounted at the wrong location will not preserve the database's actual data.

Do not treat disk snapshots as database backups

A disk gives MySQL persistent storage. It is not the same thing as a tested logical database backup. For self-hosted MySQL, use database-aware tools such as mysqldump and verify that a backup can be restored. Backups and migrations are covered in Lesson 6.

12. Find the database's internal hostname ​

Once the MySQL private service is running, open its Connect / Internal information in Render.

You should find an internal hostname and port for other Render services to use. It may look similar to:

text
deployment-mysql-<generated-identifier>:3306

The example above is illustrative only. Copy the actual internal address supplied by Render.

For your application configuration, separate it into:

  • Host: the name before :3306.
  • Port: 3306.

For example, if the dashboard shows some-internal-host:3306, use:

text
DB_HOST=some-internal-host
DB_PORT=3306

Do not use localhost, db, or your public onrender.com URL for this connection.

Keep both services in the same region

Render's private service networking works between eligible services in the same workspace and region. If your API is deployed in one region and MySQL in another, the private hostname will not work across those regions.

13. Configure the API's database environment ​

Open your deployment-api-docker web service in the Render Dashboard and add/update the following environment variables:

VariableValue
NODE_ENVproduction
PORT3000
DB_HOSTInternal hostname copied from the MySQL service
DB_PORT3306
DB_NAMESame value as MySQL's MYSQL_DATABASE
DB_USERSame value as MySQL's MYSQL_USER
DB_PASSWORDSame secret as MySQL's MYSQL_PASSWORD

Render may ask you to save the values and redeploy or restart the service so the running container receives the new environment.

Pay close attention to the variable names. Your code from Lesson 4 expects DB_HOST, DB_PORT, DB_NAME, DB_USER, and DB_PASSWORD—it does not automatically understand MySQL's initialization variables named MYSQL_*.

You should not configure MYSQL_ROOT_PASSWORD on the API. The application should connect as the restricted application user, not as root.

14. Test the fully connected application ​

After both services are running, open Insomnia.

Create an environment variable for your public base URL if you like:

text
https://YOUR-RENDER-SERVICE.onrender.com

Make these requests:

Request A — Application health

http
GET https://YOUR-RENDER-SERVICE.onrender.com/health

Expected: 200 OK

json
{"status":"ok"}

Request B — Database connection

http
GET https://YOUR-RENDER-SERVICE.onrender.com/db-check

Expected: 200 OK

json
{"database":"connected"}

Request C — Create a note

http
POST https://YOUR-RENDER-SERVICE.onrender.com/notes
Content-Type: application/json

JSON body:

json
{
  "message": "My first note in the cloud"
}

Expected: 201 Created with a response similar to:

json
{
  "id": 1,
  "message": "My first note in the cloud"
}

Request D — Read saved notes

http
GET https://YOUR-RENDER-SERVICE.onrender.com/notes

Expected: 200 OK, with your note in a JSON array:

json
[
  {
    "id": 1,
    "message": "My first note in the cloud",
    "created_at": "..."
  }
]

The created_at value will be generated by MySQL and will contain an actual date and time. The example uses "..." only to avoid implying a fixed value.

Confirm data persistence

After creating a note, restart or redeploy the API web service and request GET /notes again. The note should still exist because it is stored in MySQL, not in the API container.

For a stronger check, restart the MySQL private service using a safe provider-supported restart action, then verify that the note remains after the database becomes healthy again. Do not remove the database service or disk, and do not reset the volume.

Checkpoint — Full application ​

  • [ ] MySQL runs as a private service, not a public web service.
  • [ ] MySQL uses a persistent disk mounted at /var/lib/mysql.
  • [ ] The API uses Render's internal MySQL hostname.
  • [ ] GET /health returns 200.
  • [ ] GET /db-check returns 200.
  • [ ] POST /notes creates a database-backed note.
  • [ ] GET /notes returns the note after an API redeploy.
  • [ ] No passwords or .env files were committed to GitHub.

Part 4 — Automate Deployment with GitHub Actions and Render ​

15. Review the CI pipeline from Lesson 2 ​

In Lesson 2, you created .github/workflows/ci.yml. It should run automatically on pushes to main and on pull requests, executing checks similar to:

yaml
- name: Install dependencies
  run: npm ci

- name: Check JavaScript syntax
  run: npm run check

- name: Run Vitest tests
  run: npm test

These checks are your CI stage. They do not, by themselves, stop a hosting platform from deploying every new commit.

16. Make Render wait for successful checks ​

Render can use results from GitHub Actions as a deployment gate. This is the recommended approach for this workshop because it builds on the workflow you already have without introducing deployment credentials.

For deployment-api-docker:

  1. Open the service in Render.
  2. Go to Settings.
  3. Find the Auto-Deploy setting.
  4. Select After CI Checks Pass.
  5. Save the setting.

Render can then wait for the linked commit's reported checks before it starts an automatic deployment.

Compare the options:

Auto-Deploy modeBehavior
On CommitA new push/merge can trigger a deploy without waiting for tests
After CI Checks PassA deploy is triggered only after the detected checks pass
OffYou trigger deployments manually or through an explicit API/hook action

Keep database deployments separate

For the MySQL private service, set Auto-Deploy: Off after it is successfully initialized, or use appropriate build filters. You do not want ordinary changes to your Express API to rebuild and redeploy the database needlessly.

A database is stateful. Restarting or redeploying it may temporarily interrupt requests, even though its persistent disk preserves the data.

17. Verify your automated deployment path ​

Update the existing /version route from Lesson 2 in src/app.js to return the new value below. If you chose a different route for that exercise, add /version before export default app;. Keep only one GET /version handler; appending a duplicate would leave the earlier response active.

js
app.get('/version', (_req, res) => {
  res.json({ version: 'lesson-5' });
});

Update the existing GET /version tests in tests/app.test.js to match. If you have not added these tests yet, add this block using the imports already present in that file:

js
describe('GET /version', () => {
  it('returns HTTP 200', async () => {
    const response = await request(app).get('/version');
    expect(response.status).toBe(200);
  });

  it('returns the API version', async () => {
    const response = await request(app).get('/version');
    expect(response.body).toEqual({ version: 'lesson-5' });
  });
});

Replace the old 1.0.0 expectation rather than keeping contradictory assertions. Before committing, run:

bash
npm run check
npm test

Then push the change:

bash
git add src/app.js tests/app.test.js
git commit -m "Update deployment version endpoint and tests"
git push origin main

Observe the sequence:

  1. The GitHub Actions workflow starts for your new commit.
  2. The CI job installs dependencies, checks syntax, and runs Vitest.
  3. If the checks pass, Render starts an automatic deployment of the API web service.
  4. Render builds the API image from the Dockerfile and starts the new container.
  5. You inspect its deployment events and logs.
  6. You open /version to confirm that the new code is live.

Expected response:

json
{"version":"lesson-5"}

You now have an end-to-end path from Git push → automated tests → deployment → public verification.

18. Explore what happens when a test fails ​

You do not need to break your production application to demonstrate a failed CI check. Instead, create a feature branch:

bash
git switch -c exercise/failing-ci

Temporarily change an existing Vitest assertion so that it expects an incorrect response. Run the tests locally and confirm that the suite fails.

Commit and push the branch:

bash
git add tests/app.test.js
git commit -m "Demonstrate a failing CI check"
git push -u origin exercise/failing-ci

Open a pull request targeting main. Check that GitHub reports the failed test. Do not merge this broken change. Your Render service is linked to main, so a failing feature-branch check should not be treated as a successful release of the production branch.

Fix the assertion, run the tests again, and push the correction. Confirm that the pull request now passes its CI checks. Then close or merge the branch according to your exercise plan.

With your feature-branch work committed, return to the deployment branch before continuing:

bash
git switch main
git pull --ff-only origin main

Know what the deployment gate can—and cannot—prove

Passing Vitest checks means your test suite passed for the evaluated commit. It does not prove that your production database credentials, network configuration, or runtime behavior are correct.

That is why you still need deployment logs, health checks, and post-deployment API tests.

19. Optional: Explicit deployment from GitHub Actions ​

The After CI Checks Pass option is enough for the main exercise. If you want GitHub Actions itself to send a deployment trigger, Render also supports deploy hooks.

The alternative approach is:

  1. Set the API service's Render Auto-Deploy setting to Off.
  2. Generate/copy the API service's Deploy Hook URL from Render.
  3. Store it in your GitHub repository as a secret named RENDER_DEPLOY_HOOK_URL.
  4. Add a separate deployment job that runs only when the test job succeeds on a push to main.

If your existing workflow job is named test, an example job is:

yaml
deploy:
  name: Trigger Render deployment
  needs: test
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  runs-on: ubuntu-latest
  steps:
    - name: Trigger deployment
      env:
        RENDER_DEPLOY_HOOK_URL: ${{ secrets.RENDER_DEPLOY_HOOK_URL }}
        TESTED_COMMIT: ${{ github.sha }}
      run: |
        test -n "$RENDER_DEPLOY_HOOK_URL"
        curl --fail --show-error --silent \
          --request POST "${RENDER_DEPLOY_HOOK_URL}&ref=${TESTED_COMMIT}"

This code is a job fragment. Insert it under jobs: at the same indentation level as your existing test job. Do not create another jobs: key. If your CI job has a different name, update needs: accordingly.

The hook URL copied from Render already contains its secret key query parameter. Appending &ref= selects the exact commit that this workflow tested (github.sha). Without it, a newer push could move the linked branch forward while an earlier workflow is still running, causing the hook to deploy a commit that has not passed its checks. See Render's specific-commit deployment documentation.

A successful deploy-hook HTTP request means the deployment was triggered, not necessarily that the new application version is healthy. You must still check Render's deploy status and the public endpoints.

A deploy hook is a secret

Anyone holding a valid deploy-hook URL may be able to trigger a deployment. Never put it in source code, logs, or documentation screenshots. If it leaks, regenerate it in Render and update the GitHub secret.

Do not combine an explicit deploy hook with Render's normal After CI Checks Pass auto-deploy mode for the same service, or you may trigger duplicate deployments.


Part 5 — Inspect Deployments and Respond to Problems ​

20. Understand the application lifecycle ​

Your deployment now has several distinct operations:

OperationWhat it changesWhat it should not change
Commit and pushRepository historyCloud database data
GitHub Actions checksCI statusRunning API, unless a deployment follows
Render API deployThe running API versionMySQL's persistent data
API environment updateRuntime configurationDatabase schema, by itself
MySQL restartRunning database processPersistent database files
Schema migrationDatabase structure/dataSource repository, unless migration scripts are committed
Database restorationDatabase stateAPI image, unless separately redeployed

When something goes wrong, identify which layer failed before changing anything.

21. Troubleshoot common deployment errors ​

SymptomPossible causeWhat to inspect
Docker build fails on RenderWrong Dockerfile path or missing fileRender build logs and Dockerfile configuration
npm ci fails during buildIncompatible lockfile or dependencypackage-lock.json and Node version
Deploy fails to detect an open portExpress is listening on the wrong host/portsrc/server.js, PORT, runtime logs
/health returns an errorAPI did not start correctlyWeb-service logs and deployed commit
/health works, /db-check returns 503MySQL missing/unreachable or wrong credentialsDatabase logs, API env vars, private hostname, region
ENOTFOUND for database hostIncorrect internal hostnameRender's Internal connection information
ECONNREFUSED on port 3306MySQL not listening or not readyPrivate-service status and logs
Access denied for userUsername or password mismatchAPI DB_* values vs. MySQL MYSQL_* initialization
Table ... notes doesn't existInitialization SQL never ran on the database volumedb/init.sql and database initialization history
Data disappears after restartDisk missing or attached at wrong path/var/lib/mysql persistent-disk mount
Changes pushed but not deployedCI failed, no checks detected, or Auto-Deploy OffGitHub Actions and Render Auto-Deploy settings
App deployed but new route missingWrong branch/commit, pending deploy, or old URLDeployed commit and /version response

Changing MySQL initialization variables is not a password reset

As you learned in Lesson 4, environment variables such as MYSQL_PASSWORD are used when an empty MySQL data directory is initialized. Changing them later does not necessarily change the database user's credentials in an existing database.

If you change the app password after database initialization, you must also change the corresponding MySQL user password using an appropriate database administration procedure. Do not delete the persistent disk to make the passwords match.

22. Practice a safe recovery decision ​

Imagine you deploy version B of the API and /health still works, but POST /notes now fails.

Consider these questions before choosing an action:

  1. Are you seeing a new application-code bug, a network/configuration problem, or a schema mismatch?
  2. Is the failure limited to version B, or do older versions also fail?
  3. Can you safely redeploy the previous API commit?
  4. Did version B change the database schema in a way that would make an older API incompatible?
  5. Which logs and endpoint responses would confirm the recovery worked?

Render provides a deploy history and manual deployment controls. Releasing a previous API version can be useful for recovering from an application regression, but it is not a database rollback. Database schema changes need their own migration and recovery plan.

Do not use a production database for destructive exercises

Do not reset a database, delete disks, or intentionally change passwords just to produce a failure. Use a dedicated workshop environment and reversible changes. In Lesson 6, you will discuss backups, migrations, and safer recovery techniques.


Part 6 — Small Comparison Demo: DigitalOcean Droplet ​

You have now deployed your application on a managed application platform. Render takes care of many infrastructure tasks: provisioning runtime infrastructure, routing incoming HTTPS traffic, starting containers, and presenting deployment logs.

What if you wanted to run the same Docker Compose application on a server you control?

A DigitalOcean Droplet is a virtual machine. You receive a Linux server and must configure and operate it yourself.

23. Compare Render and a Droplet ​

ConcernRenderDigitalOcean Droplet
Main service modelManaged application platform (PaaS)Virtual private server (VPS/IaaS)
Operating system managementMostly handled by platformYou maintain the server OS
How the API runsRender Docker Web ServiceDocker container on the VM
MySQLSeparate private service + persistent diskUsually a container with a Docker volume, or an external database
HTTPSManaged for public web servicesYou configure a reverse proxy and TLS (or another front end)
DeploymentConnected Git repository / CI integrationSSH, Compose, scripts, CI/CD, or another deployment mechanism
Firewall/securityPlatform controls plus application configurationYou configure cloud firewall, host security, access, and patching
LogsPlatform dashboard and service logsDocker/system logs and any tools you install
BackupsMust design an appropriate database backup strategyMust design an appropriate database and server backup strategy

Neither approach is universally better. A managed platform usually simplifies day-to-day operations. A VPS gives you more control, but it also gives you more responsibilities.

24. DigitalOcean demonstration: the plan ​

This is a comparison demonstration, not a requirement to publish another public production API. You can follow it on an instructor-provided Droplet or simply observe the steps.

The demonstration uses:

text
Your computer
    │
    │ SSH
    ▼
Ubuntu Droplet (Virtual Machine)
    │
    ├── Git repository
    ├── Docker Engine + Compose
    │     ├── API container, bound to 127.0.0.1:3000
    │     └── MySQL container + named volume
    └── Linux host administration

The API will be reachable from the server itself, not exposed to the entire internet. An optional SSH tunnel lets you test the API in your local browser without opening port 3000 publicly. Serving a production API to the public would additionally require a secure ingress setup, a domain name if desired, and HTTPS.

25. Create and secure the Droplet (demonstration) ​

A recommended sequence is:

  1. Create a small Ubuntu Droplet that meets Docker and MySQL resource requirements.
  2. Add an SSH public key at creation time.
  3. Apply a DigitalOcean Cloud Firewall, allowing SSH only from the authorized source IPs.
  4. Connect to the server and create or use a non-root sudo user.
  5. Keep the operating system updated, and review SSH authentication settings.
  6. Install Docker Engine and the Docker Compose plugin using Docker's official Ubuntu installation guide.

Use the current official instructions rather than copying random installation scripts from a search result:

Docker administration is privileged

Running Docker or giving a user unrestricted access to the Docker socket is a powerful system privilege. For a simple instructor demonstration, using sudo docker ... can make that privilege explicit. Follow your organization's policies before granting users membership in the docker group.

26. Connect using SSH ​

From your computer, substitute the actual non-root username and Droplet IP address:

bash
ssh YOUR_USER@YOUR_DROPLET_IP

Once connected:

bash
whoami
uname -a
sudo docker --version
sudo docker compose version

These commands confirm:

  • Which Linux account you are using.
  • That you are running commands on a Linux server.
  • That Docker and the Compose plugin are available.

27. Start the existing Compose project on the Droplet ​

If the repository is public (or the server has appropriate access to a private repository), clone it on the Droplet:

bash
git clone https://github.com/YOUR_GITHUB_USERNAME/deployment-workshop.git
cd deployment-workshop

Create a server-specific environment file from the template:

bash
cp .env.example .env
chmod 600 .env
nano .env

Replace the example passwords with new demonstration-only secrets. Never copy your existing local .env or use production database credentials.

Because the Lesson 4 Compose file binds the API only to 127.0.0.1, the deployment will not publicly expose the API's port 3000 from the server. It also does not publish MySQL's port 3306.

Start the containers:

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

Test from inside the Droplet:

bash
curl -i http://127.0.0.1:3000/health
curl -i http://127.0.0.1:3000/db-check

Expected responses are the same as on your local computer:

json
{"status":"ok"}

and:

json
{"database":"connected"}

Check logs if either service is unhealthy:

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

28. Optional: inspect the server app through an SSH tunnel ​

If you want to open the Droplet's API in a browser without exposing port 3000 publicly, create an SSH tunnel from a second terminal on your computer:

bash
ssh -L 3000:127.0.0.1:3000 YOUR_USER@YOUR_DROPLET_IP

Keep the SSH session open. In a local browser, visit:

text
http://localhost:3000/health

The request travels through SSH to port 3000 on the Droplet's loopback interface. This is a learning and troubleshooting technique, not a public production deployment.

Port already in use?

If your local Docker Compose project is still listening on port 3000, stop it with docker compose down on your computer before opening the tunnel, or use a different local port:

bash
ssh -L 3001:127.0.0.1:3000 YOUR_USER@YOUR_DROPLET_IP

Then open http://localhost:3001/health.

29. What is still missing from a production VPS deployment? ​

This short demonstration proves that Docker Compose can run the same application on a remote Linux machine. It does not make the server production-ready.

A real VPS-based deployment should also consider:

  • A reverse proxy (for example, Nginx or Caddy) and properly managed HTTPS/TLS.
  • Proper firewall rules, SSH hardening, and restricted administrator access.
  • Operating system security updates and Docker image patching.
  • Automated deployments or a documented, repeatable release procedure.
  • Database backups, restore tests, and migration management.
  • Monitoring, alerts, disk-capacity checks, and log management.
  • Recovery steps if the Droplet or its storage fails.

Do not open MySQL port 3306 to the public internet for this exercise.

30. Clean up the demonstration ​

When the demonstration is finished, decide whether you still need the resources.

On the Droplet, if the project is only a disposable demonstration:

bash
sudo docker compose down

This stops and removes the containers and Compose network. It does not remove the named MySQL volume. That is intentional: deleting the volume would discard the database data.

If the Droplet is no longer needed, follow DigitalOcean's deletion procedure and verify that billable resources have been removed. Review any associated volumes, backups, IP resources, or other services separately.

If you created Render resources only for this workshop, review which services and persistent disks are still necessary and remove unused billable resources through the provider dashboard. Do not delete data you need without a verified backup.

Comparison questions ​

  1. Why can you run the entire application using docker compose up on a Droplet, but configure separate services on Render?
  2. Who maintains Ubuntu security updates on a Droplet?
  3. What does Render handle automatically that you would need to configure yourself for a public VPS deployment?
  4. Why do you need a database volume or persistent disk on both platforms?
  5. When might a managed platform be preferable to a VPS for a small team?
Reveal suggested answers
  1. On the Droplet, you administer the Docker host and can run Compose directly. Render exposes a managed service model where the API and database are configured as separate services.
  2. The Droplet owner or operator is responsible for OS configuration and maintenance.
  3. For example, Render provides public HTTPS routing for its web services and a managed deployment workflow; on a VPS, you must configure and operate the relevant components.
  4. Database files must survive container recreation or service restarts. Container writable layers are not reliable long-term storage.
  5. When the team wants to reduce infrastructure administration, focus on application development, and accept the platform's service model and costs.

Part 7 — Practical Exercises ​

Complete the activities that match the resources available to your account. If you do not have access to paid database hosting, complete Exercises 1, 2, and 4 using the public API, and study the full-database architecture with your instructor.

Exercise 1 — Deploy the Dockerized Express API ​

Deploy your deployment-workshop API to a new Render Docker web service.

Tasks:

  1. Select the correct GitHub repository and Dockerfile.
  2. Configure NODE_ENV and PORT.
  3. Deploy the API.
  4. Test GET /health using Insomnia.
  5. Find the live Git commit in the Render dashboard.

Success criteria: You can show a working public HTTPS URL, a successful health response, and the deployed commit.

Exercise 2 — Configure deployment gating ​

  1. Check that the main branch runs GitHub Actions.
  2. Enable Render's After CI Checks Pass auto-deploy mode.
  3. Update the existing /version endpoint and its tests as shown in this lesson, or add them if they do not exist yet.
  4. Push your change and observe the CI results.
  5. Confirm that Render deploys the tested commit.
  6. Query /version and record the response.

Success criteria: You can explain which checks must pass before Render deploys a new version of your API.

Exercise 3 — Deploy and connect MySQL ​

If you have access to the required paid services:

  1. Build the database image using db/Dockerfile.render.
  2. Deploy MySQL as a Render private service with a persistent disk.
  3. Copy the exact internal hostname from the service's connection information.
  4. Configure the API's DB_* variables.
  5. Test /db-check, POST /notes, and GET /notes using Insomnia.
  6. Redeploy the API and confirm the saved note remains.

Success criteria: Your API can write to and read from persistent MySQL storage in the cloud, with no public MySQL port.

Exercise 4 — Diagnose a configuration problem ​

Use this hypothetical problem rather than changing real credentials:

Your Render API returns 200 from /health, but 503 from /db-check. The runtime log reports that the database hostname cannot be resolved.

Write down:

  1. Which component is definitely responding?
  2. What configuration would you inspect first?
  3. Why would DB_HOST=db work locally but fail on Render?
  4. Where would you find the correct hostname?
  5. What would you test after correcting the configuration?
Reveal suggested answers
  1. The Express web service is running and responding to HTTP requests.
  2. The DB_HOST value and whether both services share the same Render workspace and region.
  3. db is the local Docker Compose service name, not necessarily a valid Render internal hostname.
  4. In the MySQL private service's Internal/Connect information in the Render Dashboard.
  5. Redeploy or restart the API so it receives the new environment, then test /db-check and the notes routes.

Exercise 5 — Compare deployment approaches ​

Complete this table in your own notes:

QuestionRenderDigitalOcean Droplet
Who configures the underlying Linux server???
Who manages public HTTPS for the API???
How do you start the application???
Where is MySQL data stored???
Who is responsible for backups???

Then answer: Which approach would you choose for a small student project, and why? There is no single correct answer; justify your choice using the trade-offs you learned.


Knowledge Check ​

Try answering these questions before opening the suggested answers.

  1. What is the difference between a Render web service and a Render private service?
  2. Why are the API and MySQL deployed separately on Render?
  3. Why doesn't DB_HOST=db automatically work on Render?
  4. What must your Express server do for Render to route public HTTP requests to it?
  5. Why does the MySQL service need a persistent disk, and where must it be mounted?
  6. What happens to your local MySQL notes when you create a completely new cloud database?
  7. What is the difference between GET /health and GET /db-check?
  8. What does After CI Checks Pass change about Render's deployment behavior?
  9. Does a successful GitHub Actions run guarantee a healthy production deployment?
  10. Why might you disable automatic deployment for the MySQL private service?
  11. What is the difference between a Docker Compose volume and a database backup?
  12. What extra responsibilities come with running your own DigitalOcean Droplet?
  13. Why is an SSH tunnel safer for this demonstration than exposing an unfinished API on a public port?
  14. Is deploying an old API commit always a safe way to undo database schema changes?
  15. What should you check before deleting a cloud database or Droplet?
Reveal suggested answers
  1. A web service receives public HTTP requests and has a public URL. A private service is accessible to eligible services over Render's private network and does not expose a public web URL.
  2. Render uses separate managed services for different application components; it does not run this local multi-service Compose file as one Render web service.
  3. db is a service name resolved by your local Compose network. On Render, you use the internal hostname assigned to the MySQL service.
  4. Listen on 0.0.0.0 and the configured PORT.
  5. Container storage is ephemeral. MySQL stores its database files under /var/lib/mysql, so the persistent disk must be mounted at that exact path.
  6. They stay in the local Docker volume. A new cloud database starts independently and does not import local data automatically.
  7. /health tests the API process; /db-check checks the MySQL dependency through a query.
  8. Render waits for detected CI checks to pass before triggering an automatic deployment for the linked branch.
  9. No. CI checks can pass while cloud configuration, networking, startup, or the live database is broken.
  10. Routine code pushes should not unnecessarily restart a stateful database, and a database deploy can interrupt availability.
  11. A volume or persistent disk retains files across container restarts; a backup is a separate recoverable copy that should be tested through restoration.
  12. Operating system and Docker maintenance, network and firewall configuration, secure ingress/HTTPS, monitoring, backups, and recovery procedures.
  13. SSH forwarding allows the authorized user to reach a loopback-bound service without publishing that service to the internet.
  14. No. The older application might not be compatible with a new schema; migrations need a separate recovery plan.
  15. Check for billable resources, important data, working backups, dependent services, and any related resources that require separate deletion.

Final checklist ​

Use this checklist to confirm that you are ready for the next lesson.

  • [ ] I understand why Render and Docker Compose have different deployment models.
  • [ ] I can deploy an Express API from a Dockerfile on Render.
  • [ ] I can inspect deployment logs and identify the active commit.
  • [ ] I can explain how an API connects to a private MySQL service on Render.
  • [ ] I know why a database needs persistent storage and why persistence is not backup.
  • [ ] I can configure application environment variables without committing secrets.
  • [ ] I can use Insomnia to verify health checks and database-backed endpoints.
  • [ ] I can configure automatic deployment to wait for successful CI checks.
  • [ ] I can distinguish application rollback from database recovery.
  • [ ] I can describe the additional responsibilities of a DigitalOcean Droplet.
  • [ ] I know how to avoid or clean up unnecessary cloud charges.

What's next? ​

In Lesson 6 — Monitoring, Logging & DevOps Best Practices, you will improve the reliability of your deployed application by introducing Winston, health monitoring, basic troubleshooting workflows, and MySQL backups and migrations.

Keep your Dockerfile, compose.yaml, db/init.sql, db/Dockerfile.render, CI workflow, and .env.example in your repository. Do not commit your local .env or any cloud credentials.

Further reading ​

Render ​

DigitalOcean and Docker ​

Testing and deployment automation ​