This blog runs on a Raspberry Pi 4 sitting on a shelf in my house. No cloud hosting, no static site platform, no monthly bill beyond the electricity. Here's the setup.
The stack
The site generator is a small Python program I wrote — it reads markdown files, renders them with Jinja2 templates, and writes static HTML to an output directory. To publish, I run a deploy script on my laptop that builds the site locally and rsyncs the output to the Pi. The Pi has no build tools, no git repo, no Python runtime. It just receives files and serves them.
The web server is Caddy. The entire server configuration is five lines:
blog.jasonweddington.com {
root * /home/jason/blog/output
file_server
encode gzip
}
Caddy handles TLS automatically — it provisions a Let's Encrypt certificate on first request, renews it before expiration, and redirects HTTP to HTTPS. No certbot, no cron job, no manual renewal. You point the domain at the machine, start Caddy, and HTTPS works.
Getting traffic to the Pi
The challenge with hosting at home is that residential ISPs assign dynamic IP addresses. Mine changes every few months. I needed a way to keep my DNS record pointed at whatever IP my router currently has.
The solution is a serverless dynamic DNS system built on AWS, based on this architecture from the AWS blog. The pieces:
- Route 53 holds the DNS record for
blog.jasonweddington.comwith a 60-second TTL - A Lambda function (Python 3.12) validates an update request and upserts the Route 53 A record
- API Gateway exposes the Lambda as a public HTTPS endpoint
- DynamoDB stores the configuration and a shared secret for request validation
A cron job on the Pi runs every five minutes, calls the API Gateway endpoint, and if the IP has changed, the Lambda updates Route 53. The whole round trip takes under a second. The original AWS blog post was written in 2017 with Python 2.7 Lambda functions. I recently ported the function to Python 3.12 — the main change was adding .encode() to the hash computation, since Python 3's hashlib requires bytes rather than accepting strings implicitly. The reference code on GitHub has also been updated to Python 3.
My router runs pfSense, which forwards ports 80 and 443 to the Pi's local IP. Caddy needs both — port 80 for the Let's Encrypt HTTP-01 challenge, and port 443 for serving the site.
DNS delegation
The domain jasonweddington.com is registered at Squarespace (inherited from Google Domains when they sold the business). Rather than moving the whole domain to Route 53, I use NS record delegation: Squarespace has four NS records for the blog subdomain pointing to Route 53's nameservers. Route 53 manages only the subdomain. This keeps the apex domain and its existing configuration at Squarespace while giving me full control over the blog's DNS.
What this costs
The Pi has a PoE hat and hangs off a small PoE switch I repurposed from an old security camera setup — one ethernet cable for power and data. My total AWS bill is about $23/month, but almost none of that is the blog. Route 53 accounts for $2.51 — and that covers several hosted zones beyond this blog. The Lambda invocations, API Gateway calls, and DynamoDB reads are too small to break out individually — they're somewhere inside a $1.16 "Others" line item that also covers other services. There's no compute bill because the Pi does the serving.
The tradeoff is reliability. If my power goes out or my ISP has an outage, the blog goes down. For a personal blog, that's fine. If I needed higher availability, I'd put a CDN in front of the Pi or move the static files to S3 + CloudFront. But for now, the simplicity of one machine doing one job is worth more to me than five nines.