Deploy an ECS Operations Dashboard

Deploy a hardened Flask dashboard on ECS Fargate with CDK and Secrets Manager.

Introduction

30 Second Summary

A website can work perfectly on your computer yet disappear when it moves somewhere else. Without a clear view of what is running, a tiny connection mistake can look like a healthy service.

In this project, you will build an operations dashboard with a browser frontend backed by a Flask REST API. The path exposes a Docker networking failure before ending with a hardened deployment on Amazon ECS Fargate.

What You'll Build

Your public dashboard will report Online from Amazon ECS Fargate with its runtime health visible on one screen.

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

  • A working operations dashboard that refreshes live data from the Flask REST API. The latest backend check appears on screen.
  • A reproducible container incident where the process runs but the browser cannot reach it. You can identify the loopback-only bind as the cause.
  • A public AWS deployment that you can recreate with AWS CDK. An Application Load Balancer gives the dashboard a live URL. You can prove that the app runs as a non-root user. You can confirm runtime secret injection without exposing its value. You can read request logs in Amazon CloudWatch Logs.
  • Secret Mission: A controlled ECS self-healing test where you stop the live task. You can prove that the ECS service launches a replacement before the dashboard returns Online.

What do I need before I start?

This project requires the Windows tools and AWS permissions named in the prerequisites. The lab uses a $0.50 safety budget for its short AWS deployment. Only test data belongs on its public HTTP URL.

Before We Start

This project builds an operations dashboard for API status, runtime, container hostname, runtime-secret status, and the last check time. ECS Fargate provides the managed container runtime where you deploy and operate the service.

Prepare the Windows Toolchain

Your dashboard eventually becomes a cloud stack. Synthesis depends on Node.js powering AWS CDK.

This step turns Windows PowerShell into a known-good workspace. You'll verify the existing Python, Docker Desktop, and AWS CLI tools before any infrastructure work begins.

In this step, get ready to:
  • Install Node.js 24.20.0 LTS with npm.
  • Install AWS CDK CLI 2.1139.0 globally.
  • Create the secure-ops-dashboard workspace with a .venv virtual environment.
Install Node.js and verify npm

AWS CDK uses the Node.js runtime even when your infrastructure code is written in Python. The pinned LTS release gives the CDK CLI a predictable foundation.

  • Press the Windows key to open Windows search.
  • Type PowerShell and press Enter to open it.
  • Check the installed Node.js version by running this command:
node --version

✔️ I see the required LTS version

If the output starts with v24.20.0, Node.js is ready for this project.

  • Confirm npm is available by running this command:
npm --version

You'll see an npm version number without an error. That confirms the package manager arrived with Node.js.

ⓧ I see an older version

  • Open the official Node.js download page.
  • Download the Windows installer for Node.js 24.20.0 LTS.
  • Run the downloaded installer.
  • Accept the standard Windows installer defaults.
  • Close the current PowerShell window.
  • Press the Windows key to reopen Windows search.
  • Type PowerShell and press Enter to open a fresh window.
  • Verify Node.js and npm by running these commands:
node --version
npm --version

The Node.js output should start with v24.20.0. The second command should print an npm version number.

ⓧ Command not found

  • Open the official Node.js download page.
  • Download the Windows installer for Node.js 24.20.0 LTS.
  • Run the downloaded installer.
  • Accept the standard Windows installer defaults.
  • Close the current PowerShell window.
  • Press the Windows key to reopen Windows search.
  • Type PowerShell and press Enter to open a fresh window.
  • Verify Node.js and npm by running these commands:
node --version
npm --version

The Node.js output should start with v24.20.0. The second command should print an npm version number.

Still missing Node.js or npm?

  • Confirm that you closed every PowerShell window after completing the installer.
  • Run the installer again if the fresh PowerShell window still cannot find Node.js.

Ask for help: Help me diagnose why Node.js or npm is unavailable after installation on Windows. If the issue continues, share the command output in the NextWork community.

Install AWS CDK and create the workspace

The CDK CLI turns infrastructure definitions into deployable cloud templates. A pinned CLI version keeps its behavior consistent throughout the project.

  • Check the installed AWS CDK CLI version by running this command:
cdk --version

✔️ I see the required CDK version

If the output includes 2.1139.0, the CDK CLI is ready. You can continue to the workspace setup.

ⓧ I see an older version

The global installation can pause while npm downloads packages. A quiet terminal during that work is normal.

  • Replace the older global installation with the pinned release by running this command:
npm install -g aws-cdk@2.1139.0
  • Verify the installed CDK CLI version by running this command:
cdk --version

The output should include 2.1139.0.

ⓧ Command not found

The global installation can pause while npm downloads packages. A quiet terminal during that work is normal.

  • Install the pinned CDK CLI globally by running this command:
npm install -g aws-cdk@2.1139.0
  • Verify the installed CDK CLI version by running this command:
cdk --version

The output should include 2.1139.0.

Why AWS CDK for this project?

AWS CDK converts infrastructure definitions into an AWS CloudFormation template. That gives you a repeatable deployment that can be reviewed before AWS creates resources.

A console deployment wizard records choices through clicks. CDK keeps those choices in code so the complete stack can be reproduced or removed.

CDK installation failing?

  • Confirm that node --version and npm --version both work in the current PowerShell window.
  • Check your network connection if npm cannot download the package.

Ask for help: Help me debug a failed global AWS CDK installation on Windows. If the installation still fails, share the complete npm output in the NextWork community.

A dedicated workspace keeps the dashboard files separate from unrelated projects. Its virtual environment isolates the Python packages you install later.

  • Move to your Desktop by running this command:
Set-Location -Path "$HOME\Desktop"
  • Create the secure-ops-dashboard folder and move into it by running these commands:
mkdir secure-ops-dashboard
cd secure-ops-dashboard

Your PowerShell prompt should now end with secure-ops-dashboard.

  • Create the project virtual environment by running this command:
python -m venv .venv
  • Confirm the virtual environment exists by running this command:
Get-ChildItem .venv

You should see a Scripts directory in the output. Your project now has its own Python environment.

Virtual environment missing?

  • Confirm that your PowerShell prompt ends with secure-ops-dashboard.
  • Check the Python version if the virtual environment command reports a missing Python executable.

Ask for help: Help me create the .venv virtual environment inside secure-ops-dashboard on Windows.

Verify the deployment toolchain

The remaining checks prove that Python can create project environments. They also prove that Docker can reach its engine and AWS CLI can reach your intended account.

  • Check the installed Python version by running this command:
python --version

✔️ I see the required Python version

Python 3.10 or later meets the project requirement. Your .venv environment can run the application and infrastructure dependencies.

ⓧ I see an older version

  • Open the official Python downloads page for Windows.
  • Download a Python 3.10 or later Windows installer.
  • Run the downloaded installer.
  • Enable the installer option that makes Python available in PowerShell.
  • Close the current PowerShell window.
  • Press the Windows key to reopen Windows search.
  • Type PowerShell and press Enter to open a fresh window.
  • Verify the updated Python version by running this command:
python --version

You should see Python 3.10 or later.

ⓧ Command not found

  • Open the official Python downloads page for Windows.
  • Download a Python 3.10 or later Windows installer.
  • Run the downloaded installer.
  • Enable the installer option that makes Python available in PowerShell.
  • Close the current PowerShell window.
  • Press the Windows key to reopen Windows search.
  • Type PowerShell and press Enter to open a fresh window.
  • Verify the Python installation by running this command:
python --version

You should see Python 3.10 or later.

Docker Desktop provides the Linux container engine used by the dashboard image. PowerShell needs to reach both the Docker client and its running engine.

  • Press the Windows key to open Windows search.
  • Type Docker Desktop and press Enter to open it.
  • Wait for Docker Desktop to finish starting its engine.
  • Select the Docker icon in the Windows taskbar to open the Docker menu.
  • Select Switch to Linux containers if that option is available.
  • Return to the PowerShell window from earlier.
  • Verify that PowerShell can reach Docker by running this command:
docker version

You should see client and server version details without an error. That confirms the Linux container engine is reachable.

Docker server details missing?

  • Wait for Docker Desktop to finish starting before running the version check again.
  • Use the Docker menu to switch to Linux containers if Docker Desktop is using Windows containers.

Ask for help: Help me diagnose why docker version cannot reach Docker Desktop on Windows.

