A Hugo static site published at digitalsovereignty.herbertyang.xyz, deployed on Vercel, with articles delivered to subscribers by Buttondown.
Articles publish themselves on a date you choose. You do not need to be at your machine when an article goes live, and nothing has to be merged on the day.
Three pieces make that work:
- The date decides. The production build runs
hugo --minify --gcwithout the-Fflag, so Hugo omits any article whosedateis still in the future. A future-dated article is absent from the site and the RSS feed — not hidden, genuinely not built. - A daily rebuild checks. A Vercel cron job runs every day at 14:00 UTC (22:00 Shanghai) and triggers a rebuild. Whatever has crossed its date gets published; everything else stays invisible.
- Buttondown notices once. It polls the RSS feed every 30 minutes and dedupes
on
<guid>(the article permalink), so a rebuild that changes nothing sends nothing, and each article can only ever produce one email.
Daily rebuild, any cadence you like. The cron is a heartbeat, not the schedule.
You control cadence entirely through each article's date. A missed cron run
delays an article by hours, not by a week — which is why the rebuild is daily even
if you publish weekly.
Complete workflow for creating and publishing a new article.
# Create a new branch for your article
git checkout main
git pull
git checkout -b draft/my-article-slug
# Create the article folder (YYYY/MM/DD-slug format)
mkdir -p content/posts/2025/12/09-my-article-slug
cd content/posts/2025/12/09-my-article-slugRun the interactive wizard to create index.md:
dsc-init-articleThe wizard will prompt for:
- Title: Article title
- Slug: Auto-generated from folder name (can override)
- Description: SEO description
- Category: Choose from existing or create new
- Series: Optional, choose from existing or create new
- Keywords: Optional, comma-separated for SEO
This creates index.md with proper frontmatter and template structure.
- Add your content to
index.md - Add images to the article folder (WebP format preferred)
- Add
featured-image.webpfor social media preview
Start the Hugo development server from the repo root:
hugo serve -D -FThen open http://localhost:1313/ in your browser. Your article appears at
http://localhost:1313/p/<your-slug>/.
Both flags matter for unpublished work:
| Flag | Meaning | Why you need it |
|---|---|---|
-D |
Include drafts | New articles have draft: true until dsc-publish runs |
-F |
Include future-dated posts | dsc-init-article sets a placeholder date far in the future |
Without them, Hugo builds the site but your new article is silently missing — no error, it just never appears in the list.
The server watches for changes and live-reloads the browser as you edit
index.md. Press Ctrl+C to stop it.
Troubleshooting:
- Article not showing — you almost certainly dropped
-Dor-F. - Port 1313 already in use — another server is still running. Stop it with
pkill -f "hugo serve", or pick another port:hugo serve -D -F -p 1314. - Edits not appearing — Fast Render Mode can miss some changes. Restart with
hugo serve -D -F --disableFastRender. hugo: command not found— install it:brew install hugo.
Run the publish script to validate and prepare your article:
dsc-publishThis script:
- Validates frontmatter, content, and images
- Asks when to publish —
now, or a future date to schedule it - Sets
draft: false - Shows git commands for committing
Publish date [now | YYYY-MM-DD]: 2026-09-22
[OK] Date set to: 2026-09-22T22:00:00+08:00
[i] Scheduled - stays hidden until 2026-09-22, then publishes on the daily rebuild.
The prompt is required — there is no silent default, so an article cannot be published on the wrong day by accident. Past dates are rejected: Buttondown skips items dated more than a day before it discovers them, so a backdated article would never reach subscribers.
now and today's date are not the same thing:
| You type | Date written | Goes live |
|---|---|---|
now |
current timestamp | immediately, on the next build |
| today's date | today at 22:00 |
tonight, when the cron runs |
| a future date | that day at 22:00 |
on that day |
Both are legitimate — typing today's date is how you publish "tonight at 22:00",
which lines up with the daily rebuild. To go live right away, answer now.
The script tells you which one you picked.
See Scheduled Publishing for the details.
# Stage the article path explicitly — never `git add .`, which can sweep in
# unrelated drafts sitting untracked in content/
git add content/posts/YYYY/MM/DD-my-article-slug
git commit -m "Publish: My Article Title"
git push -u origin draft/my-article-slug
gh pr create --base main --title "Publish: My Article Title"
# After PR is merged, clean up
git checkout main
git pull
git branch -d draft/my-article-slug| Script | Purpose | Usage |
|---|---|---|
dsc-init-article |
Create index.md with frontmatter | Run from article folder |
dsc-publish |
Validate, set date, set draft:false | Run from article folder |
Installation (symlinks to /usr/local/bin):
sudo ln -sf /Users/zire/matrix/zire/digital-sovereignty/scripts/dsc-init-article /usr/local/bin/dsc-init-article
sudo ln -sf /Users/zire/matrix/zire/digital-sovereignty/scripts/dsc-publish /usr/local/bin/dsc-publishReference for the mechanism described in How Publishing Works.
Set each article a week apart, merge them all at once, and leave them alone:
article A -> 2026-09-01 article B -> 2026-09-08 article C -> 2026-09-15
All three can live on main from day one. Only the one whose date has arrived is
built, so subscribers receive them one at a time.
draft: true and a future date both hide an article, but only one of them ever
un-hides itself:
| Frontmatter | Built today? | Publishes when |
|---|---|---|
draft: true |
no | never, until you flip the flag by hand |
draft: false + future date |
no | automatically, on that date |
draft: false + past date |
yes | already live |
Scheduling relies on the date, so a scheduled article is always draft: false.
Use draft: true for work that genuinely is not finished.
- Space articles at least a day apart so only one enters the feed per rebuild.
- Never change the slug of an article in the newest 10 (the RSS window). The
slug becomes the
<guid>, so a new slug reads as a brand-new post and Buttondown will send it again. Editing a title or body is safe — the guid does not move. - Do not backdate. Buttondown's "skip old items" setting ignores anything dated
more than a day before discovery, so a backdated article silently never sends.
dsc-publishrejects past dates for this reason. - Expect a fuzzy minute. On the Hobby plan Vercel fires the cron at a random minute within the scheduled hour, so publication lands somewhere in 22:00–22:59 Shanghai.
Change the date and merge again. As long as the original date has not yet passed, nothing was ever built, so nothing was sent.
| Where | Setting |
|---|---|
vercel.json |
crons → /api/rebuild on 0 14 * * * (UTC) |
api/rebuild.js |
Verifies CRON_SECRET, then POSTs to the deploy hook |
| Vercel env vars | CRON_SECRET, DEPLOY_HOOK_URL (Production) |
| Buttondown | "Skip old items" enabled; RSS-to-email on the site feed |
CRON_SECRET is the shared secret Vercel sends as Authorization: Bearer …;
/api/rebuild returns 401 to anything else. DEPLOY_HOOK_URL is the deploy hook
from Project → Settings → Git → Deploy Hooks. Both are secrets — anyone holding
the hook URL can trigger builds.
- Check the cron ran — Vercel → Settings → Cron Jobs → View Logs. A 401 means
CRON_SECRETdoes not match; a 500 meansDEPLOY_HOOK_URLis unset for Production. - Check the date has actually passed in Shanghai time, not UTC.
- Check
draft: false— a lingeringdraft: trueblocks it regardless of date. - Rebuild locally the way production does:
hugo --gcwith no-F. If the article is missing there, it will be missing on the site.
To preview scheduled work locally, use hugo serve -D -F — -F includes
future-dated posts, -D includes drafts.
Simple branch-per-article workflow:
main (production)
│
├── draft/article-a ──> PR ──> merge ──> delete
│
├── draft/article-b ──> PR ──> merge ──> delete
│
└── draft/article-c (work in progress)
Key Principles:
mainbranch is always production-ready- Each article gets its own
draft/slugbranch - Create PR to merge into main when ready
- Delete branch after merge
- Multiple articles can be in progress simultaneously
Articles use Hugo page bundles:
content/posts/
└── 2025/
└── 12/
└── 09-my-article-slug/
├── index.md # Article content
├── featured-image.webp # Social media preview
└── other-images.webp # Additional images
Frontmatter Example:
---
title: "My Article Title"
date: 2025-12-09T12:00:00+08:00
slug: my-article-slug
draft: false
description: "A compelling description for SEO (50-160 chars)"
categories:
- "crypto"
series:
- "Deep Dive Series"
images: ["featured-image.webp"]
keywords: ["keyword1", "keyword2"]
enable_rapport: true
---Utility scripts for processing images (useful for Substack migration).
./scripts/convert_heic_to_webp.sh content/posts/2025/12Converts HEIC images to WebP and updates markdown references.
Requirements: brew install imagemagick
./scripts/update_substack_urls.sh content/posts/2025/12Converts Substack CDN URLs to local file references.
./scripts/remove_heic_images.shSafely removes HEIC files after conversion (requires confirmation).
## Deployment
Deployment is handled entirely by Vercel — there are no GitHub Actions in this repo.
| Trigger | What happens |
|---------|--------------|
| Push or PR merge to `main` | Vercel builds and deploys automatically |
| Daily cron, 14:00 UTC | `/api/rebuild` fires a deploy hook; anything that has reached its date goes live |
The build command is `hugo --minify --gc` (see `vercel.json`). It deliberately
omits `-F`, which is what makes future-dated articles invisible until their day
arrives.