Why I Rebuilt hihorton.com as a Static Site on AWS
Why I left WordPress and Ghost, moved my website out of the homelab, and built a simpler static architecture with Astro, S3, CloudFront, and Cloudflare.
I wasn’t trying to build a media company. I wanted a website where I could document what I was learning, write about my homelab, and show the projects I was working on.
My first attempts were built with WordPress and Ghost. I had used both before, and either one could have powered the site. The problem was that I spent more time maintaining the platform than I wanted to.
On two separate occasions, I found myself locked out of the WordPress admin panel. I eventually got back in, but those incidents made me question why a small personal site needed an application server, a database, plugins, updates, and another login to maintain.
Ghost was cleaner, and I liked using it. It was still more publishing platform than I needed. Features for memberships, newsletters, and paid content make sense for someone building a publication or selling access to an audience. That isn’t what I want hihorton.com to become. I don’t want to put my writing behind a membership or turn every visitor into a potential source of revenue.
There was another problem with both setups: they lived in my homelab.
I use the homelab to experiment. Services move, networks change, and sometimes I break things while learning. That is fine for a lab, but it meant an experiment at home could take my public website offline.
I wanted to separate the website from the lab.
What I needed from the replacement
Before choosing another platform, I wrote down what the site actually needed to do:
- Publish normal pages and Markdown blog posts
- Stay online when I changed something in the homelab
- Require as little server maintenance as possible
- Cost very little at my current traffic level
- Keep the origin storage private
- Give me hands-on experience with AWS
- Leave room for automated deployments later
That list pointed me toward a static site.
A static site generator takes source files, templates, and content and produces the HTML, CSS, JavaScript, and assets a browser needs. The production site does not need an application process or database to render a page for each visitor. It only needs somewhere to store the generated files and a way to deliver them.
I chose Astro because it let me write the site as a normal project while producing static files for deployment. Running the build creates a dist/ directory containing the production site. Once that directory exists, the hosting platform does not need to know anything about Astro.
It only needs to serve files.
Building the AWS architecture
The first production architecture used five main services:
- Astro generates the static website
- Amazon S3 stores the generated files
- CloudFront delivers those files through a CDN
- AWS Certificate Manager provides the TLS certificate used by CloudFront
- Cloudflare manages the public DNS records for
hihorton.com
The request path looks like this:
Visitor → Cloudflare DNS → CloudFront → private S3 bucket
Each part has one job.
Cloudflare tells the visitor where to find the website. CloudFront accepts the HTTPS request, serves a cached copy when one is available, and retrieves a file from S3 when it needs the origin. S3 stores the production files generated by Astro.
Keeping S3 private
I did not want the S3 bucket to become a second public website endpoint. Visitors should reach the site through CloudFront, where the custom domain, TLS certificate, and caching behavior are configured.
The bucket is private, and CloudFront reaches it through Origin Access Control. The bucket policy allows the CloudFront distribution to read the site files without allowing the public to read the bucket directly.
That distinction matters. S3 is the storage layer, not the public entry point.
The public path is:
https://hihorton.com → CloudFront → S3
Direct anonymous access to the S3 origin is blocked.
Watching the cost
Static hosting on S3 and CloudFront is inexpensive at the scale of my site. My early usage was roughly twenty cents per month, although that number depends on traffic, requests, storage, and data transfer.
Low cost is not the same as no risk. A configuration mistake or unexpected traffic can still create charges. I configured AWS Budgets and SNS notifications early so I would know if spending moved outside the range I expected.
The alert does not prevent charges, but it keeps a small experiment from becoming a surprise bill that I notice weeks later.
The architecture I built twice
My first version separated the landing page and blog.
The landing page had its own S3 bucket, CloudFront distribution, and DNS records. The blog had another bucket, another distribution, and its own subdomain. Everything worked, and the separation looked organized on paper.
Then I had to operate it.
Every deployment involved two destinations. Every CloudFront change had two distributions to consider. DNS contained records that existed only because I had split one small website into separate systems.
I stopped and asked what problem the separation was solving.
There wasn’t one.
The landing page and blog had the same owner, security requirements, deployment process, and availability needs. They changed together and were part of the same Astro project. I had created an architectural boundary without an operational reason for it.
So I rebuilt the site around one path:
hihorton.com/ → landing page
hihorton.com/blog/ → blog
The new version uses one S3 bucket, one CloudFront distribution, and one deployment process. The blog lives under /blog as part of the same generated site.
Then I deleted the old bucket, distribution, and DNS record.
Working architecture is not automatically good architecture. The first design served pages correctly, but it made each change harder without improving security, reliability, or maintainability.
Deploying the site
The first deployment process was a local script. It performed three jobs:
- Build the Astro project
- Synchronize the generated
dist/directory to S3 - Invalidate the CloudFront cache so changed files would be served
The commands followed this basic pattern:
npm ci
npm run build
aws s3 sync dist/ "s3://${S3_BUCKET}" --delete
aws cloudfront create-invalidation \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--paths '/*'
The script was simple, but it gave me an important boundary. Astro was responsible for generating the site. AWS was responsible for storing and serving the generated output. The deployment connected those two parts.
I later moved those same steps into CI/CD, but automating them did not change the underlying model. A deployment still builds dist/, copies it to S3, and tells CloudFront that cached files may have changed.
What I learned
Moving the site out of my homelab solved the reliability problem I actually cared about. I can rebuild a server, change a VLAN, or take the lab offline without taking hihorton.com down with it.
The project also changed how I think about static sites. Static does not mean there is no infrastructure. DNS, TLS certificates, CloudFront behavior, S3 permissions, cache invalidation, and cost controls still have to work together. There is simply no application server or database in the production request path.
I also learned to question boundaries that exist only because they look organized. Separate buckets and distributions sounded cleaner until I had to maintain them. Combining the site reduced the number of things I could misconfigure without removing anything I needed.
The result is not the right architecture for every website. A site that needs authenticated user accounts, server-side application logic, or frequently changing personalized data may need a backend. My site does not. It publishes pages, posts, and project documentation, which makes static hosting a good fit.
That was the larger lesson from leaving WordPress and Ghost. I did not need to find a more powerful content platform. I needed to define what the website was for and choose the smallest system that did that job well.