{ self }: { 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"; in { options.services.tide = { enable = lib.mkEnableOption "TIDE blogging platform"; 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. ''; }; }; }; config = lib.mkIf cfg.enable { users.users.${cfg.user} = { isSystemUser = true; group = cfg.group; home = "/var/lib/tide"; }; 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} -" ]; # 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}" = { after = [ "tide-prepare.service" "postgresql.service" ]; requires = [ "tide-prepare.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"; }; }; }; }; }; }