From 85338a0126b1f14639e99a47c81ada43ad1035c3 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 18 Aug 2026 02:27:52 -0500 Subject: [PATCH] Add Nix deployment support nix-bitcoin is archived and no longer receives security fixes. Provide a maintained upstream path for reproducible builds and declarative NixOS deployment. The flake pins Nixpkgs and exports a package and NixOS module. The package builds the server and CLI, takes its version from Cargo, and installs shell completions. The module creates a service, user, configuration, state directory, and optional firewall ports for each named instance. The documentation covers local builds, deployment, secrets, and multiple instances. Generated with OpenAI Codex. --- .github/workflows/nix.yml | 21 +++ README.md | 1 + docs/nix.md | 108 +++++++++++++++ flake.lock | 27 ++++ flake.nix | 62 +++++++++ nix/module.nix | 271 ++++++++++++++++++++++++++++++++++++++ nix/package.nix | 81 ++++++++++++ nix/tests/module.nix | 78 +++++++++++ 8 files changed, 649 insertions(+) create mode 100644 .github/workflows/nix.yml create mode 100644 docs/nix.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/module.nix create mode 100644 nix/package.nix create mode 100644 nix/tests/module.nix diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 00000000..1bcf1cad --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,21 @@ +name: Nix Checks + +on: [ push, pull_request ] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout source code + uses: actions/checkout@v6 + - name: Install Nix + uses: cachix/install-nix-action@v31 + - name: Check the flake + run: nix flake check --print-build-logs diff --git a/README.md b/README.md index e69d65a1..d3e20cbc 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ See [Getting Started](docs/getting-started.md) for a full walkthrough. | [API Guide](docs/api-guide.md) | gRPC transport, authentication, and endpoint reference | | [Tor](docs/tor.md) | Connecting to and receiving connections over Tor | | [Operations](docs/operations.md) | Production deployment, backups, and monitoring | +| [Nix deployment](docs/nix.md) | Reproducible builds and a NixOS service module | ### API diff --git a/docs/nix.md b/docs/nix.md new file mode 100644 index 00000000..f0ae1693 --- /dev/null +++ b/docs/nix.md @@ -0,0 +1,108 @@ +# Nix deployment + +The flake builds `ldk-server` and `ldk-server-cli`. It also provides a NixOS +module for the daemon. + +## Run without installing + +Build and run the server from this repository: + +```bash +nix run . -- /path/to/config.toml +``` + +Run the command-line client: + +```bash +nix shell .#ldk-server -c ldk-server-cli --help +``` + +## Deploy on NixOS + +Add the flake to your system inputs: + +```nix +{ + inputs.ldk-server.url = "github:lightningdevkit/ldk-server"; + + outputs = { nixpkgs, ldk-server, ... }: { + nixosConfigurations.my-host = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + ldk-server.nixosModules.default + { + services.ldk-server.instances = { + mainnet = { + enable = true; + openFirewall = true; + lightningPort = 9735; + settings = { + node = { + network = "bitcoin"; + listening_addresses = [ "0.0.0.0:9735" ]; + grpc_service_address = "127.0.0.1:3536"; + }; + esplora.server_url = "https://mempool.space/api"; + log = { + level = "Info"; + log_to_file = false; + }; + }; + }; + + signet = { + enable = true; + lightningPort = 19735; + grpcPort = 13536; + settings = { + node = { + network = "signet"; + listening_addresses = [ "127.0.0.1:19735" ]; + grpc_service_address = "127.0.0.1:13536"; + }; + esplora.server_url = "https://mutinynet.com/api"; + }; + }; + }; + } + ]; + }; + }; +} +``` + +By default, each instance gets a separate service, user, configuration, and +data directory. For example, `mainnet` uses `ldk-server-mainnet.service` and +stores data in `/var/lib/ldk-server/mainnet`. + +Use different Lightning and gRPC addresses for each instance. The module sets +each data path even if the TOML file contains a different storage path. + +The example exposes the Lightning port but keeps the gRPC API on loopback. +Before you expose gRPC, configure its certificate and client access as +described in [Operations - TLS](operations.md#tls). + +The `settings` option writes values to the Nix store. Do not put passwords or +other secrets in this option. Use `environmentFiles` for secrets: + +```nix +services.ldk-server.instances.mainnet.environmentFiles = [ + "/run/secrets/ldk-server-mainnet" +]; +``` + +The file can override supported settings with environment variables: + +```text +LDK_SERVER_BITCOIND_RPC_USER=rpc-user +LDK_SERVER_BITCOIND_RPC_PASSWORD=rpc-password +``` + +You can also set an instance's `configFile` to a complete TOML file. You +cannot use `configFile` and `settings` on the same instance. + +After deployment, inspect the service with this command: + +```bash +systemctl status ldk-server-mainnet +``` diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..3cfef493 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1786862985, + "narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..22b95f67 --- /dev/null +++ b/flake.nix @@ -0,0 +1,62 @@ +{ + description = "LDK Server"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = + { self, nixpkgs }: + let + supportedSystems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-linux" + ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + in + { + packages = forAllSystems ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = self.packages.${system}.ldk-server; + ldk-server = pkgs.callPackage ./nix/package.nix { + gitHash = self.rev or self.dirtyRev or "unknown"; + }; + } + ); + + checks = forAllSystems ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + inherit (self.packages.${system}) ldk-server; + } + // nixpkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + nixos-module = pkgs.testers.runNixOSTest ( + import ./nix/tests/module.nix { + inherit pkgs; + ldkServerModule = self.nixosModules.ldk-server; + } + ); + } + ); + + nixosModules = { + default = self.nixosModules.ldk-server; + ldk-server = + { lib, pkgs, ... }: + { + imports = [ ./nix/module.nix ]; + services.ldk-server.package = + lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.ldk-server; + }; + }; + + formatter = forAllSystems (system: nixpkgs.legacyPackages.${system}.nixfmt-tree); + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 00000000..1627402f --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,271 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + inherit (lib) mkOption types; + cfg = config.services.ldk-server; + toml = pkgs.formats.toml { }; + + isAbsolutePath = path: lib.hasPrefix "/" path; + isAbsoluteEnvironmentFile = path: isAbsolutePath (lib.removePrefix "-" path); + + instanceType = types.submodule ( + { name, ... }: + { + options = { + enable = mkOption { + type = types.bool; + default = false; + description = "Start this LDK Server instance."; + }; + + settings = mkOption { + inherit (toml) type; + default = { }; + example = lib.literalExpression '' + { + node = { + network = "bitcoin"; + listening_addresses = [ "0.0.0.0:9735" ]; + }; + esplora.server_url = "https://mempool.space/api"; + } + ''; + description = '' + Settings for the generated TOML file. Do not put secrets in this + option because the Nix store is readable by all local users. + ''; + }; + + configFile = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + An existing TOML configuration file with an absolute path. This + option and settings cannot be used together. + ''; + }; + + environmentFiles = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "/run/secrets/ldk-server-${name}" ]; + description = '' + Files with environment variables for secrets and setting + overrides. Each file must have an absolute path and use systemd + EnvironmentFile syntax. Prefix a path with - to make it optional. + ''; + }; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/ldk-server/${name}"; + description = '' + The absolute path of the directory that stores data for this + instance. + ''; + }; + + user = mkOption { + type = types.str; + default = "ldk-server-${name}"; + description = "The user for this instance."; + }; + + group = mkOption { + type = types.str; + default = "ldk-server-${name}"; + description = "The group for this instance."; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = "Open the selected ports for this instance."; + }; + + lightningPort = mkOption { + type = types.nullOr types.port; + default = null; + description = '' + The Lightning port to open. This port must match the instance + configuration. + ''; + }; + + grpcPort = mkOption { + type = types.nullOr types.port; + default = null; + description = '' + The gRPC port to open. This port must match the instance + configuration. + ''; + }; + }; + } + ); + + enabledInstances = lib.filterAttrs (_: instance: instance.enable) cfg.instances; + + generatedConfigs = lib.mapAttrs ( + name: instance: + toml.generate "ldk-server-${name}.toml" ( + lib.recursiveUpdate instance.settings { + storage.disk.dir_path = instance.dataDir; + } + ) + ) enabledInstances; + + instanceConfigPath = + name: instance: + if instance.configFile == null then generatedConfigs.${name} else instance.configFile; + + defaultUsers = lib.filterAttrs ( + name: instance: instance.user == "ldk-server-${name}" + ) enabledInstances; + defaultGroups = lib.filterAttrs ( + name: instance: instance.group == "ldk-server-${name}" + ) enabledInstances; +in +{ + options.services.ldk-server = { + package = mkOption { + type = types.package; + default = pkgs.callPackage ./package.nix { }; + defaultText = lib.literalExpression "pkgs.callPackage ./nix/package.nix { }"; + description = "The LDK Server package to run."; + }; + + instances = mkOption { + type = types.attrsOf instanceType; + default = { }; + description = "Named LDK Server instances."; + }; + }; + + config = { + assertions = + lib.concatLists ( + lib.mapAttrsToList (name: instance: [ + { + assertion = builtins.match "^[a-zA-Z0-9_-]+$" name != null; + message = "LDK Server instance name '${name}' contains invalid characters"; + } + { + assertion = instance.configFile == null || instance.settings == { }; + message = '' + services.ldk-server.instances.${name}.configFile and settings + cannot be used together + ''; + } + { + assertion = isAbsolutePath instance.dataDir; + message = '' + services.ldk-server.instances.${name}.dataDir must be an + absolute path + ''; + } + { + assertion = instance.configFile == null || isAbsolutePath instance.configFile; + message = '' + services.ldk-server.instances.${name}.configFile must be an + absolute path + ''; + } + { + assertion = lib.all isAbsoluteEnvironmentFile instance.environmentFiles; + message = '' + services.ldk-server.instances.${name}.environmentFiles must + contain absolute paths + ''; + } + { + assertion = !instance.openFirewall || instance.lightningPort != null || instance.grpcPort != null; + message = '' + services.ldk-server.instances.${name}.openFirewall requires at + least one port + ''; + } + ]) enabledInstances + ) + ++ [ + { + assertion = + builtins.length (lib.unique (lib.mapAttrsToList (_: instance: instance.dataDir) enabledInstances)) + == builtins.length (lib.attrNames enabledInstances); + message = "Enabled LDK Server instances must use different data directories"; + } + ]; + + users.groups = lib.mapAttrs' (_: instance: lib.nameValuePair instance.group { }) defaultGroups; + + users.users = lib.mapAttrs' ( + _: instance: + lib.nameValuePair instance.user { + isSystemUser = true; + group = instance.group; + home = instance.dataDir; + } + ) defaultUsers; + + systemd.tmpfiles.rules = lib.mapAttrsToList ( + _: instance: "d '${instance.dataDir}' 0750 ${instance.user} ${instance.group} - -" + ) enabledInstances; + + systemd.services = lib.mapAttrs' ( + name: instance: + lib.nameValuePair "ldk-server-${name}" { + description = "LDK Server Lightning Node (${name})"; + documentation = [ "https://github.com/lightningdevkit/ldk-server" ]; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + + serviceConfig = { + Type = "notify"; + NotifyAccess = "main"; + ExecStart = lib.escapeShellArgs [ + (lib.getExe cfg.package) + (instanceConfigPath name instance) + "--storage-dir-path" + instance.dataDir + ]; + User = instance.user; + Group = instance.group; + EnvironmentFile = instance.environmentFiles; + Restart = "on-failure"; + RestartSec = 10; + + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + ReadWritePaths = [ instance.dataDir ]; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + }; + } + ) enabledInstances; + + networking.firewall.allowedTCPPorts = lib.concatMap ( + instance: + lib.optionals instance.openFirewall ( + lib.filter (port: port != null) [ + instance.lightningPort + instance.grpcPort + ] + ) + ) (lib.attrValues enabledInstances); + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 00000000..cbe6a481 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,81 @@ +{ + installShellFiles, + lib, + rustPlatform, + stdenv, + gitHash ? "unknown", +}: + +rustPlatform.buildRustPackage (finalAttrs: { + pname = "ldk-server"; + version = (builtins.fromTOML (builtins.readFile ../ldk-server/Cargo.toml)).package.version; + + src = lib.fileset.toSource { + root = ../.; + fileset = lib.fileset.unions [ + ../Cargo.lock + ../Cargo.toml + ../LICENSE-APACHE + ../LICENSE-MIT + ../ldk-server + ../ldk-server-cli + ../ldk-server-client + ../ldk-server-grpc + ../ldk-server-mcp + ]; + }; + + cargoLock = { + lockFile = ../Cargo.lock; + outputHashes = { + "bitcoin-payment-instructions-0.6.0" = "sha256-ZhnZopZgBIsoIKHxWmCfQDrpGpYLSedrC+Odj0Mbbo8="; + "ldk-node-0.8.0+git" = "sha256-YgdxkOdS1bvG7NWGKgzXiLqw0ciTqHMJghk/bvQEG3A="; + "lightning-0.3.0+git" = "sha256-o4NT1unDxzQ9TlPpkqsB1G7Qh9vV6ZvqPEKR+2smsvY="; + }; + }; + + GIT_HASH = gitHash; + + nativeBuildInputs = [ installShellFiles ]; + + cargoBuildFlags = [ + "-p" + "ldk-server" + "-p" + "ldk-server-cli" + ]; + cargoTestFlags = finalAttrs.cargoBuildFlags; + checkType = "debug"; + + installPhase = '' + runHook preInstall + + releaseDir="target/${stdenv.hostPlatform.rust.rustcTarget}/release" + install -Dm755 "$releaseDir/ldk-server" "$out/bin/ldk-server" + install -Dm755 "$releaseDir/ldk-server-cli" "$out/bin/ldk-server-cli" + + ${lib.optionalString (stdenv.buildPlatform.canExecute stdenv.hostPlatform) '' + completionDir="$(mktemp -d)" + "$releaseDir/ldk-server-cli" completions bash > "$completionDir/ldk-server-cli.bash" + "$releaseDir/ldk-server-cli" completions fish > "$completionDir/ldk-server-cli.fish" + "$releaseDir/ldk-server-cli" completions zsh > "$completionDir/_ldk-server-cli" + + installShellCompletion --cmd ldk-server-cli \ + --bash "$completionDir/ldk-server-cli.bash" \ + --fish "$completionDir/ldk-server-cli.fish" \ + --zsh "$completionDir/_ldk-server-cli" + ''} + + runHook postInstall + ''; + + meta = { + description = "Ready-to-run Lightning node daemon built with LDK Node"; + homepage = "https://github.com/lightningdevkit/ldk-server"; + license = with lib.licenses; [ + asl20 + mit + ]; + mainProgram = "ldk-server"; + }; +}) diff --git a/nix/tests/module.nix b/nix/tests/module.nix new file mode 100644 index 00000000..b29752ec --- /dev/null +++ b/nix/tests/module.nix @@ -0,0 +1,78 @@ +{ pkgs, ldkServerModule }: + +let + fakeServer = pkgs.writeShellApplication { + name = "ldk-server"; + runtimeInputs = [ + pkgs.coreutils + pkgs.systemd + ]; + text = '' + config_file="$1" + test "$2" = "--storage-dir-path" + data_dir="$3" + + cp --remove-destination "$config_file" "$data_dir/observed-config.toml" + printf '%s\n' "$@" > "$data_dir/observed-arguments" + exec systemd-notify --ready --exec ';' sleep infinity + ''; + }; +in +{ + name = "ldk-server-module"; + + nodes.machine = { + imports = [ ldkServerModule ]; + + services.ldk-server = { + package = fakeServer; + instances = { + mainnet = { + enable = true; + environmentFiles = [ "-/run/secrets/ldk-server-mainnet" ]; + settings.node.network = "bitcoin"; + }; + signet = { + enable = true; + dataDir = "/var/lib/ldk-server-signet-test"; + settings.node.network = "signet"; + }; + }; + }; + }; + + testScript = '' + machine.wait_for_unit("ldk-server-mainnet.service") + machine.wait_for_unit("ldk-server-signet.service") + + machine.succeed( + "test $(stat -c '%U:%G:%a' /var/lib/ldk-server/mainnet) = " + "ldk-server-mainnet:ldk-server-mainnet:750" + ) + machine.succeed( + "test $(stat -c '%U:%G:%a' /var/lib/ldk-server-signet-test) = " + "ldk-server-signet:ldk-server-signet:750" + ) + + machine.succeed( + "grep -F 'network = \"bitcoin\"' " + "/var/lib/ldk-server/mainnet/observed-config.toml" + ) + machine.succeed( + "grep -F 'dir_path = \"/var/lib/ldk-server/mainnet\"' " + "/var/lib/ldk-server/mainnet/observed-config.toml" + ) + machine.succeed( + "grep -F 'network = \"signet\"' " + "/var/lib/ldk-server-signet-test/observed-config.toml" + ) + machine.succeed( + "grep -F 'dir_path = \"/var/lib/ldk-server-signet-test\"' " + "/var/lib/ldk-server-signet-test/observed-config.toml" + ) + + machine.succeed("systemctl stop ldk-server-mainnet.service") + machine.fail("systemctl is-active --quiet ldk-server-mainnet.service") + machine.succeed("systemctl is-active --quiet ldk-server-signet.service") + ''; +}