Reseller Platform Reliability Lab

Build a local reliability lab with CI, IaC validation, and observability.

Introduction

30 Second Summary

A digital service can look fine until the moment it stops responding. The real test is whether you can restore it with clear evidence of what happened.

In this project, you will build a local reliability lab around a reseller status API. Its dashboard turns a planned service failure into visible recovery evidence.

What You'll Build

Picture your Grafana dashboard returning to green after a recovery script clears a planned service failure.

By the end of this project, you'll have:

  • A local health endpoint that returns healthy service data from a container you control.
  • A green GitHub Actions run that builds your Docker image before validating your Terraform design.
  • A complete incident response record that traces a firing alert from detection through recovery.
  • Secret Mission: A second Prometheus alert that detects a controlled burst of reseller-platform requests.

Are there any prerequisites?

You'll need a Linux computer with sudo access.

An AWS account plus a GitHub account complete the prerequisites.

Before We Start

This project turns a local reseller platform into evidence of how you detect service failures and recover from them without creating cloud charges. Before the hands-on work begins, lock in your purpose for building this reliability lab.

Prepare Your Linux DevOps Workstation

Every later result depends on the same workstation. An unverified tool can make a valid build or infrastructure design look broken.

You will use Docker Engine to run containers. Docker Compose will coordinate the services later in the lab.

Git will track your work. Terraform will validate your Infrastructure as Code design.

AWS CLI will support future AWS workflows. Visual Studio Code will give you one place to edit the lab files.

In this step, get ready to:
  • Install Docker Engine with the Docker Compose plugin.
  • Install Git, Terraform CLI 1.17.1, and AWS CLI v2.
  • Install Visual Studio Code and verify every workstation tool.
Install Docker Engine and Docker Compose

Docker's convenience script installs the local container tooling used throughout this lab. You will inspect the script's proposed changes before allowing it to install anything.

  • Download the official Docker installation script by running this command:
curl -fsSL https://get.docker.com -o get-docker.sh

What does this command do?

The command downloads Docker's installation script into a local file named get-docker.sh. The silent options still show an error if the download fails.

  • Preview the installation steps without changing your computer by running:
sudo sh ./get-docker.sh --dry-run

Why preview the script?

The dry run prints the package commands that the installer plans to use. This gives you a chance to inspect a privileged script before it changes the system.

You should see a list of proposed Docker package installation commands. Your workstation remains unchanged after this preview.

  • Install Docker Engine with the Compose plugin by running:
sudo sh get-docker.sh

What happens during installation?

The script detects your Linux distribution. It installs Docker Engine plus the supporting command-line components for a local development environment.

The installation output should finish without a package error. Docker is now present on your workstation.

Did the Docker installation stop?

Check that your Linux distribution is supported by Docker Engine. Confirm that your account can run commands with sudo access.

If the download failed, check your network connection before running the download command again.

Ask for help with your exact Linux distribution and the final lines of output: help me troubleshoot my Docker Engine installation

  • On RPM-based Linux, enable Docker at startup by running:
sudo systemctl enable --now docker

What does this service command do?

The command starts Docker now. It also enables Docker to start when your Linux computer boots.

The next permission change is powerful. Membership in the Docker group grants root-level privileges through the Docker daemon.

  • Add your current user to the Docker group by running:
sudo usermod -aG docker $USER

What does group membership change?

The command adds your current user to the Docker group. This lets you run Docker commands without placing sudo before each one.

  • Refresh your group membership in the current terminal by running:
newgrp docker

Why refresh the terminal group?

Your existing shell started before the group change. This command starts a shell that recognizes the new Docker group membership.

Before you test Docker, do you expect the container to run without sudo access?

  • Test Docker Engine by running this command:
docker run hello-world

What does the Docker test prove?

Docker downloads a small test image. It starts a container that confirms the engine can pull images and run containers.

The first run may pause while Docker downloads the test image. Give it a couple of minutes before treating the pause as a failure.

You should see Hello from Docker! in the terminal. That message proves your user can reach the running Docker Engine.

  • Verify the Docker Compose plugin by running:
docker compose version

What does this verification prove?

The command asks the Docker CLI for its Compose plugin version. A printed version confirms that later multi-service commands are available.

You should see a Docker Compose version in the terminal. Your local container toolchain is ready.

Docker command still unavailable?

Start a new terminal session if the current shell still lacks the updated group membership. On RPM-based Linux, confirm that the Docker service command completed successfully.

Ask for help with the non-sensitive terminal output: help me fix Docker access after joining the Docker group

Install Git, Terraform, and AWS CLI

These command-line tools cover three different parts of the workflow. Git records changes, Terraform validates the AWS design, and AWS CLI prepares your workstation for future AWS operations.

Choose the Git command that matches your Linux distribution.

Debian or Ubuntu

Your package manager may ask you to approve the listed packages before installation begins.

  • Install Git from your distribution package repository by running:
  • Confirm the installation if your package manager asks.
sudo apt-get install git

What does this command install?

The package manager installs Git from the configured Debian or Ubuntu repositories. The completed output confirms that the Git command is available.

Fedora

Your package manager may ask you to approve the listed packages before installation begins.

  • Install Git from your distribution package repository by running:
  • Confirm the installation if your package manager asks.
sudo dnf install git

What does this command install?

The package manager installs Git from the configured Fedora repositories. The completed output confirms that the Git command is available.

This project requires Terraform CLI 1.17.1. Check your current state before choosing an installation path.

  • Check whether Terraform is already installed by running:
terraform -version

What does the version check tell you?

The output identifies the Terraform CLI version available in your terminal. Use the matching tab below to reach the required version.

✔️ I see the required Terraform version

Your output includes Terraform CLI 1.17.1. Continue to the AWS CLI installation below.

ⓧ I see an older Terraform version

Your current Terraform version is older than the project requirement. Install the current package from HashiCorp's official repository for your distribution.

Debian or Ubuntu

  • Add HashiCorp's signing key and package repository by running these commands:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
What do these commands do?
  • The first command stores HashiCorp's package-signing key.
  • The second command adds the official HashiCorp package source for your distribution.
  • The final command refreshes package information before installing Terraform.

Fedora

  • Add HashiCorp's Fedora repository and install Terraform by running these commands:
wget -O- https://rpm.releases.hashicorp.com/fedora/hashicorp.repo | sudo tee /etc/yum.repos.d/hashicorp.repo
sudo dnf -y install terraform
What do these commands do?

The first command adds HashiCorp's official Fedora repository. The second command installs Terraform from that repository.

  • Confirm the upgraded Terraform version by running:
terraform -version

What should the version show?

The Terraform output should include 1.17.1. This confirms that the required CLI version is active.

ⓧ Terraform is unavailable

Terraform is not installed in your current environment. Install it from HashiCorp's official repository for your distribution.

Debian or Ubuntu

  • Add HashiCorp's signing key and package repository by running these commands:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
What do these commands do?
  • The first command stores HashiCorp's package-signing key.
  • The second command adds the official HashiCorp package source for your distribution.
  • The final command refreshes package information before installing Terraform.

Fedora

  • Add HashiCorp's Fedora repository and install Terraform by running these commands:
wget -O- https://rpm.releases.hashicorp.com/fedora/hashicorp.repo | sudo tee /etc/yum.repos.d/hashicorp.repo
sudo dnf -y install terraform
What do these commands do?

The first command adds HashiCorp's official Fedora repository. The second command installs Terraform from that repository.

  • Confirm the installed Terraform version by running:
terraform -version

What should the version show?

The Terraform output should include 1.17.1. This confirms that the required CLI version is active.

AWS CLI v2 gives your workstation the official AWS command-line interface. This step only installs the CLI, so no AWS resources are created.

  • Install AWS CLI v2 for all users by running:
curl -fsSL https://awscli.amazonaws.com/v2/install.sh | sudo bash -s -- --system

What does this installer do?

The command downloads the AWS CLI v2 installation script. It passes the script to a privileged Bash process for a system-wide installation.

The installer should complete with a successful result. Your terminal can now access the AWS CLI command.

Did a command-line tool fail to install?

Confirm that your distribution matches the package instructions you selected. Check the final package-manager output for a repository or network problem.

Ask for help without sharing AWS credentials: help me troubleshoot my Linux CLI installation

Install Visual Studio Code and verify the workstation

Visual Studio Code provides the editor used for the files in later steps. Microsoft maintains different repository instructions for Debian-based and Fedora-based Linux distributions.

Debian or Ubuntu

  • Use Microsoft's official Linux setup page to add the signing key and Debian repository.
  • Install Visual Studio Code after adding the repository by running:
sudo apt update && sudo apt install code

What does this command install?

The command refreshes the available Debian or Ubuntu packages. It installs Visual Studio Code from the Microsoft repository you added.

Fedora

  • Use Microsoft's official Linux setup page to add the Fedora repository.
  • Install Visual Studio Code after adding the repository by running:
sudo dnf install code

What does this command install?

The package manager installs Visual Studio Code from the Microsoft repository. The completed output confirms that its terminal command is available.

Your final check tests every tool from the same terminal. Before you run it, which command do you think is most likely to reveal an incomplete setup?

  • Verify the complete Linux DevOps workstation by running these commands:
docker run hello-world
docker compose version
git --version
terraform -version
aws --version
code --version

What does this final check prove?

  • The Docker test proves that the engine can run a container through your user account.
  • The Compose output proves that the multi-service plugin is available.
  • The remaining version outputs prove that Git, Terraform, AWS CLI, and Visual Studio Code are reachable from your terminal.

You should see Hello from Docker! followed by successful version output from every remaining command. The Terraform output should include 1.17.1.

That is the foundation locked in. Your workstation can now build containers, validate infrastructure code, and support the automation added later in the lab.

Did one verification command fail?

Use the failed command to identify the affected tool. Revisit only that tool's installation path before repeating the final check.

If Docker alone fails, start a fresh terminal session so your new group membership takes effect. If another tool fails, confirm that its installer completed without a package error.

Ask for targeted help with the failed command: help me diagnose my workstation verification failure

Your Linux DevOps workstation is ready. Next, you will use Docker to launch the first visible version of the reseller status API.

Launch the Reseller Status API

Your Linux workstation can now run Docker Engine reliably. However, verified tools alone do not prove that the reseller platform can answer health checks.

You will package a small Python status API that reports its health plus operational metrics. Docker Compose will build the image before running the service on your local machine.

In this step, get ready to:
  • Create the status API with health plus metrics endpoints.
  • Package the API in a pinned container image.
  • Run the app locally through Docker Compose.
Build the status API

The API keeps its demonstration state in memory. Its /health endpoint reports availability while /metrics exposes values that Prometheus can read later.

Why use the Python standard library?

Python's built-in HTTP server keeps this lab focused on container operations. The API needs no external application packages.

This server supports a local learning environment. Production services need a server with stronger security plus concurrency controls.

  • Return to the terminal from the workstation setup.
  • Create the project folder on your Desktop before opening it in Visual Studio Code by running these commands:
cd ~/Desktop
mkdir reseller-platform-reliability-lab
cd reseller-platform-reliability-lab
code .

What do these commands do?

  • The first command moves your terminal to the Desktop.
  • The second command creates the reseller-platform-reliability-lab folder.
  • The third command makes that folder your current terminal location.
  • The final command opens the current folder in Visual Studio Code.

You should see the empty reseller-platform-reliability-lab folder in the Visual Studio Code Explorer sidebar.

Project folder already exists?

  • Return to the existing reseller-platform-reliability-lab folder if the terminal reports that the directory already exists.
  • Confirm that the folder contains no files from an earlier attempt before continuing.

Ask for help if the folder does not open: help me open my existing project folder in Visual Studio Code

  • Create app.py inside the reseller-platform-reliability-lab folder using the Explorer sidebar.
  • Add the API state plus response helper by copying this first chunk into app.py:
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

STATE = {
    "healthy": True,
    "requests": 0,
    "last_alert": {"status": "none", "alert": "none"},
}


class ResellerHandler(BaseHTTPRequestHandler):
    server_version = "ResellerReliabilityLab"

    def write_response(self, status_code, payload, content_type="application/json"):
        if content_type == "application/json":
            body = json.dumps(payload).encode("utf-8")
        else:
            body = payload.encode("utf-8")

        self.send_response(status_code)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

What does this code do?

  • The STATE dictionary stores the current service health plus request count.
  • The last_alert value holds the latest notification received from the monitoring stack.
  • The write_response() helper converts response data into bytes before setting the HTTP headers.
  • Save app.py.
  • Check the saved section for red syntax markers in the Visual Studio Code editor.

The file now has a valid state model plus a reusable HTTP response helper.

Seeing a syntax marker?

  • Compare the braces around STATE with the code block.
  • Check that every line inside write_response() uses consistent four-space indentation.

Ask for help with the first chunk: help me find the syntax problem in my API state and response helper

  • Append the root plus health routes after the final line of write_response() by copying this code:
    def do_GET(self):
        STATE["requests"] += 1
        path = self.path.split("?", 1)[0]

        if path == "/":
            self.write_response(
                200,
                {
                    "service": "reseller-api",
                    "message": "Reseller platform reliability lab is running",
                },
            )
            return

        if path == "/health":
            healthy = STATE["healthy"]
            self.write_response(
                200 if healthy else 503,
                {
                    "service": "reseller-api",
                    "status": "healthy" if healthy else "unhealthy",
                },
            )
            return

How does health routing work?

  • The do_GET() method counts every incoming GET request.
  • The root route returns a short message that identifies the running lab.
  • The health route chooses its status code plus message from STATE["healthy"].
  • Save app.py.
  • Check the new do_GET() section for red syntax markers.

The API can now describe the lab plus calculate a health response from its current state.

Health route marked as invalid?

  • Align def do_GET(self): with def write_response(...) inside the class.
  • Keep each route condition indented inside do_GET().

Ask for help with the route indentation: help me correct the indentation in my do_GET method

  • Append the metrics plus alert-state routes after the health route's final return by copying this code:
        if path == "/metrics":
            health_value = 1 if STATE["healthy"] else 0
            metrics = (
                "# HELP reseller_requests_total Total GET requests handled by the lab API.\n"
                "# TYPE reseller_requests_total counter\n"
                f"reseller_requests_total {STATE['requests']}\n"
                "# HELP reseller_health_status Current service health where 1 is healthy and 0 is unhealthy.\n"
                "# TYPE reseller_health_status gauge\n"
                f"reseller_health_status {health_value}\n"
            )
            self.write_response(200, metrics, "text/plain; version=0.0.4")
            return

        if path == "/alerts":
            self.write_response(200, STATE["last_alert"])
            return

        self.write_response(404, {"error": "not found"})

How are metrics exposed?

  • The reseller_requests_total counter shows how many GET requests the process has handled.
  • The reseller_health_status gauge uses 1 for a healthy service plus 0 for an unhealthy service.
  • The alerts route exposes the latest webhook notification stored by the API.
  • Unknown GET paths receive a JSON response with status code 404.
  • Save app.py.
  • Check that the metric strings remain inside the parentheses assigned to metrics.

Your GET handler now covers every read-only endpoint required by the reliability lab.

