← Back to projects

Cloud Deployment

Building a production AWS stack for kvlopez.dev from scratch: S3, CloudFront, ACM, and Cloudflare DNS.

Role
Sole architect
Stack
AWS · Cloudflare
Status
Live · HTTPS
Type
Cloud infrastructure
Browser — visitor types kvlopez.dev
Cloudflare DNS — CNAME flattening at apex domain · no Route 53
Amazon CloudFront — CDN · SSL termination via ACM cert (us-east-1)
Origin Access Control (OAC) — only CloudFront can read the bucket
Amazon S3 — private bucket · static site files · no public access

Full request path: browser → Cloudflare DNS → CloudFront (CDN + SSL) → OAC → private S3 bucket.

I chose AWS for this portfolio because I had two AWS certifications and I wanted to use them. GitHub Pages exists. Netlify exists. But neither one would have required me to configure an S3 bucket, set up Origin Access Control, or work through a broken deployment on live infrastructure. I wanted to show the certifications were actually usable.

Before I built this, production deployment meant having the final product somewhere the public could see it. Actually doing it changed that. Once the site is live, everything you publish is real. Every filename, every path, every link either works or it does not. That made accuracy feel like a requirement in a way that local development never does.

The site is a static HTML portfolio served through a four-layer AWS stack. Each layer has a specific job, and they're connected in a deliberate order so that no part of the infrastructure is more exposed than it needs to be.

S3 — Storage, Not a Web Server

The S3 bucket holds all the site files but is configured as fully private. Direct public access is blocked at the bucket level. Nothing reaches S3 without going through CloudFront first, enforced by an Origin Access Control (OAC) policy attached to the bucket. OAC replaced the older Origin Access Identity (OAI) approach and is now the recommended pattern for locking down S3 origins. The result: S3 stores the files, but it isn't the thing that serves them.

CloudFront — CDN and SSL Termination

CloudFront sits in front of S3 and handles two things: distributing content from edge locations close to visitors, and terminating HTTPS. The SSL certificate is issued through AWS Certificate Manager (ACM) in us-east-1. That specific region is a hard requirement for CloudFront, regardless of where the rest of your infrastructure lives. CloudFront is the only entity with permission to read from the S3 bucket via OAC, which means the CDN layer also acts as the access layer.

Cloudflare DNS — Apex Domain Without Route 53

Pointing a custom apex domain (kvlopez.dev, not www.kvlopez.dev) to a CloudFront distribution is a known friction point. DNS specs don't allow a CNAME record at the root of a domain. You can only CNAME a subdomain. AWS Route 53 solves this with "alias records," but Route 53 costs $0.50/month per hosted zone.

Cloudflare solves the same problem for free using CNAME flattening at the apex. It resolves the CNAME to an IP address at query time and returns that IP in the DNS response. Functionally equivalent to an alias record, no Route 53 required.

Tech Stack

Storage: Amazon S3 (private, OAC-locked)
CDN: Amazon CloudFront
SSL: AWS Certificate Manager (us-east-1)
DNS: Cloudflare (CNAME flattening)
IAM: Bucket policy via OAC
Site files: HTML, Tailwind CSS, JS

The site loaded, but the Employee Attrition Analysis screenshots didn't. Those images were returning a 403 Forbidden from CloudFront.

The bucket policy was correct. The OAC was configured. CloudFront was hitting S3. So why were the images blocked?

Opening DevTools and checking the Network tab showed the exact request URL CloudFront was sending to S3 for each failed image. The request looked right. When I compared it character by character to the actual filename in the bucket, I found the problem. The HTML was referencing screenshot.PNG. The file uploaded to S3 was screenshot.png.

S3 is case-sensitive. Most local filesystems on Windows and macOS are not. The files worked fine in local development because the OS treated .PNG and .png as the same thing. S3 did not. CloudFront passed the request as written in the HTML, S3 looked for an exact match, found nothing, and returned 403.

The fix was renaming all image references in the HTML to lowercase. What works on your machine and what works in object storage are not the same thing. The Network tab will tell you exactly where the mismatch is.

Studying for the AWS certifications focuses on what you think is going to happen. Building this showed me what actually happens. The concepts stopped being abstract the moment I had to make them work together. S3 connects to CloudFront. CloudFront connects to a certificate in a specific region. That certificate has to exist before the distribution can use it. Reading about that sequence is one thing. Watching your site fail because a setting is wrong is another.

The 403 error was the most useful part of the whole project. Locally, filenames are forgiving. The operating system treats .PNG and .png as the same file. S3 does not. An image that loaded fine on my laptop returned a 403 in production because the case did not match. The DevTools Network tab showed exactly which request was failing and what S3 was looking for. That is the kind of thing a practice exam cannot teach you.

The next step for this deployment is setting up a CI/CD pipeline with GitHub Actions. Right now, every update means manually uploading files to S3. A pipeline would handle that automatically on every git push. Once I learn a process, the next question is always how to stop doing it by hand. I also want to add CloudWatch monitoring so I know when something breaks before a visitor does. Deploying something and operating it are not the same thing.

Back to the beginning

SmartWrangle 2.0 →

01 / 05