From Zero to Live: A Complete Deployment Log for a Personal Website

This is a complete deployment log for a personal website: I built a trilingual (Chinese/English/Japanese) blog at zero cost using the fully managed stack of Astro + Supabase + Cloudflare Workers. The article has two halves — the first is a from-scratch deployment walkthrough you can follow to get an identical site; the second is nine real pitfalls, each with its cause and fix. The core selling point: zero ops, publish-and-it's-live, ¥0 a month.

From Zero to Live: A Complete Deployment Log for a Personal Website

A Word Before We Start

This article is the site's "confession" — a complete record of how it grew from lines of code on a local machine into the pages you are reading right now. It comes in two halves: the first is a complete, from-scratch deployment walkthrough (follow along and you will end up with an identical site); the second is an honest log of the pitfalls I hit (I stumbled into every one of them, and I wrote down both the cause and the fix).

Who it's for: anyone who wants to build a personal blog at zero cost; or my future self — so that when I inevitably decide to tear everything down and start over, I won't have to figure it out from nothing again.

At a Glance: What Kind of Site Is This

First, a look at the finished product, so you have the overall picture:

Item Current State
Framework Astro 4 (SSR mode)
Database Supabase (Postgres — posts, comments, categories, and site config all live here)
Hosting Cloudflare Workers (free tier: 100,000 requests/day, unlimited bandwidth, 3,000 build minutes/month)
Domain Self-purchased domain, DNS hosted on Cloudflare, HTTPS issued automatically
Languages Chinese / English / 日本語, all from a single source
Writing backend A built-in /admin panel — write and publish straight from the browser
File management Uploaded images and attachments managed in one place; deleting a post cleans up its files automatically, and orphaned files are one click away from cleanup
Excerpts Generated from the body automatically (local rule-based fallback, or AI on demand), powering list cards, search, RSS, and share cards
Translation One click in the backend turns a post into English/Japanese (via Zhipu GLM, free tier), with structure-aware chunking and concurrent translation for long articles
Monthly cost ¥0 (the domain is the only historical expense; everything else runs on free tiers)

Here is how it works, in one sentence: readers always hit Cloudflare, and Cloudflare queries Supabase in real time on every single request to return your content. So the instant you hit "publish" in the backend, the post is live — no build to wait for, no redeploy needed. That is the part of the whole design I am most pleased with.

Reader's browser ──► Cloudflare Worker (SSR rendering + static assets)
                     │ live query on every request
                     ▼
              Supabase (posts / comments / categories / config)
                     ▲
/admin backend ───────┘  (browser talks to Supabase directly; publishing takes effect immediately)

Comparing Approaches: Why This Stack, and Where It Falls Short

You shouldn't choose a stack by its upsides alone. Put the mainstream options side by side and compare them honestly, and only then can you judge whether this one suits you.

Side-by-Side with Mainstream Options

Dimension This stack (Astro + Supabase + CF Workers) WordPress Next.js + Vercel Hexo / Hugo (pure static)
Server ops None, fully managed Required (or buy managed hosting) None None (but publishing needs a local build)
Publishing flow Publish straight from the browser backend, effective immediately Publish from the backend Same Write locally → build → push
Online writing ✅ Built-in backend ✅ Needs a separate CMS ❌
Database Supabase (Postgres, full SQL) MySQL Whatever (often an external CMS) None (files are the content)
Free tier CF 100k requests/day + Supabase free tier None (needs paid hosting) Available (commercial use limited) Static hosting is practically unlimited
Access from China CF edge is slow-ish but usable Depends on the host Equally unstable Can sit behind a China CDN — fastest
Ecosystem / docs Smaller Astro ecosystem, few Chinese resources Largest; plugins and themes galore Largest Next.js ecosystem Mature static-generation ecosystem
Data ownership On Supabase, exportable anytime In your own database Depends on what you connect It's just local files

