{ description = "Postgresql"; inputs = { nixpkgs = { url = "nixpkgs/nixos-unstable"; }; flake-utils = { url = "github:numtide/flake-utils"; }; }; outputs = { self, nixpkgs, flake-utils, ... }@inputs: ( flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; in { packages = { scram-sha-256 = let pname = "scram-sha-256"; version = "1.0.1"; in pkgs.buildGoModule { inherit pname version; src = pkgs.fetchFromGitHub { owner = "supercaracal"; repo = pname; rev = "v${version}"; hash = "sha256-4/6rog+Or7YBer+Gdc38OPC2UVrXmeKUK/hsZrnYhbo="; }; vendorHash = "sha256-qNJSCLMPdWgK/eFPmaYBcgH3P6jHBqQeU4gR6kE/+AE="; meta = with pkgs.lib; { homepage = "https://github.com/supercaracal/scram-sha-256"; description = "A backup program that is fast, efficient and secure"; platforms = platforms.all; license = licenses.mit; }; }; }; } ) ) // { nixosModules = { postgresql = { config, lib, pkgs, ... }: let cfg = config.jcollie.postgresql; in { options = { jcollie.postgresql = lib.options.mkOption { type = lib.types.submodule { options = { enable = lib.options.mkEnableOption "PostgreSQL"; port = lib.options.mkOption { type = lib.types.port; default = 5432; }; superuser = lib.options.mkOption { type = lib.types.submodule { options = { username = lib.options.mkOption { type = lib.types.str; default = "postgres"; }; password = lib.options.mkOption { type = lib.types.addCheck lib.types.str (x: lib.hasPrefix "SCRAM-SHA-256$" x); }; }; }; default = { }; }; replication = lib.options.mkOption { type = lib.types.submodule { options = { enable = lib.options.mkEnableOption "Enable PostgreSQL replication"; role = lib.options.mkOption { type = lib.types.enum [ "primary" "replica" ]; default = "primary"; }; username = lib.options.mkOption { type = lib.types.str; default = "replication"; }; passwordFile = lib.options.mkOption { type = lib.types.path; }; # password = lib.options.mkOption { # type = lib.types.str; # }; primary = lib.options.mkOption { type = lib.types.submodule { options = { hostname = lib.options.mkOption { type = lib.types.str; }; port = lib.options.mkOption { type = lib.types.port; default = 5432; }; }; }; default = { }; }; }; }; }; backup = lib.options.mkOption { type = lib.types.submodule { options = { enable = lib.options.mkEnableOption "Backup the database with PGBackRest"; processMax = lib.options.mkOption { type = lib.types.ints.positive; default = 4; }; log = lib.options.mkOption { type = lib.types.submodule { options = let logLevelType = lib.types.enum [ "off" "error" "warn" "info" "detail" "debug" "trace" ]; in { console = lib.options.mkOption { type = logLevelType; default = "detail"; }; file = lib.options.mkOption { type = logLevelType; default = "off"; }; stderr = lib.options.mkOption { type = logLevelType; default = "off"; }; }; }; default = { }; }; cipher = lib.options.mkOption { type = lib.types.submodule { options = { password = lib.options.mkOption { type = lib.types.str; }; type = lib.options.mkOption { type = lib.types.enum [ "aes-256-cbc" ]; default = "aes-256-cbc"; }; }; }; default = { }; }; storage = lib.options.mkOption { type = lib.types.submodule { options = { type = lib.options.mkOption { type = lib.types.enum [ "azure" "b2" ]; }; b2 = lib.options.mkOption { type = lib.types.submodule { options = { account_id = lib.options.mkOption { type = lib.types.str; }; account_key = lib.options.mkOption { type = lib.types.str; }; endpoint = lib.options.mkOption { type = lib.types.str; }; region = lib.options.mkOption { type = lib.types.str; }; bucket = lib.options.mkOption { type = lib.types.str; }; path = lib.options.mkOption { type = lib.types.str; }; }; }; default = { }; }; azure = lib.options.mkOption { type = lib.types.submodule { options = { account_name = lib.options.mkOption { type = lib.types.str; }; account_key = lib.options.mkOption { type = lib.types.str; }; container = lib.options.mkOption { type = lib.types.str; }; path = lib.options.mkOption { type = lib.types.str; }; }; }; default = { }; }; }; }; default = { }; }; }; }; default = { }; }; healthcheck = lib.options.mkOption { type = lib.types.submodule { options = { enable = lib.options.mkEnableOption "use healthcheck"; api_url = lib.options.mkOption { type = lib.types.str; }; api_key = lib.options.mkOption { type = lib.types.str; }; timeout = lib.options.mkOption { type = lib.types.int; default = 86400; }; grace = lib.options.mkOption { type = lib.types.int; default = 14400; }; }; }; default = { }; }; users = lib.options.mkOption { type = lib.types.listOf ( lib.types.submodule { options = { username = lib.options.mkOption { type = lib.types.str; }; password = lib.options.mkOption { type = lib.types.addCheck lib.types.str (x: lib.hasPrefix "SCRAM-SHA-256$" x); }; }; } ); default = [ ]; }; databases = lib.options.mkOption { type = lib.types.listOf ( lib.types.submodule { options = { name = lib.options.mkOption { type = lib.types.str; }; owner = lib.options.mkOption { type = lib.types.str; }; }; } ); default = [ ]; }; }; }; default = { }; }; }; config = let toStr = value: if true == value then "yes" else if false == value then "no" else if lib.isString value then "'${lib.replaceStrings ["'"] ["''"] value}'" else toString value; escapeShell = value: lib.replaceStrings [ "$" ] [ "\\$" ] value; postgresql = pkgs.postgresql_15.withPackages ( plugins: [ plugins.postgis ] ); rcloneConfig = pkgs.writeTextFile { name = "rclone.conf"; text = { "b2" = '' [b2] type = b2 account = ${cfg.backup.storage.b2.account_id} key = ${cfg.backup.storage.b2.account_key} ''; "azure" = '' [azure] type = azureblob account = ${cfg.backup.storage.azure.account_name} key = ${cfg.backup.storage.azure.account_key} ''; }.${cfg.backup.storage.type}; }; rclone = lib.mkIf (cfg.backup.enable) ( pkgs.writeShellScriptBin "rclone" '' exec ${pkgs.rclone}/bin/rclone --config ${rcloneConfig} "''$@" '' ); dataDir = "/var/lib/postgresql/15"; pgbackrestEnvironment = { PGBACKREST_LOG_LEVEL_CONSOLE = cfg.backup.log.console; PGBACKREST_LOG_LEVEL_FILE = cfg.backup.log.file; PGBACKREST_LOG_LEVEL_STDERR = cfg.backup.log.stderr; PGBACKREST_PG1_PATH = dataDir; PGBACKREST_PROCESS_MAX = "${builtins.toString cfg.backup.processMax}"; PGBACKREST_REPO1_CIPHER_PASS = cfg.backup.cipher.password; PGBACKREST_REPO1_CIPHER_TYPE = cfg.backup.cipher.type; PGBACKREST_REPO1_RETENTION_FULL = "14"; PGBACKREST_REPO1_RETENTION_FULL_TYPE = "time"; PGBACKREST_STANZA = "${config.networking.hostName}.${config.networking.domain}"; } // ( { "azure" = { PGBACKREST_REPO1_AZURE_ACCOUNT = cfg.backup.storage.azure.account_name; PGBACKREST_REPO1_AZURE_CONTAINER = cfg.backup.storage.azure.container; PGBACKREST_REPO1_AZURE_KEY = cfg.backup.storage.azure.account_key; PGBACKREST_REPO1_PATH = cfg.backup.storage.azure.path; PGBACKREST_REPO1_TYPE = "azure"; }; "b2" = { PGBACKREST_REPO1_PATH = cfg.backup.storage.b2.path; PGBACKREST_REPO1_S3_BUCKET = cfg.backup.storage.b2.bucket; PGBACKREST_REPO1_S3_ENDPOINT = cfg.backup.storage.b2.endpoint; PGBACKREST_REPO1_S3_KEY = cfg.backup.storage.b2.account_id; PGBACKREST_REPO1_S3_KEY_SECRET = cfg.backup.storage.b2.account_key; PGBACKREST_REPO1_S3_REGION = cfg.backup.storage.b2.region; PGBACKREST_REPO1_TYPE = "s3"; }; }.${cfg.backup.storage.type} ); pgbackrest = lib.mkIf (cfg.backup.enable) ( let environment = lib.concatStringsSep "\n" ( lib.mapAttrsToList ( n: v: ''export ${n}="${builtins.toString v}"'' ) pgbackrestEnvironment ); in pkgs.writeShellScriptBin "pgbackrest" '' ${environment} exec ${pkgs.pgbackrest}/bin/pgbackrest "''$@" '' ); in lib.mkIf cfg.enable { environment.systemPackages = [ postgresql pgbackrest rclone self.packages.${pkgs.system}.scram-sha-256 ]; networking.firewall.allowedTCPPorts = [ cfg.port ]; security.acme.certs."${config.networking.hostName}.${config.networking.domain}" = { reloadServices = [ "postgresql" ]; }; users.groups.postgres.gid = config.ids.gids.postgres; users.users.postgres = { name = "postgres"; uid = config.ids.uids.postgres; group = "postgres"; description = "PostgreSQL Server"; home = "/var/lib/postgresql/15"; useDefaultShell = true; }; systemd.services.postgresql = let hbaFile = pkgs.writeTextDir "pg_hba.conf" '' local all all ident map=default hostnossl all all all reject hostssl all all all scram-sha-256 local replication all ident map=default hostnossl replication all all reject hostssl replication all all scram-sha-256 ''; identFile = pkgs.writeTextDir "pg_ident.conf" '' default root postgres default postgres postgres ''; archiveCommand = "${pkgs.pgbackrest}/bin/pgbackrest archive-push %p"; settings = { bgwriter_flush_after = "512kB"; checkpoint_flush_after = "256kB"; data_directory = dataDir; datestyle = "iso, mdy"; default_text_search_config = "pg_catalog.english"; dynamic_shared_memory_type = "posix"; hba_file = "${dataDir}/pg_hba.conf"; hot_standby = "on"; ident_file = "${dataDir}/pg_ident.conf"; lc_messages = "en_US.utf8"; lc_monetary = "en_US.utf8"; lc_numeric = "en_US.utf8"; lc_time = "en_US.utf8"; listen_addresses = "*"; log_destination = "stderr"; log_connections = "on"; log_line_prefix = "%m [%l][%p] {%h} :%d:%u:%c: "; # log_statement = "all"; log_timezone = "America/Chicago"; logging_collector = "off"; max_connections = 100; max_wal_senders = "3"; max_wal_size = "1GB"; min_wal_size = "80MB"; password_encryption = "scram-sha-256"; port = cfg.port; shared_buffers = "128MB"; ssl = "on"; ssl_cert_file = "/run/credentials/postgresql.service/fullchain.pem"; ssl_key_file = "/run/credentials/postgresql.service/key.pem"; timezone = "America/Chicago"; unix_socket_directories = "/run/postgresql"; wal_level = "replica"; wal_log_hints = "on"; } // ( if (cfg.backup.enable) then { archive_command = archiveCommand; archive_mode = "on"; } else { } ); configFile = pkgs.writeTextDir "postgresql.conf" ( lib.concatStrings ( lib.mapAttrsToList ( n: v: "${n} = ${toStr v}\n" ) settings ) ); backupSetup = if (cfg.backup.enable) then '' init=''$(${pkgs.pgbackrest}/bin/pgbackrest info --output=json | ${pkgs.jq}/bin/jq '.[0].status.code == 0') if [ "$init" != "true" ] then ${postgresql}/bin/pg_ctl -o "-c ssl=off -c listen_addresses=''' -p 55432" -w start ${pkgs.pgbackrest}/bin/pgbackrest --pg1-port=55432 stanza-create ${postgresql}/bin/pg_ctl -m fast -w stop fi '' else ""; in { description = "PostgreSQL Server"; wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; environment = { PGDATA = dataDir; } // ( if (cfg.backup.enable && (!cfg.replication.enable || cfg.replication.role == "primary")) then pgbackrestEnvironment else { } ) // ( if (cfg.replication.enable && cfg.replication.role == "replica") then { PGPASSFILE = "${dataDir}/.pgpass"; } else { } ); path = [ postgresql pgbackrest ]; preStart = if (!cfg.replication.enable || cfg.replication.role == "primary") then '' if [ ! -s "${dataDir}/PG_VERSION" ] then ${postgresql}/bin/initdb \ --username=${cfg.superuser.username} \ --pwfile=<(echo -n "${escapeShell cfg.superuser.password}") \ --data-checksums \ --locale=en_US.utf8 \ --auth-host=scram-sha-256 \ --auth-local=trust fi ln -sfn "${configFile}/postgresql.conf" "${dataDir}/postgresql.conf" ln -sfn "${hbaFile}/pg_hba.conf" "${dataDir}/pg_hba.conf" ln -sfn "${identFile}/pg_ident.conf" "${dataDir}/pg_ident.conf" ${backupSetup} '' else '' read -r password < ${cfg.replication.passwordFile} (umask 077; echo "${cfg.replication.primary.hostname}:${builtins.toString cfg.replication.primary.port}:replication:${cfg.replication.username}:''${password}" > ${dataDir}/.pgpass) chmod 0600 ${dataDir}/.pgpass if [ ! -s "${dataDir}/PG_VERSION" ] then ${postgresql}/bin/pg_basebackup \ --verbose \ --progress \ --pgdata=${dataDir} \ --write-recovery-conf \ --slot=${config.networking.hostName} \ --create-slot \ --wal-method=stream \ --host=${cfg.replication.primary.hostname} \ --port=${builtins.toString cfg.replication.primary.port} \ --username=${cfg.replication.username} \ --no-password fi ln -sfn "${configFile}/postgresql.conf" "${dataDir}/postgresql.conf" ln -sfn "${hbaFile}/pg_hba.conf" "${dataDir}/pg_hba.conf" ln -sfn "${identFile}/pg_ident.conf" "${dataDir}/pg_ident.conf" ''; unitConfig = { RequiresMountsFor = "${dataDir}"; }; serviceConfig = { ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; ExecStart = "${postgresql}/bin/postgres"; Group = "postgres"; KillMode = "mixed"; KillSignal = "SIGINT"; RuntimeDirectory = "postgresql"; StateDirectory = "postgresql"; StateDirectoryMode = "0750"; TimeoutSec = 120; Type = "notify"; User = "postgres"; LoadCredential = [ "fullchain.pem:${config.security.acme.certs."${config.networking.hostName}.${config.networking.domain}".directory}/fullchain.pem" "key.pem:${config.security.acme.certs."${config.networking.hostName}.${config.networking.domain}".directory}/key.pem" ]; }; }; systemd.services.postgresql-setup = lib.mkIf (!cfg.replication.enable || cfg.replication.role == "primary") ( let replicationSetup = if (cfg.replication.enable && cfg.replication.role == "primary") then '' if $PSQL --command "SELECT 1 FROM pg_roles WHERE rolname='${cfg.replication.username}';" | grep -q 1 then echo "alter replication user ${cfg.replication.username}" $PSQL \ --variable=v1="''$(<''${CREDENTIALS_DIRECTORY}/postgresql-replication-password)" \ --command "ALTER ROLE ${cfg.replication.username} WITH REPLICATION LOGIN PASSWORD :'v1';" else echo "create replication user ${cfg.replication.username}" $PSQL \ --variable=v1="''$(<''${CREDENTIALS_DIRECTORY}/postgresql-replication-password)" \ --command "CREATE ROLE ${cfg.replication.username} WITH REPLICATION LOGIN PASSWORD :'v1';" fi '' else ""; userSetup = lib.strings.concatStringsSep "\n" ( map ( user: '' if $PSQL --command "SELECT 1 FROM pg_roles WHERE rolname='${user.username}';" | grep -q 1 then echo "alter user ${user.username}" $PSQL --command "ALTER ROLE ${user.username} WITH LOGIN PASSWORD '${escapeShell user.password}';" else echo "create user ${user.username}" $PSQL --command "CREATE ROLE ${user.username} WITH LOGIN PASSWORD '${escapeShell user.password}';" fi '' ) cfg.users ); databaseSetup = lib.strings.concatStringsSep "\n" ( map ( database: '' if ! ( $PSQL --command "SELECT 1 FROM pg_database WHERE datname='${database.name}';" | grep -q 1 ) then echo "create database ${database.name}" $PSQL --command "CREATE DATABASE ${database.name} WITH OWNER ${database.owner};" fi echo "grant public schema priviliges to user ${database.owner}" $PSQL --dbname ${database.name} --command "GRANT ALL PRIVILEGES ON SCHEMA public to ${database.owner};" echo "grant priviliges on database ${database.name} to user ${database.owner}" $PSQL --dbname ${database.name} --command "GRANT ALL PRIVILEGES ON DATABASE ${database.name} to ${database.owner};" '' ) cfg.databases ); in { description = "PostgreSQL User/Database Setup"; requiredBy = [ "postgresql.service" ]; bindsTo = [ "postgresql.service" ]; script = '' while ! ${postgresql}/bin/psql -d postgres -c "" 2> /dev/null do sleep 0.1 done PSQL="${postgresql}/bin/psql --tuples-only --no-align" $PSQL --command "ALTER ROLE ${cfg.superuser.username} WITH SUPERUSER LOGIN PASSWORD '${escapeShell cfg.superuser.password}';" ${replicationSetup} ${userSetup} ${databaseSetup} ''; serviceConfig = { Type = "oneshot"; User = "postgres"; Group = "postgres"; RemainAfterExit = true; TimeoutSec = 120; LoadCredential = [ "postgresql-replication-password:${cfg.replication.passwordFile}" ]; }; } ); systemd.services.postgresql-backup-full = lib.mkIf (cfg.backup.enable && (!cfg.replication.enable || cfg.replication.role == "primary")) { description = "PostgreSQL Full Backup"; requires = [ "postgresql.service" ]; script = '' while ! ${postgresql}/bin/psql -d postgres -c "" 2> /dev/null do sleep 0.1 done ${pkgs.pgbackrest}/bin/pgbackrest --type=full --start-fast --stop-auto --delta backup ''; environment = pgbackrestEnvironment; serviceConfig = { Type = "oneshot"; User = "postgres"; Group = "postgres"; TimeoutSec = 3600; }; }; systemd.timers.postgresql-backup-full = lib.mkIf (cfg.backup.enable && (!cfg.replication.enable || cfg.replication.role == "primary")) { description = "PostgreSQL Full Backup"; timerConfig = { OnCalendar = "Sun *-*-* 02:00:00"; RandomizedDelaySec = "5m"; }; wantedBy = [ "multi-user.target" ]; }; systemd.services.postgresql-backup-diff = lib.mkIf (cfg.backup.enable && (!cfg.replication.enable || cfg.replication.role == "primary")) { description = "PostgreSQL Differential Backup"; requires = [ "postgresql.service" ]; script = '' while ! ${postgresql}/bin/psql -d postgres -c "" 2> /dev/null do sleep 0.1 done ${pkgs.pgbackrest}/bin/pgbackrest --type=diff --start-fast --stop-auto --delta backup ''; environment = pgbackrestEnvironment; serviceConfig = { Type = "oneshot"; User = "postgres"; Group = "postgres"; TimeoutSec = 3600; }; }; systemd.timers.postgresql-backup-diff = lib.mkIf (cfg.backup.enable && (!cfg.replication.enable || cfg.replication.role == "primary")) { description = "PostgreSQL Differential Backup"; timerConfig = { OnCalendar = "Mon,Tue,Wed,Thu,Fri,Sat *-*-* 02:00:00"; RandomizedDelaySec = "5m"; }; wantedBy = [ "multi-user.target" ]; }; systemd.services.postgresql-backup-incr = lib.mkIf (cfg.backup.enable && (!cfg.replication.enable || cfg.replication.role == "primary")) { description = "PostgreSQL Incremental Backup"; requires = [ "postgresql.service" ]; script = '' while ! ${postgresql}/bin/psql -d postgres -c "" 2> /dev/null do sleep 0.1 done ${pkgs.pgbackrest}/bin/pgbackrest --type=incr --start-fast --stop-auto --delta backup ''; environment = pgbackrestEnvironment; serviceConfig = { Type = "oneshot"; User = "postgres"; Group = "postgres"; TimeoutSec = 3600; }; }; systemd.timers.postgresql-backup-incr = lib.mkIf (cfg.backup.enable && (!cfg.replication.enable || cfg.replication.role == "primary")) { description = "PostgreSQL Incremental Backup"; timerConfig = { OnCalendar = "*-*-* 06,10,14,18,22:00:00"; RandomizedDelaySec = "5m"; }; wantedBy = [ "multi-user.target" ]; }; }; }; }; }; }