Skip to main content

๐Ÿš€ Weekend Project: Deploy & Monitor a Full-Stack App

All week you learned the pieces of getting software into the world โ€” cloud platforms, deployment strategies, infrastructure as code, DNS, and monitoring. This weekend you tie every thread into one continuous rope: the path from a Git repository to a live, monitored, HTTPS website that the whole internet can reach. You'll deploy the containerized app you built in Week 11 to a cloud host, attach a managed Postgres database, point a real domain at it with auto-renewing TLS, wire a CI/CD pipeline that tests and ships on every push to main, and stand up the monitoring that tells you โ€” before your users do โ€” when something breaks. By Sunday night, a git push will build, test, deploy, and go live on its own, and a dashboard will be watching it. This is the capstone where "I can containerize an app" becomes "I run it in production."

Week 13 · Weekend Project · Cloud & Deployment Capstone

๐ŸŽฏ Learning Objectives

By completing this project, you will be able to:

  • Deploy a containerized full-stack app to a cloud host or PaaS, backed by a managed database you provision rather than run yourself
  • Attach a custom domain with the correct DNS records and serve it over HTTPS with auto-renewing TLS certificates
  • Build a CI/CD pipeline in GitHub Actions that builds, tests, and deploys on every push to main, and blocks the deploy when tests fail
  • Instrument the app with a real /healthz and /readyz health check and structured JSON logging that a log platform can search
  • Stand up a metrics dashboard and configure at least one alert that pages you when latency, error rate, or uptime crosses a threshold
  • Rehearse a rollback so that recovering from a bad deploy is a documented, one-command drill โ€” not a panic

Estimated Time: 6โ€“10 hours across the weekend

Project: A live full-stack app at https://yourdomain.com, deployed automatically from GitHub, backed by managed Postgres, watched by a dashboard and one working alert, with a rollback you have actually performed once.

In This Project

The Goal

Take the containerized app from Week 11 and make it real. Right now it runs on your laptop with one docker compose up. This weekend it gets an address the world can type into a browser, a lock icon next to that address, a database that a cloud provider keeps alive and backed up for you, a robot that redeploys it every time you push, and a pair of eyes โ€” a dashboard and an alert โ€” that never blink. The finish line is a URL you can text to a friend and a pipeline that turns git push into a live release with no manual steps.

The through-line of the whole week has been one idea: the path from repo to production should be automated, observable, and reversible. Automated so humans don't hand-copy files to servers at midnight. Observable so you learn about failures from a graph, not from an angry user. Reversible so a bad release is a thirty-second rollback, not an outage. Every stage below serves one of those three words.

๐Ÿ“– Why a PaaS instead of raw servers

You could rent a bare Linux VM, install Docker, configure Nginx and certbot, and wire it all together by hand โ€” and one day you may want to. But for a first production deployment, a Platform-as-a-Service (Render, Railway, Fly.io, Google Cloud Run, and the like) does the undifferentiated heavy lifting: it builds your image, runs it with health checks, renews TLS certificates, streams logs, and exposes metrics โ€” all from a small config file committed to your repo. You focus on your app and its pipeline instead of babysitting an operating system. This project uses a PaaS as the primary path; the concepts (managed DB, DNS, TLS, CI/CD, monitoring, rollback) transfer to any provider, including the AWS route the module also covered.

Prerequisites

This is the Week 13 capstone. It assumes the cloud and DevOps ideas you built up all week, plus the container work from Week 11 and the CI groundwork from Week 12. Before you start, be comfortable with:

  • A containerized full-stack app โ€” the React client + Express API + Postgres stack you wrapped in Docker in Week 11, or your own equivalent, with a working Dockerfile per service
  • Environment variables & secrets โ€” config lives in the environment, never in committed code (Week 7 and Week 11)
  • Git & GitHub โ€” branches, pushing to main, and where repository Secrets live in a repo's settings
  • The basics of CI โ€” what a GitHub Actions workflow, job, and step are (earlier this module and Week 12)
  • DNS fundamentals โ€” what A, CNAME, and ALIAS/ANAME records do, and that DNS changes take time to propagate (the domain-configuration lesson right before this one)
  • HTTP basics โ€” status codes, headers, and why HTTPS matters

