Appearance
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:
- Explain the differences between deploying an application locally, on a managed application platform, and on a virtual private server (VPS).
- Deploy an existing Dockerized Node.js/Express API to Render from a GitHub repository.
- Configure a private MySQL service with persistent storage and connect it to the API using environment variables.
- Distinguish local Docker Compose service discovery from Render's private service networking.
- Use GitHub Actions results as a gate for automatic Render deployments.
- Validate and troubleshoot a live application using deployment logs, health endpoints, and Insomnia.
- Describe what you would manage yourself when running the same application on a DigitalOcean Droplet.
- Identify basic production concerns, including secrets, costs, database persistence, rollbacks, and deployment verification.
Tools and technologies
| Tool or platform | How you will use it |
|---|---|
| Render | Host the public API and its private MySQL service |
| GitHub | Store application source code and connect it to Render |
| GitHub Actions | Run tests and checks before deployment |
| Docker / Dockerfile | Build the API and database service images |
| Docker Compose | Continue running the complete application locally |
| Node.js / Express.js | Serve HTTP requests |
| MySQL 8.4 | Store the application's notes |
| Vitest | Check that API behavior remains correct |
| Insomnia | Verify the deployed API and database-backed endpoints |
| DigitalOcean | Compare a managed platform with a server you administer |
| SSH / Ubuntu | Access 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/mysqlImportant: 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 testStart your local multi-container environment:
bash
docker compose config --quiet
docker compose up -d --build
docker compose psUse 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/notesExpected results:
| Endpoint | Expected behavior |
|---|---|
GET /health | HTTP 200, {"status":"ok"} |
GET /db-check | HTTP 200, {"database":"connected"} |
GET /notes | HTTP 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.PORTallows the hosting platform to configure the HTTP port.0.0.0.0makes 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: dbThat 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 mainDo not use git push origin main to publish changes that are still only on a feature branch.
Check what will be committed:
bash
git statusConfirm that .env is ignored:
bash
git check-ignore .envIf 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 mainIf 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.
- Choose New → Web Service.
- Connect the GitHub account that contains
deployment-workshopif it is not already connected. - Select your
deployment-workshoprepository. - Give the service a recognizable name such as
deployment-api-docker. Render may require a unique name. - 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.
- Select Docker as the language/runtime, not the native Node.js option.
- Keep the repository root as the root directory and the root
Dockerfileas the Dockerfile path. - Choose an instance type that fits your account and budget.
- Add the environment variables below.
- Start the deployment.
For this first deployment, configure:
| Variable | Value | Why |
|---|---|---|
NODE_ENV | production | Production runtime mode |
PORT | 3000 | Matches 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.comThe 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/healthExpected response:
json
{
"status": "ok"
}You can also use a terminal:
bash
curl -i https://YOUR-RENDER-SERVICE.onrender.com/healthReplace YOUR-RENDER-SERVICE with your actual Render service subdomain.
Next, try:
text
https://YOUR-RENDER-SERVICE.onrender.com/db-checkWithout 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 type | Helps answer |
|---|---|
| Build logs | Did Render build the Docker image? |
| Deployment events | Which commit is deployed? Did it become live? |
| Runtime logs | Is 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
Dockerfilefrom the GitHub repository. - [ ] The public HTTPS URL is accessible.
- [ ]
GET /healthreturns 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 volumeRender takes a different approach:
text
Render
Web Service (public) ────► Private Service (MySQL)
│
▼
Persistent diskIn both cases, the Node.js API talks to MySQL over a private network. But the service definitions are managed by different systems:
| Feature | Local Docker Compose | Render |
|---|---|---|
| Start services | docker compose up | Create services in dashboard |
| Define API image | build: . | Docker runtime + root Dockerfile |
| Define database image | mysql:8.4 | Docker-based private service |
| Database hostname | db | Render-provided internal hostname |
| Public API access | localhost:3000 | Public HTTPS onrender.com URL |
| Database persistence | mysql_data named volume | Render persistent disk mounted at /var/lib/mysql |
| Environment variables | Compose environment: and .env | Environment 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 3306This Dockerfile:
- Starts from the MySQL 8.4 Docker image.
- Copies your existing table-creation script into the image's initialization directory.
- 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. .dockerignoredoes not exclude thedbdirectory.
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 main11. 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:
- Choose New → Private Service.
- Connect the same
deployment-workshopGitHub repository. - Name the service something recognizable, such as
deployment-mysql. - Select the same region and workspace as your public API.
- Choose the Docker runtime.
- Set the Dockerfile Path to
db/Dockerfile.render. Keep the repository root as the build context/root directory. - Choose a suitable paid instance type.
- Under the service's environment settings, configure the MySQL variables shown below.
- Under the disk/storage configuration, add a persistent disk mounted at exactly
/var/lib/mysql. Select an appropriate available capacity. - Create the service and inspect its deployment logs.
Configure these environment variables on the MySQL private service:
| Variable | Example value | Notes |
|---|---|---|
MYSQL_DATABASE | deployment_lab | Name of your application database |
MYSQL_USER | lab_user | Non-root application database user |
MYSQL_PASSWORD | A new strong secret | Do not reuse the local .env password |
MYSQL_ROOT_PASSWORD | Another new strong secret | Must 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/mysqlThat 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>:3306The 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=3306Do 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:
| Variable | Value |
|---|---|
NODE_ENV | production |
PORT | 3000 |
DB_HOST | Internal hostname copied from the MySQL service |
DB_PORT | 3306 |
DB_NAME | Same value as MySQL's MYSQL_DATABASE |
DB_USER | Same value as MySQL's MYSQL_USER |
DB_PASSWORD | Same 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.comMake these requests:
Request A — Application health
http
GET https://YOUR-RENDER-SERVICE.onrender.com/healthExpected: 200 OK
json
{"status":"ok"}Request B — Database connection
http
GET https://YOUR-RENDER-SERVICE.onrender.com/db-checkExpected: 200 OK
json
{"database":"connected"}Request C — Create a note
http
POST https://YOUR-RENDER-SERVICE.onrender.com/notes
Content-Type: application/jsonJSON 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/notesExpected: 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 /healthreturns 200. - [ ]
GET /db-checkreturns 200. - [ ]
POST /notescreates a database-backed note. - [ ]
GET /notesreturns the note after an API redeploy. - [ ] No passwords or
.envfiles 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 testThese 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:
- Open the service in Render.
- Go to Settings.
- Find the Auto-Deploy setting.
- Select After CI Checks Pass.
- 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 mode | Behavior |
|---|---|
| On Commit | A new push/merge can trigger a deploy without waiting for tests |
| After CI Checks Pass | A deploy is triggered only after the detected checks pass |
| Off | You 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 testThen 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 mainObserve the sequence:
- The GitHub Actions workflow starts for your new commit.
- The CI job installs dependencies, checks syntax, and runs Vitest.
- If the checks pass, Render starts an automatic deployment of the API web service.
- Render builds the API image from the Dockerfile and starts the new container.
- You inspect its deployment events and logs.
- You open
/versionto 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-ciTemporarily 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-ciOpen 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 mainKnow 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:
- Set the API service's Render Auto-Deploy setting to Off.
- Generate/copy the API service's Deploy Hook URL from Render.
- Store it in your GitHub repository as a secret named
RENDER_DEPLOY_HOOK_URL. - 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:
| Operation | What it changes | What it should not change |
|---|---|---|
| Commit and push | Repository history | Cloud database data |
| GitHub Actions checks | CI status | Running API, unless a deployment follows |
| Render API deploy | The running API version | MySQL's persistent data |
| API environment update | Runtime configuration | Database schema, by itself |
| MySQL restart | Running database process | Persistent database files |
| Schema migration | Database structure/data | Source repository, unless migration scripts are committed |
| Database restoration | Database state | API image, unless separately redeployed |
When something goes wrong, identify which layer failed before changing anything.
21. Troubleshoot common deployment errors
| Symptom | Possible cause | What to inspect |
|---|---|---|
| Docker build fails on Render | Wrong Dockerfile path or missing file | Render build logs and Dockerfile configuration |
npm ci fails during build | Incompatible lockfile or dependency | package-lock.json and Node version |
| Deploy fails to detect an open port | Express is listening on the wrong host/port | src/server.js, PORT, runtime logs |
/health returns an error | API did not start correctly | Web-service logs and deployed commit |
/health works, /db-check returns 503 | MySQL missing/unreachable or wrong credentials | Database logs, API env vars, private hostname, region |
ENOTFOUND for database host | Incorrect internal hostname | Render's Internal connection information |
ECONNREFUSED on port 3306 | MySQL not listening or not ready | Private-service status and logs |
Access denied for user | Username or password mismatch | API DB_* values vs. MySQL MYSQL_* initialization |
Table ... notes doesn't exist | Initialization SQL never ran on the database volume | db/init.sql and database initialization history |
| Data disappears after restart | Disk missing or attached at wrong path | /var/lib/mysql persistent-disk mount |
| Changes pushed but not deployed | CI failed, no checks detected, or Auto-Deploy Off | GitHub Actions and Render Auto-Deploy settings |
| App deployed but new route missing | Wrong branch/commit, pending deploy, or old URL | Deployed 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:
- Are you seeing a new application-code bug, a network/configuration problem, or a schema mismatch?
- Is the failure limited to version B, or do older versions also fail?
- Can you safely redeploy the previous API commit?
- Did version B change the database schema in a way that would make an older API incompatible?
- 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
| Concern | Render | DigitalOcean Droplet |
|---|---|---|
| Main service model | Managed application platform (PaaS) | Virtual private server (VPS/IaaS) |
| Operating system management | Mostly handled by platform | You maintain the server OS |
| How the API runs | Render Docker Web Service | Docker container on the VM |
| MySQL | Separate private service + persistent disk | Usually a container with a Docker volume, or an external database |
| HTTPS | Managed for public web services | You configure a reverse proxy and TLS (or another front end) |
| Deployment | Connected Git repository / CI integration | SSH, Compose, scripts, CI/CD, or another deployment mechanism |
| Firewall/security | Platform controls plus application configuration | You configure cloud firewall, host security, access, and patching |
| Logs | Platform dashboard and service logs | Docker/system logs and any tools you install |
| Backups | Must design an appropriate database backup strategy | Must 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 administrationThe 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:
- Create a small Ubuntu Droplet that meets Docker and MySQL resource requirements.
- Add an SSH public key at creation time.
- Apply a DigitalOcean Cloud Firewall, allowing SSH only from the authorized source IPs.
- Connect to the server and create or use a non-root sudo user.
- Keep the operating system updated, and review SSH authentication settings.
- 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_IPOnce connected:
bash
whoami
uname -a
sudo docker --version
sudo docker compose versionThese 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-workshopCreate a server-specific environment file from the template:
bash
cp .env.example .env
chmod 600 .env
nano .envReplace 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 psTest from inside the Droplet:
bash
curl -i http://127.0.0.1:3000/health
curl -i http://127.0.0.1:3000/db-checkExpected 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 db28. 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_IPKeep the SSH session open. In a local browser, visit:
text
http://localhost:3000/healthThe 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_IPThen 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 downThis 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
- Why can you run the entire application using
docker compose upon a Droplet, but configure separate services on Render? - Who maintains Ubuntu security updates on a Droplet?
- What does Render handle automatically that you would need to configure yourself for a public VPS deployment?
- Why do you need a database volume or persistent disk on both platforms?
- When might a managed platform be preferable to a VPS for a small team?
Reveal suggested answers
- 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.
- The Droplet owner or operator is responsible for OS configuration and maintenance.
- 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.
- Database files must survive container recreation or service restarts. Container writable layers are not reliable long-term storage.
- 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:
- Select the correct GitHub repository and Dockerfile.
- Configure
NODE_ENVandPORT. - Deploy the API.
- Test
GET /healthusing Insomnia. - 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
- Check that the
mainbranch runs GitHub Actions. - Enable Render's After CI Checks Pass auto-deploy mode.
- Update the existing
/versionendpoint and its tests as shown in this lesson, or add them if they do not exist yet. - Push your change and observe the CI results.
- Confirm that Render deploys the tested commit.
- Query
/versionand 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:
- Build the database image using
db/Dockerfile.render. - Deploy MySQL as a Render private service with a persistent disk.
- Copy the exact internal hostname from the service's connection information.
- Configure the API's
DB_*variables. - Test
/db-check,POST /notes, andGET /notesusing Insomnia. - 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:
- Which component is definitely responding?
- What configuration would you inspect first?
- Why would
DB_HOST=dbwork locally but fail on Render? - Where would you find the correct hostname?
- What would you test after correcting the configuration?
Reveal suggested answers
- The Express web service is running and responding to HTTP requests.
- The
DB_HOSTvalue and whether both services share the same Render workspace and region. dbis the local Docker Compose service name, not necessarily a valid Render internal hostname.- In the MySQL private service's Internal/Connect information in the Render Dashboard.
- Redeploy or restart the API so it receives the new environment, then test
/db-checkand the notes routes.
Exercise 5 — Compare deployment approaches
Complete this table in your own notes:
| Question | Render | DigitalOcean 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.
- What is the difference between a Render web service and a Render private service?
- Why are the API and MySQL deployed separately on Render?
- Why doesn't
DB_HOST=dbautomatically work on Render? - What must your Express server do for Render to route public HTTP requests to it?
- Why does the MySQL service need a persistent disk, and where must it be mounted?
- What happens to your local MySQL notes when you create a completely new cloud database?
- What is the difference between
GET /healthandGET /db-check? - What does After CI Checks Pass change about Render's deployment behavior?
- Does a successful GitHub Actions run guarantee a healthy production deployment?
- Why might you disable automatic deployment for the MySQL private service?
- What is the difference between a Docker Compose volume and a database backup?
- What extra responsibilities come with running your own DigitalOcean Droplet?
- Why is an SSH tunnel safer for this demonstration than exposing an unfinished API on a public port?
- Is deploying an old API commit always a safe way to undo database schema changes?
- What should you check before deleting a cloud database or Droplet?
Reveal suggested answers
- 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.
- Render uses separate managed services for different application components; it does not run this local multi-service Compose file as one Render web service.
dbis a service name resolved by your local Compose network. On Render, you use the internal hostname assigned to the MySQL service.- Listen on
0.0.0.0and the configuredPORT. - Container storage is ephemeral. MySQL stores its database files under
/var/lib/mysql, so the persistent disk must be mounted at that exact path. - They stay in the local Docker volume. A new cloud database starts independently and does not import local data automatically.
/healthtests the API process;/db-checkchecks the MySQL dependency through a query.- Render waits for detected CI checks to pass before triggering an automatic deployment for the linked branch.
- No. CI checks can pass while cloud configuration, networking, startup, or the live database is broken.
- Routine code pushes should not unnecessarily restart a stateful database, and a database deploy can interrupt availability.
- A volume or persistent disk retains files across container restarts; a backup is a separate recoverable copy that should be tested through restoration.
- Operating system and Docker maintenance, network and firewall configuration, secure ingress/HTTPS, monitoring, backups, and recovery procedures.
- SSH forwarding allows the authorized user to reach a loopback-bound service without publishing that service to the internet.
- No. The older application might not be compatible with a new schema; migrations need a separate recovery plan.
- 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
- Render — Docker deployment
- Render — Web services
- Render — Private services
- Render — Private networking
- Render — Deploying MySQL
- Render — Persistent disks
- Render — Automatic deployments and CI checks
- Render — Deploy hooks
- Render — Pricing
DigitalOcean and Docker
- DigitalOcean — Recommended Droplet setup
- Docker — Install Docker Engine on Ubuntu
- Docker — Compose
- Docker — Volumes