diff --git a/flake.nix b/flake.nix index c8fed26..d36bf53 100644 --- a/flake.nix +++ b/flake.nix @@ -4,50 +4,69 @@ utils.url = "github:numtide/flake-utils"; }; outputs = { self, nixpkgs, utils }: - utils.lib.eachDefaultSystem (system: - let - pkgs = nixpkgs.legacyPackages.${system}; - in { - devShells.default = pkgs.mkShell { - buildInputs = with pkgs; [ - onefetch - php - phpPackages.composer - phpPackages.php-codesniffer - vscode-langservers-extracted - sqlite - nodejs - nixfmt-rfc-style - nixfmt-tree - cypress - yaml-language-server - typescript - postgresql - process-compose - mailpit - ]; + let + perSystem = utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in { + packages = { + tide-backend = pkgs.callPackage ./nix/packages/backend.nix { }; + tide-frontend = pkgs.callPackage ./nix/packages/frontend.nix { + apiUrl = "https://apitide.yisroelbaum.com"; + }; + }; - shellHook = '' - # Anchor PGDATA to the repo root so subshells in subdirs - # (e.g. backend/) reuse the same cluster instead of seeding - # a stray .postgres there. - REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" - export PGDATA="$REPO_ROOT/.postgres" - export PGHOST="$PGDATA" - export PGUSER="postgres" - export PGDATABASE="postgres" + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + onefetch + php + phpPackages.composer + phpPackages.php-codesniffer + vscode-langservers-extracted + sqlite + nodejs + nixfmt-rfc-style + nixfmt-tree + cypress + yaml-language-server + typescript + postgresql + process-compose + mailpit + ]; - if [ ! -d "$PGDATA" ]; then - echo "[pg] initializing cluster at $PGDATA" - initdb --auth=trust --username=postgres --no-locale --encoding=UTF8 >/dev/null - { - echo "listen_addresses = '127.0.0.1'" - echo "unix_socket_directories = '$PGDATA'" - } >> "$PGDATA/postgresql.conf" - fi + shellHook = '' + # Anchor PGDATA to the repo root so subshells in subdirs + # (e.g. backend/) reuse the same cluster instead of seeding + # a stray .postgres there. + REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + export PGDATA="$REPO_ROOT/.postgres" + export PGHOST="$PGDATA" + export PGUSER="postgres" + export PGDATABASE="postgres" - echo "[dev] run 'process-compose up' to start postgres + backend + vite" - ''; + if [ ! -d "$PGDATA" ]; then + echo "[pg] initializing cluster at $PGDATA" + initdb --auth=trust --username=postgres --no-locale --encoding=UTF8 >/dev/null + { + echo "listen_addresses = '127.0.0.1'" + echo "unix_socket_directories = '$PGDATA'" + } >> "$PGDATA/postgresql.conf" + fi + + echo "[dev] run 'process-compose up' to start postgres + backend + vite" + ''; + }; + }); + in + perSystem // { + nixosModules.tide = import ./nix/module.nix { inherit self; }; + + overlays.default = final: prev: { + tide-backend = final.callPackage ./nix/packages/backend.nix { }; + tide-frontend = final.callPackage ./nix/packages/frontend.nix { + apiUrl = "https://apitide.yisroelbaum.com"; }; - }); + }; + }; } diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..2fa939f --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,328 @@ +{ 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 + rm -rf ${appRoot} + mkdir -p ${appRoot} + for entry in ${cfg.backendPackage}/share/php/tide-backend/*; do + name="$(basename "$entry")" + case "$name" in + storage|bootstrap) + ;; + *) + ln -s "$entry" "${appRoot}/$name" + ;; + esac + done + # bootstrap dir: symlink everything from the store + # except cache/ which must be writable. + mkdir -p ${appRoot}/bootstrap + for entry in ${cfg.backendPackage}/share/php/tide-backend/bootstrap/*; do + name="$(basename "$entry")" + if [ "$name" != "cache" ]; then + ln -sf "$entry" "${appRoot}/bootstrap/$name" + fi + done + ln -sfn ${stateRoot}/bootstrap-cache ${appRoot}/bootstrap/cache + ln -sfn ${stateRoot}/storage ${appRoot}/storage + + 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; + phpEnv = { + APP_ENV = "production"; + APP_DEBUG = "false"; + APP_URL = "https://${cfg.apiDomain}"; + LOG_CHANNEL = "stderr"; + DB_CONNECTION = "pgsql"; + DB_HOST = "/run/postgresql"; + DB_PORT = "5432"; + DB_DATABASE = cfg.database.name; + DB_USERNAME = cfg.database.user; + SESSION_DRIVER = "database"; + CACHE_STORE = "database"; + QUEUE_CONNECTION = "database"; + }; + 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; + }; + environment = { + APP_ENV = "production"; + APP_DEBUG = "false"; + APP_URL = "https://${cfg.apiDomain}"; + DB_CONNECTION = "pgsql"; + DB_HOST = "/run/postgresql"; + DB_PORT = "5432"; + DB_DATABASE = cfg.database.name; + DB_USERNAME = cfg.database.user; + }; + 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 + ''; + }; + + # 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"; + }; + }; + }; + }; + }; +} diff --git a/nix/packages/backend.nix b/nix/packages/backend.nix new file mode 100644 index 0000000..c662526 --- /dev/null +++ b/nix/packages/backend.nix @@ -0,0 +1,25 @@ +{ php84, lib }: +let + php = php84; +in +php.buildComposerProject (finalAttrs: { + pname = "tide-backend"; + version = "0.1.0"; + + src = lib.cleanSource ../../backend; + + composerNoDev = true; + composerNoPlugins = true; + composerNoScripts = true; + + vendorHash = "sha256-OYpfX435tPJqiOzQPpWPXCVH1rTeQ74dGuNVyk6+c1A="; + + passthru = { + inherit php; + }; + + meta = { + description = "TIDE Laravel backend"; + license = lib.licenses.mit; + }; +}) diff --git a/nix/packages/frontend.nix b/nix/packages/frontend.nix new file mode 100644 index 0000000..8ed9d35 --- /dev/null +++ b/nix/packages/frontend.nix @@ -0,0 +1,33 @@ +{ 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; + }; +})