You'll also need a few accounts, all with free tiers big enough for this project: a cloud PaaS account, a domain name you control (a cheap .dev or .app is perfect and forces HTTPS), and your existing GitHub repo. Confirm your local toolbelt first:

# Git and the GitHub CLI (handy for setting repo secrets from the terminal)
git --version
gh --version

# Docker, to build and sanity-check the production image locally before shipping
docker --version

โš ๏ธ Buy the domain, don't just wish for one

The domain step blocks on DNS propagation, which can take minutes to an hour. Register your domain first thing Saturday morning so the records have time to settle while you work on everything else. A .dev or .app TLD is on the browser's HSTS preload list, meaning browsers refuse to load it over plain HTTP โ€” a small nudge that keeps you honest about TLS.

Required Features Checklist

These are the non-negotiables. Each maps to a stage below. Tick them off as you go โ€” the rubric at the end grades against this same list.

โœ… Must-have features

  • โ˜ Deployed to the cloud โ€” the containerized app runs on a cloud host/PaaS, reachable at a public URL
  • โ˜ Managed database โ€” a provider-managed Postgres instance (not a container you babysit), reached over a private connection string
  • โ˜ Custom domain + DNS โ€” your own domain resolves to the app via the correct DNS records
  • โ˜ HTTPS with auto-renewing TLS โ€” the site serves over HTTPS with a certificate that renews itself; HTTP redirects to HTTPS
  • โ˜ CI/CD pipeline โ€” a GitHub Actions workflow that builds + tests + deploys on every push to main, and fails the deploy when tests fail
  • โ˜ Health checks โ€” a liveness route and a readiness route the platform uses to route traffic and restart the app
  • โ˜ Structured logging โ€” JSON logs with levels and request context, shipped to a searchable log view
  • โ˜ Metrics dashboard โ€” a dashboard showing request rate, latency, error rate, and resource use
  • โ˜ At least one alert โ€” a notification that fires when error rate, latency, or uptime crosses a threshold
  • โ˜ A documented rollback plan โ€” written steps you have actually run once to revert to the previous release

The Deployed Architecture

Here is the shape of what you're building. A user's browser hits your domain; DNS points it at the platform's edge, which terminates TLS and routes to your running container; the app talks to a managed Postgres over a private network; and a monitoring layer watches every piece, escalating to an alert when a threshold trips. The GitHub repo feeds the whole thing through CI/CD.

