Skip to content

inventree — MRP/inventory system for EmbeddedEra (parts, stock, BOMs, builds, tests)

Finished goods / Paul Shop was added 2026-09-21 for finished assemblies physically held at Paul Shop. Initial stock: Cow Finger 41, Cow Remote 32, Robot Cow 21. Robot Cow outputs still in active test remain in In test until the tester completes them; do not use a manual location move to bypass final-stage consumption or test history.

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 in db/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/data in 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-proxy publishes 127.0.0.1:8100:80 (loopback only). The Caddyfile's site block is :80 (any Host header) rather than gated to INVENTREE_SITE_URL, because NPM already does host-based routing for erp.lan — this keeps curl 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 via env_file:, never inlined into docker-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.lan via NPM (http://erp.lan → container inventree-proxy:80, no TLS, block_exploits+websocket on, same pattern as list.lan/vaultwarden) — LAN-only for now. The public erp.embeddedera.com subdomain (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 -d of db+cache: docker compose run --rm inventree-server invoke update (migrations, static collection, plugin install) — must be re-run after an INVENTREE_TAG version bump, per upstream docs.
  • Settings changed from default via /api/settings/global/: STOCK_ENABLE_EXPIRY, TEST_STATION_DATA, PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS, SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS, REPORT_ENABLE — all set True. SERIAL_NUMBER_GLOBALLY_UNIQUE was also True until 2026-09-19, then set back to False (the default): the test station counts MMYY#### serials per product, so the same serial can exist on two different products (SOP-006 step 15). PART_TRACKABLE was also True from setup until 2026-09-19, which made 177 components, PCBs and kits trackable against SOP-002; set back to False and those parts untracked (only EE-A-* assemblies are trackable). Receipt: job tmp trackable_receipt.json. PART_ENABLE_REVISION and LABEL_ENABLE were already True by default. Built-in plugins inventreelabel/inventreelabelmachine were 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 via inventreelabelmachine'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. The vault copy alone is circular: found 2026-09-24, Vaultwarden's own backup is encrypted with this key, and the rescue USB (/dev/sde1, same box) holds the age binary and script, not the key. Fixed the same day: the key was printed with ~/.local/bin/key-escrow-sheet (QR + text, one key per sheet, PDF shredded) and placed in a safe. Since 2026-10-12 erp-backup also tars ~/.config, ~/.ssh and the compose .env files into the nightly set, so the paper key opens every server secret (restic password included). Open cold-start checks (Cloudflare login without the vault; safe in a different building) are in projects/plex-server.md Open. - 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.lan resolves via the *.lan wildcard already in place)

If you change this

  • Hits: NPM proxy host if the container name/port changes · docker-backup.sh snapshot size (grows with attachments/media) · ~/.config/inventree/env and ~/.config/credentials/inventree.txt if 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 bare up -d). Bring db+cache up and healthy before server/worker/proxy on a cold start.
  • Upgrade: bump INVENTREE_TAG in ./inventree/.env, docker compose pull inventree-server inventree-worker, then re-run docker compose run --rm inventree-server invoke update before restarting server+worker.
  • Restore: stop the five containers, copy inventree/db/, inventree/data/, inventree/.env back 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.

Phone layout — investigated and dropped (2026-09-20)

InvenTree's Platform UI is not built for phones and upstream says so. Measured on the sandbox at 375px: the stock table's visible window was 124px of a 1691px table at 9px type, because a 169px vertical tab rail sat beside it; parts was the same shape. Unusable on the floor.

There is no custom-CSS setting. customize.navbar_message is rendered as raw HTML inside the Layout component (__html: in static/web/assets/Layout-*.js), and Layout wraps every page, so a <style> block there reaches every screen. It is configuration, so an upgrade leaves it alone — unlike editing the built assets under data/static/web/, which INVENTREE_AUTO_UPDATE=True would silently undo. The plugin UserInterfaceMixin is not an option here: its feature types are all scoped (panel, dashboard, navigation, spotlight, template), none injects global CSS.

After, at 375px: 309px of visible table, 14px cells, the rail as a horizontal scrolling strip, bulk-select column hidden, no page overflow. At 1440px the media query does not match and nothing changes — verified, including that the select column is still there.

Source of truth is erp-config branch sandbox, customize/ee-mobile.css + apply.py (commit 205b381). Delivery is one line in the stack's .env: customize/apply.py --env-file ~/torrentvpn/inventree/.env then cd ~/torrentvpn && docker compose up -d inventree-server. A plain docker restart will not pick up a changed env_file — the container must be recreated. Revert = drop the line (or restore the .bak) and recreate. The .env is angel-owned 0600, so no root is needed; data/config.yaml would also work but is root-owned.

Caveat: this consumes navbar_message, so a real navbar message would have to live in the same string.

Dropped 2026-09-20 at Angel's call — the idea was cancelled, not the findings. The sandbox was put back to stock (config.yaml restored, container restarted, navbar_message back to null) and the tile grants were taken out of departments.yml. The stylesheet survives unmerged on erp-config branch sandbox (205b381) if this is ever picked up again. Keep the measurements above: they are what make it unnecessary to re-derive whether InvenTree's UI can be made phone-usable, and how.

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; routine cleanup uses scoped Compose stop to preserve volumes (erp-sandbox down deletes them). Used by the sandbox assistant (assistant-sandbox.service, 8201) and development-store integration tests. As of 2026-09-22, all five containers are stopped by owner request and only started during testing; dependent development-store ERP timers are disabled while idle. See sandbox-workflow for start/stop and cleanup. The installed daily red-team runner actually uses port 8202 and the main assistant environment; its isolation requires a separate audit.

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.