Metrics strings highlighted incorrectly?

  • Check that every metric line starts plus ends with a double quote.
  • Keep each \n inside its metric string.
  • Confirm that the final closing parenthesis aligns with metrics = (.

Ask for help with the metrics section: help me repair the Prometheus metric strings in app.py

  • Append the state-changing routes below the GET handler by copying this code:
    def do_POST(self):
        path = self.path.split("?", 1)[0]

        if path == "/fail":
            STATE["healthy"] = False
            self.write_response(200, {"status": "failure injected"})
            return

        if path == "/recover":
            STATE["healthy"] = True
            self.write_response(200, {"status": "service recovered"})
            return

        if path == "/alerts":
            content_length = int(self.headers.get("Content-Length", "0"))
            raw_body = self.rfile.read(content_length)
            payload = json.loads(raw_body or b"{}")
            common_labels = payload.get("commonLabels", {})
            STATE["last_alert"] = {
                "status": payload.get("status", "unknown"),
                "alert": common_labels.get("alertname", "unknown"),
            }
            self.write_response(200, {"received": True})
            return

        self.write_response(404, {"error": "not found"})

How do POST routes change state?

  • The failure route switches the health state to False for the planned incident later in the project.
  • The recovery route switches the state back to True.
  • The alerts route reads a JSON webhook before storing its status plus alert name.
  • Unknown POST paths receive a JSON response with status code 404.
  • Save app.py.
  • Check the complete do_POST() method for red syntax markers.

The service can now enter a failed state. It can also recover or record an alert webhook.

POST handler showing an error?

  • Align def do_POST(self): with def do_GET(self):.
  • Check that the STATE["last_alert"] dictionary closes before the success response.

Ask for help with the POST routes: help me debug my failure, recovery, and alert webhook routes

  • Finish app.py by appending the JSON logger plus server startup code:
    def log_message(self, format_string, *args):
        print(
            json.dumps(
                {
                    "client": self.client_address[0],
                    "message": format_string % args,
                }
            ),
            flush=True,
        )


if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", 8000), ResellerHandler)
    print("reseller-api listening on port 8000", flush=True)
    server.serve_forever()

How does the server start?

  • The log_message() method writes structured JSON request logs to the container output.
  • The HTTPServer listener accepts connections on container port 8000.
  • The serve_forever() call keeps the API process running while the container is active.
  • Save the completed app.py file.
  • Confirm that Visual Studio Code shows no red syntax markers anywhere in the file.

The API implementation is complete. Its routes are ready to run inside a container.

Server startup marked as invalid?

  • Keep log_message() indented inside ResellerHandler.
  • Keep the final if __name__ == "__main__": block aligned at the far left.

Ask for help with the final structure: help me place the logger and server startup code correctly

  • Create Dockerfile beside app.py using the Explorer sidebar.
  • Define the pinned application image by copying this code into Dockerfile:
FROM python:3.15.2-slim

WORKDIR /app
COPY app.py .

EXPOSE 8000
CMD ["python", "app.py"]

What does the Dockerfile do?

  • The base image provides Python 3.15.2 in a slim container.
  • The working directory gives the application a predictable location inside the image.
  • The copy instruction places app.py in that location.
  • The final command starts the API whenever a container uses the image.
  • Save Dockerfile.
  • Confirm that the Explorer sidebar lists both app.py plus Dockerfile.

The project now has its application code plus a repeatable image definition.

Dockerfile not detected?

  • Check that the filename is exactly Dockerfile with no extension.
  • Confirm that Dockerfile sits beside app.py in the project folder.

Ask for help with the image definition: help me check why Docker cannot find my Dockerfile

Define the local app service

A Compose service records how to build plus run the API. The host binding starts with 127.0.0.1 so port 8000 remains available only from your Linux machine.

  • Create compose.yaml beside the other project files using the Explorer sidebar.
  • Define the app-only Compose service by copying this code into compose.yaml:
services:
  app:
    build:
      context: .
    image: reseller-platform-api:local
    ports:
      - "127.0.0.1:8000:8000"

What does this service definition do?

  • The build context tells Docker to use the files in the current project folder.
  • The image setting names the local result reseller-platform-api:local.
  • The port mapping connects local port 8000 to the API's container port.
  • The explicit loopback address prevents access through other host network interfaces.
  • Save compose.yaml.
  • Return to the terminal from earlier.
  • Validate the Compose file by running this command:
docker compose config

What does this command check?

Docker Compose parses the file before rendering its normalized configuration. A rendered configuration confirms that the YAML structure is valid.

You should see an app service containing the reseller-platform-api:local image plus a loopback port binding.

Compose configuration invalid?

  • Check that services starts at the far left of compose.yaml.
  • Use spaces to preserve the indentation shown in the code block.
  • Confirm that the port mapping keeps its double quotes.

Ask for help with the configuration: help me debug my app-only compose.yaml file

Run and inspect the API

The validated service definition can now build the pinned image. Compose runs the resulting container in the background so the terminal remains available for endpoint checks.

  • Build the image plus start the app service by running this command:
docker compose up --build --detach app

What does this command do?

  • The build option creates the image from Dockerfile.
  • The detached option leaves the app container running in the background.
  • The final service name limits this launch to the app service.

The first build can take longer while Docker downloads the pinned Python image. When the command finishes, the app container remains active in the background.

Image build failed?

  • Confirm that the terminal is inside the reseller-platform-reliability-lab folder.
  • Check that app.py plus Dockerfile appear beside compose.yaml.
  • Review the build output for the first filename or instruction it could not process.

Ask for help with the build output: help me debug my Docker Compose image build

Before you check the endpoint, do you expect the running container to report a healthy or unhealthy service?

  • Call the health endpoint by running this command:
curl -fsS http://localhost:8000/health

What does this request prove?

The request reaches the API through the local port binding. A JSON response proves that the container started plus that its health route can answer requests.

You should see {"service": "reseller-api", "status": "healthy"} in the terminal.

That is the first running slice of your reseller platform. The service now answers a real operational health check.

Health request failed?

  • Wait for the Compose build command to finish before retrying the request.
  • Check that the port mapping in compose.yaml is exactly 127.0.0.1:8000:8000.
  • Compare the final server startup block in app.py with the full-code reference below.

Ask for help with the endpoint: help me diagnose why my local health endpoint is not responding

  • Inspect the operational metrics by running this command:
curl -fsS http://localhost:8000/metrics

What should the metrics show?

  • The output describes reseller_requests_total as a counter.
  • The output describes reseller_health_status as a gauge.
  • The health gauge ends with the value 1 because the service is healthy.
  • The request counter is greater than zero because your endpoint checks have reached the API.

Metrics missing or empty?

  • Confirm that the /metrics condition remains inside do_GET().
  • Check that the metrics value contains both metric names from the code block.
  • Retry the health request before checking the request counter again.

Ask for help with the metric output: help me debug my reseller API metrics endpoint

✔️ Awesome, I've got everything!

Great. Your local API image builds successfully. The running container now reports healthy JSON plus Prometheus-format metrics.

ⓧ I'd like to double check the full code

Compare your complete app.py with this reference:

import json
from http.server import BaseHTTPRequestHandler, HTTPServer

STATE = {
    "healthy": True,
    "requests": 0,
    "last_alert": {"status": "none", "alert": "none"},
}


class ResellerHandler(BaseHTTPRequestHandler):
    server_version = "ResellerReliabilityLab"

    def write_response(self, status_code, payload, content_type="application/json"):
        if content_type == "application/json":
            body = json.dumps(payload).encode("utf-8")
        else:
            body = payload.encode("utf-8")

        self.send_response(status_code)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        STATE["requests"] += 1
        path = self.path.split("?", 1)[0]

        if path == "/":
            self.write_response(
                200,
                {
                    "service": "reseller-api",
                    "message": "Reseller platform reliability lab is running",
                },
            )
            return

        if path == "/health":
            healthy = STATE["healthy"]
            self.write_response(
                200 if healthy else 503,
                {
                    "service": "reseller-api",
                    "status": "healthy" if healthy else "unhealthy",
                },
            )
            return

        if path == "/metrics":
            health_value = 1 if STATE["healthy"] else 0
            metrics = (
                "# HELP reseller_requests_total Total GET requests handled by the lab API.\n"
                "# TYPE reseller_requests_total counter\n"
                f"reseller_requests_total {STATE['requests']}\n"
                "# HELP reseller_health_status Current service health where 1 is healthy and 0 is unhealthy.\n"
                "# TYPE reseller_health_status gauge\n"
                f"reseller_health_status {health_value}\n"
            )
            self.write_response(200, metrics, "text/plain; version=0.0.4")
            return

        if path == "/alerts":
            self.write_response(200, STATE["last_alert"])
            return

        self.write_response(404, {"error": "not found"})

    def do_POST(self):
        path = self.path.split("?", 1)[0]

        if path == "/fail":
            STATE["healthy"] = False
            self.write_response(200, {"status": "failure injected"})
            return

        if path == "/recover":
            STATE["healthy"] = True
            self.write_response(200, {"status": "service recovered"})
            return

        if path == "/alerts":
            content_length = int(self.headers.get("Content-Length", "0"))
            raw_body = self.rfile.read(content_length)
            payload = json.loads(raw_body or b"{}")
            common_labels = payload.get("commonLabels", {})
            STATE["last_alert"] = {
                "status": payload.get("status", "unknown"),
                "alert": common_labels.get("alertname", "unknown"),
            }
            self.write_response(200, {"received": True})
            return

        self.write_response(404, {"error": "not found"})

    def log_message(self, format_string, *args):
        print(
            json.dumps(
                {
                    "client": self.client_address[0],
                    "message": format_string % args,
                }
            ),
            flush=True,
        )


if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", 8000), ResellerHandler)
    print("reseller-api listening on port 8000", flush=True)
    server.serve_forever()

What should match?

Check the route paths plus indentation first. The final listener must use port 8000 with ResellerHandler.

Compare your complete Dockerfile with this reference:

FROM python:3.15.2-slim

WORKDIR /app
COPY app.py .

EXPOSE 8000
CMD ["python", "app.py"]

What should match?

The image tag plus startup command must match exactly. The container exposes the same port used by the Python server.

Compare your complete compose.yaml with this reference:

services:
  app:
    build:
      context: .
    image: reseller-platform-api:local
    ports:
      - "127.0.0.1:8000:8000"

What should match?

The app service must build from the current folder. Its published port must begin with the loopback address.

Your reseller API is containerized plus answering operational checks. Next, you will add automated image builds and validate an AWS ECS design without creating AWS resources.

Add CI and Validate an ECS Design

Your status API now answers health checks from a local Docker container.

That working container still depends on manual checks. Continuous integration makes those checks repeatable through GitHub Actions.

In this step, you will use Terraform to model an Amazon ECS cluster with a Fargate task definition. The workflow validates this design without creating AWS resources.

In this step, get ready to:
  • Model a future Amazon ECS platform design with Terraform.
  • Create a GitHub Actions workflow for repeatable validation.
  • Publish the project to a public GitHub repository with a passing workflow.
Model and validate the ECS design

Terraform validation checks configuration syntax plus internal consistency. It does not test remote provider APIs.

This design is future deployment evidence only. You will format it before initializing its provider without a backend.

  • Switch back to Visual Studio Code with your local API project.
  • Create an infra folder in the file sidebar.

You should see infra beside app.py, Dockerfile, and compose.yaml.

  • Create main.tf inside the infra folder.
  • Declare the required versions plus future deployment inputs by copying this first section into infra/main.tf:
terraform {
  required_version = "= 1.17.1"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "= 6.71.0"
    }
  }
}

variable "aws_region" {
  description = "AWS Region to use only in a future reviewed deployment."
  type        = string
}

variable "container_image" {
  description = "Immutable container image reference to use only in a future reviewed deployment."
  type        = string
}

variable "task_cpu" {
  description = "Reviewed Fargate task CPU value for a future deployment."
  type        = string
}

variable "task_memory" {
  description = "Reviewed Fargate task memory value for a future deployment."
  type        = string
}

What does this configuration do?

  • The terraform block pins Terraform to 1.17.1.
  • The required_providers block pins hashicorp/aws to 6.71.0.
  • The variables hold deployment choices that require review before any future use.
  • Save infra/main.tf.
  • Confirm the file sidebar shows main.tf inside infra.
  • Add the provider plus cluster definition below the variables:
provider "aws" {
  region = var.aws_region
}

resource "aws_ecs_cluster" "reseller_platform" {
  name = "reseller-platform-lab"

  tags = {
    Environment = "portfolio-lab"
    ManagedBy   = "terraform"
  }
}

How is the cluster modeled?

The provider receives its region from var.aws_region. The resource models a cluster named reseller-platform-lab.

The tags identify this as a portfolio lab managed through Terraform. Validation checks the design without provisioning the cluster.

  • Save infra/main.tf.

