replace app with discourse module

This commit is contained in:
Yisroel Baum 2026-07-19 10:29:50 +03:00
parent 63a68f8ae0
commit 95cdeaedfa
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
236 changed files with 357 additions and 31169 deletions

View file

@ -1,301 +1,144 @@
{ self }:
{ config, lib, pkgs, ... }:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.tide;
defaultBackend = self.packages.${pkgs.system}.tide-backend;
defaultFrontend = self.packages.${pkgs.system}.tide-frontend.override {
apiUrl = "https://${cfg.apiDomain}";
};
# The Laravel package lives in the Nix store (read-only). Laravel
# needs a writable storage/ and bootstrap/cache/. We materialize a
# writable copy at /var/lib/tide/app whose contents are symlinks
# back into the store, except for storage/ and bootstrap/cache/
# which are real writable directories under /var/lib/tide/state.
appRoot = "/var/lib/tide/app";
stateRoot = "/var/lib/tide/state";
poolName = "tide";
fpmSocket = "/run/phpfpm/${poolName}.sock";
dumpCommand = lib.concatStringsSep " " [
"${config.services.postgresql.package}/bin/pg_dump"
"--format=custom"
"--dbname=${config.services.discourse.database.name}"
];
secretFileOption =
description:
lib.mkOption {
type = lib.types.path;
inherit description;
};
in
{
options.services.tide = {
enable = lib.mkEnableOption "TIDE blogging platform";
enable = lib.mkEnableOption "Torah Im Derech Eretz forum";
domain = lib.mkOption {
type = lib.types.str;
example = "tide.example.com";
description = "Domain serving the Vue frontend.";
};
apiDomain = lib.mkOption {
type = lib.types.str;
example = "apitide.example.com";
description = "Domain serving the Laravel backend API.";
};
user = lib.mkOption {
type = lib.types.str;
default = "tide";
description = "Unix user the Laravel process runs as.";
};
group = lib.mkOption {
type = lib.types.str;
default = "tide";
description = "Unix group the Laravel process runs as.";
};
database = {
name = lib.mkOption {
type = lib.types.str;
default = "tide";
description = "PostgreSQL database name.";
};
user = lib.mkOption {
type = lib.types.str;
default = "tide";
description = "PostgreSQL role used by Laravel.";
};
};
secretsFile = lib.mkOption {
type = lib.types.path;
description = ''
Path to a file containing environment variables for the
Laravel pool. Must define APP_KEY at minimum. Read at
service start, never copied into the Nix store.
'';
};
backendPackage = lib.mkOption {
type = lib.types.package;
default = defaultBackend;
defaultText = lib.literalExpression
"self.packages.\${pkgs.system}.tide-backend";
description = "The Laravel backend derivation.";
};
frontendPackage = lib.mkOption {
type = lib.types.package;
default = defaultFrontend;
defaultText = lib.literalExpression
"self.packages.\${pkgs.system}.tide-frontend";
description = "The Vue frontend derivation (built static dist).";
};
nginx = {
useACMEHost = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "example.com";
description = ''
Reuse an existing wildcard ACME cert (set this to the
apex domain whose cert covers both subdomains). When
null, each vhost requests its own cert via
enableACME = true.
'';
};
secretFiles = {
adminPassword = secretFileOption "Discourse administrator password file.";
mailPassword = secretFileOption "Mailjet SMTP password file.";
secretKeyBase = secretFileOption "Discourse secret key base file.";
borgPassphrase = secretFileOption "Borg repository passphrase file.";
borgPrivateKey = secretFileOption "Borg repository SSH private key file.";
};
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = "/var/lib/tide";
services.discourse = {
enable = true;
admin = {
email = "yisroel.d.baum@gmail.com";
fullName = "Yisroel Baum";
passwordFile = cfg.secretFiles.adminPassword;
username = "yisroeldbaum";
};
database.ignorePostgresqlVersion = true;
hostname = "discourse.torahimderecheretz.com";
mail = {
notificationEmailAddress = "system@torahimderecheretz.com";
contactEmailAddress = "system@torahimderecheretz.com";
outgoing = {
serverAddress = "in-v3.mailjet.com";
port = 587;
username = "1ebdd73d944240c1a9c3201e52a1e8c6";
passwordFile = cfg.secretFiles.mailPassword;
authentication = "login";
};
};
secretKeyBaseFile = cfg.secretFiles.secretKeyBase;
siteSettings.required = {
title = "Torah Im Derech Eretz";
site_description = "A forum to discuss the ideas of Rav Shamshon Refael Hirsch";
};
};
services.borgbackup.jobs.discourse = {
paths = [
"/var/lib/discourse"
"/var/backup/discourse/discourse.dump"
];
repo = "ssh://oas17j8p@oas17j8p.repo.borgbase.com/./repo";
user = "root";
group = "root";
encryption = {
mode = "repokey-blake2";
passCommand = "${pkgs.coreutils}/bin/cat ${cfg.secretFiles.borgPassphrase}";
};
doInit = true;
compression = "auto,zstd";
startAt = "*-*-* 07:15:00";
persistentTimer = true;
prune.keep = {
daily = 7;
weekly = 4;
monthly = 6;
};
readWritePaths = [ "/var/backup/discourse" ];
environment.BORG_RSH = lib.concatStringsSep " " [
"ssh"
"-i ${cfg.secretFiles.borgPrivateKey}"
"-o IdentitiesOnly=yes"
"-o BatchMode=yes"
"-o StrictHostKeyChecking=accept-new"
"-o UserKnownHostsFile=/root/.config/borg/known_hosts"
];
preHook = ''
discourseWasActive=/root/.cache/borg/discourse-was-active
dumpFile=/var/backup/discourse/discourse.dump
if ${pkgs.systemd}/bin/systemctl is-active --quiet discourse.service; then
touch "$discourseWasActive"
${pkgs.systemd}/bin/systemctl stop discourse.service
else
rm -f "$discourseWasActive"
fi
rm -f "$dumpFile"
umask 077
${lib.getExe' pkgs.su "su"} \
-s ${pkgs.runtimeShell} \
${config.services.postgresql.superUser} \
-c '${dumpCommand}' \
> "$dumpFile"
'';
postHook = ''
discourseWasActive=/root/.cache/borg/discourse-was-active
dumpFile=/var/backup/discourse/discourse.dump
rm -f "$dumpFile"
if [ -e "$discourseWasActive" ]; then
${pkgs.systemd}/bin/systemctl start discourse.service
rm -f "$discourseWasActive"
fi
'';
};
users.groups.${cfg.group} = { };
systemd.tmpfiles.rules = [
"d /var/lib/tide 0750 ${cfg.user} ${cfg.group} -"
"d ${stateRoot} 0750 ${cfg.user} ${cfg.group} -"
"d ${stateRoot}/storage 0750 ${cfg.user} ${cfg.group} -"
"d ${stateRoot}/storage/app 0750 ${cfg.user} ${cfg.group} -"
"d ${stateRoot}/storage/app/public 0750 ${cfg.user} ${cfg.group} -"
"d ${stateRoot}/storage/framework 0750 ${cfg.user} ${cfg.group} -"
"d ${stateRoot}/storage/framework/cache 0750 ${cfg.user} ${cfg.group} -"
"d ${stateRoot}/storage/framework/sessions 0750 ${cfg.user} ${cfg.group} -"
"d ${stateRoot}/storage/framework/views 0750 ${cfg.user} ${cfg.group} -"
"d ${stateRoot}/storage/framework/testing 0750 ${cfg.user} ${cfg.group} -"
"d ${stateRoot}/storage/logs 0750 ${cfg.user} ${cfg.group} -"
"d ${stateRoot}/bootstrap-cache 0750 ${cfg.user} ${cfg.group} -"
"d /var/backup/discourse 0700 root root - -"
];
# Materialize the writable app root by symlinking from the
# store package, then redirecting the two mutable subtrees
# to /var/lib/tide/state.
systemd.services.tide-prepare = {
description = "Prepare TIDE Laravel app root";
wantedBy = [ "multi-user.target" ];
before = [
"phpfpm-${poolName}.service"
"tide-migrate.service"
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
set -eu
# Copy the immutable store package into a writable app root.
# Symlinking would defeat Laravel's __DIR__ based path
# resolution: PHP's __DIR__ follows symlinks back to the
# read only store, breaking storage/ and bootstrap/cache/
# writes even when those subtrees are themselves symlinked
# into a writable state dir.
rm -rf ${appRoot}
mkdir -p ${appRoot}
cp -rL ${cfg.backendPackage}/share/php/tide-backend/. ${appRoot}/
chmod -R u+w ${appRoot}
# Redirect the two mutable subtrees to /var/lib/tide/state
# so they survive activations and don't accumulate inside
# the disposable app root copy.
rm -rf ${appRoot}/storage ${appRoot}/bootstrap/cache
ln -sfn ${stateRoot}/storage ${appRoot}/storage
ln -sfn ${stateRoot}/bootstrap-cache ${appRoot}/bootstrap/cache
chown -R ${cfg.user}:${cfg.group} ${appRoot} ${stateRoot}
'';
};
# Postgres
services.postgresql = {
enable = true;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.database.user;
ensureDBOwnership = true;
}
];
};
# PHP-FPM pool
services.phpfpm.pools.${poolName} = {
user = cfg.user;
group = cfg.group;
phpPackage = cfg.backendPackage.passthru.php;
settings = {
"listen.owner" = config.services.nginx.user;
"listen.group" = config.services.nginx.group;
"listen.mode" = "0660";
"pm" = "dynamic";
"pm.max_children" = 16;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 4;
"pm.max_requests" = 500;
"catch_workers_output" = true;
"decorate_workers_output" = false;
"clear_env" = false;
};
};
# Pull APP_KEY, DB_PASSWORD, MAIL_*, etc. from the secrets
# file at start time.
systemd.services."phpfpm-${poolName}" = {
systemd.services.borgbackup-job-discourse = {
after = [
"tide-prepare.service"
"postgresql.service"
"sops-install-secrets.service"
"network-online.target"
"postgresql.target"
"discourse-postgresql.service"
];
requires = [
"tide-prepare.service"
wants = [
"network-online.target"
"postgresql.target"
"discourse-postgresql.service"
];
serviceConfig = {
EnvironmentFile = cfg.secretsFile;
};
};
# One-shot migrations + cache warm.
systemd.services.tide-migrate = {
description = "Run TIDE Laravel migrations and cache warm";
wantedBy = [ "multi-user.target" ];
after = [
"tide-prepare.service"
"postgresql.service"
];
requires = [
"tide-prepare.service"
"postgresql.service"
];
before = [ "phpfpm-${poolName}.service" ];
serviceConfig = {
Type = "oneshot";
User = cfg.user;
Group = cfg.group;
EnvironmentFile = cfg.secretsFile;
WorkingDirectory = appRoot;
};
script = ''
${cfg.backendPackage.passthru.php}/bin/php artisan migrate --force --no-interaction
${cfg.backendPackage.passthru.php}/bin/php artisan storage:link --force || true
${cfg.backendPackage.passthru.php}/bin/php artisan config:cache
${cfg.backendPackage.passthru.php}/bin/php artisan route:cache
'';
};
users.users.${config.services.nginx.user}.extraGroups = [ cfg.group ];
# nginx vhosts
services.nginx = {
enable = true;
virtualHosts = {
"${cfg.apiDomain}" =
let
sslAttrs =
if cfg.nginx.useACMEHost != null then {
forceSSL = true;
useACMEHost = cfg.nginx.useACMEHost;
} else {
forceSSL = true;
enableACME = true;
};
in
sslAttrs // {
root = "${appRoot}/public";
locations = {
"/" = {
tryFiles = "$uri $uri/ /index.php?$query_string";
};
"~ \\.php$" = {
extraConfig = ''
fastcgi_pass unix:${fpmSocket};
fastcgi_index index.php;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
include ${pkgs.nginx}/conf/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTPS on;
'';
};
"~ /\\.ht" = {
extraConfig = "deny all;";
};
};
};
"${cfg.domain}" =
let
sslAttrs =
if cfg.nginx.useACMEHost != null then {
forceSSL = true;
useACMEHost = cfg.nginx.useACMEHost;
} else {
forceSSL = true;
enableACME = true;
};
in
sslAttrs // {
root = "${cfg.frontendPackage}";
locations."/" = {
tryFiles = "$uri $uri/ /index.html";
};
};
};
};
};
}

