Skip to content

Separating EmbeddedEra from the media server

Started 2026-09-24, prompted by finding the ERP database and Vaultwarden sharing one flat Docker network with 44 containers including flaresolverr and rar-extractor. Angel: "I don't want my embedded era with my media." Companion to store-erp-sync; the stack itself is documented in the ICM map at map/objects/containers/torrentvpn-stack.md.

The recommendation

Three stages. Do not buy a computer yet — do the free one first, because it fixes the problem that actually exists.

  1. Separate networks and compose projects. Free, reversible, fixes the security problem.
  2. More RAM. Already planned; fixes the resource problem for both tenants.
  3. A second machine. Decide later, with evidence, and for the right reason.

What is actually wrong today

Two different problems get conflated under "I don't want them together". They have different fixes and only one of them is urgent.

The security problem (real, urgent, free to fix). torrentvpn_default is one flat network carrying 44 containers. On it, together: inventree-db, inventree-server, vaultwarden — and flaresolverr, rar-extractor, prowlarr, radarr, sonarr, jellyfin. On a flat Docker network every container can reach every other container's ports directly. So the blast radius of compromising any one container that processes untrusted internet content is the ERP database and the password vault. Nothing is known to be exploited; the exposure is structural.

For contrast, the storefront already does this correctly — its network is explicitly internal: true, with a separate egress network for Stripe and a publish network carrying only the ingress port. That is the standard to copy, and it is already in this house.

The resource problem (real, not urgent, costs money). 16 GB total, about 4 GB available, load average around 3 on 4 cores.

Measured 2026-09-24:

EmbeddedEra RAM Media / photos RAM
postiz + temporal + es + redis ~1.9 GB immich (4 containers) ~0.9 GB
inventree worker + server ~1.7 GB radarr / sonarr / prowlarr ~0.5 GB
metabase ~1.0 GB qbittorrent ~0.2 GB
total ~4.6 GB plex + jellyfin ~0.3 GB
total ~1.9 GB

The business stack is the heavy tenant, using more than twice what the media stack uses. This matters because the instinct behind separating is usually "the media is hogging the machine", and here the reverse is true. It is also why the Android Story emulator could not get its 4800 MB earlier in the month.

Why not one container

Recorded because the question was asked and the answer is easy to get wrong: a container is a process, not a security boundary or an organisational unit. Running the ERP, the store and the site in one container means one crash takes all three down, no independent restart or upgrade, and three applications sharing a filesystem and port space that none of them expect to share. "Can they talk to each other" is a network question and is solved by a network, at no cost in coupling. The homepage is not a container at all — it is static, on Cloudflare Pages.

Stage 1 — separate networks and compose projects

The goal is that a compromise of anything in the media stack cannot reach the ERP or the vault.

Three groups, not two. This is the part that needs deciding rather than executing:

  • EmbeddedEra: inventree (db, cache, server, worker, proxy), postiz (+ its five), metabase, vaultwarden, kanboard (already on its own torrentvpn_kanboard-net).
  • Media and home: plex, jellyfin, the *arr stack, gluetun/qbittorrent, immich, tautulli, tdarr, bazarr, overseerr, unpackerr, rar-extractor, flaresolverr.
  • Shared infrastructure, serving both: pihole, npm, portainer, uptime-kuma, grafana, dozzle, duckdns, wg-easy, mosquitto, scrutiny, homer, filebrowser.

That third group is the real design work. Reverse proxy and DNS must reach both sides by definition, so they cannot simply live on one. The sane shape is: each tenant gets a private network, shared infrastructure gets a network of its own, and NPM joins each tenant network explicitly rather than everything sharing one.

Open question to settle before executing: is Vaultwarden business or personal? It is served at vault.embeddedera.com but almost certainly holds both. It is the single highest-value target on the box, and it should end up isolated from both tenants rather than sitting inside whichever one wins the argument.

Landmines, from the map card

  • Never a bare docker compose up -d. The compose samba service is a ghost: the live samba-nas was created with docker run and binds a different path. A blanket up -d would recreate it wrong.
  • gluetun and qbittorrent must be recreated together or qbittorrent loses its network namespace.
  • A hostname change needs docker exec pihole reloaddns — Pi-hole caches the old NXDOMAIN and LAN clients get "no such host" while public DNS is fine.
  • docker-backup.sh, the rescue archive, homer's links and the NPM entries all assume the current single-directory shape. Splitting the compose file means revisiting each.

Stage 1 execution — prepared 2026-09-24, one command outstanding

Scope was deliberately narrowed from "reorganise 44 containers" to "cut the path that matters". The ERP group and Vaultwarden move off the flat network; everything else stays where it is. That delivers the whole security win with a fraction of the risk, and it settles the open Vaultwarden question, because the answer is the same either way: it is isolated from both tenants.

Two plain bridge networks, ee-erp and ee-vault. Isolation is by membership, never internal: true — the kanboard-net comment in the compose file records that internal: true disables port publishing and took the board down on 2026-09-14, and inventree-proxy publishes 127.0.0.1:8100.

Network Members
ee-erp inventree-db, -cache, -server, -worker, -proxy, npm, and metabase from its own project
ee-vault vaultwarden, npm

npm keeps default as well, because it proxies the media stack too. It is the one deliberate bridge; a reverse proxy reaching everything it proxies is inherent to what it is.

