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 12:00 UTC (20: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/MMDD-slug format)
mkdir -p content/posts/2025/1209-my-article-slug
cd content/posts/2025/1209-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
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-22T20: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 20:00 |
tonight, when the cron runs |
| a future date | that day at 20:00 |
on that day |
Both are legitimate — typing today's date is how you publish "tonight at 20: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/MMDD-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 will not cause a second send — the guid does not move. But do not read that as "an edit gets picked up": Buttondown reads each guid exactly once, so an edit cannot change or trigger an email. See Recovering an article Buttondown refused. - 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 20:00–20: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 12 * * * (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.
The cron path keeps its trailing slash on purpose. trailingSlash in vercel.json
redirects /api/rebuild to /api/rebuild/, and Vercel cron jobs do not follow
redirects, so the no-slash path would silently stop the daily publish.
- 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.
Buttondown screens incoming items for prohibited keywords — the names of some crypto exchanges are on that list. When an item trips the filter you get an email saying the send failed. Nothing is saved. Buttondown discards the item rather than keeping a draft you can fix, but it still records the guid as seen, so it never reads that URL again.
The consequence is the part that costs a day: editing the article does not fix it. The site updates, the offending word is gone, and no email will ever be sent for that URL. Confirmed 2026-09-02 — the refused article had no email object in the account in any status (draft, blocked, errored), while the guid stayed suppressed.
Two ways out:
-
Give the article a new address. Change the
slug, setdateto earlier today, and add analiasesentry so the old URL redirects instead of 404ing:slug: where-to-buy-btc-in-2026 aliases: - "/p/where-to-buy-btc/"
The new slug is a new guid, so Buttondown treats it as a fresh post. Set the date to a time that has already passed — a future timestamp makes Hugo omit the article from the build entirely, and it fails silently. Keep it same-day so "skip old items" does not reject it as stale. Edit the frontmatter by hand for this;
dsc-publishalways writes 20:00, which is still ahead of you for most of the working day.Confirm with a production-equivalent build before merging —
hugo --gcwith no-F. The page should exist at the new slug, the old path should contain a redirect, and the feed's top<guid>should be the new URL. -
Create and send the email directly through the API, bypassing RSS. Leaves the URL untouched, but you compose the email yourself.
Check what Buttondown actually holds before assuming anything — note that
GET /v1/emails returns only sent emails unless you ask for another status:
set -a; . ~/.config/buttondown.env; set +a # key lives here on Moonglade, mode 600
curl -s -H "Authorization: Token $BUTTONDOWN_API_KEY" \
"https://api.buttondown.com/v1/emails?status=draft" | python3 -m json.toolThe auth scheme is Token, not Bearer, and the host is api.buttondown.com.
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/
└── 1209-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"]
enable_rapport: true
---Utility scripts for processing images (useful for Substack migration).
./scripts/convert_heic_to_webp.sh content/posts/2025/1209-my-article-slugConverts HEIC images to WebP and updates markdown references.
Requirements: brew install imagemagick
./scripts/update_substack_urls.sh content/posts/2025/1209-my-article-slugConverts 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, 12: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.