flowchart TB User["User browser"] -->|"HTTPS to yourdomain.com"| DNS["DNS provider"] DNS --> Edge["Platform edge
TLS termination + HTTP to HTTPS redirect"] Edge --> App["App container
Nginx + React + Express API"] App -->|"private connection string"| DB[("Managed Postgres
backups + failover")] App -->|"JSON logs + metrics"| Mon["Monitoring
dashboard + log search"] Mon -->|"threshold crossed"| Alert["Alert
email or Slack or PagerDuty"] Repo["GitHub repo"] -->|"push to main"| CI["CI/CD pipeline"] CI -->|"build, test, deploy"| App

Two properties make this a production topology rather than a demo. First, the database is managed and private: the provider handles backups, patching, and failover, and the connection string is only reachable from inside the platform's network โ€” never exposed to the open internet. Second, deployment is one-directional and automated: code flows from the repo through CI/CD into the running app, and the running app never becomes the source of truth. If the server disappears, you rebuild it from the repo, not from a snapshot of a hand-edited box.

The monitoring layer is not an afterthought bolted on at the end โ€” it's a first-class edge in this diagram. Every request the app serves emits a structured log line and contributes to a metric, and those feed a dashboard and, above a threshold, an alert. You're building the nervous system alongside the muscles.

Project & Infra Structure

You already have the app. This weekend you add a thin layer of infrastructure and pipeline config beside it โ€” a platform manifest, a deploy workflow, and a little logging and health-check code. Everything is committed to Git, because the setup instructions are the code now.

fullstack-app/
โ”œโ”€โ”€ compose.yaml                <-- from Week 11, still used for local dev
โ”œโ”€โ”€ render.yaml                 <-- platform manifest: services, DB, health path, env
โ”œโ”€โ”€ client/                     <-- React (Vite) SPA + its Dockerfile
โ”œโ”€โ”€ server/                     <-- Express API + its Dockerfile
โ”‚   โ””โ”€โ”€ src/
โ”‚       โ”œโ”€โ”€ server.js           <-- app entry
โ”‚       โ”œโ”€โ”€ logger.js           <-- structured JSON logging (pino)
โ”‚       โ””โ”€โ”€ routes/
โ”‚           โ””โ”€โ”€ health.js       <-- /healthz (liveness) + /readyz (readiness)
โ”œโ”€โ”€ .github/
โ”‚   โ””โ”€โ”€ workflows/
โ”‚       โ””โ”€โ”€ deploy.yml          <-- CI/CD: build + test + deploy on push to main
โ”œโ”€โ”€ monitoring/
โ”‚   โ””โ”€โ”€ alerts.md               <-- what each alert means + how to respond (runbook)
โ”œโ”€โ”€ docs/
โ”‚   โ””โ”€โ”€ ROLLBACK.md             <-- the rollback plan you will rehearse
โ”œโ”€โ”€ .env.example                <-- template of required env vars (committed)
โ””โ”€โ”€ README.md                   <-- how to deploy, links to the live URL + dashboard

๐Ÿ’ก The platform manifest is your infra as code

A single file like render.yaml (Render), fly.toml (Fly.io), or railway.json (Railway) describes your services, their managed database, the health-check path, and the environment variables โ€” declaratively. Commit it and your infrastructure becomes reviewable, diff-able, and reproducible: a teammate can recreate the entire environment from the repo. That's the same "the file is the setup" principle Docker Compose gave you, now applied to the cloud. The full Terraform route from the module lecture is the more powerful version of this same idea; the manifest is the on-ramp.

Stage 1 โ€” Provision the Infrastructure

Before code can go live, the place it lives has to exist. In PaaS terms, "provisioning" means declaring your services and their managed database in the platform manifest, then letting the platform create them. Here's a Render-style render.yaml that declares a web service built from your Dockerfile and a managed Postgres beside it.

# render.yaml โ€” declarative infrastructure for the whole app.
services:
  - type: web
    name: fullstack-app
    runtime: docker
    dockerfilePath: ./server/Dockerfile
    dockerContext: ./server
    plan: free
    # The platform hits this path to decide if the container is healthy.
    healthCheckPath: /healthz
    autoDeploy: false            # CI drives deploys, not the platform's git hook
    envVars:
      - key: NODE_ENV
        value: production
      - key: PORT
        value: "3000"
      # Pull the DB connection string straight from the managed database below.
      - key: DATABASE_URL
        fromDatabase:
          name: fullstack-db
          property: connectionString
      - key: LOG_LEVEL
        value: info

databases:
  - name: fullstack-db
    plan: free
    databaseName: appdb
    user: appuser

Read that top to bottom and it's a full environment: one web service built from a Dockerfile, wired to a managed Postgres whose connection string is injected as DATABASE_URL. No password is written in the file โ€” the platform generates the database credentials and hands them to the service through fromDatabase, so secrets never touch Git.

๐Ÿ“– What "managed" buys you

A managed database is one the provider operates for you: it lives on their hardware, they take automated backups, apply security patches, and can fail over to a standby if the primary dies. You get a connection string; they get the pager. Compare that to running postgres as a container you own โ€” fine for local dev (that's exactly what compose.yaml still does), but in production you do not want to be the person restoring a corrupted database at 3 a.m. Provisioning a managed DB is often the single highest-leverage decision in a deployment.

Commit the manifest, connect the repo to the platform once through its dashboard, and it reads render.yaml to create the service and database. On other platforms the file differs but the move is identical โ€” declare, commit, let the platform build:

# Fly.io equivalent: a guided init writes fly.toml, then attach a managed Postgres
fly launch --no-deploy          # generates fly.toml from your Dockerfile
fly postgres create             # provision a managed Postgres cluster
fly postgres attach fullstack-db  # injects DATABASE_URL into the app's secrets

Stage 2 โ€” Deploy the App + Managed DB

Infrastructure exists; now put the app on it and make sure it can talk to the database. Two things have to be true before you call this stage done: the container starts and answers a health check, and it connects to the managed Postgres and runs its migrations.

A real health check the platform can trust

The platform decides whether to route traffic to your container โ€” and whether to restart it โ€” by polling a health-check URL. A good app exposes two probes. Liveness (/healthz) answers "is the process alive?" and must never touch the database, or a slow query could get a healthy app killed. Readiness (/readyz) answers "can I actually serve requests right now?" and does check dependencies like the DB, so traffic is held back until they're reachable.

// server/src/routes/health.js
import { Router } from 'express';
import { pool } from '../db.js';   // your pg connection pool

const router = Router();

// Liveness: cheap, dependency-free. If this fails, restart the container.
router.get('/healthz', (req, res) => {
  res.status(200).json({ status: 'ok', uptime: process.uptime() });
});

// Readiness: verify the app can reach its dependencies before taking traffic.
router.get('/readyz', async (req, res) => {
  try {
    await pool.query('SELECT 1');            // can we reach Postgres?
    res.status(200).json({ status: 'ready' });
  } catch (err) {
    // 503 tells the load balancer to stop sending traffic until we recover.
    res.status(503).json({ status: 'not-ready', reason: 'database unreachable' });
  }
});

export default router;

Run migrations as part of the release, not by hand

A fresh managed database is empty. Your schema has to be applied before the app can serve real data โ€” and it must happen automatically on every deploy so the database and the code never drift apart. Run migrations in a release step (your CI does this in Stage 4) or as the container's pre-start command:

# Apply pending migrations against the managed DB, then start the server.
# DATABASE_URL is injected by the platform from the managed database.
npm run migrate:deploy && node src/server.js

โš ๏ธ Never point liveness at the database

A tempting mistake is to make /healthz run SELECT 1 so it "checks everything." Don't. If the database has a momentary hiccup, a liveness check that depends on it will report the app as dead, and the platform will restart a perfectly healthy container โ€” turning a small DB blip into a restart storm. Keep liveness about the process and readiness about dependencies. That separation is why platforms give you two hooks.

Deploy, then confirm the app is up and connected:

# Once deployed, both probes should answer over the platform URL:
curl https://fullstack-app.onrender.com/healthz
# => {"status":"ok","uptime":8.4}

curl https://fullstack-app.onrender.com/readyz
# => {"status":"ready"}   โ† proves the app reached managed Postgres

Stage 3 โ€” Custom Domain + HTTPS

The app answers at an ugly platform subdomain. Time to give it your own name and a padlock. This is where the DNS knowledge from the previous lesson pays off. The move has two halves: tell the platform that you own the domain, and tell DNS to send that domain to the platform.

Add the domain on the platform, then point DNS at it

In the platform's dashboard, add yourdomain.com (and usually www.yourdomain.com) as a custom domain. The platform gives you a target to point at. At your DNS provider, create the matching records:

RecordHostPoints toWhy
ALIAS / ANAME@ (apex)fullstack-app.onrender.comThe bare domain โ€” apex can't use a CNAME, so use ALIAS/ANAME (or the platform's A record)
CNAMEwwwfullstack-app.onrender.comThe www subdomain points at the platform's hostname

DNS changes are not instant โ€” they propagate as caches expire. Watch the record resolve from your machine while you wait:

# Follow the domain until it resolves to the platform's edge.
dig +short yourdomain.com
dig +short www.yourdomain.com CNAME

# Confirm HTTPS once the certificate is issued (should be a clean 200, not a cert error):
curl -I https://yourdomain.com

TLS that renews itself

Once DNS points at the platform and it verifies you control the domain, the platform automatically requests a certificate from Let's Encrypt and installs it โ€” usually within a minute or two. Crucially, it also renews that certificate on its own before it expires (Let's Encrypt certs last 90 days), so you never wake up to an expired-certificate outage. It also sets up the HTTP โ†’ HTTPS redirect for you, so a visitor typing http:// lands safely on https://.

