Skip to main content

Resource usage and benchmarks

This page holds the measurements behind the system requirements: how much memory, disk, and CPU the server uses, how it behaves under memory and CPU limits, and how the numbers were taken. Use it to judge a device that is not listed there, or to tune a server that is short on resources.

Measured on 5 October 2026

All measurements were taken on 5 October 2026, in three rounds with the same method: first with commit db8baef (pre-alpha); then after template rendering was reworked (commit eca0012 plus that change); and then after lookups inside template loops were batched (commit cf7a537). This page shows the latest round, and Template rendering compares them. The numbers are a snapshot. Memory and timings change as MiniPAM changes, so re-check them before relying on them for a release. How the numbers were measured describes the setup and its limits.

Disclaimer: I had these benchmark, research, and this page of the documentation entirely done by AI (Claude-Code Opus 5.5).

Memory​

What the server uses​

Memory of the server container, measured with 1,000 devices in the inventory (each with an interface, an address, and a link, plus 250 applications).

StateContainer memory
Fresh start, empty database140 MB
Fresh start, 1,000 devices140–155 MB
After the first template render (render helper ready)235–240 MB
Long-running, in-memory log full (20,000 records)260–290 MB
Peak during one template render295–320 MB
Peak during two template renders at once345–355 MB
Peak during a burst of 20 render requests350–360 MB

Almost all of it is private process memory. The page cache for the database stays under 2 MB at this size, because SQLite reads little and the whole database is 2.5 MB.

Two things drive these numbers:

  • Template renders. Each render runs in a separate, short-lived process so a runaway template can be stopped. These processes are forked from a small render helper that loads the template engine once, on the first render, and then stays in memory: about 80 MB, which is the jump after the first render. Each render then adds only 15–30 MB while it runs. By default two renders run at a time; further requests wait up to two seconds for a free slot and are then refused with "renderer is busy". The limit is the Concurrent template renders setting under Administration → System → Settings (Templates), so the worst case stays a few renders' worth on top of the steady state, however many requests arrive.
  • The in-memory log. The server keeps the last 20,000 log records (at most 64 MiB) for Administration → System → Logs. Measured full, it added only about 6 MB, because typical records are small. You can lower buffer-capacity and buffer-max-bytes in the configuration file if memory is tight.

Running with a memory limit​

The server was restarted on the same data with a hard memory limit and no swap (--memory and --memory-swap set to the same value), then aged with writes, a full log buffer, and repeated reads, exports, and template renders.

LimitFresh startLong-running
512 MBEverything worksEverything works. Bursts of 20 renders peaked at 362 MB
256 MBTemplate renders fail with an errorTemplate renders fail, then a burst kills the server

With 256 MB there is no room for the render helper. Render processes ask the kernel to kill them first when memory runs out, so a render fails with an error and the server keeps running. Under a burst of renders the memory still runs out, and the kernel then kills the whole container.

Where the memory goes​

The 140 MB baseline is mostly the cost of loading Python libraries, before any request is served:

Loaded so farProcess memory
Python interpreter alone9 MB
+ SQLAlchemy, SQLModel, Pydantic55 MB
+ FastAPI, Uvicorn72 MB
+ openpyxl, XlsxWriter, Jinja281 MB
+ MiniPAM models and template engine96 MB
+ the full application (1,379 modules)144 MB

Disk​

ItemSize
Image download (compressed, arm64)92 MB
Image on disk (unpacked, arm64)316 MB
– Debian slim base and Python 3.14141 MB
– Python packages (/opt/venv)157 MB
– server code and web interface12 MB
Container writable layerabout 1 MB
Database, 250 devices1.6 MB
Database, 1,000 devices2.5 MB

Data grows by about 1.1 KB per fully linked device (device, interface, address, and link, including indexes) and about 0.5 KB per job record. The db usage command shows the current numbers for your server. In Docker, replace minipam with your container's name:

docker exec minipam python cli.py db usage --config config.yml

Plan for more than the inventory alone:

  • Job history and scan results grow with agent activity. This session ran no agents, so it does not measure them. Keep the retention settings under Administration → System → Settings at their defaults or lower.
  • Upgrades copy the database before a migration, so leave room for at least one extra copy.
  • Logs go to the console by default. Docker keeps them unless you configure log rotation, for example --log-opt max-size=10m --log-opt max-file=3.

Template rendering​

Template rendering was reworked after the first measurements. Before, every render started a new Python process that loaded the whole template engine again. Now every render is forked from the render helper, which has the engine loaded already. The same server and data, measured on 5 October 2026 before and after:

Measurement (1,000 devices)BeforeAfter
Render a one-line template915 ms56–68 ms
Render 400 devices, one address lookup each1,100 ms165–175 ms
First render after start (starts the helper)915 ms670–800 ms
Memory added while one render runs75–90 MB15–30 MB
Peak with two renders at once370 MB315–340 MB
Burst of 10 render requests2 served, 8 refused10 served in 0.8 s
Burst of 20 render requestsnot measured20 served in 1.7 s
Memory at rest, long-running205–215 MB255–285 MB
Render on a quarter of one corefails (timeout)0.4 s

The trade-off is memory at rest: the render helper stays loaded after the first render, about 50–70 MB more than before. In return, renders are 6–15 times faster, peaks are lower, bursts are served instead of refused, and renders work on slow CPUs.

Lookups inside loops​

The third round measured the batching of lookups inside loops. A loop such as {% for device in devices %}{% for address in data.ip_addresses.of(device) %} used to run one query per device. Now the lookups of a whole loop are loaded together (see How queries are counted). Same server and data, with the lookups forced onto the old one-per-record path for comparison:

