Deploy a Secure AWS Portfolio Site

Use Terraform and GitHub Actions to deploy a private S3 site with CloudFront.

Introduction

30 Second Summary

A claim on a resume becomes more convincing when someone can open the result and see it working. A live project also gives you a specific story to tell in interviews.

In this project, you will deploy a secure portfolio page to Amazon Web Services with Terraform. GitHub Actions will give reviewers a visible validation result for each infrastructure change.

What You'll Build

You open an HTTPS link to see your portfolio page load through Amazon CloudFront from private Amazon S3 storage.

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

  • A secure live demo that loads over HTTPS through CloudFront. Direct S3 access returns Access Denied to prove the origin is private.
  • A reproducible Terraform workflow for the complete AWS environment. You can review proposed changes before applying them.
  • An automatic CI check that validates your infrastructure code on pushes and pull requests in a public GitHub repository.
  • Secret Mission: Create configuration drift by changing an S3 tag outside Terraform. Detect the drift in a plan. Reconcile the bucket back to code.

Are there any prerequisites?

You need a Windows computer with Visual Studio Code and Git for Windows. You also need AWS and GitHub accounts with permission to use AWS console credentials and create the required S3 and CloudFront resources.

Before We Start

Before the hands-on work begins, commit to deploying a secure, reproducible AWS portfolio site. Connect that project to the DevOps or cloud role you want next.

Set Up Terraform and Secure AWS Access

Your secure portfolio site eventually creates real AWS resources. Predictable local tooling keeps that Infrastructure as Code workflow reproducible.

Terraform CLI version 1.16.5 gives a fresh Visual Studio Code PowerShell terminal a pinned infrastructure interpreter.

AWS CLI version 2.37.12 connects that terminal to your AWS account. Browser-based sign-in supplies temporary credentials for the identity check.

In this step, get ready to:
  • Make Terraform CLI 1.16.5 available to PowerShell.
  • Install AWS CLI 2.37.12 for your Windows user.
  • Authenticate AWS CLI with temporary credentials.
Install Terraform CLI 1.16.5

Terraform reads declarative configuration to calculate changes for your cloud infrastructure. Pinning its version keeps local results consistent with the automation you add later.

  • Press the Windows key to open Search.
  • Enter Visual Studio Code in the search field.
  • Press Enter to open Visual Studio Code.
  • Create a PowerShell terminal from Visual Studio Code's top terminal menu.
  • Check the available Terraform version by running this command:
terraform -version

What Does This Check?

Terraform prints the installed CLI version. The result tells you whether the required executable is already available through your user PATH.

✔️ I see the required version

Terraform CLI 1.16.5 is ready. Your current terminal can find the pinned executable.

ⓧ I see an older version

Your user PATH currently finds an older Terraform executable. Pointing it to the pinned binary keeps this project on version 1.16.5.

  • Download the Windows AMD64 binary for version 1.16.5 from HashiCorp's official Terraform install page.
  • Create a permanent folder named terraform inside your Windows user folder with File Explorer.
  • Extract terraform.exe from the downloaded archive into that folder.
  • Use Windows Search to open the environment-variable settings for your user account.
  • Replace the old Terraform folder in your user PATH with the folder containing terraform.exe.
  • Close every Visual Studio Code window.
  • Reopen Visual Studio Code through Windows Search.
  • Create a fresh PowerShell terminal from Visual Studio Code's top terminal menu.
  • Confirm the new Terraform version by running this command:
terraform -version

Why Use a Fresh Terminal?

A fresh PowerShell terminal reads your updated user PATH. Terraform should now report version 1.16.5.

ⓧ Command not found

Terraform is ready to install. Windows PATH can be fiddly because an open terminal keeps the environment it started with.

  • Download the Windows AMD64 binary for version 1.16.5 from HashiCorp's official Terraform install page.
  • Create a permanent folder named terraform inside your Windows user folder with File Explorer.
  • Extract terraform.exe from the downloaded archive into that folder.
  • Use Windows Search to open the environment-variable settings for your user account.
  • Add the folder containing terraform.exe to your user PATH.
  • Close every Visual Studio Code window.
  • Reopen Visual Studio Code through Windows Search.
  • Create a fresh PowerShell terminal from Visual Studio Code's top terminal menu.
  • Confirm the Terraform installation by running this command:
terraform -version

Why Use a Fresh Terminal?

A fresh PowerShell terminal reads your updated user PATH. Terraform should now report version 1.16.5.

Still Missing the Required Terraform Version?

  • Confirm that terraform.exe sits directly inside the folder you added to your user PATH.
  • Close every Visual Studio Code window after changing your user PATH.
  • Remove older Terraform folders from your user PATH if PowerShell keeps finding the wrong executable.

Help me fix the Terraform version check.

Install AWS CLI 2.37.12

AWS CLI sends authenticated commands from your terminal to AWS services. The current-user package installs it without changing the setup for other Windows users.

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

What Does This Check?

AWS CLI prints its installed version. The result shows whether your terminal can find the required command.

✔️ I see the required version

AWS CLI 2.37.12 is available. That version supports the browser-based sign-in used in this project.

ⓧ I see an older version

Your terminal finds an older AWS CLI installation. The current-user installer updates the CLI used by your Windows account.

  • Install the current AWS CLI package for your Windows user by running this command:
irm https://awscli.amazonaws.com/v2/install.ps1 | iex

What Does This Installer Do?

PowerShell downloads the official AWS CLI current-user installer. The package installs AWS CLI for your Windows account.

  • Close every Visual Studio Code window after the installer completes.
  • Reopen Visual Studio Code through Windows Search.
  • Create a fresh PowerShell terminal from Visual Studio Code's top terminal menu.
  • Confirm the updated AWS CLI version by running this command:
aws --version

What Should You See?

The fresh terminal should report AWS CLI version 2.37.12. This confirms that PowerShell can find the updated installation.

ⓧ Command not found

AWS CLI is ready to install for your Windows account. The official current-user installer avoids an administrator-level installation.

  • Install AWS CLI for your current Windows user by running this command:
irm https://awscli.amazonaws.com/v2/install.ps1 | iex

What Does This Installer Do?

PowerShell downloads the official AWS CLI current-user installer. The package installs AWS CLI for your Windows account.

  • Close every Visual Studio Code window after the installer completes.
  • Reopen Visual Studio Code through Windows Search.
  • Create a fresh PowerShell terminal from Visual Studio Code's top terminal menu.
  • Confirm the AWS CLI installation by running this command:
aws --version

What Should You See?

The fresh terminal should report AWS CLI version 2.37.12. This confirms that PowerShell can find the new installation.

Still Missing AWS CLI?

  • Close every Visual Studio Code window after the installer finishes.
  • Reopen Visual Studio Code through Windows Search before checking the version again.
  • Rerun the official current-user installer if the first download was interrupted.

Help me fix the AWS CLI installation.

Sign in and verify your AWS identity

AWS sign-in handles sensitive access through a browser flow that keeps long-lived keys out of the terminal. The CLI receives temporary credentials for your signed-in console identity.

  • Start the browser-based AWS sign-in by running this command:
aws login

What Does This Sign-In Do?

AWS CLI opens a browser-based sign-in flow. Version 2.37.12 meets the required version of 2.32.0 or later.