โœ… What auto-renewing TLS actually saves you

Before automated certificates, expired TLS was one of the most common self-inflicted outages: a cert quietly lapsed and every visitor got a scary browser warning. Auto-renewal โ€” whether the platform does it or you run certbot on a timer on your own server โ€” removes that entire failure mode. Verify it once by checking the certificate's expiry, then trust the machine to keep it fresh:

# Inspect the live certificate's validity dates.
echo | openssl s_client -connect yourdomain.com:443 -servername yourdomain.com 2>/dev/null \
  | openssl x509 -noout -issuer -dates
# issuer=... Let's Encrypt ...   notAfter=... about 90 days out, auto-renewed

Stage 4 โ€” CI/CD That Ships on Push

So far you've deployed by clicking. That doesn't scale and it isn't repeatable. The heart of this project is a pipeline: push to main, and a robot builds the image, runs the tests, and โ€” only if the tests pass โ€” deploys. A red test suite must block the release. That single guarantee is what lets a team ship many times a day without fear.

Here is the shape of the pipeline before the code โ€” worth internalizing, because every CI/CD system is a variation on it:

sequenceDiagram participant Dev as Developer participant GH as GitHub participant CI as GitHub Actions participant Plat as Cloud platform participant Live as Live site Dev->>GH: Push commit to main GH->>CI: Trigger the deploy workflow CI->>CI: Install dependencies and run the test suite Note over CI: If any test fails, stop here and mark the run red CI->>CI: Build the production Docker image CI->>Plat: Trigger a deploy of the new image Plat->>Plat: Roll out and wait for the health check to pass Plat-->>Live: Route traffic to the new release Plat-->>Dev: Report success or failure

