Recover a Secure AWS Web Tier

Deploy, monitor, and recover a private EC2 web tier with CDK and GitHub OIDC.

Introduction

30 Second Summary

An online service can look healthy from inside its server while every visitor sees an error page. The real test is whether you can prove where the failure lives.

In this project, you will build an automated web tier on Amazon Web Services using AWS CDK, GitHub Actions, and Amazon CloudWatch. You will diagnose an intentional network failure before repairing the path to a private Linux server.

What You'll Build

You will open a public URL to a green Cloud Engineer Portfolio status page served by a private Linux instance.

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

  • A live status page that proves a public URL can reach a private Linux target without exposing the instance directly.
  • A network recovery demo that turns 503 Service Unavailable into a healthy target through a least-privilege security group fix.
  • An automated operations trail where a push to main produces a live page update with dashboard evidence for service health.
  • Secret Mission: Recover a deliberate Apache outage through AWS Systems Manager without SSH.

Are there any prerequisites?

You need an AWS account plus a GitHub account. This guide verifies Node.js v24.21.0, npm, Git, and Visual Studio Code on your Mac before the cloud work starts.

Before We Start

Before any hands-on work, commit to the story this project proves for the Populous Cloud Engineer role. You will explain how each deliberate design choice demonstrates secure networking, troubleshooting, or operational maturity.

Prepare Your Mac and AWS Shell

Later, you will diagnose a deliberately broken network path. An unverified local command or AWS identity could make that failure ambiguous.

This step removes that uncertainty from your Mac toolchain. It also proves that AWS CloudShell is using your intended console identity.

In this step, get ready to:
  • Verify Node.js 24.21.0 plus npm on your Mac.
  • Verify Git plus Visual Studio Code on your Mac.
  • Confirm the CloudShell tools plus your authenticated AWS identity.
Verify your Mac toolchain

Version checks expose local setup problems before any cloud code exists. This project pins your Mac to Node.js 24.21.0 so later results stay reproducible.

  • Press Cmd+Space to open Spotlight Search.
  • Type Terminal into Spotlight Search.
  • Press Enter to open Terminal.
  • Check the four local commands by running:
node --version
npm --version
git --version
code --version

What do these checks prove?

  • The first line confirms which Node.js runtime your Mac uses.
  • The second line confirms that npm is available for installing project packages.
  • The third line confirms that Git can track your project history.
  • The final output confirms that Terminal can launch Visual Studio Code through the code command.

Start with the Node.js result. Choose the tab that matches the first line from your terminal.

✔️ I see the required Node.js release

Your first line shows v24.21.0. Your Node.js runtime plus npm are ready.

ⓧ I see an older Node.js release

Your Mac has Node.js, but this project uses v24.21.0 for consistent AWS CDK results.

  • Open the official Node.js download page in your browser.
  • Download the official macOS package for Node.js v24.21.0.
  • Run the downloaded installer package.
  • Complete the installer prompts.
  • Close Terminal after the installation completes.
  • Reopen Terminal through Spotlight Search.
  • Confirm the installed runtime plus npm by running:
node --version
npm --version

What should the recheck show?

The first line should show v24.21.0. The second line should show an npm version number.

Still seeing the older release?

Close every Terminal window before opening a new one. This forces your shell to reload its command search path.

If the old release remains active, ask for help checking which Node.js executable your Mac is using.

ⓧ Node.js command not found

Node.js is missing from your Mac. The official package installs the runtime plus npm together.

  • Open the official Node.js download page in your browser.
  • Download the official macOS package for Node.js v24.21.0.
  • Run the downloaded installer package.
  • Complete the installer prompts.
  • Close Terminal after the installation completes.
  • Reopen Terminal through Spotlight Search.
  • Confirm the new runtime plus npm by running:
node --version
npm --version

What should the installation add?

The first line should show v24.21.0. The second line confirms that the matching npm command is available.

Node.js still not recognized?

Confirm that the installer completed before reopening Terminal. An existing Terminal session can retain the command path from before installation.

If the command remains unavailable, ask for help fixing the Node.js command path on macOS.

Next, match the Git result from the same terminal check.

✔️ Git prints a version

Git is installed. Your Mac can create commits plus publish the project in the next step.

ⓧ Git command not found

Git needs to be installed before your Mac can publish the project history.

  • Open the official Git installation page for macOS in your browser.
  • Choose a macOS installation option from the page.
  • Complete the installation steps for that option.
  • Close Terminal after the installation completes.
  • Reopen Terminal through Spotlight Search.
  • Confirm Git is available by running:
git --version

What should the Git check show?

You should see a line containing the Git name plus a version number. That output confirms Terminal can find the installed command.

Git still missing?

Confirm that you completed one installation option from the official page. Reopen Terminal after the installer finishes.

If the command still fails, ask for help checking your Git installation on macOS.

Finally, match the Visual Studio Code result. The code command must work from Terminal.

✔️ The code command prints details

Visual Studio Code plus its terminal command are ready. You can launch the editor from a project directory later.

ⓧ Visual Studio Code is installed

The command-path action is tucked inside Visual Studio Code, so it is easy to miss. Running it connects the desktop app to Terminal.

  • Press Cmd+Space to open Spotlight Search.
  • Type Visual Studio Code into Spotlight Search.
  • Press Enter to open Visual Studio Code.
  • Run the Shell Command: Install 'code' command in PATH action inside Visual Studio Code.
  • Close Terminal after the action completes.
  • Reopen Terminal through Spotlight Search.
  • Confirm the terminal command by running:
code --version

What should the command show?

You should see Visual Studio Code version details across several lines. This proves the code command is on your shell path.

The code command still fails?

Close every Terminal window after running the shell-command action. A fresh Terminal session loads the updated path.

If the command remains unavailable, ask for help connecting Visual Studio Code to your Mac terminal.

ⓧ Visual Studio Code is not installed

Install the desktop app first. Then add its terminal command to your shell path.

  • Open the official Visual Studio Code macOS setup guide in your browser.
  • Download Visual Studio Code for macOS.
  • Open the downloaded disk image.
  • Move Visual Studio Code.app into the Applications folder.
  • Open Visual Studio Code through Spotlight Search.
  • Run the Shell Command: Install 'code' command in PATH action inside Visual Studio Code.
  • Close Terminal after the action completes.
  • Reopen Terminal through Spotlight Search.
  • Confirm the installation by running:
code --version

What should the installation provide?

You should see Visual Studio Code version details. The output confirms that the app is installed plus reachable from Terminal.

Visual Studio Code still unavailable?

Confirm that Visual Studio Code.app is inside the Applications folder. Run the shell-command action again before reopening Terminal.

If the check still fails, ask for help completing the Visual Studio Code setup on macOS.

Verify AWS CloudShell and your identity

CloudShell forwards your console credentials into its preinstalled AWS CLI. The identity check proves which AWS account plus principal your later deployments use.

  • Sign in to the AWS Management Console with the account you plan to use for this project.
  • Select US East (N. Virginia) from the region selector.
  • Choose CloudShell from the console toolbar.
  • Wait for the CloudShell prompt to become ready.
  • Check the preinstalled tools by running:
aws --version
node --version
npm --version
git --version

What do the CloudShell checks prove?

  • The first line confirms that the AWS CLI is ready.
  • The next three lines confirm that CloudShell has Node.js plus npm plus Git.
  • These versions belong to CloudShell. They are separate from the versions installed on your Mac.

Before you run the identity check, which AWS account do you expect CloudShell to report?

  • Reveal the active console identity by running:
aws sts get-caller-identity

What does the identity response mean?

  • The UserId field identifies the active principal.
  • The Account field identifies the AWS account.
  • The Arn field describes the authenticated identity.

You should see a response containing UserId plus Account plus Arn. This confirms CloudShell is already authenticated with your console identity.

Activate Node.js 24.21.0 in CloudShell if needed

CloudShell images can expose different Node.js releases over time. If your result is older than 22, Node Version Manager activates this project's 24.21.0 runtime.

Choose the tab that matches the CloudShell Node.js result you saw above.

✔️ CloudShell Node.js is ready

Your CloudShell release is 22 or newer. Continue to the final verification.

ⓧ I see an older Node.js release

Install Node Version Manager plus Node.js 24.21.0 in your CloudShell home environment.

  • Install Node Version Manager plus activate the pinned runtime by running these commands:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 24.21.0
nvm use 24.21.0

What do these commands do?

  • The first command downloads plus runs the Node Version Manager installer.
  • The source ~/.bashrc command loads it into the current shell.
  • The final commands install plus activate Node.js 24.21.0.
  • Confirm the active runtime plus npm by running:
node --version
npm --version

What should CloudShell show now?

The first line should show v24.21.0. The second line should show an npm version number.

The older release is still active?

Run the Node Version Manager activation command again in the same CloudShell session. Confirm that the installation completed before checking the version.

If the release remains unchanged, ask for help activating Node.js through Node Version Manager.

ⓧ Node.js command not found

Install Node Version Manager plus Node.js 24.21.0 to add the missing runtime to CloudShell.

  • Install Node Version Manager plus activate the pinned runtime by running these commands:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 24.21.0
nvm use 24.21.0

What does this installation add?

  • Node Version Manager installs into your CloudShell home environment.
  • Reloading ~/.bashrc makes the version-manager command available immediately.
  • The final commands install plus activate Node.js 24.21.0.
  • Confirm the new runtime plus npm by running:
node --version
npm --version

What should the new commands show?

The first line should show v24.21.0. The second line confirms npm was installed with Node.js.

Node.js still missing in CloudShell?

Confirm that the installation script completed before loading ~/.bashrc. Keep the CloudShell session open while you run the remaining commands.

If Node.js remains unavailable, ask for help completing the Node Version Manager setup in CloudShell.

Before the final check, do you expect CloudShell to recognize every command plus return the same AWS identity as before?

  • Verify the CloudShell toolchain plus authenticated identity by running:
aws --version
node --version
npm --version
git --version
aws sts get-caller-identity

What does final success look like?

The first four checks should print version information. The identity response should contain UserId plus Account plus Arn.

Your Mac commands now work. CloudShell also has an authenticated AWS identity plus a suitable Node.js runtime.

That clears the setup uncertainty: your Mac tools are ready plus CloudShell can make authenticated AWS requests. Next, you will build the status page that becomes the visible heart of the web tier.

Build and Publish the Status Page

Your Mac toolchain is ready. The next risk is proving the page itself works before AWS networking enters the picture.

You'll use Node.js to preview the status page. You'll also prepare AWS CDK and TypeScript configuration before publishing the project to GitHub.

In this step, get ready to:
  • Create the local AWS CDK TypeScript project.
  • Preview the Cloud Engineer Portfolio status page.
  • Publish the initial project to a public GitHub repository.
Build and preview the local project

The local project holds the status page plus the configuration that later steps use for cloud infrastructure. Keeping everything together gives the preview server and future deployment code one version-controlled home.

  • Press Cmd+Space to open Spotlight on your Mac.
  • Type Visual Studio Code into Spotlight.
  • Press Enter to open Visual Studio Code.
  • Create a terminal from the top menu in Visual Studio Code.
  • Create the project folders on your Desktop by running these commands:
cd ~/Desktop
mkdir -p cloud-engineer-portfolio/tools cloud-engineer-portfolio/site
cd cloud-engineer-portfolio
code .

What do these commands do?

  • The first command moves your terminal to the Desktop.
  • The second command creates the cloud-engineer-portfolio folder plus its tools and site subfolders.
  • The third command moves the terminal into the new project folder.
  • The final command opens that folder as your Visual Studio Code workspace.

Your project shell is now in place. The tools and site folders should be visible in the Explorer sidebar.

Project folders missing?

Check that the Explorer sidebar shows cloud-engineer-portfolio as the workspace folder. If another folder is open, run the commands again from a fresh terminal.

Ask for help with finding the new project folder.

The package.json file pins every project dependency. Pinning versions keeps later builds consistent across your Mac, CloudShell, and GitHub Actions.

  • Select the new-file icon beside cloud-engineer-portfolio in the Explorer sidebar.
  • Enter package.json as the file name.
  • Add this dependency and script configuration to package.json:
{
  "name": "cloud-engineer-portfolio",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "tsc",
    "preview": "node tools/preview.mjs",
    "cdk": "cdk"
  },
  "dependencies": {
    "aws-cdk-lib": "2.272.0",
    "constructs": "10.8.1"
  },
  "devDependencies": {
    "@types/node": "24.0.0",
    "aws-cdk": "2.1144.0",
    "ts-node": "10.9.2",
    "typescript": "5.9.3"
  }
}

What does this file control?

This JSON document defines the commands available through npm. The preview script starts the local page server.

The dependency lists pin aws-cdk-lib to 2.272.0, constructs to 10.8.1, aws-cdk to 2.1144.0, ts-node to 10.9.2, TypeScript to 5.9.3, and @types/node to 24.0.0.

  • Save package.json.