Where This Stack Genuinely Wins

  • Zero cost and zero ops: no server to patch, no database backup scripts, certificates renew themselves. The biggest hidden cost of a personal site is really "the fear that it will break" — this stack zeroes that out too.
  • Publish and it's live: SSR queries on every request, so the moment you hit publish, readers can see it. The "build locally → push → wait for deploy" chain of pure-static setups simply doesn't exist here.
  • A built-in backend plus full database power: posts, comments, a category registry, and site config all live in Postgres, with RLS drawing a crystal-clear line around who can read what — and you can write SQL directly for stats. File-based setups can't offer that.
  • Single-source trilingual content and built-in SEO: RSS, sitemap, canonical, and hreflang all come from the architecture itself, not from stitched-together plugins.

The Downsides You Should Be Honest About

  • A smaller Astro ecosystem than Next.js: when you hit an obscure problem, there are limited Chinese-language solutions to search for; more often than not you'll be reading English docs or asking an AI directly.
  • Cloudflare's edge from China is "usable but slow-ish": that's simply the reality of CF's mainland network, and readers will feel a notch worse than with Vercel/Netlify or a China CDN. I position this site as "a record for myself," so I never chased top speed in China; if you're building a content site for a Chinese audience, weigh this carefully.
  • Publishing from the backend depends on your network: the backend page talks to Supabase directly from the browser, so on an unstable Chinese connection, login and saves may spin. Switch to a smoother network — readers notice nothing.
  • Supabase free projects pause after 7 days of inactivity: you'll need to open the dashboard and wake it up. No impact on a weekly blog, but "annual updaters" should keep it in mind.
  • No page-level caching by default: the price of zero-latency publishing is that every request renders live (luckily, SSR is usually far below the 10ms ceiling). Add s-maxage yourself when you really need caching — not necessary for a personal site today.
  • Long-tail risk: if the free-tier policies (Cloudflare / Supabase / Zhipu) ever change, migration or payment may be required — a shared risk of every "living off free tiers" approach. Go in with that expectation.

In one sentence: if you want a personal website that is yours, that you can keep writing on for years, and that doesn't make you tinker — this stack fits well; if you're after fast access from China or a rich plugin ecosystem, WordPress or a static setup may be less trouble.

A Short History of My Sites (Why This Is the Third One)

This is not the first personal website I have owned.

In 2018, I encountered the open-source world for the first time on GitHub, followed a blog project's README to reproduce it step by step locally, and the moment it ran successfully, a door to a new world opened. Later I bought my own domain and server on Alibaba Cloud, made the most of free cloud storage and CDN, and deployed the site. Regulatory requirements meant the site could only go properly live after I completed both the MIIT ICP filing and the public security network filing. For those years I wrote about life beyond code — until, five years later, cost forced me to shut the site down and cancel the filing.

In October 2026, AI programming had matured to the point where an ordinary person could build a system through conversation alone. I decided to build another one — but this time I changed my approach entirely: the traditional WordPress and Hexo routes simply didn't fit my needs (one demands server operations, the other a local build on every post). I settled on a "fully managed, zero-server" combination: code on GitHub, hosting on Cloudflare, data in Supabase, static pages up front plus a backend admin page that publishes directly. For a personal site without much traffic, the free tiers of these services are more than enough.

And so you have the site you're reading now. Let's get into it.

Step 1: Preparation

Account Checklist

Service Purpose Cost
GitHub Code repository, deployment trigger Free
Cloudflare Worker hosting, DNS, HTTPS Free
Supabase Database + authentication Free
Zhipu AI Open Platform Translation API (optional) Free at the model level for GLM-4.7-Flash
A domain Your address on the web No new cost if you already own one