Notice the gate in the middle: tests run before the build, and a failure halts the whole run. Now the workflow. Create .github/workflows/deploy.yml:

name: Build, Test & Deploy

on:
  push:
    branches: [ main ]

# Cancel an in-flight run if a newer commit lands, so only the latest ships.
concurrency:
  group: deploy-main
  cancel-in-progress: true

jobs:
  test:
    name: Install and test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
          cache-dependency-path: server/package-lock.json

      - name: Install dependencies
        working-directory: server
        run: npm ci

      - name: Run the test suite
        working-directory: server
        run: npm test        # a red suite fails the job and blocks deploy

  deploy:
    name: Build image and deploy
    needs: test              # only runs if the test job passed
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build the production image
        run: docker build -t fullstack-app:${{ github.sha }} ./server

      # Trigger the platform's deploy. The deploy hook URL is stored as a
      # repository secret, never committed to the workflow file.
      - name: Deploy to the cloud platform
        run: curl -fsSL -X POST "${{ secrets.RENDER_DEPLOY_HOOK_URL }}"

      - name: Report the deployed version
        run: echo "Deployed commit ${{ github.sha }} to production"

The needs: test line is the whole point: the deploy job refuses to run unless test succeeded. The concurrency block prevents two deploys from racing when you push twice quickly. And the deploy hook lives in repository secrets, not in the file โ€” set it once from the terminal:

# Store the platform's deploy hook URL as an encrypted repo secret.
gh secret set RENDER_DEPLOY_HOOK_URL --body "https://api.render.com/deploy/srv-xxxx?key=yyyy"

๐Ÿ’ก Build once, deploy the same artifact

Tagging the image with ${{ github.sha }} โ€” the exact commit โ€” means every release is traceable to a line of code, and the artifact you tested is the artifact you ship. Mature pipelines take this further: build the image, push it to a registry, and have the platform deploy that specific digest, so testing, staging, and production all run byte-for-byte identical bits. Even at this smaller scale, tying the image to the commit SHA is the habit that makes "which version is live?" a question with an exact answer.

Stage 5 โ€” Monitoring, Logging & an Alert

An app you can't see inside of is an app you're flying blind. This stage gives you three senses: logs (what happened, in detail), metrics (how much and how fast, over time), and alerts (a tap on the shoulder when something's wrong). You want all three before you have real users, not after your first outage.

Structured logging you can actually search

Plain console.log("user logged in") is invisible at scale โ€” you can't filter or aggregate free-form text. Structured logging emits each event as JSON with a level, a timestamp, and named fields, so a log platform can search "show me every error for user 42 in the last hour." Use pino, the fast standard for Node:

// server/src/logger.js
import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  // Redact anything sensitive so tokens never land in your logs.
  redact: ['req.headers.authorization', 'req.headers.cookie'],
  formatters: {
    level: (label) => ({ level: label }),   // human-readable level names
  },
});
// server/src/server.js โ€” attach a request logger with a correlation id.
import { logger } from './logger.js';
import { randomUUID } from 'node:crypto';

app.use((req, res, next) => {
  req.id = req.headers['x-request-id'] || randomUUID();
  const child = logger.child({ reqId: req.id, method: req.method, path: req.path });
  res.on('finish', () => {
    child.info({ status: res.statusCode, ms: Date.now() - req.startTime }, 'request completed');
  });
  req.startTime = Date.now();
  req.log = child;
  next();
});

