How this site is built: a portfolio on Netlify, a blog on Cloudflare, one address
Why the portfolio and the blog are separate sites on different hosts, how they share one domain, and how GitHub became the only CMS I need.
When I decided to start writing, I already had a portfolio running on Netlify. I didn’t want to rebuild it, and I didn’t want a CMS with a login page, a database and plugins to patch. I wanted to write in my editor, see the post before anyone else does, and publish by merging a pull request.
This post walks through how the site works now, and why I think it’s the right shape for what it does. It’s a small example of the same ideas I use for platforms at work: pick the simplest tool for each job, make the safe path the easy one, and keep each piece independent.
What it needed to do
- Keep the portfolio as it is. It works; a blog shouldn’t put it at risk.
- Make writing easy. Markdown with the option of diagrams and code, no admin panel.
- Preview before publishing, without the preview turning up in search results.
- Deploy only what changed. A new post shouldn’t rebuild the portfolio, and the reverse.
- Live on one address, so links and search rankings add up in one place.
- Be secure by default and cost nothing to run.
The shape
/blog/ to a Cloudflare Worker behind the scenes. Visitors never see the second host.The portfolio is a Next.js site on Netlify. Every page is prerendered at build time, so Netlify serves static files.
The blog is a separate Astro site, deployed as a Cloudflare Worker with static assets. There is no Worker script and no server code, just files.
They share one address. Netlify rewrites anything under /blog/ to the Worker with a 200 status, which means Netlify fetches the page from Cloudflare and returns it as if it were its own:
# netlify.toml
[[redirects]]
from = "/blog/*"
to = "https://satnam-portfolio.bsatnam98.workers.dev/blog/:splat"
status = 200
force = true
The address bar stays on satnam.is-a.dev/blog/, and the blog’s own security headers come through the rewrite unchanged.
Why two tools instead of one
Next.js is a good fit for the portfolio’s interactive pages. For long-form writing, Astro fits better:
- Content collections with a schema. Every post’s front matter is validated when the site builds, so a missing description or a badly formatted date fails the build instead of publishing a broken page.
- MDX. Posts are Markdown, and they can import components when they need more. The animated diagrams in these posts are Astro components written in SVG and CSS.
- No client JavaScript by default. The blog ships HTML and CSS only. That keeps pages fast and lets the Content Security Policy say
script-src 'self'with no exceptions.
Two sites also means two blast radii. A mistake in a blog post can’t break the portfolio, and a portfolio dependency upgrade can’t break the blog.
Why Cloudflare for the blog
On Cloudflare’s free plan, requests for static assets are free and unlimited, and Workers Builds includes 3,000 build minutes a month. Each branch gets its own preview URL. _headers and _redirects files work the way they do on most static hosts, so security headers and redirects live in the repository next to the code:
# blog/site-root/_headers
/*
Strict-Transport-Security: max-age=63072000; includeSubDomains
X-Frame-Options: DENY
Content-Security-Policy: default-src 'self'; script-src 'self'; ...
One address without owning a DNS zone
The obvious way to give a Worker its own address is a Cloudflare custom domain, but that needs the domain’s DNS zone on your Cloudflare account. My domain is a subdomain of is-a.dev, a free service that manages the DNS for you. So the domain stays where it is and Netlify does the routing.
A few details make that work cleanly:
- The blog is built under
/blog. Astro builds withbase: '/blog'intodist/blog/, so every page and asset has the same path on the Worker, on preview URLs and on the public site. There’s no path rewriting to get wrong. - One canonical address. Canonical URLs, the sitemap and the RSS feed all point at
satnam.is-a.dev/blog/. The Worker’s own address redirects to/blog/and itsrobots.txtblocks crawling, so search engines only index the public address. - The portfolio’s
robots.txtlists the blog’s sitemap, so search engines find both sites from one place.
GitHub is the CMS
Writing a post looks like writing code:
cd blog
npm run new -- "How this site is built" # creates a draft MDX file
npm run dev # local preview with drafts visible
The front matter is the whole interface:
---
title: "How this site is built"
description: "One or two sentences for lists, search results and link previews."
pubDate: 2026-10-30
tags: ["platform-engineering", "cloudflare"]
draft: true
---
Then:
- Open a pull request. Cloudflare builds the branch and posts a preview URL. Drafts are visible there, and every preview page carries
noindex, because preview URLs are public. - Review it. The diff, the preview and the build checks are all on the pull request.
- Set
draft: falseand merge. Cloudflare deploys the blog. Netlify doesn’t rebuild.
There’s no database to back up, no login to protect and no plugins to update. Version history, review and rollback come from Git.
Each host only builds what it serves
ignore command skips pushes that only touch blog/ or docs/; Cloudflare's build watch path only reacts to blog/*.Two small pieces of configuration keep the deploys independent. Netlify skips any build where nothing outside blog/ and docs/ changed:
# netlify.toml
ignore = "if [ -z \"$CACHED_COMMIT_REF\" ]; then exit 1; fi; git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- . ':(exclude)blog' ':(exclude)docs'"
The ignore command exits 0 to skip a build and 1 to build. On the first build there’s nothing to compare against, so it always builds.
On Cloudflare, the Worker’s build watch path is blog/* and its root directory is blog, so it only builds when the blog changes, and only builds the blog.
Secure by default
- Nothing to attack at runtime. Both sites are static files. There’s no server code, no database and no admin login.
- Strict headers. The blog’s CSP allows only its own origin and no inline scripts. HSTS, frame denial and the other headers are set in files in the repository, so they are reviewed like code.
- Least privilege for automation. The Cloudflare GitHub app only has access to this one repository, and the build needs no secrets.
- Self-hosted fonts. No third-party requests, so nothing to add to the CSP.
What went wrong on the way
It didn’t all work first time, and the problems were the instructive part.
- The build ran in the wrong folder. Preview builds for branches have their own settings in Cloudflare. Without a root directory they ran from the repository root, built the portfolio instead of the blog, and then couldn’t find the Worker’s config. Production was fine, which made it confusing.
- A redirect that pointed at itself. I added a Netlify rule to send
/blogto/blog/. Netlify matches rules without caring about the trailing slash, so/blog/matched it too and redirected to itself. Posts worked; the blog’s home page didn’t. The fix was to serve the index through the same rewrite instead of redirecting it, tested on the pull request’s deploy preview before merging.
The common thread: small build and routing settings fail in ways that are hard to see. Test them on a preview before they reach production, and keep as much as possible in files in the repository, where it’s reviewed and versioned like code.
Why it fits
| Need | How it’s met |
|---|---|
| Keep the portfolio safe | Separate site, separate host, separate build |
| Easy writing | MDX in Git, a script to start a post, schema-checked front matter |
| Preview first | A preview URL per branch, drafts visible, noindex on every preview |
| Deploy only what changed | Netlify ignore command and Cloudflare build watch paths |
| One address | Netlify rewrite to the Worker, everything built under /blog |
| Secure | Static only, strict CSP and HSTS, no secrets, scoped GitHub app |
| Cost | Free plans on both hosts |
Next on the list are social preview images for each post and a search page. Both fit the same shape: generated at build time, served as static files.