The previous post explained why edgeblog runs entirely
on Fastly Compute with no origin server. This one is the step-by-step of
how — from fastly compute init to a live site at blog.alpagot.net.
I work on Fastly’s delivery platform for a living, so I had a head start on the concepts, but the specifics of building something substantial on Compute (as opposed to VCL) were new to me. The retrospective here might be useful if you’re thinking about doing the same. Or at least, that’s my hope!
The shape of the thing
The finished system is small enough that’s its fairly easy to grasp:
- A Rust crate that compiles to small, bundled into a Fastly Compute service.
- Markdown posts live in
content/posts/and are rendered to HTML at build time by abuild.rsscript, then embedded directly into the Wasm binary. - A tiny HTML/CSS/JS frontend that works with JavaScript disabled.
- A KV Store holding two boolean feature flags:
maintenance_modeandshow_drafts. - GitHub Actions: every push to
mainbuilds and publishes to Fastly. - That’s it. No database, no origin (GitHub is only a repo, not origin), no Docker, no VCL config.
The bit that surprised me
I’d assumed that going origin-less would be the hard part — that there’d be some awkward Compute-specific thing where you’d miss having a real server. There isn’t.
For a read-mostly site, the “origin” and the “edge” were always the same conceptual thing: render content, cache it, serve it. Fastly Compute just collapses them into the same artifact. You lose the two-tier architecture and, with it, the surface area that comes from maintaining two tiers.
What I didn’t expect: how much of the work disappears. There is no “oh, the origin went down” failure mode. There is no “oh, the TLS cert expired from origin” story (Certainly auto-renews). There is no “we need to scale the origin” problem. When the requirements for your thing match what an edge platform does well, the edge platform just… does it.
There is however a small caveat for when we come onto images later. We will need some sort of origin for that. Although not nessasarily. More on that in later articles and we’ll be keeping it all in Fastly!
Turn-by-turn
Turn 1 — the empty starter
fastly compute init gives you a working Rust Compute service in about
thirty seconds: a Cargo.toml, a fastly.toml, a main.rs that returns
“hello world”. I deployed that unchanged, which gave me a public URL
(enormously-lucky-lemur.edgecompute.app if curious) and — crucially — a real service
ID I could scope an API token against. Then I threw everything away and
replaced it with the router I actually wanted.
The router is very literally:
match path {
"" | "/" => render_home(),
"/feed.xml" => xml_response(FEED_XML, "application/atom+xml"),
"/sitemap.xml" => xml_response(SITEMAP_XML, "application/xml"),
"/assets/style.css" => asset_response(CSS, "text/css"),
"/robots.txt" => text_response(ROBOTS_TXT, "text/plain"),
"/healthz" => health_response(),
p if p.starts_with("/posts/") => render_post(&p["/posts/".len()..]),
_ => render_404(),
}
Rust’s match-with-guards handles prefix routing without reaching for a
framework. There is no Router::new().get(...) in this codebase. There
doesn’t need to be.
Security headers — CSP, HSTS, X-Content-Type-Options, Referrer-Policy,
Permissions-Policy — go on every HTML response. I set them up in Turn 1 so
I wouldn’t be tempted to leave that to “later”.
Turn 2 — the content pipeline
This is the part of the design I’m most pleased with. Posts are Markdown
files with TOML front-matter, living in content/posts/. They get processed
by build.rs, which runs on the host during cargo build, not inside the
Wasm sandbox.
+++
title = "How I built edgeblog"
date = "2026-04-25"
tags = ["fastly", "rust"]
draft = false
+++
(post body here in Markdown)
build.rs walks the directory, parses the front-matter, runs the body
through pulldown-cmark, and generates a Rust source file containing a
static POSTS: &[Post] array. That generated file is include!()-ed into
the main crate at compile time.
The runtime cost of serving a post is therefore:
POSTS.iter().find(|p| p.slug == slug)— a linear scan through a dozen entries living in the binary’s read-only section.- A
format!call to wrap the post body in a document template. - Send the response.
No filesystem I/O. No Markdown parser in the hot path. No regex state machines. Response time on a cache miss is a few hundred microseconds of Wasm execution; cache hits are served directly from the POP’s readthrough cache and never instantiate a sandbox at all.
Atom feed and sitemap are generated the same way, also at build time, also
embedded as &'static str.
Turn 3 — feature flags
Two flags, both boolean, both in a Fastly KV Store called edgeblog_flags:
maintenance_mode: whentrue, every route except/healthzand/assets/*returns a 503 with a maintenance page.show_drafts: whentrue, posts withdraft: truein their front-matter appear on the home page (with a “DRAFT” badge) and are accessible at their URL. Whenfalse, draft posts are not findable.
Config Store would have been the technically more natural choice here — Config Store is purpose-built for small configuration values that ship in-memory to every POP and serve sub-millisecond reads. Two booleans is exactly its sweet spot. I went with KV Store instead because the free tier is clearly documented on Fastly’s pricing page; Config Store’s free-tier status is less explicit, and for a personal blog I wanted the predictable-zero-cost option even if it cost me a few milliseconds of read latency. The propagation model is also worth understanding: KV Store has a write region (mine is eu-west) where writes land durably, and per-POP caches that get populated as keys are read. A flag flip propagates as POPs see traffic for the key. Config Store, by contrast, is in-memory at every POP from the moment a service version goes live — no propagation, no cache warming and thus is a premium (although included to a degree in starter packs).
The feed and sitemap always exclude drafts — those are public manifests that crawlers and feed readers consume, and there’s no legitimate reason to let a search engine index an unfinished post.
KV reads happen on every request. Fastly’s KV Store is regionally-cached at the POP level, so the cost of the lookup is a few hundred microseconds — on the same order of magnitude as the rest of the request handling. When not cached (accelerated) lolcally, a read from the durable store backing the KV stored would be measured in a few tens of miliseconds. I’m happy with this performance profile.
The fail-open semantics matter. If the KV lookup errors for any reason —
store not linked to the service, rate limit breach, network blip — the flag
reads as its default (false). A flaky control plane must never
accidentally put the site into maintenance mode.
Flipping a flag is one CLI command and no redeploy:
fastly kv-store-entry create \
--store-id=<store-id> \
--key=maintenance_mode \
--value=true
The change propagates to every POP within seconds (KV is eventually
consistent). Flipping it back is symmetric. This is the demo that sells
Compute to anyone who’s ever done a git push for a one-line config
change and sat through a 12-minute deploy pipeline.
Turn 4 — the real domain
Three things had to happen to retire the edgecompute.app URL:
- A TLS cert for
blog.alpagot.net. I already had a wildcard for*.alpagot.netthrough Fastly’s Certainly ACME CA, so this was free — the wildcard covers all subdomains I might want to stand up later. Fastly provides easy to use provisioning tools to do this with DNS verification. - The domain needed to be attached to the Compute service. Fastly’s newer
“versionless domain” API means this is a single command and doesn’t
require a service version bump:
I then associated the domain with the wildcard TLS configuration in the Fastly UI. There’s probably a CLI path for that too, but the UI was fine / simple.fastly domain create \ --service-id=SXOpETWAC1hmiiJ57GSNui \ --fqdn=blog.alpagot.net - A DNS CNAME pointing
blog.alpagot.netat Fastly’s routing target. In my case a personalisedalpagot.map.fastly.net— functionally the same as the default shared maps offered, just a named alias. Fastly support can set one up for you on a support request; it’s purely cosmetic. They can even do HTTPS records on the map so requests are initialised on HTTP/3.
CI/CD is two GitHub Actions workflows:
ci.ymlrunscargo fmt,clippy,check, and a release build on every push and PR, gated on the shared Cargo cache.deploy.ymlruns on every push tomain, installs the Fastly CLI, runsfastly compute publish, and then smoke-tests the deployed version by hitting/healthz,/, and/feed.xml.
The token for the deploy job is a service-scoped engineer-role API token, stored as a GitHub repository secret. It can only touch this one service; it cannot read any other service’s logs; it cannot create resources on the account. Minimum viable access for good security.
What I’d change if I started again
Two things.
First: I’d commit to implicit format argument captures from the start.
This is the Rust 1.58+ feature where format!("{name}") captures a local
called name from the enclosing scope. I mixed implicit and named arguments
across the codebase and it cost me one avoidable compile error when I wrote
{summary} in a template but the local was called summary_html. Pick one
style and stick with it.
Second: I’d use r##"..."## (two hashes) for every HTML raw string from
day one. The HTML attribute syntax href="#main" contains the sequence
"#, which prematurely terminates an r#"..."# raw string. I hit this
problem and fixed it; starting with two hashes would have avoided even
that.
Neither of these is a design flaw — both are Rust ergonomics I came to over the course of the project. I’m still learning rust and using AI a lot to build this.
What this actually cost
For a personal blog at blog.alpagot.net’s traffic levels:
- Compute requests: effectively free (first 10M/month on the new PAYG pricing).
- Compute vCPU: effectively free (requests under 20 ms of CPU don’t meter at all; edgeblog’s requests complete in well under 1 ms).
- KV Store: effectively free (first 250 000 Class A ops / 5 M Class B ops per month).
- TLS: free on Certainly (2-domain allowance covers the wildcard).
- DNS: whatever your DNS host charges. Mine is ~$1/month.
- Domain : whatever your registrar charges. You can use the free Fastly generated domain if you choose.
Pricing for Fastly and whats in the free tier can be found at: https://www.fastly.com/pricing#pricing
What’s next
I’ve got three follow-up posts planned:
- Adding Fastly’s Image Optimizer for responsive images and format negotiation without hand-rolling a pipeline.
- Turning on Next-Gen WAF with bot protection — mostly to demo the product, since a read-only blog has little for a WAF to do, but the tutorial is useful.
- Observability: log streaming, DDoS Protection, and a test suite that runs under Viceroy locally.
If you’ve read this far and want to build your own, the full source is at https://github.com/alpagot/edgeblog (MIT). Fork it, adapt it, tell me what you break.