Each request now produces a searchable JSON line with a correlation id that ties every log for one request together โ€” priceless when you're chasing a single user's failing session through thousands of events. Your platform captures stdout automatically, so these lines flow straight into its log viewer with no extra shipping code.

A structured log line (what the platform stores)

{"level":"info","time":1719800000000,"reqId":"a1b2-c3d4","method":"GET",
 "path":"/api/tasks","status":200,"ms":37,"msg":"request completed"}

A metrics dashboard

Metrics are numbers over time: request rate, latency percentiles, error rate, CPU, and memory. Your platform ships a built-in dashboard for CPU/memory/response-time out of the box โ€” pin it and know what "normal" looks like. To watch application-level signals, expose a /metrics endpoint in Prometheus format and point a hosted Grafana (or the platform's own metrics) at it:

// server/src/metrics.js โ€” expose app metrics for a dashboard to scrape.
import client from 'prom-client';

client.collectDefaultMetrics();   // process CPU, memory, event-loop lag

export const httpDuration = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP request latency in seconds',
  labelNames: ['method', 'route', 'status'],
  buckets: [0.05, 0.1, 0.3, 0.5, 1, 2, 5],
});

export function metricsHandler(req, res) {
  res.set('Content-Type', client.register.contentType);
  client.register.metrics().then((data) => res.end(data));
}

Track the four signals that catch most incidents โ€” often called the "golden signals": latency, traffic, errors, and saturation (how full your resources are). A dashboard with those four panels tells you at a glance whether the app is healthy.

At least one alert that reaches you

A dashboard only helps when someone's looking at it. An alert watches a metric for you and notifies you the moment it crosses a line. Configure at least one โ€” a high 5xx error rate is the highest-value first alert. In your monitoring tool (the platform's alerting, Grafana, Better Stack, and the like), the rule reads like this:

# A Prometheus-style alert rule: page us when the app starts erroring.
groups:
  - name: fullstack-app
    rules:
      - alert: HighErrorRate
        # More than 5 percent of requests are 5xx over the last five minutes.
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[5m]))
            / sum(rate(http_requests_total[5m])) > 0.05
        for: 5m
        labels:
          severity: page
        annotations:
          summary: "Error rate above 5 percent for 5 minutes"
          runbook: "See monitoring/alerts.md โ€” check recent deploy, then roll back if needed"

Wire the alert's notification channel to somewhere you'll actually see it โ€” email, a Slack channel, or PagerDuty. Then write down what to do when it fires, so a 2 a.m. page is a checklist, not a puzzle:

# monitoring/alerts.md โ€” the runbook

## HighErrorRate (5xx > 5% for 5m)
1. Open the metrics dashboard โ€” is latency also up? Is it one route or all?
2. Check the logs for the correlation ids on the failing requests.
3. Did a deploy land in the last 30 minutes? If yes, ROLL BACK first (docs/ROLLBACK.md),
   investigate second. Reverting is faster than debugging under fire.
4. If it is the database, check the managed DB's status page and connection count.

โš ๏ธ An alert nobody sees is decoration

The most common monitoring failure isn't too few alerts โ€” it's alerts that fire into a channel no one watches, or that cry wolf so often people mute them. Start with one high-signal alert (server errors) wired to a place you can't ignore, and prove it works by deliberately triggering it (Stage 6). One trustworthy alert beats ten you've learned to tune out.

Stage 6 โ€” Verify & Run a Rollback Drill

A deployment isn't done when it's green โ€” it's done when you've proven the whole system, including the parts that only matter when things go wrong. Two verifications close out the weekend: an end-to-end smoke test of the live site, and a real rollback drill.

End-to-end smoke test

Walk the entire path a user takes, on the real domain:

โœ… Live-site checklist

  • โ˜ https://yourdomain.com loads over HTTPS with a valid certificate and no mixed-content warnings
  • โ˜ http://yourdomain.com redirects to https://
  • โ˜ A real user action (create/read/update/delete a record) works against the managed database
  • โ˜ /healthz and /readyz both return 200 on the live URL
  • โ˜ A test request appears as a structured JSON line in the log viewer, with its correlation id
  • โ˜ The dashboard shows the request you just made in its traffic and latency panels
  • โ˜ Pushing a commit to main triggers CI, runs tests, and deploys automatically
  • โ˜ Deliberately failing a test in a branch blocks its deploy (the gate works)

Prove the alert fires

An untested alert is a guess. Trigger it on purpose โ€” add a temporary route that throws, hit it a few times, and confirm the notification actually lands where you expect. Then remove the route.

# Generate a burst of 5xx responses to trip the HighErrorRate alert.
for i in $(seq 1 50); do curl -s -o /dev/null https://yourdomain.com/api/boom; done
# โ†’ within a few minutes you should receive the alert notification

The rollback drill โ€” the most important five minutes

Every deploy carries the risk of shipping a bug. What separates a calm team from a scrambling one is a rehearsed rollback: the ability to return to the last known-good release quickly and predictably. Write it down, then actually do it once so you know the steps work before you need them under pressure.

# docs/ROLLBACK.md โ€” the rollback plan

## When to roll back
Error rate or latency spikes right after a deploy, or /readyz starts failing.
Roll back FIRST, debug SECOND โ€” reverting is faster than fixing live.

## Option A โ€” platform one-click (fastest)
Open the platform dashboard โ†’ Deploys โ†’ select the previous successful
deploy โ†’ "Redeploy" / "Rollback". Traffic returns to the old image in under a minute.

## Option B โ€” revert the commit (keeps Git as source of truth)
git revert <bad-sha>      # creates a new commit that undoes the bad one
git push origin main      # CI re-runs tests and deploys the reverted code

## After rolling back
1. Confirm /healthz, /readyz, and a real user action all pass on the live site.
2. Confirm the error-rate alert has cleared on the dashboard.
3. Open an issue with the failing commit + logs so the fix goes through CI next time.

Run Option A right now as a drill: deploy a trivial visible change (say, a heading text tweak), confirm it's live, then roll back to the previous deploy and confirm the old version returns. Time it. That number โ€” how long it takes you to undo a release โ€” is one of the most reassuring facts you can know about your own system.

๐Ÿ“– Why "roll back first, debug second"

Under a live incident, your instinct is to find and fix the bug. Resist it. Every minute spent debugging is a minute your users are hitting errors. Rolling back to the last good release stops the bleeding immediately and buys you a calm environment to diagnose in. The bug will still be there in the reverted commit, waiting for you to fix it properly and re-ship through the pipeline. Fast recovery beats fast diagnosis โ€” that's the core of the discipline the industry calls Site Reliability Engineering.

Stretch Goals