The first dependency installation can take a minute while npm downloads the pinned packages. A short pause here is expected.

  • Install the pinned dependencies without creating a lock file by running:
npm install --package-lock=false

What does this command do?

The command reads package.json and installs its exact dependency versions into node_modules. The flag prevents npm from writing package-lock.json.

The terminal should print package installation activity. You should also see node_modules appear in the project.

Dependency installation failed?

Confirm that the terminal prompt is inside cloud-engineer-portfolio. Also confirm that package.json has matching braces and commas.

Ask for help with diagnosing the npm installation.

The remaining configuration files tell TypeScript which project files to compile. They also tell AWS CDK how to start the future application.

  • Select the new-file icon beside cloud-engineer-portfolio in the Explorer sidebar.
  • Enter tsconfig.json as the file name.
  • Add this strict TypeScript configuration to tsconfig.json:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "moduleResolution": "node",
    "lib": ["es2022"],
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "inlineSourceMap": true,
    "inlineSources": true,
    "experimentalDecorators": true,
    "strictPropertyInitialization": false,
    "typeRoots": ["./node_modules/@types"]
  },
  "include": ["bin/**/*.ts", "lib/**/*.ts"]
}

What does this configuration do?

Strict compiler checks catch missing types and unused code before deployment. The final include rule limits compilation to TypeScript files inside bin and lib.

  • Save tsconfig.json.
  • Confirm that tsconfig.json appears beside package.json in the Explorer sidebar.

TypeScript configuration marked as invalid?

Check the commas after each property. Also check that every opening brace has a matching closing brace.

Ask for help with finding the JSON syntax issue.

  • Select the new-file icon beside cloud-engineer-portfolio in the Explorer sidebar.
  • Enter cdk.json as the file name.
  • Add this application command to cdk.json:
{
  "app": "npx ts-node --prefer-ts-exts bin/cloud-engineer-portfolio.ts"
}

How does AWS CDK use this file?

AWS CDK reads the app value when it needs to load the infrastructure application. The command points to the entry file that you add in the next step.

  • Save cdk.json.
  • Confirm that cdk.json appears beside tsconfig.json in the Explorer sidebar.

CDK configuration marked as invalid?

Make sure the application command remains inside double quotes. Also check the braces around the property.

Ask for help with checking cdk.json.

The ignore file keeps generated dependencies and deployment output out of Git history. This prevents bulky local artifacts from appearing in the public repository.

  • Select the new-file icon beside cloud-engineer-portfolio in the Explorer sidebar.
  • Enter .gitignore as the file name.
  • Add these exclusions to .gitignore:
node_modules/
cdk.out/
outputs.json
.DS_Store

What stays out of Git?

The rules exclude installed dependencies, synthesized CDK output, generated stack outputs, and the macOS metadata file. Every excluded artifact can be recreated or contains machine-specific state.

  • Save .gitignore.
  • Confirm that .gitignore appears in the Explorer sidebar.

Cannot see the ignore file?

Confirm that the file name starts with a single full stop. The complete name is .gitignore.

Ask for help with creating the hidden file.

The preview server creates a small local HTTP endpoint. It reads the page from site/index.html for every request.

  • Select the tools folder in the Explorer sidebar.
  • Select the new-file icon beside tools.
  • Enter preview.mjs as the file name.
  • Add this preview server to tools/preview.mjs:
import http from 'node:http';
import { readFile } from 'node:fs/promises';

const port = 8080;
const server = http.createServer(async (_request, response) => {
  try {
    const html = await readFile(new URL('../site/index.html', import.meta.url));
    response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    response.end(html);
  } catch (error) {
    response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
    response.end(`Preview failed: ${error instanceof Error ? error.message : 'unknown error'}`);
  }
});
server.listen(port, '127.0.0.1', () => console.log(`Portfolio preview: http://127.0.0.1:${port}`));

What does the preview server do?

  • The server listens on local port 8080.
  • Each request reads the latest contents of site/index.html.
  • A successful read returns the page as HTML.
  • A failed read returns a plain-text error with an HTTP status of 500.
  • Save tools/preview.mjs.
  • Confirm that preview.mjs appears inside the tools folder.

Preview file in the wrong folder?

Drag preview.mjs into the tools folder in the Explorer sidebar. Its final path must be tools/preview.mjs.

Ask for help with fixing the file path.

The first page version provides a plain structural baseline. Running it before adding styles proves the server can already find and return the correct file.

  • Select the site folder in the Explorer sidebar.
  • Select the new-file icon beside site.
  • Enter index.html as the file name.
  • Add this page structure to site/index.html:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Cloud Engineer Portfolio</title>
</head>
<body>
  <main>
    <p class="status">SYSTEM HEALTHY</p>
    <h1>Secure AWS Web Tier</h1>
    <p class="subtitle">A hiring-manager demo of Linux, networking, AWS CDK, CI/CD, monitoring, and incident recovery.</p>
    <section class="grid">
      <article><h2>Network</h2><p>Public load balancer, private application subnet, and security-group segmentation.</p></article>
      <article><h2>Linux</h2><p>Amazon Linux 2023, Apache, systemd, and Systems Manager operations.</p></article>
      <article><h2>Delivery</h2><p>GitHub Actions uses OIDC and synchronizes this page to a private S3 bucket.</p></article>
      <article><h2>Monitoring</h2><p>CloudWatch tracks requests, latency, target health, CPU, and network flow records.</p></article>
    </section>
    <footer>Deployment version: <code>v1.1</code></footer>
  </main>
</body>
</html>

How is the status page organized?

The main status message identifies the demo as healthy. Four articles summarize the Network, Linux, Delivery, and Monitoring capabilities.

The footer exposes deployment version v1.1. That visible version later confirms whether automated delivery reached the live server.

  • Save site/index.html.

Before you start the preview, which page sections do you expect the browser to show from this first structural version?

  • Start the local preview server by running:
npm run preview

What does the preview command do?

npm runs the preview script from package.json. That script starts tools/preview.mjs on local port 8080.

The server keeps this terminal occupied while it runs. Leave the process active so browser refreshes can load your latest saved page.

  • Open a browser tab.
  • Enter http://127.0.0.1:8080 in the address bar.

You should see SYSTEM HEALTHY, the Secure AWS Web Tier heading, and four unstyled capability sections. This proves the local server can read the page.

Local page unavailable?

Check that the preview terminal is still running. Also confirm that the page is saved at site/index.html.

Ask for help with diagnosing the local preview.