Before you run these checks, do you think Terraform can validate the cluster design without contacting AWS?

  • Format the file before initializing its provider by running these commands from the terminal in your local project directory:
terraform -chdir=infra fmt
terraform -chdir=infra init -backend=false
terraform -chdir=infra validate

What do these commands check?

  • The first command applies Terraform's canonical formatting to infra/main.tf.
  • The second command downloads the required provider without configuring a state backend.
  • The third command checks the configuration's syntax plus internal consistency.

You should see Success! The configuration is valid. Terraform has now validated the cluster-only version without creating an AWS resource.

  • Add the Fargate task definition below the cluster resource:
resource "aws_ecs_task_definition" "reseller_api" {
  family                   = "reseller-platform-lab"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = var.task_cpu
  memory                   = var.task_memory

  container_definitions = jsonencode([
    {
      name      = "reseller-api"
      image     = var.container_image
      essential = true
      portMappings = [
        {
          containerPort = 8000
          hostPort      = 8000
        }
      ]
    }
  ])

  tags = {
    Environment = "portfolio-lab"
    ManagedBy   = "terraform"
  }
}

What does the task definition describe?

The task definition describes a Fargate workload using awsvpc networking. Its CPU plus memory values remain reviewable inputs.

The encoded container definition points at a future immutable image. It exposes the same port 8000 used by your local API.

  • Save infra/main.tf.

Before you validate the complete file, do you expect the new task definition to remain internally consistent?

  • Format the completed design before validating it again by running:
terraform -chdir=infra fmt
terraform -chdir=infra validate

What does the second check prove?

Formatting keeps the completed file canonical. Validation now checks the cluster plus task-definition structures together.

Neither command plans infrastructure. Neither command applies infrastructure.

You should see Success! The configuration is valid. again. That is the first complete piece of AWS design evidence in your lab.

Terraform validation failed?

Check that every opening brace in infra/main.tf has a matching closing brace. Confirm that the provider source plus both version pins match the code above.

If initialization cannot download the provider, confirm your Linux machine has internet access before retrying.

help me diagnose my Terraform initialization or validation failure

Create the CI workflow

A CI workflow turns your local checks into repository evidence. Each push to main triggers the same validation plus a container health test.

The workflow grants read-only repository access. It uses Terraform 1.17.1 so local validation matches automated validation.

  • Create a .github folder in the Visual Studio Code file sidebar.
  • Create a workflows folder inside .github.

You should see the nested path .github/workflows in the file sidebar.

  • Create ci.yml inside .github/workflows.
  • Add the workflow triggers plus validation setup by copying this first section:
name: DevOps CI

on:
  push:
    branches:
      - main
  pull_request:

permissions:
  contents: read

jobs:
  validate-and-build:
    runs-on: ubuntu-24.04

    steps:
      - name: Check out repository
        uses: actions/checkout@v7.0.1

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v4.0.1
        with:
          terraform_version: "1.17.1"
          terraform_wrapper: false

      - name: Check Terraform formatting
        run: terraform fmt -check
        working-directory: infra

How does the workflow begin?

  • The workflow runs for pushes to main plus pull requests.
  • The contents: read permission limits repository access to reading files.
  • The runner checks out the repository before installing the pinned Terraform version.
  • The formatting step fails when Terraform files differ from canonical formatting.
  • Save .github/workflows/ci.yml.
  • Confirm the file sidebar shows ci.yml below .github/workflows.
  • Append the infrastructure validation plus container smoke test below the formatting step:
      - name: Initialize Terraform without a backend
        run: terraform init -backend=false
        working-directory: infra

      - name: Validate Terraform
        run: terraform validate
        working-directory: infra

      - name: Build container image
        run: docker build --tag reseller-api:ci .

      - name: Start smoke-test container
        run: docker run --detach --name reseller-api-ci --publish 8000:8000 reseller-api:ci

      - name: Check health endpoint
        run: |
          for attempt in {1..10}; do
            if curl -fsS http://localhost:8000/health; then
              exit 0
            fi
            sleep 2
          done
          exit 1

      - name: Remove smoke-test container
        if: always()
        run: docker rm --force reseller-api-ci || true

How does the smoke test work?

The workflow initializes Terraform without a backend before validating the design. It builds a separate image tagged reseller-api:ci.

The health check retries while the container starts. The final cleanup step runs even when an earlier step fails.

  • Save .github/workflows/ci.yml.
  • Confirm the workflow contains one validate-and-build job.

Seeing YAML warnings?

Check that each step begins at the same indentation level below steps. Keep the health-check loop indented beneath run: |.

help me fix the indentation in my GitHub Actions workflow

Terraform initialization creates a local provider cache. Git should ignore that cache plus any future local state files.

  • Create .gitignore beside app.py in the local project directory.
  • Add the local Terraform plus Python exclusions by copying:
infra/.terraform/
*.tfstate
*.tfstate.*
__pycache__/

What stays out of Git?

The first three patterns keep Terraform's downloaded provider cache plus local state files out of the repository. The final pattern ignores Python bytecode cache folders.

  • Save .gitignore.
  • Confirm the file sidebar shows .gitignore beside your application files.

✔️ Awesome, I've got everything!

Great. Your ECS design, CI workflow, and ignore rules are ready to publish.

ⓧ I'd like to double check the full code

Compare your complete infra/main.tf with this reference:

terraform {
  required_version = "= 1.17.1"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "= 6.71.0"
    }
  }
}

variable "aws_region" {
  description = "AWS Region to use only in a future reviewed deployment."
  type        = string
}

variable "container_image" {
  description = "Immutable container image reference to use only in a future reviewed deployment."
  type        = string
}

variable "task_cpu" {
  description = "Reviewed Fargate task CPU value for a future deployment."
  type        = string
}

variable "task_memory" {
  description = "Reviewed Fargate task memory value for a future deployment."
  type        = string
}

provider "aws" {
  region = var.aws_region
}

resource "aws_ecs_cluster" "reseller_platform" {
  name = "reseller-platform-lab"

  tags = {
    Environment = "portfolio-lab"
    ManagedBy   = "terraform"
  }
}

resource "aws_ecs_task_definition" "reseller_api" {
  family                   = "reseller-platform-lab"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = var.task_cpu
  memory                   = var.task_memory

  container_definitions = jsonencode([
    {
      name      = "reseller-api"
      image     = var.container_image
      essential = true
      portMappings = [
        {
          containerPort = 8000
          hostPort      = 8000
        }
      ]
    }
  ])

  tags = {
    Environment = "portfolio-lab"
    ManagedBy   = "terraform"
  }
}

This is the complete validate-only cluster plus task-definition design.

Compare your complete .github/workflows/ci.yml with this reference:

name: DevOps CI

on:
  push:
    branches:
      - main
  pull_request:

permissions:
  contents: read

jobs:
  validate-and-build:
    runs-on: ubuntu-24.04

    steps:
      - name: Check out repository
        uses: actions/checkout@v7.0.1

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v4.0.1
        with:
          terraform_version: "1.17.1"
          terraform_wrapper: false

      - name: Check Terraform formatting
        run: terraform fmt -check
        working-directory: infra

      - name: Initialize Terraform without a backend
        run: terraform init -backend=false
        working-directory: infra

      - name: Validate Terraform
        run: terraform validate
        working-directory: infra

      - name: Build container image
        run: docker build --tag reseller-api:ci .

      - name: Start smoke-test container
        run: docker run --detach --name reseller-api-ci --publish 8000:8000 reseller-api:ci

      - name: Check health endpoint
        run: |
          for attempt in {1..10}; do
            if curl -fsS http://localhost:8000/health; then
              exit 0
            fi
            sleep 2
          done
          exit 1

      - name: Remove smoke-test container
        if: always()
        run: docker rm --force reseller-api-ci || true

This is the complete workflow that validates Terraform before building plus smoke-testing the API image.

Compare your complete .gitignore with this reference:

infra/.terraform/
*.tfstate
*.tfstate.*
__pycache__/

These four patterns keep generated local artifacts out of the repository.

Publish the project and inspect CI

A public GitHub repository gives this project a remote home. Standard GitHub-hosted runners are free for public repositories.

The repository starts empty so your existing files can become its first commit. Git connects the local project to that remote repository.

  • Select New repository in GitHub.
  • Enter reseller-platform-reliability-lab as the repository name.
  • Set the repository visibility to Public.
  • Leave every repository initialization option empty.
  • Select Create repository.

You should see an empty repository page with instructions for publishing existing local code.

  • Switch back to the terminal in your local project directory.
  • Create the main branch plus its first commit by running:
git init -b main
git add .
git commit -m "Build reseller reliability lab"

What does this Git sequence do?

  • The first command initializes the local repository with main as its branch.
  • The second command stages the project files that are not excluded by .gitignore.
  • The third command records the project as one local commit.

You should see a commit summary listing the new infrastructure, workflow, and application files.

Git cannot create the commit?

Git may need your author name plus email before it can create a first commit. Follow the guidance printed in your terminal before rerunning the commit command.

help me finish my first Git commit

  • Copy the remote repository URL from the empty GitHub repository page.
  • Replace REMOTE-URL in the command below with the copied URL.
  • Connect origin before publishing main by running these commands:
git remote add origin REMOTE-URL
git push -u origin main

What happens during the push?

The first command names the public GitHub repository origin. The second command publishes your local main branch.

The push also triggers DevOps CI because the workflow listens for changes on main.

You should see terminal output confirming that main now tracks the remote branch.

