Skip to content

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 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, SERIAL_NUMBER_GLOBALLY_UNIQUE, PART_TRACKABLE, TEST_STATION_DATA, PREVENT_BUILD_COMPLETION_HAVING_INCOMPLETED_TESTS, SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS, REPORT_ENABLE — all set True. 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. - 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 angeladiaz@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.

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.