{ bitcoin-pulse, config, lib, pkgs, ... }: let backupDirectory = "/var/backup/bitcoin-pulse"; backupDump = "${backupDirectory}/bitcoin-pulse.dump"; backupRepository = "b2:tobcloud-backup:bitcoin-pulse"; backupServiceName = "restic-backups-bitcoin-pulse"; containerName = "bitcoin-pulse-postgres"; databaseUrl = "jdbc:postgresql://127.0.0.1:5432/bitcoin_pulse?tcpKeepAlive=true"; migrationServiceName = "bitcoin-pulse-migrate"; serviceName = "podman-${containerName}"; runtimeDirectory = "/run/${containerName}"; secretFiles = { admin = config.sops.secrets."bitcoin-pulse/postgres/admin-password".path; app = config.sops.secrets."bitcoin-pulse/postgres/app-password".path; backblaze = config.sops.secrets."backblaze/tobcloud-backup-env".path; migration = config.sops.secrets."bitcoin-pulse/postgres/migration-password".path; restic = config.sops.secrets."restic/bitcoin-pulse-password".path; }; prepareSecrets = pkgs.writeShellApplication { name = "bitcoin-pulse-postgresql-prepare-secrets"; runtimeInputs = [ config.virtualisation.podman.package pkgs.coreutils ]; text = '' copy_secret() { source="$1" destination="$2" podman unshare rm -f "$destination" # PostgreSQL runs as uid 999 in the container. Store each runtime copy # with the corresponding rootless subordinate uid and a private mode. podman unshare install -m 0400 -o 999 -g 999 "$source" "$destination" } copy_secret ${lib.escapeShellArg secretFiles.admin} \ ${lib.escapeShellArg "${runtimeDirectory}/admin-password"} copy_secret ${lib.escapeShellArg secretFiles.app} \ ${lib.escapeShellArg "${runtimeDirectory}/app-password"} copy_secret ${lib.escapeShellArg secretFiles.migration} \ ${lib.escapeShellArg "${runtimeDirectory}/migration-password"} ''; }; cleanupSecrets = pkgs.writeShellApplication { name = "bitcoin-pulse-postgresql-cleanup-secrets"; runtimeInputs = [ config.virtualisation.podman.package pkgs.coreutils ]; text = '' podman unshare rm -f \ ${lib.escapeShellArg "${runtimeDirectory}/admin-password"} \ ${lib.escapeShellArg "${runtimeDirectory}/app-password"} \ ${lib.escapeShellArg "${runtimeDirectory}/migration-password"} ''; }; waitForPostgresql = pkgs.writeShellApplication { name = "bitcoin-pulse-wait-for-postgresql"; runtimeInputs = [ pkgs.coreutils pkgs.postgresql_18 ]; text = '' deadline=$((SECONDS + 60)) echo "Waiting up to 60 seconds for PostgreSQL on 127.0.0.1:5432" while (( SECONDS < deadline )); do if pg_isready \ --host=127.0.0.1 \ --port=5432 \ --username=bitcoin_pulse_migration \ --dbname=bitcoin_pulse \ --timeout=2 \ --quiet; then echo "PostgreSQL is accepting connections" exit 0 fi sleep 2 done echo "PostgreSQL did not become ready on 127.0.0.1:5432 within 60 seconds" >&2 pg_isready \ --host=127.0.0.1 \ --port=5432 \ --username=bitcoin_pulse_migration \ --dbname=bitcoin_pulse \ --timeout=2 || true exit 1 ''; }; in { imports = [ bitcoin-pulse.nixosModules.default ]; services.bitcoin-pulse = { enable = true; bindAddress = "127.0.0.1"; port = 3000; mempool.baseUrl = "http://127.0.0.1:8999"; database = { url = databaseUrl; username = "bitcoin_pulse_app"; passwordFile = secretFiles.app; }; }; services.restic.backups.bitcoin-pulse = { initialize = true; repository = backupRepository; passwordFile = secretFiles.restic; environmentFile = secretFiles.backblaze; paths = [ backupDump ]; backupPrepareCommand = '' set -euo pipefail install -d -m 0700 -o root -g root ${lib.escapeShellArg backupDirectory} dump_path=${lib.escapeShellArg backupDump} temporary_dump="$dump_path.tmp" pgpass_file="$RUNTIME_DIRECTORY/postgresql.pgpass" rm -f "$dump_path" "$temporary_dump" "$pgpass_file" password="$(cat "$CREDENTIALS_DIRECTORY/postgres-password")" escaped_password="''${password//\\/\\\\}" escaped_password="''${escaped_password//:/\\:}" printf '127.0.0.1:5432:bitcoin_pulse:bitcoin_pulse_admin:%s\n' \ "$escaped_password" > "$pgpass_file" chmod 0600 "$pgpass_file" trap 'rm -f "$temporary_dump" "$pgpass_file"' EXIT PGPASSFILE="$pgpass_file" ${pkgs.postgresql_18}/bin/pg_dump \ --host=127.0.0.1 \ --port=5432 \ --username=bitcoin_pulse_admin \ --dbname=bitcoin_pulse \ --format=custom \ --no-password \ --file="$temporary_dump" mv "$temporary_dump" "$dump_path" ''; backupCleanupCommand = '' rm -f \ ${lib.escapeShellArg backupDump} \ ${lib.escapeShellArg "${backupDump}.tmp"} \ "$RUNTIME_DIRECTORY/postgresql.pgpass" ''; timerConfig = { OnCalendar = "*-*-* 03:00:00"; Persistent = true; RandomizedDelaySec = "30m"; }; pruneOpts = [ "--keep-daily 7" "--keep-weekly 4" "--keep-monthly 12" ]; }; users.groups = { bitcoin-pulse = { }; bitcoin-pulse-migration = { }; bitcoin-pulse-postgres.gid = 400; }; users.users = { bitcoin-pulse-migration = { description = "Bitcoin Pulse database migration account"; isSystemUser = true; group = "bitcoin-pulse-migration"; }; bitcoin-pulse-postgres = { description = "Rootless PostgreSQL container account for Bitcoin Pulse"; isSystemUser = true; uid = 400; group = "bitcoin-pulse-postgres"; extraGroups = [ "bitcoin-pulse" "bitcoin-pulse-migration" ]; home = "/var/lib/bitcoin-pulse-postgres"; homeMode = "0700"; createHome = true; linger = true; autoSubUidGidRange = true; }; }; sops.secrets = { "bitcoin-pulse/postgres/admin-password" = { group = "bitcoin-pulse-postgres"; mode = "0440"; }; "bitcoin-pulse/postgres/app-password" = { group = "bitcoin-pulse"; mode = "0440"; }; "bitcoin-pulse/postgres/migration-password" = { group = "bitcoin-pulse-migration"; mode = "0440"; }; "restic/bitcoin-pulse-password" = { owner = "root"; group = "root"; mode = "0400"; }; }; virtualisation.oci-containers = { backend = "podman"; containers.${containerName} = { # Official PostgreSQL 18.4 Bookworm multi-architecture image, pinned to # the immutable digest tested by BitcoinPulse on 2026-07-16. image = "docker.io/library/postgres@sha256:1961f96e6029a02c3812d7cb329a3b03a3ac2bb067058dec17b0f5596aca9296"; pull = "missing"; autoStart = true; autoRemoveOnStop = true; podman = { user = "bitcoin-pulse-postgres"; sdnotify = "healthy"; }; environment = { POSTGRES_DB = "bitcoin_pulse"; POSTGRES_USER = "bitcoin_pulse_admin"; POSTGRES_PASSWORD_FILE = "/run/secrets/admin-password"; POSTGRES_HOST_AUTH_METHOD = "scram-sha-256"; POSTGRES_INITDB_ARGS = "--auth-host=scram-sha-256 --data-checksums"; }; ports = [ "127.0.0.1:5432:5432" ]; volumes = [ "bitcoin-pulse-db:/var/lib/postgresql" "${runtimeDirectory}/admin-password:/run/secrets/admin-password:ro" "${runtimeDirectory}/app-password:/run/secrets/app-password:ro" "${runtimeDirectory}/migration-password:/run/secrets/migration-password:ro" "${bitcoin-pulse}/resources/postgres/initdb/010-roles.sql:/docker-entrypoint-initdb.d/010-roles.sql:ro" ]; extraOptions = [ "--health-cmd=pg_isready --host=127.0.0.1 --port=5432 --username=bitcoin_pulse_admin --dbname=bitcoin_pulse" "--health-interval=10s" "--health-timeout=5s" "--health-retries=6" "--health-start-period=60s" "--stop-signal=SIGINT" "--stop-timeout=60" "--pids-limit=256" "--security-opt=no-new-privileges" ]; }; }; systemd.services = { ${serviceName}.serviceConfig = { ExecStartPre = lib.mkAfter [ "${prepareSecrets}/bin/bitcoin-pulse-postgresql-prepare-secrets" ]; ExecStartPost = [ "${cleanupSecrets}/bin/bitcoin-pulse-postgresql-cleanup-secrets" ]; ExecStopPost = lib.mkAfter [ "${cleanupSecrets}/bin/bitcoin-pulse-postgresql-cleanup-secrets" ]; RuntimeDirectoryMode = "0700"; RestartSec = "5s"; TimeoutStopSec = lib.mkForce "90s"; UMask = "0077"; }; ${migrationServiceName} = { description = "Migrate the Bitcoin Pulse PostgreSQL database"; requires = [ "${serviceName}.service" ]; after = [ "${serviceName}.service" ]; before = [ "bitcoin-pulse.service" ]; environment = { MIGRATUS_DATABASE_URL = databaseUrl; MIGRATUS_USERNAME = "bitcoin_pulse_migration"; MIGRATUS_PASSWORD_FILE = secretFiles.migration; }; serviceConfig = { Type = "oneshot"; ExecStartPre = [ "${waitForPostgresql}/bin/bitcoin-pulse-wait-for-postgresql" ]; ExecStart = "${config.services.bitcoin-pulse.package}/bin/bitcoin-pulse-migrate migrate"; User = "bitcoin-pulse-migration"; Group = "bitcoin-pulse-migration"; Restart = "no"; TimeoutStartSec = "5min"; AmbientCapabilities = ""; CapabilityBoundingSet = ""; LockPersonality = true; NoNewPrivileges = true; PrivateDevices = true; PrivateTmp = true; ProcSubset = "pid"; ProtectClock = true; ProtectControlGroups = true; ProtectHome = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectProc = "invisible"; ProtectSystem = "strict"; RemoveIPC = true; RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; SystemCallArchitectures = "native"; UMask = "0077"; }; }; bitcoin-pulse = { requires = [ "${migrationServiceName}.service" ]; after = [ "${migrationServiceName}.service" ]; }; ${backupServiceName} = { requires = [ "${serviceName}.service" ]; after = [ "${serviceName}.service" ]; serviceConfig = { LoadCredential = [ "postgres-password:${secretFiles.admin}" ]; TimeoutStartSec = "6h"; UMask = "0077"; }; }; }; services.nginx = { appendHttpConfig = '' limit_req_zone $binary_remote_addr zone=bitcoin_pulse_api:10m rate=300r/m; ''; virtualHosts."bitcoin-pulse.tobiasostner.de" = { enableACME = true; forceSSL = true; locations."/" = { proxyPass = "http://127.0.0.1:3000"; extraConfig = '' limit_req zone=bitcoin_pulse_api burst=50 nodelay; limit_req_status 429; client_max_body_size 16k; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Connection ""; proxy_connect_timeout 2s; proxy_read_timeout 10s; proxy_send_timeout 10s; proxy_next_upstream off; ''; }; }; }; systemd.tmpfiles.rules = [ "d ${backupDirectory} 0700 root root - -" ]; environment.systemPackages = [ pkgs.postgresql_18 ]; }