Initial commit: Quartz NixOS module as flake

- quartz.nix: NixOS module for Quartz static site hosting
- flake.nix: Flake wrapper exposing nixosModules.quartz

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
padreug 2026-01-15 12:31:12 +01:00
commit dc94f9d5bc
4 changed files with 316 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
result

38
CLAUDE.md Normal file
View file

@ -0,0 +1,38 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This repository contains a single NixOS module (`quartz.nix`) that provides a service for hosting Quartz static sites. Quartz is a fast, batteries-included static site generator that transforms Markdown content into full featured websites.
## Architecture
The module creates a complete deployment system:
1. **Git-based content management** - Clones and pulls content from a configurable git repository
2. **Build service** (`quartz-pull-and-build.service`) - Handles npm install and `npx quartz build`
3. **Timer** - Periodic rebuilds (default: every 15 minutes) as fallback
4. **Webhook server** (optional) - Listens for Forgejo/GitHub push events to trigger immediate rebuilds
5. **nginx vhost** - Serves the built static files with ACME/SSL
Key assumption: nginx and ACME must be configured elsewhere in the NixOS configuration.
## Testing Changes
To test this module, include it in a NixOS configuration:
```nix
{ pkgs, ... }:
{
imports = [ ./quartz.nix ];
services.quartz = {
enable = true;
domain = "docs.example.com";
gitRepo = "https://git.example.com/user/quartz-notes.git";
};
}
```
Validate syntax with: `nix-instantiate --parse quartz.nix`

10
flake.nix Normal file
View file

@ -0,0 +1,10 @@
{
description = "Quartz static site NixOS module";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }: {
nixosModules.quartz = import ./quartz.nix;
nixosModules.default = self.nixosModules.quartz;
};
}

267
quartz.nix Normal file
View file