AWS CLI must use major version 2 for this project. The version check determines whether your existing installation is ready or needs an update.

  • Check the installed AWS CLI version by running this command:
aws --version

✔️ I see AWS CLI version 2

AWS CLI reports major version 2. Your installation is ready for the account identity check.

ⓧ I see AWS CLI version 1

  • Close the current PowerShell window.
  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Choose the administrator launch option for PowerShell.
  • Update the official AWS CLI installation by running this command:
aws update
  • Close the Administrator PowerShell window after the update completes.
  • Press the Windows key to reopen Windows search.
  • Type PowerShell and press Enter to open a normal window.
  • Verify the updated AWS CLI version by running this command:
aws --version

AWS CLI should now report major version 2.

ⓧ Command not found

  • Open the official AWS CLI installation guide.
  • Download the AWS CLI installer for Windows.
  • Run the downloaded installer with its standard options.
  • Close the current PowerShell window.
  • Press the Windows key to reopen Windows search.
  • Type PowerShell and press Enter to open a fresh window.
  • Verify the AWS CLI installation by running this command:
aws --version

AWS CLI should report major version 2.

  • Return to the secure-ops-dashboard workspace by running this command:
Set-Location -Path "$HOME\Desktop\secure-ops-dashboard"

The final identity check returns account metadata such as an account ID and ARN. Your access keys stay hidden.

Before you run the checks, do you expect every tool to respond from this fresh PowerShell session?

  • Verify the complete toolchain and active AWS identity by running these commands:
node --version
cdk --version
python --version
docker version
aws --version
aws sts get-caller-identity

Node.js starts with v24.20.0. AWS CDK includes 2.1139.0.

Python is 3.10 or later. Docker prints client and server details.

AWS CLI reports major version 2. AWS STS returns the intended account ID and ARN.

One of the final checks failed?

  • Reopen PowerShell if a newly installed command is still unavailable.
  • Start Docker Desktop if the Docker output does not include server details.
  • Stop before deploying if AWS STS shows an unexpected account identity.

Ask for help: Help me diagnose the failing Windows toolchain check before I continue. If the issue remains, share the redacted output in the NextWork community.

That's your deployment runway ready. PowerShell can now reach every local tool and the intended AWS account.

Your Windows toolchain and isolated workspace are ready. Next, you'll build the dashboard and prove its REST endpoint works locally.

Build the Operations Dashboard

Your Windows toolchain is ready. The project now needs a working browser experience that proves the application contract on your machine.

A local dashboard gives you a clean baseline before Docker or AWS introduces networking or deployment variables. You will connect a Flask REST API to a frontend that displays live runtime data.

In this step, get ready to:
  • Create the Flask backend with its dashboard routes.
  • Build the browser interface with HTML, CSS, and JavaScript.
  • Run the local service and inspect its responses.
Build the Flask backend

Flask maps URLs to Python functions called routes. The status route returns JSON that the browser can display.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Select the existing secure-ops-dashboard folder with the folder picker.

You should see the existing .venv folder in the file sidebar.

  • Create requirements-app.txt inside the secure-ops-dashboard folder with the file sidebar.
  • Add the pinned application dependencies by pasting this code:
Flask==3.1.3
gunicorn==26.2.0

What do these dependencies provide?

  • Flask 3.1.3 serves the dashboard template and its API routes.
  • Gunicorn 26.2.0 provides the application server used by the container later.
  • Save requirements-app.txt.
  • Switch back to the Windows PowerShell window from the previous step.
  • Install the application dependencies into .venv by running:
.\.venv\Scripts\python.exe -m pip install -r requirements-app.txt

What does this command do?

The virtual environment runs pip against requirements-app.txt. This keeps the two packages inside this workspace.

The installation finishes without a dependency error. Your virtual environment can now import Flask and Gunicorn.

Did the dependency installation fail?

  • Confirm PowerShell is still inside the secure-ops-dashboard workspace.
  • Confirm requirements-app.txt appears beside the .venv folder.

Ask for help: Help me diagnose why the application dependencies will not install into my existing .venv environment.

  • Switch back to Visual Studio Code.
  • Create app.py inside the secure-ops-dashboard folder with the file sidebar.
  • Add the Flask application and security header configuration by pasting:
from datetime import datetime, timezone
import os
import socket

from flask import Flask, render_template

app = Flask(__name__)

SECURITY_HEADERS = {
    "Content-Security-Policy": (
        "default-src 'self'; "
        "script-src 'self'; "
        "style-src 'self'; "
        "connect-src 'self'; "
        "img-src 'self'; "
        "object-src 'none'; "
        "base-uri 'none'; "
        "frame-ancestors 'none'"
    ),
    "Permissions-Policy": "camera=(), geolocation=(), microphone=()",
    "Referrer-Policy": "no-referrer",
    "X-Content-Type-Options": "nosniff",
    "X-Frame-Options": "DENY",
}

What does this code do?

  • The imports provide timestamps, environment variables, hostnames, Flask routing, and template rendering.
  • The app object becomes the web application.
  • The SECURITY_HEADERS dictionary defines browser protections returned with every route.
  • Add the three routes below the SECURITY_HEADERS dictionary by pasting:
@app.route("/")
def index():
    return render_template("index.html"), 200, SECURITY_HEADERS


@app.route("/api/health")
def health():
    return {"status": "healthy"}, 200, SECURITY_HEADERS


@app.route("/api/status")
def status():
    secret_loaded = bool(os.getenv("APP_SECRET"))
    return {
        "status": "online",
        "runtime": "ECS Fargate" if secret_loaded else "Local or Docker",
        "container": socket.gethostname(),
        "secret_loaded": secret_loaded,
        "checked_at": datetime.now(timezone.utc).isoformat(),
    }, 200, SECURITY_HEADERS

How do the routes support operations?

  • The index() route renders the browser dashboard.
  • The health() route provides a small health-check response.
  • The status() route reports runtime state without exposing the secret value.
  • Save app.py.
  • Switch back to the existing PowerShell window.
  • Start the Flask development server by running:
.\.venv\Scripts\python.exe -m flask --app app run

What does this command do?

The virtual environment launches Flask with the app object from app.py. The process keeps this PowerShell window occupied while the local server is running.

  • Enter http://127.0.0.1:5000/api/status in your browser address bar.

You should see a JSON response with status, runtime, container, secret_loaded, and checked_at.

Cannot load the status endpoint?

  • Check that the Flask process is still running in PowerShell.
  • Check that app.py is inside the secure-ops-dashboard folder.
  • Check the route spelling against /api/status.

Ask for help: Help me debug why my local Flask status endpoint does not return JSON.

Create the dashboard interface

The browser interface uses an HTML template for structure. JavaScript requests live data from the backend while CSS turns that data into an operations dashboard.

  • Switch back to Visual Studio Code.
  • Create the templates folder inside secure-ops-dashboard with the file sidebar.
  • Create the static folder inside secure-ops-dashboard with the file sidebar.

You should see templates and static beside app.py.

  • Create templates/index.html with the file sidebar.
  • Add the document shell by pasting:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ECS Operations Dashboard</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
  <script src="{{ url_for('static', filename='app.js') }}" defer></script>
</head>
<body>
  <main class="shell">
  </main>
</body>
</html>

What does the document shell do?

The document shell loads the stylesheet and browser script through Flask static-file URLs. The main element provides the container for the dashboard sections.

  • Save templates/index.html.
  • Enter http://127.0.0.1:5000 in your browser address bar.

You should see the ECS Operations Dashboard title in the browser tab. This proves Flask found the template.

Did the dashboard route fail?

Confirm the file path is exactly templates/index.html. Flask looks in the templates folder when render_template() runs.

Ask for help: Help me find why Flask cannot render templates/index.html.

  • Place the dashboard header inside the main element by pasting:
    <header class="hero">
      <p class="eyebrow">DEVOPS INCIDENT LAB</p>
      <h1>ECS Operations Dashboard</h1>
      <p>Frontend and Flask API health from the same deployed container.</p>
    </header>

What does the header communicate?

The header names the incident lab and its dashboard. Its summary makes the frontend-to-backend relationship visible.

  • Save templates/index.html.
  • Refresh http://127.0.0.1:5000 in your browser.

You should see the incident lab label and the dashboard heading.

Is the heading missing?

Confirm the header block sits between the opening and closing main tags.

