Merge branch 'nix-deploy'

This commit is contained in:
Yisroel Baum 2026-05-08 10:59:38 +03:00
commit dfa5327a2b
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
4 changed files with 447 additions and 42 deletions

103
flake.nix
View file

@ -4,50 +4,69 @@
utils.url = "github:numtide/flake-utils"; utils.url = "github:numtide/flake-utils";
}; };
outputs = { self, nixpkgs, utils }: outputs = { self, nixpkgs, utils }:
utils.lib.eachDefaultSystem (system: let
let perSystem = utils.lib.eachDefaultSystem (system:
pkgs = nixpkgs.legacyPackages.${system}; let
in { pkgs = nixpkgs.legacyPackages.${system};
devShells.default = pkgs.mkShell { in {
buildInputs = with pkgs; [ packages = {
onefetch tide-backend = pkgs.callPackage ./nix/packages/backend.nix { };
php tide-frontend = pkgs.callPackage ./nix/packages/frontend.nix {
phpPackages.composer apiUrl = "https://apitide.yisroelbaum.com";
phpPackages.php-codesniffer };
vscode-langservers-extracted };
sqlite
nodejs
nixfmt-rfc-style
nixfmt-tree
cypress
yaml-language-server
typescript
postgresql
process-compose
mailpit
];
shellHook = '' devShells.default = pkgs.mkShell {
# Anchor PGDATA to the repo root so subshells in subdirs buildInputs = with pkgs; [
# (e.g. backend/) reuse the same cluster instead of seeding onefetch
# a stray .postgres there. php
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" phpPackages.composer
export PGDATA="$REPO_ROOT/.postgres" phpPackages.php-codesniffer
export PGHOST="$PGDATA" vscode-langservers-extracted
export PGUSER="postgres" sqlite
export PGDATABASE="postgres" nodejs
nixfmt-rfc-style
nixfmt-tree
cypress
yaml-language-server
typescript
postgresql
process-compose
mailpit
];
if [ ! -d "$PGDATA" ]; then shellHook = ''
echo "[pg] initializing cluster at $PGDATA" # Anchor PGDATA to the repo root so subshells in subdirs
initdb --auth=trust --username=postgres --no-locale --encoding=UTF8 >/dev/null # (e.g. backend/) reuse the same cluster instead of seeding
{ # a stray .postgres there.
echo "listen_addresses = '127.0.0.1'" REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
echo "unix_socket_directories = '$PGDATA'" export PGDATA="$REPO_ROOT/.postgres"
} >> "$PGDATA/postgresql.conf" export PGHOST="$PGDATA"
fi 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";
}; };
}); };
};
} }

328
nix/module.nix Normal file
View file

@ -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";
};
};
};
};
};
}

25
nix/packages/backend.nix Normal file
View file

@ -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;
};
})

33
nix/packages/frontend.nix Normal file
View file

@ -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;
};
})