Push blocked by authentication?

Complete GitHub's authentication flow before retrying the push. Confirm that the copied URL belongs to your empty public repository.

help me troubleshoot my GitHub push

The first workflow run can take a few minutes while its runner prepares the tools plus container image.

Before you inspect the result, which checks do you expect to pass based on what worked locally?

  • Select the Actions tab in the public repository.
  • Select the latest DevOps CI workflow run.
  • Refresh the run page if the job is still in progress.

You should see a passing validate-and-build job. Its green checks cover Terraform formatting, Terraform validation, the Docker build, and the health smoke test.

That passing run proves your repository can rebuild plus validate the lab automatically. No AWS resources exist because this workflow never plans or applies the Terraform configuration.

Your public repository now rebuilds the container plus validates its future ECS design. Next, you will add local monitoring that can detect a deliberate service failure.

Monitor the Platform and Trigger an Incident

Your public repository now builds the container through GitHub Actions while Terraform validates the future AWS design. The local API also remains healthy and available.

A healthy API alone does not prove production readiness. This step adds an observable path from raw metrics to a visible alert during a controlled service failure.

In this step, get ready to:
  • Configure Prometheus metrics collection and alert evaluation.
  • Route alerts through Alertmanager and display service health in Grafana.
  • Trigger a controlled failure and observe the firing incident.
Create the monitoring configuration

Prometheus collects the API metrics every five seconds. Its alert rule watches the health metric for a sustained unhealthy state.

Alertmanager receives that alert and sends a webhook back to the API. This creates a local metrics-to-notification path that you can inspect without provisioning AWS resources.

  • Return to the local project in Visual Studio Code.
  • Select the project folder in the Explorer sidebar.
  • Click the New Folder icon.
  • Enter monitoring as the folder name.
  • Select the new monitoring folder.
  • Click the New File icon.
  • Enter prometheus.yml as the file name.
  • Configure metric collection by pasting this code into monitoring/prometheus.yml:
global:
  scrape_interval: 5s
  evaluation_interval: 5s

rule_files:
  - /etc/prometheus/alerts.yml

alerting:
  alertmanagers:
    - static_configs:
        - targets:
            - alertmanager:9093

scrape_configs:
  - job_name: reseller-api
    metrics_path: /metrics
    static_configs:
      - targets:
          - app:8000

What does this configuration do?

  • The global intervals tell Prometheus to scrape metrics and evaluate rules every five seconds.
  • The alerting section sends firing rules to Alertmanager on port 9093.
  • The scrape job reads /metrics from the app container on port 8000.
  • Save monitoring/prometheus.yml.
  • Confirm prometheus.yml appears under the monitoring folder in the Explorer sidebar.

Prometheus file missing?

Check that the file sits inside monitoring within the project from earlier. Confirm that its name ends in .yml.

Ask for help if the folder structure differs from the expected path. Help me place the Prometheus configuration in the correct project folder.

  • Select the monitoring folder in the Explorer sidebar.
  • Click the New File icon.
  • Enter alerts.yml as the file name.
  • Define the unhealthy-service rule by pasting this code into monitoring/alerts.yml:
groups:
  - name: reseller-platform
    rules:
      - alert: ResellerApiUnhealthy
        expr: reseller_health_status == 0
        for: 10s
        labels:
          severity: critical
          service: reseller-api
        annotations:
          summary: Reseller API is unhealthy
          description: The local reseller API has reported an unhealthy state for 10 seconds.

How does the alert work?

  • The expression checks whether reseller_health_status equals 0.
  • The duration requires the unhealthy value to persist for ten seconds.
  • The labels identify the affected service and classify the alert as critical.
  • Save monitoring/alerts.yml.
  • Confirm alerts.yml appears beside prometheus.yml in the Explorer sidebar.

Alert rule looks misaligned?

YAML uses indentation to group rule fields. Confirm that expr and for line up beneath the alert name.

Use the exact spacing from the code block. Help me diagnose the indentation in my Prometheus alert rule.

  • Select the monitoring folder in the Explorer sidebar.
  • Click the New File icon.
  • Enter alertmanager.yml as the file name.
  • Configure webhook delivery by pasting this code into monitoring/alertmanager.yml:
route:
  receiver: local-reseller-api
  group_by:
    - alertname
  group_wait: 1s
  group_interval: 5s
  repeat_interval: 1h

receivers:
  - name: local-reseller-api
    webhook_configs:
      - url: http://app:8000/alerts
        send_resolved: true

Where does the alert go?

  • The route groups notifications by alert name.
  • The receiver sends webhook data to the API's /alerts endpoint.
  • The resolved setting sends another notification after service health recovers.
  • Save monitoring/alertmanager.yml.
  • Confirm the monitoring folder now contains all three YAML files.

Webhook route missing?

Confirm the receiver name matches local-reseller-api in both places. Confirm the webhook URL uses the Compose service name app.

Get targeted help if the route cannot find its receiver. Help me troubleshoot my Alertmanager webhook configuration.

Grafana reads the Prometheus data source and provisions the reliability dashboard from files. The shared prometheus identifier keeps the dashboard panels connected to that source.

  • Select the project folder in the Explorer sidebar.
  • Click the New Folder icon.
  • Enter grafana/provisioning/datasources as the nested folder path.
  • Select the new datasources folder.
  • Click the New File icon.
  • Enter prometheus.yml as the file name.
  • Provision the data source by pasting this code into grafana/provisioning/datasources/prometheus.yml:
apiVersion: 1

prune: true

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    uid: prometheus
    url: http://prometheus:9090
    isDefault: true
    editable: false

What does the data source provide?

  • The URL points Grafana at the Prometheus service within the Compose network.
  • The data source becomes the default source for dashboard queries.
  • The fixed UID gives each panel a stable reference.
  • Save grafana/provisioning/datasources/prometheus.yml.
  • Confirm the file appears beneath the datasources folder.

Data source path incorrect?

Confirm the complete path is grafana/provisioning/datasources/prometheus.yml. A file outside that path is not mounted into the expected provisioning directory.

Ask for a path check if your folder tree differs. Help me verify my Grafana data source path.

  • Select the provisioning folder in the Explorer sidebar.
  • Click the New Folder icon.
  • Enter dashboards as the folder name.
  • Select the new dashboards folder.
  • Click the New File icon.
  • Enter dashboards.yml as the file name.
  • Provision the dashboard folder by pasting this code into grafana/provisioning/dashboards/dashboards.yml:
apiVersion: 1

providers:
  - name: Reseller Platform
    orgId: 1
    folder: Reseller Platform
    type: file
    disableDeletion: false
    updateIntervalSeconds: 30
    allowUiUpdates: false
    options:
      path: /var/lib/grafana/dashboards
      foldersFromFilesStructure: false

How is the dashboard loaded?

  • The provider reads dashboard files from Grafana's mounted dashboard directory.
  • The folder field places the provisioned dashboard under Reseller Platform.
  • The update interval checks the mounted files every thirty seconds.
  • Save grafana/provisioning/dashboards/dashboards.yml.
  • Confirm the file appears beneath grafana/provisioning/dashboards.

Dashboard provider not listed?

Check that dashboards.yml sits in the provisioning folder. Confirm that the dashboard path inside the file starts with /var/lib/grafana/dashboards.

Use focused help if the provider configuration is unclear. Help me review my Grafana dashboard provider file.

The dashboard JSON contains two stat panels. One panel shows service health while the other shows the number of GET requests observed by the API.

  • Select the grafana folder in the Explorer sidebar.
  • Click the New Folder icon.
  • Enter dashboards as the folder name.
  • Select the new dashboards folder.
  • Click the New File icon.
  • Enter reseller-platform.json as the file name.
  • Start the dashboard model by pasting this first section into grafana/dashboards/reseller-platform.json:
{
  "annotations": {
    "list": []
  },
  "editable": false,
  "graphTooltip": 0,
  "id": null,
  "links": [],
  "panels": [
    {
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {

What starts the dashboard model?

This section establishes the dashboard object and begins the first panel. The panel references the provisioned Prometheus data source by its fixed UID.

  • Continue directly after the opening section by pasting the health mappings below:
          "mappings": [
            {
              "options": {
                "0": {
                  "color": "red",
                  "text": "UNHEALTHY"
                },
                "1": {
                  "color": "green",
                  "text": "HEALTHY"
                }
              },
              "type": "value"
            }
          ],
          "thresholds": {
            "mode": "absolute",
            "steps": [
              {
                "color": "red",
                "value": null
              },
              {
                "color": "green",
                "value": 1
              }
            ]
          }
        },
        "overrides": []

How are health values displayed?

The value mappings turn 0 into an unhealthy state and 1 into a healthy state. The threshold colors make that state visible at a glance.

  • Continue the first panel by pasting its position and display options below:
      },
      "gridPos": {
        "h": 8,
        "w": 12,
        "x": 0,
        "y": 0
      },
      "id": 1,
      "options": {
        "colorMode": "background",
        "graphMode": "none",
        "justifyMode": "auto",
        "orientation": "auto",
        "reduceOptions": {
          "calcs": [
            "lastNotNull"
          ],
          "fields": "",
          "values": false
        },
        "showPercentChange": false,
        "textMode": "value_and_name",
        "wideLayout": true
      },

What controls the health panel?

The grid position uses half the dashboard width. The stat options display the latest health value with a colored background.

  • Finish the health panel and begin the request panel by pasting the next section below:
      "targets": [
        {
          "datasource": {
            "type": "prometheus",
            "uid": "prometheus"
          },
          "expr": "reseller_health_status",
          "instant": true,
          "legendFormat": "Reseller API",
          "refId": "A"
        }
      ],
      "title": "API Health",
      "type": "stat"
    },
    {
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {
          "mappings": [],
          "thresholds": {
            "mode": "absolute",
            "steps": [
              {
                "color": "green",
                "value": null
              }
            ]

Which metrics power the panels?

The first query reads reseller_health_status for the API Health panel. The next object begins the Requests Observed panel.

  • Continue the request panel by pasting its layout and display options below:
          }
        },
        "overrides": []
      },
      "gridPos": {
        "h": 8,
        "w": 12,
        "x": 12,
        "y": 0
      },
      "id": 2,
      "options": {
        "colorMode": "value",
        "graphMode": "area",
        "justifyMode": "auto",
        "orientation": "auto",
        "reduceOptions": {
          "calcs": [
            "lastNotNull"
          ],
          "fields": "",
          "values": false
        },
        "showPercentChange": false,
        "textMode": "auto",
        "wideLayout": true
      },

How is the request panel arranged?

The second panel fills the other half of the first dashboard row. Its area graph mode adds a small visual trace behind the current request value.

  • Add the request metric query by pasting this section below:
      "targets": [
        {
          "datasource": {
            "type": "prometheus",
            "uid": "prometheus"
          },
          "expr": "reseller_requests_total",
          "instant": true,
          "legendFormat": "GET requests",
          "refId": "A"
        }
      ],
      "title": "Requests Observed",
      "type": "stat"
    }
  ],
  "refresh": "5s",
  "schemaVersion": 42,