Ask for help: Help me place the dashboard header correctly inside templates/index.html.

  • Add the four status cards below the header element by pasting:
    <section class="grid" aria-label="Service status">
      <article class="card">
        <span>API status</span>
        <strong id="service-status">Checking</strong>
      </article>
      <article class="card">
        <span>Runtime</span>
        <strong id="runtime">Checking</strong>
      </article>
      <article class="card">
        <span>Container</span>
        <strong id="container">Checking</strong>
      </article>
      <article class="card">
        <span>Runtime secret</span>
        <strong id="secret-status">Checking</strong>
      </article>
    </section>

How do the status cards work?

Each card reserves an element for one API field. The element IDs give the browser script exact targets for live values.

  • Save templates/index.html.
  • Refresh http://127.0.0.1:5000 in your browser.

You should see four unstyled status cards with Checking values.

Are any status cards missing?

Check that every article closes before the next card begins. Confirm each element ID matches the snippet.

Ask for help: Help me compare the four dashboard status cards with the required HTML structure.

  • Add the operational panel below the status grid by pasting:
    <section class="panel">
      <div>
        <h2>Operational check</h2>
        <p id="checked-at">Waiting for the backend.</p>
      </div>
      <button id="refresh-button" type="button">Refresh status</button>
    </section>

    <p id="error-message" class="error" role="alert" hidden></p>

What does the operational panel add?

The panel shows when the backend was last checked. The button starts another status request while the hidden alert provides space for request errors.

  • Save templates/index.html.
  • Refresh http://127.0.0.1:5000 in your browser.

You should see the operational check text and the Refresh status button below the cards.

Is the refresh button missing?

Confirm the panel appears after the grid inside the main element. Check that the button ID is refresh-button.

Ask for help: Help me find why the operational panel does not appear in the dashboard.

  • Create static/app.js with the file sidebar.
  • Add the dashboard element references by pasting:
const serviceStatus = document.querySelector("#service-status");
const runtime = document.querySelector("#runtime");
const container = document.querySelector("#container");
const secretStatus = document.querySelector("#secret-status");
const checkedAt = document.querySelector("#checked-at");
const refreshButton = document.querySelector("#refresh-button");
const errorMessage = document.querySelector("#error-message");

Why store these element references?

Each constant points to an element from index.html. The refresh function can update those elements when the API responds.

  • Add the refresh behavior below the element references by pasting:
async function refreshStatus() {
  refreshButton.disabled = true;
  errorMessage.hidden = true;

  try {
    const response = await fetch("/api/status", {
      headers: { Accept: "application/json" },
    });

    if (!response.ok) {
      throw new Error(`API returned HTTP ${response.status}`);
    }

    const data = await response.json();
    serviceStatus.textContent = data.status;
    runtime.textContent = data.runtime;
    container.textContent = data.container;
    secretStatus.textContent = data.secret_loaded ? "Injected" : "Not injected";
    checkedAt.textContent = `Last checked: ${new Date(data.checked_at).toLocaleString()}`;
  } catch (error) {
    serviceStatus.textContent = "Offline";
    errorMessage.textContent = error.message;
    errorMessage.hidden = false;
  } finally {
    refreshButton.disabled = false;
  }
}

refreshButton.addEventListener("click", refreshStatus);
refreshStatus();

How does the browser refresh status?

  • The refreshStatus() function requests /api/status as JSON.
  • A successful response replaces every Checking value with backend data.
  • A failed response changes the API status to Offline and reveals the error message.
  • Save static/app.js.
  • Refresh http://127.0.0.1:5000 in your browser.
  • Click Refresh status.

You should see online for the API status. You should also see Local or Docker for the runtime.

Are the cards stuck on Checking?

  • Confirm static/app.js is inside the static folder.
  • Compare every JavaScript selector with its matching ID in templates/index.html.
  • Confirm the Flask server still responds at /api/status.

Ask for help: Help me debug why refreshStatus leaves my dashboard cards on Checking.

  • Create static/style.css with the file sidebar.
  • Add the page foundation by pasting:
:root {
  color-scheme: dark;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  background: #07111f;
  color: #e8f0ff;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background:
    radial-gradient(circle at top right, #183b66 0, transparent 38%),
    linear-gradient(145deg, #07111f, #0b1b30);
}

.shell {
  width: min(980px, calc(100% - 32px));
  margin: 0 auto;
  padding: 64px 0;
}

.hero {
  margin-bottom: 32px;
}

What does the page foundation control?

These rules establish the dark colour scheme and page background. The .shell selector keeps the dashboard centred with space around it.

  • Save static/style.css.
  • Refresh http://127.0.0.1:5000 in your browser.

You should see a dark blue background with the dashboard content centred on the page.

Is the page still unstyled?

Confirm the file path is static/style.css. Check that the stylesheet link in index.html uses style.css.

Ask for help: Help me debug why static/style.css is not loading through Flask.

  • Add the header and grid styles below the .hero rule by pasting:
.eyebrow {
  color: #65d4ff;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.18em;
}

h1 {
  margin: 8px 0;
  font-size: clamp(2rem, 6vw, 4rem);
}

.hero p:last-child,
.panel p {
  color: #a9bad2;
}

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
}

What changes in the layout?

The header gains a clear visual hierarchy. The responsive grid places status cards into available columns.

  • Save static/style.css.
  • Refresh http://127.0.0.1:5000 in your browser.

You should see a larger heading and four evenly spaced status areas.

Did the cards stay in one column?

Confirm the status section still uses class="grid" in index.html. Check that the CSS selector begins with .grid.

Ask for help: Help me debug why the dashboard grid styles do not apply.

  • Add the card styles below the .grid rule by pasting:
.card,
.panel {
  border: 1px solid #29435f;
  border-radius: 16px;
  background: rgb(10 27 47 / 82%);
  box-shadow: 0 18px 45px rgb(0 0 0 / 24%);
}

.card {
  min-height: 132px;
  padding: 22px;
}

.card span {
  display: block;
  margin-bottom: 18px;
  color: #8fa4bf;
}

.card strong {
  overflow-wrap: anywhere;
  color: #77e2a8;
  font-size: 1.15rem;
}

What makes the status readable?

The cards gain borders and spacing. Labels stay muted while live values use a brighter colour.

  • Save static/style.css.
  • Refresh http://127.0.0.1:5000 in your browser.

You should see each status value inside a rounded card with a green value.

Are the cards still plain?

Confirm each status article uses class="card" in index.html. Check the punctuation in the combined .card and .panel selector.

Ask for help: Help me debug why the .card styles are missing from my dashboard.

  • Add the panel and button styles below the .card strong rule by pasting:
.panel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  margin-top: 16px;
  padding: 22px;
}

.panel h2 {
  margin: 0 0 6px;
}

