Personal technical blog covering DevOps, platform engineering, Kubernetes, cloud infrastructure, and SRE. Live at cloudista.org.
| Layer | Technology |
|---|---|
| Frontend | Static HTML/CSS/JS — no framework |
| API | FastAPI (Python 3.11), served via Docker on EC2 |
| Database | PostgreSQL 16 (blog posts, tags, authors, subscribers) |
| Web server | nginx (reverse proxy + static files) |
| CI/CD | GitHub Actions → SSH deploy to EC2 |
cloudista/
├── api/ FastAPI backend
│ ├── main.py App bootstrap, subscriber routes, lifespan
│ ├── blog_routes.py Blog API + server-rendered post pages
│ ├── config.py Pydantic BaseSettings (all config from env)
│ ├── dependencies.py PostgreSQL connection pool + Depends()
│ └── schemas.py All Pydantic request/response models
├── blog/ Post source files (*.txt) + import/image tools
├── blog-site/ Blog listing + post pages (HTML/JS)
├── site/ Landing site (index.html, style.css, etc.)
│ └── assets/ Favicons, og-image, webmanifest
├── infra/ nginx config, PostgreSQL schema
├── images/ Post hero images
├── scripts/ Operational one-off tools
└── .github/workflows CI/CD pipelines
Prerequisites: Docker, Python 3.11, pipenv, Node.js
# Start local PostgreSQL + API with hot-reload
make dev
# API runs at http://localhost:8000
# Blog at http://localhost:8000/blog/Local DB: postgresql://cloudista:cloudista_dev@localhost:5433/cloudista
make deploy # Full deploy: site + API + nginx
make api # API only (Docker rebuild + restart)
make site # Static files only
make import # Import blog/*.txt posts into PostgreSQL
make populate-images # Fetch Unsplash/Pexels images for posts missing one
make new-post SLUG=x # Scaffold a new post
make logs # Tail live API logs
make health # Hit /api/health on production
make ssh # SSH into vabch.org
make db-shell # psql into the remote blog DBmake new-post SLUG=my-post-title— createsblog/YYYY-MM-my-post-title.txt- Write the post in Markdown after the
====separator - Add an
Image:frontmatter field (required — see below) make import— imports into local PostgreSQL- Open a PR against
main— lint must pass before merge - Merge → auto-deploys to production
Post frontmatter:
Title: My Post Title
Author: Marie H.
Date: 2026-03-20
Image: https://images.unsplash.com/photo-...?w=900&q=80&fm=webp&auto=format&fit=crop
Tags: kubernetes, devops
============================================================
Content in Markdown...
Finding an image:
UNSPLASH_ACCESS_KEY=... PEXELS_ACCESS_KEY=... python3 blog/populate_images.py --slug my-post-titleOr trigger the Populate Post Images workflow from the Actions tab.
| Workflow | Trigger | What it does |
|---|---|---|
| Lint | Push / PR | ruff, yamllint, shellcheck, eslint via reviewdog |
| Test | Push / PR | pytest — all API unit tests must pass |
| Deploy to Production | Push to main / manual |
SSH deploy — auto-detects --api vs --site from changed paths |
| Populate Post Images | Manual | Fetches Unsplash/Pexels images for posts missing one |
Branch protection on main:
- Direct pushes blocked — PRs required (enforced for all, including admins)
Lint / lintandTest / testchecks must pass before merge- Verified (signed) commits required
Loaded by api/config.py via Pydantic BaseSettings. Set in /www/cloudista.org/api/.env on the server (scaffolded by deploy.sh on first run):
# PostgreSQL — blog posts + subscribers
BLOG_DB_HOST=localhost
BLOG_DB_PORT=5433
BLOG_DB_USER=cloudista
BLOG_DB_PASSWORD=...
BLOG_DB_NAME=cloudista
# AWS SES
AWS_REGION=us-east-1
FROM_EMAIL=noreply@cloudista.org
CONFIRM_BASE_URL=https://cloudista.org/api/confirm
SITE_URL=https://cloudista.org
# Cloudflare Turnstile (optional — skipped if blank)
TURNSTILE_SECRET=
# SNS Topic ARN for SES bounce/complaint webhook (optional — skips topic validation if blank)
SES_TOPIC_ARN=Missing required variables raise a ValidationError at startup with a clear message.
GitHub Actions secrets: SSH_PRIVATE_KEY, SSH_HOST, SSH_USER, UNSPLASH_ACCESS_KEY, PEXELS_ACCESS_KEY, BLOG_DB_PASSWORD, GOOGLE_API_KEY
- PostgreSQL schema (posts, tags, authors, subscribers)
- FastAPI backend (Docker, EC2,
--network host) - nginx reverse proxy + static file serving
- SSL/TLS via Certbot (auto-renewal)
- Cloudflare in front (real-IP forwarding configured)
- Security headers (CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy)
-
/api/healthendpoint with DB status - Migrate subscribers from MySQL → PostgreSQL
- GitHub Actions: lint (ruff, eslint, yamllint, shellcheck via reviewdog)
- GitHub Actions: pytest suite (unit + integration, mocked DB)
- GitHub Actions: auto-deploy on push to
main(detects--apivs--site) - Branch protection: PRs required, lint + test must pass, signed commits enforced
-
make devlocal dev environment (Docker Postgres + uvicorn hot-reload) - Upgrade to Python 3.12
-
Dockerfilebase image updated topython:3.12-slim -
Pipfilepython_versionupdated to"3.12" - All GitHub Actions workflows updated to
python-version: "3.12"
-
- Blog at root URL (
cloudista.org) — listing + post pages - Server-rendered post HTML via FastAPI (SEO-friendly)
- Client-side search
- Categories + tags + related posts
- Pagination
- Post revision history and restore
- Post hero images (Unsplash/Pexels via
populate_images.py) - WebP images with nginx content negotiation (fallback to original)
- Performance: non-blocking fonts, CLS/LCP fixes
- Open Graph + Twitter card meta tags
- Sitemap at
/sitemap.xml - RSS feed at
/feed.xml -
robots.txt
- Subscribe form with Cloudflare Turnstile CAPTCHA
- Rate limiting on
/api/subscribe(5/min per IP) - SES verification email (72-hour token expiry)
- Confirmation flow (
/api/confirm/{token}) - Unsubscribe link in every email (
/api/unsubscribe/{token}) - SES bounce/complaint webhook via SNS
- Verification email copy updated for live blog
- SES production access — granted (50k/day, 14/sec; carries over from account-level approval)
- New-post notification email — immediate and weekly digest modes; subscriber frequency preferences via one-time link (
/api/preferences/{token}); GHA workflows on cron
- Server-rendered post pages (crawlable HTML with title, description, canonical)
- Sitemap + RSS
- Per-post OG image — post pages use the generic
og-image.png; should use the post's hero image - Google Search Console — submit sitemap, verify indexing
- Plausible privacy-friendly analytics (all pages, no cookie banner required)
- Post view metrics — daily time-series in PostgreSQL; bot detection via User-Agent regex; geolocation via
CF-IPCountry(ISO 3166-1 alpha-2) - Prometheus counter
cloudista_post_views_totalwithslug/country/is_botlabels — feeds existing Grafana dashboards -
GET /api/posts/{slug}/stats— daily breakdown (90 days), top-20 countries, 7d/30d/all human vs bot aggregates (admin key required) -
GET /api/stats— top posts by views with period filter (7d/30d/all),include_botsflag (admin key required) - Analytics dashboard — admin UI showing top posts, country heatmap, bot/human split; currently API-only
- Referrer tracking — add
referrercolumn topost_views; surface top referrers per post
Goal: reach first revenue by month 3. Strategy: grow organic search traffic → build subscriber list → monetize via sponsorships and a paid tier.
- Google Search Console — submit sitemap, verify indexing, monitor impressions
- Per-post OG image — use post hero image for social shares (higher CTR)
- Consistent publishing cadence — 2–3 posts/week targeting long-tail DevOps/cloud keywords
- Keyword research — identify low-competition, high-intent terms (e.g. "kubectl debug cheatsheet", "terraform state locking fix")
- Internal linking pass — link related posts to each other to improve crawl depth and time-on-site
- Email list hygiene — weekly GHA cron purges
pendingsubscribers older than 30 days; dry-run mode viaworkflow_dispatch
- Sponsorship page — audience stats, rate card, contact form; target DevOps SaaS companies (Datadog, Doppler, Cloudflare, Pulumi, etc.)
- Carbon Ads or EthicalAds — low-friction developer-focused ad network; single slot in post sidebar/footer
- Affiliate links — DigitalOcean, Linode/Akamai, AWS (via Amazon Associates) referral links in relevant posts
- Subscriber milestone target: 100 confirmed — use as signal for first outbound sponsorship pitch
- First sponsored post or newsletter slot — flat-fee placement ($150–$500 for a technical blog at this stage)
- "Buy me a coffee" / GitHub Sponsors — low-friction one-time support for readers who find value
- Premium content experiment — one gated deep-dive (e.g. "Production Kubernetes on a Budget: Full Walkthrough") behind an email gate or $5–10 paywall via Gumroad/Lemon Squeezy
- Referral program — "Share with a colleague" CTA in digest emails with a tracked link
| Metric | Month 1 target | Month 3 target |
|---|---|---|
| Organic search impressions | Baseline | 5,000/mo |
| Confirmed subscribers | 50 | 200 |
| Monthly page views | Baseline | 2,000 |
| Revenue | $0 | First dollar |