This post records how this blog was built.

The requirements were simple: write posts locally in Markdown, preview locally, and publish to my own server. The server should not run a blog backend, maintain a database, or expose a login entry. It should only serve static files through OpenResty.

The final stack is:

Hugo + PaperMod + private GitHub repository + systemd timer + 1Panel OpenResty

I considered WordPress and Ghost at first, but they are not necessary for now. They are better for sites that need an admin editor, accounts, comments, subscriptions, and plugin ecosystems. What I need is a long-term place for notes, with writing and publishing kept as simple as possible.

Local Site

First create the Hugo project.

hugo new site blog
cd blog
git init

I started with the PaperMod theme.

git submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod

The main settings in hugo.yaml looked like this:

baseURL: https://example.com/
title: my blog
theme: PaperMod
timeZone: Asia/Shanghai
hasCJKLanguage: true

outputs:
  home:
    - HTML
    - RSS
    - JSON

The JSON entry under outputs.home is for search. PaperMod’s search page depends on index.json; if it is not enabled, the search page may open but find nothing.

New posts go under content/posts/.

hugo new posts/my-note.md

The front matter usually looks like this:

---
title: "Post title"
date: 2026-06-16T04:00:00+08:00
draft: false
tags:
  - Hugo
---

Local preview:

hugo server -D

Default address:

http://127.0.0.1:1313/

Build static files:

hugo --gc --minify

The result is generated under public/. This directory is not committed to Git; it is only a build artifact.

Server

On the server, I use 1Panel to manage OpenResty and create a normal static site. The site directory points to the final publish directory. Conceptually it is similar to:

server {
    server_name example.com;
    root /path/to/site/root;
    index index.html;
}

1Panel generates the OpenResty configuration. It is better not to manually edit the whole generated config. A steadier approach is to handle domains, certificates, HTTPS, and the site directory in the panel, while the publish script only syncs the Hugo build output.

The earliest publish flow was building locally and syncing directly:

hugo --gc --minify
rsync -a --delete public/ user@your-server:/path/to/site/root/

This works, but it is not ideal for writing from multiple devices. On a new computer, SSH keys, remote paths, and publish scripts all need to be configured again. Later I switched to GitHub as the central repository, with the server pulling and building by itself.

The current publish chain is:

Write posts locally
git push to GitHub
server runs scheduled git pull
server builds with Hugo locally
rsync to the OpenResty site directory

This way, local computers, VSCode, Obsidian, and Codex only need to interact with GitHub. There is no need to SSH into the server for daily publishing.

GitHub Sync

The GitHub repository stores source files and posts, not public/. The server uses a read-only Deploy Key to pull the repository. This key only needs read permission; it is not a GitHub account password and does not need write access.

The core logic of the server sync script is:

git fetch --prune origin main
git reset --hard origin/main
git submodule update --init --recursive
hugo --gc --minify --destination /tmp/blog-build
rsync -a --delete /tmp/blog-build/ /path/to/site/root/

Pay attention to --delete. It keeps the online directory exactly aligned with the build directory, which is suitable for static-site publishing, but only if the source and destination paths are definitely correct. The script should use fixed build and site directories rather than constructing risky temporary paths.

The scheduled task uses a systemd timer.

The service is roughly:

[Service]
Type=oneshot
ExecStart=/usr/local/bin/blog-sync

The timer is roughly:

[Timer]
OnBootSec=30s
OnUnitActiveSec=2min
Persistent=true

This avoids webhooks and avoids letting GitHub actively access the server. The tradeoff is a small delay, usually one or two minutes. For a personal blog, that is fine.

When troubleshooting, check these:

systemctl status blog-sync.service
systemctl status blog-sync.timer
journalctl -u blog-sync.service -n 80 --no-pager

After this flow worked, I removed the local scripts/deploy.sh. Git is now the only publish entry.

DNS and HTTPS

After putting the domain behind Cloudflare, do not rely only on what the browser shows when debugging. Browser cache, old redirect rules, and Cloudflare rules can all make the result confusing.

Check response headers first:

curl -I https://example.com/

Then check the final browser redirect.

This time I mainly checked:

  • whether DNS points to the correct server;
  • whether Cloudflare still has old redirect rules;
  • whether the 1Panel/OpenResty site is bound to the correct domain;
  • whether the HTTPS mode and origin certificate match;
  • whether both the root domain and article pages return 200.

The static site itself is not complicated. The truly confusing parts are cache and redirects.

Writing Workflow

From now on, writing a post follows this flow:

cd blog
git pull --rebase
hugo new posts/my-note.md
hugo server -D
git add .
git commit -m "docs: add my note"
git push

The server timer will pull and publish automatically.

In VSCode, it is best to open the whole Hugo project, because Git, the theme, configuration, and terminal are all in the same directory.

There are two ways to use Obsidian:

  • If you only write posts, use content/ as the vault.
  • If you want to use the Obsidian Git plugin, open the whole repository.

If you use Obsidian, remember to ignore .obsidian/ and do not commit editor configuration to the blog repository.

Images

The hardest part of a static blog is not Markdown, but deciding where images live.

This setup uses an independent image host. The repository only keeps upload scripts and example variables. The real token stays in local .env, which is included in .gitignore.

Example configuration:

LSKY_BASE_URL=https://your-image-host.example
LSKY_TOKEN=

Upload an image:

./scripts/lsky-upload.sh /path/to/image.png

After the script returns the image URL, put it in the post front matter:

cover:
  image: "https://your-image-host.example/i/2026/06/17/example.png"
  alt: "Image description"

This keeps only public image URLs in the post source, without accounts or secrets. When writing from another computer, only the local .env needs to be configured again.

Later

For now, keep the setup simple. Add these only when they are truly needed:

  • comments;
  • search improvements;
  • analytics;