“The impediment to action advances action. What stands in the way becomes the way.” — Marcus Aurelius
Welcome to my first post.
I’m an IT professional by trade. I’ve deployed infrastructure on more platforms than I care to list, and I’ve broken things on every single one of them. That is the fundamental nature of the work—you learn by building, and you build by breaking. Tech is full of compromises, and this blog isn’t a dry documentation page; it’s a narrative about navigating those tradeoffs.
The persona of Stoic Knight is a mashup of my love for fantasy, RPGs, and Stoicism. While the knight part is mostly just more fun than calling myself an engineer, the discipline is entirely real. I try to practice it daily. My core principles—honesty, accountability, and resilience—are what keep me iterating when things don’t work the first time. Or the fifth.
This marks the beginning of an ongoing journey, and I hope it serves as both a practical guide and inspiration for technical peers, as well as a transparent window for potential clients into exactly how the magic works. To kick things off, I’m pulling back the curtain on how I built the infrastructure that serves the very site you are reading right now.
It is a tale of deliberate over-engineering, trial and error, and the humbling realization that “simple” static sites are rarely actually simple. I didn’t rely on hand-holding tutorials for this, and I deliberately kept AI out of the driver’s seat. Don’t get me wrong—I am not anti-AI. But after using it extensively, I realized how easy it is to fall into a trap. An illusion where you mistake a successful prompt for actual progress. Sacrificing your own experience for the sake of convenience and speed is a bad trade. You outsource the struggle and rob yourself of the “aha!” moment.
Instead, I chose to approach this from first principles: deploy, test, break, read the documentation, fix, and iterate.
The Roadmap
This is a three-part series pulling apart the entire deployment. Here is how the journey breaks down:
- Part 1 (This Post): The Infrastructure – A deep dive into the AWS stack, the mistakes made along the way, and the discipline required to get it right.
- Part 2: The Frontend – Looking under the hood at the Hugo site setup and Tailwind CSS integration.
- Part 3: The Pipeline – Walking through the GitOps workflow and the GitHub Actions CI/CD automation.
Let’s get into it.
The Vision
I started with a clear picture: a completely private S3 bucket with no public access, served through CloudFront with signed requests, automated deployments via GitHub Actions using OIDC so no static credentials ever touched the repository, and strict security headers enforced at the edge. The S3 bucket would be locked down. CloudFront would be the only door. No WAF, no public bucket policies, no static secrets.
That was the theory. I estimated the cost at a few dollars a month. I knew exactly what I wanted.
What I didn’t know was how many ways reality would disagree with me before the vision became real.
The Iterations: Lessons from the Git Log
No project survives first contact with reality. Here are the real issues I ran into — pulled straight from the git history — and what each one taught me.
The Security Missteps
The first wounds were self-inflicted.
I started with a Web Application Firewall configured to block everything except my IP. I wanted to validate the pipeline before opening the gates. The WAF was deliberate, but it was also massive overkill for a static site with no dynamic content, no API, and no sensitive data to exploit. After implementing Origin Access Control and strict security headers, the WAF was redundant. I removed it entirely, saving complexity and about $8 a month.
The deeper lesson came from the Content Security Policy. After deploying CloudFront Functions to inject security headers, I set a strict CSP:
headers['content-security-policy'] = {
value:
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self';"
};
I pushed the change, the deployment succeeded, and I opened the site to a blank white page. The browser console was a wall of violations — Google Fonts blocked, inline scripts blocked, everything broken. I had locked myself out of my own site.
The progression was painful and necessary: start strict, break things, loosen selectively, then tighten again as the frontend simplified. I added SHA hashes for inline scripts, allowed necessary CDN resources, and later replaced external fonts with self-hosted ones and icon fonts with inline SVGs. The current policy is far cleaner:
var CSP_POLICY_VALUE =
"default-src 'none'; " +
"base-uri 'self'; " +
"connect-src 'self'; " +
"img-src 'self' data: https://img.shields.io; " +
"media-src 'self'; " +
"worker-src 'self'; " +
"font-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"script-src 'self' 'unsafe-inline';";
Security is a continuous refinement, not a one-shot configuration. Start strict, observe the friction, and adapt.
The Identity and Naming Traps
While the CSP taught me about browser enforcement, the next roadblock reminded me that AWS has its own rigid expectations. Some problems are small in fix but large in lesson.
My first s3.tf used "${var.domain_name}-site" as the bucket name, which resolved to stoicknight.com-site. terraform apply failed immediately — S3 bucket names cannot contain dots safely with certain SSL/TLS features. The same issue cascaded into CloudFront function names and log bucket configurations. I had made a naming assumption based on what “looked right” rather than verifying AWS’s actual constraints.
The fix was one line:
bucket = "${replace(var.domain_name, ".", "-")}-site"
That replace() function now anchors every resource name in the infrastructure. A small change, but it represents a larger habit: validate your assumptions before committing them to code. AWS naming rules are scattered across dozens of documentation pages. You learn them one error at a time.
Then came the OIDC puzzle. The GitHub Actions workflow failed with a blunt AccessDenied error. I spent over an hour tearing apart the IAM policy, the OIDC provider, the thumbprints, and the AWS regions — all because of this:
variable "github_repo" {
default = "stoicknight/stoicknight-site" # lowercase
}
GitHub’s OIDC token uses the exact casing of the username: repo:StoicKnight/stoicknight-site:ref:refs/heads/main. My variable was entirely lowercase. The string comparison failed silently. IAM does not tell you why it denied the request — it just says no.
The fix was a single character change. The lesson stuck: OIDC debugging is opaque by design. When a trust policy fails, you compare strings character by character.
The Edge is Ancient (Or at least, it used to be)
Moving past identity, I hit a snag with traffic routing. When writing URL rewrite functions at the edge, I initially crashed into runtime limitations.
By default, CloudFront Functions run in a JavaScript 1.0 environment that supports only ES5.1. No const, no let, no arrow functions, no endsWith().
My first URL rewrite function used uri.endsWith('/'). The deployment failed. S3 is a strict object store — it expects literal file paths (/about/index.html), not pretty URLs (/about). A rewrite function at the edge is necessary to bridge that gap.
The working solution is primitive but effective:
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.charAt(uri.length - 1) === '/') {
request.uri += 'index.html';
}
var lastSegment = uri.substring(uri.lastIndexOf('/') + 1);
if (lastSegment.indexOf('.') === -1) {
request.uri += '/index.html';
}
return request;
}
This handles both /posts/ and /posts, resolving both to the correct S3 object. It runs in under 2ms at the edge.
Constraints breed creativity. Sometimes the simplest, most primitive solution, is also the fastest and most resilient.
The Evolution: From Naive to Hardened
After surviving those iterations, the infrastructure settled into something stable. Here is what production-grade looks like for a static site:
| Component | Role | Why |
|---|---|---|
| S3 (private bucket) | Stores all static files | No public access, ever. CloudFront is the only door. |
| CloudFront + OAC | CDN with signed S3 requests | Origin Access Control means SigV4-signed requests only. No open bucket policy. |
| CloudFront Functions | URL rewrite + security headers | ES5.1 edge compute — lightweight, cheap, fast. |
| ACM | SSL/TLS certificate | Required by CloudFront. Provisioned in us-east-1. |
| Route53 | DNS authority | A/AAAA alias records pointing to CloudFront. |
| GitHub Actions + OIDC | CI/CD | Short-lived tokens. No static secrets to rotate or leak. |
The key principle: the S3 bucket is entirely private. No static website hosting enabled, no public bucket policy. CloudFront talks to S3 through Origin Access Control using signed requests. That is the security boundary — not a WAF, not an IP whitelist, just solid access control.
S3 Encryption, Versioning
Both production and log buckets enforce SSE-S3 (AES256) encryption and versioning. If a Terraform run goes rogue or I accidentally overwrite a vital asset, I can roll back.
resource "aws_s3_bucket_server_side_encryption_configuration" "site" {
bucket = aws_s3_bucket.site.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_versioning" "site" {
bucket = aws_s3_bucket.site.id
versioning_configuration {
status = "Enabled"
}
}
Lifecycle Rules
To maintain cost discipline, non-current object versions expire after 30 days, and logs purge after 90 days. Store what provides value; automate the deletion of what doesn’t.
resource "aws_s3_bucket_lifecycle_configuration" "site_lifecycle" {
bucket = aws_s3_bucket.site.id
rule {
id = "expire-old-versions"
status = "Enabled"
noncurrent_version_expiration {
noncurrent_days = 30
}
}
}
S3 Native State Locking
With Terraform 1.10 introducing use_lockfile = true for S3 backends, I was able to drop DynamoDB entirely. That’s one less service to provision, pay for, and manage.
terraform {
backend "s3" {
bucket = "stoicknight-terraform-state"
key = "global/s3/terraform.tfstate"
region = "eu-central-1"
encrypt = true
use_lockfile = true # No DynamoDB required
}
}
The CI/CD Security Model: Least Privilege in Practice
I initially planned a single IAM role for all GitHub Actions. Functional, but it violated least privilege. A workflow publishing a blog post does not need permission to rewrite IAM policies, and an infrastructure deployment role doesn’t need to trigger on every typo fix.
So I split them.
The narrow role handles exactly two things: sync static files to S3 and invalidate the CloudFront cache. It cannot read secrets, modify infrastructure, or view other AWS services.
data "aws_iam_policy_document" "github_actions_deploy_policy_doc" {
statement {
actions = ["s3:PutObject", "s3:ListBucket", "s3:DeleteObject"]
resources = [aws_s3_bucket.site.arn, "${aws_s3_bucket.site.arn}/*"]
}
statement {
actions = ["cloudfront:CreateInvalidation"]
resources = [aws_cloudfront_distribution.site.arn]
}
}
The broad role holds wider permissions (S3, CloudFront, ACM, IAM) but is strictly gated by the production environment in GitHub Actions. A human must explicitly approve every terraform apply job.
Refining these roles meant dealing with permissions I hadn’t initially considered: the broad role needed access to the Terraform state bucket, cloudfront:GetOriginAccessControl, and OIDC assumption during Pull Request events. Each failure tightened the policy.
CloudFront HTTP/3 and Cache Policy
The distribution uses HTTP/3, enforces TLSv1.2_2021 as the minimum protocol, and relies on AWS’s managed caching policies rather than manual TTL overrides:
resource "aws_cloudfront_distribution" "site" {
http_version = "http3"
default_cache_behavior {
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # Managed-CachingOptimized
viewer_protocol_policy = "redirect-to-https"
}
viewer_certificate {
minimum_protocol_version = "TLSv1.2_2021"
}
}
Current Tradeoffs and the Road Ahead
The architecture works, but “working” and “complete” are two entirely different states. I accepted a few deliberate tradeoffs to keep the initial deployment lean. They sit on the ledger as technical debt, and they define the immediate roadmap.
- Flying Blind: If the infrastructure drops, I will likely find out from an end-user. There is no active alerting configured to scream when things break.
- Manual Bootstrapping: The S3 bucket for Terraform state management was spun up by hand - a one-time setup tax, but it prevents the infrastructure from fully self-provisioning.
- Sluggish Cache Purges: CloudFront edge invalidations take 2 to 5 minutes.
The real equation is determining when the friction of these tradeoffs outweighs the value of keeping the system simple. As that balance shifts, I have several upgrades lined up.
Top of mind is migrating DNS authority from Route 53 to Cloudflare. The appeal of apex CNAME flattening combined with consolidating DNS management into a unified control plane aligns cleanly with my long-term infrastructure goals. A complete Terraform implementation for this migration is already sitting in the archives, waiting for the right window to deploy.
Beyond DNS, the upcoming iterations will focus on:
- Closing the Visibility Gap: Implementing lightweight CloudWatch dashboards paired with external uptime monitoring to finally get proactive alerts.
- Performance Tuning: Optimizing the frontend build pipeline specifically to ace Core Web Vitals.
- Content Pipeline Streamlining: Stripping out friction from the local writing workflow to the live deployment.
- Updating CloudFront Functions: AWS recently introduced a JS 2.0 runtime supporting ES6.
Each of these upgrades will get its own deep dive as the architecture evolves from a minimum viable setup into a mature platform.
The Cost Reality
| Item | Hosting Services | My Setup |
|---|---|---|
| Hosting | $15/month | $0.50 (S3) |
| CDN | $0 (Cloudflare free) | ~$2 (CloudFront) |
| DNS | Included | $0.50 (Route 53) |
| WAF | Not applicable | Removed ($5 saved) |
| Total | $15/month | ~$3/month |
The cost reduction is a nice bonus, but it isn’t the primary goal. The real value is transparency. Every dollar is spent intentionally on a mechanism I fully understand. I know exactly why my S3 bill is $0.50. I know why CloudFront costs $2. There are no black boxes, no hidden platform fees, and no surprise renewal spikes.
Closing: What We Built, What We Know, and What Comes Next
After weeks of iteration, the infrastructure is stable. When I push to main, GitHub Actions assumes an OIDC role, builds the site, syncs it to a private S3 bucket, invalidates the CloudFront cache, and the new version is live within minutes.
But I am not mistaking stability for perfection. The real lesson of this project is not the specific Terraform modules. Is the discipline of approaching infrastructure as a series of deliberate decisions rather than a checklist of best practices. The WAF was not “wrong” - It was the right choice at the wrong stage. The CSP was not “too strict” - it was a useful stress test.
If you are building your own infrastructure, start with the smallest possible perimeter. Break things on purpose. The goal is not a perfect architecture on day one; it is a system you can reason about when it fails at 2 AM.
That is what it means to build a true foundation. In Part 2, we will construct the house on top of it: exploring the modular Hugo architecture, the Tailwind v4 styling, and the design logic that makes this text readable.