What does the request query show?

The query reads the API's cumulative GET request counter. The dashboard refreshes every five seconds so new requests become visible quickly.

  • Complete the dashboard model by pasting the final section below:
  "tags": [
    "devops",
    "reliability",
    "reseller-platform"
  ],
  "templating": {
    "list": []
  },
  "time": {
    "from": "now-15m",
    "to": "now"
  },
  "timepicker": {},
  "timezone": "browser",
  "title": "Reseller Platform Reliability",
  "uid": "reseller-platform",
  "version": 0
}

What completes the dashboard?

The final section sets the visible title and a fifteen-minute time range. The stable dashboard UID identifies this provisioned dashboard across restarts.

  • Save grafana/dashboards/reseller-platform.json.
  • Confirm the editor reports no JSON syntax problem in the completed file.

Dashboard JSON showing a syntax problem?

Confirm that every section was pasted in order. Check the join between sections for a missing comma or duplicated brace.

Compare the complete file in the double-check tab below if the editor still flags the JSON. Help me locate a syntax problem in my Grafana dashboard JSON.

Launch the four-service stack

Docker Compose now needs to connect the API with Prometheus, Alertmanager, and Grafana. Each published port remains bound to 127.0.0.1 so the lab stays local to your Linux computer.

  • Select compose.yaml in the Explorer sidebar.
  • Select all existing content in compose.yaml.
  • Replace the app-only configuration with this first section:
services:
  app:
    build:
      context: .
    image: reseller-platform-api:local
    ports:
      - "127.0.0.1:8000:8000"

  prometheus:
    image: prom/prometheus:v3.15.1
    command:
      - --config.file=/etc/prometheus/prometheus.yml
    ports:
      - "127.0.0.1:9090:9090"
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
    depends_on:
      - app
      - alertmanager

How do the first services connect?

  • The app keeps its local image and port from the earlier Compose file.
  • Prometheus mounts the scrape configuration and alert rules as read-only files.
  • The dependency list connects Prometheus to the app and Alertmanager services.
  • Continue directly below the Prometheus service by pasting the remaining services:
  alertmanager:
    image: prom/alertmanager:v0.34.1
    command:
      - --config.file=/etc/alertmanager/alertmanager.yml
    ports:
      - "127.0.0.1:9093:9093"
    volumes:
      - ./monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro

  grafana:
    image: grafana/grafana:13.3.0
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "true"
      GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
      GF_AUTH_DISABLE_LOGIN_FORM: "true"
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
    depends_on:
      - prometheus

What do the remaining services add?

  • Alertmanager mounts the webhook routing configuration and publishes its local interface on port 9093.
  • Grafana enables anonymous Viewer access for this local lab.
  • Grafana mounts both provisioning files and the dashboard model as read-only content.
  • Save compose.yaml.
  • Validate the expanded Compose configuration by running this command in the project terminal:
docker compose config

What does this check prove?

Docker Compose parses the service definitions and renders the resolved configuration. A successful result confirms that the YAML structure and mounted paths can be interpreted.

You should see the rendered configuration for app, prometheus, alertmanager, and grafana.

Compose configuration rejected?

Check the indentation in compose.yaml first. Confirm that all four service names line up beneath services.

Check that every mounted file exists at the path shown in the configuration. Help me troubleshoot my four-service Compose configuration.

The first start may take a few minutes while Docker downloads three monitoring images. Quiet download periods are normal while the image layers arrive.

  • Build the API image and start all four services in the background by running:
docker compose up --build --detach

What does this command start?

Docker rebuilds the local API image before starting the complete stack. Detached mode leaves the containers running while returning control of the terminal.

  • Inspect the running services by running:
docker compose ps

What should the service list show?

You should see containers for the app, Prometheus, Alertmanager, and Grafana. Their published addresses should use the local host interface.

One of the services missing?

Review the output from the start command for the service that stopped. A missing mounted file or malformed configuration can prevent its container from staying up.

Use the service name from the output when asking for help. Help me diagnose a missing service in my Docker Compose stack.

  • Open http://localhost:3000 in your browser.
  • Wait for the provisioned Reseller Platform Reliability dashboard to load.
  • Confirm the API Health panel shows 1.
  • Confirm the Requests Observed panel shows a request count.

The healthy baseline is live. Your dashboard can now distinguish the normal service state before the incident begins.

Dashboard not ready?

Give Grafana up to thirty seconds to load the provisioned dashboard. Refresh the browser after the provider has checked the mounted files.

If the panels have no data, confirm that the Prometheus service is running in the Compose status output. Help me troubleshoot an empty Grafana reliability dashboard.

Trigger and observe the incident

The monitoring path is now watching a healthy service. A controlled failure tests whether each link carries the unhealthy state from the API to the dashboard and webhook.

Before you trigger the incident, which parts of the stack do you expect to change after the unhealthy state persists?

  • Inject the controlled service failure by running:
curl -fsS -X POST http://localhost:8000/fail

What changed inside the API?

The request calls the API's /fail operation. The API changes its in-memory health state and returns a failure-injected confirmation.

The service is intentionally unhealthy now. This is the visible shortfall that the recovery workflow closes in the next step.

  • Open http://localhost:8000/health in your browser.
  • Confirm the response reports the service status as unhealthy.
  • Wait about twenty seconds for metric collection and alert delivery.
  • Return to http://localhost:3000 in your browser.
  • Confirm the API Health panel now shows 0.
  • Open http://localhost:9093 in your browser.
  • Confirm Alertmanager shows ResellerApiUnhealthy.

Before the final check, do you expect the API's webhook record to show a pending state or a firing state?

  • Inspect the latest webhook notification by running:
curl -fsS http://localhost:8000/alerts

What does the webhook response prove?

You should see the alert name ResellerApiUnhealthy with a firing status. This proves that Prometheus evaluated the rule and Alertmanager delivered the webhook.

Alert still missing?

Confirm the API Health panel remains at 0 after the waiting period. Confirm all four services still appear in the Compose status output.

Leave the API unhealthy for the next step once the firing state appears. Help me trace a missing ResellerApiUnhealthy alert through the local stack.

✔️ Awesome, I've got everything!

Great work. Your local observability stack now detects the unhealthy API and records the firing incident.

ⓧ I'd like to double check the full code

Compare the files changed in this step with the complete versions below.

services:
  app:
    build:
      context: .
    image: reseller-platform-api:local
    ports:
      - "127.0.0.1:8000:8000"

  prometheus:
    image: prom/prometheus:v3.15.1
    command:
      - --config.file=/etc/prometheus/prometheus.yml
    ports:
      - "127.0.0.1:9090:9090"
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./monitoring/alerts.yml:/etc/prometheus/alerts.yml:ro
    depends_on:
      - app
      - alertmanager

  alertmanager:
    image: prom/alertmanager:v0.34.1
    command:
      - --config.file=/etc/alertmanager/alertmanager.yml
    ports:
      - "127.0.0.1:9093:9093"
    volumes:
      - ./monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro

  grafana:
    image: grafana/grafana:13.3.0
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "true"
      GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
      GF_AUTH_DISABLE_LOGIN_FORM: "true"
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
    depends_on:
      - prometheus
global:
  scrape_interval: 5s
  evaluation_interval: 5s

rule_files:
  - /etc/prometheus/alerts.yml

alerting:
  alertmanagers:
    - static_configs:
        - targets:
            - alertmanager:9093

scrape_configs:
  - job_name: reseller-api
    metrics_path: /metrics
    static_configs:
      - targets:
          - app:8000
groups:
  - name: reseller-platform
    rules:
      - alert: ResellerApiUnhealthy
        expr: reseller_health_status == 0
        for: 10s
        labels:
          severity: critical
          service: reseller-api
        annotations:
          summary: Reseller API is unhealthy
          description: The local reseller API has reported an unhealthy state for 10 seconds.
