What better thing to have as the first / only item in a blog than a discussion of how the blog was created? Be warned, it’s pretty cursed.

Definitions & Links

  • Obsidian is my preferred note-taking solution.
    • I’ve got a vault which generally follows the structure suggested in Building a Second Brain.
    • There’s also separate sections for templates, daily notes (which I mainly use to track various metrics about my day), and this website.
    • It’s backed up in a git repo, using the git plugin.
  • The website you’re reading now is generated using Quartz.
    • This seems to be the most Obsidian-compatible static site generator, and is even reasonably flexible with its plugin system.

Why I bothered making it complicated

Quartz is great out of the box, but it behaved in a way that didn’t fit well with my existing workflow.

The recommended way to use it is to fork the main repo, commit whatever changes you want to the config files, and commit your content into the content folder. When you want to update to a new version of Quartz, you either merge in the upstream changes or rebase. This seems like a lot of work to me, and I prefer using Docker images which let me apply updates quite easily.

I also only wanted to publish a subset of my Obsidian vault, which is already in its own Git repo. Quartz expects you to put your entire vault into it and filter out the content you don’t want to publish, but that would have resulted in a lot of residual mess throughout the rest of my vault. It also publishes all non-Markdown content in your vault by default, which I wanted to avoid.

My self-hosting platform is mainly Docker-focused. I won’t go into it in much detail here, but I use a combination of docker compose, Caddy, and Traefik. Quartz does supply a Docker image, but I didn’t like it much so decided to make my own.

Finally, I wanted to make a container which can hot-reload changes to my vault as I commit them. This is where the cursed part comes in - I much prefer a Docker workflow where changes are built into an immutable container and served later. However, my self-hosting platform doesn’t have any CI (because I can’t be bothered), so I have to do it myself and it’s gross!

How I complicated it

The git repo

Instead of using a fork of the Quartz repo, I’ve got a new plain repo on my private git server (using Forgejo). It’s got the Quartz repo as a submodule, meaning I can update the version of Quartz without having to mess with any merge conflicts or complicated rebase processes.

I end up with a fairly small number of files in the git repo, all of which relate directly to my vault’s interaction with Quartz. This means things are kept nice and separate from one another. It’s about the only nice part of this project.

The Dockerfile

My customisations to the upstream config are all simple files in the root of the repo, which I copy into the tree as part of my Docker build process. I could use a more detailed folder structure for this, but there’s not really enough individual files for me to be particularly bothered. You can see my entire Dockerfile below.

FROM node:20-slim AS builder
 
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && apt-get clean && rm -rf /var/lib/apt/lists/*
 
COPY quartz /usr/src/app
WORKDIR /usr/src/app
RUN rm -r .git content
RUN npm ci --foreground-scripts
 
COPY quartz.config.ts /usr/src/app/quartz.config.ts
COPY quartz.layout.ts /usr/src/app/quartz.layout.ts
COPY runner.sh /runner.sh
COPY get_token.sh /get_token.sh
 
EXPOSE 8080
CMD ["/runner.sh"]

This combines the official Quartz install instructions with a few other tweaks:

  • git, ca-certificates, and nginx are surprise tools which will help us later.
  • The .git file and content directory are removed from the Quartz installation. .git has content reflecting the repo’s state as a submodule which just confuses itself, and content will be replaced later.
    • This could be done with a .dockerignore file, but I choose to be explicit here.
  • npm ci --foreground-scripts means I actually get output from npm ci, which is helpful if you ever run into issues. Docker eats the output during an interactive build anyway, so I don’t care about polluting my terminal.
  • Finally, I copy in all my custom stuff (including my Quartz config files) and a couple of helper scripts.

The helper scripts

There’s two helper scripts. First up is runner.sh, the entrypoint into the Docker container.

#!/bin/bash
 
set -euo pipefail  
  
git config --global credential.https://git.wijk.xyz.username daniel  
export GIT_ASKPASS=/get_token.sh  
  
rm -rf content  
git clone https://git.wijk.xyz/personal/obsidian.git content  
  
npx quartz build -d content/Public -o public --serve &  
nginx  
  
while true; do  
	sleep 300
	(cd content && git pull | (grep -v "Already up to date." || true))  
done

Honestly, I wouldn’t recommend doing it like this. It works for this scenario, but it’s definitely not the right way to use Docker. Let’s go through it in detail.

First up, I configure git to use a custom API token for my account, which gives it read-only access to my private repos. Let’s also look at get_token.sh while we’re here:

#!/bin/bash
echo $GIT_KEY

That’s a secret I pass in as an environment variable via docker compose.

Next up, I re-delete the content folder (in case the container was restarted in an unclean state), and clone my Obsidian vault into the content directory where Quartz expects it.

In a background process, I let npx quartz build --serve loose on the specific subdirectory I want to publish. I only let it --serve because I want it to reload the static content as it comes through.

This comes with the downside that any files which are deleted from my vault will have their published variants lying around - as far as I’m concerned, this is a bug in Quartz and I’m not too fussed about working around it. If I do delete content, I can just go and recreate the container manually.

I also use the -d argument to tell it to use the Public directory of my vault as its content root.

Nginx is also running in its own process to serve the resulting static content. This is a pretty standard setup so I’ll skip the details.

Finally, the while true. Every 5 minutes, I do a git pull on my obsidian repo, ignoring the output of pulls which do nothing. Quartz will then pick up any content changes and rebuild the static content, which nginx will begin serving.

docker compose

This is not that interesting and pretty specific to how my platform works, but I’ll include it for completeness. The bulk of it is just to make traefik forward traffic through to nginx. I also use a .gitignored env_file with my git API token.

services:
	quartz:
		build:
			context: .
			dockerfile: Dockerfile
		restart: unless-stopped
		env_file:
			secret.env
		labels:
			- "traefik.enable=true"
			- "traefik.http.routers.quartz.entrypoints=web"
			- "traefik.http.routers.quartz.service=quartz"
			- "traefik.http.routers.quartz.rule=Host(`ooster.wijk.xyz`)"
			- "traefik.http.services.quartz.loadbalancer.server.port=80"
		networks:
			traefik:
 
networks:
	traefik:
		external: true

Conclusion

Gross, right? I wouldn’t recommend doing it like this, you should really just do proper CI and use Docker properly.