Required build done with time to spare? Push it toward how a seasoned team runs things. None of these are needed to pass the rubric โ€” pick whatever excites you.

  • ๐Ÿ—๏ธ Infrastructure as Code with Terraform โ€” replace the platform manifest with Terraform so the entire environment (service, managed DB, DNS, alerts) is declared, versioned, and reproducible with terraform apply
  • ๐Ÿ”ต๐ŸŸข Blue-green or canary deploys โ€” ship the new release alongside the old and shift traffic gradually, so a bad deploy affects a slice of users instead of everyone before you roll back
  • ๐Ÿ“ˆ Autoscaling โ€” configure the platform to add and remove instances based on CPU or request rate, so the app rides out a traffic spike without falling over
  • ๐ŸŸฉ A public status page โ€” publish an uptime/status page (via the platform, Better Stack, or a static status.yourdomain.com) so users can self-serve "is it down?"
  • ๐Ÿ” Secret management โ€” move secrets into a dedicated store (the platform's secrets, Doppler, or AWS Secrets Manager) and rotate the database password
  • ๐Ÿงช Smoke tests in the pipeline โ€” after deploy, have CI curl /readyz and one real endpoint on the live URL, and auto-roll-back if they fail
  • ๐Ÿ’ธ Cost & budget alerts โ€” set a spend alert on the cloud account so a runaway resource can't quietly drain your wallet

IaC starter โ€” the same infra, in Terraform

# A taste of the Terraform shape (HCL). The whole environment as code,
# reviewable in a pull request and reproducible with one command.
resource "render_web_service" "app" {
  name           = "fullstack-app"
  runtime        = "docker"
  dockerfile     = "./server/Dockerfile"
  health_check   = "/healthz"
  auto_deploy    = false
}

resource "render_postgres" "db" {
  name = "fullstack-db"
  plan = "free"
}
# terraform plan  โ†’ preview changes
# terraform apply โ†’ create/update the real infrastructure

Self-Check Rubric

Before you call this done, grade yourself. Aim to answer "yes" to everything in the required column โ€” the stretch column is bonus. This is your capstone, so be honest: a "yes" means you verified it on the live site, not that you intended to.

AreaMeets expectations (required)Exceeds (stretch)
Deployment Containerized app runs on a cloud PaaS at a public URL; config comes from environment variables Built image pushed to a registry and deployed by digest; multi-region
Managed database Provider-managed Postgres, reached over a private connection string; migrations run on deploy Automated backups verified by a restore test; read replica
Domain & DNS Custom domain resolves to the app via correct DNS records www and apex both work; DNS managed in IaC
HTTPS / TLS Site serves over HTTPS with an auto-renewing cert; HTTP redirects to HTTPS HSTS enabled; TLS 1.3 only; cert expiry monitored
CI/CD Push to main builds, tests, and deploys; a red suite blocks the deploy Post-deploy smoke test with auto-rollback; image tagged by SHA
Health checks Separate liveness (/healthz) and readiness (/readyz); platform uses them to route and restart Readiness gates the load balancer; graceful shutdown on SIGTERM
Logging Structured JSON logs with levels and a correlation id, searchable in a log view Log retention + a saved query dashboard; sensitive fields redacted
Metrics & alerts Dashboard shows traffic, latency, errors, and resources; at least one alert fires and reaches you Golden-signal panels; alert tuned to avoid noise; on-call channel
Rollback Written rollback plan you have actually run once to revert to the previous release Blue-green/canary so rollback is instant and blast radius is small

๐Ÿงช Final go-live checklist

  • โ˜ The app is live at https://yourdomain.com with a valid, auto-renewing certificate
  • โ˜ A managed Postgres backs the app over a private connection; migrations ran on deploy
  • โ˜ Pushing to main runs tests and auto-deploys; a failing test blocks the release
  • โ˜ /healthz and /readyz both answer 200 on the live URL
  • โ˜ Structured JSON logs are searchable, and the metrics dashboard shows live traffic
  • โ˜ At least one alert has fired in a test and reached your notification channel
  • โ˜ You have performed a rollback drill and know how long it takes
  • โ˜ The README links the live URL, the dashboard, and docs/ROLLBACK.md

Summary

๐ŸŽ‰ What You Built

  • A live, public full-stack app deployed to a cloud PaaS and backed by a managed Postgres you provisioned rather than operate
  • A custom domain with correct DNS and auto-renewing HTTPS, with plain HTTP safely redirected to secure HTTPS
  • A CI/CD pipeline that turns every push to main into a tested, automatic release โ€” and refuses to ship when tests are red
  • Real observability: separate liveness and readiness health checks, structured JSON logs with correlation ids, a metrics dashboard, and an alert you proved actually fires
  • A rehearsed rollback plan โ€” so a bad deploy is a calm, timed drill instead of an emergency

This is the capstone that ties Week 13 together. You didn't learn deployment, DNS, TLS, pipelines, and monitoring as separate trivia โ€” you connected them into one continuous, automated path from git push to a live site the world can reach and you can watch. Every technique the week introduced showed up here as a working part of a whole: the container from Week 11, the tests from Week 12, and this week's cloud, DNS, and monitoring skills, wired together so the system is automated (the pipeline ships it), observable (the dashboard and alert watch it), and reversible (the rollback undoes it). That triad is what "running software in production" actually means.

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

Your app is one deployable unit โ€” a single service that does everything. That's exactly the right choice for now, and it will carry you a long way. But as systems and teams grow, that one unit can become a bottleneck: every change redeploys everything, and one component's load drags on the rest. Week 14 opens the door to the alternative. The next lesson, Microservices Principles, examines when and why teams split a monolith into independently deployable services โ€” and the real costs that come with that power. You now know how to deploy and monitor one service well; next you'll learn how those same skills scale to many.

๐ŸŽ‰ You finished Week 13!

You took an app from a repo to a live, monitored, HTTPS site that redeploys itself. Put the URL on your rรฉsumรฉ โ€” you can honestly say you run software in production.