Last time, I left the safe waters of a blog platform service in favour of a self hosted solution. I've opted for Wagtail, which is a Django-based CMS as I have plenty of python experience.
This article describes how I've built the page block by block, added some Tailwind CSS on top along with custom footnotes and table of contents. Then I went on to deploy the site on a VPS using nginx, gunicorn and systemd.
It feels good to have (practically) full control over my blog. As I'm not in this for the money, I am not that nervous about potential issues. And yes, the scanners and AI crawlers are already here - it's not that lethal to blogs. Let's dive in.
I'd like to describe Wagtail as Wordpress from IKEA. You don't get a working product, there is some assembly required. This is explained in the zen of Wagtail, which I agree with fully. I am still figuring things out with Wagtail. But I like it so far.
If I had a cent for every time I've blogged with python-based publishing system named after a bird, I would have 2 cents.
Wagtail is build upon Python/Django, which puts me in familiar territory. You get all of the good stuff like:
Conceptually, Wagtail divides pages into fields (and then blocks). Each field/block has its own format and definitions. The page templates then align these fields/blocks for a coherent experience.
This also means that migrating content is somewhat tedious. You need to break articles down into these blocks. That's how I've realized how wordy my articles are, I'll try more for brevity.
Of course, plain HTML won't do the trick. I decided to try something modern and I've heard good things about Tailwind CSS framework. Well, I love it. I find it easy to use, especially with the pre-built CLI tool that lets me escape npm dependency hell.
I found it quite easy to tackle all of my issues. Dark mode? Sprinkle a bit of javascript. Responsive design? First-class citizen in Tailwind. Docs? Ready to roll.
I am confident that I could build a beautiful site with it if I had the skills. Which I don't have. You could also use LLMs, provided you have taste for good webdesign. Which I lack too. If I offend your eyes, please let me know, I might fix it.
I am probably just weird, but to me there's something calming about working with CSS and colors. No ridiculous errors with deep stack traces, the thing is just a bit to the left. Let's gently push it back. Ah, but now the image doesn't fit. No matter, let's resize it then. And let's adjust this color slightly while we're at it. So comfy.
I got quite used to footnotes - you can add more info for the interested reader, while keeping the page flow faster for others. Sadly, it has to be added to Wagtail, it's not built in.
To implement footnotes I've added a custom footnote block, with an anchor containing the footnote id. To link to footnotes I've just used the anchor link in Wagtail.
To link back to text in article from footnote, I've overriden the anchor link definition to include an id I can reference:
from wagtail.rich_text import LinkHandler
class BackLinkHandler(LinkHandler):
identifier = "anchor"
@classmethod
def expand_db_attributes(cls, attrs):
href = attrs["href"]
return f'<a href="{href}" id="back-{href[1:]}">'
To have a table of contents, you need to point to headings. Rather than parsing them out, I've put them into dedicated blocks (which made the migration even more painful). But I could then reach out for them like so:
def get_context(self, request):
context = super().get_context(request)
table_of_contents = list()
p_map = {1: 1, 2: 2, 3: 4, 4: 8, 5: 9, 6: 10} # these are for Tailwind. Ugly, yes.
table_of_contents.append({"heading": self.title, "level": p_map[1]})
for block in self.body:
if (
type(block.value) == blocks.struct_block.StructValue
and "heading" in block.value
):
table_of_contents.append(
{
"heading": block.value["heading"],
"level": p_map[block.value["level"]],
}
)
context["table_of_contents"] = table_of_contents
return context
There was quite some fiddling with embeds. First, since I have a datawrapper tables in couple articles, I needed to include a custom embed fetcher as it's not included by default.
Then, as I've tried to embed YouTube, it was tiny. Using responsive embeds setting is not the way as it breaks data wrapper in weird padding ways.
So I've inserted a custom YouTube embed block + a bit of CSS (16:9 might not be always ideal, but I like it better than padding hacks):
div.block-youtube {
position: relative;
width: 100%;
max-width: 100%;
aspect-ratio: 16 / 9;
}
div.block-youtube div, div.block-youtube div iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
Since I own the domains of my blog, I have the option to keep the old links alive. Fortunately, all I had to do was add this to the nginx config:
rewrite ^/p/(.*)$ /$1 last;
rewrite ^/feed.$ /rss last;
I use Uptime Kuma and NTFY for my monitoring/alerting needs. I have nothing more to say, it just works and it works well.
I was thinking about docker, but I've decided to go old school with virtual environment, scripts, duct tape and prayers to Omnissiah (the Machine God). Sometimes you need a bit of pain in your life, you know?
Here's a short collection of the scripts and cronjobs:
They are quite mundane and simple, so I won't waste space with them.
It's trendy to hate on systemd (linux system manager). I've learned to trust it and use it and I am mostly happy with this decision. So I've created a systemd service for the gunicorn webserver (here's the reason why add another server to the mix 1).
This is a good idea, because you have system support for various events out of the box - like readiness check and signal handling. Here's the code:
[Unit]
Description=Wagtail Blog
Requires=wagtail-blog.socket
After=network.target
[Service]
Restart=always
EnvironmentFile=/home/wagtail/.env
WorkingDirectory=/home/wagtail/wagtail-blog
ExecStartPre=/home/wagtail/wagtail-blog/scripts/entrypoint.sh
ExecStart=/home/wagtail/wagtail-blog/.venv/bin/gunicorn -w5 --bind unix:/home/wagtail/wagtail-blog.sock mh_blog.wsgi
Type=notify
NotifyAccess=main
# User=wagtail
# Group=wagtail
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=5
# PrivateTmp=true
[Install]
WantedBy=default.target
Since I had a dedicated linux user for this site, I had the bright idea of setting the service into userspace. It mostly works, with some gotchas:
su wagtail
is not enough) to view journalctl
,PrivateTMP=True
completely broke my site and I didn't bother figuring out why.loginctl enable-linger wagtail
as root, your service will be killed on log-out.The last one was particularly nasty as I thought I've done it and it's ready, only for the monitor to start beeping couple minutes after I closed the laptop. I've decided to tackle it in the morning before work, capturing this vibe:
If you are reading this, the site probably works. This is the first version of this site I am willing to publish. There might be more changes coming.
Overall, it feels good to have full control over my site. I know that my discoverability will take a hit, but I've come to the terms with not having a world famous blog. The much bigger problem is my inconsistent writing schedule and that I cannot really align with my other goals.
I am thinking whether to recommend self-hosting to you, dear reader. If you read it up to this point without clicking away in boredom/disgust, you are probably ready to tackle it. It takes some time, but not that much. The bigger problem is the breadth of the skills needed to do so, but with LLMs getting better, it's probably the best time to try it.
You can't just run python and point web traffic to it. Normally, you need to open a port for listening, handle connections on socket levels, do all of the TCP handshakes, set up encryption/decryption for TLS. Then you need to deal with HTTP - methods, paths, headers and the like.
Of course, you don't want to deal with all of this, especially when there are mature and open source projects like nginx. Ideally, you'd use that.
PEP 333 defines the Web Server Gateway Interface (WSGI) for both web servers and python apps to communicate (there's an update in PEP 3333). The server side invokes a callable object that is provided by the application side. Nginx implements the server side, your app implements the client side and you're good.
For one reason or another, it is common to insert a gunicorn middle-man in between nginx and python. Then nginx uses standard web proxying to talk to gunicorn which is specifically built to invoke python. My reason is that it's much simpler to setup and it's performant enough.