Building a Serverless Blog with Astro, Cloudflare, and Zero Monthly Cost
The Goal
Build a technical blog that:
- Loads instantly (static where possible)
- Has a custom admin panel (no external CMS)
- Authors content in Markdown (the only sane choice)
- Supports paste-to-upload images with auto WebP compression
- Renders Mermaid diagrams for architecture docs
- Embeds YouTube videos for demos
- Costs nothing to run
- Deploys in seconds
Tech Stack
| Layer | Technology | Purpose |
|---|---|---|
| Framework | Astro 5 | Hybrid SSG/SSR |
| Hosting | Cloudflare Pages | Edge deployment |
| Database | Cloudflare D1 | SQLite at the edge |
| Storage | Cloudflare R2 | Image hosting |
| Styling | Vanilla CSS | No build overhead |
| Syntax Highlighting | Shiki + Prism.js | Code blocks |
| Diagrams | Mermaid.js | Architecture diagrams |
Total monthly cost: $0 (within free tier limits)
Architecture Overview
Hybrid Rendering Strategy
Astro's killer feature is hybrid rendering — choose SSG or SSR per route:
// astro.config.mjs
export default defineConfig({
output: 'server', // SSR by default
adapter: cloudflare({
platformProxy: { enabled: true }
})
});
Static Routes (Pre-rendered)
---
// src/pages/blog/[slug].astro
export const prerender = true; // Generate at build time
export async function getStaticPaths() {
const posts = await db.prepare(
'SELECT slug FROM posts WHERE status = ?'
).bind('published').all();
return posts.results.map(post => ({
params: { slug: post.slug }
}));
}
---
Dynamic Routes (SSR)
---
// src/pages/admin/posts/[id].astro
// No prerender = SSR by default
const { id } = Astro.params;
const post = await db.prepare(
'SELECT * FROM posts WHERE id = ?'
).bind(id).first();
---
Result: Blog posts are static HTML (fast), admin panel is dynamic (secure).
Database Schema
D1 is SQLite at the edge — simple, fast, and free:
CREATE TABLE posts (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
excerpt TEXT,
content TEXT NOT NULL,
cover_image TEXT,
status TEXT DEFAULT 'draft',
tags TEXT, -- JSON array
published_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE images (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
url TEXT NOT NULL,
size INTEGER,
mime_type TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_posts_status ON posts(status);
CREATE INDEX idx_posts_slug ON posts(slug);
Querying from Astro
// Access D1 via Cloudflare runtime
const runtime = Astro.locals.runtime;
const db = runtime.env.DB;
const posts = await db.prepare(`
SELECT * FROM posts
WHERE status = 'published'
ORDER BY published_at DESC
`).all();
Image Upload Flow
Paste-to-Upload with WebP Compression
The killer feature: paste an image from clipboard, it auto-compresses to WebP and uploads.
Here's a an image copy-pasted from the blog's PageSpeed Insights:

Console shows the compression stats:
Compressed: pasted-image-1771781692758.png (265.6KB) → pasted-image-1771781692758.webp (65.2KB)
// Client-side compression before upload
async function compressImage(file, maxWidth = 1600, quality = 0.85) {
if (!file.type.startsWith('image/') || file.type === 'image/gif') {
return file; // Skip GIFs to preserve animation
}
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxWidth / bitmap.width);
const width = Math.round(bitmap.width * scale);
const height = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0, width, height);
// Convert to WebP at 85% quality
const blob = await canvas.convertToBlob({
type: 'image/webp',
quality
});
return new File([blob], file.name.replace(/\.\w+$/, '.webp'), {
type: 'image/webp'
});
}
Handling Paste Events
// Listen for paste in the markdown editor
contentArea.addEventListener('paste', async (e) => {
const items = e.clipboardData.items;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
const compressed = await compressImage(file);
// Upload and insert markdown
const { url } = await uploadToR2(compressed);
insertAtCursor(``);
return;
}
}
});
Result: Screenshot → Paste → Auto-compressed WebP → Markdown inserted. No manual steps.
Upload Endpoint
// src/pages/admin/api/upload.ts
export const POST: APIRoute = async ({ request, locals }) => {
const bucket = locals.runtime.env.BLOG_ASSETS;
const formData = await request.formData();
const file = formData.get('file') as File;
// Generate unique filename
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 10);
const key = `blog/${timestamp}-${random}.webp`;
// Upload to R2
await bucket.put(key, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type }
});
return new Response(JSON.stringify({
url: `https://blog-assets.scopecreeplabs.com/${key}`
}));
};
The Admin Panel
A custom CMS built with Astro SSR:
Authentication
Simple password protection via environment variable:
// src/middleware.ts
export const onRequest = async ({ request, locals, redirect }, next) => {
if (request.url.includes('/admin')) {
const auth = request.headers.get('Authorization');
const expected = `Basic ${btoa(`admin:${locals.runtime.env.ADMIN_PASSWORD}`)}`;
if (auth !== expected) {
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="Admin"' }
});
}
}
return next();
};
Deploy Hook Architecture
When content changes, the static pages need rebuilding:
Rebuild Endpoint
// src/pages/admin/api/rebuild.ts
export const POST: APIRoute = async ({ locals }) => {
const hookUrl = locals.runtime.env.DEPLOY_HOOK_URL;
const response = await fetch(hookUrl, { method: 'POST' });
if (response.ok) {
return new Response(JSON.stringify({
success: true,
message: 'Rebuild triggered'
}));
}
return new Response(JSON.stringify({
error: 'Failed to trigger rebuild'
}), { status: 500 });
};
Markdown Processing
Build-time Syntax Highlighting
Astro uses Shiki for build-time highlighting — zero JS shipped to client:
// astro.config.mjs
export default defineConfig({
markdown: {
syntaxHighlight: 'shiki',
shikiConfig: {
theme: 'github-dark',
wrap: true
}
}
});
Runtime Preview Highlighting
For the live preview (before build), we use Prism.js:
// src/pages/admin/api/preview.ts
import { marked } from 'marked';
export const POST: APIRoute = async ({ request }) => {
const { content } = await request.json();
// Convert markdown to HTML
const html = await marked(content);
return new Response(JSON.stringify({ html }));
};
<!-- Client-side highlighting after content loads -->
<script>
if (window.Prism) {
Prism.highlightAllUnder(document.getElementById('preview-content'));
}
if (window.mermaid) {
mermaid.run({ nodes: document.querySelectorAll('.mermaid') });
}
</script>
Mermaid Diagrams
Diagrams render client-side from fenced code blocks:
```mermaid
flowchart LR
A[Start] --> B[Process] --> C[End]
```
// Initialize Mermaid with theme detection
document.addEventListener('DOMContentLoaded', () => {
mermaid.initialize({
startOnLoad: true,
theme: window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'default'
});
});
YouTube Embeds
Just paste a YouTube URL on its own line, and it auto-embeds:
Check out this video:
https://youtu.be/W3X0N3q3Ds0
More content below...
The markdown processor detects standalone YouTube URLs and converts them to responsive iframes:
// Post-process YouTube URLs wrapped in <a> tags (GFM autolinks)
html = html.replace(
/<p>\s*<a href="(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})(?:[?&][^"]*)?">.*?<\/a>\s*<\/p>/g,
`<div class="youtube-embed">
<iframe src="https://www.youtube-nocookie.com/embed/$1" ...></iframe>
</div>`
);
Key details:
- Uses
youtube-nocookie.comfor privacy-enhanced embeds - Handles both
youtu.be/IDandyoutube.com/watch?v=IDformats - Strips tracking parameters (like
?si=...) automatically - Inline links remain as links — only standalone URLs embed
/* Responsive 16:9 container */
.youtube-embed {
position: relative;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;
margin: 2rem 0;
border-radius: var(--radius);
}
.youtube-embed iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
Cloudflare Bindings
All services connected via wrangler.jsonc:
{
"name": "scopecreeplabs-site",
"compatibility_date": "2024-12-01",
"pages_build_output_dir": "./dist",
"d1_databases": [{
"binding": "DB",
"database_name": "scopecreeplabs-blog",
"database_id": "eb478fdb-..."
}],
"r2_buckets": [{
"binding": "IMAGES",
"bucket_name": "scopecreeplabs-images"
}],
"vars": {
"SITE_URL": "https://scopecreeplabs.com"
}
}
Performance Results
| Metric | Value |
|---|---|
| Blog post TTFB | ~50ms (edge cached) |
| Admin panel TTFB | ~100ms (SSR) |
| Lighthouse score | 100/100 |
| Build time | ~30 seconds |
| Deploy time | ~10 seconds |
Cost Breakdown
| Service | Free Tier | Our Usage |
|---|---|---|
| Pages | Unlimited static requests | ✓ |
| D1 | 5M reads/day, 100K writes/day | ~100/day |
| R2 | 10GB storage, 10M reads/month | ~50MB |
| Workers | 100K requests/day | ~50/day (SSR + API) |
Monthly bill: $0.00
Key Takeaways
- Hybrid rendering is powerful — Static for readers, dynamic for authors
- Cloudflare's free tier is generous — D1 + R2 + Pages covers most use cases
- Build your own CMS — It's simpler than adopting one, if your requirements are simple :-)
- Edge computing is fast — 50ms TTFB from anywhere in the world
- SQLite scales surprisingly well — D1 handles our queries effortlessly
What's Next
- RSS feed generation
- Draft preview links
- Scheduled publishing
This blog runs on ~500 lines of Astro code, costs nothing, and deploys in seconds.