route:
  receiver: local-reseller-api
  group_by:
    - alertname
  group_wait: 1s
  group_interval: 5s
  repeat_interval: 1h

receivers:
  - name: local-reseller-api
    webhook_configs:
      - url: http://app:8000/alerts
        send_resolved: true
apiVersion: 1

prune: true

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    uid: prometheus
    url: http://prometheus:9090
    isDefault: true
    editable: false
apiVersion: 1

providers:
  - name: Reseller Platform
    orgId: 1
    folder: Reseller Platform
    type: file
    disableDeletion: false
    updateIntervalSeconds: 30
    allowUiUpdates: false
    options:
      path: /var/lib/grafana/dashboards
      foldersFromFilesStructure: false
{
  "annotations": {
    "list": []
  },
  "editable": false,
  "graphTooltip": 0,
  "id": null,
  "links": [],
  "panels": [
    {
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {
          "mappings": [
            {
              "options": {
                "0": {
                  "color": "red",
                  "text": "UNHEALTHY"
                },
                "1": {
                  "color": "green",
                  "text": "HEALTHY"
                }
              },
              "type": "value"
            }
          ],
          "thresholds": {
            "mode": "absolute",
            "steps": [
              {
                "color": "red",
                "value": null
              },
              {
                "color": "green",
                "value": 1
              }
            ]
          }
        },
        "overrides": []
      },
      "gridPos": {
        "h": 8,
        "w": 12,
        "x": 0,
        "y": 0
      },
      "id": 1,
      "options": {
        "colorMode": "background",
        "graphMode": "none",
        "justifyMode": "auto",
        "orientation": "auto",
        "reduceOptions": {
          "calcs": [
            "lastNotNull"
          ],
          "fields": "",
          "values": false
        },
        "showPercentChange": false,
        "textMode": "value_and_name",
        "wideLayout": true
      },
      "targets": [
        {
          "datasource": {
            "type": "prometheus",
            "uid": "prometheus"
          },
          "expr": "reseller_health_status",
          "instant": true,
          "legendFormat": "Reseller API",
          "refId": "A"
        }
      ],
      "title": "API Health",
      "type": "stat"
    },
    {
      "datasource": {
        "type": "prometheus",
        "uid": "prometheus"
      },
      "fieldConfig": {
        "defaults": {
          "mappings": [],
          "thresholds": {
            "mode": "absolute",
            "steps": [
              {
                "color": "green",
                "value": null
              }
            ]
          }
        },
        "overrides": []
      },
      "gridPos": {
        "h": 8,
        "w": 12,
        "x": 12,
        "y": 0
      },
      "id": 2,
      "options": {
        "colorMode": "value",
        "graphMode": "area",
        "justifyMode": "auto",
        "orientation": "auto",
        "reduceOptions": {
          "calcs": [
            "lastNotNull"
          ],
          "fields": "",
          "values": false
        },
        "showPercentChange": false,
        "textMode": "auto",
        "wideLayout": true
      },
      "targets": [
        {
          "datasource": {
            "type": "prometheus",
            "uid": "prometheus"
          },
          "expr": "reseller_requests_total",
          "instant": true,
          "legendFormat": "GET requests",
          "refId": "A"
        }
      ],
      "title": "Requests Observed",
      "type": "stat"
    }
  ],
  "refresh": "5s",
  "schemaVersion": 42,
  "tags": [
    "devops",
    "reliability",
    "reseller-platform"
  ],
  "templating": {
    "list": []
  },
  "time": {
    "from": "now-15m",
    "to": "now"
  },
  "timepicker": {},
  "timezone": "browser",
  "title": "Reseller Platform Reliability",
  "uid": "reseller-platform",
  "version": 0
}

Your monitoring stack has detected a real controlled failure and delivered the alert back to the API. Next, you will recover the service through a repeatable Bash command and document the full incident-response loop.

Recover the Service and Document the Incident

Your local stack has caught the planned failure. The alert is visible across Prometheus, Alertmanager, and Grafana.

Detection alone leaves the reseller API unhealthy. A Bash script turns recovery into one repeatable command.

You will capture the procedure in a runbook. You will record the incident as evidence in your public GitHub repository.

In this step, get ready to:
  • Automate service recovery with an executable Bash script.
  • Document the recovery procedure in a runbook.
  • Publish the incident evidence to GitHub.
Make recovery repeatable

A recovery script performs the same response steps every time. The runbook gives an operator the surrounding triage and escalation guidance.

  • Switch back to Visual Studio Code from earlier.
  • Right-click the repository name in the file sidebar.
  • Use the folder-creation control to create a folder named scripts.
  • Right-click the new scripts folder.
  • Use the file-creation control to create recover.sh.
  • Add the recovery workflow to scripts/recover.sh by pasting this code:
#!/usr/bin/env bash
set -euo pipefail

curl -fsS -X POST http://localhost:8000/recover
printf '\nWaiting for Prometheus and Alertmanager to observe recovery...\n'
sleep 10
curl -fsS http://localhost:8000/health
printf '\n'
curl -fsS http://localhost:8000/alerts
printf '\n'

How does recovery work?

  • The strict-mode line stops the script when a command fails.
  • The first curl request calls the /recover endpoint to restore the in-memory health state.
  • The ten-second pause gives Prometheus time to scrape the healthy metric. It also gives Alertmanager time to route the resolved notification.
  • The final requests display the service health and latest webhook state.
  • Save scripts/recover.sh.

Linux uses an executable permission to decide whether a script can run directly. You need to grant that permission before using the recovery command.

  • Make the recovery script executable by running this command in the terminal from earlier:
chmod +x scripts/recover.sh

What does this command do?

The chmod command adds executable permission to scripts/recover.sh. Your shell can now run the script directly.

Your terminal returns to the prompt without an error when the permission change succeeds.

Permission command failed?

Check that your terminal is still inside the reseller reliability repository. Confirm that scripts/recover.sh is saved inside the scripts folder.

If the path still fails, help me find and make my recovery script executable.

  • Right-click the repository name in the Visual Studio Code file sidebar.
  • Use the file-creation control to create RUNBOOK.md.
  • Add the operating procedure to RUNBOOK.md by pasting this content:
# Reseller API Recovery Runbook

## Trigger

Use this runbook when `ResellerApiUnhealthy` is firing or the health endpoint returns an unhealthy response.

## Triage

1. Check service state with `docker compose ps`.
2. Check application output with `docker compose logs app`.
3. Confirm impact with `curl -fsS http://localhost:8000/health`.
4. Confirm the latest notification with `curl -fsS http://localhost:8000/alerts`.

## Recovery

Run `./scripts/recover.sh` from the repository root.

## Verification

- The health endpoint reports `healthy`.
- Grafana shows health value `1`.
- The webhook status changes to `resolved`.
- `docker compose ps` shows the four services running.

## Escalation

If recovery fails, preserve `docker compose logs app prometheus alertmanager`, stop making changes, and escalate with the command output and incident timeline.

How does the runbook help?

  • The trigger tells an operator when this procedure applies.
  • The triage checks use Docker Compose output and API responses to confirm impact.
  • The recovery section points to one tested command.
  • The verification and escalation sections define success and the next response path.
  • Save RUNBOOK.md.
  • Confirm that the file contains the Trigger, Triage, Recovery, Verification, and Escalation headings.

Runbook headings missing?

Confirm that you pasted the content into RUNBOOK.md at the top level of the repository. Markdown headings begin with the exact number of hash characters shown in the reference.

If the structure looks different, help me compare my recovery runbook with the required Markdown structure.

✔️ Awesome, I've got everything!

Great. Your executable recovery script and operator runbook are ready for the active incident.

ⓧ I'd like to double check the full code

Compare your recovery script with this complete reference.

#!/usr/bin/env bash
set -euo pipefail

curl -fsS -X POST http://localhost:8000/recover
printf '\nWaiting for Prometheus and Alertmanager to observe recovery...\n'
sleep 10
curl -fsS http://localhost:8000/health
printf '\n'
curl -fsS http://localhost:8000/alerts
printf '\n'

Compare your runbook with this complete reference.

# Reseller API Recovery Runbook

## Trigger

Use this runbook when `ResellerApiUnhealthy` is firing or the health endpoint returns an unhealthy response.

## Triage

1. Check service state with `docker compose ps`.
2. Check application output with `docker compose logs app`.
3. Confirm impact with `curl -fsS http://localhost:8000/health`.
4. Confirm the latest notification with `curl -fsS http://localhost:8000/alerts`.

## Recovery

Run `./scripts/recover.sh` from the repository root.

## Verification

- The health endpoint reports `healthy`.
- Grafana shows health value `1`.
- The webhook status changes to `resolved`.
- `docker compose ps` shows the four services running.

## Escalation

If recovery fails, preserve `docker compose logs app prometheus alertmanager`, stop making changes, and escalate with the command output and incident timeline.
Close the active alert

The service is still unhealthy from the controlled failure. Running the script tests whether your documented recovery path actually closes the incident.

Before you run the recovery, which health and alert states do you expect the script to print after its wait?

  • Recover the reseller API by running the script from the repository directory:
./scripts/recover.sh

What happens during this check?

The script restores the application health state before pausing for ten seconds. It then asks the API for fresh health and webhook evidence.

This sequence verifies recovery through the same monitoring path that detected the incident.

