inventree — MRP/inventory system for EmbeddedEra (parts, stock, BOMs, builds, tests)¶
Five services at the end of the torrentvpn compose file, following InvenTree's official
production Docker recipe (docs.inventree.org/en/stable/start/docker_install/) adapted into this
stack: inventree-db (postgres:17), inventree-cache (redis:7-alpine), inventree-server
(inventree/inventree:stable, gunicorn), inventree-worker (same image, invoke worker
background task processor), inventree-proxy (caddy:alpine, serves static/media and fronts the
other three). Phase 1 of mrp — see that note for the full data model and integration plan.
Why this shape¶
Electronics-native MRP: parts with manufacturer/supplier parts, BOMs, build orders that consume stock and create serialised units, and per-serial test results in the free tier — see mrp-tool for why InvenTree over ERPNext/Odoo/Dolibarr. Split into 5 containers (not one) because that's InvenTree's own supported shape — trying to run it any other way isn't supported upstream.
Shape¶
- Volumes (deviates from upstream's single shared volume, for cleaner separation):
./inventree/db→ postgres data (PGDATA=/var/lib/postgresql/data/pgdb, so actual files land indb/pgdb/, owned by the postgres container's uid, 0600 — not root-readable, that's normal)../inventree/data→ app data: static/media/plugins/secret key, mounted at/home/inventree/datain server+worker, and re-mounted into the proxy at/var/www/static,/var/www/media,/var/log,/data,/config(proxy needs read access to what the server writes)../inventree/redis→ cache data (ephemeral,--save "" --appendonly no). - No host ports on db/cache/server/worker — only
inventree-proxypublishes127.0.0.1:8100:80(loopback only). The Caddyfile's site block is:80(any Host header) rather than gated toINVENTREE_SITE_URL, because NPM already does host-based routing forerp.lan— this keepscurl http://127.0.0.1:8100/api/working without a Host-header override. - Secrets:
./inventree/.env(0600) — DB password,INVENTREE_SECRET_KEY, admin user/password/email — referenced by all five services viaenv_file:, never inlined intodocker-compose.yml. Admin creds also in~/.config/credentials/inventree.txt(0600). A long-lived API token for scripts is in~/.config/inventree/env(0600):INVENTREE_URL+INVENTREE_API_TOKEN. - Name:
erp.lanvia NPM (http://erp.lan→ containerinventree-proxy:80, no TLS,block_exploits+websocket on, same pattern aslist.lan/vaultwarden) — LAN-only for now. The publicerp.embeddedera.comsubdomain (Cloudflare Tunnel + Access) is a later phase per mrp — do not expose this box's ports to the internet before that lands. - One-time setup after first
docker compose up -dof db+cache:docker compose run --rm inventree-server invoke update(migrations, static collection, plugin install) — must be re-run after anINVENTREE_TAGversion bump, per upstream docs. - Settings changed from default via
/api/settings/global/:STOCK_ENABLE_EXPIRY,SERIAL_NUMBER_GLOBALLY_UNIQUE,PART_TRACKABLE,TEST_STATION_DATA,PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS,SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS,REPORT_ENABLE— all setTrue.PART_ENABLE_REVISIONandLABEL_ENABLEwere alreadyTrueby default. Built-in pluginsinventreelabel/inventreelabelmachinewere already active;inventreelabelsheet(arrays labels onto a printable sheet — the no-printer option) was enabled. A physical label printer (Brother QL/Zebra/Dymo) plugs in later viainventreelabelmachine's machine-driver framework, or a dedicated printer plugin from the InvenTree plugin index. - Seed data (categories/locations) created via API, not the UI — see mrp Log for the exact counts. SSO, email sending, and any UI-only settings are not scriptable via the settings API — none were required for phase 1.
Backup¶
Off-site, encrypted, restore-verified — separate from (and in addition to) InvenTree's own
built-in django-dbbackup snapshots that land unencrypted in inventree/data/backup/.
- What: pg_dump -Fc of inventree-db, listmonk-db, immich-postgres; a tar of
inventree/data/media + a copy of inventree/.env and data/config.yaml; Vaultwarden's
db.sqlite3 via sqlite3 .backup. Everything encrypted with age
(recipient-only — no private key touches the nightly run).
- Where: local copy /mnt/storage/EmbeddedEra/backups/erp/<stamp>/ (SnapRAID parity, 30-day
retention) + off-site in Cloudflare R2 bucket embeddedera-backups, key
erp/<stamp>/<file>.age (90-day lifecycle rule, set via the R2 API). Uploaded with the plain
Cloudflare account REST API (PUT .../r2/buckets/{bucket}/objects/{key}, bearer token from
~/.config/cloudflare/env) — the same mechanism list-sync already uses against
embeddedera-uploads; no S3 access keys needed (minting them needs a token-creation permission
this account token doesn't have, confirmed by testing).
- How: ~/.local/bin/erp-backup backup|verify|list|restore <stamp> — see the script's header
- What verify proves (widened 2026-09-13): it restores the InvenTree dump into a throwaway
postgres and opens the Kanboard, assistant and Vaultwarden sqlite copies from the same
decrypted archive, counting rows in each. Before that it only checked the ERP, which was
proving a third of the restore. Weekly, 06:15 Sunday, from cron.
comment for the full behavior. Cron: 15 3 * * * backup (before the 03:30 snapraid-sync),
15 6 * * 0 verify (restores the newest InvenTree dump into a throwaway postgres:17
container on a random loopback port, checks part_partcategory=10 and stock_stocklocation=20,
tears the container down).
- Restore on a fresh machine (~5 steps): 1) copy ~/.config/erp-backup/age.key back from the
Vaultwarden vault entry (0600) — without it nothing below can be decrypted. 2) install age
(user-space binary, no root) and set up ~/.config/cloudflare/env +
~/.local/bin/erp-backup. 3) erp-backup list to see available stamps, then
erp-backup restore <stamp> — downloads+decrypts that stamp into
/var/tmp/erp-restore-<stamp>/. 4) Stand up the five InvenTree containers per this card's own
restore steps below, then docker exec -i inventree-db pg_restore -U <user> -d <db> --no-owner
< inventree-db-<stamp>.dump, untar inventree-media-*.tar.gz into inventree/data/media,
and drop the .env back into place. 5) Do the same pg_restore pattern for listmonk-db and
immich-postgres, and cp the vaultwarden .sqlite3 file back into
torrentvpn/vaultwarden/data/.
- Single point of failure: the age private key. It lives at ~/.config/erp-backup/age.key
and must also be in the Vaultwarden vault (per ~/.config/credentials/erp-backup.txt) — lose
both and every backup, local and off-site, is permanently unreadable.
- Hits: if the age key rotates, re-encrypt is not needed (old backups still decrypt with the
old key — keep it) but new backups need the new public key baked into this script's key path.
If CLOUDFLARE_API_TOKEN rotates or loses R2 edit scope, erp-backup backup fails loudly via
ntfy. If any container's postgres credentials rotate, nothing needs updating here — the script
reads them live via docker exec printenv.
Public access (2026-09-11)¶
https://erp.embeddedera.com → Cloudflare Access (Zero Trust free plan; app "EmbeddedEra ERP",
policy allows adiaz@embeddedera.com + angelgdo7@gmail.com; login method = Google (OAuth client "Cloudflare Access" in GCP project embeddedera, consent screen Internal → only @embeddedera.com accounts; add One-time PIN in Zero Trust → Settings → Authentication if a gmail account ever needs in). Team name renamed to embeddedera on 2026-09-11 (auth domain embeddedera.cloudflareaccess.com; Google OAuth redirect URI updated to match)) → Cloudflare
Tunnel plex (id in ~/.config/cloudflared/env) → cloudflared running as the user unit
cloudflared-plex.service (linger on) → http://127.0.0.1:8100 (inventree-proxy). No router
port is open. InvenTree's .env lists the hostname in INVENTREE_SITE_URL, INVENTREE_TRUSTED_ORIGINS
and INVENTREE_ALLOWED_HOSTS. LAN path http://erp.lan (NPM) still works. Add a helper: create
the InvenTree user + group, then add their email to the Access policy (dashboard or API).
Single sign-on (2026-09-11)¶
One login only. InvenTree trusts the identity Cloudflare Access already verified:
INVENTREE_REMOTE_LOGIN=True + INVENTREE_REMOTE_LOGIN_HEADER=HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL
in inventree/.env (Django RemoteUserBackend, auto-creates unknown users with no permissions).
The InvenTree username must equal the Google email — the admin was renamed angel →
adiaz@embeddedera.com for this. New helper = InvenTree user named by their email + group, then
their email in the Access policy. Both .env files back up as .env.bak.*.
Security invariant: the header must only ever come from Cloudflare. inventree-proxy is bound
to 127.0.0.1 so the LAN can reach it only through NPM, and the NPM proxy host erp.lan (id 26)
carries a custom / location whose advanced config sets Cf-Access-Authenticated-User-Email ""
and Cf-Access-Jwt-Assertion "" (nginx drops empty headers). It has to be in the location
block, not server-level: nginx does not inherit proxy_set_header once a location sets its own.
Verified: forged header on erp.lan → 401; header on loopback → dashboard as the superuser
(headless Chromium; http://127.0.0.1:8100 was added to INVENTREE_TRUSTED_ORIGINS for that).
Password login still exists on erp.lan as the fallback if Cloudflare is down.
Phones: INVENTREE_FRONTEND_SETTINGS={"mobile_mode":"allow-always"} in .env (2026-09-11) disables the "Mobile viewport detected" block (UI checks width/height < 425 unless that flag is set).
Gate page branding is set through the API (PUT /accounts/{id}/access/organizations, needs token
permission Access: Organizations, Identity Providers, and Groups · Edit): name "EmbeddedEra",
logo https://embeddedera.com/icon-512.png (the white wordmark vanishes on the white card; the dark square badge works), header_text empty (it renders as a redundant grey box), bg #0b0d10, text #e6e9ee. Changes take a minute or two to show.
The app has allowed_idps = [Google] and auto_redirect_to_identity: false — Angel tried the
instant redirect to Google on 2026-09-11 and found it disorienting; the branded page with one
"Sign in with Google" button is the pattern to keep (decision, do not re-argue). The "Cloudflare" login method
was deleted by hand (dashboard). API tokens get 10405 on PATCH for Access apps: GET, edit,
PUT the whole object (policies as a list of ids). Re-run that call if
the logo path or brand colors change.
Connected to¶
- owned-by: torrentvpn-stack
- joins: npm (proxy host, no cert — LAN only), pihole (
erp.lanresolves via the*.lanwildcard already in place)
If you change this¶
- Hits: NPM proxy host if the container name/port changes ·
docker-backup.shsnapshot size (grows with attachments/media) ·~/.config/inventree/envand~/.config/credentials/inventree.txtif the API token or admin password rotate · the mrp plan's phase 2+ (KiCad BOM import, tester integration, quote/order automation) all assume this API and these settings stay put. - Restart:
docker compose up -d inventree-db inventree-cache inventree-server inventree-worker inventree-proxy(named — never bareup -d). Bringdb+cacheup and healthy beforeserver/worker/proxyon a cold start. - Upgrade: bump
INVENTREE_TAGin./inventree/.env,docker compose pull inventree-server inventree-worker, then re-rundocker compose run --rm inventree-server invoke updatebefore restarting server+worker. - Restore: stop the five containers, copy
inventree/db/,inventree/data/,inventree/.envback from/mnt/storage/docker-backups/snapshots/<date>/inventree/, start db+cache first, then the rest.
See¶
- Source:
/home/angel/torrentvpn/docker-compose.yml(inventree-* services, end of file) ·/home/angel/torrentvpn/inventree/.env·/home/angel/torrentvpn/inventree/Caddyfile· upstream recipe:github.com/inventree/InvenTree/tree/stable/contrib/container· plan: mrp · tool choice: mrp-tool
Plugins (2026-09-11)¶
Plugin dir inventree/data/plugins/ (root-owned volume). Source of truth + install steps: ~/torrentvpn/inventree/plugins-src/README.md. Installed: soplink (dashboard tile → sop.embeddedera.com; no nav item, SPA-only routing). UI plugins need global ENABLE_PLUGINS_INTERFACE=True; static files collected with manage.py collectplugins after every copy.
KiCad HTTP library (2026-09-12)¶
inventree-kicad-plugin==2.0.4 pinned in data/plugins.txt, installed with invoke plugins.
KiCad 8 sees the ERP as a symbol library: pick a real part number, get its symbol, footprint,
datasheet, manufacturer and live stock. Direction is ERP → KiCad only; KiCad cannot push new parts
back, so the ERP is where a part is born (see mrp).
Three global settings had to be on, and none is on by default: ENABLE_PLUGINS_INTERFACE,
ENABLE_PLUGINS_URL (without it every /plugin/... URL 302s to /web with no error anywhere) and
ENABLE_PLUGINS_APP (without it the plugin's own tables are missing and its API returns
ProgrammingError). Plugin URLs only register at startup, so activate then restart.
Parameter templates Symbol (1), Footprint (2), Reference (3), Value (4) hold the per-part
KiCad fields; the plugin settings point at those IDs. Categories exposed: Components and its four
children, Mechanical, PCBs — each with a default symbol and reference designator.
Client config: ~/torrentvpn/inventree/kicad/EmbeddedEra-ERP.kicad_httplib (0600, holds the token).
Reader is a dedicated user kicad-library in the Read-only group, token in
~/.config/credentials/inventree.txt. Root URL is http://erp.lan/... — LAN only: KiCad
authenticates with a bearer token, which Cloudflare Access rejects, so the public hostname 302s.
Remote use would need an Access bypass policy scoped to that path, leaving the InvenTree token as
the only guard. Not done; ask Angel first.
Sandbox (2026-09-12)¶
~/.local/bin/erp-sandbox up|reset|down|status|token — a full second InvenTree (compose project
inventree-sandbox, containers sandbox-inventree-*, own volumes, proxy on 127.0.0.1:8300 and, on the LAN, http://sandbox.lan via NPM (proxy host 27; the
sandbox proxy container is also attached to torrentvpn_default so NPM can reach it; Pi-hole
resolves any *.lan to the box) — never through the tunnel) restored from a backup set via erp-backup restore. reset (and up --fresh)
takes a new backup first, so the copy is minutes old — and that backup is a real off-site set.
Instance name is set to "SANDBOX — data resets", password login (no header SSO), admin password printed
at the end of up. Isolation proven: a part created in the sandbox does not appear in production, and
production containers were never restarted. Five more containers: check free -g, down when done.
Used by the sandbox assistant (assistant-sandbox.service, 8201) and the daily red-team canaries.
Bug found while building it: erp-backup's R2 listing never followed the pagination cursor
(the API returns 20 keys per page), so "latest set" went stale once the bucket held more than a few
nights. Fixed in erp-backup (follows result_info.cursor). The Sunday verify cron had likely been
checking an old set — see the note in mrp.
Superuser angel removed (2026-09-12). It was recreated on every container start from INVENTREE_ADMIN_USER in inventree/.env; those three lines are now commented out and the user deleted. The only superuser is adiaz@embeddedera.com. If the ERP ever has to be rebuilt from scratch, uncomment them once for the first start, then comment again.