In my previous post, "From Localhost to the Cloud: How I Hosted My Python Server Using Cloudflare", I shared how I set up my personal site running on bare metal right in my own home. I wrote a custom Python server from scratch, pointed a Cloudflare Tunnel at it, put Cloudflare Access in front, and felt like a wizard. It was an incredible learning experience, and it served me well.

I have officially outgrown it.

Running your own stack at home is fun until you actually want to use the site without performing maintenance chores. Over the past few weeks, I built a complete replacement designed to take my home hardware out of the loop and move the site to Cloudflare Workers.

To be transparent about where this project stands right now: the new site is built and deployed in a locked test environment, but production hasn't been cut over yet. Consider this part one of the migration.

The Reality of the "One-File" Python Server

My original setup was built around simplicity, or at least what I thought was simplicity at the time. It consisted of hand-written HTML, CSS, and vanilla JavaScript powered by a single-file Python script relying purely on standard library modules like http.server. It ran on a dedicated Windows box on my home network.

Over time, that single Python file took on way too many responsibilities:

  • It scraped the raw HTML of posts on the fly to generate the blog index and XML sitemap.
  • It hosted a homemade admin panel with custom session management, an image uploader, and an editor for my site's toolbox section.
  • It handled an encrypted contact-card mechanism.
  • It even automatically made git commits in the background whenever a post was published.

Because the server ran on Windows, it gradually accumulated defensive boilerplate code to handle path traversal risks, Windows-specific filename restrictions, request timeouts, and payload limits.

Worst of all was the deployment workflow. For a long time, "pushed" did not mean "deployed". Updating the site meant pushing commits to GitHub, walking over or remoting into the server, pulling the repo, and restarting the service. I once spent an embarrassing amount of time troubleshooting why a new CSS tweak refused to show up in my browser, restarting the daemon repeatedly, only to realize I had never pulled the changes onto the machine.

Beyond the backend, the frontend had its own friction. Every page carried its own duplicate copy of the navigation header, masked by a fragile piece of custom JavaScript that hijacked link clicks to swap out the document body and simulate single-page app transitions. Posts were saved as raw HTML with the original Markdown text converted into an invisible base64 string at the bottom of the file so the editor could parse it again later. It worked, but keeping it running felt like balancing plates on sticks.

Redefining the Architecture

Before writing any new code, I set down a few hard requirements:

  • Zero physical machines to babysit at home.
  • Automatic branch-based deployments: pushing code should immediately update the site.
  • A strict separation between a private test/development environment and the live production site.
  • A clean in-browser writing experience with support for rich formatting and easy image placement.
  • Keeping the primary GitHub repository private.

My initial impulse was classic over-engineering: "Should I just rewrite the entire backend in Rust?"

I sat with that idea for a minute, but the answer was an emphatic no. Compiling Rust to WebAssembly to run on an edge network, setting up toolchains, and handling bindings just to serve static content and small handlers would have swapped one set of maintenance headaches for a much shinier, heavier set.

Instead, I chose Astro. Astro handles content sites brilliantly out of the box. Moving to Astro meant accepting npm into a project that had historically avoided package dependencies, but the benefits paid off immediately. The duplicate navigation was replaced by a single layout component. The brittle JavaScript page-swap script was replaced by Astro's native view transitions. Sitemaps, feeds, and clean URLs are now generated automatically at build time.

For hosting, I originally planned to use Cloudflare Pages, but modern Astro tooling targets Cloudflare Workers with static assets, and Cloudflare's dashboard now notes Pages as a legacy path. Workers on the free tier provides more than enough headroom for what I need.

For the content management side, I installed Keystatic. Keystatic is a free, git-based CMS that lives directly inside Astro at /keystatic. It offers a clean WYSIWYG editor complete with toolbars, image uploads, tables, and drafting toggles. Instead of writing to a SQL database or keeping hidden base64 blobs, it commits clean Markdown files directly back to my GitHub repository, which immediately triggers a new site build. It also uses GitHub authentication, letting me delete my homemade login system entirely.

