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, andnginxare surprise tools which will help us later.- The
.gitfile andcontentdirectory are removed from the Quartz installation..githas content reflecting the repo’s state as a submodule which just confuses itself, andcontentwill be replaced later.- This could be done with a
.dockerignorefile, but I choose to be explicit here.
- This could be done with a
npm ci --foreground-scriptsmeans I actually get output fromnpm 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))
doneHonestly, 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_KEYThat’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: trueConclusion
Gross, right? I wouldn’t recommend doing it like this, you should really just do proper CI and use Docker properly.