You should first see {"status": "service recovered"}. The health check then prints {"service": "reseller-api", "status": "healthy"}.

The final response should report {"status": "resolved", "alert": "ResellerApiUnhealthy"}.

  • Return to the Grafana browser tab from earlier.
  • Refresh the reliability dashboard.

You should see the API Health panel return to green with a value of 1. That closes the loop from detection through monitored recovery.

Alert still shows as firing?

Confirm that the script printed a healthy response. Wait for another monitoring cycle before checking the alert state again.

If the service stays unhealthy, help me trace why my reseller API recovery script did not resolve the alert.

Capture the incident evidence

An incident report preserves what failed and how monitoring detected it. It also records the recovery path and follow-up work.

  • Right-click the repository name in the Visual Studio Code file sidebar.
  • Use the file-creation control to create INCIDENT-REPORT.md.
  • Add the incident summary and timeline structure by pasting this first section:
# Incident Report: Reseller API Health Failure

## Summary

A controlled failure changed the reseller API health state from healthy to unhealthy.

## Impact

The `/health` endpoint returned an unhealthy response while the incident was active.

## Detection

Prometheus evaluated `reseller_health_status == 0`, fired `ResellerApiUnhealthy`, and sent the alert to Alertmanager. Alertmanager delivered the notification to the API webhook.

## Timeline

- Failure injected:
- Alert observed:
- Recovery started:
- Service healthy:
- Alert resolved:

What does this section record?

  • The summary names the controlled state change.
  • The impact identifies the affected health endpoint.
  • The detection section records the metric-to-webhook chain.
  • The timeline provides places for the key incident milestones.
  • Save INCIDENT-REPORT.md.
  • Confirm that the report now shows the Summary, Impact, Detection, and Timeline headings.

Missing the first report sections?

Check that the first line starts with the incident report title. Confirm that each section heading begins on its own line.

If the Markdown structure is unclear, help me fix the first half of my incident report.

  • Place your cursor below - Alert resolved: in INCIDENT-REPORT.md.
  • Complete the report by pasting this remaining section:
## Root Cause

The lab's `/fail` operation intentionally changed the in-memory health state to unhealthy.

## Recovery

The Bash recovery script called the `/recover` operation and then verified service and alert state.

## Prevention and Follow-up

- Keep health and alert checks in CI and operational runbooks.
- Review alert thresholds before using a similar pattern in production.
- Replace the in-memory demonstration state with production-safe health signals in a real service.

How does this close the report?

  • The root cause identifies the intentional in-memory state change.
  • The recovery section connects the script to its verification checks.
  • The follow-up items turn the exercise into reusable operational lessons.
  • Save INCIDENT-REPORT.md.
  • Confirm that the report ends with three prevention and follow-up items.

Report sections out of order?

Make sure the root-cause section begins immediately after the final timeline item. Check that the recovery section appears before prevention and follow-up.

If the sections still look misplaced, help me restore the required incident report order.

✔️ Awesome, I've got everything!

Your incident report now records the controlled failure from impact through prevention.

ⓧ I'd like to double check the full code

Compare your incident report with this complete reference.

# Incident Report: Reseller API Health Failure

## Summary

A controlled failure changed the reseller API health state from healthy to unhealthy.

## Impact

The `/health` endpoint returned an unhealthy response while the incident was active.

## Detection

Prometheus evaluated `reseller_health_status == 0`, fired `ResellerApiUnhealthy`, and sent the alert to Alertmanager. Alertmanager delivered the notification to the API webhook.

## Timeline

- Failure injected:
- Alert observed:
- Recovery started:
- Service healthy:
- Alert resolved:

## Root Cause

The lab's `/fail` operation intentionally changed the in-memory health state to unhealthy.

## Recovery

The Bash recovery script called the `/recover` operation and then verified service and alert state.

## Prevention and Follow-up

- Keep health and alert checks in CI and operational runbooks.
- Review alert thresholds before using a similar pattern in production.
- Replace the in-memory demonstration state with production-safe health signals in a real service.

The local files now hold your recovery procedure and incident evidence. A repository commit preserves them beside the application and monitoring configuration.

  • Stage the operational artifacts and create a repository commit by running these commands:
git add .
git commit -m "First commit"

What do these commands record?

The first git command stages the new script and Markdown files. The second command stores that staged state in the repository history.

You should see output confirming that the recovery script and both Markdown files were included in the commit.

Nothing to commit?

Confirm that all three new files are saved inside the repository. Unsaved editor tabs do not become part of a Git commit.

If Git still cannot see the files, help me find why my operational artifacts are missing from the commit.

Before you push, do you expect the repository to start a new DevOps CI run for this commit?

  • Push the operational artifacts to the public repository by running this command:
git push -u origin main

What does this push do?

The git command sends your local main branch to the existing remote repository. The push also triggers the workflow that watches that branch.

You should see output confirming that the remote main branch received the commit.

Push rejected?

Confirm that you still have access to the same public repository from earlier. Check that your terminal is using the repository that already has the remote configured.

If authentication or the remote blocks the push, help me troubleshoot my GitHub push.

  • Return to the public GitHub repository page from earlier.
  • Refresh the repository page.
  • Confirm that scripts/recover.sh, RUNBOOK.md, and INCIDENT-REPORT.md are listed.
  • Select the Actions tab.
  • Open the latest DevOps CI run.

The workflow needs time to build and test the project. When it completes, you should see the Terraform checks, Docker build, and health smoke test pass again.

You have closed the incident-response loop. Your repository now shows how you detect a failure, recover the service, verify resolution, and preserve operational evidence.

Secret mission

Detect a Request Surge

A service can stay healthy while unusual demand builds around it. Add a second Prometheus alert that detects a controlled burst of reseller-platform requests and records the firing notification through the existing webhook.

Clean Up Your Resources

Clean Up Your Resources

The lab stayed local. No AWS resources were created, so your project cost remains $0.

Choose whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The four local containers managed by Docker Compose: app, prometheus, alertmanager, grafana.
  • The local Compose network connects the four services.
  • The infra/.terraform provider cache stores downloaded Terraform provider files.
  • The public GitHub repository reseller-platform-reliability-lab includes its GitHub Actions workflow history.

Keep everything running

No action needed. Choose this if you want to demonstrate the API, dashboard, alerts, recovery script, or incident evidence again.

  • Leave the four-service local stack running for future demonstrations.
  • Keep infra/.terraform so later validation runs can reuse the provider cache.
  • Keep reseller-platform-reliability-lab public so your passing workflow remains available as portfolio evidence.

Pause - I'll come back to this later

Stop the local services to free system resources. Your project files remain available for a later demonstration.

  • In a terminal, move to the local repository directory that contains compose.yaml.
  • Stop the four local services by running this command:
docker compose stop

What does this command do?

This stops all four services. The containers and local configuration stay in place.

Can't stop the stack?

If Compose cannot find compose.yaml, run the command from the local repository directory that contains that file. Help me stop my local Compose stack.

  • Resume the existing containers later by running this command from the same directory:
docker compose start

What happens when you resume?

This starts the existing containers again. Your local API and observability endpoints become available.

Services not starting?

Make sure you run the command from the directory containing compose.yaml. Help me restart my Compose services.

Delete - I don't want to use this again

Remove the local runtime resources and cached provider files. You can also delete the online repository if you no longer want the portfolio evidence.

  • In a terminal, move to the local repository directory that contains compose.yaml.
  • Remove the Compose containers, network, and managed volumes by running this command:
docker compose down --volumes

What does this command remove?

This removes the four Compose containers, their network, and Compose-managed volumes. It keeps your source files and public repository untouched.

Stack still present?

If Compose cannot locate the stack, confirm that your terminal is in the directory containing compose.yaml. Help me remove my local Compose stack.

The Terraform provider cache is safe to remove because the configuration was only initialized and validated. Finding a dot-prefixed folder can be fiddly because Linux file managers often hide it.

  • Use your Linux file manager to inspect the infra directory.
  • Enable hidden files if .terraform is not visible.
  • Delete the .terraform folder.
  • Confirm that infra/main.tf remains available as your validated ECS design.

Deleting the public repository permanently removes the online portfolio copy and its workflow history. Keep it unless you are certain you no longer need that evidence.

  • Open the public reseller-platform-reliability-lab repository on GitHub.
  • Go to the repository settings page.
  • Scroll to the repository deletion area.
  • Use the repository deletion control.
  • Complete the confirmation steps shown by GitHub.
  • Return to your repositories list to confirm that reseller-platform-reliability-lab is absent.

Nice Work!

Nice Work!

Outstanding work! Your local reseller-platform reliability lab now demonstrates a complete incident-response loop without creating any AWS resources.

You've learned how to:

  • Build a containerized status API with Docker. Expose live health responses. Publish operational metrics in the Prometheus text format.
  • Automate a DevOps CI pipeline with GitHub Actions. Validate the Terraform design for Amazon ECS without provisioning it. Build the image. Smoke-test the health endpoint.
  • Operate a local observability stack with Alertmanager plus Grafana. Trigger a controlled outage. Detect the alert through Prometheus. Recover the API through a Bash script. Preserve the operational evidence in RUNBOOK.md plus INCIDENT-REPORT.md.
  • Secret Mission: Add request-surge detection through ResellerApiRequestSurge. Generate controlled traffic. Show the demand alert firing beside the separate availability alert.

Ready to quiz yourself?