I also decided to drop the encrypted contact-card feature altogether. Trimming that single piece of functionality eliminated the need for external key-value stores or third-party databases, letting the entire site rely strictly on git.

Migrating the Content and Tackling Gotchas

Rebuilding the frontend meant bringing over my dark red and black color scheme into Astro's component structure, recreating the portfolio view, the toolbox list, the blog index, and a placeholder for game server dashboards.

Old links were protected with redirects: legacy paths like /blog/posts/example.html forward to modern clean URLs, and visits to the old /admin path point to /keystatic. Out of my four existing blog posts, the one with the hidden Markdown blob converted cleanly back into native source text, while the others were converted over from their rendered HTML.

Naturally, combining edge runtimes, modern package managers, and a git-based CMS came with a few hurdles:

  • Package script blocks: Newer versions of npm block third-party install scripts by default. Essential packages like esbuild and Cloudflare's local runtime workerd stalled until I explicitly whitelisted them.
  • Pre-rendering inside the edge runtime: Astro's Cloudflare adapter evaluates pages inside the Cloudflare runtime during build time, which cannot read build-phase environment variables. My check to differentiate production from preview builds silently broke until I refactored the logic to inspect the configured site URL instead.
  • Keystatic's local storage mode: When developing on your local machine, Keystatic expects to read and write directly to your local file system. Because Cloudflare's runtime sandboxes local file operations, running the editor locally failed until I configured Vite to run the dev server under native Node.js, reserving the Cloudflare adapter strictly for production build steps.
  • Branch routing: By default, Keystatic targets the default repository branch (main). I wrote a lightweight middleware snippet that checks the environment and forces the editor on the test deployment to commit strictly to the dev branch, ensuring staging tests never accidentally touch the live site.

The Real Challenge: Dashboard Navigation

Writing code is usually the predictable part; configuring cloud dashboards and API tokens is where the real puzzle lives.

Setting up GitHub authentication for Keystatic required creating a GitHub App. GitHub recently changed terminology across their developer settings (using "Redirect URI" rather than "Callback URL"), and generating the client secret sent me searching across multiple menus until a full hard refresh finally brought up the correct pane. GitHub also insisted on generating a downloadable private key before letting me install the app, even though Keystatic only requires OAuth client credentials.

Connecting Cloudflare to my private repository brought another pause. When I opened the Cloudflare dashboard, the repository wasn't listed. The issue wasn't the private status of the repository; Cloudflare's GitHub application simply needed manual organization permissions to see the new repo. Once approved, the build pipeline linked up cleanly.

Finally, I secured the staging pipeline:

  • The dev branch deploys to a dedicated staging subdomain.
  • A Cloudflare Access application sits in front of the entire test domain, requiring an email-based one-time PIN before letting anyone access the site.
  • Search engines are explicitly blocked from indexing the staging build.

What's Left for Part 2

Right now, the heavy lifting is complete. The site runs smoothly inside a secure sandbox, builds pass in under 60 seconds, and the test editor correctly pushes commits to the dev branch.

Before decommissioning my home server, a few checklist items remain:

  1. Run a complete end-to-end publishing test in staging: write a post in the editor, drop in inline media, save, and confirm that the automated commit and rebuild succeed.
  2. Merge dev into main and wire up the production Worker.
  3. Repoint the root domain records away from the homelab tunnel and over to Cloudflare Workers.
  4. Shut down the Windows server process and close down the home tunnel permanently.

Looking back at this phase of the migration, the biggest lesson has been knowing what to delete. Stripping away bespoke server loops, dropping redundant database ambitions, and letting Git act as the single source of truth made the stack substantially easier to maintain.

If your current deployment routine relies on remembering to pull files or restart services on another computer, look for ways to automate the chore away.

Have you ever migrated a homelab service to a fully managed edge platform, or do you prefer keeping everything running on your own physical hardware?