HugoによるブログをGitHub Pagesなどの外部サービス無しで自宅サーバやVPSに展開する

ブログをセルフホストしたくなったので、今までhatenablogに設置していたブログをcoppelab.netに移管した。その際、ページ全体をgitリポジトリで管理し、GitHubに配置してpushイベントをGitHub Actions等でフックしてGitHub PagesやFirebaseなどへデプロイするのが定石のようだが、外部サービスに依存したくないというお気持ちからそれらから独立して構築できるよう考えた。

Hugoとは

まずはCMSとしてHugoを採用することに決めた。

hugoは、いわゆる静的サイトジェネレータと呼ばれるもので、ざっくりいうと “テーマ” を記述するHTML, CSS, Javascript を準備して記事をmarkdownやHTMLで書き、コマンドを叩くと静的ページを構築してくれるものだ。半自動HTML生成とも言える。記事 (文章の構造) とページ構成/修飾 (文章の体裁) を分離することで、ユーザが記事を書くときは文章の構造のみに集中できるという利点がある。Hugo自体に関する解説はさくらのナレッジ 静的サイトジェネレータ「Hugo」と技術文書公開向けテーマ「Docsy」でOSSサイトを作るが詳しい。

似たサービスにWordpressがある。大きな違いは、Wordpressはwebサーバの面倒まで見てくれることに対して、Hugoはページ (サイト) を構成するHTMLなどを生成するのみにとどまる点だ。これらをサーブするためのwebサーバは自前で用意しなければならない (いちおうhugoにもwebサーバがついてくるが、おそらくこれはテスト用の簡易なものだと思われる)。

本当は、Webサーバの面倒まで見てくれることをCMS選定の要件に含めていた。というのも、コメント機能やアクセスログなんかを設置したかった。しかし最近の流行りとして、ダイナミックなコンテンツはサーバを分離して (RESTfulとかにする)、静的なページからそこを叩いてブラウザでレンダリングするのがよいらしいので、そうすることにした。

加えて、wordpressはPHPで書かれているが、PHPは経験がなく何かあったときにデバッグができないだろうとふんで、Goで書かれているhugoを選んだというのもある。

ところで、記事を書くときのお手軽さから記事投稿用のwebインターフェースが欲しかった。執筆やプレビュー、投稿まで、デスクトップアプリなし (ブラウザを除く) でひとつながりで完了するのが魅力的だった。Hugoはwebサーバを持たないので当然標準ではこれを実現することはできない。gitで書いてpushしてデプロイする方法が定石のようだが、コンピュータにgitとプレビュー用のhugoがインストールされていないといけないので煩雑だと思っていた。Frontend Interfaces with Hugoを見ると、webサーバを追加してブラウザから操作できるようなものはない。デスクトップアプリによるものばかりだ。そんなものをインストールするくらいならgitをインストールするほうがまだマシである。

そうなれば、hugoがやっていることはどうせ静的ファイルの生成なので、別に認証とhugoコマンドを発行するサーバを書けばよいと思っていた。しかし、「パスワードが増えるのはセキュリティ上のリスク」という記述を目にした (参考) ので、諦めてgitで管理することにした。

やりたいこと

さて、gitで管理するのはよいが、外部のリポジトリホスティングサービスを使用したくない。ローカルホストとリモートホスト (webサーバとファイルを格納する。自宅サーバとかVPSとか) との通信のみでデプロイが完了してほしい。つまり、どうにかしてリモートホストに直接コミットをpushし、それをトリガに hugo build コマンドを発行、 destionation を Nginx で静的サーブしたい。

git リポジトリのホスト

まず考えたのはリモートホストにGitlabやGiteaなどのリポジトリホストデーモンをデプロイすることだ。しかし、たったひとつのリポジトリのために仰々しいアプリを入れるのは気が引けた。

よく調べると、gitをインストールすると git-receive-pack というのもついてきて、このコマンドはpushされたデータを指定されたディレクトリに展開するようだ。実際、 git push を verbose でみると、ローカルホストで いかようにかしてリクエストを発行し、 リモートで (sshコマンドで、ログインシェルの変わりのコマンドとして) git-receive-pack を実行して受け取るようだ。このようすは

GIT_SSH_COMMAND='ssh -v' git push -v origin master`

を発行することで観察できる。そしてこのコマンドのdestinationは、データベースなどの特別な環境を必要とせず、単に.gitが含まれるファイルシステムを操作するだけである。

つまり、リモートホストにgit-receive-packがインストールされていさえすればpush可能である。そういうわけで、この方法を採用することにした。

テーマを記述するサブモジュールを考慮したレポジトリ配置

Hugoは “テーマ” と呼ばれるもので体裁を整えるが、テーマのためのファイルはhugoリポジトリのサブモジュールとしてデプロイすることになっている。ここで当然、テーマのgitリポジトリもリモートホストで管理したくなる。 ここでポイントがある。push “される"リポジトリは、 bare repostiroy と呼ばれ、作業用ファイルが含まれない、つまり .git/ 以下にるようなファイル飲みからなるリポジトリ形態であるべきである (non-bare リポジトリの場合pushはできるがwarningが発生する)。そして bare repository は作業用ファイルを持たないので、submoduleについてはリファレンスしか持たない (submoduleのコンテンツは持たない)。したがって、多少煩雑な気はするが、リモートホストには3つのリポジトリが必要である; hugo本体 (記事も含む) の bare リポジトリ、 hugo theme の bare リポジトリ、そしてpublish用の non-bare な hugo リポジトリだ。

リポジトリの作成と連携

ここから実際に作成を行う。hugo本体の bare リポジトリを /home/blog/blog-bare.git以下に (ディレクトリである)、 テーマのbare-repositoryを /home/blog/Blonde.git 以下に (筆者はBlondeをテーマに採用したので、各自自身のテーマ名で読み替えてほしい)、publish用のnon-bareなhugoリポジトリを /home/blog/blog-publish 以下に配置するとする。

bareレポジトリは

git init --bare

のように init--bare オプションをつけて作成し、git remote サブコマンドなどでよしなにコンテンツを持ってくればよい。この方法で、hugo本体 (blog-bare.git) とthemeのbareレポジトリ (Blonde.git) を作る。

続いて、hugoでビルドされることになる、作業用ファイル (記事のソース) を持ったnon-bare リポジトリ (blog-publish) を作成する。これは、remote url をhugoリポジトリの絶対ファイルパスに指定すれば良い。そのあとはsubmoduleのアップデートをする。

git submodule update --init

続いて、連携のためのフックを設定する。ローカルホストからリモートホストのblog-bare.gitに新規記事を含むcommitがpushされた場合、あるいはテーマを書き換えてBlonde.gitにpushがされたとき、 blog-publishにてpullコマンドを発行、続けてhugo buildを起動し静的ファイルを生成されるようにする。

blog-bare.git/hooks/post-receive を下記内容で新規作成する。

#!/bin/bash -xv
cd /home/blog/blog-public && git --git-dir=.git pull origin master && /usr/local/bin/hugo

Blonde.git/hooks/post-receive には、submoduleに関するコマンドも入れる。

#!/bin/bash
cd /home/blog/blog-public && git --git-dir=.git pull origin master && git --git-dir=.git submodule update --remote && /usr/local/bin/hugo

これで何らかの方法で /home/blog/blog-public/public をサーブすれば一通りのデプロイが完成した。

記事の書き方

執筆

ローカルホストにて、任意のエディタで記事を書く。

プレビュー

hugo build コマンドを発行すれば公開用ソースが生成されるので、そのディレクトリで python -m http.server などでサーブしてプレビューすればよいが、ひとつ注意するべきことがある。hugoは設定ファイル内でURLのプレフィックスを設定し、cssなどのassetsを絶対パスでフェッチしているが、それはたいていリモートホストのものを設定するのでそちらをフェッチに行き、ページが破壊されたり、あるいは変更が適用されてないように見えることがある。configファイルを毎回書き換えてもいいが、賢い方法として、hugoは -b オプションを提供している。 -b のあとに任意のプレフィックス (http://localhost:8000/ など) を指定すれば、そのプレフィックスでビルドしてくれるため正常にプレビューできる。

自動生成のassetsの一部には、suffixにbuild毎に一意なIDが振ってある。これは、上述の変更不適用によってソースが壊れているのに気づかないことを防止する目的があるのだろう (何も変わっていないよりも404によってページが盛大に壊れてくれたほうが誤りに気づきやすい)。かしこい。

こんなかんじのMakefileでも用意しておくとよい。

IMAGE_NAME := klakegg/hugo:ext-alpine
BASEURL := http://localhost:8000/

.PHONY: build
build:
        docker run --rm -it -v$(CURDIR):/src $(IMAGE_NAME)

.PHONY: build-local
build-local:
        docker run --rm -it -v$(CURDIR):/src $(IMAGE_NAME) -b $(BASEURL)

公開

coimmitしてpushすれば直ちに変更が適用される。push時には、環境変数 GIT_SSH_COMMAND や、-cオプションでリモートサーバへの鍵やユーザーIDなどを指定することになるだろう。

今後の予定

コメント機能をデプロイする。Remarkというのが、セルフホストできてブラウザにレンダリングさせるところまでサポートされているのでよさそう。Goで書かれているので、何かあったときのデバッグも (筆者にとって) 容易だ。

アクセス解析はGoaccessを用いてnginxのアクセスログを解析することにした。これに関する記事は他に譲る。

その他

CSS等の編集/追加を想定してthemeを編集する前提でいたが、これらはblog-bare.gitのほうで `override’ するのがよいらしい。やっているときは知らなかった。