button {
  border: 0;
  border-radius: 10px;
  padding: 12px 18px;
  background: #65d4ff;
  color: #04111e;
  font: inherit;
  font-weight: 800;
  cursor: pointer;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

How does the control panel behave?

The panel separates the last-check timestamp from the refresh control. The button changes its cursor and opacity while a request is running.

  • Save static/style.css.
  • Refresh http://127.0.0.1:5000 in your browser.

You should see the timestamp and blue refresh button inside a shared panel.

Does the panel look misaligned?

Confirm the operational section uses class="panel" in index.html. Check that every declaration stays inside its selector braces.

Ask for help: Help me debug the panel and button layout in static/style.css.

  • Add the error and mobile styles below the button:disabled rule by pasting:
.error {
  border: 1px solid #ff6b7a;
  border-radius: 10px;
  padding: 14px;
  color: #ffd6da;
  background: #48131a;
}

@media (max-width: 620px) {
  .shell {
    padding: 36px 0;
  }

  .panel {
    align-items: stretch;
    flex-direction: column;
  }
}

How does the dashboard handle smaller screens?

The error rule makes request failures visible when JavaScript reveals the alert. The media query stacks the panel vertically on narrow screens.

  • Save static/style.css.
  • Refresh http://127.0.0.1:5000 in your browser.

You should see the completed dark dashboard with four cards and one operational panel.

Did the last styles break the page?

Confirm the @media block has both closing braces. Check that the .error selector remains outside that block.

Ask for help: Help me check the final error and responsive CSS blocks for missing braces.

✔️ Awesome, I've got everything!

Great. Double check that all five files are saved before the final local verification.

ⓧ I'd like to double check the full code

Compare each complete file with the version in your secure-ops-dashboard workspace.

Flask==3.1.3
gunicorn==26.2.0

Dependency checkpoint

This file pins the two application packages used by the local dashboard and container.

from datetime import datetime, timezone
import os
import socket

from flask import Flask, render_template

app = Flask(__name__)

SECURITY_HEADERS = {
    "Content-Security-Policy": (
        "default-src 'self'; "
        "script-src 'self'; "
        "style-src 'self'; "
        "connect-src 'self'; "
        "img-src 'self'; "
        "object-src 'none'; "
        "base-uri 'none'; "
        "frame-ancestors 'none'"
    ),
    "Permissions-Policy": "camera=(), geolocation=(), microphone=()",
    "Referrer-Policy": "no-referrer",
    "X-Content-Type-Options": "nosniff",
    "X-Frame-Options": "DENY",
}


@app.route("/")
def index():
    return render_template("index.html"), 200, SECURITY_HEADERS


@app.route("/api/health")
def health():
    return {"status": "healthy"}, 200, SECURITY_HEADERS


@app.route("/api/status")
def status():
    secret_loaded = bool(os.getenv("APP_SECRET"))
    return {
        "status": "online",
        "runtime": "ECS Fargate" if secret_loaded else "Local or Docker",
        "container": socket.gethostname(),
        "secret_loaded": secret_loaded,
        "checked_at": datetime.now(timezone.utc).isoformat(),
    }, 200, SECURITY_HEADERS

Backend checkpoint

This file defines the Flask application and all three routes. It also attaches the same security headers to every response.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ECS Operations Dashboard</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
  <script src="{{ url_for('static', filename='app.js') }}" defer></script>
</head>
<body>
  <main class="shell">
    <header class="hero">
      <p class="eyebrow">DEVOPS INCIDENT LAB</p>
      <h1>ECS Operations Dashboard</h1>
      <p>Frontend and Flask API health from the same deployed container.</p>
    </header>

    <section class="grid" aria-label="Service status">
      <article class="card">
        <span>API status</span>
        <strong id="service-status">Checking</strong>
      </article>
      <article class="card">
        <span>Runtime</span>
        <strong id="runtime">Checking</strong>
      </article>
      <article class="card">
        <span>Container</span>
        <strong id="container">Checking</strong>
      </article>
      <article class="card">
        <span>Runtime secret</span>
        <strong id="secret-status">Checking</strong>
      </article>
    </section>

    <section class="panel">
      <div>
        <h2>Operational check</h2>
        <p id="checked-at">Waiting for the backend.</p>
      </div>
      <button id="refresh-button" type="button">Refresh status</button>
    </section>

    <p id="error-message" class="error" role="alert" hidden></p>
  </main>
</body>
</html>

Template checkpoint

This template contains every status target used by the browser script. Its static links load the stylesheet and JavaScript through Flask.

:root {
  color-scheme: dark;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  background: #07111f;
  color: #e8f0ff;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  background:
    radial-gradient(circle at top right, #183b66 0, transparent 38%),
    linear-gradient(145deg, #07111f, #0b1b30);
}

.shell {
  width: min(980px, calc(100% - 32px));
  margin: 0 auto;
  padding: 64px 0;
}

.hero {
  margin-bottom: 32px;
}

.eyebrow {
  color: #65d4ff;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.18em;
}

h1 {
  margin: 8px 0;
  font-size: clamp(2rem, 6vw, 4rem);
}

.hero p:last-child,
.panel p {
  color: #a9bad2;
}

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
}

.card,
.panel {
  border: 1px solid #29435f;
  border-radius: 16px;
  background: rgb(10 27 47 / 82%);
  box-shadow: 0 18px 45px rgb(0 0 0 / 24%);
}

.card {
  min-height: 132px;
  padding: 22px;
}

.card span {
  display: block;
  margin-bottom: 18px;
  color: #8fa4bf;
}

.card strong {
  overflow-wrap: anywhere;
  color: #77e2a8;
  font-size: 1.15rem;
}

.panel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  margin-top: 16px;
  padding: 22px;
}

.panel h2 {
  margin: 0 0 6px;
}

button {
  border: 0;
  border-radius: 10px;
  padding: 12px 18px;
  background: #65d4ff;
  color: #04111e;
  font: inherit;
  font-weight: 800;
  cursor: pointer;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

.error {
  border: 1px solid #ff6b7a;
  border-radius: 10px;
  padding: 14px;
  color: #ffd6da;
  background: #48131a;
}

@media (max-width: 620px) {
  .shell {
    padding: 36px 0;
  }

  .panel {
    align-items: stretch;
    flex-direction: column;
  }
}

Stylesheet checkpoint

This stylesheet defines the complete desktop layout and narrow-screen panel behavior.

const serviceStatus = document.querySelector("#service-status");
const runtime = document.querySelector("#runtime");
const container = document.querySelector("#container");
const secretStatus = document.querySelector("#secret-status");
const checkedAt = document.querySelector("#checked-at");
const refreshButton = document.querySelector("#refresh-button");
const errorMessage = document.querySelector("#error-message");

async function refreshStatus() {
  refreshButton.disabled = true;
  errorMessage.hidden = true;

  try {
    const response = await fetch("/api/status", {
      headers: { Accept: "application/json" },
    });

    if (!response.ok) {
      throw new Error(`API returned HTTP ${response.status}`);
    }

    const data = await response.json();
    serviceStatus.textContent = data.status;
    runtime.textContent = data.runtime;
    container.textContent = data.container;
    secretStatus.textContent = data.secret_loaded ? "Injected" : "Not injected";
    checkedAt.textContent = `Last checked: ${new Date(data.checked_at).toLocaleString()}`;
  } catch (error) {
    serviceStatus.textContent = "Offline";
    errorMessage.textContent = error.message;
    errorMessage.hidden = false;
  } finally {
    refreshButton.disabled = false;
  }
}

refreshButton.addEventListener("click", refreshStatus);
refreshStatus();

Browser logic checkpoint

This script requests the status endpoint during page load. It also repeats the request whenever the learner uses the refresh button.

Run and inspect the dashboard

The local verification checks more than appearance. It proves the frontend can call the backend and that every route returns the configured browser protections.

  • Switch back to the PowerShell window running Flask.
  • Press Ctrl+C to stop the current server.

Before you restart the server, do you expect the dashboard to replace every Checking value after the page loads?

  • Start the completed dashboard by running:
.\.venv\Scripts\python.exe -m flask --app app run

What does this final run prove?

Flask loads app.py and renders the completed template. The same process serves the static assets and API routes.

  • Refresh http://127.0.0.1:5000 in your browser.
  • Click Refresh status.

You should see online and Local or Docker in the dashboard. You should also see a container hostname and a recent check time.

  • Enter http://127.0.0.1:5000/api/status in the browser address bar.

You should see the five status fields returned as JSON. The secret_loaded field should be false during this local run.

  • Enter http://127.0.0.1:5000/api/health in the browser address bar.

You should see a JSON response where status is healthy.

  • Inspect the /api/status response headers with your browser developer tools.

What headers should you find?

  • Look for Content-Security-Policy.
  • Look for Permissions-Policy.
  • Look for Referrer-Policy.
  • Look for X-Content-Type-Options and X-Frame-Options.

Does the final verification differ?

  • Reload the page after confirming the latest files are saved.
  • Compare the browser element IDs with the constants in static/app.js.
  • Compare each route response with the SECURITY_HEADERS dictionary.

Ask for help: Help me debug my completed local operations dashboard and identify which layer is failing.

That is the local baseline locked in. Your frontend now reads live state from the Flask backend.

  • Return to the PowerShell window running Flask.
  • Press Ctrl+C to stop the local server.

Your dashboard works across the browser and backend on Windows. Next, you will package the same application into a container and reproduce a networking incident.

Reproduce the Networking Incident

The local Flask dashboard already proves that your frontend can reach its API. A process can still look healthy inside a container while traffic from Windows cannot reach it.

This step packages the dashboard with Docker. You will run a deliberate networking incident before using the container state and server logs to diagnose it.

In this step, get ready to:
  • Package the dashboard with a Docker build definition.
  • Build the image and start a container with a published host port.
  • Compare the browser failure with the container state and logs.
Package the dashboard image

A .dockerignore file controls which local files enter the Docker build context. This keeps virtual environments and future infrastructure files out of the image build.

  • In Visual Studio Code, create .dockerignore inside the secure-ops-dashboard folder with this content:
.venv
cdk.out
.git
__pycache__
*.pyc
outputs.json
infra.py
cdk.json
requirements-infra.txt

What does this file do?

  • The first five patterns exclude your virtual environment and generated cache data.
  • The remaining patterns exclude deployment outputs and infrastructure files that the application image does not need.
  • Save .dockerignore.
  • Confirm that .dockerignore appears inside the secure-ops-dashboard folder in the file sidebar.

Missing the Ignore File?

Check whether Windows added a hidden .txt extension. The filename must be exactly .dockerignore.

Ask for help: Help me check why my .dockerignore file is missing or has the wrong filename.

A Dockerfile describes how Docker assembles the application image. This temporary version starts Gunicorn on a specific container address so you can test how that address behaves through port publishing.

  • Create Dockerfile inside the secure-ops-dashboard folder with this content:
FROM python:3.14.7-slim-trixie

WORKDIR /app

COPY requirements-app.txt .
RUN python -m pip install --no-cache-dir -r requirements-app.txt

COPY app.py .
COPY templates ./templates
COPY static ./static

RUN chown -R 10001:10001 /app
USER 10001:10001

EXPOSE 8000

CMD ["gunicorn", "--bind", "127.0.0.1:8000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "--no-control-socket", "app:app"]

What Does This Dockerfile Do?

  • The base image provides Python through python:3.14.7-slim-trixie.
  • The copy instructions add the dependency list and dashboard files to /app.
  • The USER 10001:10001 instruction sets the identity used by the application command.
  • The final command starts Gunicorn with two workers at 127.0.0.1:8000.
  • Save Dockerfile.
  • Confirm that Dockerfile appears beside app.py in the file sidebar.

Dockerfile Not Showing Up?

Check that the filename starts with a capital D and has no file extension. Make sure the file sits directly inside secure-ops-dashboard.

Ask for help: Help me verify the name and location of my Dockerfile.

✔️ Awesome, I've got everything!

Great. Double check that both files are saved before building the image.

ⓧ I'd like to double check the full code

Compare both files with these complete versions.

.venv
cdk.out
.git
__pycache__
*.pyc
outputs.json
infra.py
cdk.json
requirements-infra.txt

What Should Match?

Your .dockerignore file should contain these nine patterns in the same order.

FROM python:3.14.7-slim-trixie

WORKDIR /app

COPY requirements-app.txt .
RUN python -m pip install --no-cache-dir -r requirements-app.txt

COPY app.py .
COPY templates ./templates
COPY static ./static

RUN chown -R 10001:10001 /app
USER 10001:10001

EXPOSE 8000

CMD ["gunicorn", "--bind", "127.0.0.1:8000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "--no-control-socket", "app:app"]

What Should Match?

Your temporary Dockerfile should end with the Gunicorn command that uses 127.0.0.1:8000.

Build and start the incident container

A Docker image captures the application files and runtime instructions in one reusable package. Building it also confirms that Docker can read every file referenced by Dockerfile.

  • Confirm Docker Desktop is running as a Linux container engine.
  • Switch back to PowerShell in the secure-ops-dashboard workspace.
  • Build the secure-ops-dashboard image by running:
docker build -t secure-ops-dashboard .

What Does This Command Do?

Docker reads Dockerfile from the current folder. The -t option names the resulting image secure-ops-dashboard.

The first build may pause while Docker downloads the base image. Continued layer output means the build is still working.

The final build output confirms that Docker created the image. Your dashboard is now packaged for a container runtime.

Image Build Failed?

  • Confirm PowerShell is still inside the secure-ops-dashboard folder.
  • Check that requirements-app.txt and app.py appear beside Dockerfile.
  • Confirm Docker Desktop reports that its engine is running.

Ask for help: Help me diagnose why my secure-ops-dashboard image did not build.

  • Start the temporary container with host port 127.0.0.1:8000 published to container port 8000 by running:
docker run --rm -d -p 127.0.0.1:8000:8000 --name secure-ops-dashboard secure-ops-dashboard

What Does This Command Do?

  • The -d option runs the container in the background.
  • The -p option publishes Windows port 8000 to the same container port.
  • The --name option assigns the name secure-ops-dashboard.
  • The --rm option removes the temporary container after it stops.

PowerShell prints a container ID. You now have a running container ready for the incident check.

No Container ID?

Confirm the image build completed successfully. Check that another application is not already using Windows port 8000.

Ask for help: Help me diagnose why the secure-ops-dashboard container did not start.

Before opening the dashboard, do you think a running container and a published port guarantee that the browser request succeeds?

  • Enter http://127.0.0.1:8000 in your browser address bar.

The browser cannot reach the dashboard. This failure is the intended incident, so keep the container running while you collect evidence.

Diagnose and stop the container

Docker port publishing forwards requests toward the container port. The application process must also listen on a container interface that can receive that forwarded traffic.

Before checking the evidence, do you expect the container state and Gunicorn logs to agree with the failed browser request?

  • Check whether the container is still running by running:
docker ps

What Does This Command Prove?

The container list shows secure-ops-dashboard as running. It also shows the published connection between Windows port 8000 and container port 8000.

The running state proves that the container did not crash. The browser failure must come from the request path inside that running container.

  • Inspect the Gunicorn startup evidence by running:
docker logs secure-ops-dashboard

What Do These Logs Prove?

Gunicorn reports that it is listening at 127.0.0.1:8000. That address is the container's own loopback interface.

Traffic arriving through Docker's published port cannot reach a process restricted to container loopback. The process is healthy inside the container while the dashboard remains unreachable from Windows.

You have reproduced the networking incident and identified its cause from three signals. The browser fails while the container runs and Gunicorn listens only on container loopback.

  • Stop the temporary container by running:
docker stop secure-ops-dashboard

What Does This Command Do?

Docker stops the named container. The earlier --rm option then removes that temporary container automatically.

PowerShell prints secure-ops-dashboard when the stop completes. Strong work. You diagnosed the network boundary with runtime evidence instead of assuming the process had crashed.

Container Already Stopped?

The container may already be gone if it exited after startup. Your saved image and project files remain available.

Ask for help: Help me understand why my temporary container stopped before I ran docker stop.

The failed request now has a concrete cause. Next up, you will correct the container network boundary and verify the hardened runtime behavior.

Fix and Harden the Container

Your last test proved that a Docker container can keep running while Gunicorn listens only on its own loopback interface. Traffic from the published Windows port never reached that listener.

The fix changes the bind address to every container interface. The rebuilt image also keeps the project's full security baseline.

In this step, get ready to:
  • Change Gunicorn from loopback to all container interfaces.
  • Rebuild the image as a numeric non-root user.
  • Verify the container's runtime security baseline.
Change the Gunicorn bind address

A listener bound to 127.0.0.1 accepts traffic only from inside its container. Binding to 0.0.0.0 lets the published Docker port reach Gunicorn.

  • In Visual Studio Code from earlier, find this final line in Dockerfile:
CMD ["gunicorn", "--bind", "127.0.0.1:8000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "--no-control-socket", "app:app"]

Why did this bind fail?

  • The 127.0.0.1:8000 bind restricts Gunicorn to the container's loopback interface.
  • Docker publishes the port through a separate container interface.
  • The loopback-only listener cannot receive that published traffic.
  • Replace 127.0.0.1:8000 with 0.0.0.0:8000 using this final line:
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "--no-control-socket", "app:app"]

What changes with this line?

  • The 0.0.0.0:8000 bind accepts traffic through every container IPv4 interface.
  • The USER 10001:10001 instruction still runs the application command with a numeric non-root identity.
  • The logging flags continue sending access output to stdout and error output to stderr.
  • Save Dockerfile.

✔️ Awesome, I've got everything!

Your corrected Dockerfile is saved. Your existing .dockerignore remains unchanged.

ⓧ I'd like to double check the full code

Compare both container files with these final versions.

.venv
cdk.out
.git
__pycache__
*.pyc
outputs.json
infra.py
cdk.json
requirements-infra.txt

What does this file protect?

The ignore list keeps local environments, infrastructure files, generated output, Git metadata, and Python cache files out of the image build context.

FROM python:3.14.7-slim-trixie

WORKDIR /app

COPY requirements-app.txt .
RUN python -m pip install --no-cache-dir -r requirements-app.txt

COPY app.py .
COPY templates ./templates
COPY static ./static

RUN chown -R 10001:10001 /app
USER 10001:10001

EXPOSE 8000

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-", "--no-control-socket", "app:app"]

What does the final image contain?

  • The image installs the application dependencies before copying the Flask source and browser assets.
  • The ownership instructions give UID and GID 10001:10001 control of /app.
  • The final command exposes Gunicorn through port 8000 with two workers.
Rebuild and run the hardened image

A container image keeps the instructions used during its build. Rebuilding packages the corrected bind address into the local image.

  • Return to the Windows PowerShell terminal from earlier.
  • Rebuild the corrected image by running this command:
docker build -t secure-ops-dashboard .

What does this command do?

  • This command reads the corrected Dockerfile from the current folder.
  • The local secure-ops-dashboard tag points to the rebuilt image.

You'll see each Dockerfile stage complete before Docker reports a successful build.

Build not completing?

  • Confirm Docker Desktop is still running with Linux containers.
  • Confirm the current folder contains Dockerfile and requirements-app.txt.

Ask for help: Help me diagnose why my secure-ops-dashboard Docker image is not building.

Before you start the rebuilt container, do you expect the published port to reach Gunicorn this time?

  • Start the rebuilt container by running this command:
docker run --rm -d -p 127.0.0.1:8000:8000 --name secure-ops-dashboard secure-ops-dashboard

How does this container run?

  • The -d flag keeps the container running in the background.
  • The port mapping sends Windows traffic on port 8000 to the same container port.
  • The --rm flag removes the temporary container after it stops.

PowerShell prints a container ID. That output confirms the rebuilt container started in the background.

Container not starting?

  • Check that the earlier container remains stopped so the name is available.
  • Check that no other local process is using port 8000.

Ask for help: Help me find why the rebuilt secure-ops-dashboard container will not start.

Verify the security baseline

Reachability proves that the interface change worked. The dashboard, request logs, and response headers provide separate evidence for the remaining baseline.

Before you refresh the page, do you expect the dashboard to report a local runtime or an ECS runtime?

  • Switch back to the browser from earlier.
  • Enter http://127.0.0.1:8000 in the address bar.
  • Click Refresh status.

That networking gap is closed. You'll see online for the API status and Local or Docker for the runtime.

The Runtime secret card shows Not injected because APP_SECRET is absent. The page reveals only whether runtime configuration exists.

  • Inspect the container's request logs by running this command:
docker logs secure-ops-dashboard

What do these logs prove?

Gunicorn writes each browser request to the container's standard output. A container platform can collect that stream without writing log files inside the image.

You'll see access entries for the dashboard and its /api/status request. Those entries prove that traffic now reaches Gunicorn.

No access requests in the logs?

  • Refresh the dashboard once more before checking the logs.
  • Confirm the browser is using port 8000.

Ask for help: Help me understand why Gunicorn is not showing browser requests in my container logs.

Before you inspect the response, which security protections do you expect the Flask routes to attach?

  • Inspect the dashboard response headers by running this command:
(Invoke-WebRequest http://127.0.0.1:8000).Headers

What does this check prove?

The Invoke-WebRequest command requests the dashboard through the published host port. Its Headers property displays the response metadata returned by Flask.

You'll see entries for Content-Security-Policy, Permissions-Policy, Referrer-Policy, X-Content-Type-Options, and X-Frame-Options. Their presence proves the Flask routes returned the configured security headers.

Missing the security headers?

  • Confirm the running container came from the image you rebuilt in this step.
  • Confirm each route in app.py returns SECURITY_HEADERS.

Ask for help: Help me diagnose why the dashboard response is missing its configured security headers.

  • Stop the verified local container by running this command:
docker stop secure-ops-dashboard

What happens when it stops?

  • Docker stops the running secure-ops-dashboard container.
  • The earlier --rm setting removes that temporary container after shutdown.

PowerShell prints secure-ops-dashboard. Your final image remains available for deployment.

Container already stopped?

The container may have exited after an application error. Review its earlier logs to identify the failure before rebuilding.

Ask for help: Help me understand why my secure-ops-dashboard container stopped before I finished testing it.

Your hardened container is reachable, observable, and ready for cloud deployment. Next, you'll define its AWS infrastructure and launch it behind a public load balancer.

Deploy to ECS Fargate

Your hardened Docker image is reachable on your machine. The remaining challenge is turning that local container into a repeatable public deployment.

In this step, AWS CDK will define the complete AWS environment for your image. You will verify the deployed dashboard through its generated public URL.

In this step, get ready to:
  • Define the infrastructure files for the dashboard stack.
  • Bootstrap the AWS environment and synthesize the CloudFormation template.
  • Deploy the stack and verify the live dashboard logs.
Define the CDK infrastructure

AWS CDK represents cloud infrastructure as application code. Your Python definition will connect the network, container service, runtime secret, load balancer, logs, and stack outputs.

Why ECS Fargate for this project?

Amazon ECS manages the service that keeps your container running. AWS Fargate supplies the task compute without requiring you to manage EC2 instances.

This approach keeps the deployment focused on container operations. It also gives you health checks, runtime secret injection, and centralized logs to verify.

  • Create requirements-infra.txt inside the existing secure-ops-dashboard workspace with this content:
aws-cdk-lib==2.267.0
constructs==10.8.1

What do these dependencies provide?

  • The aws-cdk-lib package supplies the AWS constructs used to define the stack.
  • The constructs package supplies the base construct model used by AWS CDK.
  • The exact versions keep synthesis behavior consistent for this project.
  • Save requirements-infra.txt.
  • Install the infrastructure dependencies into the existing virtual environment by running:
.\.venv\Scripts\python.exe -m pip install -r requirements-infra.txt

What does this command do?

The virtual environment's Python executable runs pip against requirements-infra.txt. This keeps the infrastructure libraries isolated inside .venv.

The installation output should complete without a dependency error. Your virtual environment now contains aws-cdk-lib version 2.267.0 plus constructs version 10.8.1.

Dependency installation failed?

  • Confirm PowerShell is inside the secure-ops-dashboard workspace.
  • Check that requirements-infra.txt contains both pinned package versions.
  • Confirm your Python version meets the project's Python 3.10 or later requirement.

Ask for help: Help me diagnose why the pinned AWS CDK Python dependencies will not install in my Windows virtual environment.

  • Create cdk.json inside the secure-ops-dashboard workspace with this content:
{
  "app": ".venv\\Scripts\\python.exe infra.py"
}

How does CDK start the app?

The app setting tells the CDK CLI to run infra.py with the virtual environment's Python executable. This ensures synthesis uses the dependencies you just installed.

  • Save cdk.json.
  • Create infra.py inside the same workspace with this first section:
import os

from aws_cdk import App, CfnOutput, Environment, RemovalPolicy, Stack
from aws_cdk import aws_ec2 as ec2
from aws_cdk import aws_ecs as ecs
from aws_cdk import aws_ecs_patterns as ecs_patterns
from aws_cdk import aws_secretsmanager as secretsmanager

app = App()
stack = Stack(
    app,
    "SecureDashboardStack",
    env=Environment(
        account=os.getenv("CDK_DEFAULT_ACCOUNT"),
        region="us-east-1",
    ),
)

What does this section define?

  • The imports expose the CDK constructs needed for networking, containers, secrets, and outputs.
  • The App object contains the infrastructure application.
  • The SecureDashboardStack stack uses your authenticated AWS account in us-east-1.
  • Save infra.py.
  • Confirm the stack block ends with the us-east-1 environment setting.

The next construct defines where the Fargate task and load balancer run. This lab uses public subnets without a NAT gateway to limit infrastructure cost.

  • Add the VPC definition below the stack block in infra.py by copying:
vpc = ec2.Vpc(
    stack,
    "Vpc",
    max_azs=2,
    nat_gateways=0,
    subnet_configuration=[
        ec2.SubnetConfiguration(
            name="Public",
            subnet_type=ec2.SubnetType.PUBLIC,
        )
    ],
)

How is the network shaped?

  • The Vpc construct spans two Availability Zones.
  • The nat_gateways=0 setting prevents CDK from creating a NAT gateway.
  • The public subnet configuration supports the task's public IP address and the public load balancer.
  • Save infra.py.
  • Confirm the VPC block contains max_azs=2 plus nat_gateways=0.

The service needs a cluster for task scheduling. It also needs a generated value in AWS Secrets Manager so the container can detect runtime injection.

  • Add the cluster and generated secret below the VPC block by copying:
cluster = ecs.Cluster(stack, "Cluster", vpc=vpc)

app_secret = secretsmanager.Secret(
    stack,
    "AppSecret",
    description="Runtime-only configuration for the operations dashboard",
    removal_policy=RemovalPolicy.DESTROY,
)

What do these resources provide?

  • The Cluster construct gives the ECS service a scheduling boundary inside the VPC.
  • The AppSecret construct generates its value in Secrets Manager.
  • The destroy removal policy allows the generated secret to be removed with the application stack.
  • Save infra.py.
  • Confirm the editor shows cluster plus app_secret below the VPC definition.

An Application Load Balancer will route public requests to one Fargate task. The task receives APP_SECRET only when the container starts.

  • Add the load-balanced Fargate service below the secret definition by copying:
service = ecs_patterns.ApplicationLoadBalancedFargateService(
    stack,
    "DashboardService",
    cluster=cluster,
    assign_public_ip=True,
    public_load_balancer=True,
    cpu=256,
    memory_limit_mib=512,
    desired_count=1,
    task_image_options=ecs_patterns.ApplicationLoadBalancedTaskImageOptions(
        image=ecs.ContainerImage.from_asset("."),
        container_name="dashboard",
        container_port=8000,
        log_driver=ecs.LogDrivers.aws_logs(stream_prefix="secure-dashboard"),
        secrets={
            "APP_SECRET": ecs.Secret.from_secrets_manager(app_secret),
        },
    ),
)

What does the service configure?

  • The service keeps one task running with 256 CPU units plus 512 MiB of memory.
  • The image asset uses the existing Dockerfile from the current workspace.
  • The container listens on port 8000 behind the public load balancer.
  • The log driver sends stdout plus stderr to Amazon CloudWatch Logs with the secure-dashboard stream prefix.
  • The secrets mapping injects APP_SECRET without storing its value in the image.
  • Save infra.py.
  • Confirm the service block ends with the runtime secret mapping.

The load balancer needs a health endpoint before it can decide whether the task should receive traffic. Stack outputs will also preserve the website URL, cluster name, and service name in outputs.json after deployment.

  • Complete infra.py by adding the health check and outputs below the service block:
service.target_group.configure_health_check(
    path="/api/health",
    healthy_http_codes="200",
)

CfnOutput(
    stack,
    "WebsiteUrl",
    value=f"http://{service.load_balancer.load_balancer_dns_name}",
)
CfnOutput(stack, "ClusterName", value=cluster.cluster_name)
CfnOutput(stack, "ServiceName", value=service.service.service_name)

app.synth()

How does this finish the stack?

  • The target group checks /api/health for an HTTP 200 response.
  • The outputs expose WebsiteUrl, ClusterName, and ServiceName after deployment.
  • The final app.synth() call produces the CloudFormation assembly.
  • Save infra.py.
  • Confirm its final line is app.synth().

Infrastructure file looks incomplete?

  • Confirm every code section was added to infra.py in the displayed order.
  • Check that the Python indentation matches each surrounding construct.
  • Confirm cdk.json points to the existing virtual environment.

Ask for help: Help me compare my CDK files with the required stack definition and find any missing or misplaced section.

✔️ Awesome, I've got everything!

Great. Double check that all three infrastructure files are saved in the secure-ops-dashboard workspace.

ⓧ I'd like to double check the full code

Compare these complete files with the saved versions in your workspace.

aws-cdk-lib==2.267.0
constructs==10.8.1
{
  "app": ".venv\\Scripts\\python.exe infra.py"
}
import os

from aws_cdk import App, CfnOutput, Environment, RemovalPolicy, Stack
from aws_cdk import aws_ec2 as ec2
from aws_cdk import aws_ecs as ecs
from aws_cdk import aws_ecs_patterns as ecs_patterns
from aws_cdk import aws_secretsmanager as secretsmanager

app = App()
stack = Stack(
    app,
    "SecureDashboardStack",
    env=Environment(
        account=os.getenv("CDK_DEFAULT_ACCOUNT"),
        region="us-east-1",
    ),
)

vpc = ec2.Vpc(
    stack,
    "Vpc",
    max_azs=2,
    nat_gateways=0,
    subnet_configuration=[
        ec2.SubnetConfiguration(
            name="Public",
            subnet_type=ec2.SubnetType.PUBLIC,
        )
    ],
)

cluster = ecs.Cluster(stack, "Cluster", vpc=vpc)

app_secret = secretsmanager.Secret(
    stack,
    "AppSecret",
    description="Runtime-only configuration for the operations dashboard",
    removal_policy=RemovalPolicy.DESTROY,
)

service = ecs_patterns.ApplicationLoadBalancedFargateService(
    stack,
    "DashboardService",
    cluster=cluster,
    assign_public_ip=True,
    public_load_balancer=True,
    cpu=256,
    memory_limit_mib=512,
    desired_count=1,
    task_image_options=ecs_patterns.ApplicationLoadBalancedTaskImageOptions(
        image=ecs.ContainerImage.from_asset("."),
        container_name="dashboard",
        container_port=8000,
        log_driver=ecs.LogDrivers.aws_logs(stream_prefix="secure-dashboard"),
        secrets={
            "APP_SECRET": ecs.Secret.from_secrets_manager(app_secret),
        },
    ),
)

service.target_group.configure_health_check(
    path="/api/health",
    healthy_http_codes="200",
)

CfnOutput(
    stack,
    "WebsiteUrl",
    value=f"http://{service.load_balancer.load_balancer_dns_name}",
)
CfnOutput(stack, "ClusterName", value=cluster.cluster_name)
CfnOutput(stack, "ServiceName", value=service.service.service_name)

app.synth()
Bootstrap and synthesize the stack

CDK bootstrap prepares the account with shared deployment assets. Synthesis then converts infra.py into an AWS CloudFormation template that you can inspect before deployment.

Keep the safety budget in view

Bootstrapping creates shared AWS assets that can retain stored data. Keep this short lab within the $0.50 safety budget in us-east-1.

  • Bootstrap the authenticated AWS environment by running:
cdk bootstrap

What did bootstrap create?

The command prepares a shared CDKToolkit stack. That stack includes an S3 bucket, an Amazon ECR repository, and IAM roles used during CDK deployments.

The bootstrap command should complete without an authentication or permissions error. Your account is now ready to stage and deploy the image asset.

Bootstrap failed?

  • Confirm PowerShell still uses the intended AWS account from the earlier identity check.
  • Confirm that identity can create CloudFormation, S3, ECR, and IAM resources.
  • Check that the stack region remains us-east-1.

Ask for help: Help me diagnose an AWS CDK bootstrap failure in us-east-1 using the account and permissions shown by my AWS CLI.

Before you synthesize the stack, which values should prove that this design stays small and cost-conscious?

  • Synthesize the CloudFormation template by running:
cdk synth

What does synthesis prove?

The CDK CLI evaluates infra.py and writes the cloud assembly to cdk.out. It also prints the synthesized CloudFormation template for review.

Synthesis checks whether the constructs can produce a valid template. It does not deploy the application stack.

  • Review the synthesized template in PowerShell.
  • Confirm the VPC spans two Availability Zones.
  • Confirm the VPC uses public subnets with no NAT gateway.
  • Confirm the Fargate task uses 256 CPU units.
  • Confirm the Fargate task uses 512 MiB of memory.
  • Confirm the target group checks /api/health for an HTTP 200 response.
  • Confirm the template maps APP_SECRET without containing a plaintext secret value.

The synthesized design now matches the intended baseline. It defines two public subnets without a NAT gateway plus one small Fargate task behind the load balancer.

Synthesis reported a problem?

  • Check the first reported Python file and line number.
  • Compare the surrounding section with the full infra.py reference.
  • Confirm the virtual environment contains both pinned infrastructure dependencies.

Ask for help: Help me trace my CDK synthesis error to the relevant section of infra.py.

Deploy and verify the live dashboard

The synthesized template is ready to create billable AWS resources. Deployment builds the image asset, publishes it to ECR, creates the stack, and starts the load-balanced task.

This deployment creates billable resources

The Fargate task, load balancer, public IPv4 addresses, generated secret, logs, and retained assets can generate charges. Keep the deployment short and stay inside the $0.50 safety budget.

Deployment can take several minutes while CDK builds the image and CloudFormation creates the network. A quiet terminal during part of this process does not mean the deployment has stopped.

CDK pauses so you can review the IAM changes before resources are created.

  • Review the IAM changes when the approval prompt appears.
  • Approve the deployment at that prompt.
  • Deploy SecureDashboardStack and write its outputs by running:
cdk deploy --outputs-file outputs.json

What happens during deployment?

  • CDK builds the container image from the existing Dockerfile.
  • The image asset is published to ECR for the Fargate task.
  • CloudFormation creates the VPC, cluster, service, load balancer, IAM roles, secret, and log group.
  • The outputs.json file receives WebsiteUrl, ClusterName, and ServiceName.

The deployment output should report that SecureDashboardStack completed successfully. The workspace should now contain outputs.json.

Deployment did not complete?

  • Read the first failed CloudFormation resource in the deployment output.
  • Confirm Docker Desktop still runs Linux containers before retrying an image build failure.
  • Confirm the authenticated AWS identity can create every resource named in the project prerequisites.

Ask for help: Help me identify the first failed resource in my CDK deployment and suggest a safe next check.

Before you open the public URL, which runtime and secret status do you expect the deployed dashboard to report?

  • Load the stack outputs into PowerShell by running the first command below.
  • Open the generated website URL by running the second command below.
$Outputs = Get-Content .\outputs.json | ConvertFrom-Json
Start-Process $Outputs.SecureDashboardStack.WebsiteUrl

How do these commands find the site?

The first command converts outputs.json into a PowerShell object named $Outputs. The second command reads WebsiteUrl from SecureDashboardStack and opens it in your browser.

Your live dashboard should report online for its API status. It should show ECS Fargate as the runtime.

You should also see a task hostname plus Injected under the runtime secret card. That result proves APP_SECRET reached the container at runtime without exposing its value.

  • Click Refresh status on the live dashboard.
  • Use the AWS console search to open CloudWatch Logs.
  • Select the log group created for SecureDashboardStack.
  • Select a log stream whose name begins with secure-dashboard.
  • Refresh the log entries after using the dashboard.
  • Confirm the stream contains a Gunicorn access request for /api/status.

That's the deployment working end to end. Your public load balancer now reaches a non-root Fargate container with health checks, runtime secret injection, and centralized request logs.

Secret mission

Prove ECS Self-Healing

Create a controlled service incident by stopping the dashboard's running task. You will prove that Amazon ECS launches a replacement task and restores the public dashboard without a manual task launch.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your resources running, pause the Fargate service for later, or delete the deployment entirely.

This project uses chargeable AWS resources in us-east-1. Leaving the stack deployed can push your usage beyond the $0.50 safety budget.

Cost warning

The AWS Fargate task, Application Load Balancer, public IPv4 addresses, AWS Secrets Manager secret, and Amazon CloudWatch Logs can generate charges while the application stack exists.

The Application Load Balancer bills each partial hour as a full hour. Stored assets in Amazon ECR and Amazon S3 can continue generating small charges after the application stack is destroyed.

Delete the deployment immediately if you have finished the lab. This is the safest way to prevent unexpected usage.

Resources you used:

  • The SecureDashboardStack networking layer, including its VPC, two public subnets, security groups, and IAM roles.
  • The Amazon ECS cluster, desired-count-one Fargate service, task definition, Application Load Balancer, target group, and health check.
  • The generated AppSecret secret and CloudWatch log group.
  • The shared AWS CDK bootstrap stack named CDKToolkit, including its S3 bucket, ECR repository, and IAM roles.
  • The local secure-ops-dashboard workspace and its .venv environment.

Keep everything running

No action is needed. Choose this option if you are still testing the live dashboard or want to extend the deployment.

  • The ECS service continues maintaining one running Fargate task.
  • The public HTTP dashboard remains reachable through the Application Load Balancer.
  • The secret, logs, public IPv4 addresses, and stored deployment assets remain in your AWS account.
  • Usage charges continue while these resources remain deployed.
  • Your local secure-ops-dashboard workspace remains available for future changes.

Pause - I'll come back to this later

Pause the running task to stop Fargate compute while keeping the deployment ready for later. The load balancer, public IPv4 addresses, secret, logs, and stored assets can still generate charges.

  • Reduce the ECS service to zero running tasks by running this command in the authenticated PowerShell session from earlier:
aws ecs update-service --cluster $Cluster --service $Service --desired-count 0

What does this command do?

The command changes the service desired count to zero. ECS stops the running task while retaining the service configuration.

Fargate task compute stops after the task exits. The Application Load Balancer and the remaining AWS resources stay provisioned.

PowerShell returns updated service details. After the task stops, the dashboard URL no longer reports an online service.

  • Restore the ECS service when you return by running this command:
aws ecs update-service --cluster $Cluster --service $Service --desired-count 1

How does the service resume?

The desired count returns to one. ECS launches a replacement task using the existing task definition.

  • Wait for the replacement task to become stable by running:
aws ecs wait services-stable --cluster $Cluster --services $Service

What does the waiter confirm?

The waiter keeps checking the ECS service until its running count matches its desired count. This gives the replacement task time to pass the load balancer health check.

  • Return to the dashboard URL from earlier and refresh the page.

You should see the online status again after the replacement task becomes healthy.

Delete - I don't want to use this again

Deleting the deployment is permanent, which can feel drastic. Your local source files remain untouched until the final local cleanup.

PowerShell asks for confirmation before AWS CDK deletes the application stack.

  • Return to the secure-ops-dashboard workspace in the authenticated PowerShell session from earlier.
  • Start the application stack deletion by running:
cdk destroy SecureDashboardStack

What does this command remove?

AWS CDK deletes the resources managed by SecureDashboardStack. This includes the VPC, ECS cluster, Fargate service, load balancer, target group, security groups, IAM roles, generated secret, and CloudWatch log group.

The shared CDKToolkit bootstrap stack remains because other CDK deployments may depend on it.

  • Confirm the deletion when PowerShell prompts you.
  • Wait for AWS CloudFormation to confirm the stack deletion by running:
aws cloudformation wait stack-delete-complete --stack-name SecureDashboardStack --region us-east-1

What does this waiter prove?

The command waits until CloudFormation finishes removing SecureDashboardStack. PowerShell returns control after the stack deletion completes.

That is the main cost-bearing stack gone. Its load balancer and running Fargate task no longer generate new usage.

Protect Shared CDK Assets

The CDKToolkit stack contains shared S3, ECR, and IAM resources used by AWS CDK. Delete it only when this AWS account has no other CDK projects that depend on those assets.

  • Delete the shared bootstrap stack only when no other CDK projects use it by running:
aws cloudformation delete-stack --stack-name CDKToolkit --region us-east-1

What does this deletion target?

This command starts deletion of the shared CDKToolkit stack. Its bootstrap S3 bucket, ECR repository, and IAM roles belong to that stack.

  • Confirm in the AWS CloudFormation console that CDKToolkit no longer appears after deletion completes.

Deletion Not Completing?

  • Inspect the CloudFormation event that names the blocked resource.
  • Keep CDKToolkit if another AWS CDK project depends on its assets.

help me troubleshoot this AWS cleanup.

Remove the local workspace:

  • Close the authenticated PowerShell session from earlier.
  • Use Windows File Explorer to delete the secure-ops-dashboard folder from its current location.

The secure-ops-dashboard folder should disappear from File Explorer with its application, container, CDK, and virtual environment files.

Nice Work!

Nice Work!

Mission complete! Your operations dashboard now serves live Flask API status from a hardened container on Amazon ECS behind an Application Load Balancer. You also proved that the service replaces a failed task without a manual launch.

You've learned how to:

  • Built a full-stack operations dashboard that refreshes live data from a Flask REST backend. The dashboard shows API state plus runtime details. It also shows the container hostname plus runtime secret status.
  • Reproduced a Docker networking incident caused by Gunicorn binding to loopback. Fixed reachability by binding Gunicorn to all container interfaces. Configured the image to run with numeric non-root identity 10001:10001.
  • Defined the deployment with AWS CDK. The stack runs one ECS Fargate task behind an Application Load Balancer. AWS Secrets Manager injects runtime configuration without exposing its value. Amazon CloudWatch Logs captures Gunicorn request logs.
  • Secret Mission: Proved ECS self-healing by stopping the running task. The desired-count-one service launched a replacement automatically. The dashboard returned to online status with a different task ARN.

Ready to quiz yourself?