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>
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.nix→lnbits.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:
- Download the catalog tarball.
- Extract it directly over the mounted directory.
- 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):
- Read DB row via
get_super_settings()(lnbits/core/services/users.py:236). - If the DB row is empty → seed it from
.envviainit_admin_settings(). First-boot only. update_cached_settings(settings_db.dict())overwrites the in-memorySettingswith the DB row..envvalues 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 bycheck_admin), or - Clearing the relevant rows in the
system_settingstable in the core DB.
Exceptions where .env still wins on every boot:
-
super_user— env overrides DB explicitly inusers.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
ReadOnlySettingsfields (defined inlnbits/settings.py—EnvSettings/PersistenceSettings/SuperUserSettings/ExtensionsInstallSettings). Concretely:host,portlnbits_path,lnbits_data_folder,lnbits_extensions_pathlnbits_database_urlauth_secret_key,first_install_tokenlnbits_title(the API title — NOTlnbits_site_title, which is editable)lnbits_admin_uiitselflnbits_allowed_funding_sources(the list of which sources can be enabled — the per-source credentials live inFundingSourcesSettings→EditableSettingsand ARE DB-frozen)
update_cached_settingsskips any key inreadonly_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
NostrSignerabstraction 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
DelegatedSignervia 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.
CLINK is Lightning.Pub-specific, not LNbits
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
extrapayloads 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.tomlversion:<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 perLNBITS_BACKEND_WALLET_CLASSvalue.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 intodev(see lnbits-upstream-flow.md for why--first-parentmatters here).