A CSS stylesheet turns the structural page into the finished status board. The layout uses a dark background, responsive cards, and green health text.

  • Return to site/index.html in Visual Studio Code.
  • Find the line containing <title>Cloud Engineer Portfolio</title>.
  • Insert this style block immediately below that title line:
  <style>
    :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #07111f; color: #e8f0ff; }
    body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: radial-gradient(circle at top, #17345f, #07111f 60%); }
    main { width: min(920px, 90vw); padding: 48px; border: 1px solid #31527d; border-radius: 24px; background: rgba(8, 23, 42, 0.92); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
    h1 { margin-top: 0; font-size: clamp(2rem, 5vw, 4rem); }
    .subtitle { color: #a9bad3; font-size: 1.1rem; }
    .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 16px; margin-top: 32px; }
    article { padding: 20px; border-radius: 16px; background: #0e213b; border: 1px solid #294b76; }
    .status { color: #58e49b; font-weight: 700; }
    code { color: #8fc6ff; }
    footer { margin-top: 28px; color: #8197b5; }
  </style>

What do these styles change?

  • The page background uses a dark radial gradient.
  • The main panel gains spacing, rounded corners, a border, and a shadow.
  • The grid adapts the four capability cards to the available browser width.
  • The health status becomes green so the operational state stands out.
  • Save site/index.html.
  • Return to the local browser tab.
  • Refresh http://127.0.0.1:8080.

You should now see a centered dark status board with green SYSTEM HEALTHY text. The Network, Linux, Delivery, and Monitoring sections should appear as bordered cards.

Page still looks unstyled?

Confirm that the style block sits inside <head> before the closing </head> tag. Also confirm that you saved site/index.html before refreshing.

Ask for help with finding the missing styles.

✔️ Awesome, I've got everything!

Your six project files are saved. Your local preview now shows the finished green status board.

ⓧ I'd like to double check the full code

Compare each file with these complete references. The contents below represent the exact project state for this step.

{
  "name": "cloud-engineer-portfolio",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "tsc",
    "preview": "node tools/preview.mjs",
    "cdk": "cdk"
  },
  "dependencies": {
    "aws-cdk-lib": "2.272.0",
    "constructs": "10.8.1"
  },
  "devDependencies": {
    "@types/node": "24.0.0",
    "aws-cdk": "2.1144.0",
    "ts-node": "10.9.2",
    "typescript": "5.9.3"
  }
}

Package reference

This file contains the complete dependency pins and project scripts for the current step.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "moduleResolution": "node",
    "lib": ["es2022"],
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "inlineSourceMap": true,
    "inlineSources": true,
    "experimentalDecorators": true,
    "strictPropertyInitialization": false,
    "typeRoots": ["./node_modules/@types"]
  },
  "include": ["bin/**/*.ts", "lib/**/*.ts"]
}

TypeScript reference

This file contains the complete strict compiler configuration for the current step.

{
  "app": "npx ts-node --prefer-ts-exts bin/cloud-engineer-portfolio.ts"
}

CDK reference

This file contains the complete AWS CDK application command for the current step.

node_modules/
cdk.out/
outputs.json
.DS_Store

Ignore-file reference

This file contains every generated or machine-specific path excluded from Git.

import http from 'node:http';
import { readFile } from 'node:fs/promises';

const port = 8080;
const server = http.createServer(async (_request, response) => {
  try {
    const html = await readFile(new URL('../site/index.html', import.meta.url));
    response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    response.end(html);
  } catch (error) {
    response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
    response.end(`Preview failed: ${error instanceof Error ? error.message : 'unknown error'}`);
  }
});
server.listen(port, '127.0.0.1', () => console.log(`Portfolio preview: http://127.0.0.1:${port}`));

Preview-server reference

This file contains the complete local HTTP server for the current step.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Cloud Engineer Portfolio</title>
  <style>
    :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #07111f; color: #e8f0ff; }
    body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: radial-gradient(circle at top, #17345f, #07111f 60%); }
    main { width: min(920px, 90vw); padding: 48px; border: 1px solid #31527d; border-radius: 24px; background: rgba(8, 23, 42, 0.92); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
    h1 { margin-top: 0; font-size: clamp(2rem, 5vw, 4rem); }
    .subtitle { color: #a9bad3; font-size: 1.1rem; }
    .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 16px; margin-top: 32px; }
    article { padding: 20px; border-radius: 16px; background: #0e213b; border: 1px solid #294b76; }
    .status { color: #58e49b; font-weight: 700; }
    code { color: #8fc6ff; }
    footer { margin-top: 28px; color: #8197b5; }
  </style>
</head>
<body>
  <main>
    <p class="status">SYSTEM HEALTHY</p>
    <h1>Secure AWS Web Tier</h1>
    <p class="subtitle">A hiring-manager demo of Linux, networking, AWS CDK, CI/CD, monitoring, and incident recovery.</p>
    <section class="grid">
      <article><h2>Network</h2><p>Public load balancer, private application subnet, and security-group segmentation.</p></article>
      <article><h2>Linux</h2><p>Amazon Linux 2023, Apache, systemd, and Systems Manager operations.</p></article>
      <article><h2>Delivery</h2><p>GitHub Actions uses OIDC and synchronizes this page to a private S3 bucket.</p></article>
      <article><h2>Monitoring</h2><p>CloudWatch tracks requests, latency, target health, CPU, and network flow records.</p></article>
    </section>
    <footer>Deployment version: <code>v1.1</code></footer>
  </main>
</body>
</html>

Status-page reference

This file contains the complete styled Cloud Engineer Portfolio page for the current step.

Create the public GitHub repository

The local preview proves that the page works on your Mac. A public repository turns that result into reviewable project history.

Public visibility means anyone can read the committed files. This project contains no credentials, so the initial files are safe to publish.

  • Go to GitHub's new repository page in your browser.
  • Sign in to your GitHub account if prompted.
  • Enter cloud-engineer-portfolio in the Repository name field.
  • Select Public as the repository visibility.
  • Leave README initialization turned off.
  • Leave .gitignore initialization turned off.
  • Leave license initialization turned off.
  • Select Create repository.

GitHub should open the empty repository page. Its address should end with /cloud-engineer-portfolio.

  • Record the repository owner shown before /cloud-engineer-portfolio as your GitHub username.

Repository already contains files?

The repository must begin empty because your local project already contains its own ignore file. Create another repository with README, license, and ignore-file initialization turned off.

Ask for help with creating an empty repository.

Publish the initial project

Git records the project files as a commit on the main branch. The remote connection then sends that commit to the empty repository.

Git may ask you to authenticate with GitHub during the first push. Complete the browser sign-in if that prompt appears.

  • Return to Visual Studio Code.
  • Create another terminal from the top menu so the preview server can keep running.

Before you publish, which branch do you expect GitHub to show after the push completes?

  • Create the Git history and publish the project by running these commands:
git init -b main
git add .
git commit -m "Build local cloud status page"
git remote add origin https://github.com/[[GITHUB_OWNER="your GitHub username"]]/cloud-engineer-portfolio.git
git push -u origin main

What do these Git commands do?

  • The first command creates a repository with main as its initial branch.
  • The second command stages every project file that is not excluded by .gitignore.
  • The third command records the initial project snapshot.
  • The fourth command connects the local repository to your GitHub repository.
  • The final command publishes main and configures it to track the remote branch.
  • Return to the GitHub repository page from the previous substep.
  • Refresh the repository page.

You should see the main branch with package.json, tsconfig.json, cdk.json, .gitignore, tools, and site.

That completes the local-to-public loop. Your working status page now has a visible commit history that later infrastructure changes can build on.

Push did not reach GitHub?

Check that the remote URL uses your exact GitHub username. If authentication opens in the browser, finish that sign-in before retrying the push.

Ask for help with diagnosing the failed push.

Your status page now runs locally and lives in a public repository. Next, you'll place it behind a private Linux target and observe the planned network failure.

Deploy the Broken Private Web Tier

Your local status page proves that the content works. Cloud deployment adds a second failure surface because a healthy Linux service can still be unreachable through its network path.

You will define that network with AWS CDK. You will then deploy a deliberately incomplete path behind an Application Load Balancer and collect the failure evidence.

In this step, get ready to:
  • Define the private web tier as TypeScript infrastructure.
  • Publish the infrastructure code and record the repository identifiers.
  • Deploy the stack and observe the unhealthy target.
Define the intentionally broken stack

The stack places an Amazon EC2 web server in a private subnet. Public requests enter through the load balancer while administrative commands use AWS Systems Manager.

Why this architecture?

A public load balancer can accept internet traffic while the instance remains private. This preserves a controlled entry point and prevents direct access to the Linux server.

Systems Manager provides the operational path without opening SSH or managing an SSH key. The result demonstrates both network segmentation and Linux administration.

  • Switch back to the local cloud-engineer-portfolio project in Visual Studio Code.
  • Create a bin folder from the Explorer sidebar.
  • Create cloud-engineer-portfolio.ts inside the bin folder.
  • Add the CDK application entry point to bin/cloud-engineer-portfolio.ts by copying this code:
#!/usr/bin/env node
import { App } from 'aws-cdk-lib';
import { CloudEngineerPortfolioStack } from '../lib/cloud-engineer-portfolio-stack';

const app = new App();

new CloudEngineerPortfolioStack(app, 'CloudEngineerPortfolio', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

What does the CDK entry point do?

  • The App object contains the CDK application.
  • CloudEngineerPortfolio becomes the stack name used by deployment commands.
  • CDK_DEFAULT_ACCOUNT and CDK_DEFAULT_REGION bind the stack to the authenticated AWS environment.
  • Save bin/cloud-engineer-portfolio.ts.
  • Create a lib folder from the Explorer sidebar.
  • Create cloud-engineer-portfolio-stack.ts inside the lib folder.
  • Add the imports and stack foundation to lib/cloud-engineer-portfolio-stack.ts by copying this code:
import { CfnOutput, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as targets from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import { Construct } from 'constructs';

export class CloudEngineerPortfolioStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const githubOwner = this.node.tryGetContext('githubOwner') as string | undefined;
    const githubRepo = this.node.tryGetContext('githubRepo') as string | undefined;
    const githubOwnerId = this.node.tryGetContext('githubOwnerId') as string | undefined;
    const githubRepoId = this.node.tryGetContext('githubRepoId') as string | undefined;
    const existingProviderArn = this.node.tryGetContext('githubOidcProviderArn') as string | undefined;

    if (!githubOwner || !githubRepo || !githubOwnerId || !githubRepoId) {
      throw new Error('Pass githubOwner, githubRepo, githubOwnerId, and githubRepoId as CDK context values.');
    }
  }
}

What does the stack foundation do?

  • The service imports provide the CDK constructs used throughout the stack.
  • The four GitHub context values identify the exact repository allowed to request AWS credentials.
  • The context check stops synthesis when any required repository identity is missing.
  • githubOidcProviderArn supports accounts that already have the GitHub OIDC provider registered.

The rest of the infrastructure belongs inside the constructor. Add each following chunk after the context check and before the constructor's closing brace.

  • Add the Amazon VPC and network logging configuration by copying this chunk:
    const vpc = new ec2.Vpc(this, 'Vpc', {
      ipAddresses: ec2.IpAddresses.cidr('10.20.0.0/16'),
      maxAzs: 2,
      natGateways: 1,
      subnetConfiguration: [
        { cidrMask: 24, name: 'public', subnetType: ec2.SubnetType.PUBLIC },
        { cidrMask: 24, name: 'application', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      ],
    });

    vpc.addGatewayEndpoint('S3Endpoint', { service: ec2.GatewayVpcEndpointAwsService.S3 });

    const flowLogGroup = new logs.LogGroup(this, 'VpcFlowLogGroup', {
      retention: logs.RetentionDays.ONE_DAY,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    vpc.addFlowLog('VpcFlowLogs', {
      destination: ec2.FlowLogDestination.toCloudWatchLogs(flowLogGroup),
      trafficType: ec2.FlowLogTrafficType.ALL,
      maxAggregationInterval: ec2.FlowLogMaxAggregationInterval.ONE_MINUTE,
    });

How is the network segmented?

  • The VPC uses 10.20.0.0/16 across two Availability Zones.
  • Public subnets hold internet-facing infrastructure.
  • Private application subnets hold the web server while retaining outbound access through one NAT gateway.
  • VPC Flow Logs capture accepted and rejected traffic in a log group with one-day retention.
  • Add the private Amazon S3 deployment bucket and initial site upload by copying this chunk below the flow log configuration:
    const siteBucket = new s3.Bucket(this, 'SiteBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    new s3deploy.BucketDeployment(this, 'InitialSiteDeployment', {
      sources: [s3deploy.Source.asset('site')],
      destinationBucket: siteBucket,
      prune: true,
    });

How is the site stored?

  • The bucket blocks all public access because the web server retrieves the page privately.
  • S3-managed encryption protects stored objects.
  • The bucket deployment uploads the current contents of site during stack deployment.
  • Automatic object deletion allows the bucket to be removed with the stack.
  • Add the instance role and the two security groups below the bucket deployment by copying this chunk:
    const instanceRole = new iam.Role(this, 'WebServerRole', {
      assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
      managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')],
    });
    siteBucket.grants.read(instanceRole);

    const loadBalancerSecurityGroup = new ec2.SecurityGroup(this, 'LoadBalancerSecurityGroup', {
      vpc,
      description: 'Allow public HTTP to the load balancer',
      allowAllOutbound: true,
    });
    loadBalancerSecurityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow public HTTP');

    const webServerSecurityGroup = new ec2.SecurityGroup(this, 'WebServerSecurityGroup', {
      vpc,
      description: 'Allow application traffic only from the load balancer',
      allowAllOutbound: true,
    });

What do the role and security groups control?

  • WebServerRole grants Systems Manager access through the managed policy named AmazonSSMManagedInstanceCore.
  • The role can read the private deployment bucket so the instance can retrieve the page.
  • LoadBalancerSecurityGroup accepts public TCP traffic on port 80.
  • WebServerSecurityGroup starts with outbound access for installation and synchronization tasks.
  • Add the private Amazon Linux instance below the security groups by copying this chunk:
    const webServer = new ec2.Instance(this, 'WebServer', {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.MICRO),
      machineImage: ec2.MachineImage.latestAmazonLinux2023(),
      role: instanceRole,
      securityGroup: webServerSecurityGroup,
    });

What does the instance configuration guarantee?

The WebServer uses Amazon Linux 2023 on a t3.micro instance. Its private subnet placement prevents it from becoming a direct public entry point.

The next four chunks form one addUserData() call. Start with the balanced call below and insert each later fragment immediately before its closing ); line.

  • Add the initial Apache bootstrap below the instance definition by copying this code:
    webServer.addUserData(
      'dnf install -y httpd',
      'systemctl enable --now httpd',
    );

What does the Apache bootstrap do?

The first command installs Apache during instance initialization. The second command starts the service immediately and enables it after future reboots.

  • Insert the site synchronization script immediately before the closing ); line of addUserData() by copying this fragment:
      `cat > /usr/local/bin/site-sync.sh <<'EOF'
#!/bin/bash
set -euo pipefail
aws s3 sync s3://${siteBucket.bucketName}/ /var/www/html/ --delete
EOF`,
      'chmod +x /usr/local/bin/site-sync.sh',

What does the synchronization script do?

The script synchronizes the private bucket into Apache's /var/www/html/ directory. The --delete flag removes server files that no longer exist in the bucket.

  • Insert the systemd service fragment before the same closing ); line by copying this code:
      `cat > /etc/systemd/system/site-sync.service <<'EOF'
[Unit]
Description=Sync portfolio site from Amazon S3
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/site-sync.sh
EOF`,

What does the systemd service do?

The site-sync.service unit runs the synchronization script after the network becomes available. Its one-shot mode exits after each completed synchronization.

  • Insert the timer and activation commands before the closing ); line by copying this final fragment:
      `cat > /etc/systemd/system/site-sync.timer <<'EOF'
[Unit]
Description=Refresh portfolio site from Amazon S3

[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=5s
Unit=site-sync.service

[Install]
WantedBy=timers.target
EOF`,
      'systemctl daemon-reload',
      'systemctl enable --now site-sync.timer',
      '/usr/local/bin/site-sync.sh',

How does the timer keep the page current?

The timer runs the synchronization service after boot and repeats it every 60 seconds. The final script call performs the first synchronization without waiting for the timer.

  • Add the public load balancer and target group below addUserData() by copying this chunk:
    const loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', {
      vpc,
      internetFacing: true,
      securityGroup: loadBalancerSecurityGroup,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
    });
    const listener = loadBalancer.addListener('HttpListener', { port: 80, open: false });
    const targetGroup = listener.addTargets('WebTargets', {
      port: 80,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targets: [new targets.InstanceTarget(webServer, 80)],
      healthCheck: { path: '/', healthyHttpCodes: '200' },
    });

How does traffic reach the target?

  • The load balancer sits in public subnets and uses its dedicated security group.
  • The listener accepts HTTP on port 80.
  • WebTargets forwards requests to the instance on port 80.
  • The health check requests / and expects an HTTP 200 response.
  • Add the OIDC provider and GitHub deployment role below the target group by copying this chunk:
    const githubProvider = existingProviderArn
      ? iam.OidcProviderNative.fromOidcProviderArn(this, 'ImportedGitHubProvider', existingProviderArn)
      : new iam.OidcProviderNative(this, 'GitHubProvider', {
          url: 'https://token.actions.githubusercontent.com',
          clientIds: ['sts.amazonaws.com'],
        });

    const githubPrincipal = new iam.OpenIdConnectPrincipal(githubProvider).withConditions({
      StringEquals: {
        'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
        'token.actions.githubusercontent.com:sub': `repo:${githubOwner}@${githubOwnerId}/${githubRepo}@${githubRepoId}:ref:refs/heads/main`,
      },
    });

    const githubRole = new iam.Role(this, 'GitHubDeployRole', {
      assumedBy: githubPrincipal,
      description: 'Allows the main branch to deploy the portfolio site',
    });
    siteBucket.grants.readWrite(githubRole);
    siteBucket.grants.delete(githubRole);
    githubRole.addToPolicy(new iam.PolicyStatement({ actions: ['cloudformation:DescribeStacks'], resources: ['*'] }));

How is the GitHub role restricted?

The trust condition includes the repository owner name and numeric owner ID. It also includes the repository name and numeric repository ID.

Only the main branch can assume the role. The role can synchronize the site bucket and read stack outputs without storing long-lived AWS keys in GitHub.

  • Add the stack outputs below the GitHub role permissions by copying this final chunk:
    new CfnOutput(this, 'LoadBalancerUrl', { value: `http://${loadBalancer.loadBalancerDnsName}` });
    new CfnOutput(this, 'InstanceId', { value: webServer.instanceId });
    new CfnOutput(this, 'SiteBucketName', { value: siteBucket.bucketName });
    new CfnOutput(this, 'GitHubRoleArn', { value: githubRole.roleArn });
    new CfnOutput(this, 'FlowLogGroupName', { value: flowLogGroup.logGroupName });

Why expose these outputs?

The outputs expose operational identifiers without hard-coding generated AWS names. You will use them to open the load balancer and manage the private instance in later work.

  • Save lib/cloud-engineer-portfolio-stack.ts.
  • Validate the complete TypeScript project from the VS Code terminal by running this command:
npm run build

What does the build prove?

The build runs the pinned TypeScript compiler through the existing build script. A successful return to the prompt confirms that both new TypeScript files compile.

Seeing a TypeScript error?

  • Confirm that both new files sit inside the existing bin and lib folders.
  • Check that every infrastructure chunk remains inside the stack constructor.
  • Compare the punctuation around each template string in addUserData().

Ask for help with the exact compiler output: Help me trace this TypeScript build error to the matching section of my AWS CDK stack.

✔️ Awesome, I've got everything!

Your two TypeScript files are saved and npm run build completes without compiler errors.

ⓧ I'd like to double check the full code

Compare bin/cloud-engineer-portfolio.ts with this complete file:

#!/usr/bin/env node
import { App } from 'aws-cdk-lib';
import { CloudEngineerPortfolioStack } from '../lib/cloud-engineer-portfolio-stack';

const app = new App();

new CloudEngineerPortfolioStack(app, 'CloudEngineerPortfolio', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

What should match?

Confirm the stack name is CloudEngineerPortfolio. Confirm the environment uses the two CDK default environment variables.

Compare lib/cloud-engineer-portfolio-stack.ts with this complete intermediate stack:

import { CfnOutput, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as targets from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import { Construct } from 'constructs';

export class CloudEngineerPortfolioStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const githubOwner = this.node.tryGetContext('githubOwner') as string | undefined;
    const githubRepo = this.node.tryGetContext('githubRepo') as string | undefined;
    const githubOwnerId = this.node.tryGetContext('githubOwnerId') as string | undefined;
    const githubRepoId = this.node.tryGetContext('githubRepoId') as string | undefined;
    const existingProviderArn = this.node.tryGetContext('githubOidcProviderArn') as string | undefined;

    if (!githubOwner || !githubRepo || !githubOwnerId || !githubRepoId) {
      throw new Error('Pass githubOwner, githubRepo, githubOwnerId, and githubRepoId as CDK context values.');
    }

    const vpc = new ec2.Vpc(this, 'Vpc', {
      ipAddresses: ec2.IpAddresses.cidr('10.20.0.0/16'),
      maxAzs: 2,
      natGateways: 1,
      subnetConfiguration: [
        { cidrMask: 24, name: 'public', subnetType: ec2.SubnetType.PUBLIC },
        { cidrMask: 24, name: 'application', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      ],
    });

    vpc.addGatewayEndpoint('S3Endpoint', { service: ec2.GatewayVpcEndpointAwsService.S3 });

    const flowLogGroup = new logs.LogGroup(this, 'VpcFlowLogGroup', {
      retention: logs.RetentionDays.ONE_DAY,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    vpc.addFlowLog('VpcFlowLogs', {
      destination: ec2.FlowLogDestination.toCloudWatchLogs(flowLogGroup),
      trafficType: ec2.FlowLogTrafficType.ALL,
      maxAggregationInterval: ec2.FlowLogMaxAggregationInterval.ONE_MINUTE,
    });

    const siteBucket = new s3.Bucket(this, 'SiteBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    new s3deploy.BucketDeployment(this, 'InitialSiteDeployment', {
      sources: [s3deploy.Source.asset('site')],
      destinationBucket: siteBucket,
      prune: true,
    });

    const instanceRole = new iam.Role(this, 'WebServerRole', {
      assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
      managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')],
    });
    siteBucket.grants.read(instanceRole);

    const loadBalancerSecurityGroup = new ec2.SecurityGroup(this, 'LoadBalancerSecurityGroup', {
      vpc,
      description: 'Allow public HTTP to the load balancer',
      allowAllOutbound: true,
    });
    loadBalancerSecurityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow public HTTP');

    const webServerSecurityGroup = new ec2.SecurityGroup(this, 'WebServerSecurityGroup', {
      vpc,
      description: 'Allow application traffic only from the load balancer',
      allowAllOutbound: true,
    });

    const webServer = new ec2.Instance(this, 'WebServer', {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.MICRO),
      machineImage: ec2.MachineImage.latestAmazonLinux2023(),
      role: instanceRole,
      securityGroup: webServerSecurityGroup,
    });

    webServer.addUserData(
      'dnf install -y httpd',
      'systemctl enable --now httpd',
      `cat > /usr/local/bin/site-sync.sh <<'EOF'
#!/bin/bash
set -euo pipefail
aws s3 sync s3://${siteBucket.bucketName}/ /var/www/html/ --delete
EOF`,
      'chmod +x /usr/local/bin/site-sync.sh',
      `cat > /etc/systemd/system/site-sync.service <<'EOF'
[Unit]
Description=Sync portfolio site from Amazon S3
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/site-sync.sh
EOF`,
      `cat > /etc/systemd/system/site-sync.timer <<'EOF'
[Unit]
Description=Refresh portfolio site from Amazon S3

[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=5s
Unit=site-sync.service

[Install]
WantedBy=timers.target
EOF`,
      'systemctl daemon-reload',
      'systemctl enable --now site-sync.timer',
      '/usr/local/bin/site-sync.sh',
    );

    const loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', {
      vpc,
      internetFacing: true,
      securityGroup: loadBalancerSecurityGroup,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
    });
    const listener = loadBalancer.addListener('HttpListener', { port: 80, open: false });
    const targetGroup = listener.addTargets('WebTargets', {
      port: 80,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targets: [new targets.InstanceTarget(webServer, 80)],
      healthCheck: { path: '/', healthyHttpCodes: '200' },
    });

    const githubProvider = existingProviderArn
      ? iam.OidcProviderNative.fromOidcProviderArn(this, 'ImportedGitHubProvider', existingProviderArn)
      : new iam.OidcProviderNative(this, 'GitHubProvider', {
          url: 'https://token.actions.githubusercontent.com',
          clientIds: ['sts.amazonaws.com'],
        });

    const githubPrincipal = new iam.OpenIdConnectPrincipal(githubProvider).withConditions({
      StringEquals: {
        'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
        'token.actions.githubusercontent.com:sub': `repo:${githubOwner}@${githubOwnerId}/${githubRepo}@${githubRepoId}:ref:refs/heads/main`,
      },
    });

    const githubRole = new iam.Role(this, 'GitHubDeployRole', {
      assumedBy: githubPrincipal,
      description: 'Allows the main branch to deploy the portfolio site',
    });
    siteBucket.grants.readWrite(githubRole);
    siteBucket.grants.delete(githubRole);
    githubRole.addToPolicy(new iam.PolicyStatement({ actions: ['cloudformation:DescribeStacks'], resources: ['*'] }));

    new CfnOutput(this, 'LoadBalancerUrl', { value: `http://${loadBalancer.loadBalancerDnsName}` });
    new CfnOutput(this, 'InstanceId', { value: webServer.instanceId });
    new CfnOutput(this, 'SiteBucketName', { value: siteBucket.bucketName });
    new CfnOutput(this, 'GitHubRoleArn', { value: githubRole.roleArn });
    new CfnOutput(this, 'FlowLogGroupName', { value: flowLogGroup.logGroupName });
  }
}

What should the intermediate stack contain?

Confirm the file contains the network, storage, private instance, load balancer, GitHub role, and five outputs. The file should not contain monitoring widgets or a dashboard output.

Publish the infrastructure and record repository IDs

New GitHub repositories use immutable numeric owner and repository IDs in their OIDC subject claims. Recording both IDs now lets the AWS role trust one exact repository instead of relying only on names.

  • Commit the new infrastructure files from the local project terminal by running these commands:
git add .
git commit -m "Deploy broken private web tier"
git push

What do these Git commands do?

The commands stage the two new files and create a local commit. The final command publishes that commit to the existing main branch.

Did the push fail?

  • Confirm the terminal is inside the local cloud-engineer-portfolio folder.
  • Confirm the repository still has its existing GitHub remote.
  • Complete any GitHub authentication prompt shown by the credential helper.

Ask for help with the exact Git output: Help me diagnose why my existing cloud-engineer-portfolio repository will not push to main.

  • Record your GitHub username here: your GitHub username.
  • Load the repository metadata in your browser by pasting this URL:
https://api.github.com/repos/[[GITHUB_OWNER="your GitHub username"]]/cloud-engineer-portfolio

What does the repository response contain?

The response is a public JSON document describing the repository. The top-level id identifies the repository while owner.id identifies its owner.

  • Record the numeric value beside the top-level id: your GitHub repository ID.
  • Record the numeric value beside owner.id: your GitHub owner ID.

Does the API page show an error?

Confirm the URL contains your exact GitHub username and the repository name cloud-engineer-portfolio. The repository must remain public for this unauthenticated request.

Ask for help checking the URL: Help me find the correct public GitHub repository API URL and identify the repository and owner IDs.

Deploy the stack and observe the failure

AWS CloudShell already carries your console identity, so it can deploy without local access keys. The public repository gives CloudShell a clean copy of the same commit you just published.

This deployment starts billable resources

The stack creates a NAT gateway, an Application Load Balancer, a public IPv4 path, an EC2 instance, and logs. Keep the deployment for the lab and delete it the same day to stay within the project's cost guardrail.

  • Switch back to the authenticated AWS CloudShell session from earlier.
  • Clone the public repository into CloudShell by running these commands:
git clone https://github.com/[[GITHUB_OWNER="your GitHub username"]]/cloud-engineer-portfolio.git
cd cloud-engineer-portfolio

What does cloning create?

Git downloads the public repository into a new cloud-engineer-portfolio directory. The second command moves the CloudShell session into that directory.

  • Install the pinned project dependencies in CloudShell by running this command:
npm install --package-lock=false

What does this installation use?

npm reads the exact dependency versions from package.json. The flag prevents CloudShell from creating a new package-lock.json file.

Did dependency installation fail?

  • Confirm CloudShell is inside the cloned cloud-engineer-portfolio directory.
  • Confirm package.json appears in the CloudShell file list.
  • Check the CloudShell Node.js version if npm reports an engine or runtime problem.

Ask for help with the installation output: Help me diagnose this npm install failure in AWS CloudShell using the pinned package versions.

  • Bootstrap the CDK environment in the authenticated AWS account by running this command:
npm run cdk -- bootstrap

What does CDK bootstrap create?

Bootstrapping prepares the account and region with the shared resources CDK deployments require. The project stack remains separate under the name CloudEngineerPortfolio.

The first bootstrap can take several minutes while AWS creates the supporting stack. A quiet terminal during part of that wait does not mean the command has stopped.

Did bootstrap fail?

  • Confirm the CloudShell region is US East (N. Virginia).
  • Confirm the current console identity has permission to create CloudFormation and IAM resources.
  • Read the first error in the CloudShell output because later failures often follow from it.

Ask for help with the bootstrap event: Help me interpret this AWS CDK bootstrap failure in us-east-1.

  • Deploy CloudEngineerPortfolio with the recorded GitHub identity values by running this command:
npm run cdk -- deploy CloudEngineerPortfolio --require-approval never --outputs-file outputs.json -c githubOwner=[[GITHUB_OWNER="your GitHub username"]] -c githubRepo=cloud-engineer-portfolio -c githubOwnerId=[[OWNER_ID="your GitHub owner ID"]] -c githubRepoId=[[REPOSITORY_ID="your GitHub repository ID"]]

What does the deployment command control?

  • CloudEngineerPortfolio selects the stack defined by the CDK application.
  • --require-approval never allows the deployment to proceed without an interactive security-change prompt.
  • --outputs-file outputs.json stores the generated stack outputs in the cloned project.
  • The four context values build the immutable GitHub OIDC subject used by the deployment role.

This deployment can take several minutes because AWS creates the VPC, NAT gateway, load balancer, instance, bucket deployment, IAM roles, and logging resources. Keep CloudShell open until the stack reports completion.

Did the deployment fail?

  • Confirm all three recorded GitHub values contain their real names or numeric IDs.
  • Confirm the stack deploys in us-east-1.
  • Check whether the AWS account already has a GitHub OIDC provider registered for the same provider URL.

Ask for help with the first failed resource: Help me diagnose this CloudEngineerPortfolio deployment failure from the first CloudFormation error.

The completed deployment writes five values to outputs.json. Capture them now because they identify the live resources you will operate.

  • Record LoadBalancerUrl here: your load balancer URL.
  • Record InstanceId here: your EC2 instance ID.
  • Record SiteBucketName here: your site bucket name.
  • Record GitHubRoleArn here: your GitHub deployment role ARN.
  • Record FlowLogGroupName here: your VPC flow log group name.

Before you open the load balancer URL, do you expect the page to load or the target path to fail?

  • Paste your load balancer URL into your browser's address bar.

You will see 503 Service Unavailable. The load balancer is reachable, but it has no healthy target available for the request.

  • Open the target group created by CloudEngineerPortfolio in the EC2 console.
  • Confirm the registered target reports an unhealthy state.

This is the intended shortfall. You have proven that a successful infrastructure deployment does not guarantee application availability.

What does the failure prove?

The public load balancer answered your request, so its internet-facing path exists. The unhealthy target shows that the next hop cannot complete its health check.

The evidence still leaves two possible layers to investigate. Apache could be unhealthy, or the network path to Apache could be blocked.

Your private web tier is deployed and producing a real incident signal. Next, you will separate Linux service health from network reachability and repair the path in code.

Diagnose and Repair the Network Path

Your private web tier is deployed. Its 503 Service Unavailable response exposed the deliberate failure from the previous step.

The next goal is to identify the failing layer before changing the infrastructure. AWS Systems Manager lets you collect evidence from Apache on the private instance without SSH.

You will compare that Linux evidence with Application Load Balancer target health plus VPC Flow Logs. The mismatch reveals the missing security group path that you will repair in AWS CDK.

In this step, get ready to:
  • Prove that the private instance serves the portfolio page locally.
  • Correlate unhealthy target status with rejected VPC Flow Log traffic.
  • Repair the ingress path through AWS CDK.
Check Apache from Systems Manager

Systems Manager Run Command executes administrative commands through the instance role. This keeps the server private because you do not need a public address or port 22.

  • Record the InstanceId value from outputs.json here: your EC2 instance ID.
  • Replace INSTANCE_ID in the command below with the instance ID you recorded.

Before you run this, do you expect the Linux service to be healthy or stopped?

  • Check the Apache service plus its local HTTP response from the authenticated AWS CloudShell session by running:
COMMAND_ID=$(aws ssm send-command --instance-ids "INSTANCE_ID" --document-name "AWS-RunShellScript" --parameters 'commands=["systemctl status httpd","curl localhost"]' --query "Command.CommandId" --output text)

What does this command do?

  • The AWS-RunShellScript document runs shell commands on the managed Linux instance.
  • The systemctl status httpd command checks whether Apache is running.
  • The curl localhost command requests the page directly from Apache inside the instance.
  • The COMMAND_ID variable stores the Run Command identifier for the result lookup.
  • Replace INSTANCE_ID in the next command with the same instance ID.
  • Retrieve the command result by running:
aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "INSTANCE_ID"

What should you see?

The result reports a successful command status. Its standard output shows that Apache is active.

The local HTTP output contains the portfolio page HTML. This proves that the application process plus its content work inside the private instance.

That is the key diagnostic win. The server works locally even while the load balancer reports it as unavailable.

Can't retrieve the command result?

  • Check that you replaced both INSTANCE_ID placeholders with the value from outputs.json.
  • Run the result command again if Systems Manager still reports that execution is in progress.
  • Confirm that the first command printed a command ID before retrying the lookup.

Help me diagnose why Systems Manager Run Command is not returning the Apache status for my private EC2 instance.

Correlate the network evidence

A successful local request narrows the fault to the path between the load balancer plus the instance. Target health shows the failed request from one end.

Flow Logs show whether the network interface accepted or rejected the packet. The two signals isolate the boundary that needs repair.

  • Return to the AWS Management Console in us-east-1.
  • Open the Amazon EC2 service from the console search bar.
  • Select the target group whose generated name contains WebTargets.
  • Select its targets view to confirm the registered instance remains unhealthy on port 80.

The unhealthy state proves that the load balancer cannot complete its health check. It does not contradict the successful local request.

Matching flow log records is fiddly because the records identify network interfaces plus addresses instead of CDK construct names.

  • Read the FlowLogGroupName value from outputs.json.
  • Open Amazon CloudWatch from the AWS console search bar.
  • Select the CloudWatch Logs group whose name matches the recorded output.
  • Search the recent log events for rejected traffic to the private instance on destination port 80.
  • Compare the record times with the unhealthy target checks.

How does the evidence isolate the fault?

  • Active Apache status proves that the Linux service is running.
  • A successful local HTTP request proves that Apache can return the portfolio page.
  • Unhealthy target status proves that the load balancer cannot reach the service.
  • Rejected port 80 traffic points to the missing inbound rule on WebServerSecurityGroup.

Can't find the rejected flow records?

  • Check that the selected log group matches FlowLogGroupName from outputs.json.
  • Refresh the load balancer URL to generate another health-check failure.
  • Search the newest records for rejected traffic on destination port 80.

Help me correlate VPC Flow Logs with an unhealthy Application Load Balancer target on port 80.

Add the least-privilege ingress rule

A security group can use another security group as its source. This rule accepts HTTP only from resources attached to LoadBalancerSecurityGroup.

The instance keeps its private address. It also keeps port 22 closed.

  • On your Mac, return to the cloud-engineer-portfolio repository in Visual Studio Code.
  • Open lib/cloud-engineer-portfolio-stack.ts from the Explorer sidebar.
  • Find the current webServerSecurityGroup block:
    const webServerSecurityGroup = new ec2.SecurityGroup(this, 'WebServerSecurityGroup', {
      vpc,
      description: 'Allow application traffic only from the load balancer',
      allowAllOutbound: true,
    });

Where is the gap?

This block allows outbound traffic from the instance. It defines no inbound path for the load balancer health checks.

  • Add the marked ingress rule immediately below the security group block so the section looks like this:
    const webServerSecurityGroup = new ec2.SecurityGroup(this, 'WebServerSecurityGroup', {
      vpc,
      description: 'Allow application traffic only from the load balancer',
      allowAllOutbound: true,
    });

    // STEP 4 FIX: omit this line during the deliberately broken deployment.
    webServerSecurityGroup.addIngressRule(loadBalancerSecurityGroup, ec2.Port.tcp(80), 'Allow HTTP only from the load balancer');

Why is this rule least privilege?

The source is loadBalancerSecurityGroup. Internet clients cannot connect directly to the instance through this rule.

The destination is limited to TCP port 80. Administrative access still uses Systems Manager.

  • Save lib/cloud-engineer-portfolio-stack.ts.
  • Select Source Control in the VS Code Activity Bar.

You will see lib/cloud-engineer-portfolio-stack.ts listed as a changed file.

  • Select the plus icon beside lib/cloud-engineer-portfolio-stack.ts to stage it.

The file moves into the staged changes area. The commit now contains only the network repair.

  • Enter Repair load balancer network path in the source control message field.
  • Select Commit.

The staged changes clear after the commit succeeds.

  • Select Sync Changes to push the commit to GitHub.

You will see synchronization finish in VS Code. The repaired CDK code is now available to CloudShell.

  • Switch back to the authenticated CloudShell session from earlier.
  • Pull the new commit into the cloned repository by running:
git pull

What does this command do?

The git pull command fetches the upstream branch. It integrates the new commit into the current CloudShell branch.

  • Replace the three uppercase placeholders in the deployment command with the GitHub values you recorded in the previous step.

Before you redeploy, do you expect the target to become healthy without changing Apache?

CloudFormation updates can sit quietly between progress events for several minutes. That pause is expected while AWS applies the security group change.

  • Deploy the repaired stack by running:
npm run cdk -- deploy CloudEngineerPortfolio --require-approval never --outputs-file outputs.json -c githubOwner=GITHUB_OWNER -c githubRepo=cloud-engineer-portfolio -c githubOwnerId=OWNER_ID -c githubRepoId=REPOSITORY_ID

What does this deployment change?

  • The local CDK CLI compares the updated stack with the deployed stack.
  • CloudFormation adds the inbound TCP port 80 rule to WebServerSecurityGroup.
  • The rule uses LoadBalancerSecurityGroup as its only source.
  • The deployment refreshes outputs.json after the stack update completes.
  • Return to the WebTargets target group in the Amazon EC2 console.
  • Refresh the target status after about 150 seconds.

You will see the target become healthy after the required successful health checks. Apache now receives requests from the load balancer.

  • Open the URL stored under LoadBalancerUrl in outputs.json.

You will see the Cloud Engineer Portfolio page with green status cards. The earlier 503 Service Unavailable response is gone.

  • Select the WebServer instance in the Amazon EC2 console.
  • Confirm that its public IPv4 address field has no address.
  • Inspect the inbound rules attached to WebServerSecurityGroup.

You will see TCP port 80 allowed from the load balancer security group. You will not see an internet-wide source or an SSH rule.

Is the target still unhealthy?

  • Confirm that the CDK deployment finished successfully before refreshing target health.
  • Check that the ingress rule sits after the webServerSecurityGroup declaration.
  • Confirm that the rule uses loadBalancerSecurityGroup as the source plus TCP port 80 as the destination.

Help me diagnose why my Application Load Balancer target remains unhealthy after adding the CDK ingress rule.

✔️ Awesome, I've got everything!

Great. Confirm that the target is healthy plus the portfolio page loads through LoadBalancerUrl.

ⓧ I'd like to double check the full code

Compare your lib/cloud-engineer-portfolio-stack.ts file with the complete Step 4 version below.

import { CfnOutput, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as targets from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import { Construct } from 'constructs';

export class CloudEngineerPortfolioStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const githubOwner = this.node.tryGetContext('githubOwner') as string | undefined;
    const githubRepo = this.node.tryGetContext('githubRepo') as string | undefined;
    const githubOwnerId = this.node.tryGetContext('githubOwnerId') as string | undefined;
    const githubRepoId = this.node.tryGetContext('githubRepoId') as string | undefined;
    const existingProviderArn = this.node.tryGetContext('githubOidcProviderArn') as string | undefined;

    if (!githubOwner || !githubRepo || !githubOwnerId || !githubRepoId) {
      throw new Error('Pass githubOwner, githubRepo, githubOwnerId, and githubRepoId as CDK context values.');
    }

    const vpc = new ec2.Vpc(this, 'Vpc', {
      ipAddresses: ec2.IpAddresses.cidr('10.20.0.0/16'),
      maxAzs: 2,
      natGateways: 1,
      subnetConfiguration: [
        { cidrMask: 24, name: 'public', subnetType: ec2.SubnetType.PUBLIC },
        { cidrMask: 24, name: 'application', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      ],
    });

    vpc.addGatewayEndpoint('S3Endpoint', { service: ec2.GatewayVpcEndpointAwsService.S3 });

    const flowLogGroup = new logs.LogGroup(this, 'VpcFlowLogGroup', {
      retention: logs.RetentionDays.ONE_DAY,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    vpc.addFlowLog('VpcFlowLogs', {
      destination: ec2.FlowLogDestination.toCloudWatchLogs(flowLogGroup),
      trafficType: ec2.FlowLogTrafficType.ALL,
      maxAggregationInterval: ec2.FlowLogMaxAggregationInterval.ONE_MINUTE,
    });

    const siteBucket = new s3.Bucket(this, 'SiteBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    new s3deploy.BucketDeployment(this, 'InitialSiteDeployment', {
      sources: [s3deploy.Source.asset('site')],
      destinationBucket: siteBucket,
      prune: true,
    });

    const instanceRole = new iam.Role(this, 'WebServerRole', {
      assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
      managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')],
    });
    siteBucket.grants.read(instanceRole);

    const loadBalancerSecurityGroup = new ec2.SecurityGroup(this, 'LoadBalancerSecurityGroup', {
      vpc,
      description: 'Allow public HTTP to the load balancer',
      allowAllOutbound: true,
    });
    loadBalancerSecurityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow public HTTP');

    const webServerSecurityGroup = new ec2.SecurityGroup(this, 'WebServerSecurityGroup', {
      vpc,
      description: 'Allow application traffic only from the load balancer',
      allowAllOutbound: true,
    });

    // STEP 4 FIX: omit this line during the deliberately broken deployment.
    webServerSecurityGroup.addIngressRule(loadBalancerSecurityGroup, ec2.Port.tcp(80), 'Allow HTTP only from the load balancer');

    const webServer = new ec2.Instance(this, 'WebServer', {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.MICRO),
      machineImage: ec2.MachineImage.latestAmazonLinux2023(),
      role: instanceRole,
      securityGroup: webServerSecurityGroup,
    });

    webServer.addUserData(
      'dnf install -y httpd',
      'systemctl enable --now httpd',
      `cat > /usr/local/bin/site-sync.sh <<'EOF'
#!/bin/bash
set -euo pipefail
aws s3 sync s3://${siteBucket.bucketName}/ /var/www/html/ --delete
EOF`,
      'chmod +x /usr/local/bin/site-sync.sh',
      `cat > /etc/systemd/system/site-sync.service <<'EOF'
[Unit]
Description=Sync portfolio site from Amazon S3
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/site-sync.sh
EOF`,
      `cat > /etc/systemd/system/site-sync.timer <<'EOF'
[Unit]
Description=Refresh portfolio site from Amazon S3

[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=5s
Unit=site-sync.service

[Install]
WantedBy=timers.target
EOF`,
      'systemctl daemon-reload',
      'systemctl enable --now site-sync.timer',
      '/usr/local/bin/site-sync.sh',
    );

    const loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', {
      vpc,
      internetFacing: true,
      securityGroup: loadBalancerSecurityGroup,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
    });
    const listener = loadBalancer.addListener('HttpListener', { port: 80, open: false });
    const targetGroup = listener.addTargets('WebTargets', {
      port: 80,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targets: [new targets.InstanceTarget(webServer, 80)],
      healthCheck: { path: '/', healthyHttpCodes: '200' },
    });

    const githubProvider = existingProviderArn
      ? iam.OidcProviderNative.fromOidcProviderArn(this, 'ImportedGitHubProvider', existingProviderArn)
      : new iam.OidcProviderNative(this, 'GitHubProvider', {
          url: 'https://token.actions.githubusercontent.com',
          clientIds: ['sts.amazonaws.com'],
        });

    const githubPrincipal = new iam.OpenIdConnectPrincipal(githubProvider).withConditions({
      StringEquals: {
        'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
        'token.actions.githubusercontent.com:sub': `repo:${githubOwner}@${githubOwnerId}/${githubRepo}@${githubRepoId}:ref:refs/heads/main`,
      },
    });

    const githubRole = new iam.Role(this, 'GitHubDeployRole', {
      assumedBy: githubPrincipal,
      description: 'Allows the main branch to deploy the portfolio site',
    });
    siteBucket.grants.readWrite(githubRole);
    siteBucket.grants.delete(githubRole);
    githubRole.addToPolicy(new iam.PolicyStatement({ actions: ['cloudformation:DescribeStacks'], resources: ['*'] }));

    new CfnOutput(this, 'LoadBalancerUrl', { value: `http://${loadBalancer.loadBalancerDnsName}` });
    new CfnOutput(this, 'InstanceId', { value: webServer.instanceId });
    new CfnOutput(this, 'SiteBucketName', { value: siteBucket.bucketName });
    new CfnOutput(this, 'GitHubRoleArn', { value: githubRole.roleArn });
    new CfnOutput(this, 'FlowLogGroupName', { value: flowLogGroup.logGroupName });
  }
}

The ingress rule is the only Step 4 addition. Monitoring resources remain outside this version of the stack.

The network path is healthy. Next, you will automate delivery with short-lived credentials while adding visible health signals.

Add CI/CD and Monitoring

Your repaired private web tier now serves the portfolio page through a healthy load balancer. You have already proved that you can separate Linux health from network reachability.

A one-off deployment only proves that the system worked once. This step adds repeatable delivery through GitHub Actions and visible health signals in Amazon CloudWatch.

In this step, get ready to:
  • Define an operations dashboard and availability alarm with AWS CDK.
  • Configure an OIDC-authenticated workflow that deploys the site from GitHub.
  • Document the architecture and verify the automated deployment.
Add monitoring to the CDK stack

A monitoring dashboard turns infrastructure activity into evidence you can inspect. The alarm converts an unhealthy load balancer target into a visible availability signal.

  • Switch back to the cloud-engineer-portfolio project in Visual Studio Code.
  • Select lib/cloud-engineer-portfolio-stack.ts in the Explorer sidebar.
  • Find the import line that starts with import { CfnOutput.
  • Replace that line with the import group below:
import { CfnOutput, Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';

What do these imports provide?

  • Duration defines the measurement period for each metric.
  • The cloudwatch namespace provides metrics, graph widgets, dashboards, and alarms.
  • Find the existing githubRole.addToPolicy line near the bottom of the constructor.
  • Add the monitoring resources immediately below that line by pasting this code:
    // STEP 5 MONITORING START
    const requestCount = loadBalancer.metrics.requestCount({ period: Duration.minutes(1), statistic: 'Sum' });
    const targetResponseTime = loadBalancer.metrics.targetResponseTime({ period: Duration.minutes(1), statistic: 'Average' });
    const unhealthyHosts = targetGroup.metrics.unhealthyHostCount({ period: Duration.minutes(1), statistic: 'Maximum' });
    const cpuUtilization = new cloudwatch.Metric({
      namespace: 'AWS/EC2',
      metricName: 'CPUUtilization',
      dimensionsMap: { InstanceId: webServer.instanceId },
      period: Duration.minutes(5),
      statistic: 'Average',
    });
    const dashboard = new cloudwatch.Dashboard(this, 'OperationsDashboard', { dashboardName: 'CloudEngineerPortfolio' });
    dashboard.addWidgets(
      new cloudwatch.GraphWidget({ title: 'Application Load Balancer Requests', left: [requestCount], width: 8 }),
      new cloudwatch.GraphWidget({ title: 'Target Response Time', left: [targetResponseTime], width: 8 }),
      new cloudwatch.GraphWidget({ title: 'Availability and EC2 CPU', left: [unhealthyHosts], right: [cpuUtilization], width: 8 }),
    );
    new cloudwatch.Alarm(this, 'UnhealthyTargetAlarm', {
      alarmName: 'CloudEngineerPortfolio-UnhealthyTargets',
      alarmDescription: 'At least one load balancer target is unhealthy',
      metric: unhealthyHosts,
      threshold: 1,
      evaluationPeriods: 1,
    });
    // STEP 5 MONITORING END

How does the monitoring layer work?

  • The request count shows how much traffic reaches the load balancer.
  • The target response time shows how long the private web target takes to respond.
  • The unhealthy host count exposes availability failures in the target group.
  • The CPU metric adds host-level evidence from the private EC2 instance.
  • The alarm enters an unhealthy state when at least one target is unhealthy for one evaluation period.
  • Save lib/cloud-engineer-portfolio-stack.ts.

Before you compile the stack, do you expect TypeScript to accept every new metric reference?

  • Compile the updated CDK application from the Visual Studio Code terminal by running:
npm run build

What should the build prove?

The command should return to the terminal prompt without a TypeScript error. That result proves the new imports and monitoring references compile together.

Seeing a TypeScript error?

  • Check that Duration appears in the existing aws-cdk-lib import.
  • Check that the cloudwatch namespace import sits directly below the first import.
  • Confirm that the monitoring block remains inside the stack constructor.

Help me diagnose my CloudWatch CDK TypeScript build error.

  • Find the existing FlowLogGroupName output near the end of lib/cloud-engineer-portfolio-stack.ts.
  • Add the dashboard output below it by matching these final two lines:
    new CfnOutput(this, 'FlowLogGroupName', { value: flowLogGroup.logGroupName });
    new CfnOutput(this, 'DashboardName', { value: dashboard.dashboardName });

Why output the dashboard name?

The stack output makes the dashboard name discoverable after deployment. This keeps the operational entry point alongside the load balancer URL and instance ID.

  • Save lib/cloud-engineer-portfolio-stack.ts.
  • Publish the monitoring change to main by running these commands in the Visual Studio Code terminal:
git add .
git commit -m "Add CloudWatch monitoring"
git push origin main

What do these Git commands do?

The commands stage the updated stack and record it in Git. The final command sends the commit to the repository's main branch.

  • Return to the authenticated AWS CloudShell session from earlier.
  • Pull the monitoring commit into the existing CloudShell clone by running:
git pull origin main

What does the pull update?

The command updates the CloudShell clone with the commit you pushed from the Mac. CloudShell can now deploy the same stack definition you just compiled.

Before you deploy, do you expect CloudFormation to replace the web server or add monitoring around the healthy resources?

This update can take several minutes while CloudFormation creates the dashboard and alarm. The healthy web tier remains available during the update.

  • Substitute your recorded values for the three uppercase placeholders in the deploy command.
  • Deploy the monitoring resources from CloudShell by running:
npm run cdk -- deploy CloudEngineerPortfolio --require-approval never --outputs-file outputs.json -c githubOwner=GITHUB_OWNER -c githubRepo=cloud-engineer-portfolio -c githubOwnerId=OWNER_ID -c githubRepoId=REPOSITORY_ID

What should the deployment create?

The deployment should finish with stack outputs that include DashboardName. The value should be CloudEngineerPortfolio.

CloudFormation adds the dashboard and alarm around the existing load balancer target. Your repaired security-group rule remains in place.

  • Return to the AWS Management Console in US East (N. Virginia).
  • Use the console search bar to open Amazon CloudWatch.
  • Use the dashboard list to open CloudEngineerPortfolio.
  • Confirm that the dashboard shows the request count graph.
  • Confirm that the dashboard shows the target response time graph.
  • Confirm that the availability graph contains unhealthy target and EC2 CPU signals.
  • Confirm that CloudEngineerPortfolio-UnhealthyTargets shows normal operation while the target is healthy.

That is the observability layer working. You can now show both performance and availability from one dashboard.

✔️ Awesome, the monitoring is live

Your dashboard and unhealthy-target alarm now match the deployed CDK stack. Keep the dashboard available for the final delivery check.

ⓧ I'd like to double check the full code

Compare lib/cloud-engineer-portfolio-stack.ts with the complete file below.

import { CfnOutput, Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as targets from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import { Construct } from 'constructs';

export class CloudEngineerPortfolioStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const githubOwner = this.node.tryGetContext('githubOwner') as string | undefined;
    const githubRepo = this.node.tryGetContext('githubRepo') as string | undefined;
    const githubOwnerId = this.node.tryGetContext('githubOwnerId') as string | undefined;
    const githubRepoId = this.node.tryGetContext('githubRepoId') as string | undefined;
    const existingProviderArn = this.node.tryGetContext('githubOidcProviderArn') as string | undefined;

    if (!githubOwner || !githubRepo || !githubOwnerId || !githubRepoId) {
      throw new Error('Pass githubOwner, githubRepo, githubOwnerId, and githubRepoId as CDK context values.');
    }

    const vpc = new ec2.Vpc(this, 'Vpc', {
      ipAddresses: ec2.IpAddresses.cidr('10.20.0.0/16'),
      maxAzs: 2,
      natGateways: 1,
      subnetConfiguration: [
        { cidrMask: 24, name: 'public', subnetType: ec2.SubnetType.PUBLIC },
        { cidrMask: 24, name: 'application', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      ],
    });

    vpc.addGatewayEndpoint('S3Endpoint', { service: ec2.GatewayVpcEndpointAwsService.S3 });

    const flowLogGroup = new logs.LogGroup(this, 'VpcFlowLogGroup', {
      retention: logs.RetentionDays.ONE_DAY,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    vpc.addFlowLog('VpcFlowLogs', {
      destination: ec2.FlowLogDestination.toCloudWatchLogs(flowLogGroup),
      trafficType: ec2.FlowLogTrafficType.ALL,
      maxAggregationInterval: ec2.FlowLogMaxAggregationInterval.ONE_MINUTE,
    });

    const siteBucket = new s3.Bucket(this, 'SiteBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    new s3deploy.BucketDeployment(this, 'InitialSiteDeployment', {
      sources: [s3deploy.Source.asset('site')],
      destinationBucket: siteBucket,
      prune: true,
    });

    const instanceRole = new iam.Role(this, 'WebServerRole', {
      assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
      managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')],
    });
    siteBucket.grants.read(instanceRole);

    const loadBalancerSecurityGroup = new ec2.SecurityGroup(this, 'LoadBalancerSecurityGroup', {
      vpc,
      description: 'Allow public HTTP to the load balancer',
      allowAllOutbound: true,
    });
    loadBalancerSecurityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow public HTTP');

    const webServerSecurityGroup = new ec2.SecurityGroup(this, 'WebServerSecurityGroup', {
      vpc,
      description: 'Allow application traffic only from the load balancer',
      allowAllOutbound: true,
    });

    // STEP 4 FIX: omit this line during the deliberately broken deployment.
    webServerSecurityGroup.addIngressRule(loadBalancerSecurityGroup, ec2.Port.tcp(80), 'Allow HTTP only from the load balancer');

    const webServer = new ec2.Instance(this, 'WebServer', {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.MICRO),
      machineImage: ec2.MachineImage.latestAmazonLinux2023(),
      role: instanceRole,
      securityGroup: webServerSecurityGroup,
    });

    webServer.addUserData(
      'dnf install -y httpd',
      'systemctl enable --now httpd',
      `cat > /usr/local/bin/site-sync.sh <<'EOF'
#!/bin/bash
set -euo pipefail
aws s3 sync s3://${siteBucket.bucketName}/ /var/www/html/ --delete
EOF`,
      'chmod +x /usr/local/bin/site-sync.sh',
      `cat > /etc/systemd/system/site-sync.service <<'EOF'
[Unit]
Description=Sync portfolio site from Amazon S3
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/site-sync.sh
EOF`,
      `cat > /etc/systemd/system/site-sync.timer <<'EOF'
[Unit]
Description=Refresh portfolio site from Amazon S3

[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=5s
Unit=site-sync.service

[Install]
WantedBy=timers.target
EOF`,
      'systemctl daemon-reload',
      'systemctl enable --now site-sync.timer',
      '/usr/local/bin/site-sync.sh',
    );

    const loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', {
      vpc,
      internetFacing: true,
      securityGroup: loadBalancerSecurityGroup,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
    });
    const listener = loadBalancer.addListener('HttpListener', { port: 80, open: false });
    const targetGroup = listener.addTargets('WebTargets', {
      port: 80,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targets: [new targets.InstanceTarget(webServer, 80)],
      healthCheck: { path: '/', healthyHttpCodes: '200' },
    });

    const githubProvider = existingProviderArn
      ? iam.OidcProviderNative.fromOidcProviderArn(this, 'ImportedGitHubProvider', existingProviderArn)
      : new iam.OidcProviderNative(this, 'GitHubProvider', {
          url: 'https://token.actions.githubusercontent.com',
          clientIds: ['sts.amazonaws.com'],
        });

    const githubPrincipal = new iam.OpenIdConnectPrincipal(githubProvider).withConditions({
      StringEquals: {
        'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
        'token.actions.githubusercontent.com:sub': `repo:${githubOwner}@${githubOwnerId}/${githubRepo}@${githubRepoId}:ref:refs/heads/main`,
      },
    });

    const githubRole = new iam.Role(this, 'GitHubDeployRole', {
      assumedBy: githubPrincipal,
      description: 'Allows the main branch to deploy the portfolio site',
    });
    siteBucket.grants.readWrite(githubRole);
    siteBucket.grants.delete(githubRole);
    githubRole.addToPolicy(new iam.PolicyStatement({ actions: ['cloudformation:DescribeStacks'], resources: ['*'] }));

    // STEP 5 MONITORING START
    const requestCount = loadBalancer.metrics.requestCount({ period: Duration.minutes(1), statistic: 'Sum' });
    const targetResponseTime = loadBalancer.metrics.targetResponseTime({ period: Duration.minutes(1), statistic: 'Average' });
    const unhealthyHosts = targetGroup.metrics.unhealthyHostCount({ period: Duration.minutes(1), statistic: 'Maximum' });
    const cpuUtilization = new cloudwatch.Metric({
      namespace: 'AWS/EC2',
      metricName: 'CPUUtilization',
      dimensionsMap: { InstanceId: webServer.instanceId },
      period: Duration.minutes(5),
      statistic: 'Average',
    });
    const dashboard = new cloudwatch.Dashboard(this, 'OperationsDashboard', { dashboardName: 'CloudEngineerPortfolio' });
    dashboard.addWidgets(
      new cloudwatch.GraphWidget({ title: 'Application Load Balancer Requests', left: [requestCount], width: 8 }),
      new cloudwatch.GraphWidget({ title: 'Target Response Time', left: [targetResponseTime], width: 8 }),
      new cloudwatch.GraphWidget({ title: 'Availability and EC2 CPU', left: [unhealthyHosts], right: [cpuUtilization], width: 8 }),
    );
    new cloudwatch.Alarm(this, 'UnhealthyTargetAlarm', {
      alarmName: 'CloudEngineerPortfolio-UnhealthyTargets',
      alarmDescription: 'At least one load balancer target is unhealthy',
      metric: unhealthyHosts,
      threshold: 1,
      evaluationPeriods: 1,
    });
    // STEP 5 MONITORING END

    new CfnOutput(this, 'LoadBalancerUrl', { value: `http://${loadBalancer.loadBalancerDnsName}` });
    new CfnOutput(this, 'InstanceId', { value: webServer.instanceId });
    new CfnOutput(this, 'SiteBucketName', { value: siteBucket.bucketName });
    new CfnOutput(this, 'GitHubRoleArn', { value: githubRole.roleArn });
    new CfnOutput(this, 'FlowLogGroupName', { value: flowLogGroup.logGroupName });
    new CfnOutput(this, 'DashboardName', { value: dashboard.dashboardName });
  }
}

How to compare the stack

Check the imports first. Then compare the monitoring block and final outputs.

Configure OIDC delivery

OpenID Connect federation lets the workflow request short-lived AWS credentials for one run. The existing GitHubDeployRole restricts that access to the immutable repository identity on main.

The workflow can read the private bucket name from CloudFormation. It can then synchronize the saved site without storing a long-lived AWS key in GitHub.

  • Return to outputs.json in the CloudShell clone.
  • Copy the value beside GitHubRoleArn: your GitHub deployment role ARN
  • Return to the cloud-engineer-portfolio repository on GitHub.
  • Select Settings.
  • Select Secrets and variables.
  • Select Actions.
  • Select Variables.

Why use a repository variable?

The role ARN identifies which AWS role the workflow should request. It is configuration data and contains no AWS secret.

  • Choose New repository variable.
  • Enter AWS_ROLE_ARN in the Name field.
  • Paste your GitHub deployment role ARN into the Value field.
  • Choose Add variable.

The repository variable list should now show AWS_ROLE_ARN. The workflow can reference it without exposing a credential.

  • Switch back to the cloud-engineer-portfolio project in Visual Studio Code.
  • Create .github/workflows/deploy.yml inside the cloud-engineer-portfolio folder from the Explorer sidebar.
  • Add the workflow triggers and permissions by pasting this first section:
name: Deploy site to AWS

on:
  push:
    branches: [main]
    paths:
      - site/**
      - lib/**
      - bin/**
      - package.json
      - tsconfig.json
      - cdk.json
      - .github/workflows/deploy.yml
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  STACK_NAME: CloudEngineerPortfolio

What does the workflow header control?

  • The workflow runs for relevant changes pushed to main.
  • The manual trigger gives you a second way to run the deployment.
  • The identity-token permission allows GitHub to request an OIDC token.
  • The contents permission lets the workflow read the repository.
  • Append the validation and credential steps below the environment values by pasting:

jobs:
  validate-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository
        uses: actions/checkout@v7.0.1
      - name: Set up Node.js
        uses: actions/setup-node@v7.0.0
        with:
          node-version: '24.21.0'
      - name: Install pinned dependencies
        run: npm install --package-lock=false
      - name: Validate TypeScript
        run: npm run build
      - name: Synthesize AWS CDK
        run: >-
          npm run cdk -- synth
          -c githubOwner=${{ github.repository_owner }}
          -c githubRepo=cloud-engineer-portfolio
          -c githubOwnerId=${{ github.repository_owner_id }}
          -c githubRepoId=${{ github.repository_id }}
      - name: Configure short-lived AWS credentials
        uses: aws-actions/configure-aws-credentials@v6.3.0
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          role-session-name: cloud-engineer-portfolio
          aws-region: ${{ env.AWS_REGION }}

How does the workflow validate access?

  • The checkout step copies the repository into the workflow runner.
  • The Node.js step selects runtime version 24.21.0.
  • The build step validates the TypeScript application.
  • The synthesis step supplies the immutable GitHub owner and repository IDs to CDK.
  • The credential action exchanges the GitHub OIDC token for short-lived AWS credentials.
  • Append the bucket lookup and site deployment steps at the end of the workflow by pasting:
      - name: Resolve deployment bucket
        run: |
          BUCKET_NAME=$(aws cloudformation describe-stacks \
            --stack-name "$STACK_NAME" \
            --query "Stacks[0].Outputs[?OutputKey=='SiteBucketName'].OutputValue" \
            --output text)
          echo "BUCKET_NAME=$BUCKET_NAME" >> "$GITHUB_ENV"
      - name: Deploy site content
        run: aws s3 sync site/ "s3://${BUCKET_NAME}/" --delete

How does the site reach the private server?

The workflow resolves SiteBucketName from the deployed stack. It synchronizes the contents of site/ to that private bucket.

The systemd timer on the private EC2 instance pulls the bucket content into /var/www/html/. This preserves the private network boundary.

  • Save .github/workflows/deploy.yml.
  • Run the same TypeScript check used by the workflow from the Visual Studio Code terminal:
npm run build

What should the local check show?

The command should finish without a TypeScript error. Your local project now passes the workflow's validation stage.

  • Confirm that Git sees the new workflow by running:
git status --short

What should Git report?

The short status should include the new .github workflow path. This proves the file sits inside the repository.

Workflow file missing from Git status?

  • Confirm that deploy.yml is inside .github/workflows/.
  • Check that the filename ends with .yml.
  • Save the file before checking Git status again.

Help me find why Git cannot see my GitHub Actions workflow.

✔️ The workflow is ready

Your repository variable and workflow now form the OIDC delivery path. The next push can test the entire pipeline.

ⓧ I'd like to double check the full workflow

Compare .github/workflows/deploy.yml with the complete workflow below.

name: Deploy site to AWS

on:
  push:
    branches: [main]
    paths:
      - site/**
      - lib/**
      - bin/**
      - package.json
      - tsconfig.json
      - cdk.json
      - .github/workflows/deploy.yml
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: us-east-1
  STACK_NAME: CloudEngineerPortfolio

jobs:
  validate-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Check out repository
        uses: actions/checkout@v7.0.1
      - name: Set up Node.js
        uses: actions/setup-node@v7.0.0
        with:
          node-version: '24.21.0'
      - name: Install pinned dependencies
        run: npm install --package-lock=false
      - name: Validate TypeScript
        run: npm run build
      - name: Synthesize AWS CDK
        run: >-
          npm run cdk -- synth
          -c githubOwner=${{ github.repository_owner }}
          -c githubRepo=cloud-engineer-portfolio
          -c githubOwnerId=${{ github.repository_owner_id }}
          -c githubRepoId=${{ github.repository_id }}
      - name: Configure short-lived AWS credentials
        uses: aws-actions/configure-aws-credentials@v6.3.0
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          role-session-name: cloud-engineer-portfolio
          aws-region: ${{ env.AWS_REGION }}
      - name: Resolve deployment bucket
        run: |
          BUCKET_NAME=$(aws cloudformation describe-stacks \
            --stack-name "$STACK_NAME" \
            --query "Stacks[0].Outputs[?OutputKey=='SiteBucketName'].OutputValue" \
            --output text)
          echo "BUCKET_NAME=$BUCKET_NAME" >> "$GITHUB_ENV"
      - name: Deploy site content
        run: aws s3 sync site/ "s3://${BUCKET_NAME}/" --delete

How to compare the workflow

Check the trigger paths first. Then compare the OIDC step and final S3 synchronization command.

Document and run the pipeline

A hiring-manager demo needs an explanation of the design and the incident. The repository documentation connects the live system to the decisions you made.

  • Create README.md inside the cloud-engineer-portfolio folder from the Visual Studio Code Explorer sidebar.
  • Add the project summary and architecture sections by pasting this first part:
# Secure AWS Web Tier Portfolio

This project demonstrates the core AWS responsibilities in the Populous Cloud Engineer posting: Linux administration, cloud networking, security controls, AWS CDK with TypeScript, CI/CD infrastructure, monitoring, and troubleshooting.

Containers are intentionally excluded because the posting does not request Docker, Amazon ECS, Amazon EKS, or Kubernetes.

## Architecture

```mermaid
flowchart LR
  User[Hiring manager or user] --> ALB[Public Application Load Balancer]
  ALB --> EC2[Private Amazon Linux 2023 EC2 instance]
  GitHub[GitHub Actions with OIDC] --> S3[Private S3 deployment bucket]
  S3 --> EC2
  EC2 --> CW[CloudWatch metrics and alarm]
  VPC[VPC Flow Logs] --> CW
```

## Security decisions

- The EC2 instance is in a private subnet and has no direct inbound access from the internet.
- The application security group accepts HTTP only from the load balancer security group.
- Systems Manager replaces SSH for administrative commands.
- GitHub Actions uses short-lived OIDC credentials.
- The site bucket blocks public access and uses S3-managed encryption.
- An S3 gateway endpoint keeps S3 traffic on the AWS network.

## Demonstrated incident

What does this documentation establish?

The architecture diagram shows the public entry point and private application target. The security list explains how access remains constrained.

The container note keeps the portfolio focused on the Linux and networking skills requested by the role.

  • Append the incident summary and demo guide below the final heading by pasting:

The first deployment intentionally omitted the load-balancer-to-instance security-group rule. Apache returned the page locally, but the load balancer reported an unhealthy target and returned HTTP 503. Adding the missing source-security-group rule in AWS CDK restored service.

See `docs/incident-report.md` for the evidence and recovery narrative.

## Hiring manager demo

1. Open the load balancer URL and show the healthy status page.
2. Show the CDK-defined VPC, private EC2 instance, security groups, and monitoring resources.
3. Open the GitHub Actions run and explain OIDC authentication and the S3 deployment target.
4. Open the CloudWatch dashboard and explain request, latency, target health, and CPU signals.
5. Walk through the incident report and the commit that repaired the firewall path.

## Cost control

The NAT gateway, Application Load Balancer, public IPv4 addresses, EC2 instance, and logs can incur charges. Delete the stack after collecting screenshots unless you intentionally want to keep the live demo running.

Why include the demo sequence?

The sequence turns the repository into a guided technical demonstration. Each item points to evidence that already exists in the project.

  • Save README.md.
  • View the rendered Markdown in Visual Studio Code's preview pane.

You should see the Secure AWS Web Tier Portfolio heading and a rendered architecture diagram. The security decisions should appear as six distinct bullets.

  • Create docs/incident-report.md inside the cloud-engineer-portfolio folder from the Explorer sidebar.
  • Document the original HTTP 503 incident by pasting this report:
# Incident Report: Unhealthy Private Web Target

## Summary

The public Application Load Balancer returned HTTP 503 because its target was unhealthy.

## Evidence

- Load balancer symptom: [Add screenshot or target-health output]
- Linux service evidence: [Add Systems Manager command output]
- Network evidence: [Add VPC Flow Logs screenshot or observation]
- Recovery evidence: [Add healthy target and live-page screenshot]

## Diagnosis

Apache was active and `curl localhost` returned the expected page. The failure was therefore outside the application process. The EC2 security group did not allow TCP port 80 from the load balancer security group, so health checks could not reach the target.

## Remediation

The missing ingress rule was added to `lib/cloud-engineer-portfolio-stack.ts` and deployed through AWS CDK. The rule uses the load balancer security group as its source instead of an internet-wide CIDR.

## Verification

The target changed to healthy and the load balancer served the portfolio page.

## Prevention

Keep network controls in version control, require CDK synthesis in CI, monitor unhealthy target count, and preserve troubleshooting evidence with the change that fixes the incident.

How is the incident structured?

  • The summary records the customer-facing symptom.
  • The evidence section separates load balancer, Linux, and network observations.
  • The diagnosis identifies the missing security-group path.
  • The remediation and prevention sections connect the code fix to future controls.
  • Save docs/incident-report.md.
  • View the report in Visual Studio Code's Markdown preview pane.

You should see separate sections for evidence, diagnosis, remediation, verification, and prevention. The report now preserves the reasoning behind the security-group repair.

The saved site/index.html already carries deployment version v1.1. That page is the artifact the workflow synchronizes to the private deployment bucket.

✔️ My documentation is complete

Your repository now explains the architecture and the original incident. The final push can test the delivery path.

ⓧ I'd like to double check the documentation

Compare README.md with the complete file below.

# Secure AWS Web Tier Portfolio

This project demonstrates the core AWS responsibilities in the Populous Cloud Engineer posting: Linux administration, cloud networking, security controls, AWS CDK with TypeScript, CI/CD infrastructure, monitoring, and troubleshooting.

Containers are intentionally excluded because the posting does not request Docker, Amazon ECS, Amazon EKS, or Kubernetes.

## Architecture

```mermaid
flowchart LR
  User[Hiring manager or user] --> ALB[Public Application Load Balancer]
  ALB --> EC2[Private Amazon Linux 2023 EC2 instance]
  GitHub[GitHub Actions with OIDC] --> S3[Private S3 deployment bucket]
  S3 --> EC2
  EC2 --> CW[CloudWatch metrics and alarm]
  VPC[VPC Flow Logs] --> CW
```

## Security decisions

- The EC2 instance is in a private subnet and has no direct inbound access from the internet.
- The application security group accepts HTTP only from the load balancer security group.
- Systems Manager replaces SSH for administrative commands.
- GitHub Actions uses short-lived OIDC credentials.
- The site bucket blocks public access and uses S3-managed encryption.
- An S3 gateway endpoint keeps S3 traffic on the AWS network.

## Demonstrated incident

The first deployment intentionally omitted the load-balancer-to-instance security-group rule. Apache returned the page locally, but the load balancer reported an unhealthy target and returned HTTP 503. Adding the missing source-security-group rule in AWS CDK restored service.

See `docs/incident-report.md` for the evidence and recovery narrative.

## Hiring manager demo

1. Open the load balancer URL and show the healthy status page.
2. Show the CDK-defined VPC, private EC2 instance, security groups, and monitoring resources.
3. Open the GitHub Actions run and explain OIDC authentication and the S3 deployment target.
4. Open the CloudWatch dashboard and explain request, latency, target health, and CPU signals.
5. Walk through the incident report and the commit that repaired the firewall path.

## Cost control

The NAT gateway, Application Load Balancer, public IPv4 addresses, EC2 instance, and logs can incur charges. Delete the stack after collecting screenshots unless you intentionally want to keep the live demo running.

How to compare the README

Check the Mermaid diagram first. Then compare the security decisions and hiring-manager demo sections.

Compare docs/incident-report.md with the complete report below.

# Incident Report: Unhealthy Private Web Target

## Summary

The public Application Load Balancer returned HTTP 503 because its target was unhealthy.

## Evidence

- Load balancer symptom: [Add screenshot or target-health output]
- Linux service evidence: [Add Systems Manager command output]
- Network evidence: [Add VPC Flow Logs screenshot or observation]
- Recovery evidence: [Add healthy target and live-page screenshot]

## Diagnosis

Apache was active and `curl localhost` returned the expected page. The failure was therefore outside the application process. The EC2 security group did not allow TCP port 80 from the load balancer security group, so health checks could not reach the target.

## Remediation

The missing ingress rule was added to `lib/cloud-engineer-portfolio-stack.ts` and deployed through AWS CDK. The rule uses the load balancer security group as its source instead of an internet-wide CIDR.

## Verification

The target changed to healthy and the load balancer served the portfolio page.

## Prevention

Keep network controls in version control, require CDK synthesis in CI, monitor unhealthy target count, and preserve troubleshooting evidence with the change that fixes the incident.

How to compare the incident report

Check that every incident stage has its own heading. Confirm that the diagnosis names the missing port 80 path from the load balancer security group.

Before you push, do you expect the workflow to validate the CDK application before it requests AWS credentials?

  • Publish the workflow and documentation to main by running these commands in the Visual Studio Code terminal:
git add .
git commit -m "Add CI/CD and monitoring"
git push origin main

What does this push trigger?

The commit includes the workflow path listed in its own trigger. GitHub starts Deploy site to AWS after the push reaches main.

The workflow validates TypeScript before requesting short-lived AWS credentials. It then synchronizes the saved site/ content to the private bucket.

  • Return to the cloud-engineer-portfolio repository on GitHub.
  • Select Actions.
  • Select the Deploy site to AWS workflow.
  • Open the run created by your latest push.
  • Wait for the workflow to reach a successful state.

Strong finish. Your repository has now authenticated to AWS without a long-lived access key and synchronized the site through the private bucket.

  • Wait up to 60 seconds for the WebServer systemd timer to synchronize the bucket.
  • Refresh the page at your recorded LoadBalancerUrl.
  • Confirm that the portfolio page loads with green status cards and deployment version v1.1.
  • Return to the CloudEngineerPortfolio dashboard in Amazon CloudWatch.
  • Refresh the dashboard after loading the portfolio page.
  • Confirm that the request and response-time graphs contain activity.
  • Confirm that the unhealthy-target signal remains normal.

The live page proves delivery succeeded. The dashboard proves the healthy target handled the request.

Workflow did not complete successfully?

  • Confirm that the repository variable is named exactly AWS_ROLE_ARN.
  • Confirm that its value matches the GitHubRoleArn stack output.
  • Check that the workflow synthesis step includes the immutable owner and repository ID contexts.
  • Open the failed workflow step to identify whether validation, OIDC authentication, or S3 synchronization stopped the run.

Help me diagnose the failed GitHub Actions deployment.

You now have a repeatable delivery path and an operations dashboard for the private web tier. The repository also preserves the architecture and troubleshooting evidence behind the result.

Secret mission

Recover a Failed Linux Service

Create a controlled Apache outage on the private WebServer. Use the load balancer and CloudWatch alarm to detect the failure. Recover the service through Systems Manager without opening SSH.

Clean Up Your Resources

Clean Up Your Resources

Your AWS resources can keep accruing charges after the lab ends. Decide whether to keep the live demo running, pause part of it, or delete the project stack today.

Cost warning

The four-hour estimate stays under $2 with light traffic. A $5 guardrail leaves room for workload-dependent usage.

  • The NAT gateway continues billing while it exists.
  • The Application Load Balancer continues billing while it exists.
  • Public IPv4 addresses continue billing while allocated.
  • Amazon EC2 can add compute charges while the instance runs.
  • Log ingestion can add usage-based charges.
  • Storage can add usage-based charges.
  • Delete the stack promptly unless you need the live hiring-manager demo.

Resources you used:

  • The AWS CloudFormation stack named CloudEngineerPortfolio.
  • The Amazon VPC network with public subnets, private application subnets, route tables, an internet gateway, a NAT gateway, and an S3 gateway endpoint.
  • The private WebServer instance with its security groups, Apache service, systemd timer, load balancer, and target group.
  • The private Amazon S3 site bucket with its deployed portfolio files.
  • The instance role with its Systems Manager permissions and the GitHubDeployRole with its OIDC trust policy.
  • The VPC Flow Log with its log group, the CloudWatch dashboard, and the CloudEngineerPortfolio-UnhealthyTargets alarm.

Keep everything running

No action needed. Choose this if you still need the live status page, delivery workflow, monitoring dashboard, or hiring-manager demo.

  • Keep CloudEngineerPortfolio deployed to preserve the live load balancer URL.
  • Keep the GitHub Actions workflow available for updates from the main branch.
  • Check AWS Billing regularly if you keep the stack beyond the four-hour lab window.
  • Leave CDKToolkit in place for future AWS CDK deployments.

Pause - I'll come back to this later

Shut down the running EC2 instance to reduce compute use while preserving the stack. This remains a paid partial pause because the NAT gateway and load balancer continue billing.

  • Return to the AWS Management Console from earlier.
  • Go to Amazon EC2 in us-east-1.
  • Select the private WebServer instance.
  • Stop the selected instance to suspend its compute usage.
  • Confirm WebTargets becomes unhealthy after the instance stops.
  • Confirm CloudEngineerPortfolio-UnhealthyTargets returns to ALARM.
  • Start the same instance when you return to the project.
  • Delete the stack instead if you need to stop the remaining infrastructure charges.

Delete - I don't want to use this again

Deleting the stack removes your live AWS environment. Your public GitHub repository and saved screenshots remain available as portfolio evidence.

  • Return to the authenticated AWS CloudShell session in us-east-1.
  • Switch back to the cloned cloud-engineer-portfolio repository.
  • Replace GITHUB_OWNER with the owner value you recorded.
  • Replace OWNER_ID with the owner ID you recorded.
  • Replace REPOSITORY_ID with the repository ID you recorded.
  • Delete CloudEngineerPortfolio by running this command:
npm run cdk -- destroy CloudEngineerPortfolio --force -c githubOwner=GITHUB_OWNER -c githubRepo=cloud-engineer-portfolio -c githubOwnerId=OWNER_ID -c githubRepoId=REPOSITORY_ID

What does this command do?

  • The npm script invokes AWS CDK to destroy CloudEngineerPortfolio.
  • The --force flag skips the destruction confirmation prompt.
  • The context values let the CDK application identify the same deployment configuration.
  • AWS CloudFormation deletes the stack-managed network, compute, storage, IAM, logging, dashboard, and alarm resources listed above.

Expect CloudShell to remain busy while AWS removes dependent resources in order.

  • Wait for the CloudShell command to finish.
  • Refresh the saved LoadBalancerUrl.
  • Keep the public repository and your screenshots for the hiring-manager demo.
  • Leave the separate CDKToolkit bootstrap stack in place unless you deliberately plan to remove shared bootstrap resources.

Your saved load balancer URL no longer serves the portfolio page. The dashboard and alarm also disappear with the deleted stack.

Stack deletion stopped?

  • Confirm AWS CloudShell still uses the AWS identity that deployed the stack.
  • Confirm the active region is us-east-1.
  • Confirm you replaced all three recorded context values in the command.
  • Help me diagnose why my CloudEngineerPortfolio CDK destroy command failed.

Nice Work!

Nice Work!

You did it! Your secure web tier is live. The finished portfolio now proves the operational thinking behind every design choice.

You've learned how to:

  • Built a live Cloud Engineer Portfolio page on a private Amazon EC2 instance behind an internet-facing Application Load Balancer. Defined its segmented VPC as infrastructure as code using AWS CDK with TypeScript.
  • Used AWS Systems Manager to prove that Apache was healthy during the planned network failure. Used VPC Flow Logs to identify the rejected path. Repaired the least-privilege security-group rule in AWS CDK.
  • Created a GitHub Actions deployment that uses OIDC federation for short-lived AWS credentials. Added an Amazon CloudWatch dashboard for application health. Captured the architecture and incident evidence in the repository.
  • Secret Mission: Recovered from a controlled Apache outage through AWS Systems Manager. Confirmed the alarm reached ALARM. Restored the service without SSH.

Ready to quiz yourself?