View file

@ -1,32 +0,0 @@
{ php84, lib }:
let
# Laravel connects to Postgres via PDO, so the runtime PHP must
# ship pdo_pgsql. The default php84 set ships pdo, pdo_mysql, and
# pdo_sqlite but not pdo_pgsql. buildEnv gives us a php with the
# extra extension that flows through to passthru.php and the
# composer build hooks.
php = php84.buildEnv {
extensions = ({ enabled, all }: enabled ++ [ all.pdo_pgsql ]);
};
in
php.buildComposerProject (finalAttrs: {
pname = "tide-backend";
version = "0.1.0";
src = lib.cleanSource ../../backend;
composerNoDev = true;
composerNoPlugins = true;
composerNoScripts = true;
vendorHash = "sha256-MckSwzA5JP+wLV6P96OyOVxmDANZpg8bqqG8B7RSgnY=";
passthru = {
inherit php;
};
meta = {
description = "TIDE Laravel backend";
license = lib.licenses.mit;
};
})

View file

@ -1,33 +0,0 @@
{ buildNpmPackage, lib, nodejs_22, apiUrl }:
buildNpmPackage (finalAttrs: {
pname = "tide-frontend";
version = "0.0.0";
src = lib.cleanSource ../../frontend/blog_portal;
nodejs = nodejs_22;
npmDepsFetcherVersion = 2;
npmDepsHash = "sha256-NHAo9Bvg80W2341yPaw97khUCJyr/7fyQFvhQFKWYnY=";
env = {
VITE_API_URL = apiUrl;
# Cypress' postinstall reaches out to download.cypress.io, which
# is not allowed inside the Nix sandbox. Cypress is only used
# for local E2E so skip the download during the production
# build.
CYPRESS_INSTALL_BINARY = "0";
};
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r dist/* $out/
runHook postInstall
'';
meta = {
description = "TIDE Vue frontend (blog_portal)";
license = lib.licenses.mit;
};
})

View file

@ -1,4 +1,8 @@
{ nixpkgs, self, system }:
{
nixpkgs,
self,
system,
}:
let
pkgs = nixpkgs.legacyPackages.${system};
evaluatedSystem = nixpkgs.lib.nixosSystem {
@ -22,6 +26,7 @@ let
evaluatedConfig = evaluatedSystem.config;
discourseConfig = evaluatedConfig.services.discourse;
backupConfig = evaluatedConfig.services.borgbackup.jobs.discourse;
backupService = evaluatedConfig.systemd.services.borgbackup-job-discourse;
in
assert discourseConfig.enable;
assert discourseConfig.hostname == "discourse.torahimderecheretz.com";
@ -29,12 +34,27 @@ assert discourseConfig.admin.email == "yisroel.d.baum@gmail.com";
assert discourseConfig.admin.fullName == "Yisroel Baum";
assert discourseConfig.admin.username == "yisroeldbaum";
assert discourseConfig.admin.passwordFile == "/run/secrets/tide-admin-password";
assert discourseConfig.mail.notificationEmailAddress ==
"system@torahimderecheretz.com";
assert discourseConfig.mail.contactEmailAddress ==
"system@torahimderecheretz.com";
assert discourseConfig.mail.outgoing.passwordFile == "/run/secrets/tide-mail-password";
assert discourseConfig.mail.outgoing.serverAddress == "in-v3.mailjet.com";
assert discourseConfig.mail.outgoing.port == 587;
assert discourseConfig.mail.outgoing.authentication == "login";
assert discourseConfig.siteSettings.required.title == "Torah Im Derech Eretz";
assert discourseConfig.secretKeyBaseFile == "/run/secrets/tide-secret-key-base";
assert backupConfig.repo ==
"ssh://oas17j8p@oas17j8p.repo.borgbase.com/./repo";
assert backupConfig.repo == "ssh://oas17j8p@oas17j8p.repo.borgbase.com/./repo";
assert backupConfig.startAt == "*-*-* 07:15:00";
assert backupConfig.persistentTimer;
assert backupConfig.prune.keep.daily == 7;
assert backupConfig.prune.keep.weekly == 4;
assert backupConfig.prune.keep.monthly == 6;
assert builtins.elem "/var/lib/discourse" backupConfig.paths;
assert builtins.elem "sops-install-secrets.service" backupService.after;
assert !(evaluatedConfig.services.phpfpm.pools ? tide);
assert !(evaluatedConfig.services.nginx.virtualHosts ? "tide.yisroelbaum.com");
assert !(evaluatedConfig.services.nginx.virtualHosts ? "apitide.yisroelbaum.com");
pkgs.runCommand "tide-module-test" { } ''
touch "$out"
''