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:
commit
dc94f9d5bc
4 changed files with 316 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
result
|
||||
38
CLAUDE.md
Normal file
38
CLAUDE.md
Normal 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
10
flake.nix
Normal 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
267
quartz.nix
Normal 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;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue