Secure AWS Portfolio Pipeline
Deploy a private S3 portfolio through CloudFront with Terraform and GitHub OIDC.
Introduction
30 Second Summary
A hiring manager can see a finished website in seconds. The decisions that made it secure and repeatable are much harder to prove.
In this project, you will build a cloud-engineering portfolio that Terraform delivers through Amazon CloudFront from a private Amazon S3 origin on AWS. A GitHub Actions pipeline will publish each main-branch update over HTTPS with short-lived OpenID Connect credentials.
What You'll Build
You push a visible change to main, watch the deployment succeed, and refresh a live portfolio while its direct storage URL remains blocked.
By the end of this project, you'll have:
- A polished cloud-engineering portfolio that presents your infrastructure, networking, security, and automation experience.
- A private storage origin that returns AccessDenied through its direct URL while CloudFront serves the same page over HTTPS.
- An automated deployment pipeline that publishes changes from the main branch without storing AWS access keys in GitHub.
- Secret Mission: Prove that a feature branch cannot deploy until its change reaches the trusted main branch.
Are there any prerequisites?
This project assumes you have AWS credits plus AWS and GitHub accounts with permission to create the required resources.
You also need a Windows workspace with the Terraform CLI, AWS CLI, Git, and Visual Studio Code installed.
Before We Start
Before the hands-on work begins, lock in the cloud role you are targeting. Choose one security or automation decision from this project that you want to explain during an interview.
Verify Your Windows Workspace
Cloud deployments depend on a chain of local commands. Remote permissions complete that chain.
One failed link can stop every later step. This workspace check finds that gap before any resources exist.
One PowerShell terminal will confirm your toolchain. It will also identify the active AWS caller.
An empty GitHub repository will receive your project later. A local Git repository will hold the files you create next.
In this step, get ready to:
- Prove the local toolchain can run deployment commands.
- Create an empty public GitHub repository.
- Initialize a local Git repository on the main branch.
Verify your tools and AWS identity
The integrated terminal in Visual Studio Code keeps every check in one place. The identity response confirms which AWS caller controls later infrastructure changes.
- Press the Windows key to open the Windows search bar.
- Type Visual Studio Code into the search bar.
- Press Enter to open Visual Studio Code.
- Press Ctrl+` to open the integrated terminal.
- Select the Launch Profile dropdown in the terminal panel.
- Select PowerShell as the terminal profile.
The AWS identity check only reports the active caller. It does not create any resources.
Before you run the checks, do you expect all four commands to succeed?
- Run the four readiness checks from PowerShell:
terraform version
git --version
code --version
aws sts get-caller-identity
What should you see?
- Terraform identifies version 1.16.6 in its output.
- Git prints its installed version.
- Visual Studio Code prints its installed version.
- The AWS response includes UserId, Account, plus Arn values for the active caller.
Your command line now reaches every local tool plus the intended AWS identity. That control path is ready for infrastructure work.
Did one of the checks fail?
Close Visual Studio Code after confirming the affected tool is installed. Reopen it so the integrated terminal reloads your Windows path.
If the AWS check fails, review the active AWS CLI authentication before continuing. The account must match the one you intend to use for this project.
Help me troubleshoot my Windows toolchain checks.
Create the empty GitHub repository
The remote repository needs to begin empty because your first project files will come from your computer. Public visibility also makes the finished code available as project evidence.
- Open the new repository form in your browser.
- Select your personal account from the Owner dropdown.
- Enter secure-cloud-portfolio in the Repository name field.
- Select Public for the repository visibility.
- Leave the Add README option unselected.
- Leave the .gitignore option unchanged.
- Leave the license option unchanged.
- Click Create repository.
Good work. Your public repository now exists as an empty destination for the local files you create next.
Initialize the local main branch
The remote repository is waiting for code. Your local folder now needs the matching name plus a Git history that starts on main.
- Switch back to the PowerShell terminal in Visual Studio Code.
- Move to your Windows Desktop by running this command:
Set-Location -Path "$env:USERPROFILE\Desktop" -PassThru
What does this command do?
PowerShell changes its current location to your Desktop. The printed path confirms where the project folder will be created.
- Create the secure-cloud-portfolio folder by running these commands:
New-Item -Path "secure-cloud-portfolio" -ItemType Directory
Set-Location -Path "secure-cloud-portfolio" -PassThru
What do these commands do?
- The first command creates the secure-cloud-portfolio folder on your Desktop.
- The second command moves PowerShell into that folder. Its output ends with Desktop\secure-cloud-portfolio.
- Initialize the folder as a Git repository on main by running:
git init -b main
What does this command do?
Git creates its repository metadata inside the current folder. The -b main option sets the initial branch name to main.
Before you run the final check, which branch name do you expect Git to print?
- Open the current folder in Visual Studio Code plus print the active branch by running:
code .
git branch --show-current
What should you see?
- Visual Studio Code opens the secure-cloud-portfolio folder.
- PowerShell remains available in the integrated terminal.
- Git prints main as the current branch.
Folder or branch missing?
Check that the terminal path ends with secure-cloud-portfolio before initializing Git. Running the initialization command from the Desktop would create repository metadata in the wrong location.
If the editor does not switch folders, return to the terminal where you created the folder. Confirm that the code command worked during the earlier tool check.
Help me verify my local Git repository.
✔️ My workspace matches
Your GitHub repository is public plus empty. Your local secure-cloud-portfolio folder is open in Visual Studio Code on the main branch.
Your PowerShell terminal has also confirmed the local tools plus the active AWS caller.
ⓧ I'd like to double check
The GitHub repository should be named secure-cloud-portfolio. It should be public with no initial files.
The local folder should also be named secure-cloud-portfolio. It should be open in Visual Studio Code.
The branch check should print main in the PowerShell terminal.
Your local tools, AWS identity, remote repository, plus local branch are ready. Next, you'll turn this empty workspace into a portfolio you can open in a browser.
Build the Portfolio Locally
Your Windows workspace is verified. Your local Git repository is ready in Visual Studio Code.
The Terraform infrastructure comes later. This step first proves the portfolio content in a browser before publishing the same files to GitHub.
In this step, get ready to:
- Build a responsive cloud-engineering portfolio as one local file.
- Document the secure architecture and protect local Terraform files.
- Commit the project and publish it to the main branch.
Create the local portfolio page
A single-file portfolio keeps the HTML structure and CSS styling together. This gives you a complete page that a browser can render before any cloud resources exist.
- Select the open secure-cloud-portfolio folder in the file sidebar.
- Click the new-file icon beside the folder name.
- Name the file index.html.
- Select the double-check tab below.
- Copy the complete portfolio into index.html.
✔️ Awesome, I've got everything!
Great. Double check that index.html is saved inside the open secure-cloud-portfolio folder.
ⓧ I'd like to double check the full code
Compare your complete index.html file with this version:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Cloud engineering portfolio demonstrating AWS, Terraform, networking, and CI/CD skills.">
<title>Cloud Engineering Portfolio</title>
<style>
:root {
color-scheme: dark;
--bg: #07111f;
--panel: #101f33;
--panel-soft: #142944;
--text: #edf6ff;
--muted: #a8bed6;
--accent: #64d8ff;
--accent-2: #8d7dff;
--success: #5ce1a4;
--line: rgba(255, 255, 255, 0.12);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
font-family: Inter, Segoe UI, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(circle at top right, rgba(100, 216, 255, 0.14), transparent 30%),
radial-gradient(circle at left 45%, rgba(141, 125, 255, 0.12), transparent 28%),
var(--bg);
line-height: 1.6;
}
a { color: inherit; }
.shell { width: min(1080px, calc(100% - 32px)); margin: 0 auto; }
header { padding: 26px 0; border-bottom: 1px solid var(--line); }
nav { display: flex; justify-content: space-between; align-items: center; gap: 20px; }
.brand { font-weight: 800; letter-spacing: 0.04em; }
nav ul { display: flex; gap: 18px; margin: 0; padding: 0; list-style: none; }
nav a { color: var(--muted); text-decoration: none; }
nav a:hover { color: var(--accent); }
.hero {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 36px;
align-items: center;
padding: 84px 0 64px;
}
.eyebrow, .status {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 7px 12px;
color: var(--accent);
background: rgba(100, 216, 255, 0.06);
font-size: 0.9rem;
}
h1 {
margin: 18px 0;
font-size: clamp(2.7rem, 7vw, 5.7rem);
line-height: 0.96;
letter-spacing: -0.06em;
}
h1 span { color: var(--accent); }
.lead { max-width: 650px; color: var(--muted); font-size: 1.1rem; }
.hero-card, .card, .architecture {
border: 1px solid var(--line);
border-radius: 22px;
background: linear-gradient(145deg, rgba(20, 41, 68, 0.94), rgba(11, 26, 45, 0.94));
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.24);
}
.hero-card { padding: 26px; }
.hero-card h2, section h2 { margin-top: 0; }
.metric { display: flex; justify-content: space-between; gap: 18px; padding: 13px 0; border-bottom: 1px solid var(--line); }
.metric:last-child { border-bottom: 0; }
.metric span, .section-copy, .card p { color: var(--muted); }
main section { padding: 34px 0; }
.section-copy { max-width: 720px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 22px; }
.card { padding: 22px; }
.card p { margin-bottom: 0; }
.architecture { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; padding: 22px; margin-top: 22px; }
.node {
padding: 18px 12px;
border: 1px solid rgba(100, 216, 255, 0.22);
border-radius: 14px;
text-align: center;
background: rgba(100, 216, 255, 0.06);
}
.node strong { display: block; color: var(--accent); }
.node span { color: var(--muted); font-size: 0.9rem; }
footer { margin-top: 40px; padding: 28px 0 44px; border-top: 1px solid var(--line); color: var(--muted); }
@media (max-width: 760px) {
nav, .hero { display: block; }
nav ul { margin-top: 14px; flex-wrap: wrap; }
.hero-card { margin-top: 28px; }
.grid, .architecture { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<nav class="shell" aria-label="Primary navigation">
<div class="brand">CLOUD.PORTFOLIO</div>
<ul>
<li><a href="#about">About</a></li>
<li><a href="#architecture">Architecture</a></li>
<li><a href="#skills">Skills</a></li>
</ul>
</nav>
</header>
<main class="shell">
<section class="hero" id="about">
<div>
<div class="eyebrow">AWS Cloud Engineer</div>
<h1>Cloud Engineering <span>Portfolio</span></h1>
<p class="lead">
I build reproducible AWS infrastructure with Terraform, secure network paths,
and automated delivery workflows. This portfolio is itself a cloud project:
private S3 storage, CloudFront Origin Access Control, and GitHub OIDC deployment.
</p>
<p class="status">Local portfolio ready</p>
</div>
<aside class="hero-card" aria-label="Project facts">
<h2>Project signals</h2>
<div class="metric"><span>Infrastructure</span><strong>Terraform</strong></div>
<div class="metric"><span>Delivery</span><strong>CloudFront</strong></div>
<div class="metric"><span>Origin</span><strong>Private S3</strong></div>
<div class="metric"><span>Authentication</span><strong>GitHub OIDC</strong></div>
</aside>
</section>
<section id="architecture">
<h2>Secure delivery architecture</h2>
<p class="section-copy">
Source changes travel through a short-lived identity path. Visitors reach the site
through CloudFront, while direct public access to the S3 origin remains blocked.
</p>
<div class="architecture" aria-label="Deployment and request flow">
<div class="node"><strong>Git push</strong><span>main branch</span></div>
<div class="node"><strong>GitHub Actions</strong><span>OIDC token</span></div>
<div class="node"><strong>Private S3</strong><span>website object</span></div>
<div class="node"><strong>CloudFront</strong><span>HTTPS delivery</span></div>
</div>
</section>
<section id="skills">
<h2>Cloud skills demonstrated</h2>
<div class="grid">
<article class="card">
<h3>Infrastructure as code</h3>
<p>Version-pinned Terraform creates repeatable S3, CloudFront, and IAM resources.</p>
</article>
<article class="card">
<h3>Cloud security</h3>
<p>Public S3 access is blocked, and CloudFront is the only trusted read path.</p>
</article>
<article class="card">
<h3>CI/CD and identity</h3>
<p>GitHub Actions deploys with temporary OIDC credentials scoped to the main branch.</p>
</article>
</div>
</section>
</main>
<footer>
<div class="shell">Built as a demonstrable AWS cloud-engineering project.</div>
</footer>
</body>
</html>
How is the portfolio structured?
- The inline styles create the dark responsive layout without a separate stylesheet.
- The hero presents your AWS cloud-engineering focus and the local readiness status.
- The architecture flow connects a Git push to GitHub Actions, private S3 storage, and CloudFront delivery.
- The skill cards turn the technical architecture into interview-ready evidence.
- Save index.html in Visual Studio Code.
Before you open the page, which parts of the final cloud portfolio do you expect to work locally?
- Open the portfolio in your default browser by running this command in the PowerShell terminal from earlier:
Invoke-Item .\index.html
What does this command do?
PowerShell performs the default action for index.html. Windows opens an HTML file in its associated web browser.
You should see the heading Cloud Engineering Portfolio. The page should also show the architecture flow, three skill cards, and the status Local portfolio ready.
That is your first visible win. The portfolio content and its inline styling now work without any cloud infrastructure.
Page missing styles or content?
- Confirm that the file is named index.html instead of a text file with a hidden extension.
- Save the latest changes before reopening the page.
- Compare the opening and closing HTML tags with the complete file above.
Help me find why my local portfolio page is missing its styles or sections.
Document and protect the project
The repository needs a public explanation of the architecture. It also needs ignore rules that keep local Terraform state and configuration out of version control.
- Create a new file named .gitignore inside the open secure-cloud-portfolio folder.
- Paste these ignore rules into .gitignore:
.terraform/
*.tfstate
*.tfstate.*
terraform/terraform.tfvars
crash.log
What do these rules protect?
- The .terraform/ rule excludes downloaded provider files.
- The state patterns exclude local Terraform state and its related copies.
- The terraform/terraform.tfvars rule keeps your local variable values out of the repository.
- The crash.log rule excludes Terraform crash output.
- Save .gitignore.
You should now see .gitignore beside index.html in the file sidebar.
Cannot see the ignore file?
Confirm that the filename starts with a period. Remove any hidden .txt extension added by another editor.
Help me check why my .gitignore file is missing or has the wrong filename.
- Create a new file named README.md inside the open secure-cloud-portfolio folder.
- Select the README double-check tab below.
- Copy the complete project explanation into README.md.
✔️ Awesome, I've got everything!
Great. Your project now has its page, architecture documentation, and ignore rules.
ⓧ I'd like to double check README.md
Compare your complete README.md file with this version:
# Secure AWS Portfolio Deployment Pipeline
A cloud-engineering portfolio served through Amazon CloudFront from a private Amazon S3 origin. Terraform defines the infrastructure, and GitHub Actions deploys `index.html` through OpenID Connect without stored AWS access keys.
## Architecture
```text
Developer push to main
|
v
GitHub Actions OIDC
|
v
IAM deployment role ---> private S3 bucket
|
v
CloudFront OAC and HTTPS
|
v
Visitor
```
## Security decisions
- S3 Block Public Access remains enabled.
- The bucket policy grants `s3:GetObject` only to the CloudFront service principal for this distribution.
- The GitHub role trusts the repository's immutable owner and repository IDs on `refs/heads/main`.
- The workflow receives permission only to upload `index.html`.
- No AWS access key is stored in GitHub.
## Deploy infrastructure
1. Copy `terraform/terraform.tfvars.example` to `terraform/terraform.tfvars` and replace the placeholders.
2. Run `terraform -chdir=terraform init`.
3. Run `terraform -chdir=terraform validate`.
4. Run `terraform -chdir=terraform apply`.
5. Add the Terraform outputs to the GitHub repository variables used by the workflow.
## Demonstrate the project
1. Open the direct S3 object URL and show that access is denied.
2. Open the CloudFront URL and show the live portfolio over HTTPS.
3. Change visible text in `index.html`, commit it, and push to `main`.
4. Show the successful GitHub Actions run and the updated live page.
## Clean up
Run `terraform -chdir=terraform destroy` and confirm the destroy plan.
What does the README explain?
- The architecture diagram shows how code travels from the main branch to the private origin.
- The security decisions connect private storage, branch-scoped identity, and limited upload permissions.
- The deployment section records the Terraform workflow that later steps complete.
- The demonstration section turns the finished system into a repeatable interview demo.
ⓧ I'd like to double check .gitignore
Compare your complete .gitignore file with this version:
.terraform/
*.tfstate
*.tfstate.*
terraform/terraform.tfvars
crash.log
What should match?
Your file should contain these five patterns in this order. Each pattern protects a local Terraform artifact that should stay outside the public repository.
- Save README.md.
You should now see index.html, README.md, and .gitignore in the file sidebar.
README formatting looks wrong?
- Confirm that the filename ends with .md.
- Check that the architecture diagram keeps its opening and closing code fences.
- Compare the heading markers with the complete file above.
Help me fix the formatting in my project README.
Commit and publish the files
A commit captures the local portfolio as one reviewable version. The remote repository gives that version a public home before the deployment pipeline is added.
- Stage the three files and create the first commit by running these commands:
git add .
git commit -m "First commit"
What do these commands do?
- The first command stages the files in the open repository.
- The second command records the staged version on the local main branch.
You should see a commit summary that lists three newly tracked files. Your local repository now has a saved starting point.
Commit blocked by your Git identity?
Git may ask you to configure an author name or email before it can create the commit. Follow the instructions shown in the terminal before rerunning the commit.
Help me resolve the Git identity problem blocking my first commit.
- Copy the HTTPS repository URL from your empty public secure-cloud-portfolio repository.
- Replace REMOTE-URL in this command with the copied URL before running it:
git remote add origin REMOTE-URL
What does this command do?
The command names your GitHub repository origin inside the local Git configuration. Future pushes can use that short remote name.
- Confirm the remote was saved by running:
git remote -v
What should I see?
You should see the same GitHub repository address listed for fetching and pushing. This proves that origin points to your public repository.
Remote already exists or points elsewhere?
Check the address shown by the remote verification command. Remove or update the existing remote through Git before continuing if it belongs to another repository.
Help me fix the origin remote for my secure-cloud-portfolio repository.
GitHub may open a browser authentication flow during the first push. Complete that sign-in using the GitHub account that owns the repository.
Before you push, do you expect the repository's main branch to remain empty or show the three project files?
- Publish the local main branch by running:
git push -u origin main
What does this command do?
The push uploads your first commit to the remote main branch. The upstream link lets later pushes reuse the same destination.
- Return to the public secure-cloud-portfolio repository in your browser.
- Refresh the repository page.
You should see index.html, README.md, and .gitignore on the main branch. The README should render beneath the file list.
- Switch back to the local portfolio browser tab from earlier.
- Refresh the local page.
You should still see Cloud Engineering Portfolio, the four-part architecture flow, three skill cards, and Local portfolio ready without missing styles.
Push rejected or files missing?
- Confirm that your browser authentication used the account that owns the repository.
- Check that origin points to the empty public repository you created earlier.
- Confirm that the commit summary included all three project files.
Help me debug why my first push did not publish the portfolio files.
Your portfolio now works locally and lives on the public main branch. Next, you will place a private S3 barrier in front of that content and prove why secure delivery needs another layer.
Meet the Private S3 Barrier
Your portfolio now renders locally. Its source is safely committed on GitHub.
A private Amazon S3 origin protects your files from direct public reads. In this step, Terraform will create that boundary so you can test it from a browser.
In this step, get ready to:
- Define a version-pinned Terraform configuration for a private S3 bucket.
- Configure the local values that Terraform reads during deployment.
- Apply the configuration and test the direct S3 object URL.
Define the private S3 infrastructure
Terraform configuration files describe the infrastructure you want in a repeatable format. The first block pins the exact Terraform CLI and AWS Provider versions used by this project.
- In the Explorer sidebar in Visual Studio Code, select the secure-cloud-portfolio folder.
- Create a folder named terraform with the folder icon in the Explorer toolbar.
You should see the new terraform folder under secure-cloud-portfolio.
- Select the terraform folder in the Explorer sidebar.
- Create main.tf with the new file icon in the Explorer toolbar.
- Add the Terraform and AWS Provider pins by pasting this code into main.tf:
terraform {
required_version = "= 1.16.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 6.69.1"
}
}
}
What does this code do?
- The required_version setting pins Terraform CLI 1.16.6 for predictable behavior.
- The required_providers block tells Terraform to install hashicorp/aws version 6.69.1.
- Save main.tf by pressing Ctrl+S.
- Return to the integrated PowerShell terminal by pressing Ctrl+`.
- Initialize the terraform directory by running:
terraform -chdir=terraform init
What does this command do?
The -chdir=terraform option tells Terraform to work inside the terraform folder. The init command installs the pinned provider into the local project.
Terraform should report a successful initialization. You should also see that hashicorp/aws version 6.69.1 was selected.
Initialization not completing?
Check that the terminal is at the top level of secure-cloud-portfolio. Confirm that main.tf is inside the terraform folder.
A provider download failure can come from a blocked network connection. Retry after confirming that your computer can reach the Terraform Registry.
Help me diagnose why Terraform initialization failed in my Windows project.
Input variables keep account-specific values outside the reusable resource definitions. This project also declares the GitHub identity values needed by a later deployment step.
- Select the terraform folder in the Explorer sidebar.
- Create variables.tf with the new file icon in the Explorer toolbar.
You should see variables.tf listed beside main.tf.
- Add the region and project-name variables by pasting this code into variables.tf:
variable "aws_region" {
description = "AWS Region for the S3 bucket and provider operations"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Stable prefix for project resources"
type = string
default = "secure-cloud-portfolio"
}
What do these variables control?
- The aws_region variable selects where the S3 bucket is created.
- The project_name variable supplies the stable secure-cloud-portfolio prefix used for project resources.
- Save variables.tf by pressing Ctrl+S.
- Switch back to main.tf from the editor tab.
- Add the provider configuration and account-aware bucket name below the existing Terraform block:
provider "aws" {
region = var.aws_region
}
data "aws_caller_identity" "current" {}
locals {
bucket_name = lower("${var.project_name}-${data.aws_caller_identity.current.account_id}-${var.aws_region}")
}
How is the bucket name built?
- The AWS provider uses var.aws_region for provider operations.
- The aws_caller_identity data source reads the account ID from your active AWS identity.
- The bucket_name local combines the project name with the account ID and region.
- The lower() function converts the generated bucket name to lowercase.
- Save main.tf by pressing Ctrl+S.
- Check the current configuration by running:
terraform -chdir=terraform validate
What does validation check?
The validate command checks the configuration structure and provider references. It catches local configuration problems before Terraform contacts AWS to create resources.
Terraform should confirm that the configuration is valid. This proves the provider can read both variables used by main.tf.
- Switch back to variables.tf from the editor tab.
- Add the GitHub identity variables below project_name with this code:
variable "github_owner" {
description = "GitHub username or organization that owns the repository"
type = string
}
variable "github_owner_id" {
description = "Immutable numeric ID of the GitHub repository owner"
type = string
}
variable "github_repository" {
description = "GitHub repository name"
type = string
default = "secure-cloud-portfolio"
}
variable "github_repository_id" {
description = "Immutable numeric GitHub repository ID"
type = string
}
variable "github_oidc_provider_arn" {
description = "Existing GitHub OIDC provider ARN, or an empty string to create one"
type = string
default = ""
}
Why declare these values now?
These variables reserve the repository identity inputs used by the secure deployment path. The numeric owner and repository IDs provide immutable identity values alongside the readable repository name.
The github_oidc_provider_arn variable supports an AWS account that already has the GitHub identity provider. An empty value tells the later configuration to create one.
- Save variables.tf by pressing Ctrl+S.
- Validate the expanded variable declarations by running:
terraform -chdir=terraform validate
What does this second check prove?
This validation confirms that every variable block is structurally valid. It also confirms that the existing provider configuration still resolves its inputs.
You should see another successful validation. Your configuration now accepts the values required for this step and the secure deployment work that follows.
The bucket resource creates the private origin. A separate Public Access Block enforces the four account-facing controls that prevent public ACLs and policies.
- Switch back to main.tf from the editor tab.
- Add the bucket and its Public Access Block below the locals block:
resource "aws_s3_bucket" "site" {
bucket = local.bucket_name
force_destroy = true
tags = {
Name = var.project_name
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
}
How does this keep S3 private?
- The aws_s3_bucket.site resource creates the bucket with the account-aware name from local.bucket_name.
- The force_destroy setting allows Terraform to remove contained objects during project cleanup.
- The four Public Access Block settings reject public ACLs and public bucket policies.
- The tags identify the project name and Terraform ownership in AWS.
- Save main.tf by pressing Ctrl+S.
- Validate the bucket resources by running:
terraform -chdir=terraform validate
What is Terraform checking now?
Terraform checks that the S3 bucket references the declared local value. It also checks that the Public Access Block points to the bucket resource.
You should see a successful validation. The private bucket definition is now ready for its output values.
Outputs expose useful values after an apply. This project needs the selected region, generated bucket name, and direct object URL.
- Select the terraform folder in the Explorer sidebar.
- Create outputs.tf with the new file icon in the Explorer toolbar.
You should see outputs.tf beside the other Terraform files.
- Define the three initial outputs by pasting this code into outputs.tf:
output "aws_region" {
value = var.aws_region
description = "AWS Region used by the deployment workflow"
}
output "bucket_name" {
value = aws_s3_bucket.site.id
description = "Private S3 bucket populated by GitHub Actions"
}
output "direct_s3_object_url" {
value = "https://${aws_s3_bucket.site.bucket_regional_domain_name}/index.html"
description = "Direct origin URL that should remain blocked"
}
What will these outputs show?
- The aws_region output repeats the region used by the provider.
- The bucket_name output returns the generated S3 bucket name.
- The direct_s3_object_url output builds a browser URL for index.html at the private origin.
- Save outputs.tf by pressing Ctrl+S.
- Validate the complete initial configuration by running:
terraform -chdir=terraform validate
What does the final configuration check cover?
This validation includes the provider, variables, data source, local value, S3 resources, and outputs. A successful result proves that the initial configuration is internally consistent.
You should see a successful validation. The reusable infrastructure files are ready for account-specific values.
✔️ Awesome, I've got main.tf
Great. Double check that main.tf is saved inside the terraform folder.
What should be in this file?
The file should contain the version pins, AWS provider, caller identity, bucket-name local, private bucket, and Public Access Block.
ⓧ I'd like to double check main.tf
Compare your terraform/main.tf file with this complete reference.
terraform {
required_version = "= 1.16.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 6.69.1"
}
}
}
provider "aws" {
region = var.aws_region
}
data "aws_caller_identity" "current" {}
locals {
bucket_name = lower("${var.project_name}-${data.aws_caller_identity.current.account_id}-${var.aws_region}")
}
resource "aws_s3_bucket" "site" {
bucket = local.bucket_name
force_destroy = true
tags = {
Name = var.project_name
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
}
How to use this reference
Check the order of each block. Pay close attention to the braces around locals and both S3 resources.
✔️ Awesome, I've got variables.tf
Great. Double check that variables.tf is saved inside the terraform folder.
What should be in this file?
The file should declare the region, project name, GitHub repository identity values, and optional existing OIDC provider ARN.
ⓧ I'd like to double check variables.tf
Compare your terraform/variables.tf file with this complete reference.
variable "aws_region" {
description = "AWS Region for the S3 bucket and provider operations"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Stable prefix for project resources"
type = string
default = "secure-cloud-portfolio"
}
variable "github_owner" {
description = "GitHub username or organization that owns the repository"
type = string
}
variable "github_owner_id" {
description = "Immutable numeric ID of the GitHub repository owner"
type = string
}
variable "github_repository" {
description = "GitHub repository name"
type = string
default = "secure-cloud-portfolio"
}
variable "github_repository_id" {
description = "Immutable numeric GitHub repository ID"
type = string
}
variable "github_oidc_provider_arn" {
description = "Existing GitHub OIDC provider ARN, or an empty string to create one"
type = string
default = ""
}
How to use this reference
Compare every variable name with your file. Check that the default values appear only on the variables shown here.
✔️ Awesome, I've got outputs.tf
Great. Double check that outputs.tf is saved inside the terraform folder.
What should be in this file?
The file should expose the selected region, generated bucket name, and direct S3 object URL.
ⓧ I'd like to double check outputs.tf
Compare your terraform/outputs.tf file with this complete reference.
output "aws_region" {
value = var.aws_region
description = "AWS Region used by the deployment workflow"
}
output "bucket_name" {
value = aws_s3_bucket.site.id
description = "Private S3 bucket populated by GitHub Actions"
}
output "direct_s3_object_url" {
value = "https://${aws_s3_bucket.site.bucket_regional_domain_name}/index.html"
description = "Direct origin URL that should remain blocked"
}
How to use this reference
Check each output name and value expression. The direct URL must use the bucket regional domain name.
Configure local Terraform values
The example values document every input needed by the full project. The local terraform.tfvars copy supplies your own settings without committing them to Git.
- Select the terraform folder in the Explorer sidebar.
- Create terraform.tfvars.example with the new file icon in the Explorer toolbar.
You should see terraform.tfvars.example listed with your other Terraform files.
- Add the reusable example values by pasting this code into terraform.tfvars.example:
aws_region = "us-east-1"
github_owner = "your-github-username"
github_owner_id = "123456"
github_repository = "secure-cloud-portfolio"
github_repository_id = "789012"
# Leave empty to create the GitHub OIDC provider.
# If the provider already exists in this AWS account, paste its ARN here.
github_oidc_provider_arn = ""
How does this example help?
The file shows the expected format for every input. Its comments explain when the optional OIDC provider ARN stays empty.
This example remains safe to commit because it contains placeholders. Your personal values belong in the ignored local copy.
- Save terraform.tfvars.example by pressing Ctrl+S.
✔️ Awesome, I've got the example
Great. Double check that terraform.tfvars.example is saved inside the terraform folder.
What should be in this file?
The file should contain five repository and region values plus the optional existing OIDC provider ARN.
ⓧ I'd like to double check the example
Compare your terraform/terraform.tfvars.example file with this complete reference.
aws_region = "us-east-1"
github_owner = "your-github-username"
github_owner_id = "123456"
github_repository = "secure-cloud-portfolio"
github_repository_id = "789012"
# Leave empty to create the GitHub OIDC provider.
# If the provider already exists in this AWS account, paste its ARN here.
github_oidc_provider_arn = ""
How to use this reference
Check the input names and placeholder values. Keep both comments directly above github_oidc_provider_arn.
Your existing .gitignore already excludes terraform/terraform.tfvars. That rule keeps your local settings outside the repository.
- Duplicate terraform.tfvars.example from the Explorer sidebar.
- Name the duplicate terraform.tfvars.
- Set aws_region to the AWS Region you want to use.
- Replace your-github-username with the owner of your public GitHub repository.
- Keep github_owner_id as 123456 unless you already know the numeric owner ID.
- Keep github_repository set to secure-cloud-portfolio.
- Keep github_repository_id as 789012 unless you already know the numeric repository ID.
- Leave github_oidc_provider_arn empty unless your AWS account already has the GitHub OIDC provider.
Placeholder IDs are fine here
The initial S3 configuration does not use the GitHub numeric IDs. You will replace any remaining placeholders when the identity resources are added.
- Save terraform.tfvars by pressing Ctrl+S.
- Check the source control sidebar for the files waiting to be committed.
You should not see terraform/terraform.tfvars in the source control list. This confirms that the existing ignore rule is protecting the local values file.
Seeing terraform.tfvars in source control?
Check that the file is named exactly terraform.tfvars inside the terraform folder. Confirm that .gitignore still contains terraform/terraform.tfvars.
Help me find why my local Terraform values file is appearing in Git source control.
Apply and test the access boundary
A Terraform apply compares your configuration with the current infrastructure. It presents the proposed changes before it creates anything in AWS.
- Run one final configuration check with your local values in place:
terraform -chdir=terraform validate
Why validate again?
This check reads the completed configuration from the terraform directory. It confirms that your saved files remain structurally valid before the apply contacts AWS.
You should see a successful validation. Your configuration is ready to propose real infrastructure changes.
The next command creates an S3 resource that can incur usage charges. Review the proposed changes before approving them so the deployment stays within your available AWS credits.
- Generate the plan and start the apply by running:
terraform -chdir=terraform apply
What happens during apply?
Terraform reads your active AWS identity through data.aws_caller_identity.current. It uses the returned account ID to finish local.bucket_name.
Terraform then proposes the S3 bucket and its Public Access Block. No resource is created until you approve the displayed plan.
- Review the plan for the S3 bucket and Public Access Block.
- Enter yes when Terraform asks for approval.
Terraform should complete the apply and print the three outputs. The bucket_name value should follow the lowercase secure-cloud-portfolio-ACCOUNT_ID-AWS_REGION pattern.
Strong work. Your AWS account now contains a Terraform-managed bucket with all four Public Access Block settings enabled.
Apply not completing?
An apply failure mentioning permissions means the active AWS identity may not be allowed to create S3 resources. Confirm that the authenticated identity has the permissions listed in the project prerequisites.
A bucket-name failure can point to a typo in local.bucket_name or an unexpected value in aws_region. Compare main.tf with the full reference above.
Help me debug why my Terraform apply cannot create the private S3 bucket.
Before you test the origin, do you think a browser can read index.html directly from this bucket?
- Copy the URL shown for direct_s3_object_url in the apply output.
- Paste the copied URL into your browser address bar.
- Press Enter to load the direct S3 object URL.
You should see an AccessDenied response instead of the portfolio. The direct origin is refusing the public browser request.
Why is access denied?
The S3 bucket has no public read path. Its Public Access Block rejects public ACLs and public bucket policies.
That blocked page is the intended security boundary. Visitors need a controlled delivery path before they can reach the portfolio.
Your private origin is live and rejecting direct public reads. Next, you will place Amazon CloudFront in front of it to deliver the same portfolio over HTTPS.
Deliver Securely Through CloudFront
Your Terraform configuration now manages a private Amazon S3 origin. The direct object URL proves that public visitors cannot read from it.
Amazon CloudFront Origin Access Control gives the distribution a signed path to that private bucket. GitHub OIDC gives GitHub Actions short-lived credentials for deployments from the trusted branch.
In this step, get ready to:
- Complete the CloudFront delivery infrastructure and branch-scoped IAM configuration.
- Apply the infrastructure and configure the GitHub repository variables.
- Deploy the portfolio through GitHub Actions and verify both access paths.
Add secure delivery and OIDC infrastructure
GitHub includes immutable numeric identifiers in the trusted OIDC subject for new repositories. These identifiers keep the trust relationship tied to the same owner and repository after a rename.
- Enter https://api.github.com/repos/OWNER/secure-cloud-portfolio in your browser address bar after replacing OWNER with your GitHub username.
- Copy the top-level id value here: your repository ID.
- Copy the nested owner.id value here: your GitHub owner ID.
- Return to terraform/terraform.tfvars in Visual Studio Code.
- Replace the github_owner_id value with your GitHub owner ID.
- Replace the github_repository_id value with your repository ID.
- Save terraform/terraform.tfvars.
Why use immutable IDs?
The role trusts a subject containing the owner name plus its numeric ID. It also contains the repository name plus its numeric ID.
A rename changes a readable name. The immutable IDs continue identifying the original GitHub resources.
The new resources build on the bucket already declared in terraform/main.tf. You will validate the configuration after each small group of changes.
- Return to the locals block near the top of terraform/main.tf.
- Add this line below bucket_name:
origin_id = "${var.project_name}-s3-origin"
What does this value do?
The origin_id value gives the private S3 origin a stable identifier inside the CloudFront distribution.
- Add the Origin Access Control resource after the existing Public Access Block resource in terraform/main.tf by pasting:
resource "aws_cloudfront_origin_access_control" "site" {
name = "${var.project_name}-oac"
description = "OAC for the private portfolio bucket"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
What does this resource do?
- The OAC identifies the origin as an S3 bucket.
- SigV4 signing lets CloudFront authenticate every request sent to the private origin.
- Save terraform/main.tf.
- Validate these additions by running:
terraform -chdir=terraform validate
What does this command check?
Terraform checks that the new local value and OAC resource fit the provider schema. A successful validation confirms that the configuration remains structurally valid.
You should see confirmation that the configuration is valid.
Terraform validation failing?
Check that the origin_id line remains inside the existing locals block. Check that the OAC resource sits outside the Public Access Block resource.
Compare every quote and closing brace with the snippets above.
Help me fix this Terraform validation error.
The distribution is one large resource. You will paste it in two consecutive pieces so each group of settings stays readable.
- Add the first part of the distribution below the OAC resource in terraform/main.tf by pasting:
resource "aws_cloudfront_distribution" "site" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
price_class = "PriceClass_100"
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
}
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
min_ttl = 0
default_ttl = 0
max_ttl = 0
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
}
What does this section configure?
- The origin points at the bucket's regional domain and attaches the OAC.
- The cache behavior accepts read requests through HTTPS and keeps compression enabled.
- The zero TTL values make a newly uploaded page immediately observable in this project.
- Complete the same distribution resource by adding this code directly below the previous block:
restrictions {
geo_restriction {
restriction_type = "none"
locations = []
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
tags = {
Name = var.project_name
ManagedBy = "Terraform"
}
}
How is delivery completed?
- The distribution applies no geographic restriction.
- The default CloudFront certificate provides the public HTTPS endpoint.
- The tags identify the distribution as part of this Terraform project.
- Save terraform/main.tf.
- Validate the completed distribution by running:
terraform -chdir=terraform validate
What does this validation prove?
Terraform can now parse the completed CloudFront resource. It also confirms that the OAC and S3 origin references resolve.
You should see confirmation that the configuration is valid.
- Add the CloudFront read policy document below the distribution by pasting:
data "aws_iam_policy_document" "cloudfront_read" {
statement {
sid = "AllowCloudFrontReadOnly"
effect = "Allow"
actions = ["s3:GetObject"]
resources = [
"${aws_s3_bucket.site.arn}/*"
]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
What does this policy allow?
- The service principal allows CloudFront to request objects from the bucket.
- The SourceArn condition limits that permission to this distribution.
- The policy grants read access only through s3:GetObject.
- Attach the generated policy to the private bucket by adding:
resource "aws_s3_bucket_policy" "cloudfront_read" {
bucket = aws_s3_bucket.site.id
policy = data.aws_iam_policy_document.cloudfront_read.json
}
Why attach this policy?
The bucket policy turns the policy document into an access rule on the existing private bucket. Public Access Block remains enabled.
- Save terraform/main.tf.
- Validate the bucket access path by running:
terraform -chdir=terraform validate
What does this validation cover?
Terraform checks the IAM policy document and bucket policy references. Success confirms that the CloudFront-only read path is structurally complete.
You should see confirmation that the configuration is valid.
- Add the conditional GitHub OIDC provider below the bucket policy by pasting:
resource "aws_iam_openid_connect_provider" "github" {
count = var.github_oidc_provider_arn == "" ? 1 : 0
url = "https://token.actions.githubusercontent.com"
client_id_list = [
"sts.amazonaws.com"
]
tags = {
Name = "GitHub Actions OIDC"
ManagedBy = "Terraform"
}
}
How does the conditional provider work?
An empty github_oidc_provider_arn makes Terraform create the provider. An existing ARN keeps the resource count at zero.
- Return to the locals block near the top of terraform/main.tf.
- Add this line below origin_id:
github_oidc_provider_arn = var.github_oidc_provider_arn != "" ? var.github_oidc_provider_arn : aws_iam_openid_connect_provider.github[0].arn
What does this local select?
The local selects the existing provider ARN when one was supplied. Otherwise it selects the ARN of the provider created by this configuration.
- Save terraform/main.tf.
- Validate the OIDC provider selection by running:
terraform -chdir=terraform validate
What does this validation prove?
Terraform can resolve the conditional provider and the local ARN selection. The configuration now supports either provider state.
You should see confirmation that the configuration is valid.
The deployment role needs a trust policy before GitHub can request short-lived AWS credentials. Its subject is restricted to the immutable repository identity on main.
- Add the first part of the role trust policy below the OIDC provider by pasting:
data "aws_iam_policy_document" "github_assume_role" {
statement {
sid = "AllowGitHubMainBranch"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.github_oidc_provider_arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
What does this trust section require?
The federated principal must come from the selected GitHub OIDC provider. The token audience must identify AWS Security Token Service.
- Complete the same trust policy by adding this code directly below the previous block:
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = [
"repo:${var.github_owner}@${var.github_owner_id}/${var.github_repository}@${var.github_repository_id}:ref:refs/heads/main"
]
}
}
}
How is the branch boundary enforced?
The subject combines immutable owner and repository IDs with refs/heads/main. A token with a different subject cannot satisfy this trust condition.
- Save terraform/main.tf.
- Validate the completed trust policy by running:
terraform -chdir=terraform validate
What does this validation cover?
Terraform checks both OIDC token conditions inside the trust policy. Success confirms that the immutable subject expression is valid HCL.
You should see confirmation that the configuration is valid.
- Create the GitHub deployment role below the trust policy by pasting:
resource "aws_iam_role" "github_deploy" {
name = "${var.project_name}-github-deploy"
description = "Uploads the portfolio object from the trusted GitHub main branch"
assume_role_policy = data.aws_iam_policy_document.github_assume_role.json
tags = {
Name = "${var.project_name}-github-deploy"
ManagedBy = "Terraform"
}
}
What does this role do?
The IAM role uses the OIDC trust policy as its entry condition. The role itself receives deployment permissions through a separate inline policy.
- Define the role's upload permission below the role by adding:
data "aws_iam_policy_document" "github_deploy" {
statement {
sid = "UploadPortfolioObject"
effect = "Allow"
actions = ["s3:PutObject"]
resources = ["${aws_s3_bucket.site.arn}/index.html"]
}
}
How narrow is this permission?
The policy allows one upload action against one object path. The workflow cannot use this role to upload other files or administer the bucket.
- Save terraform/main.tf.
- Validate the role and permission document by running:
terraform -chdir=terraform validate
What does this validation prove?
Terraform can connect the role to its trust document. It can also build the object-specific upload policy.
You should see confirmation that the configuration is valid.
- Attach the upload policy to the deployment role by adding:
resource "aws_iam_role_policy" "github_deploy" {
name = "${var.project_name}-upload"
role = aws_iam_role.github_deploy.id
policy = data.aws_iam_policy_document.github_deploy.json
}
Why attach the inline policy?
The inline policy gives this role only the permission needed by the deployment workflow. It keeps the deployment path focused on index.html.
- Save terraform/main.tf.
- Validate the completed main configuration by running:
terraform -chdir=terraform validate
What does this validation prove?
Terraform can now resolve the complete CloudFront and OIDC deployment path. A successful result means terraform/main.tf is ready to apply.
You should see confirmation that the configuration is valid.
Still seeing a validation error?
Check that both split resources include their final closing braces. Confirm that the OIDC provider local remains inside the original locals block.
Check the owner and repository variable names against terraform/variables.tf.
Help me debug my completed Terraform configuration.
- Return to terraform/outputs.tf.
- Add the public CloudFront URL output below direct_s3_object_url by pasting:
output "cloudfront_url" {
value = "https://${aws_cloudfront_distribution.site.domain_name}"
description = "Public HTTPS URL for the portfolio"
}
What does this output provide?
The output exposes the distribution's public HTTPS address after Terraform creates it.
- Add the deployment role output directly below the CloudFront output by pasting:
output "github_actions_role_arn" {
value = aws_iam_role.github_deploy.arn
description = "Role ARN used by the GitHub Actions OIDC workflow"
}
What does this output provide?
The output exposes the role ARN that GitHub Actions needs when requesting short-lived AWS credentials.
- Save terraform/outputs.tf.
- Validate both completed Terraform files by running:
terraform -chdir=terraform validate
What does this final validation check?
Terraform checks the completed resources and outputs together. You should see confirmation that the configuration is valid.
✔️ My main configuration is complete
Great. Your terraform/main.tf file now defines the secure delivery path and deployment identity.
ⓧ I'd like to double check the full code
Compare your complete terraform/main.tf file with this reference:
terraform {
required_version = "= 1.16.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 6.69.1"
}
}
}
provider "aws" {
region = var.aws_region
}
data "aws_caller_identity" "current" {}
locals {
bucket_name = lower("${var.project_name}-${data.aws_caller_identity.current.account_id}-${var.aws_region}")
origin_id = "${var.project_name}-s3-origin"
github_oidc_provider_arn = var.github_oidc_provider_arn != "" ? var.github_oidc_provider_arn : aws_iam_openid_connect_provider.github[0].arn
}
resource "aws_s3_bucket" "site" {
bucket = local.bucket_name
force_destroy = true
tags = {
Name = var.project_name
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_cloudfront_origin_access_control" "site" {
name = "${var.project_name}-oac"
description = "OAC for the private portfolio bucket"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "site" {
enabled = true
is_ipv6_enabled = true
default_root_object = "index.html"
price_class = "PriceClass_100"
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
}
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
min_ttl = 0
default_ttl = 0
max_ttl = 0
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
}
restrictions {
geo_restriction {
restriction_type = "none"
locations = []
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
tags = {
Name = var.project_name
ManagedBy = "Terraform"
}
}
data "aws_iam_policy_document" "cloudfront_read" {
statement {
sid = "AllowCloudFrontReadOnly"
effect = "Allow"
actions = ["s3:GetObject"]
resources = [
"${aws_s3_bucket.site.arn}/*"
]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
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
}
resource "aws_iam_openid_connect_provider" "github" {
count = var.github_oidc_provider_arn == "" ? 1 : 0
url = "https://token.actions.githubusercontent.com"
client_id_list = [
"sts.amazonaws.com"
]
tags = {
Name = "GitHub Actions OIDC"
ManagedBy = "Terraform"
}
}
data "aws_iam_policy_document" "github_assume_role" {
statement {
sid = "AllowGitHubMainBranch"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.github_oidc_provider_arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = [
"repo:${var.github_owner}@${var.github_owner_id}/${var.github_repository}@${var.github_repository_id}:ref:refs/heads/main"
]
}
}
}
resource "aws_iam_role" "github_deploy" {
name = "${var.project_name}-github-deploy"
description = "Uploads the portfolio object from the trusted GitHub main branch"
assume_role_policy = data.aws_iam_policy_document.github_assume_role.json
tags = {
Name = "${var.project_name}-github-deploy"
ManagedBy = "Terraform"
}
}
data "aws_iam_policy_document" "github_deploy" {
statement {
sid = "UploadPortfolioObject"
effect = "Allow"
actions = ["s3:PutObject"]
resources = ["${aws_s3_bucket.site.arn}/index.html"]
}
}
resource "aws_iam_role_policy" "github_deploy" {
name = "${var.project_name}-upload"
role = aws_iam_role.github_deploy.id
policy = data.aws_iam_policy_document.github_deploy.json
}
What should match?
Check the resource order and every closing brace. Confirm that the trust subject ends with refs/heads/main.
✔️ My outputs are complete
Your outputs now expose every value needed for GitHub and the browser checks.
ⓧ I'd like to double check the full code
Compare your complete terraform/outputs.tf file with this reference:
output "aws_region" {
value = var.aws_region
description = "AWS Region used by the deployment workflow"
}
output "bucket_name" {
value = aws_s3_bucket.site.id
description = "Private S3 bucket populated by GitHub Actions"
}
output "direct_s3_object_url" {
value = "https://${aws_s3_bucket.site.bucket_regional_domain_name}/index.html"
description = "Direct origin URL that should remain blocked"
}
output "cloudfront_url" {
value = "https://${aws_cloudfront_distribution.site.domain_name}"
description = "Public HTTPS URL for the portfolio"
}
output "github_actions_role_arn" {
value = aws_iam_role.github_deploy.arn
description = "Role ARN used by the GitHub Actions OIDC workflow"
}
What should match?
Confirm that all five outputs appear once. The CloudFront URL and GitHub role ARN are the two additions from this step.
Apply the infrastructure and configure repository variables
The configuration now describes the full secure path. Applying it creates the distribution and the short-lived deployment identity in AWS.
CloudFront distribution creation can take about 15 minutes. A quiet terminal during this wait means AWS is still provisioning the distribution.
- Apply the completed configuration by running:
terraform -chdir=terraform apply
What does this command create?
Terraform presents a plan for the OAC and CloudFront distribution. It also includes the bucket policy plus the GitHub deployment identity.
Follow the terminal confirmation prompt to approve the plan.
When the apply completes, Terraform prints the updated outputs. The distribution and deployment role are now ready for the workflow.
- Record the aws_region output here: your Terraform aws_region output.
- Record the bucket_name output here: your Terraform bucket_name output.
- Record the github_actions_role_arn output here: your Terraform github_actions_role_arn output.
- Record the cloudfront_url output here: your Terraform cloudfront_url output.
- Record the direct_s3_object_url output here: your Terraform direct_s3_object_url output.
Terraform apply did not complete?
Check that terraform/terraform.tfvars contains the numeric owner and repository IDs from the GitHub API response. Confirm that the AWS CLI identity still has permission to create CloudFront and IAM resources.
If the account already has a GitHub OIDC provider, confirm that github_oidc_provider_arn contains that provider ARN.
Help me troubleshoot my Terraform apply failure.
The workflow reads repository variables through the vars context. These values identify the AWS Region plus the exact bucket and role created by Terraform.
- Return to the secure-cloud-portfolio repository on GitHub.
- Select Settings.
- Select Secrets and variables.
- Select Actions.
- Select Variables.
- Create the repository variable AWS_REGION with the value your Terraform aws_region output.
- Create the repository variable BUCKET_NAME with the value your Terraform bucket_name output.
- Create the repository variable AWS_ROLE_ARN with the value your Terraform github_actions_role_arn output.
You should see AWS_REGION, BUCKET_NAME, and AWS_ROLE_ARN listed as repository variables.
Create and run the deployment workflow
The AWS role and GitHub variables are ready. The workflow can now request a temporary OIDC session before uploading the portfolio object.
Its permissions allow token issuance and repository checkout. The upload command can write only index.html because the AWS role supplies the final authorization boundary.
- Create a .github folder inside the open secure-cloud-portfolio folder from the Visual Studio Code Explorer sidebar.
- Create a workflows folder inside .github.
- Create deploy.yml inside .github/workflows.
- Add the workflow trigger and first job step to .github/workflows/deploy.yml by pasting:
name: Deploy portfolio to AWS
on:
push:
branches:
- main
workflow_dispatch:
permissions:
id-token: write
contents: read
env:
AWS_REGION: ${{ vars.AWS_REGION }}
BUCKET_NAME: ${{ vars.BUCKET_NAME }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7.0.1
What does this workflow section do?
- The workflow runs for pushes to main or a manual dispatch.
- The permissions allow an OIDC token and read-only repository checkout.
- The environment reads the Region and bucket name from repository variables.
- Complete the workflow by adding this code directly below the checkout step:
- name: Configure short-lived AWS credentials
uses: aws-actions/configure-aws-credentials@v6.3.0
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Verify assumed identity
run: aws sts get-caller-identity
- name: Upload portfolio
run: aws s3 cp ./index.html s3://${{ env.BUCKET_NAME }}/
How does deployment reach AWS?
- The credentials action exchanges the GitHub OIDC token for a temporary AWS session.
- The identity check proves which AWS principal the job assumed.
- The final step uploads index.html to the private bucket.
- Save .github/workflows/deploy.yml.
✔️ My workflow is complete
Your workflow now contains the branch trigger and the complete OIDC deployment job.
ⓧ I'd like to double check the full code
Compare your complete .github/workflows/deploy.yml file with this reference:
name: Deploy portfolio to AWS
on:
push:
branches:
- main
workflow_dispatch:
permissions:
id-token: write
contents: read
env:
AWS_REGION: ${{ vars.AWS_REGION }}
BUCKET_NAME: ${{ vars.BUCKET_NAME }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7.0.1
- name: Configure short-lived AWS credentials
uses: aws-actions/configure-aws-credentials@v6.3.0
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Verify assumed identity
run: aws sts get-caller-identity
- name: Upload portfolio
run: aws s3 cp ./index.html s3://${{ env.BUCKET_NAME }}/
What should match?
Confirm that the workflow targets main and reads all three repository variables. The final command must upload only index.html.
- Return to the integrated terminal in Visual Studio Code.
- Stage the completed Terraform and workflow files by running:
git add .
What does this command stage?
Git stages the tracked Terraform changes and the new workflow file. The excluded terraform/terraform.tfvars file remains outside the commit.
- Commit the staged files by running:
git commit -m "First commit"
What does this command record?
Git records the secure infrastructure and deployment workflow as one repository revision.
Before you push, do you expect the workflow to reach AWS with a stored access key or a short-lived OIDC session?
- Push the committed configuration to main by running:
git push -u origin main
What does this push trigger?
The push matches the workflow's main branch trigger. GitHub Actions starts the deployment job using the committed workflow.
- Return to the repository page on GitHub.
- Select Actions.
- Open the Deploy portfolio to AWS workflow run.
- Confirm that the Configure short-lived AWS credentials step succeeds.
- Confirm that the Upload portfolio step succeeds.
The successful run proves that GitHub assumed the branch-scoped role and uploaded the portfolio without a stored AWS access key.
Workflow did not succeed?
Check that the three repository variable names match AWS_REGION, BUCKET_NAME, and AWS_ROLE_ARN exactly.
Confirm that the pushed branch is main and that the numeric GitHub IDs in terraform/terraform.tfvars came from the repository API response.
Help me troubleshoot the failed OIDC deployment.
Before you open both URLs, which one do you expect to serve the portfolio through HTTPS?
- Open your Terraform cloudfront_url output in your browser.
You should see the complete Cloud Engineering Portfolio over HTTPS. Its visible status still reads Local portfolio ready.
- Open your Terraform direct_s3_object_url output in another browser tab.
You should still see AccessDenied at the direct S3 URL. That is the secure delivery boundary working as designed.
Your portfolio now has a private origin and an automated deployment path. Next, you will prove that a normal source change travels from Git to the live CloudFront page.
Prove the Automated Deployment
Your portfolio already reaches visitors through CloudFront. Its private Amazon S3 origin stays hidden from direct visitors.
A CI/CD pipeline becomes credible when an ordinary source change reaches the live endpoint without manual AWS console work. In this step, GitHub Actions carries one visible edit from main to the private bucket through OpenID Connect.
In this step, get ready to:
- Change the visible portfolio status.
- Push the committed edit to the main branch.
- Verify the updated CloudFront page while direct S3 access stays blocked.
Update the portfolio status
The status line gives this deployment a visible marker. When the same text reaches CloudFront, you know the pipeline uploaded the new object.
- Switch back to the index.html tab in Visual Studio Code.
- Locate the status paragraph containing Local portfolio ready.
- Replace only its text with Deployment automated with GitHub OIDC.
- Save index.html.
The editor now shows the new deployment marker inside the status pill.
✔️ The status is updated
Good. Your portfolio now contains a visible marker that the automated deployment can carry to AWS.
ⓧ I'd like to double check the full code
Compare your complete index.html file with this reference:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Cloud engineering portfolio demonstrating AWS, Terraform, networking, and CI/CD skills.">
<title>Cloud Engineering Portfolio</title>
<style>
:root {
color-scheme: dark;
--bg: #07111f;
--panel: #101f33;
--panel-soft: #142944;
--text: #edf6ff;
--muted: #a8bed6;
--accent: #64d8ff;
--accent-2: #8d7dff;
--success: #5ce1a4;
--line: rgba(255, 255, 255, 0.12);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
font-family: Inter, Segoe UI, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(circle at top right, rgba(100, 216, 255, 0.14), transparent 30%),
radial-gradient(circle at left 45%, rgba(141, 125, 255, 0.12), transparent 28%),
var(--bg);
line-height: 1.6;
}
a { color: inherit; }
.shell { width: min(1080px, calc(100% - 32px)); margin: 0 auto; }
header { padding: 26px 0; border-bottom: 1px solid var(--line); }
nav { display: flex; justify-content: space-between; align-items: center; gap: 20px; }
.brand { font-weight: 800; letter-spacing: 0.04em; }
nav ul { display: flex; gap: 18px; margin: 0; padding: 0; list-style: none; }
nav a { color: var(--muted); text-decoration: none; }
nav a:hover { color: var(--accent); }
.hero {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 36px;
align-items: center;
padding: 84px 0 64px;
}
.eyebrow, .status {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 7px 12px;
color: var(--accent);
background: rgba(100, 216, 255, 0.06);
font-size: 0.9rem;
}
h1 {
margin: 18px 0;
font-size: clamp(2.7rem, 7vw, 5.7rem);
line-height: 0.96;
letter-spacing: -0.06em;
}
h1 span { color: var(--accent); }
.lead { max-width: 650px; color: var(--muted); font-size: 1.1rem; }
.hero-card, .card, .architecture {
border: 1px solid var(--line);
border-radius: 22px;
background: linear-gradient(145deg, rgba(20, 41, 68, 0.94), rgba(11, 26, 45, 0.94));
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.24);
}
.hero-card { padding: 26px; }
.hero-card h2, section h2 { margin-top: 0; }
.metric { display: flex; justify-content: space-between; gap: 18px; padding: 13px 0; border-bottom: 1px solid var(--line); }
.metric:last-child { border-bottom: 0; }
.metric span, .section-copy, .card p { color: var(--muted); }
main section { padding: 34px 0; }
.section-copy { max-width: 720px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 22px; }
.card { padding: 22px; }
.card p { margin-bottom: 0; }
.architecture { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; padding: 22px; margin-top: 22px; }
.node {
padding: 18px 12px;
border: 1px solid rgba(100, 216, 255, 0.22);
border-radius: 14px;
text-align: center;
background: rgba(100, 216, 255, 0.06);
}
.node strong { display: block; color: var(--accent); }
.node span { color: var(--muted); font-size: 0.9rem; }
footer { margin-top: 40px; padding: 28px 0 44px; border-top: 1px solid var(--line); color: var(--muted); }
@media (max-width: 760px) {
nav, .hero { display: block; }
nav ul { margin-top: 14px; flex-wrap: wrap; }
.hero-card { margin-top: 28px; }
.grid, .architecture { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<nav class="shell" aria-label="Primary navigation">
<div class="brand">CLOUD.PORTFOLIO</div>
<ul>
<li><a href="#about">About</a></li>
<li><a href="#architecture">Architecture</a></li>
<li><a href="#skills">Skills</a></li>
</ul>
</nav>
</header>
<main class="shell">
<section class="hero" id="about">
<div>
<div class="eyebrow">AWS Cloud Engineer</div>
<h1>Cloud Engineering <span>Portfolio</span></h1>
<p class="lead">
I build reproducible AWS infrastructure with Terraform, secure network paths,
and automated delivery workflows. This portfolio is itself a cloud project:
private S3 storage, CloudFront Origin Access Control, and GitHub OIDC deployment.
</p>
<p class="status">Deployment automated with GitHub OIDC</p>
</div>
<aside class="hero-card" aria-label="Project facts">
<h2>Project signals</h2>
<div class="metric"><span>Infrastructure</span><strong>Terraform</strong></div>
<div class="metric"><span>Delivery</span><strong>CloudFront</strong></div>
<div class="metric"><span>Origin</span><strong>Private S3</strong></div>
<div class="metric"><span>Authentication</span><strong>GitHub OIDC</strong></div>
</aside>
</section>
<section id="architecture">
<h2>Secure delivery architecture</h2>
<p class="section-copy">
Source changes travel through a short-lived identity path. Visitors reach the site
through CloudFront, while direct public access to the S3 origin remains blocked.
</p>
<div class="architecture" aria-label="Deployment and request flow">
<div class="node"><strong>Git push</strong><span>main branch</span></div>
<div class="node"><strong>GitHub Actions</strong><span>OIDC token</span></div>
<div class="node"><strong>Private S3</strong><span>website object</span></div>
<div class="node"><strong>CloudFront</strong><span>HTTPS delivery</span></div>
</div>
</section>
<section id="skills">
<h2>Cloud skills demonstrated</h2>
<div class="grid">
<article class="card">
<h3>Infrastructure as code</h3>
<p>Version-pinned Terraform creates repeatable S3, CloudFront, and IAM resources.</p>
</article>
<article class="card">
<h3>Cloud security</h3>
<p>Public S3 access is blocked, and CloudFront is the only trusted read path.</p>
</article>
<article class="card">
<h3>CI/CD and identity</h3>
<p>GitHub Actions deploys with temporary OIDC credentials scoped to the main branch.</p>
</article>
</div>
</section>
</main>
<footer>
<div class="shell">Built as a demonstrable AWS cloud-engineering project.</div>
</footer>
</body>
</html>
Push the change through GitHub Actions
The workflow listens for pushes to main. A commit on that branch starts the same deployment path you would use for a normal portfolio update.
- Select the Source Control view in the Visual Studio Code Activity Bar.
- Hover over index.html in the Changes list.
- Select the + icon to stage the file.
You will see index.html move into Staged Changes.
- Enter Prove automated deployment in the commit message input box.
- Select Commit.
The staged file disappears after Visual Studio Code records the commit in your local Git history.
Before you push, predict which workflow step proves that AWS accepted the temporary identity.
- Return to the integrated PowerShell terminal from earlier.
- Push the committed status change to main by running this command:
git push origin main
What does this command do?
This sends your local commit to the remote main branch. The branch update starts the deployment workflow.
The workflow requests a GitHub OIDC token. AWS exchanges that token for temporary role credentials before the upload.
- Return to the secure-cloud-portfolio repository in GitHub from earlier.
- Select Actions in the repository navigation.
- Open the newest Deploy portfolio to AWS run.
- Watch the run until every step completes.
You will see a green check beside the workflow. Its steps check out the repository, configure temporary AWS credentials, verify the assumed identity, and upload index.html.
Workflow did not turn green?
- Confirm the repository page shows main as the current branch.
- Confirm the repository variables are named AWS_REGION, BUCKET_NAME, and AWS_ROLE_ARN.
- Open the failed workflow step to identify whether authentication or the upload stopped the run.
Help me diagnose the failed GitHub Actions deployment.
That is the central proof. A normal Git push has authenticated with temporary credentials and delivered your new page without manual AWS console work.
Verify the live and blocked paths
Both URLs point toward the same private origin object. Their access paths produce different outcomes because only CloudFront has read permission.
Before you refresh both pages, predict which route reveals the new status while the other route stays blocked.
- Return to the cloudfront_url page from earlier.
- Refresh the page.
You will see Deployment automated with GitHub OIDC inside the status pill.
- Return to the direct_s3_object_url page from earlier.
- Refresh the page.
You will still see AccessDenied. The updated object exists, but direct public access remains blocked.
Live status has not updated?
- Confirm the newest workflow run completed successfully before refreshing CloudFront again.
- Confirm the latest commit on main contains the new status text.
- Confirm your browser is using the cloudfront_url output for the public page.
Help me trace why the latest portfolio status is missing from CloudFront.
You have proved the complete deployment loop. Your live portfolio updates from main while its S3 origin remains private.
Secret mission
Test the Branch Security Boundary
Your automated deployment trusts the main branch. Push a visible change from a feature branch to prove that the live portfolio stays unchanged. Then merge that change into main to watch the trusted path deploy it.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep the AWS resources live, pause deployment changes, or delete the Terraform-managed infrastructure. Amazon S3 and Amazon CloudFront usage can continue consuming your available AWS credits.
Cost warning
Leaving the CloudFront distribution and private S3 bucket available can continue creating AWS usage charges. The Delete option prevents further usage from these project resources.
Resources you used:
- Private Amazon S3 general purpose bucket secure-cloud-portfolio-ACCOUNT_ID-AWS_REGION containing the deployed index.html object.
- S3 bucket policy that grants read access only to the CloudFront distribution.
- Amazon CloudFront distribution site with Origin Access Control secure-cloud-portfolio-oac.
- IAM deployment role secure-cloud-portfolio-github-deploy with its least-privilege inline policy.
- GitHub OIDC identity provider when Terraform created it for this project.
- Local secure-cloud-portfolio repository containing the project files and local Terraform state.
Keep everything running
No action needed. Choose this if you want the HTTPS portfolio to remain available as a live demonstration.
- Leave the Terraform-managed AWS infrastructure unchanged.
- Keep the public GitHub repository as code evidence.
- Keep the GitHub repository variables and deployment workflow ready for future updates.
- Monitor your available AWS credits because S3 and CloudFront usage charges can continue.
Pause - I'll come back to this later
Stop deployment activity while keeping the project ready for your return. The live site remains online, so AWS usage can still consume credits.
- Stop pushing deployment commits to main.
- Leave the GitHub repository variables and workflow unchanged.
- Keep the local secure-cloud-portfolio repository on your Desktop.
- Resume automated deployment later by pushing a new portfolio change to main.
Delete - I don't want to use this again
Deleting cloud infrastructure can feel drastic, so Terraform shows every planned removal before asking for approval. Your public GitHub repository remains available as code evidence.
- Return to the PowerShell terminal in Visual Studio Code from earlier.
- Start the Terraform destroy process by running this command:
terraform -chdir=terraform destroy
What does this command remove?
The -chdir=terraform option points Terraform to the configuration and state inside terraform. The destroy command uses that state to prepare the removal plan.
The plan includes the private bucket, bucket policy, CloudFront resources, deployment role, and its inline policy. Terraform also removes the GitHub OIDC provider only when this project created it.
- Review every resource in the destroy plan.
- Approve the confirmation prompt.
- Confirm the final summary reports that the planned resources were destroyed.
That is the billable cloud layer cleared. Your S3 bucket, CloudFront resources, deployment role, and any project-created OIDC provider are gone.
Terraform cannot remove a resource?
- Check that PowerShell still uses the authenticated AWS identity from this project.
- Read the named resource in the failure message to identify the dependency blocking removal.
- Resolve the named dependency before retrying the same destroy command.
Help me diagnose why Terraform cannot destroy this project's AWS resources.
The local repository contains your personalized portfolio and Terraform state. Remove it after confirming that the main branch on GitHub contains the code you want to keep.
- Confirm the GitHub repository's main branch contains your final project files.
- Close Visual Studio Code.
- Press the Windows key to open the search bar.
- Type PowerShell and press Enter to open it.
- Remove the local repository and Terraform state by running these commands:
cd $HOME\Desktop
rm -Recurse -Force .\secure-cloud-portfolio
What do these commands remove?
The first command moves PowerShell to your Desktop. The second command permanently removes the secure-cloud-portfolio folder.
This deletes the local Terraform state and project files from your computer. It does not delete the public GitHub repository.
- Press the Windows key to open the search bar.
- Type File Explorer and press Enter to open it.
- Select Desktop in the navigation pane.
You should no longer see the secure-cloud-portfolio folder on your Desktop.
Folder still visible?
- Confirm that Visual Studio Code is fully closed.
- Check that the folder name is exactly secure-cloud-portfolio.
- Run the removal commands again from a standalone PowerShell window.
Help me remove the local project folder safely.
Nice Work!
Nice Work!
You did it! Your live cloud-engineering portfolio now deploys through GitHub Actions with short-lived OIDC credentials. Amazon CloudFront serves it over HTTPS while the Amazon S3 origin stays private.
What you learned:
- Built a polished cloud-engineering portfolio that presents your AWS architecture flow. It also showcases your infrastructure knowledge through a live project.
- Defined private storage and secure delivery with Terraform. You kept direct S3 access blocked through CloudFront Origin Access Control.
- Automated each main-branch deployment through a CI/CD workflow. The workflow assumes a least-privilege IAM role without storing AWS access keys.
- Secret Mission: proved the branch security boundary by pushing oidc-test without triggering a deployment. You then merged the change into main to deploy it successfully.
Ready to quiz yourself?