After sign-in, AWS CLI caches temporary credentials for the default profile. Those credentials refresh automatically during the active session.

  • Complete the browser sign-in with your AWS console identity.

You should see the terminal report a successful sign-in. Your temporary AWS session is now active.

Browser Sign-In Not Completing?

If your IAM identity cannot start the flow, ask your AWS administrator whether it has the SignInLocalDevelopmentAccess managed policy. Use an authorized non-root identity for routine project work.

Help me troubleshoot AWS browser sign-in.

  • Create a fresh PowerShell terminal from Visual Studio Code's top terminal menu.

Before you run the checks, ask yourself which AWS account identity this terminal should report.

  • Verify both pinned tools plus your temporary AWS identity by running these commands:
terraform -version
aws --version
aws sts get-caller-identity

What Do These Checks Prove?

  • The first line confirms that PowerShell can find the pinned Terraform CLI.
  • The second line confirms that PowerShell can find the pinned AWS CLI.
  • The third line asks AWS Security Token Service which identity owns the current temporary session.

You should see Terraform version 1.16.5 plus AWS CLI version 2.37.12.

You should also see your AWS account identity. That result proves the temporary credentials are working.

Authentication and Authorization

The identity result proves that AWS accepted your temporary credentials. Your account authorization still determines which resources that identity can create.

  • Confirm that the reported identity can create Amazon S3 resources.
  • Confirm that the reported identity can create Amazon CloudFront resources.
  • Stop before any future Terraform apply if either permission is missing.

That's the setup locked in. Your fresh PowerShell terminal now has pinned infrastructure tooling plus authenticated AWS access.

Your infrastructure tools and temporary AWS access are ready. Next, you'll build the portfolio page locally before cloud resources enter the picture.

Build the Local Portfolio Page

Your Terraform tools can now create cloud resources safely. Your Amazon Web Services session is also ready for the work ahead.

Before cloud infrastructure adds storage or delivery concerns, you need a visible page that works locally. This step gives you that baseline before you publish its source to GitHub.

In this step, get ready to:
  • Create the local portfolio page.
  • Style the page around six DevOps capabilities.
  • Publish a Terraform-safe public repository.
Create the first visible page

An HTML file defines the content that your browser displays. Building this file locally gives you an immediate result before AWS becomes part of the request path.

  • Switch back to the Visual Studio Code window from the previous step.
  • Click File in the top menu.
  • Select Open Folder.
  • Select Desktop in the folder dialog.
  • Select New Folder.
  • Enter secure-aws-portfolio as the folder name.
  • Select Select Folder to open the new folder.
  • Confirm that you trust the folder if Visual Studio Code shows a Workspace Trust prompt.

Your Project Workspace

Opening secure-aws-portfolio as a workspace keeps its files visible in the Explorer. Later steps can use the same folder for the Terraform configuration.

  • Select the New Folder button at the top of the Explorer.
  • Type site as the folder name.
  • Press Enter.
  • Select the site folder in the Explorer.
  • Select the New File button at the top of the Explorer.
  • Type index.html as the file name.
  • Press Enter to open the file in the editor.
  • Create the first visible version of the page by pasting this code into site/index.html:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Secure AWS Portfolio Site</title>
</head>
<body>
  <main>
    <p class="eyebrow">DevOps Portfolio Project</p>
    <h1>Secure AWS Portfolio Site</h1>
    <p class="summary">
      This page is stored in a private Amazon S3 bucket, delivered over HTTPS by
      Amazon CloudFront, and managed as reproducible infrastructure with Terraform.
    </p>
    <ul>
      <li><strong>Infrastructure as Code</strong>Terraform plans and manages the AWS environment.</li>
      <li><strong>Private Origin</strong>Amazon S3 blocks direct public access.</li>
      <li><strong>Secure Delivery</strong>CloudFront uses Origin Access Control and HTTPS.</li>
      <li><strong>Continuous Integration</strong>GitHub Actions formats and validates every change.</li>
      <li><strong>Drift Recovery</strong>Out-of-band changes are detected and reconciled.</li>
      <li><strong>Cost Control</strong>The environment can be destroyed from one Terraform workflow.</li>
    </ul>
    <footer>Built to demonstrate practical cloud and DevOps engineering skills.</footer>
  </main>
</body>
</html>

What Does This Code Do?

  • The metadata helps the page display correctly across desktop and mobile browsers.
  • The main content introduces the project through six capabilities that you can discuss in an interview.
  • The class names give the next substep precise targets for visual styling.
  • Save site/index.html by pressing Ctrl+S.
  • Right-click index.html in the Explorer.
  • Select Reveal in File Explorer.
  • Double-click index.html in File Explorer.

Your browser displays the title, summary, six capability descriptions, and footer as an unstyled page. That visible baseline proves the content works before styling changes its presentation.

Page Missing Content?

Confirm that the file is named index.html inside the site folder. Check that the file ends with the closing </html> tag.

Help me find why my local HTML page is missing content.

Style the portfolio page

The page content works, so CSS can now control its colours, spacing, and layout. Each small styling change produces a visible result in the browser.

  • Return to site/index.html in Visual Studio Code.
  • Find the <title>Secure AWS Portfolio Site</title> line.
  • Add the global colour and font rules directly below that line by pasting this code:
  <style>
    :root {
      color-scheme: dark;
      font-family: Inter, system-ui, sans-serif;
      background: #07111f;
      color: #e6edf7;
    }

    * {
      box-sizing: border-box;
    }
  </style>

What Do These Rules Control?

  • The root rule gives the page a dark colour scheme with a readable system font.
  • The universal rule keeps padding and borders inside each element's declared size.
  • Save site/index.html.
  • Refresh the browser page.

You should see a dark navy background with light text. The page now uses the selected system font.

Colours Still Unchanged?

Confirm that the entire <style> block sits inside <head>. Make sure the browser is displaying the same site/index.html file you saved.

