Self Hosting Jekyll Blog
Although I haven’t written posts for a while, I have set up a home server and deployed various services like cloud storage, music streaming, and task management tools on my own domain. Now, I would like to proceed with self-hosting my Github blog, which I had been postponing. I have been running my blog using a modified version of the minimal mistakes template powered by Jekyll. While Github handles the build and deployment automatically once the repository is configured, self-hosting requires internalizing all these functionalities.
Install Jekyll, Bundler
The first step is to configure the server environment to enable building the website. Assuming Ruby is already installed, we need to modify the following environment variable:
GEM_HOME=$HOME/.gems
This allows the installed processes to operate without root permissions. After that, run the following command to complete the necessary environment settings.
gem install jekyll bundler
If properly installed, Jekyll blog can be built with the following command.
cd /directory/to/blog/repository
bundle install
bundle exec jekyll build
It is important to check if the build is properly completed with this command.
Nginx setup
If the build is properly completed, the website should be well-finished in the _site directory.
Serve it with nginx.
I use HAProxy to manage certificates and connect to internal pages.
Therefore, nginx does not require any reverse proxy or certificate settings, so it is deployed with the following simple docker compose syntax.
services:
nginx-blog:
image: nginx:latest
container_name: nginx-blog
ports:
- "10023:80" # change port to your own
volumes:
- ./blog/_site:/usr/share/nginx/html:ro # volume bind _site directory
If the following backend is added to the HAProxy configuration, the website can be accessed with the same domain as before, page.teahaven.kr.
backend blog-http
mode http
balance roundrobin
option forwardfor
option httpchk HEAD /
http-check send ver HTTP/1.1 hdr Host localhost
server jekyll localhost:10023 check inter 5s
timeout connect 4s
timeout server 4s
frontend redirect
mode http
bind :::80 v4v6
bind :::443 v4v6 ssl crt {certificate_path}
http-request redirect scheme https code 301 unless { ssl_fc }
http-response set-header Strict-Transport-Security "max-age=16000000; includeSubDomains; preload;"
http-request set-header Host %[req.hdr(Host)]
http-request set-header X-Forwarded-Proto https if { ssl_fc }
acl is_page hdr(host) -i ${your_domain}
use_backend blog-http if is_page
timeout client 4s
Webhook
Now, we need to tell the server to pull and build the blog repository automatically when a new post is pushed to the blog repository.
The repository I am currently using is gitea, a self-deployed repository, and I use woodpecker as CI.
woodpecker is similar to github action, and the commonly used file name is .woodpecker.yml.
Add the following content to the repository root.
version: 1
steps:
- name: build
image: ruby:3.1
commands:
- gem install bundler:2.5.16
- bundle install
- bundle exec jekyll build
- name: custom-webhook
image: curlimages/curl:7.78.0
secrets:
- WEBHOOK_SECRET
commands:
- |
curl -X POST -H "Content-Type: application/json" -d '{
"event": "push",
"branch": "'$${CI_COMMIT_BRANCH}'",
"commit": "'$${CI_COMMIT_SHA}'",
"secret": "'$${WEBHOOK_SECRET}'"
}' https://your.domain.com/webhook
when:
status:
- success
when:
branch:
- main
event:
- push
Each step is described as follows:
- build step tests if the push is successfully built.
- If it is successful, it proceeds to the next step, where the important thing is that any message cannot be entered as a secret, so we store and use a pre-specified secret in woodpecker and the local server. If a push is made to the main branch and the push commit is buildable, the local server will send a webhook request.
Then, the local server receives the request and checks the secret, then pulls the blog repository and builds it. If the build is successful, the _site will be automatically deployed by nginx. The code for this operation is as follows.
import http.server
import socketserver
import json
import subprocess
import os
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
HOST = os.getenv('WEBHOOK_HOST')
PORT = int(os.getenv('WEBHOOK_PORT'))
SECRET = os.getenv('WEBHOOK_SECRET')
BLOG_DIR = os.getenv('BLOG_DIR')
class WebhookHandler(http.server.SimpleHTTPRequestHandler):
def do_POST(self):
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
payload = json.loads(post_data.decode('utf-8'))
if payload.get('secret') != SECRET:
self.send_response(403)
self.end_headers()
return
if payload.get('event') == 'push' and payload.get('branch') == 'main':
self.send_response(200)
self.end_headers()
self.wfile.write(b'Update started')
self.update_site()
else:
self.send_response(400)
self.end_headers()
self.wfile.write(b'Invalid event or branch')
def update_site(self):
os.chdir(BLOG_DIR)
subprocess.run(['git', 'pull', 'origin', 'main'])
subprocess.run(['bundle', 'install'])
subprocess.run(['bundle', 'exec', 'jekyll', 'clean'])
subprocess.run(['bundle', 'exec', 'jekyll', 'build'])
subprocess.run(["docker", "compose", "restart", "nginx-blog"])
print("Site updated successfully")
if __name__ == "__main__":
with socketserver.TCPServer((HOST, PORT), WebhookHandler) as httpd:
print(f"Webhook server running on port {PORT}")
httpd.serve_forever()
The reason for the docker restart is to reload the binding in consideration of the possibility that the bound file may be deleted or newly created. To run this, the DOCKER_HOST environment is needed, which will be passed to the following system service.
In addition, the .env file contains sensitive information, which is loaded and used in the python script.
Here, WEBHOOK_SECRET must be the same as the woodpecker secret mentioned in the previous section.
This feature also needs to be accessible from the outside, so the following part is added to HAProxy.
backend blog-webhook
mode http
balance roundrobin
option forwardfor
option http-server-close
option httpchk GET /
http-check send meth GET ver HTTP/1.1 hdr Host localhost
server webhook localhost:12412 check inter 5s
timeout connect 4s
timeout server 4s
frontend redirect
# ..
acl is_page hdr(host) -i ${your_domain}
acl is_webhook path_beg -i /webhook
use_backend blog-webhook if is_blog is_webhook
# ..
To automatically run the webhook, it is recommended to register it as a system service, so the following file is written and saved as blog-webhook.service in the /etc/systemd/user directory.
[Unit]
Description=Webhook Server for Blog Updates
After=network.target
[Service]
ExecStart=path/to/python path/to/webhook_server.py
WorkingDirectory=direc/to/blog
Environment="ENV_FILE=path/to/.env"
Environment="GEM_HOME=gem/home/directory"
Environment="PATH=gem/home/directory/bin:/usr/bin:/usr/local/bin"
Environment="DOCKER_HOST:=unix:///run/user/{uid}/docker.sock"
Restart=always
[Install]
WantedBy=default.target
After saving, run the following command to automatically build the blog when a webhook is received.
systemctl --user enable blog-webhook.service
systemctl --user start blog-webhook.service
Change link format
The last thing to modify is the image or page link.
Until now, since the github blog repository was publicly available, it was possible to link images or pages using the github url.
However, this is no longer possible.
Therefore, we need to refer to the image by reflecting the directory structure, and the jekyll-relative-links plugin helps with this.
To use it, modify the Gemfile as follows.
source "https://rubygems.org"
gemspec
gem "webrick", "~> 1.7"
gem "mini_racer"
group :jekyll_plugins do
gem "jekyll-relative-links", "~> 0.6.1"
end
At this time (2024.07.21), version 0.7.0 cannot be built for some reason, so it is recommended to force version 0.6.1.
After running the bundle install command again, the plugin will be installed.
With the help of this plugin, images can be loaded in the following way.
Image : 
Image : 