Design Doc: Posting to My Hugo Blog Directly from iPhone
1. Problem
I wanted a low-friction way to publish to my blog from my iPhone.
The original blog setup was simple:
- content lives in Markdown inside a Hugo repo
- GitHub Actions builds and deploys the site
- GitHub Pages serves the final static output
That setup is good for reliability, but not good for writing on mobile. Editing Markdown through GitHub’s mobile UI technically works, but it is still a repo workflow, not a writing workflow.
The requirement was:
- open phone
- write a post
- publish
Without needing to manually browse files, edit Markdown in GitHub, or use the GitHub API.
2. Goals
- Publish a new post from iPhone with minimal friction
- Keep the existing Hugo + GitHub Actions deployment pipeline
- Avoid GitHub API integrations
- Keep content stored as normal Markdown files in the repo
- Use a simple, understandable architecture
3. Non-Goals
- Full CMS with rich media management
- Multi-user editing workflow
- In-browser editing of existing posts
- Database-backed content storage
- Real-time collaboration
4. Existing Stack
The baseline blog stack was:
- Hugo for static site generation
- PaperMod as the theme
- GitHub repository for source control
- GitHub Actions for build and deploy
- GitHub Pages for static hosting behind
yutongjin.com
The deployment flow already existed:
- Push to
main - GitHub Actions runs
hugo --minify - Built output is deployed to
yutongjin/yutongjin.github.io - Custom domain serves the updated site
5. High-Level Design
The final design keeps the static site static, and adds a very small private publishing service.
There are two separate pieces:
5.1 Public static admin page
The Hugo site now serves /admin/.
This is only a form UI. It collects:
- publish endpoint
- username
- password
- title
- slug
- tags
- date
- draft flag
- body
It does not write files directly.
5.2 Private publish server
A small Python server runs on my Mac:
scripts/publish_server.py
Its job is:
- accept authenticated
POST /publish - validate the payload
- create
content/posts/<slug>.md - run
git add - run
git commit - run
git push origin main
This avoids the GitHub API entirely. It uses normal git operations against the repo checkout.
6. Tech Stack Used
The implementation used:
- Hugo for the blog site
- PaperMod for rendering
- HTML/CSS/JavaScript for the
/admin/page - Python standard library HTTP server for the publish backend
- Basic Auth for lightweight authentication
- git CLI for commit and push
- Tailscale Funnel to expose the local publisher over public HTTPS
- GitHub Actions for the existing deployment pipeline
- GitHub Pages for serving the final site
7. Data Flow
The end-to-end data flow is:
- On iPhone, open
https://yutongjin.com/admin/ - Hugo/GitHub Pages serves the static admin page
- User fills in post data and taps
Publish - Browser sends
OPTIONS /publishto check CORS - Browser sends authenticated
POST /publishto the publish endpoint - Tailscale Funnel receives the public HTTPS request
- Funnel forwards it to
http://127.0.0.1:8787on the Mac publish_server.pyvalidates auth and request payload- Server writes a new Markdown file under
content/posts/ - Server runs
git add,git commit, andgit push - GitHub receives the push to
main - GitHub Actions workflow is triggered by the push event
- Workflow runs
hugo --minify - Built site is deployed to the GitHub Pages repo
yutongjin.comserves the updated post and post index
8. Event Triggering
There are several explicit trigger points in the system:
8.1 Browser submit event
The user taps Publish on /admin/.
That triggers:
- JavaScript form submission
- JSON payload creation
- authenticated
fetch()call to/publish
8.2 CORS preflight
Because the admin page and publisher are on different origins, Safari issues:
OPTIONS /publish
The server responds with:
204 No Content- CORS headers allowing the request
Only after that does the browser send the real publish request.
8.3 Publish request
The real publish event is:
POST /publish
If authentication succeeds and the payload is valid, the post is created.
8.4 Git push event
After the server pushes to main, GitHub Actions is triggered by repository state change:
on: pushbranches: ["main"]
8.5 Deploy event
The existing GitHub Actions workflow builds and publishes the static site.
That is what makes the new post visible at:
/posts/<slug>//posts/
9. Request and Auth Model
The publisher uses HTTP Basic Auth.
Required environment variables:
export BLOG_ADMIN_USER="<admin-username>"
export BLOG_ADMIN_PASSWORD="<strong-password>"
export BLOG_ALLOWED_ORIGIN="https://yutongjin.com"
The admin page sends:
Authorization: Basic ...- JSON request body with post fields
The publisher verifies:
- username matches
BLOG_ADMIN_USER - password matches
BLOG_ADMIN_PASSWORD
This is intentionally lightweight. It is enough for a private personal workflow, but it is not a full IAM system.
10. Write Path
When a publish request succeeds, the server renders the post into standard front matter:
---
title: "Post title"
date: 2026-05-03
draft: false
tags: ["tag1", "tag2"]
---
Then it appends the Markdown body and writes the file into:
content/posts/<slug>.md
This keeps the source of truth exactly where Hugo already expects it.
11. Operational Setup
Two processes must be running on the Mac:
11.1 Publisher server
python3 scripts/publish_server.py --host 0.0.0.0 --port 8787 --repo .
11.2 Public HTTPS tunnel
tailscale funnel 8787
That produced a public endpoint similar to:
https://<device-name>.<tailnet>.ts.net
That endpoint was then configured as the default publish endpoint on /admin/.
12. What Worked
- The form submitted successfully from iPhone
- The publish server created a new Markdown file
- The server committed and pushed the change
- GitHub Actions deployed the updated site
- The new post appeared both on its direct URL and on the posts index
This confirmed that the system works end to end.
13. Failure Modes Observed
Several errors showed up during setup, each tied to a specific layer:
13.1 Failed to fetch
Cause:
- no reachable publish endpoint yet
Meaning:
- browser could not connect to any publisher
13.2 405
Cause:
- wrong request path or direct browser access to
/publish
Meaning:
- the endpoint was reachable, but the method/path usage was wrong
13.3 404
Cause:
- using the blog domain instead of the publisher domain
Meaning:
- request reached the static site, not the publish server
13.4 Unauthorized
Cause:
- wrong username/password
Meaning:
- network path worked, auth failed
13.5 Address already in use
Cause:
- publisher already running on port
8787
Meaning:
- a second server instance was started accidentally
These were useful because each error corresponded to a different system boundary:
- browser/network
- routing
- auth
- local process lifecycle
14. Improvements Already Made
To reduce friction, the admin page was updated to default:
Publish endpointUsername
The date is also auto-filled to today in the browser.
This means the normal mobile workflow is now:
- open
/admin/ - type password
- enter post content
- publish
15. Tradeoffs
15.1 Pros
- very small system
- no GitHub API dependency
- content remains plain Markdown
- reuses the existing deploy pipeline
- easy to debug because each step is explicit
15.2 Cons
- Mac must be online
- publisher process must be running
- Tailscale Funnel must be running
- endpoint is tied to operational state, not permanent infrastructure
- auth is minimal
- only supports creating new posts, not editing old ones
16. Possible Improvements
The current version works, but it is still an MVP. Obvious next steps:
16.1 Reliability
- run the publisher with
launchdso it auto-starts - auto-start Tailscale and keep Funnel available
- move the publisher to a small VPS for always-on availability
16.2 UX
- hide the publish endpoint field entirely
- prefill more metadata defaults
- show the published post URL after success
- support editing existing posts from
/admin/ - add a lightweight post list in the admin page
16.3 Security
- replace Basic Auth with a stronger auth flow
- rate-limit publish requests
- restrict publishing to a narrower device or identity boundary
- rotate credentials regularly
16.4 Content model
- support descriptions and cover images
- support scheduled publishing
- support timestamped front matter instead of date-only
17. Why This Design Was Good Enough
This design is not a full CMS, and it does not try to be.
It solves the real problem:
- writing and publishing from iPhone with low friction
while preserving the existing strengths of the blog:
- Git-based history
- Markdown source of truth
- Hugo build
- GitHub Actions deploy
In other words, instead of replacing the publishing stack, it adds the thinnest possible write layer in front of it.
18. Final Architecture Summary
The final system looks like this:
iPhone Safari
-> yutongjin.com/admin/ (static Hugo page on GitHub Pages)
-> HTTPS POST to Tailscale Funnel endpoint
-> local Python publish server on Mac
-> write Markdown into content/posts/
-> git add / commit / push
-> GitHub Actions build and deploy
-> updated yutongjin.com
That is the whole idea:
- static site for reading
- tiny private service for writing
- git for versioned content
- existing CI/CD for deployment
Small system, clear boundaries, good enough to use.