Skip to content

morissette/cloudista

Repository files navigation

cloudista.org

Lint Test Deploy

Personal technical blog covering DevOps, platform engineering, Kubernetes, cloud infrastructure, and SRE. Live at cloudista.org.


Stack

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

Repo layout

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

Development

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


Common tasks

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 DB

Writing a post

  1. make new-post SLUG=my-post-title — creates blog/YYYY-MM-my-post-title.txt
  2. Write the post in Markdown after the ==== separator
  3. Add an Image: frontmatter field (required — see below)
  4. make import — imports into local PostgreSQL
  5. Open a PR against main — lint must pass before merge
  6. 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-title

Or trigger the Populate Post Images workflow from the Actions tab.


CI/CD

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 / lint and Test / test checks must pass before merge
  • Verified (signed) commits required

Environment variables

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


Productization roadmap

Infrastructure & platform

  • 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/health endpoint with DB status
  • Migrate subscribers from MySQL → PostgreSQL

CI/CD & quality

  • 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 --api vs --site)
  • Branch protection: PRs required, lint + test must pass, signed commits enforced
  • make dev local dev environment (Docker Postgres + uvicorn hot-reload)
  • Upgrade to Python 3.12
    • Dockerfile base image updated to python:3.12-slim
    • Pipfile python_version updated to "3.12"
    • All GitHub Actions workflows updated to python-version: "3.12"

Blog content & UX

  • 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

Subscriber / email

  • 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

SEO & discoverability

  • 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

Analytics

  • 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_total with slug/country/is_bot labels — 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_bots flag (admin key required)
  • Analytics dashboard — admin UI showing top posts, country heatmap, bot/human split; currently API-only
  • Referrer tracking — add referrer column to post_views; surface top referrers per post

Revenue roadmap (next 3 months)

Goal: reach first revenue by month 3. Strategy: grow organic search traffic → build subscriber list → monetize via sponsorships and a paid tier.

Month 1 — Audience foundation

  • 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 pending subscribers older than 30 days; dry-run mode via workflow_dispatch

Month 2 — Monetization groundwork

  • 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

Month 3 — First revenue

  • 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

Metrics to track

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

About

cloudista.org — static blog + FastAPI backend

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors