This repository has been archived on 2026-06-22. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
lnbits-sensei/docs/lnbits-workspace-notes.md
Padreug 35ef01fd70 docs(workspace-notes): add settings precedence (.env vs DB)
LNbits has two sources of truth for settings depending on lifecycle.
The trap: on first boot, .env seeds the DB; on every subsequent boot,
the DB row overwrites the in-memory Settings. Editing .env after the
first boot is a no-op for editable fields — they can only change via
the Admin UI or by clearing rows in system_settings.

Documented:
- the boot-time read-DB → seed-from-env → overwrite-cached-Settings
  sequence with file:line references for verification
- exceptions where env still wins (super_user, lnbits_admin_ui=False,
  the full ReadOnlySettings field list)
- LNBITS_FIRST_INSTALL_TOKEN rotation does NOT reset settings to env
  (it's an admin-recovery escape hatch, not a config-refresh)
- the deploy-side implication: declarative env vars are a *seed*
  for EditableSettings, authoritative for ReadOnlySettings — plan
  your deploy story accordingly

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:32:48 +02:00

10 KiB

lnbits workspace notes

Practical reference for day-to-day work in an lnbits dev environment. Collected gotchas, conventions, and design constraints that have repeatedly surprised people. Not a tutorial — assumes you're already running lnbits and contributing or extending it.

Pick a non-default port for your local dev server

LNbits defaults to :5000. That collides with macOS AirPlay Receiver, common docker-compose stacks, and other dev tools. Pick something unambiguous (many forks settle on :5001) and stick to it across:

  • settings.nixlnbits.port
  • any MCP server config you wire against your dev instance
  • bookmarks, env files, CI configs

Once you wire it in three places, switching the port retroactively is mostly find-and-replace pain. Decide early.

LNBITS_SRC and docker-compose build context

If you run a docker-compose dev stack (regtest or otherwise) that builds lnbits from a local checkout, the Dockerfile typically reads from ${LNBITS_SRC:-/some/default}.

The trap: commits to your day-to-day worktree don't reach the dev image if LNBITS_SRC is currently pointed elsewhere (e.g. at a feature-branch worktree you were testing). Even docker compose build --no-cache happily rebuilds from the wrong checkout.

Sanity-check before assuming a rebuild picked up your change:

docker compose config | grep -A2 lnbits
# look for the resolved `context:` path

If it's pointing somewhere stale, either cherry-pick your commit onto the active build branch or flip LNBITS_SRC and rebuild.

Extension folder is the install target — "Upgrade" wipes forks

If your dev setup mounts an extension checkout directly into the lnbits container (e.g. ~/dev/shared/extensions//shared, with LNBITS_EXTENSIONS_PATH=/shared), the extension git checkout IS the installed extension. There's no separate copy.

Consequence: clicking "Upgrade" in the LNbits UI on an extension that's mounted from a fork checkout will:

  1. Download the catalog tarball.
  2. Extract it directly over the mounted directory.
  3. Wipe .git, replace every file with the catalog version, and silently discard any local changes / fork patches.

If this happens, recover with git clone <your-fork-url> <dir> over the wiped directory. Mitigations:

  • Set an extension version in your fork that always sorts above the catalog's so "Upgrade" never thinks the catalog is newer.
  • Or don't expose the catalog upgrade UI to a workflow that has mounted-fork extensions.

Settings precedence: .env seeds the DB on first boot, then the DB wins

This one trips up most people who deploy lnbits declaratively (via NixOS, docker-compose, ansible, …). Verified 2026-05-24 against upstream lnbits/main.

LNbits has two sources of truth for settings depending on lifecycle. On boot, when lnbits_admin_ui=True (the default):

  1. Read DB row via get_super_settings() (lnbits/core/services/users.py:236).
  2. If the DB row is empty → seed it from .env via init_admin_settings(). First-boot only.
  3. update_cached_settings(settings_db.dict()) overwrites the in-memory Settings with the DB row. .env values loaded by Pydantic at startup are clobbered.

Practical consequence: once an instance has booted once, editing .env and restarting changes nothing for editable fields. They can only be changed via:

  • The Admin UI (PUT /api/v1/settings, gated by check_admin), or
  • Clearing the relevant rows in the system_settings table in the core DB.

Exceptions where .env still wins on every boot:

  • super_user — env overrides DB explicitly in users.py.

  • lnbits_admin_ui=False — the whole DB-load block is skipped; env stays authoritative (there's no Admin UI to populate the DB anyway).

  • All ReadOnlySettings fields (defined in lnbits/settings.pyEnvSettings / PersistenceSettings / SuperUserSettings / ExtensionsInstallSettings). Concretely:

    • host, port
    • lnbits_path, lnbits_data_folder, lnbits_extensions_path
    • lnbits_database_url
    • auth_secret_key, first_install_token
    • lnbits_title (the API title — NOT lnbits_site_title, which is editable)
    • lnbits_admin_ui itself
    • lnbits_allowed_funding_sources (the list of which sources can be enabled — the per-source credentials live in FundingSourcesSettingsEditableSettings and ARE DB-frozen)

    update_cached_settings skips any key in readonly_variables, so env-loaded values for these fields survive the DB-load overwrite.

Editable settings — site title/tagline, theme, watchdog thresholds, fee defaults, rate limits, all per-funding-source credentials, the whole Admin UI form — get DB-frozen after first boot.

LNBITS_FIRST_INSTALL_TOKEN rotation ≠ "reset settings to env"

Rotating the first-install token creates a new super_user account with a fresh random UUID and re-enables the /first_install endpoint. It is an escape hatch for a locked-out admin to re-claim the instance with a new username/password. It does NOT refresh any settings values from .env — by the time it runs, the DB row has already overwritten cached settings, and init_admin_settings() then upserts those same DB values back, so no env values flow through.

Deploy-side implication

If you manage lnbits config declaratively (NixOS module, ansible role, docker-compose env file), editable env vars only take effect on a fresh install (empty settings table). For an existing deployment, changing them in your declarative source and redeploying won't change runtime behavior — you have to edit through the Admin UI OR clear the relevant rows in system_settings.

The "set it in nix, redeploy, done" mental model only works for ReadOnlySettings fields. Anything an admin can edit in the UI is DB-frozen post-first-boot. Plan your deploy story accordingly:

  • For ReadOnlySettings (host, port, paths, secret_key, …): declarative source is authoritative on every boot. Reproducible.
  • For EditableSettings (site title, fees, funding-source creds, …): declarative source is a seed, not authoritative. To re-seed an existing instance, you must explicitly clear the DB row.

Nostr key handling — don't persist plaintext nsecs

As of 2026, LNbits's upstream account model server-generates a Nostr private key (accounts.prvkey) for every new account and stores it plaintext in the DB. The key is returned to clients over HTTPS via auth endpoints. A DB snapshot, backup leak, or curious operator is a wholesale identity compromise for every user.

If your fork or extensions touch user Nostr keys, design around this. A defensible shape:

  • A NostrSigner abstraction with at least three implementations:
    • LocalSigner — server-side key, but envelope-encrypted at rest. User unlocks per session.
    • RemoteBunkerSigner — NIP-46 connection to a bunker the user runs themselves. Server never sees the private key.
    • ClientSideOnlySigner — the server only knows the user's public key. Signing happens entirely in the browser / NIP-07 extension / hardware signer.
  • Optionally a DelegatedSigner via NIP-26 for accounts that want a server-side signer for low-value operations while keeping high-value ops client-side.

Also audit every server-side Nostr key your extensions persist (merchant signing keys, notification keys, transport keys, …) and either encrypt at rest or document why ephemeral is fine.

The general principle: prefer "don't store the key" when possible. Let the user's signer (browser extension, hardware device, NIP-46 bunker) do the signing.

Shocknet's CLINK protocol — the Nostr-native payment flow with event kinds 21001 (offer/noffer), 21002 (debit/ndebit request), 21003 (debit response) — lives inside Lightning.Pub, not LNbits. LNbits as of 2026 has zero CLINK knowledge.

If you're thinking about ndebit / noffer semantics in an lnbits context:

  • They're Lightning.Pub primitives. LNbits doesn't implement them.
  • Building "equivalent outcomes" in LNbits typically goes through LNURL + payment-hash subscriptions + opaque extra payloads rather than CLINK-style events. Different primitives, similar effective semantics.
  • If you genuinely need CLINK in lnbits, it's a new transport module, not a tweak to existing code paths. NIP-44 plumbing in the nostr-transport surface is the natural building block.

Don't conflate the two — keep CLINK semantics on the Lightning.Pub side of any cross-system design.

Fork versioning — surface your fork in the version string

If you maintain a long-lived fork (especially one running in production), the released version string should make it obvious that fork-modified code is what's running. Consumers of the package, log readers, and bug reporters all benefit.

A common pattern:

  • Git tag: v<upstream-version>-<your-tag>.<N> e.g. v1.5.4-aio.1, v1.5.4-aio.2, …
  • pyproject.toml version: <upstream-version>+<your-tag>.<N> e.g. 1.5.4+aio.1.

The two spellings differ because PEP 440 doesn't accept hyphen-with-arbitrary-suffix as a valid package version, but the local- version form (+<tag>.<N>) is valid. Extensions don't have the PEP 440 constraint (their config.json is freeform), so the tag form works everywhere except pyproject.toml.

Decide your own tag string and stick to it. The key property is that importlib.metadata.version("lnbits") and the LNbits UI footer make it visible to anyone looking.

For the upstream lnbits branch / release model these tags sit on top of, see lnbits-upstream-flow.md.

Reading the codebase fast

For mass-grep and reference reading, point a tools-friendly mirror of the lnbits source at a known location (see your ~/dev/refs/ setup if you use one). Key paths once you have a checkout:

  • lnbits/core/services/ — core money flows. Start here for invoice / payment / wallet logic.
  • lnbits/wallets/ — backend implementations. One file per LNBITS_BACKEND_WALLET_CLASS value.
  • lnbits/extensions/ — first-party extensions. Third-party extensions follow the same shape (config.json, views.py, migrations.py, etc.).
  • git log --first-parent dev --oneline — see the squash-merge sequence into dev (see lnbits-upstream-flow.md for why --first-parent matters here).