Help me fix the global page styles.

  • Find the closing </style> tag.
  • Center the page over its gradient background by inserting this block immediately above that tag:
    body {
      min-height: 100vh;
      margin: 0;
      display: grid;
      place-items: center;
      padding: 2rem;
      background:
        radial-gradient(circle at top right, #164e63 0, transparent 35%),
        radial-gradient(circle at bottom left, #312e81 0, transparent 35%),
        #07111f;
    }

How Does the Page Centre Itself?

  • The grid layout places the main content in the centre of the viewport.
  • The layered radial gradients add colour while preserving the dark background.
  • Save site/index.html.
  • Refresh the browser page.

You should see the content centred over cyan and indigo background glows.

Content Not Centred?

Check that the selector is spelled body. Confirm that every property sits between its opening and closing braces.

Help me correct the body layout.

  • Find the closing </style> tag again.
  • Turn the main content into a bordered card by inserting this block immediately above that tag:
    main {
      width: min(760px, 100%);
      padding: 2.5rem;
      border: 1px solid #334155;
      border-radius: 1.25rem;
      background: rgba(15, 23, 42, 0.92);
      box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
    }

What Creates the Card?

  • The width rule keeps the card responsive on narrow screens.
  • The border, rounded corners, background, and shadow separate the content from the page.
  • Save site/index.html.
  • Refresh the browser page.

You should see the portfolio content inside a wide translucent card with rounded corners.

Card Still Missing?

Confirm that the page contains one <main> element. Check that the main selector appears inside the style block.

Help me fix the main card styling.

  • Find the closing </style> tag.
  • Style the project label by inserting this block immediately above that tag:
    .eyebrow {
      margin: 0 0 0.75rem;
      color: #67e8f9;
      font-weight: 700;
      letter-spacing: 0.12em;
      text-transform: uppercase;
    }

Why Use an Eyebrow Label?

The eyebrow class turns the short project label into a compact visual category. Its colour and spacing separate it from the main heading.

  • Save site/index.html.
  • Refresh the browser page.

You should see the project label in cyan uppercase text above the title.

Label Style Not Applying?

Check that the paragraph uses class="eyebrow". Confirm that the CSS selector starts with a period.

Help me connect the eyebrow class to its CSS.

  • Find the closing </style> tag.
  • Scale the page title by inserting this block immediately above that tag:
    h1 {
      margin: 0;
      font-size: clamp(2rem, 7vw, 4rem);
      line-height: 1;
    }

How Does the Title Stay Responsive?

The clamp function lets the heading grow with the viewport. Its minimum and maximum values keep the text readable.

  • Save site/index.html.
  • Refresh the browser page.

You should see a large title that fits within the card.

Title Size Still Unchanged?

Confirm that the title uses an <h1> element. Check the commas inside the clamp() value.

Help me repair the responsive title rule.

  • Find the closing </style> tag.
  • Improve the summary spacing and readability by inserting this block immediately above that tag:
    .summary {
      margin: 1.25rem 0 2rem;
      color: #cbd5e1;
      font-size: 1.1rem;
      line-height: 1.7;
    }

What Changes the Summary?

The summary rule gives the paragraph more breathing room. Its softer colour keeps the title at the top of the visual hierarchy.

  • Save site/index.html.
  • Refresh the browser page.

You should see a muted summary with more space above the capability list.

Summary Still Looks Plain?

Check that the paragraph uses class="summary". Confirm that the selector is spelled .summary.

Help me connect the summary class to its styles.

  • Find the closing </style> tag.
  • Arrange the capabilities as a responsive grid by inserting this block immediately above that tag:
    ul {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
      gap: 0.85rem;
      margin: 0;
      padding: 0;
      list-style: none;
    }

How Does the Grid Adapt?

The grid creates as many columns as the card width can support. Each capability keeps a useful minimum width.

  • Save site/index.html.
  • Refresh the browser page.

You should see the six capabilities arranged in responsive columns without bullet markers.

Capabilities Still in One List?

Confirm that the capabilities remain inside one <ul> element. Check the parentheses inside the grid column value.

Help me fix the capability grid.

  • Find the closing </style> tag.
  • Turn each capability into a small card by inserting this block immediately above that tag:
    li {
      padding: 1rem;
      border: 1px solid #334155;
      border-radius: 0.8rem;
      background: #111c30;
    }

What Defines Each Capability Card?

Padding creates space around each capability. The border and background make every list item a separate card.

  • Save site/index.html.
  • Refresh the browser page.

You should see six bordered capability cards inside the main portfolio card.

Capability Cards Missing?

Check that every capability is wrapped in an <li> element. Confirm that the li selector is inside the style block.

Help me fix the capability card styling.

  • Find the closing </style> tag.
  • Emphasize each capability name by inserting this block immediately above that tag:
    strong {
      display: block;
      margin-bottom: 0.35rem;
      color: #a5f3fc;
    }

Why Display Names as Blocks?

Block display places every capability name on its own line. The cyan colour separates each name from its explanation.

  • Save site/index.html.
  • Refresh the browser page.

You should see a cyan heading above the description in every capability card.

Capability Names Not Emphasized?

Confirm that each capability name sits inside <strong> tags. Check that the selector is spelled strong.

Help me repair the capability name styles.

  • Find the closing </style> tag.
  • Finish the page hierarchy by inserting this footer block immediately above that tag:
    footer {
      margin-top: 2rem;
      color: #94a3b8;
      font-size: 0.9rem;
    }

What Finishes the Footer?

The top margin separates the footer from the grid. Its smaller muted text keeps attention on the portfolio capabilities.

  • Save site/index.html.
  • Refresh the browser page.

You should see a smaller muted footer below the six capability cards. The finished page now matches the intended dark portfolio design.

Footer Still Too Prominent?

Confirm that the final sentence sits inside a <footer> element. Check that the footer rule appears before the closing style tag.

Help me fix the footer styling.

✔️ Awesome, I've got everything!

Great. Save site/index.html once more before moving on.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Secure AWS Portfolio Site</title>
  <style>
    :root {
      color-scheme: dark;
      font-family: Inter, system-ui, sans-serif;
      background: #07111f;
      color: #e6edf7;
    }

    * {
      box-sizing: border-box;
    }

    body {
      min-height: 100vh;
      margin: 0;
      display: grid;
      place-items: center;
      padding: 2rem;
      background:
        radial-gradient(circle at top right, #164e63 0, transparent 35%),
        radial-gradient(circle at bottom left, #312e81 0, transparent 35%),
        #07111f;
    }

    main {
      width: min(760px, 100%);
      padding: 2.5rem;
      border: 1px solid #334155;
      border-radius: 1.25rem;
      background: rgba(15, 23, 42, 0.92);
      box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
    }

    .eyebrow {
      margin: 0 0 0.75rem;
      color: #67e8f9;
      font-weight: 700;
      letter-spacing: 0.12em;
      text-transform: uppercase;
    }

    h1 {
      margin: 0;
      font-size: clamp(2rem, 7vw, 4rem);
      line-height: 1;
    }

    .summary {
      margin: 1.25rem 0 2rem;
      color: #cbd5e1;
      font-size: 1.1rem;
      line-height: 1.7;
    }

    ul {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
      gap: 0.85rem;
      margin: 0;
      padding: 0;
      list-style: none;
    }

    li {
      padding: 1rem;
      border: 1px solid #334155;
      border-radius: 0.8rem;
      background: #111c30;
    }

    strong {
      display: block;
      margin-bottom: 0.35rem;
      color: #a5f3fc;
    }

    footer {
      margin-top: 2rem;
      color: #94a3b8;
      font-size: 0.9rem;
    }
  </style>
</head>
<body>
  <main>
    <p class="eyebrow">DevOps Portfolio Project</p>
    <h1>Secure AWS Portfolio Site</h1>
    <p class="summary">
      This page is stored in a private Amazon S3 bucket, delivered over HTTPS by
      Amazon CloudFront, and managed as reproducible infrastructure with Terraform.
    </p>
    <ul>
      <li><strong>Infrastructure as Code</strong>Terraform plans and manages the AWS environment.</li>
      <li><strong>Private Origin</strong>Amazon S3 blocks direct public access.</li>
      <li><strong>Secure Delivery</strong>CloudFront uses Origin Access Control and HTTPS.</li>
      <li><strong>Continuous Integration</strong>GitHub Actions formats and validates every change.</li>
      <li><strong>Drift Recovery</strong>Out-of-band changes are detected and reconciled.</li>
      <li><strong>Cost Control</strong>The environment can be destroyed from one Terraform workflow.</li>
    </ul>
    <footer>Built to demonstrate practical cloud and DevOps engineering skills.</footer>
  </main>
</body>
</html>
Protect state and publish the repository

Terraform state can contain infrastructure details that do not belong in Git. A .gitignore file keeps state, crash logs, and saved plans outside the repository.

  • Select the secure-aws-portfolio folder in the Explorer.
  • Select the New File button at the top of the Explorer.
  • Type .gitignore as the file name.
  • Press Enter to open the file.
  • Add the Terraform-safe ignore rules by pasting this code into .gitignore:
.terraform/
*.tfstate
*.tfstate.*
crash.log
crash.*.log
*.tfplan

What Do These Rules Exclude?

  • The first rule excludes Terraform's downloaded working directory.
  • The state patterns exclude primary state files and their related copies.
  • The remaining patterns exclude crash logs and saved plan files.
  • Save .gitignore.
  • Confirm that .gitignore appears beside the site folder in the Explorer.

The project now contains the exact local files needed for this step. Terraform state stays outside future commits.

Ignore File Not Visible?

Confirm that the filename begins with a period. Remove any accidental .txt ending.

Help me correct the ignore file name or patterns.

✔️ Awesome, I've got everything!

Your local project files are ready for version control.

ⓧ I'd like to double check the full code

.terraform/
*.tfstate
*.tfstate.*
crash.log
crash.*.log
*.tfplan

A public repository makes the project visible to reviewers. Visual Studio Code can initialize the local Git history before creating the matching repository on GitHub.

  • Select Source Control in the Visual Studio Code Activity Bar.
  • Select Initialize Repository.
  • Select the plus button beside the Changes group to stage every project file.
  • Enter Build local portfolio page in the commit message field.
  • Select Commit.

Your first commit now records the page and its ignore rules together. The repository is ready to publish.

  • Press Ctrl+Shift+P to open the Command Palette.
  • Type Publish to GitHub.
  • Select Publish to GitHub from the command list.
  • Complete the browser sign-in if GitHub asks you to authorize Visual Studio Code.
  • Return to Visual Studio Code after the authorization completes.
  • Enter secure-aws-portfolio as the repository name.
  • Choose the public repository option.
  • Include every project file if Visual Studio Code asks which files to publish.

Visual Studio Code creates the public repository before pushing your commit. Your local project now has a remote copy on GitHub.

Repository Did Not Publish?

Confirm that Git for Windows is available to Visual Studio Code. Complete any GitHub authorization prompt in the browser before returning to the editor.

Help me troubleshoot publishing from Visual Studio Code.

  • Form a prediction about which files the public repository should show before you inspect it.
  • Switch back to the browser used for GitHub authorization.
  • Open GitHub.
  • Select the secure-aws-portfolio repository from your profile.
  • Confirm that the repository is marked public.
  • Open the site folder.
  • Open index.html.

You should see the portfolio page source stored inside site/index.html. The repository root should also show .gitignore without any Terraform state files or a .terraform directory.

Strong finish. Your styled portfolio page now works locally with its source protected and published. Next, you will place that page in private cloud storage.

Put the Site in Private Cloud Storage

Your portfolio page now works locally. Its source is also safely published in your public GitHub repository.

This step uses Terraform to define a private Amazon S3 origin through Infrastructure as Code. Anonymous visitors cannot read the page directly from storage, so you will test that security boundary after deployment.

In this step, get ready to:
  • Define the Terraform foundation for your AWS infrastructure.
  • Create a private S3 bucket with public access blocked.
  • Upload the portfolio page and test direct access.
Define the Terraform foundation

A Terraform configuration describes the state you want AWS to maintain. The first section pins the tooling before connecting the configuration to your signed-in AWS identity.

  • In the Visual Studio Code Explorer sidebar, click the New File icon.
  • Enter main.tf as the file name.
  • Create the Terraform foundation by pasting this configuration into main.tf:
terraform {
  required_version = "= 1.16.5"

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

variable "aws_region" {
  description = "AWS Region for the S3 origin."
  type        = string
  default     = "us-east-1"
}

provider "aws" {
  region = var.aws_region
}

data "aws_caller_identity" "current" {}

locals {
  bucket_name = "devops-portfolio-${data.aws_caller_identity.current.account_id}"
  origin_id   = "private-s3-origin"
}

What Does This Configuration Do?

  • The terraform block pins Terraform to 1.16.5 and the AWS provider to 6.68.0.
  • The aws_region variable gives the provider a default AWS Region.
  • The aws_caller_identity.current data source reads the account identity from your temporary credentials.
  • The bucket_name local uses your AWS account ID to personalize the bucket name.
  • The origin_id local reserves a stable name for the private origin.
  • Save main.tf.
  • Confirm that main.tf appears beside .gitignore in the Explorer sidebar.

Cannot See main.tf?

  • Check that you created main.tf inside the secure-aws-portfolio folder.
  • Check that the file name ends with .tf.

Ask for help with creating main.tf in the correct folder.

The next resource turns the desired bucket into a repeatable declaration. Its tags also record the project purpose and management method.

Understand Force Destroy

The bucket uses force_destroy = true so Terraform can remove its objects during cleanup. Deleted objects cannot be recovered, but your portfolio source remains in GitHub.

  • Add the private bucket declaration below the locals block by pasting this code:
resource "aws_s3_bucket" "site" {
  bucket        = local.bucket_name
  force_destroy = true

  tags = {
    Project     = "secure-aws-portfolio"
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

What Does This Resource Do?

  • The aws_s3_bucket.site resource declares the storage bucket that Terraform manages.
  • The bucket argument uses the account-specific name calculated in locals.
  • The tags map identifies the project as a learning environment managed by Terraform.
  • Save main.tf.
  • Return to the PowerShell terminal from earlier.
  • Initialize the Terraform configuration by running this command:
terraform init

What Does This Command Do?

The command reads the provider declaration and prepares the local .terraform directory. Your existing .gitignore rules keep that generated directory out of GitHub.

You should see a message confirming that Terraform initialized the configuration. The AWS provider is now ready for validation and planning.

Initialization Failed?

  • Check that the PowerShell prompt is inside the secure-aws-portfolio folder.
  • Check your internet connection because Terraform downloads the declared provider.
  • Check the braces in main.tf against the code above.

Ask for help with troubleshooting Terraform initialization.

Block public access and prepare the plan

A private bucket needs an explicit public access boundary. The public access block applies all four protections before Terraform uploads the page.

  • Add the public access block below aws_s3_bucket.site by pasting this code:
resource "aws_s3_bucket_public_access_block" "site" {
  bucket = aws_s3_bucket.site.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

How Is Public Access Blocked?

  • The resource attaches its controls to aws_s3_bucket.site.
  • The four settings block public ACLs and public policies.
  • The same settings ignore existing public ACLs and restrict public buckets.
  • Save main.tf.
  • Check the configuration structure by running this command:
terraform validate

What Does Validation Check?

Terraform checks the configuration syntax and its internal consistency. This check does not create the bucket.

You should see confirmation that the configuration is valid. That result proves the provider and first two resources fit together correctly.

Validation Found a Problem?

  • Compare every opening brace in main.tf with its closing brace.
  • Check that all four public access settings use the literal value true.
  • Confirm that the resource name is aws_s3_bucket.site.

Ask for help with understanding the validation result.

The storage boundary is ready. The final declarations upload site/index.html and expose values that you can inspect after deployment.

  • Add the object and output declarations below the public access block by pasting this code:
resource "aws_s3_object" "index" {
  bucket       = aws_s3_bucket.site.id
  key          = "index.html"
  source       = "${path.module}/site/index.html"
  content_type = "text/html"
  etag         = filemd5("${path.module}/site/index.html")

  depends_on = [aws_s3_bucket_public_access_block.site]
}

output "bucket_name" {
  description = "Name of the private S3 origin bucket."
  value       = aws_s3_bucket.site.id
}

output "direct_s3_url" {
  description = "Direct URL that should remain inaccessible to anonymous viewers."
  value       = "https://${aws_s3_bucket.site.bucket_regional_domain_name}/index.html"
}

How Is the Page Uploaded?

  • The aws_s3_object.index resource uploads site/index.html as index.html.
  • The etag value lets Terraform detect changes to the local HTML file.
  • The depends_on value makes the upload wait for the public access block.
  • The outputs reveal the generated bucket name and its direct object URL after deployment.
  • Save main.tf.

✔️ Awesome, I've got everything!

Your complete storage configuration is ready. Double-check that main.tf is saved before planning the deployment.

ⓧ I'd like to double check the full code

terraform {
  required_version = "= 1.16.5"

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

variable "aws_region" {
  description = "AWS Region for the S3 origin."
  type        = string
  default     = "us-east-1"
}

provider "aws" {
  region = var.aws_region
}

data "aws_caller_identity" "current" {}

locals {
  bucket_name = "devops-portfolio-${data.aws_caller_identity.current.account_id}"
  origin_id   = "private-s3-origin"
}

resource "aws_s3_bucket" "site" {
  bucket        = local.bucket_name
  force_destroy = true

  tags = {
    Project     = "secure-aws-portfolio"
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

resource "aws_s3_bucket_public_access_block" "site" {
  bucket = aws_s3_bucket.site.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_object" "index" {
  bucket       = aws_s3_bucket.site.id
  key          = "index.html"
  source       = "${path.module}/site/index.html"
  content_type = "text/html"
  etag         = filemd5("${path.module}/site/index.html")

  depends_on = [aws_s3_bucket_public_access_block.site]
}

output "bucket_name" {
  description = "Name of the private S3 origin bucket."
  value       = aws_s3_bucket.site.id
}

output "direct_s3_url" {
  description = "Direct URL that should remain inaccessible to anonymous viewers."
  value       = "https://${aws_s3_bucket.site.bucket_regional_domain_name}/index.html"
}

How to Use This Reference

Compare this reference with your saved main.tf. Every declaration shown here belongs in the file before you create the infrastructure.

Before you run these checks, which S3 resources do you expect Terraform to propose?

  • Format the file and review the deployment plan by running these commands:
terraform fmt
terraform validate
terraform plan

What Do These Checks Do?

  • The first command rewrites Terraform configuration into its canonical style.
  • The second command checks syntax and internal consistency again.
  • The third command refreshes remote information and previews the proposed AWS changes.

What Should the Plan Show?

  • The plan proposes aws_s3_bucket.site as the private storage bucket.
  • The plan proposes aws_s3_bucket_public_access_block.site with all four controls enabled.
  • The plan proposes aws_s3_object.index as the uploaded portfolio page.
  • The plan contains no public-read ACL or public bucket policy.

Your reviewed plan now describes a private bucket and one HTML object. Terraform has shown you the complete change before touching AWS.

Plan Failed or Looks Different?

  • Return to the AWS login flow from Step 1 if your temporary credentials have expired.
  • Check that site/index.html still exists inside secure-aws-portfolio.
  • Stop before applying if the plan proposes a public ACL or public bucket policy.

Ask for help with reviewing the Terraform plan safely.

Apply the infrastructure and test privacy

A Terraform plan is a preview. Applying that reviewed plan creates the declared resources and records their state locally.

Before You Create Resources

This is the first action that creates AWS resources with usage-based charges. The project keeps the footprint small, and the cleanup section removes the managed resources with Terraform.

  • Create the reviewed AWS resources by running this command:
terraform apply

What Happens During Apply?

Terraform creates an execution plan and asks for approval. After you approve it, Terraform creates the bucket and blocks public access before uploading the HTML object.

  • Review the proposed actions again.
  • Approve the plan when Terraform prompts you.

You should see the completed apply followed by the bucket_name and direct_s3_url outputs. That is the hard part done: your page now lives in Terraform-managed private storage.

Apply Failed?

  • Read the final error to identify the resource AWS rejected.
  • Confirm that your signed-in AWS identity can create S3 resources.
  • Return to the AWS login flow from Step 1 if the temporary session has expired.

Ask for help with diagnosing the failed Terraform apply.

Before you open the direct object URL, what response do you think an anonymous browser receives from a bucket with all four public access controls enabled?

  • Find the value beside direct_s3_url in the completed Terraform output.
  • Record the output here: your direct S3 URL.
  • Paste your direct S3 URL into your browser address bar.
  • Press Enter to request the page directly from S3.

You should see an Access Denied response instead of the portfolio page. This planned failure proves that anonymous visitors cannot read the S3 origin directly.

Why Is Access Denied a Success?

The uploaded object exists, but the bucket grants no anonymous read path. Your storage layer is behaving exactly as the Terraform declaration requires.

Portfolio Page Loaded Directly?

  • Stop before continuing because the origin is publicly readable.
  • Check that all four settings in aws_s3_bucket_public_access_block.site remain set to true.
  • Run the planning command again to check for configuration differences.

Ask for help with finding why the S3 object is public.

Your portfolio is stored in AWS while its direct origin remains private. Next up, you will use Amazon CloudFront to deliver that private page over HTTPS without making the bucket public.

Deliver the Private Site Through CloudFront

Your private Amazon S3 origin now rejects anonymous requests. That Access Denied result proved the storage layer is protected.

Now you need a public route that preserves that protection. Amazon CloudFront will deliver the page over HTTPS. Origin Access Control will give only your distribution permission to read the private object.

In this step, get ready to:
  • Define secure CloudFront delivery for the private S3 object.
  • Grant CloudFront least-privilege read access through an S3 bucket policy.
  • Apply the configuration and verify both access paths.
Add secure CloudFront delivery

An Origin Access Control signs CloudFront requests before they reach S3. This creates a controlled path to the object without exposing the bucket publicly.

Why use Origin Access Control?

Origin Access Control supports a regular private S3 bucket as the CloudFront origin. Its signing behavior keeps communication between CloudFront and S3 authenticated.

A public S3 website endpoint would expose the storage layer directly. The private-origin pattern gives you one public delivery route while S3 continues rejecting anonymous requests.

  • In main.tf, find the aws_s3_object.index resource.
  • Place your cursor after the resource's closing brace.
  • Add the Origin Access Control resource by pasting this block:
resource "aws_cloudfront_origin_access_control" "site" {
  name                              = "devops-portfolio-oac-${data.aws_caller_identity.current.account_id}"
  description                       = "OAC for the secure DevOps portfolio site"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

What does this resource do?

  • The resource creates a dedicated access control for the S3 origin.
  • The account ID keeps the access control name specific to your AWS account.
  • The always behavior signs every origin request with SigV4.
  • Save main.tf.
  • Format the file and confirm the new resource is valid by running:
terraform fmt
terraform validate

What does this check prove?

  • The formatting command rewrites main.tf into Terraform's canonical style.
  • The validation command checks the complete configuration for syntax problems and inconsistent references.

That first delivery resource is valid. Your configuration now has the identity CloudFront uses when it requests the private object.

Is validation failing?

  • Check that the Origin Access Control block sits outside the aws_s3_object.index resource.
  • Compare the opening and closing braces in the new block.

Ask for help with the exact validation output: Help me fix my Terraform validation error after adding aws_cloudfront_origin_access_control.site.

The distribution is one larger Terraform resource. You will paste it in two connected pieces before saving the complete resource.

  • Place your cursor after the aws_cloudfront_origin_access_control.site resource.
  • Paste the first part of the CloudFront distribution:
resource "aws_cloudfront_distribution" "site" {
  origin {
    domain_name              = aws_s3_bucket.site.bucket_regional_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.site.id
    origin_id                = local.origin_id
  }

  enabled             = true
  is_ipv6_enabled     = true
  comment             = "Secure DevOps portfolio site"
  default_root_object = "index.html"
  price_class         = "PriceClass_100"

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = local.origin_id
    viewer_protocol_policy = "redirect-to-https"
    compress               = true

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

What does this part configure?

  • The origin uses the bucket's regional domain name instead of a public S3 website endpoint.
  • The access control ID connects the distribution to the signed origin requests you just configured.
  • The default cache behavior permits read requests and redirects viewers to HTTPS.
  • The price class limits delivery to the PriceClass_100 coverage group.
  • Keep your cursor directly after the closing brace for forwarded_values.
  • Complete the same CloudFront distribution by pasting this second part:
    min_ttl     = 0
    default_ttl = 300
    max_ttl     = 3600
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
      locations        = []
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }

  tags = {
    Project     = "secure-aws-portfolio"
    Environment = "learning"
    ManagedBy   = "Terraform"
  }

  depends_on = [aws_s3_object.index]
}

What does the rest configure?

  • The cache lifetimes control how long CloudFront can retain responses.
  • The geographic restriction block leaves the site available across the selected CloudFront price class.
  • The default CloudFront certificate provides the public HTTPS endpoint.
  • The dependency ensures the S3 object exists before Terraform creates the distribution.
  • Save main.tf.
  • Format the file and validate the completed distribution by running:
terraform fmt
terraform validate

What does this check confirm?

Terraform now checks the two pasted sections as one complete CloudFront distribution. A successful validation confirms that its origin and cache behavior references connect correctly.

Your distribution definition is valid. The next policy gives that distribution a narrow path into the private bucket.

Is the distribution block invalid?

  • Check that min_ttl remains inside default_cache_behavior.
  • Check that the final brace closes aws_cloudfront_distribution.site.
  • Confirm that both pasted sections appear before the existing output blocks.

Share the validation output for targeted help: Help me locate the brace or reference error in aws_cloudfront_distribution.site.

A least-privilege policy grants only the access CloudFront needs. Here that means permission to read objects when the request comes from this exact distribution.

  • Place your cursor after the aws_cloudfront_distribution.site resource.
  • Define the read-only policy document by pasting this block:
data "aws_iam_policy_document" "cloudfront_read" {
  statement {
    sid    = "AllowCloudFrontServicePrincipalReadOnly"
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }

    actions = ["s3:GetObject"]

    resources = ["${aws_s3_bucket.site.arn}/*"]

    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values   = [aws_cloudfront_distribution.site.arn]
    }
  }
}

How does this policy stay narrow?

  • The service principal limits access to CloudFront.
  • The action permits object reads without granting bucket administration.
  • The resource pattern covers objects inside your portfolio bucket.
  • The source ARN condition limits permission to your specific distribution.
  • Place your cursor after the policy document.
  • Attach the policy and expose the public site URL by pasting these blocks:
resource "aws_s3_bucket_policy" "cloudfront_read" {
  bucket = aws_s3_bucket.site.id
  policy = data.aws_iam_policy_document.cloudfront_read.json
}

output "site_url" {
  description = "Public HTTPS URL for the CloudFront distribution."
  value       = "https://${aws_cloudfront_distribution.site.domain_name}"
}

What do these blocks add?

  • The bucket policy resource applies the generated policy document to the existing private bucket.
  • The site_url output exposes the distribution domain as an HTTPS address after the apply completes.
  • Save main.tf.

✔️ Awesome, I've got everything!

Your complete CloudFront delivery configuration is ready for formatting and review.

ⓧ I'd like to double check the full code

terraform {
  required_version = "= 1.16.5"

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

variable "aws_region" {
  description = "AWS Region for the S3 origin."
  type        = string
  default     = "us-east-1"
}

provider "aws" {
  region = var.aws_region
}

data "aws_caller_identity" "current" {}

locals {
  bucket_name = "devops-portfolio-${data.aws_caller_identity.current.account_id}"
  origin_id   = "private-s3-origin"
}

resource "aws_s3_bucket" "site" {
  bucket        = local.bucket_name
  force_destroy = true

  tags = {
    Project     = "secure-aws-portfolio"
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

resource "aws_s3_bucket_public_access_block" "site" {
  bucket = aws_s3_bucket.site.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_object" "index" {
  bucket       = aws_s3_bucket.site.id
  key          = "index.html"
  source       = "${path.module}/site/index.html"
  content_type = "text/html"
  etag         = filemd5("${path.module}/site/index.html")

  depends_on = [aws_s3_bucket_public_access_block.site]
}

resource "aws_cloudfront_origin_access_control" "site" {
  name                              = "devops-portfolio-oac-${data.aws_caller_identity.current.account_id}"
  description                       = "OAC for the secure DevOps portfolio site"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_distribution" "site" {
  origin {
    domain_name              = aws_s3_bucket.site.bucket_regional_domain_name
    origin_access_control_id = aws_cloudfront_origin_access_control.site.id
    origin_id                = local.origin_id
  }

  enabled             = true
  is_ipv6_enabled     = true
  comment             = "Secure DevOps portfolio site"
  default_root_object = "index.html"
  price_class         = "PriceClass_100"

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = local.origin_id
    viewer_protocol_policy = "redirect-to-https"
    compress               = true

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    min_ttl     = 0
    default_ttl = 300
    max_ttl     = 3600
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
      locations        = []
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }

  tags = {
    Project     = "secure-aws-portfolio"
    Environment = "learning"
    ManagedBy   = "Terraform"
  }

  depends_on = [aws_s3_object.index]
}

data "aws_iam_policy_document" "cloudfront_read" {
  statement {
    sid    = "AllowCloudFrontServicePrincipalReadOnly"
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["cloudfront.amazonaws.com"]
    }

    actions = ["s3:GetObject"]

    resources = ["${aws_s3_bucket.site.arn}/*"]

    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values   = [aws_cloudfront_distribution.site.arn]
    }
  }
}

resource "aws_s3_bucket_policy" "cloudfront_read" {
  bucket = aws_s3_bucket.site.id
  policy = data.aws_iam_policy_document.cloudfront_read.json
}

output "bucket_name" {
  description = "Name of the private S3 origin bucket."
  value       = aws_s3_bucket.site.id
}

output "direct_s3_url" {
  description = "Direct URL that should remain inaccessible to anonymous viewers."
  value       = "https://${aws_s3_bucket.site.bucket_regional_domain_name}/index.html"
}

output "site_url" {
  description = "Public HTTPS URL for the CloudFront distribution."
  value       = "https://${aws_cloudfront_distribution.site.domain_name}"
}

Compare this reference with your saved main.tf. Every existing S3 block remains unchanged.

Review the CloudFront plan

A Terraform plan shows the proposed infrastructure changes before AWS receives them. This review protects the public-access block that made your S3 origin private.

Before you run the plan, do you expect Terraform to replace the existing S3 bucket or extend its secure delivery path?

  • Format the configuration and review its proposed changes by running:
terraform fmt
terraform validate
terraform plan

What does this review cover?

  • Formatting keeps the Terraform file consistent.
  • Validation checks the references between S3 and CloudFront.
  • Planning refreshes the current AWS state and previews the delivery resources.

You should see proposed additions for the Origin Access Control, CloudFront distribution, and S3 bucket policy. The existing public-access block should remain unchanged.

  • Confirm that the plan contains no public-read ACL.
  • Confirm that the plan contains no public bucket policy.
  • Confirm that the bucket policy grants only s3:GetObject through your CloudFront distribution.

Does the plan show an unexpected change?

  • Stop before applying if Terraform proposes replacing the S3 bucket.
  • Compare the existing bucket resource with the full-code reference.
  • Run the identity check from your earlier setup if Terraform cannot refresh AWS resources.

Get help interpreting the plan before continuing: Help me review this Terraform plan for unexpected S3 or CloudFront changes.

Apply and test both paths

CloudFront and S3 can produce usage charges. The cleanup section includes a complete Terraform teardown for the resources created here.

Terraform pauses for approval after producing its execution plan. Review that plan before allowing the cloud changes to proceed.

Before you apply, do you think the direct S3 URL needs to become public for the CloudFront route to work?

  • Start the reviewed deployment by running:
terraform apply

What does this command do?

Terraform creates a fresh execution plan before asking for approval. It performs only the operations you approve.

  • Review the execution plan for the expected CloudFront and policy additions.
  • Approve the plan when Terraform asks.

CloudFront distribution creation takes about 15 minutes. A quiet wait during this deployment is expected.

  • Wait for Terraform to finish applying the configuration.
  • Copy the value beside site_url here: your CloudFront HTTPS URL.

Before testing both routes, which one should display the page to an anonymous visitor?

  • Paste your CloudFront HTTPS URL into your browser's address bar.

You should see the styled Secure AWS Portfolio Site page through CloudFront. The browser should show that the page is using HTTPS.

  • Switch back to the browser tab containing the direct S3 URL from earlier.
  • Refresh the direct S3 browser tab.

You should still see Access Denied. This proves that CloudFront can read the object while anonymous direct S3 access remains blocked.

You've closed the security loop. Your portfolio is publicly reachable through CloudFront while its storage origin stays private.

Is the CloudFront URL not loading?

  • Confirm that the Terraform apply completed before testing the URL.
  • Check that the bucket policy references aws_cloudfront_distribution.site.arn.
  • Check that the distribution origin uses aws_s3_bucket.site.bucket_regional_domain_name.

Get help tracing the private delivery path: Help me diagnose why my CloudFront URL cannot read the private S3 object.

Your secure delivery path is live. Next, you will make Terraform checks repeatable and visible through GitHub Actions.

Validate Infrastructure Changes with GitHub Actions

Your private Amazon S3 origin now serves the portfolio securely through Amazon CloudFront.

Local Terraform validation depends on someone remembering every check. GitHub Actions makes those checks repeatable through continuous integration.

In this step, get ready to:
  • Create a workflow that formats and validates the Terraform configuration.
  • Confirm that a push to main triggers a successful validation run.
  • Prove that the validation job also passes on a pull request.
Create the Terraform validation workflow

A workflow is a YAML file that defines automated triggers and jobs. This workflow validates repository changes without applying infrastructure.

  • Switch back to the secure-aws-portfolio project in Visual Studio Code.
  • Expand .github/workflows in the file sidebar.
  • Use the new-file control beside .github/workflows to create terraform-ci.yml.
  • Add the workflow triggers and runner setup by pasting this code into terraform-ci.yml:
name: Terraform CI

on:
  push:
    branches:
      - main
    paths:
      - "**.tf"
      - ".github/workflows/terraform-ci.yml"
  pull_request:
    paths:
      - "**.tf"
      - ".github/workflows/terraform-ci.yml"

permissions:
  contents: read

jobs:
  validate:
    name: Terraform Validate
    runs-on: ubuntu-24.04

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

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v4.0.1
        with:
          terraform_version: 1.16.5

What does this workflow set up?

  • The push trigger watches relevant changes on main.
  • The pull_request trigger watches relevant proposed changes.
  • The path filters limit runs to Terraform files or the workflow file.
  • The contents: read permission gives the job read-only repository access.
  • The ubuntu-24.04 runner receives Terraform 1.16.5 through the setup action.
  • Save terraform-ci.yml by pressing Ctrl+S.
  • Confirm that terraform-ci.yml appears inside .github/workflows in the file sidebar.

Workflow file in the wrong folder?

  • Check that the complete path is .github/workflows/terraform-ci.yml.
  • Move the file into .github/workflows if it appears elsewhere.

Help me place the workflow file correctly.

The workflow can now prepare a consistent runner. The remaining steps perform the Terraform checks.

  • Find terraform_version: 1.16.5 in terraform-ci.yml.
  • Paste the remaining job steps directly below that line:
      - name: Format Terraform
        run: terraform fmt

      - name: Initialize without a backend
        run: terraform init -backend=false

      - name: Validate Terraform
        run: terraform validate

What do these checks prove?

  • The terraform fmt step applies Terraform's canonical formatting inside the runner.
  • The terraform init -backend=false step prepares Terraform without accessing a backend.
  • The terraform validate step checks syntax and internal consistency.
  • The workflow never runs terraform apply. Your AWS credentials stay outside the job.
  • Save the completed terraform-ci.yml file.
  • Confirm that the final workflow contains the formatting step, the initialization step, and the validation step.

✔️ Awesome, I've got everything!

Your saved workflow contains the required triggers, permissions, runner setup, formatting step, initialization step, and validation step.

ⓧ I'd like to double check the full code

name: Terraform CI

on:
  push:
    branches:
      - main
    paths:
      - "**.tf"
      - ".github/workflows/terraform-ci.yml"
  pull_request:
    paths:
      - "**.tf"
      - ".github/workflows/terraform-ci.yml"

permissions:
  contents: read

jobs:
  validate:
    name: Terraform Validate
    runs-on: ubuntu-24.04

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

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v4.0.1
        with:
          terraform_version: 1.16.5

      - name: Format Terraform
        run: terraform fmt

      - name: Initialize without a backend
        run: terraform init -backend=false

      - name: Validate Terraform
        run: terraform validate

Seeing a YAML warning?

  • Compare the indentation beneath steps: with the complete reference.
  • Confirm that every workflow step begins at the same indentation level.
  • Confirm that the filename ends with .yml.

Help me find the YAML structure problem.

Publish and inspect the push run

The workflow exists only on your computer until you push it. GitHub starts a run when the workflow file reaches main.

  • Switch to the source control view in Visual Studio Code.
  • Stage .github/workflows/terraform-ci.yml.
  • Enter Add Terraform CI workflow as the commit message.
  • Commit the staged workflow file.
  • Push the commit to main.

Before you inspect the run, predict whether the formatting, initialization, and validation steps will succeed.

  • Return to your public repository in the browser.
  • Select the Actions tab in the repository header.
  • Open the latest Terraform CI run.
  • Open the Terraform Validate job.

You should see Format Terraform complete successfully. You should also see Initialize without a backend complete successfully.

You should see Validate Terraform complete successfully. The job uses no AWS credentials.

That first green run proves your repository can repeat the same Terraform checks without relying on local habits.

Push run not successful?

  • Confirm that the workflow file reached the main branch.
  • Open the failed workflow step to identify the failing check.
  • Compare the repository workflow with the complete reference before committing a fix.

Help me diagnose the failed Terraform CI run.

Test validation with a pull request

A pull request shows reviewers that proposed Terraform changes receive the same validation. The browser editor asks where to commit your change.

Use a new branch for this test. Your deployed configuration on main stays unchanged.

  • Return to the repository file list in your browser.
  • Open main.tf on the main branch.
  • Select the pencil-shaped file editor control above the code.
  • Find the Environment tag inside aws_s3_bucket.site.
  • Change its value from learning to review.
  • Start the repository's commit flow.
  • Choose the option to commit the change to a new branch.
  • Name the new branch test-terraform-ci.

This temporary tag change gives the pull request a Terraform file to validate. It does not reach AWS unless someone merges the branch and runs an apply.

  • Commit the change to test-terraform-ci.
  • Open the new pull request from the branch comparison page.
  • Set main as the base branch.
  • Create the pull request.

Before you inspect the pull request check, predict whether validation needs access to your AWS account.

  • Wait for the Terraform Validate check to finish.
  • Open the completed check to inspect its workflow steps.

You should see the Terraform Validate job complete successfully. The pull request now carries visible evidence that the proposed Terraform change passed validation.

Why are AWS credentials unnecessary?

The workflow formats the checked-out files. It initializes Terraform without a backend.

Validation checks the configuration's syntax and internal consistency. No step applies infrastructure or changes your AWS account.

  • Close the pull request without merging it.

Strong finish. Your public repository now proves that pushes and pull requests receive repeatable Terraform validation before changes are merged.

Secret mission

Detect and Repair Configuration Drift

Deliberately replace the tags on your private S3 bucket outside Terraform. Use a plan to expose the drift. Reconcile the live bucket with main.tf until the final plan reports no changes.

Clean Up Your Resources

Clean Up Your Resources

Your live deployment still uses Amazon S3 storage through Amazon CloudFront delivery. Usage can create charges in your Amazon Web Services (AWS) account.

Decide whether to keep your resources running, pause delivery until later, or delete the deployment entirely.

Cost warning

This Terraform configuration does not enroll the distribution in CloudFront's optional $0 plan. Amazon S3 storage can also produce usage charges.

Standard GitHub Actions runners remain free for your public repository. Deleting the Terraform-managed infrastructure is the surest way to stop project-related AWS charges.

Resources you used:

  • The private Amazon S3 bucket that stores index.html.
  • The S3 public access block that prevents public bucket access.
  • The S3 bucket policy that grants read access to the CloudFront distribution.
  • The CloudFront distribution that serves the portfolio through site_url.
  • The Origin Access Control that signs requests from CloudFront to Amazon S3.
  • The secure-aws-portfolio folder containing your local Terraform state.

Keep everything running

No action is needed while you are actively using the portfolio site. The cloud resources remain available for demonstrations.

  • Keep the CloudFront distribution enabled to preserve the live HTTPS portfolio.
  • Use the site_url output when you want to demonstrate the deployment.
  • Monitor your AWS billing information for storage or delivery charges.
  • Retain the public GitHub repository as a record of your Terraform code and successful CI workflow.

Pause - I'll come back to this later

Disabling the CloudFront distribution pauses delivery while preserving your infrastructure. Stored resources can still produce AWS charges.

  • Switch back to main.tf in Visual Studio Code.
  • Find enabled = true inside aws_cloudfront_distribution.site.
  • Replace true with false.
  • Save main.tf.

CloudFront updates can take about 15 minutes. A quiet wait after approval is expected.

  • Return to the PowerShell terminal from earlier.
  • Apply the disabled state by running this command:
terraform apply

What happens during the update?

Terraform creates an execution plan before changing the distribution. The command waits for your approval before applying the disabled state.

  • Review the proposed CloudFront change.
  • Confirm the approval prompt.
  • Wait for AWS to finish updating the distribution.

You should find that the CloudFront URL no longer serves the portfolio after the update finishes.

Resuming delivery returns the configuration to its original declared state.

  • Switch back to main.tf.
  • Find enabled = false.
  • Replace false with true.
  • Save main.tf.
  • Resume delivery by running this command:
terraform apply

What happens when you resume?

Terraform updates the existing distribution to match enabled = true. Your bucket and local state remain intact during the update.

  • Review the proposed CloudFront change.
  • Confirm the approval prompt.
  • Wait for the distribution to finish deploying.

You should see the portfolio at site_url again after deployment finishes.

Delete - I don't want to use this again

Deleting cloud infrastructure is a big step. Terraform shows the complete destroy plan before it removes anything.

CloudFront deletion can take about 15 minutes. Keep the terminal open until Terraform finishes.

  • Return to the PowerShell terminal from earlier.
  • Start the Terraform-managed cleanup by running this command:
terraform destroy

What does destroy remove?

Terraform reads the local state before building a destroy plan for every managed AWS object. This includes the CloudFront distribution and its access control resources.

The bucket uses force_destroy = true. Terraform can therefore remove index.html before deleting the bucket.

  • Review every resource in the destroy plan.
  • Confirm the approval prompt.
  • Wait for Terraform to finish deleting the infrastructure.
  • Return to the Amazon S3 console from earlier.
  • Confirm that the bucket named by the bucket_name output no longer appears.
  • Return to the Amazon CloudFront console from earlier.
  • Confirm that the distribution associated with site_url no longer appears.
  • Remove the cached credentials for your default AWS profile by running this command:
aws logout

What does logout remove?

The command deletes the cached temporary credentials for the default profile. It does not delete your AWS account.

That is the cloud cleanup complete. Your AWS resources are gone and the temporary login is cleared.

Deleting the local folder permanently removes its Terraform state from your computer. Your public GitHub repository remains online as the source record for this project.

  • Return to the PowerShell terminal that is still inside secure-aws-portfolio.
  • Move to its parent folder and delete the local project by running these commands:
cd ..
Remove-Item -Recurse -Force secure-aws-portfolio

What does local deletion remove?

The first command moves outside the project folder. The second command permanently removes secure-aws-portfolio from your computer.

You should no longer see the folder or its local Terraform state on disk. Your public GitHub repository remains available.

Nice Work!

Nice Work!

You did it! Your secure AWS portfolio site is live over HTTPS through Amazon CloudFront while its Amazon S3 origin stays private. The complete deployment is reproducible with Terraform.

You've learned how to:

  • Build a live portfolio page that works locally. You also published its source in a public GitHub repository.
  • Use Infrastructure as Code to create a private S3 origin with public access controls. You proved the security boundary by seeing direct access denied while CloudFront served the page.
  • Automate Terraform formatting and validation with GitHub Actions on pushes and pull requests.
  • Secret Mission: Created configuration drift by changing the S3 bucket tags outside Terraform. You detected the differences in a plan before reconciling AWS with the declared state in main.tf.

Ready to quiz yourself?