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.
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).
| State | Container memory |
|---|---|
| Fresh start, empty database | 140 MB |
| Fresh start, 1,000 devices | 140–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 render | 295–320 MB |
| Peak during two template renders at once | 345–355 MB |
| Peak during a burst of 20 render requests | 350–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-capacityandbuffer-max-bytesin 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.
| Limit | Fresh start | Long-running |
|---|---|---|
| 512 MB | Everything works | Everything works. Bursts of 20 renders peaked at 362 MB |
| 256 MB | Template renders fail with an error | Template 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 far | Process memory |
|---|---|
| Python interpreter alone | 9 MB |
| + SQLAlchemy, SQLModel, Pydantic | 55 MB |
| + FastAPI, Uvicorn | 72 MB |
| + openpyxl, XlsxWriter, Jinja2 | 81 MB |
| + MiniPAM models and template engine | 96 MB |
| + the full application (1,379 modules) | 144 MB |
Disk
| Item | Size |
|---|---|
| Image download (compressed, arm64) | 92 MB |
| Image on disk (unpacked, arm64) | 316 MB |
| – Debian slim base and Python 3.14 | 141 MB |
– Python packages (/opt/venv) | 157 MB |
| – server code and web interface | 12 MB |
| Container writable layer | about 1 MB |
| Database, 250 devices | 1.6 MB |
| Database, 1,000 devices | 2.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) | Before | After |
|---|---|---|
| Render a one-line template | 915 ms | 56–68 ms |
| Render 400 devices, one address lookup each | 1,100 ms | 165–175 ms |
| First render after start (starts the helper) | 915 ms | 670–800 ms |
| Memory added while one render runs | 75–90 MB | 15–30 MB |
| Peak with two renders at once | 370 MB | 315–340 MB |
| Burst of 10 render requests | 2 served, 8 refused | 10 served in 0.8 s |
| Burst of 20 render requests | not measured | 20 served in 1.7 s |
| Memory at rest, long-running | 205–215 MB | 255–285 MB |
| Render on a quarter of one core | fails (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 record | Batched |
|---|---|---|
| 400 devices, first address of each: queries | 401 | 4 |
| 400 devices, first address of each: render time | 190–215 ms | 140 ms |
| Every device and its addresses (2,000 lines): queries | fails: over the limit | 5 |
| Every device and its addresses: render time | fails | 240–270 ms |
| Burst of 10 / 20 render requests | 0.8 s / 1.7 s (round 2) | 0.7 s / 1.4 s |
| Memory added while one render runs | 30–35 MB | 30–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:
| Request | Time |
|---|---|
| Start the server (to healthy) | 1.4–3 s |
Load the web interface (index.html) | 3 ms |
| List 100 devices | 10–15 ms |
| List 500 devices / 500 addresses | 30–120 ms |
| Search | 40 ms |
| Overview | 10 ms |
| Export the whole inventory as JSON / XLSX | 80 ms / 180 ms |
| Render a template (small / 400 devices) | 60 ms / 140 ms |
| Render every device with its addresses | 240–270 ms |
| First template render after start | 0.7–0.8 s |
| Log in | 210 ms |
| Health check throughput | 830 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 core | Start-up | Log in | First render | Later renders | Full inventory | First round |
|---|---|---|---|---|---|---|
| 1.0 | 1.4 s | 0.2 s | 0.8 s | 56 ms | 0.23 s | 0.9–1.0 s |
| 0.5 | 2.8 s | 0.4 s | 2.5 s | 115 ms | 0.6 s | 1.7–2.8 s |
| 0.25 | 10 s | 1.1 s | 8.6 s | 0.3 s | 1.7 s | fails |
| 0.15 | 26 s | 3.9 s | 19 s | 1.1 s | fails (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.
- Built the image and recorded the compressed size (
docker save | gzip), the layer sizes (docker history), and the unpacked size per directory (duinside the container). - Created the first admin with
cli.py auth init-rooton a fresh volume, as the development Docker Compose setup does, and started the server. - 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.
- Read memory from the container's cgroup (
memory.current,memory.peak, andmemory.stat) and from each process's resident set, at each stage: empty, seeded, after a read workload, and with a full log buffer. - 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.
- Filled the in-memory log to its 20,000-record limit with health-check requests.
- 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.
- Profiled write requests with
py-spyto find where their CPU time goes. - Restarted the server with
--cpusset to 1.0, 0.5, 0.25, and 0.15 and timed start-up, login, a list, and template renders. - Measured how much memory each group of libraries adds by importing them one after another in a fresh Python process inside the image.
- 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. - 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.