Local Environment

  • Node.js 22 (the repo pins it with .nvmrc, so nvm use is all you need)
  • git, npm
  • A network environment that can reach GitHub and Supabase smoothly (I'll explain why later)

Step 2: Set Up the Database (Supabase)

  1. Sign up for Supabase and create a project with New project. Pick any region (distance doesn't matter much for a personal site), and remember your database password.

  2. Once the project is ready, go to the SQL Editor and run the five files in the repo's supabase/ directory in order, one at a time (all of them are re-runnable, so an "already exists" error is normal):

    • schema.sql: the core. Creates four tables — posts, comments, site_settings, and category_aliases (for 301 redirects of old category links) — sets up the RLS row-level security policies, the three comment-related RPC functions, and a publicly readable images bucket.
    • categories.sql: creates the category registry. The registry is the authoritative source for categories, supporting add/rename/merge/delete, and old links 301 automatically after a rename instead of 404ing.
    • posts_author.sql: adds an author column to the posts table. Don't skip this one, or publishing from the backend will throw an error.
    • posts_trash.sql: the post trash bin (soft delete). Deleting a post now moves it to the trash, where it's kept for 5 days before being purged; this also includes the front-end RLS filter and the scheduled cleanup function. Don't skip it, or deletion won't work.
    • media_library.sql: file management (media library). Creates the media table that records each file's ownership by post, adds an attachments bucket (supporting pdf/word/ppt/excel and more), and an "orphaned files" detection view. Don't skip it, or the backend "File Management" module won't work.

    The difference between three SQL files and five is the features added after this article first went live — follow along today and you get the complete version.

  3. Go to Project Settings → API Keys and copy two things you'll need shortly (this is where the newer Supabase console keeps them):

    • Project URL: something like https://xxxx.supabase.co
    • anon public key: a long key starting with eyJ...
  4. Create an admin account: Authentication → Users → Add user, enter your email and password, and check the auto-confirm box. This is your login credential for the /admin backend — the site has no separate registration system of its own, and using Supabase's account system directly is all a one-person site needs.

On the security of the anon key: it is designed to be public and is shipped to the front end. The real security boundary is RLS — readers can only read "published posts" and "approved comments," and write operations are strictly validated at the policy layer (nickname/email/content length and format). Sensitive operations (such as the translation API key) live in a Cloudflare Secret and are never sent to the browser.

Step 3: Run the Site Locally

git clone https://github.com/<your-username>/<repo-name>.git
cd <repo-name>
nvm use          # or make sure you're on Node 22
npm install

Copy .env.example to .env and fill in the two values from Step 2:

PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
PUBLIC_SUPABASE_ANON_KEY=eyJ...your anon key
# Public site URL (affects sitemap / RSS / canonical); can be left empty during local development
PUBLIC_SITE_URL=

Then:

npm run dev      # http://localhost:4321

It runs even without a .env — the site falls back to the built-in trilingual sample posts so you can see it working first. Fill in .env when you want to verify the full "connected to a real database" experience.

To preview the production build output locally, use npm run build && npx wrangler dev (simulating the Workers runtime locally, identical to production; wrangler reads the repo's wrangler.toml). Note that you should not use wrangler pages dev — this project runs on Workers, not Pages, and the command and output don't line up (see the pitfall in Step 5).

Step 4: Push to GitHub

git add .
git commit -m "init"
git remote add origin https://github.com/<your-username>/<repo-name>.git
git push -u origin main

Every "change the code" flow afterwards is the same: edit locally → commit → push. The push itself is the deployment trigger.

Step 5: Deploy to Cloudflare Workers (the Core Step)

⚠️ One big pitfall up front: when creating the project, choose Workers, not Pages. This site is an SSR app (it relies on the Worker runtime produced by the build), so it must go through Workers. Choose wrong and you'll hit Workers-specific command in a Pages project later, forcing you to delete and start over.

  1. Cloudflare Dashboard → Workers & Pages → Create → Connect to Git, authorize GitHub, pick the repo, and set the Production branch to main.
  2. Build settings (choosing the Astro framework preset usually fills these in):
    • Build command: npm run build
    • Build output directory: dist
    • Deploy command: npx wrangler deploy ← driven by the wrangler.toml in the repo root; don't change it
  3. Go to Settings → Builds → Build environment variables (note: this is the build variables section, not the Runtime variables section) and add four:
    Variable Value Notes
    PUBLIC_SUPABASE_URL https://xxxx.supabase.co Required
    PUBLIC_SUPABASE_ANON_KEY eyJ... Required
    PUBLIC_SITE_URL Start with https://<project-name>.workers.dev Recommended; change after binding your domain
    NODE_VERSION 22 Belt-and-braces with .nvmrc

    These three PUBLIC_* variables are inlined at build time — their values are baked into the output the moment npm run build runs. So if you change them while setting up, you must redeploy for the change to take effect.

  4. Save and Deploy, wait 1–3 minutes, and you'll get https://<project-name>.workers.dev.
  5. From here on: only code changes need a push. Publishing a post triggers no build at all.

wrangler.toml already ships in the repo, and its entire contents are just this — nothing to configure by hand:

name = "mystyle"
main = "dist/_worker.js/index.js"
compatibility_date = "2025-07-18"
compatibility_flags = ["nodejs_compat"]

[assets]
directory = "./dist"

Note that nodejs_compat is the lifeline of SSR — without it, any Node built-in call inside the Worker will 500 outright.

Step 6: Bind a Custom Domain

I bought my domain years ago on Alibaba Cloud (a .cn one). Here's the onboarding flow:

  1. Host the domain on Cloudflare (the new path after the 2025 console redesign): in the Dashboard, go to Domains → Onboard a domain on the left → enter your apex domain → Continue. Choose the Free plan.
  2. Cloudflare gives you two nameservers (xxx.ns.cloudflare.com). At your domain registrar, change the domain's DNS servers (NS) to these two addresses — every registrar has this entry, usually under a menu like "Domain Management → DNS Settings / Nameservers".
  3. Wait for the domain status in the CF panel to become Active (usually a few minutes to a few hours, depending on NS propagation).
  4. In the Workers project → Settings → Triggers → Custom Domains → Add, and enter your domain. Since DNS is already on CF, the record is configured automatically and the HTTPS certificate is issued automatically — all free.
  5. Don't forget the last step: change PUBLIC_SITE_URL in the build environment variables to https://your-domain, then Retry deployment (Deployments page → the latest deployment → ··· → Retry). Only then will the domain in sitemap, RSS, and canonical be fully correct. Post content is unaffected.

Step 7: Log Into the Backend and Publish Your First Post

Open <your-domain>/admin in a browser and log in with the Supabase account you created in Step 2. There are four modules in the backend:

  • Posts: write a title, pick a language (Chinese/English/Japanese), a category, and the body (Markdown with live preview), then hit "Publish and go live." Publishing is immediate — refresh the front end and your post is already waiting. Under "More settings" in the editor you can also adjust the publish time (which flows through to the front-end display, sorting, and RSS), the slug, the cover image, and the excerpt (leave it blank and one is generated from the body at publish time). Deleting a post is a soft delete — it moves to the trash, is kept for 5 days, then purged automatically, and can be restored at any point in between.
  • Files: manage the images and attachments (pdf/word/ppt/excel, etc.) used in your posts in one place. Deleting a post also cleans up the files it used; "orphaned files" that no post references can be cleaned up with one click, so you never have to worry about storage piling up.
  • Comments: moderate, reply to (admin replies get a special badge and are displayed nested), and delete.
  • Other features: the homepage countdown, the excerpt auto-generation settings (length cap and style), and category management (renames 301 automatically).

A quick word on how comments are designed: the comments table collects email addresses but never shows them publicly — only the site owner can see them in the backend. Anonymous commenters receive a deletion token that lets them manage their own comments — no one needs to register.

Step 8: Optional Enhancements

One-click translation: write in Chinese in the backend editor, hit "Translate," and you get an English/Japanese version. It uses the OpenAI-compatible protocol and currently points at Zhipu GLM-4.7-Flash (free at the model level, forever, so no anxiety about personal usage limits). Switching to any compatible service (DeepSeek, Moonshot, Gemini) means changing just two variables, with zero code changes:

Variable Location
TRANSLATE_BASE_URL / TRANSLATE_MODEL [vars] in wrangler.toml
TRANSLATE_API_KEY Cloudflare dashboard → Settings → Variables and Secrets → Add (Type: Secret)

There's a specific art to translating long articles: chunking has to be structure-aware. Early versions cut by line count, and a 12-column table ended up sliced down the middle — reassembled, one table became four, and the translation was unreadable. It now recognizes structure before packing: code blocks go in and out whole, tables go in and out whole (oversized tables are split by row, with the header repeated in every piece so each piece is itself a valid table), and overlong paragraphs are only cut at sentence boundaries. Chunks are translated with 2-way concurrency (with a 1.5-second gap between chunks to avoid rate limits), and the tail of the previous chunk's source text is sent along as "preceding context," which keeps cross-chunk references intact and terminology from drifting. Failures still use exponential backoff.

This change fixed a real failure: a nearly 12,000-character article translated serially took about 233 seconds, while the front end's wait limit was 200 seconds — a guaranteed timeout. With concurrency it drops to about 140 seconds, inside the window.

AI excerpts: a post's excerpt drives five places — list cards, the lead paragraph on the post page, search results, RSS, and social share cards. Leave the excerpt field blank while writing and, at publish time, one is auto-excerpted from the body (skipping headings, code blocks, and tables, taking only narrative prose, stopping at a sentence boundary and appending an ellipsis); or click "✨ AI generate" in the editor to have the model write one. The length cap and style (declarative / bullet-point / hook) are configurable under "Other features" in the backend, defaulting to 200 characters. If the AI is unavailable, it automatically falls back to local excerpting and clearly tells you in the UI that this is a degraded result — it never silently inserts something that looks like AI output.

Trilingual content and SEO: a separate RSS feed per language, a site-wide sitemap, canonical, and hreflang are all built in. When a post is missing a translation, the front end shows a "no translation available" banner and keeps the original — no blank pages.

Social share cards: the thumbnail and description shown when sharing to WeChat/Weibo/Facebook go through the Open Graph protocol. og:image uses the post cover first, falling back to a site default when no cover is set; the description comes from the post excerpt. So leaving the excerpt blank affects more than the site itself — it also leaves the share card without a description — which is part of why that auto-generation exists.

Pitfall Log (the Most Valuable Part of This Article)

Every item below is one I actually hit, recorded as "symptom → cause → fix."

1. Chose Pages when creating the project, and deployment failed immediately
Symptom: Workers-specific command in a Pages project.
Cause: this site is SSR (its output includes the Worker runtime), and the deploy command is npx wrangler deploy, which is the Workers way to deploy; a Pages project's build model is incompatible with it.
Fix: choose Workers → Connect to Git when creating, not Pages.

2. The whole site 500'd, and the logs mentioned a Node built-in module
Cause: the nodejs_compat compatibility flag was missing, so the Node capabilities SSR relies on weren't available in the Workers runtime.
Fix: include compatibility_flags = ["nodejs_compat"] in wrangler.toml (already in the repo).

3. The sitemap / RSS had the wrong domain
Symptom: the output contained the your-domain.com placeholder, or the live canonical pointed to example.com.
Cause: PUBLIC_SITE_URL wasn't set; and there's a subtler layer — Astro does not inject .env into the process.env available when astro.config.mjs loads; only import.meta.env can see it. So reading process.env.PUBLIC_SITE_URL directly inside the config file silently gets an empty value and falls back to the default site address.
Fix: in astro.config.mjs, use Vite's loadEnv to read file-based env vars explicitly, while also honoring the build variables that come in through process.env in CI — covering both paths; then Retry deployment after changing the variable.

4. Publishing from the backend threw an error (missing author column)
Cause: the posts table didn't have an author column early on, and the publish logic requires it when writing to the database.
Fix: run supabase/posts_author.sql to add the column. This is why not one of the SQL files can be skipped.

5. "Mysterious rollback" after switching translation services
Symptom: I switched the translation service from Gemini to Zhipu GLM in the Cloudflare dashboard, and after a while it reverted to Gemini.
Cause: the deploy command is npx wrangler deploy, and wrangler.toml's [vars] are pushed live with every deploy — so ordinary variables changed in the dashboard can be overwritten by the values in the repo (Secret-type API keys are unaffected, which produces the odd combination of "model changed, key didn't").
Fix: keep the repo's wrangler.toml and the dashboard variables in sync. I ended up changing the toml to the Zhipu values too, aligning both sides so neither can silently overwrite the other.

6. Accessing the backend from China means "depending on your network's mood"
Cause: the backend page talks to Supabase (*.supabase.co) directly from the browser, and direct connections from China are unstable; but readers go through Cloudflare's edge and are completely unaffected.
Fix: just log into the backend on a smoother network when publishing — readers notice nothing. This is also a result of the architecture: read and write paths are separated, and the reader side doesn't depend on Supabase being reachable.

7. Do you need to worry about Workers' free tier?
Based on real experience: builds are 1–3 minutes each, and 3,000 minutes/month is effectively inexhaustible for a personal site; the runtime gives 100,000 requests/day and 10ms of CPU per invocation, while SSR rendering usually comes in far below 10ms. The only thing to watch is "only code changes trigger a build" — publishing a post consumes no build minutes.

8. AI excerpts sometimes report "the service returned empty," even though nothing errored
Symptom: click "✨ AI generate," wait a few seconds, and you get a failure saying the excerpt could not be generated, with the error reading "the translation service returned empty" — yet translation works perfectly.
Cause: this is the most counterintuitive pitfall — the API returns HTTP 200, no error at all, but the content is empty. The Zhipu GLM-4.7-Flash I was using is a reasoning model: it writes its thinking process into the reasoning_content field first, and max_tokens is the combined budget for "thinking + answer," not just the answer. The excerpt request only allowed 1024, while this model's thinking measurably runs 1,300–1,600 characters, so the budget was exhausted during the thinking phase and there was nothing left to write the answer with. The tell is that finish_reason in the response is length (truncated by length) rather than the normal stop.
Fix: give reasoning models a generous budget. First, raise the excerpt request's max_tokens from 1024 to 4096; second, add self-healing at the call layer — when it detects "empty content + finish_reason is length," it automatically doubles the budget and retries (in practice, the first doubling is enough to get an answer). I also rewrote the error messages so they point at the actual problem (distinguishing "budget exhausted," "returned only thinking, no answer," and "genuinely empty content") instead of vaguely saying "returned empty." And finally, one more layer of fallback: when the AI is unavailable, it falls back to local excerpting, so writing is never blocked.

9. Long articles always fail to translate, while short ones are fine
Symptom: articles under a thousand characters translate normally; anything over ten thousand characters always fails, and only after a long wait.
Cause: early on, each chunk was translated serially, with a 45-second timeout per chunk and a 2-second wait between chunks. A nearly 12,000-character article split into 5 chunks meant a worst case of 5×45 + 4×2 ≈ 233 seconds, while the front end's wait limit was 200 seconds — a guaranteed failure by design, regardless of network quality. The chunker also sliced tables down the middle, leaving the reassembled translation broken.
Fix: chunking became structure-aware (code blocks and tables go in and out whole, and oversized tables repeat the header in every piece), and translation became 2-way concurrent, cutting the time to about 140 seconds; each chunk also carries the tail of the previous chunk's source text as context, reducing cross-chunk reference errors.

Cost Breakdown

Item Cost
Domain The only historical expense (bought years ago, renewed annually)
Cloudflare Workers / DNS / HTTPS 0
Supabase (database + auth + image hosting) 0 (free tier)
GitHub 0 (public repo)
Zhipu GLM (translation + AI excerpts) 0 (free at the model level for GLM-4.7-Flash)
Total monthly cost ¥0

Day-to-Day Maintenance

  • Publishing / editing posts: done in the /admin backend, effective immediately, zero deploys.
  • Changing code / styles / features: edit locally → push → CF builds automatically (1–3 minutes) → done.
  • Changing configuration (env vars, translation provider): change it in the Cloudflare dashboard → Retry deployment.
  • Data backup: Supabase lets you export anytime; and the posts themselves are Markdown text — with the database in hand, you're set for anything.
  • Certificates and renewal: handled automatically by Cloudflare; nothing to do.

Closing

From that hand-operated Alibaba Cloud server in 2018 to this fully managed 2026 combination of "push to deploy, publish to go live," what changed isn't just the tech stack — it's the bar for owning a personal website. It dropped from "you have to be a sysadmin" to "you just need a GitHub account." And this whole rebuild was done with AI's help: I'm not an expert on Supabase, but I knew which questions to ask.

If you followed this article and built one of your own, drop by the comments and tell me about it.

One thread through it all, as ever.

Comments0

    ← Back to home