Put the whole DevOps Fundamentals course together, take a Node.js API from a single commit to production with CI, security checks, Docker, Terraform, staging and production environments, automatic rollback, monitoring and an incident drill.

Bishal Biswas
Self Learner
The project
Over thirty lessons you have learned the pieces of DevOps one at a time. In this capstone you put them all together and take a real application from a commit on your laptop to a running, monitored production service, with every step automated.
You will use the recipe-api app that has grown through the course: the price function from Your First Pipeline with GitHub Actions, the web server from Docker Essentials, and the configuration loader from Configuration, Environments and Secrets.
When you finish, a git push to main will automatically:
- lint, test and security-check the code;
- build one container image and push it to a registry;
- deploy that image to staging, check its health, and roll back by itself if it is unhealthy;
- wait for your approval, then deploy the same image to production;
and you will be able to see it running, get alerted when it breaks, and recover in minutes.
The architecture
You ──git push──> GitHub repository (main is protected: PRs + green checks)
│
v
GitHub Actions: Release workflow
┌──────────┬───────────────┬──────────────────┬─────────────────────────┐
│ test │ image │ staging │ production │
│ lint │ docker build │ ssh deploy.sh │ [approval required] │
│ tests │ push to GHCR │ smoke test │ ssh deploy.sh │
│ audit │ sha-3ef9d3a │ │ smoke test │
└──────────┴───────────────┴──────────────────┴─────────────────────────┘
│ │
v v
staging VM (Terraform) production VM (Terraform)
┌──────────────────────┐ ┌──────────────────────┐
users ──HTTPS──> │ Caddy (ports 80/443) │ │ Caddy │
│ └─> api container │ │ └─> api container │
│ └─> postgres │ │ └─> postgres │
└──────────────────────┘ └──────────────────────┘
^ ^
uptime checks, metrics, alerts ─────┘
It is deliberately simple: one virtual machine per environment, running Docker Compose. That keeps the project affordable and lets you see every moving part. The stretch goals at the end show how to grow it towards Kubernetes and canary releases.
What you need
- A GitHub account and a new repository for
recipe-api. - A cloud account (the examples use AWS, as in Infrastructure as Code with Terraform; any provider with Ubuntu VMs works).
- A domain name you control, for
staging.and production addresses. - On your machine: Git, Node.js, Docker and Terraform (or OpenTofu).
Note: Two small servers cost a little money while they run. Set a budget alert first (Cloud Computing Basics), and run
terraform destroywhen you finish.
Work through the milestones in order. Each one says which lessons it uses and how you know it is done.
Milestone 1: the repository and its rules
Uses: Version Control with Git, Branching Strategies.
- Create the repository, push the
recipe-apiproject, and add a.gitignorefornode_modules/,dist/,.env,*.tfstate*and.terraform/. - Turn on secret scanning and push protection in the repository’s security settings.
- Add a ruleset for
main: require a pull request, require status checks, block force pushes. - From now on, every change is a short-lived branch and a pull request, even when you work alone.
Done when: pushing directly to main is rejected, and a test commit containing a fake-looking key is blocked.
Milestone 2: an app that is ready for production
Uses: Automated Testing, Configuration, Environments and Secrets, Docker Essentials.
Make sure the app has everything production needs:
- a cheap
/healthendpoint; - configuration from environment variables, validated at start-up, with secrets masked in logs;
- structured JSON logs to stdout, with a request ID (Monitoring, Logging and Alerting);
- listening on
0.0.0.0and shutting down cleanly onSIGTERM; - unit and integration tests that run with
npm test.
Done when: npm test passes, and starting the app without DATABASE_URL fails immediately with a clear message.
Milestone 3: continuous integration and security checks
Uses: Continuous Integration, Your First Pipeline with GitHub Actions, DevSecOps.
- Add the CI workflow from lesson 16 (lint and tests in parallel, Node version matrix, build artifact).
- Add the security workflow from lesson 30 (dependency audit, CodeQL, image and config scanning, SBOM).
- Add Dependabot for npm, Docker and GitHub Actions.
- Mark the CI jobs as required status checks in your ruleset.
Done when: a pull request shows green checks before it can be merged, and one with a lint error cannot be merged.
Milestone 4: the container image
Uses: Containers vs Virtual Machines, Docker Essentials, Docker Compose, Build Artifacts and Versioning.
- Add the two-stage Dockerfile from lesson 22, with a non-root user and a
HEALTHCHECK, plus a.dockerignore. - Add a development
compose.yaml(lesson 23) sodocker compose upruns the API and PostgreSQL locally. - Build and run the image locally and check
/health.
Done when: docker compose up -d --build gives you a healthy API on http://localhost:3000, and docker images shows an image far smaller than one built on the full node:24 base.
Milestone 5: infrastructure as code
Uses: IP Addresses, Ports and Protocols, DNS, Cloud Computing Basics, Infrastructure as Code with Terraform.
- Start from the Terraform configuration in lesson 26, and keep a separate state (and
.tfvarsfile) for staging and production. - Change the cloud-init template so each server is ready to run containers. Replace the
packagesandruncmdsections with:
packages:
- docker.io # Docker Engine from Ubuntu's own repository
- docker-compose-v2 # the "docker compose" plugin
- ufw
- unattended-upgrades
runcmd:
- systemctl restart ssh
- usermod -aG docker deploy # let the deploy user run docker
- mkdir -p /srv/recipe-api
- chown deploy:deploy /srv/recipe-api
- ufw allow OpenSSH
- ufw allow 80/tcp
- ufw allow 443/tcp
- ufw --force enable
terraform applyfor each environment, then create DNS A records:staging.example.comandrecipes.example.compointing at the two public IPs (or manage them in Terraform too).
Note: Docker publishes container ports by adding its own firewall rules, which take effect before ufw’s. A port published with
ports:in Compose is reachable from the internet even if ufw does not allow it. That is why only the proxy publishes ports in this project, and the database publishes none.
Done when: ssh deploy@staging.example.com docker version works on both servers, and dig +short staging.example.com returns the right IP.
Milestone 6: the servers’ runtime setup
Uses: HTTP, HTTPS and TLS, Reverse Proxies and Load Balancers, Docker Compose, Configuration, Environments and Secrets.
Each server gets a small folder, /srv/recipe-api, with four kinds of file. The first is a compose.yaml that runs the Caddy reverse proxy (which gets and renews HTTPS certificates from Let’s Encrypt automatically), the API and PostgreSQL:
services:
caddy: # reverse proxy with automatic HTTPS
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data # certificates survive restarts
restart: unless-stopped
api:
image: ${APP_IMAGE:?APP_IMAGE is not set} # written to .env by deploy.sh
env_file: api.env # DATABASE_URL and other settings, never in Git
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
env_file: db.env # POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
volumes:
caddy-data:
db-data:
($$ tells Compose to leave the $ for the container’s shell, which fills in the variables from db.env.)
A Caddyfile with one site per server, here for staging:
staging.example.com {
reverse_proxy api:3000
}
The configuration files api.env and db.env, created by hand on each server with strong, different passwords per environment, readable only by the deploy user (chmod 600). They never go in Git.
And deploy.sh, which switches to a new image and rolls back automatically if the new version does not become healthy:
#!/usr/bin/env bash
# /srv/recipe-api/deploy.sh: switch to a new image, and roll back if it is not healthy.
# Usage (the pipeline runs this over SSH): APP_IMAGE=ghcr.io/your-name/recipe-api:sha-3ef9d3a ./deploy.sh
set -euo pipefail
cd "$(dirname "$0")"
NEW="${APP_IMAGE:?Set APP_IMAGE to the image to deploy}"
OLD="$(grep -s '^APP_IMAGE=' .env | cut -d= -f2- || true)"
echo "Deploying $NEW (currently: ${OLD:-nothing})"
echo "APP_IMAGE=$NEW" > .env # Compose reads the image name from .env
docker compose pull api
# --wait blocks until the container's HEALTHCHECK passes, or fails after the timeout
if docker compose up -d --wait --wait-timeout 90 api; then
echo "Healthy: $NEW is live"
else
echo "NOT healthy: rolling back to ${OLD:-nothing}" >&2
if [ -n "$OLD" ]; then
echo "APP_IMAGE=$OLD" > .env
docker compose up -d --wait --wait-timeout 90 api
fi
exit 1
fi
Notice the last line: after rolling back, the script still exits with an error, so the pipeline run turns red and nobody mistakes a rollback for a successful release. Here is what the three cases look like:
Deploying ghcr.io/your-name/recipe-api:sha-2222222 (currently: ghcr.io/your-name/recipe-api:sha-1111111)
Healthy: ghcr.io/your-name/recipe-api:sha-2222222 is live
Deploying ghcr.io/your-name/recipe-api:sha-3333333 (currently: ghcr.io/your-name/recipe-api:sha-2222222)
NOT healthy: rolling back to ghcr.io/your-name/recipe-api:sha-2222222
Finally, let the servers pull your image. Either make the package public in GitHub’s package settings, or log in once on each server with a token that can only read packages:
docker login ghcr.io -u your-name # paste a read-only (read:packages) token when asked
Done when: on each server, APP_IMAGE=<an image you pushed by hand> ./deploy.sh reports healthy, and https://staging.example.com/health returns ok with a valid certificate.
Milestone 7: the release pipeline
Uses: Continuous Delivery vs Continuous Deployment, Build Artifacts and Versioning, SSH, Users and Permissions.
The code panel for this lesson has the release workflow that joins everything together. On every push to main it:
- tests the code again, including
npm audit; - builds one image, tagged with the commit (
sha-3ef9d3a), and pushes it to GitHub Container Registry; - deploys to staging by running
deploy.shover SSH, then runs a smoke test through the real DNS name, HTTPS and the proxy; - waits for approval, then deploys the same image tag to production and smoke-tests it.
Set up two GitHub environments, exactly as in lesson 19:
| Setting | staging |
production |
|---|---|---|
Secret DEPLOY_SSH_KEY |
Private key for the staging deploy user |
Private key for the production deploy user |
Variable DEPLOY_HOST |
staging.example.com |
recipes.example.com |
Variable DEPLOY_KNOWN_HOSTS |
Output of ssh-keyscan staging.example.com, checked by hand |
The same, for production |
| Required reviewers | None | You |
Done when: merging a pull request deploys to staging automatically, the run waits for your approval, and approving it updates production. Check the sha-... tag on both servers with cat /srv/recipe-api/.env.
Milestone 8: seeing it run
Uses: Monitoring, Logging and Alerting.
- Set up an external uptime check (many monitoring services have a free tier) that requests
https://recipes.example.com/healthevery minute and alerts you by email or phone. - Check logs on the server:
docker compose logs -f apishould show one JSON line per request. - Add a certificate expiry check, alerting if fewer than 14 days are left.
- Define one SLO, for example “99.5% of requests to
/totalsucceed over 30 days”, and decide how you will measure it.
Done when: stopping the API on production (docker compose stop api) triggers an alert within a few minutes. Start it again afterwards.
Milestone 9: break it on purpose
Uses: Deployment Strategies, Incident Response and Blameless Postmortems.
Now practise for a bad day, the way real teams run a game day.
- An unhealthy release. On a branch, make
/healthreturn status500. Merge it. Watchdeploy.shon staging fail its health check and roll back on its own, and the pipeline stop before production. Revert the change. - A healthy but wrong release. Make
/totalreturn the wrong sum, but leave/healthalone. This one passes the health check, so it reaches production (after your approval). Treat it as a real incident: declare a severity, roll back by runningdeploy.shon production with the previous image tag (it is in the last successful run’s logs), and write a blameless postmortem with a timeline and action items. - Ask yourself which check should have caught the second bug, and add it: an integration test, a smoke test of
/totalin the pipeline, or both.
Done when: you have a short postmortem in your repository, and the action item it produced has been merged.
Milestone 10: measure and improve
Uses: Measuring DevOps: The Four DORA Metrics, DevOps Principles.
Keep working on the project for a couple of weeks, shipping small changes. Then collect your DORA metrics from GitHub: deployments to production, commit-to-production lead times, failed deployments and recovery times. Feed them into the script from lesson 4. Pick the weakest metric and try one improvement.
Done when: you can state all four numbers for your project, and what you changed because of them.
Definition of done
Your capstone is complete when all of these are true:
-
mainis protected; every change arrives through a pull request with green checks. - CI lints and tests on every pull request; security checks run on pull requests and weekly.
- One image per commit, tagged with the commit ID, is the only thing ever deployed.
- Both servers are created by Terraform, with state stored safely outside Git.
- HTTPS works on both addresses, with automatically renewed certificates.
- Staging deploys automatically; production deploys the same image after approval.
- An unhealthy release rolls back by itself, and the pipeline goes red.
- No secret exists in the repository, the image, the workflow files or the logs.
- An outage triggers an alert within minutes.
- You have written at least one blameless postmortem, and acted on it.
- You can rebuild everything from scratch with
terraform applyand the pipeline.
That last point is the real test. If a cloud region disappeared tomorrow, could you be running again in an hour? If yes, you have built something genuinely DevOps.
Stretch goals
Ready for more? Each of these builds on a lesson you have done:
- Kubernetes: run the app on a local kind cluster, or a managed one, using the manifest from Why Kubernetes?, and deploy it from the pipeline.
- Canary releases: send 10% of traffic to the new version first and roll back automatically on errors, as in Deployment Strategies.
- OIDC everywhere: replace stored cloud keys with OIDC in the pipeline, and run
terraform planon pull requests (Configuration, Environments and Secrets). - Metrics and dashboards: expose
/metrics, scrape it with Prometheus and build a Grafana dashboard of the golden signals, with alerts on your SLO. - Tracing: add OpenTelemetry and follow one request from Caddy through the API to PostgreSQL.
- Signed images: sign every image with cosign, and verify the signature in
deploy.shbefore switching. - Database migrations: add a schema change using expand and contract, and run migrations as a pipeline step.
Where to go next
You now know the shape of the whole DevOps toolchain and have built a working delivery system end to end. The best next step is to go deeper into the tools you will use most. The full Docker course is ready now and picks up where the Docker lessons here stopped. Full courses on Linux and Shell, Git and GitHub and Kubernetes are coming soon.
And keep the habits that matter more than any tool: small changes, automate the second time, measure, learn without blame and share what you learn.
Congratulations on finishing DevOps Fundamentals!
# .github/workflows/release.yml: the capstone pipeline, from commit to production.
# test -> build and push one image -> deploy it to staging -> approval -> same image to production
name: Release
on:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: release
cancel-in-progress: false # never stop a deployment half-way
env:
IMAGE: ghcr.io/your-name/recipe-api # must be lowercase
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm audit --audit-level=high
image:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push to GitHub Container Registry
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v5
- id: tag
run: echo "tag=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.IMAGE }}:${{ steps.tag.outputs.tag }}
staging:
needs: image
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- name: Deploy over SSH (deploy.sh rolls back by itself if unhealthy)
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
HOST: ${{ vars.DEPLOY_HOST }}
KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
APP_IMAGE: ${{ env.IMAGE }}:${{ needs.image.outputs.tag }}
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh "deploy@$HOST" "APP_IMAGE=$APP_IMAGE /srv/recipe-api/deploy.sh"
- name: Smoke test through HTTPS, DNS and the proxy
run: curl -fsS --retry 10 --retry-delay 3 https://staging.example.com/health
production:
needs: [image, staging]
runs-on: ubuntu-latest
environment: # "production" has required reviewers: the run waits here
name: production
url: https://recipes.example.com
steps:
- name: Deploy over SSH (deploy.sh rolls back by itself if unhealthy)
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
HOST: ${{ vars.DEPLOY_HOST }}
KNOWN_HOSTS: ${{ vars.DEPLOY_KNOWN_HOSTS }}
APP_IMAGE: ${{ env.IMAGE }}:${{ needs.image.outputs.tag }}
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts
ssh "deploy@$HOST" "APP_IMAGE=$APP_IMAGE /srv/recipe-api/deploy.sh"
- name: Smoke test through HTTPS, DNS and the proxy
run: curl -fsS --retry 10 --retry-delay 3 https://recipes.example.com/health
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready