Building a Website on NixOS with Zola
Table of Contents
Why Zola?
Recently, I've gotten really into NixOS, and I am now running it on 4 devices, including a small homeserver. For this homeserver, I wanted to explore how difficult it would be to set up a small static website with a blog and maybe an about page. At first I thought about doing it in raw html and css, without any js, but then I stumbled upon zola.
Zola is a static site generator written in Rust, simple to set up and configure, and you can just write your pages in markdown and use one of the many themes, which is very convenient if you're not great at webdesign but still just wanna get a website up and running quickly without any fuss.
The Plan
My NixOS is deployed using flakes, however, I didn't want to deploy my website using it's own flake as described here or here. Instead, I wanted to write a module that seamlessly integrates into my existing system configuration. My goals with this module are simple:
- integrate with my existing nginx setup
- automatically rebuild when I change the content
- backup the source files so that I don't loose the content I have written
Writing the Module
With these goals in mind, let's get right into it!
First, let's take a look at the options that I defined:
options.homelab.services.zola = {
enable = lib.mkEnableOption "Zola static site generator & server";
sourceDir = lib.mkOption {
type = lib.types.path;
description = "Path to your blog source directory";
example = "/home/user/blog";
};
sourceOwner = lib.mkOption {
type = lib.types.str;
description = "User that owns your blog source directory";
example = "user";
};
outputDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/zola-blog";
description = "Directory where built site will be stored";
};
};
These are pretty self explanatory, I think. The reason we set the sourceOwner is that we will run the systemd service that watches the source directory and rebuilds the website upon changes under this user to ensure that the service can access it without giving it full root privileges.
We will see this in just a bit.
Backup
This one is pretty easy. Since my homelab has a properly configured restic backup service, we just need to add the sourceDir to the list of directories that are backed up like so:
homelab.services.restic.backupDirs = [ cfg.sourceDir ];
Nginx Integration
Serving the page with nginx was also not too difficult, since I already have a working ssl setup that I can tap into:
services.nginx.virtualHosts."blog.${hl.baseDomain}" = {
enableACME = true;
acmeRoot = null;
forceSSL = true;
root = "${cfg.outputDir}/www";
locations."/" = {
index = "index.html";
tryFiles = "$uri $uri/ =404";
};
};
There is actually a good reason why I used a subdirectory of outputDir as the real location for the files that make up the website.
When I rebuild the website using zola build --force, it first attempts to remove its output directory and then creates a new one with the new content.
The problem is that I wanted to use /var/lib as the parent for the output directory, however, this directory is owned by the root user, so zola is unable to create a new directory in it after deleting the previous one.
So to fix this problem, I just gave zola appropriate permissions for the output directory but only let it work in a subdirectory, so that the outputDir (which zola has permission for) doesn't get deleted.
I also added this systemd rule to ensure the output directory exists and has the correct permissions set:
systemd.tmpfiles.rules = [
"d ${cfg.outputDir} 0755 ${cfg.sourceOwner} nginx -"
];
Automatic Builds!
Finally, the interesting part of the module.
Here we define a new systemd service, which runs with the privileges of the owner of the source files and runs zola build --force whenever there are changes to any of the files in the source directory.
Most of this is done inside of the script part of the service.
We use watchexec which then recursively watches for any changes to these files and triggers a rebuild of the website if detected.
systemd.services.zola-blog-server = {
description = "Watch for blog changes and rebuild";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
Type = "simple";
User = cfg.sourceOwner;
WorkingDirectory = cfg.sourceDir;
Restart = "always";
RestartSec = 5;
# Security restrictions
NoNewPrivileges = true;
PrivateTmp = true;
# Network restrictions (zola build doesn't need network)
PrivateNetwork = true;
};
script = ''
${pkgs.watchexec}/bin/watchexec --watch ${cfg.sourceDir} --exts md,toml,html -- \
${pkgs.zola}/bin/zola build --output-dir ${cfg.outputDir}/www --force
'';
path = [ pkgs.watchexec pkgs.zola ];
};
If you want to have your own blog and this inspired you to make one the same way, feel free to copy my code. The whole module can be found in my dotfiles on github here.
I'm hoping to post some more NixOS related things, but also about other topics, so stay tuned for that :)