Measurement (1,000 devices)One query per recordBatched
400 devices, first address of each: queries4014
400 devices, first address of each: render time190–215 ms140 ms
Every device and its addresses (2,000 lines): queriesfails: over the limit5
Every device and its addresses: render timefails240–270 ms
Burst of 10 / 20 render requests0.8 s / 1.7 s (round 2)0.7 s / 1.4 s
Memory added while one render runs30–35 MB30–35 MB

Each SQLite query is cheap here (about 0.15 ms), so the time saved is moderate. The larger gain is that a template over a big inventory no longer runs out of queries, and that it stays well inside the limit on slow devices, where each query costs several times more.

The helper is a separate, single-threaded process. Render processes are not forked from the server itself, because SQLite is not safe to use in a process forked while other threads are using it. That was measured too: forking from the server was even faster (about 30 ms per render), but under a burst of 10 requests some render processes hung on an inherited SQLite lock until the time limit stopped them.

CPU​

CPU depends on the device in ways memory does not, so this section gives measurements on a fast core and estimates for slower ones.

Measured on one fast core​

On one Apple M4 Pro core, with 1,000 devices:

RequestTime
Start the server (to healthy)1.4–3 s
Load the web interface (index.html)3 ms
List 100 devices10–15 ms
List 500 devices / 500 addresses30–120 ms
Search40 ms
Overview10 ms
Export the whole inventory as JSON / XLSX80 ms / 180 ms
Render a template (small / 400 devices)60 ms / 140 ms
Render every device with its addresses240–270 ms
First template render after start0.7–0.8 s
Log in210 ms
Health check throughput830 per second

Estimated for slower devices​

To approximate slower devices, the same server was given a fraction of one core (docker run --cpus). This throttles a fast core rather than simulating a slow one, so treat it as a rough guide:

Share of one M4 Pro coreStart-upLog inFirst renderLater rendersFull inventoryFirst round
1.01.4 s0.2 s0.8 s56 ms0.23 s0.9–1.0 s
0.52.8 s0.4 s2.5 s115 ms0.6 s1.7–2.8 s
0.2510 s1.1 s8.6 s0.3 s1.7 sfails
0.1526 s3.9 s19 s1.1 sfails (3 s)fails

"Later renders" is a small template. "Full inventory" lists all 1,000 devices with their addresses (2,000 lines). "First round" is the small template before the template-rendering rework.

Template rendering has a 3-second limit. It now counts only the render itself, not starting the render process, and the render helper is started once instead of for every render. A Raspberry Pi 5 core is roughly a third to a quarter of an M4 Pro core, and a Raspberry Pi 4 slower still, so on those devices expect the first render after a restart to take several seconds and later renders well under a second. A template that lists a large inventory is limited by rendering time, not queries: at 1,000 devices it can pass the 3-second limit on the slowest of these settings. A Pi 3 or Pi Zero 2 W is much slower again. These are estimates: no Raspberry Pi was measured.

Idle CPU use is negligible: about 0.2% of one core with no requests.

How the numbers were measured​

When and what. 5 October 2026, built with the repository's deployments/docker/server.Dockerfile. First round: commit db8baef. Second round, after the template-rendering change: commit eca0012 plus that change.

Machine. Docker 29.7.2 in Docker Desktop on an Apple M4 Pro, Linux VM kernel 7.0.12 (arm64), 14 CPUs and 8 GB of memory available to the VM. The server container had no CPU or memory limit, except in the limit and CPU tests described above.

Steps.

  1. Built the image and recorded the compressed size (docker save | gzip), the layer sizes (docker history), and the unpacked size per directory (du inside the container).
  2. Created the first admin with cli.py auth init-root on a fresh volume, as the development Docker Compose setup does, and started the server.
  3. Seeded the inventory through the REST API: one site, 1,000 devices, each with an interface, an IPv4 address, and an address link, plus an application on every fourth device, spread over four /24 networks.
  4. Read memory from the container's cgroup (memory.current, memory.peak, and memory.stat) and from each process's resident set, at each stage: empty, seeded, after a read workload, and with a full log buffer.
  5. Ran the read workload as a logged-in browser session (cookie and CSRF token): list pages, search, overview, two template previews, and JSON and XLSX exports. Memory was sampled every 20 ms during each request to catch short peaks.
  6. Filled the in-memory log to its 20,000-record limit with health-check requests.
  7. Restarted the server on the same data with 512 MB and 256 MB limits, no swap, aged it with more writes and a full log, then repeated the workload and two concurrent renders. Recorded whether the kernel's OOM killer fired.
  8. Profiled write requests with py-spy to find where their CPU time goes.
  9. Restarted the server with --cpus set to 1.0, 0.5, 0.25, and 0.15 and timed start-up, login, a list, and template renders.
  10. Measured how much memory each group of libraries adds by importing them one after another in a fresh Python process inside the image.
  11. After the template-rendering change, repeated steps 2–7 and 9 on a fresh volume, and added bursts of 1, 2, 10, and 20 concurrent render requests, recording each response and the peak container memory. A stuck render process was inspected with py-spy dump, which showed the SQLite lock described in Template rendering.
  12. After batching lookups inside loops (commit cf7a537), repeated steps 2–7, 9, and 11 on a fresh volume. Added a template listing every device with its addresses, and ran each lookup template a second time with its lookups forced onto the one-per-record path (.sort('id').of(...)) for the comparison.

Limits of these measurements.

  • One run on one machine, inside Docker Desktop's Linux VM, not on bare metal or a Raspberry Pi.
  • arm64 only. x86-64 should be similar. 32-bit ARM was not built or tested.
  • No agents were connected, so network scans, discovery, and the data they produce are not included. Expect a busy discovery setup to use more memory and disk than shown here.
  • The inventory is uniform and synthetic. Real inventories with more tags, services, and history will use more disk, but memory at rest depends mainly on the libraries, not the data.