--- # phoenixd Deployment Playbook # # Deploys phoenixd (https://phoenix.acinq.co/server), the server version of the # Phoenix Lightning wallet, on vipy so LNBits can use it as a wallet backend # over loopback. # # What this does: # 1. Downloads the pinned phoenixd release and installs phoenixd + phoenix-cli # 2. Creates a dedicated system user and a 0700 data directory # 3. Creates and enables a systemd service # 4. Creates a push-monitor health check script + systemd timer # 5. Registers a push monitor in Uptime Kuma # # The HTTP API stays bound to 127.0.0.1 and is NOT proxied by Caddy: phoenixd # holds funds and its API is protected by a single password. Anything that needs # it either runs on this host or reaches it over the Tailscale mesh. # # ⚠️ After the first run, back up {{ phoenixd_data_dir }}/seed.dat. Losing it # means losing the funds. See setup_backup_phoenixd_to_lapy.yml. - name: Deploy phoenixd on vipy hosts: edge become: yes vars_files: - ../../infra_vars.yml - ../../services_config.yml - ../../infra_secrets.yml - ./phoenixd_vars.yml vars: uptime_kuma_api_url: "https://{{ subdomains.uptime_kuma }}.{{ root_domain }}" tasks: # =========================================== # Prerequisites # =========================================== - name: Install phoenixd runtime dependencies apt: name: - unzip - curl state: present update_cache: yes # =========================================== # System User and Directories # =========================================== - name: Create phoenixd system group group: name: "{{ phoenixd_group }}" system: yes - name: Create phoenixd system user user: name: "{{ phoenixd_user }}" group: "{{ phoenixd_group }}" system: yes shell: /usr/sbin/nologin home: "{{ phoenixd_home }}" create_home: yes comment: "phoenixd Lightning node" - name: Create phoenixd home directory file: path: "{{ phoenixd_home }}" state: directory owner: "{{ phoenixd_user }}" group: "{{ phoenixd_group }}" mode: "0750" - name: Create phoenixd data directory file: path: "{{ phoenixd_data_dir }}" state: directory owner: "{{ phoenixd_user }}" group: "{{ phoenixd_group }}" mode: "0700" # =========================================== # Download and Install # =========================================== - name: Check if phoenixd is already installed stat: path: "{{ phoenixd_bin_dir }}/phoenixd" register: phoenixd_binary - name: Check installed phoenixd version command: "{{ phoenixd_bin_dir }}/phoenixd --version" register: phoenixd_installed_version changed_when: false failed_when: false when: phoenixd_binary.stat.exists - name: Decide whether phoenixd needs installing set_fact: phoenixd_needs_install: >- {{ not phoenixd_binary.stat.exists or phoenixd_version not in (phoenixd_installed_version.stdout | default('')) }} - name: Download phoenixd {{ phoenixd_version }} get_url: url: "{{ phoenixd_url }}" dest: "/tmp/phoenixd-{{ phoenixd_version }}.zip" mode: "0644" when: phoenixd_needs_install | bool - name: Create temporary extraction directory file: path: /tmp/phoenixd-extract state: directory mode: "0755" when: phoenixd_needs_install | bool - name: Extract phoenixd archive unarchive: src: "/tmp/phoenixd-{{ phoenixd_version }}.zip" dest: /tmp/phoenixd-extract remote_src: yes when: phoenixd_needs_install | bool - name: Locate extracted binaries find: paths: /tmp/phoenixd-extract patterns: "{{ item }}" recurse: yes file_type: file register: phoenixd_extracted loop: - phoenixd - phoenix-cli when: phoenixd_needs_install | bool - name: Fail if the archive did not contain the expected binaries assert: that: - item.files | length > 0 fail_msg: "Could not find '{{ item.item }}' in the phoenixd {{ phoenixd_version }} archive" loop: "{{ phoenixd_extracted.results }}" loop_control: label: "{{ item.item }}" when: phoenixd_needs_install | bool - name: Install phoenixd and phoenix-cli binaries copy: src: "{{ item.files[0].path }}" dest: "{{ phoenixd_bin_dir }}/{{ item.item }}" remote_src: yes owner: root group: root mode: "0755" loop: "{{ phoenixd_extracted.results }}" loop_control: label: "{{ item.item }}" when: phoenixd_needs_install | bool notify: Restart phoenixd - name: Clean up phoenixd download artifacts file: path: "{{ item }}" state: absent loop: - "/tmp/phoenixd-{{ phoenixd_version }}.zip" - /tmp/phoenixd-extract # =========================================== # Systemd Service # =========================================== - name: Build phoenixd command line arguments set_fact: phoenixd_args: >- {{ (['--agree-to-terms-of-service'] if phoenixd_agree_tos else []) + ['--chain', phoenixd_chain] + ['--auto-liquidity', phoenixd_auto_liquidity] + ['--http-bind-ip', phoenixd_http_bind_ip] + ['--http-bind-port', phoenixd_http_bind_port | string] + (['--max-mining-fee', phoenixd_max_mining_fee | string] if phoenixd_max_mining_fee else []) + (['--webhook', phoenixd_webhook_url] if phoenixd_webhook_url else []) + ['--silent'] }} - name: Create phoenixd systemd service copy: dest: /etc/systemd/system/phoenixd.service content: | [Unit] Description=phoenixd - Lightning Network Node Documentation=https://phoenix.acinq.co/server After=network-online.target Wants=network-online.target [Service] Type=simple User={{ phoenixd_user }} Group={{ phoenixd_group }} WorkingDirectory={{ phoenixd_home }} Environment=PHOENIX_DATADIR={{ phoenixd_data_dir }} ExecStart={{ phoenixd_bin_dir }}/phoenixd {{ phoenixd_args | join(' ') }} Restart=always RestartSec=30 TimeoutStartSec=120 TimeoutStopSec=120 StandardOutput=journal StandardError=journal # Hardening: the node only ever writes to its own data directory NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=read-only ReadWritePaths={{ phoenixd_data_dir }} LimitNOFILE=65535 [Install] WantedBy=multi-user.target owner: root group: root mode: "0644" notify: Restart phoenixd - name: Reload systemd daemon systemd: daemon_reload: yes - name: Enable and start phoenixd systemd: name: phoenixd enabled: yes state: started - name: Flush handlers so phoenixd is running before we inspect its data dir meta: flush_handlers # =========================================== # First Boot Checks # =========================================== - name: Wait for phoenixd to write its config file wait_for: path: "{{ phoenixd_data_dir }}/phoenix.conf" state: present timeout: 120 - name: Check that the seed file exists stat: path: "{{ phoenixd_data_dir }}/seed.dat" register: phoenixd_seed_file - name: Fail if phoenixd did not create a seed assert: that: - phoenixd_seed_file.stat.exists fail_msg: "phoenixd started but {{ phoenixd_data_dir }}/seed.dat is missing - check 'journalctl -u phoenixd'" # =========================================== # Health Check Script + Systemd Timer # =========================================== # ═════════════════════════════════════════════════════════════════════════ # DEPRECATED — Uptime Kuma was decommissioned on 2026-09-11. # # Every task below is inert: uptime_kuma_enabled is false in # group_vars/all/main.yml, so they all skip and the deployment above still # runs normally. Kept because the health-check logic is the durable part — # when a replacement exists, rewire the push transport and flip the flag. # # What was being monitored: archive/uptime_kuma/MONITORS.md # ═════════════════════════════════════════════════════════════════════════ - name: Create phoenixd health check script when: uptime_kuma_enabled | default(false) copy: dest: "{{ phoenixd_healthcheck_script_path }}" content: | #!/bin/bash # Checks phoenixd and pushes the result to Uptime Kuma. UPTIME_KUMA_PUSH_URL="${UPTIME_KUMA_PUSH_URL}" export PHOENIX_DATADIR="{{ phoenixd_data_dir }}" check_phoenixd() { # Service must be active and the node must answer getinfo. # phoenix-cli reads the api password from $PHOENIX_DATADIR/phoenix.conf, # but not the bind address, so pass it explicitly. systemctl is-active --quiet phoenixd && \ {{ phoenixd_bin_dir }}/phoenix-cli \ --http-bind-ip {{ phoenixd_http_bind_ip }} \ --http-bind-port {{ phoenixd_http_bind_port }} \ getinfo 2>/dev/null | grep -q '"nodeId"' } push_to_uptime_kuma() { local status=$1 local msg=$2 if [ -z "$UPTIME_KUMA_PUSH_URL" ]; then echo "ERROR: UPTIME_KUMA_PUSH_URL not set" return 1 fi curl -s --max-time 10 --retry 2 -o /dev/null \ "${UPTIME_KUMA_PUSH_URL}?status=${status}&msg=${msg// /%20}&ping=" || true } if check_phoenixd; then push_to_uptime_kuma "up" "OK" exit 0 else push_to_uptime_kuma "down" "phoenixd not responding" exit 1 fi owner: root group: root mode: "0755" - name: Create phoenixd health check systemd service copy: dest: "/etc/systemd/system/{{ phoenixd_healthcheck_service_name }}.service" content: | [Unit] Description=phoenixd Health Check After=network.target phoenixd.service [Service] Type=oneshot User=root ExecStart={{ phoenixd_healthcheck_script_path }} Environment=UPTIME_KUMA_PUSH_URL= StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target owner: root group: root mode: "0644" - name: Create phoenixd health check systemd timer copy: dest: "/etc/systemd/system/{{ phoenixd_healthcheck_service_name }}.timer" content: | [Unit] Description=phoenixd Health Check Timer [Timer] OnBootSec=2min OnUnitActiveSec=1min Persistent=true [Install] WantedBy=timers.target owner: root group: root mode: "0644" - name: Reload systemd daemon after health check units systemd: daemon_reload: yes - name: Enable and start phoenixd health check timer when: uptime_kuma_enabled | default(false) systemd: name: "{{ phoenixd_healthcheck_service_name }}.timer" enabled: yes state: started # =========================================== # Uptime Kuma Push Monitor Setup # =========================================== - name: Create Uptime Kuma push monitor setup script for phoenixd when: uptime_kuma_enabled | default(false) delegate_to: localhost become: no copy: dest: /tmp/setup_phoenixd_monitor.py content: | #!/usr/bin/env python3 import sys import time import traceback import yaml from uptime_kuma_api import UptimeKumaApi, MonitorType try: with open('/tmp/ansible_phoenixd_config.yml', 'r') as f: config = yaml.safe_load(f) url = config['uptime_kuma_url'] username = config['username'] password = config['password'] monitor_name = config['monitor_name'] api = UptimeKumaApi(url, timeout=30) api.login(username, password) monitors = api.get_monitors() # Find or create "services" group group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None) if not group: try: api.add_monitor(type='group', name='services') except Exception: time.sleep(2) monitors = api.get_monitors() group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None) # Get ntfy notification ID notifications = api.get_notifications() ntfy_notification_id = None for notif in notifications: if notif.get('type') == 'ntfy': ntfy_notification_id = notif.get('id') break existing = next((m for m in monitors if m.get('name') == monitor_name), None) push_url = None if existing: print(f"Monitor '{monitor_name}' already exists (ID: {existing['id']})") push_token = existing.get('pushToken') or existing.get('push_token') if push_token: push_url = f"{url}/api/push/{push_token}" else: print(f"Creating push monitor '{monitor_name}'...") try: api.add_monitor( type=MonitorType.PUSH, name=monitor_name, parent=group['id'], interval=90, maxretries=3, retryInterval=60, notificationIDList={ntfy_notification_id: True} if ntfy_notification_id else {} ) except Exception as e: # socketio timeout: add_monitor may have succeeded server-side print(f"add_monitor raised (possibly timeout): {e}", file=sys.stderr) time.sleep(2) monitors = api.get_monitors() new_monitor = next((m for m in monitors if m.get('name') == monitor_name), None) if new_monitor: push_token = new_monitor.get('pushToken') or new_monitor.get('push_token') if push_token: push_url = f"{url}/api/push/{push_token}" api.disconnect() if push_url: print(f"PUSH_URL={push_url}") with open('/tmp/phoenixd_push_url.txt', 'w') as f: f.write(push_url) print("SUCCESS") except Exception as e: print(f"ERROR: {str(e)}", file=sys.stderr) traceback.print_exc(file=sys.stderr) sys.exit(1) mode: "0755" - name: Create temporary config for push monitor setup when: uptime_kuma_enabled | default(false) delegate_to: localhost become: no copy: dest: /tmp/ansible_phoenixd_config.yml content: | uptime_kuma_url: "{{ uptime_kuma_api_url }}" username: "{{ uptime_kuma_username }}" password: "{{ uptime_kuma_password }}" monitor_name: "{{ phoenixd_monitor_name }}" mode: "0644" - name: Run Uptime Kuma push monitor setup when: uptime_kuma_enabled | default(false) command: python3 /tmp/setup_phoenixd_monitor.py delegate_to: localhost become: no register: monitor_setup changed_when: "'SUCCESS' in monitor_setup.stdout" ignore_errors: yes - name: Display monitor setup output debug: msg: "{{ monitor_setup.stdout_lines }}" when: monitor_setup.stdout is defined - name: Read push URL from file when: uptime_kuma_enabled | default(false) slurp: src: /tmp/phoenixd_push_url.txt delegate_to: localhost become: no register: push_url_file ignore_errors: yes - name: Parse push URL set_fact: phoenixd_push_url: "{{ push_url_file.content | b64decode | trim }}" when: push_url_file.content is defined - name: Update health check service with push URL lineinfile: path: "/etc/systemd/system/{{ phoenixd_healthcheck_service_name }}.service" regexp: "^Environment=UPTIME_KUMA_PUSH_URL=" line: "Environment=UPTIME_KUMA_PUSH_URL={{ phoenixd_push_url }}" when: phoenixd_push_url is defined notify: Restart phoenixd health check timer - name: Clean up temporary files when: uptime_kuma_enabled | default(false) delegate_to: localhost become: no file: path: "{{ item }}" state: absent loop: - /tmp/setup_phoenixd_monitor.py - /tmp/ansible_phoenixd_config.yml - /tmp/phoenixd_push_url.txt # =========================================== # Post-install Notes # =========================================== - name: Display post-install information debug: msg: | ✓ phoenixd {{ phoenixd_version }} deployed Status: systemctl status phoenixd Logs: journalctl -u phoenixd -f CLI: sudo PHOENIX_DATADIR={{ phoenixd_data_dir }} phoenix-cli --http-bind-port {{ phoenixd_http_bind_port }} getinfo HTTP API: http://{{ phoenixd_http_bind_ip }}:{{ phoenixd_http_bind_port }} (loopback only) Data dir: {{ phoenixd_data_dir }} API password (needed to wire LNBits up to this node): sudo grep '^http-password=' {{ phoenixd_data_dir }}/phoenix.conf ⚠️ BACK UP THE SEED NOW: {{ phoenixd_data_dir }}/seed.dat Losing it means losing the funds. Run services/phoenixd/setup_backup_phoenixd_to_lapy.yml and also keep the 12 words somewhere offline. handlers: - name: Restart phoenixd systemd: name: phoenixd state: restarted daemon_reload: yes - name: Restart phoenixd health check timer systemd: name: "{{ phoenixd_healthcheck_service_name }}.timer" state: restarted daemon_reload: yes