repertory-api/nixos/module.nix

201 lines
7 KiB
Nix
Raw Permalink Normal View History

2026-06-09 15:07:08 -04:00
# NixOS module for repertory-api
#
# This module fetches the application source directly from a self-hosted Forgejo instance at a pinned
# tag/commit, builds a Python environment, runs Alembic migrations on start,
# and manages the Flask service under systemd.
#
# ── Typical usage in configuration.nix ───────────────────────────────────────
#
# imports = [ (fetchTarball {
# url = "https://git.yourdomain.com/yourusername/repertory-api/archive/v1.0.0.tar.gz";
# sha256 = "0000000000000000000000000000000000000000000000000000";
# } + "/nixos/module.nix") ];
#
# ── Secrets ──────────────────────────────────────────────────────────────────
#
# The module expects two sops-nix secrets to exist. Add to configuration.nix:
#
# sops.secrets."repertory-api-key" = {};
# sops.secrets."repertory-db-url" = {};
#
# ── Minimal configuration.nix example ────────────────────────────────────────
#
# services.repertory-api = {
# enable = true;
# domain = "git.yourdomain.com";
# owner = "yourusername";
# version = "v1.0.0";
# rev = "abc123..."; # git commit SHA for the tag
# sha256 = "sha256-..."; # `nix-prefetch-url --unpack <tarball url>`
# };
#
# ── Updating ─────────────────────────────────────────────────────────────────
#
# 1. Push a new tag to your Forgejo instance (e.g. v1.1.0).
# 2. Get the new sha256:
# nix-prefetch-url --unpack \
# https://git.yourdomain.com/yourusername/repertory-api/archive/v1.1.0.tar.gz
# 3. Update `version`, `rev`, and `sha256` in your configuration.nix.
# 4. nixos-rebuild switch
# → Nix fetches the new source, rebuilds the Python env, restarts the
# service, and runs migrations automatically in preStart.
#
# ─────────────────────────────────────────────────────────────────────────────
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.repertory-api;
# Fetch the application source from the self-hosted Forgejo instance at the
# pinned rev. Nix verifies the sha256 hash, so this is fully reproducible.
appSrc = pkgs.fetchFromGitea {
domain = cfg.domain;
owner = cfg.owner;
repo = "repertory-api";
rev = cfg.rev;
sha256 = cfg.sha256;
};
# Build the Python environment with all runtime dependencies.
# This mirrors pyproject.toml [project.dependencies].
# uv is a local dev tool — on NixOS, Nix itself plays the role of
# dependency resolver and the Python env is built at evaluation time.
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
flask
flask-sqlalchemy
sqlalchemy
psycopg2
python-dotenv
alembic
]);
in {
options.services.repertory-api = {
enable = mkEnableOption "Repertory API";
domain = mkOption {
type = types.str;
description = "Hostname of your self-hosted Forgejo instance.";
example = "git.yourdomain.com";
};
owner = mkOption {
type = types.str;
description = "Forgejo username / org that owns the repertory-api repo.";
example = "yourusername";
};
version = mkOption {
type = types.str;
description = "Human-readable version label (used only for documentation/logging).";
example = "v1.0.0";
};
rev = mkOption {
type = types.str;
description = "Git commit SHA for the tag you want to deploy.";
example = "abc1234def5678";
};
sha256 = mkOption {
type = types.str;
description = ''
sha256 hash of the fetched source tarball.
Obtain with:
nix-prefetch-url --unpack \
https://git.yourdomain.com/<owner>/repertory-api/archive/<rev>.tar.gz
'';
example = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
port = mkOption {
type = types.port;
default = 5000;
description = "Port the Flask app listens on.";
};
user = mkOption {
type = types.str;
default = "repertory";
description = "Unix user to run the service as.";
};
group = mkOption {
type = types.str;
default = "repertory";
description = "Unix group to run the service as.";
};
apiKeySecret = mkOption {
type = types.str;
default = "/run/secrets/repertory-api-key";
description = "Path to the SOPS-decrypted file containing the raw API key.";
};
dbUrlSecret = mkOption {
type = types.str;
default = "/run/secrets/repertory-db-url";
description = "Path to the SOPS-decrypted file containing the DATABASE_URL string.";
};
};
config = mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = "/var/lib/repertory-api";
createHome = true;
};
users.groups.${cfg.group} = {};
systemd.services.repertory-api = {
description = "Repertory API (Flask) ${cfg.version}";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "postgresql.service" ];
serviceConfig = {
User = cfg.user;
Group = cfg.group;
WorkingDirectory = appSrc;
Restart = "on-failure";
RestartSec = "5s";
ExecStart = "${pythonEnv}/bin/python -m flask --app wsgi:app run --host=0.0.0.0 --port=${toString cfg.port}";
# Secrets are injected via the env file written in preStart.
EnvironmentFiles = [ "/run/repertory-api/env" ];
# Security hardening
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
NoNewPrivileges = true;
# /run/repertory-api is on tmpfs and holds only the mode-600 env file.
ReadWritePaths = [ "/run/repertory-api" ];
};
preStart = ''
# Write secrets into a tmpfs env file so nothing is on-disk in plaintext.
mkdir -p /run/repertory-api
chmod 700 /run/repertory-api
printf 'API_KEY=%s\n' "$(cat ${cfg.apiKeySecret})" > /run/repertory-api/env
printf 'DATABASE_URL=%s\n' "$(cat ${cfg.dbUrlSecret})" >> /run/repertory-api/env
chmod 600 /run/repertory-api/env
# Run any pending Alembic migrations before the server starts.
# On a fresh database this creates all tables; on subsequent deploys
# it applies only new migration files.
export DATABASE_URL="$(cat ${cfg.dbUrlSecret})"
cd ${appSrc}
${pythonEnv}/bin/python -m alembic upgrade head
'';
};
};
}