@ -0,0 +1,267 @@
# Quartz NixOS module for static site generation
# Builds Quartz from a git repository and serves via nginx
# Supports webhook-triggered rebuilds from Forgejo/GitHub
#
# Note: This module assumes nginx and ACME are already configured elsewhere
{ config, pkgs, lib, ... }:
let
cfg = config.services.quartz;
# Compute actual output path: custom if set, otherwise public/ as sibling to content dir
# e.g., if contentPath is /var/lib/quartz/content, output is /var/lib/quartz/public
actualOutputPath = if cfg.outputPath != ""
then cfg.outputPath
else builtins.dirOf cfg.contentPath + "/public";
# Build location inside repo (Quartz default)
buildOutputDir = "${cfg.contentPath}/public";
# Webhook configuration file for the webhook server
webhookConfig = pkgs.writeText "quartz-webhook.json" (builtins.toJSON [
{
id = "quartz-rebuild";
execute-command = "${pkgs.systemd}/bin/systemctl";
command-working-directory = "/tmp";
pass-arguments-to-command = [
{ source = "string"; name = "start"; }
{ source = "string"; name = "quartz-pull-and-build.service"; }
];
trigger-rule = {
match = {
type = "value";
value = "{{webhook-secret}}";
parameter = {
source = "header";
name = "X-Forgejo-Signature";
};
};
};
}
]);
in
{
options.services.quartz = {
enable = lib.mkEnableOption "Quartz static site generator";
domain = lib.mkOption {
type = lib.types.str;
description = "Domain name for the Quartz site";
example = "docs.example.com";
};
gitRepo = lib.mkOption {
type = lib.types.str;
description = "Git repository URL for Quartz content";
example = "https://git.example.com/user/quartz-notes.git";
};
gitBranch = lib.mkOption {
type = lib.types.str;
default = "main";
description = "Git branch to track";
};
contentPath = lib.mkOption {
type = lib.types.str;
default = "/var/lib/quartz/content";
description = "Path to Quartz content (cloned from git)";
};
outputPath = lib.mkOption {
type = lib.types.str;
default = ""; # Empty means use Quartz default (contentPath/public)
description = "Path for built static files. Empty uses Quartz default (public/ inside content dir)";
};
rebuildInterval = lib.mkOption {
type = lib.types.str;
default = "*:0/15";
description = "Systemd calendar expression for rebuild timer (default: every 15 min)";
};
webhook = {
enable = lib.mkEnableOption "webhook receiver for auto-rebuild on git push";
port = lib.mkOption {
type = lib.types.port;
default = 9000;
description = "Port for webhook receiver";
};
secretFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Path to file containing webhook secret";
example = "/run/secrets/quartz-webhook-secret";
};
};
};
config = lib.mkIf cfg.enable {
# Systemd service to clone/pull and build Quartz
systemd.services.quartz-pull-and-build = {
description = "Pull latest Quartz content and rebuild";
after = [ "network.target" ];
path = [ pkgs.nodejs_22 pkgs.git pkgs.bash pkgs.coreutils pkgs.rsync ];
serviceConfig = {
Type = "oneshot";
User = "quartz";
Group = "quartz";
};
script = ''
set -euo pipefail
CONTENT_PATH="${cfg.contentPath}"
BUILD_DIR="${buildOutputDir}"
SERVE_DIR="${actualOutputPath}"
NEEDS_BUILD=0
# Clone if not exists
if [ ! -d "$CONTENT_PATH/.git" ]; then
echo "Cloning repository..."
git clone --branch ${cfg.gitBranch} ${cfg.gitRepo} "$CONTENT_PATH"
cd "$CONTENT_PATH"
NEEDS_BUILD=1
else
cd "$CONTENT_PATH"
# Check for changes before pulling
git fetch origin
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/${cfg.gitBranch})
if [ "$LOCAL" != "$REMOTE" ]; then
echo "Changes detected, pulling..."
git reset --hard origin/${cfg.gitBranch}
NEEDS_BUILD=1
fi
fi
# Force build if node_modules missing or serve dir empty
if [ ! -d "node_modules" ]; then
echo "node_modules missing, forcing build..."
NEEDS_BUILD=1
fi
if [ ! -d "$SERVE_DIR" ] || [ -z "$(ls -A $SERVE_DIR 2>/dev/null)" ]; then
echo "Serve directory empty, forcing build..."
NEEDS_BUILD=1
fi
if [ "$NEEDS_BUILD" = "0" ]; then
echo "No changes detected, skipping build."
exit 0
fi
# Install dependencies if needed or if package.json changed
if [ ! -d "node_modules" ] || [ "package.json" -nt "node_modules" ]; then
echo "Installing dependencies..."
npm ci
fi
# Build the site to default location (inside repo)
echo "Building Quartz..."
npx quartz build
# Sync to serving directory (avoids EBUSY since we don't delete the dir)
echo "Syncing to serve directory..."
mkdir -p "$SERVE_DIR"
rsync -a --delete "$BUILD_DIR/" "$SERVE_DIR/"
echo "Quartz build complete: $(date)"
'';
};
# Timer for periodic rebuilds (fallback if webhook misses)
systemd.timers.quartz-pull-and-build = {
description = "Periodically pull and rebuild Quartz site";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.rebuildInterval;
Persistent = true;
RandomizedDelaySec = "1min";
};
};
# Webhook receiver for Forgejo/GitHub push events
systemd.services.quartz-webhook = lib.mkIf cfg.webhook.enable {
description = "Webhook receiver for Quartz rebuilds";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = "quartz";
Group = "quartz";
Restart = "always";
RestartSec = "5s";
};
script = ''
# Read the webhook secret
SECRET=$(cat ${cfg.webhook.secretFile})
# Create webhook config with actual secret
CONFIG=$(mktemp)
${pkgs.gnused}/bin/sed "s/{{webhook-secret}}/$SECRET/" ${webhookConfig} > "$CONFIG"
# Start webhook server
exec ${pkgs.webhook}/bin/webhook \
-hooks "$CONFIG" \
-port ${toString cfg.webhook.port} \
-verbose
'';
};
# Create quartz user and group
users.users.quartz = {
isSystemUser = true;
group = "quartz";
home = "/var/lib/quartz";
createHome = true;
homeMode = "755"; # Allow nginx to traverse for serving files
};
users.groups.quartz = {};
# Ensure directories exist with correct permissions
systemd.tmpfiles.rules = [
"d ${cfg.contentPath} 0755 quartz quartz -"
"d ${actualOutputPath} 0755 quartz quartz -"
];
# nginx virtual host (assumes nginx is already enabled elsewhere)
services.nginx.virtualHosts.${cfg.domain} = {
forceSSL = true;
enableACME = true;
root = actualOutputPath;
locations."/" = {
tryFiles = "$uri $uri/ $uri.html =404";
extraConfig = ''
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
'';
};
# Custom 404 page (Quartz generates one)
extraConfig = ''
error_page 404 /404.html;
'';
# Webhook endpoint (proxied to webhook server)
locations."/webhook" = lib.mkIf cfg.webhook.enable {
proxyPass = "http://127.0.0.1:${toString cfg.webhook.port}";
extraConfig = ''
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
'';
};
};
};
}