Done:

  • ~/torrentvpn/docker-compose.yml — networks declared and attached to the seven services. docker compose config validates and renders correctly; media services remain on default. Backup: docker-compose.yml.bak.pre-network-split-20260924-133814.
  • ~/.config/metabase/env — ERP_DOCKER_NETWORK changed from torrentvpn_default to ee-erp. Metabase already had the right shape: it joins the ERP network as an external network and only its app container does. Backup: env.bak.pre-network-split, both 0600.
  • ~/.local/bin/ee-network-check — asserts the isolation holds, by comparing the untrusted containers against where the sensitive ones actually are rather than against expected network names, so it cannot pass trivially. Run it before and after.

Outstanding — blocked on approval, this is the whole change:

cd ~/torrentvpn && docker compose up -d \
  inventree-db inventree-cache inventree-server inventree-worker inventree-proxy \
  vaultwarden npm
cd ~/erp-config/reporting/metabase \
  && docker compose --env-file ~/.config/metabase/env up -d
ee-network-check

--env-file is not optional for metabase. Its compose reads ERP_DOCKER_NETWORK, MB_DB_USER, MB_DB_PASS and more from ~/.config/metabase/env, not from a .env in the project directory. Without it every variable resolves to a blank string and the recreate would produce a metabase with no database credentials and a blank network name. A first version of this procedure omitted it; caught on 2026-09-24 before it ran.

Services are named explicitly. Never a bare docker compose up -d here — the compose samba service is a ghost and would be recreated wrong, and gluetun/qbittorrent must be recreated together. Neither is touched by the command above.

Expect a few seconds of downtime on the ERP, the vault and all *.lan proxying while those containers are recreated. Recreating the worker also clears the stale c5130928fe77_ name prefix it picked up from an old name collision.

Verify after: ee-network-check exits 0 · erp.lan and vault.embeddedera.com load · curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8100/ returns 302 · metabase still queries the ERP.

Rollback: restore docker-compose.yml.bak.pre-network-split-20260924-133814 and ~/.config/metabase/env.bak.pre-network-split, then re-run the same two up -d commands.

Known follow-up: the stopped sandbox ERP (sandbox-inventree-*, NPM route sandbox.lan) is a separate compose project and is unaffected while stopped. When it next starts, decide whether it joins ee-erp or gets its own network — it should not land back on torrentvpn_default.

Stage 2 — RAM

Already Angel's plan. 16 GB with ~4 GB free is the binding constraint on everything else, including the Story emulator. This is the cheapest real improvement available and it helps both tenants at once. Do it before evaluating a second machine, because it changes the evidence.

Stage 3 — a second machine

Worth doing eventually, but for maintenance independence, not for security — stage 1 already buys the security, for free.

The honest argument for separate hardware is: you should not have to think about the ERP when you reboot the box to fix Plex, and business data should not share a failure domain with a hobby. That is a real argument, and it gets stronger as the business does.

The honest argument against doing it now: a second machine does not inherit any of this box's plumbing. It needs its own backup story, its own monitoring, its own UPS, its own rescue kit and its own place in the SnapRAID/rsnapshot design — all of which currently assume one host. That is a meaningful amount of work to take on while the store is still pre-launch.

Decide after stage 1 and stage 2, on evidence: if the business stack is still contending for CPU or RAM after the memory upgrade, buy the machine.

What this does not change

The store ↔ ERP sync design in store-erp-sync is unaffected. It deliberately runs on the host and never needed the two containers to reach each other, so none of this is a prerequisite for that work.

Log

  • 2026-09-24 — Problem identified and staged. Measured the split: the business stack is the heavy tenant at ~4.6 GB against ~1.9 GB for media, which reverses the usual assumption.

Stage 3 re-measured, 2026-09-25 — the answer is no, not yet

Stage 1 is done (ERP and vault on their own networks). Stage 2 has not happened — the box still reports 15,852 MB, so the RAM was never added. Measured anyway, to answer the migration question on evidence rather than impression:

Signal Reading Reading it
Memory stalls (PSI) 0.00 at 10s/60s/300s; 535 s total over 6 days 0.1% of uptime. Effectively no memory pressure at all
Swap 4,095 of 4,095 MB used Full, but with swappiness=10 these are cold pages pushed out during some past spike and never needed since. Largest holders: node 1,000 MB + 337 MB (postiz), immich 225 MB, java 215 MB
CPU load 1.84 / 1.87 / 2.04 on 4 cores ~47% utilised
CPU stalls (PSI) avg300 = 7.85% Inflated at sample time by a transient kicad-check ERC run on the Cricket project at 41% CPU — not steady state
Disk root 66%, /mnt/storage 44% Comfortable

This machine is not at its limit. Memory pressure is essentially zero, CPU sits under half, disk is fine. The one genuine concern is that swap has no headroom left — the next spike has nowhere to go and would OOM rather than swap.

Recommendation: add the RAM, do not buy a machine. 16 → 32 GB restores headroom, lets the 4 GB of cold pages come back, and costs a fraction of a second host. Re-measure after.

The argument for a second machine was never resource exhaustion — it was maintenance independence: not having to think about the ERP when rebooting for Plex, and not sharing a failure domain between the business and a hobby. That argument is unchanged and still valid, but it is a business-continuity decision, not a performance one, and the numbers do not force it.

Steady-state cost worth knowing: the postiz stack (postiz + temporal + temporal-postgres) is the largest continuous consumer at roughly 18% of one core and ~1.9 GB, for a marketing tool. Worth asking whether it earns that before buying hardware to carry it.