How much RAM do self-hosted apps use? Measured idle RAM and disk for 96 apps
data as of September 28, 2026
Most self-hosted apps publish a minimum RAM figure. Almost none publish what the app actually uses once it is running. We installed each app on a clean server of the same shape, using the install commands on its page, let it settle, and read the memory and disk it was using. This page is that list: 96 apps, each with the machine and date it was measured on.
It answers a narrow question on purpose: what does this app cost you in RAM and disk before anyone uses it? That is the floor under any sizing decision, not the whole decision. Load, users, and stored data all push the real number up. The section on reading these numbers says by how much we can and cannot tell you.
The numbers
Method
Every app was installed on a Google Cloud e2-standard-2 VM (2 vCPU, 8 GB RAM, 60 GB balanced persistent disk) from the stock ubuntu-2604-resolute-amd64 image, with Docker Engine installed from get.docker.com (29.8.1 in the 2026-09-28 sweep). Each VM ran one app at a time, and before each app the harness removes every container, volume, network, image and build cache, leaves swarm mode, and refuses to continue if a container survives. That reset is what makes the disk number mean this app, not this app plus the previous one.
The install commands are the ones on each app's page, run as written by scripts/verify-install.ts. If every step exits 0, the harness waits 30 seconds and then reads:
docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}' # summed across the app's containers
docker system df --format '{{.Type}}\t{{.Size}}' # images + containers + volumes + cacheSo RAM is the sum of every container the install started, databases and workers included, and disk is the total Docker footprint on an otherwise empty box. Each figure is one reading, not an average.
An exit code of 0 is not enough. A docker compose up -d returns 0 even when a container then crash-loops, so at measurement time the harness also lists every container that is restarting or exited with an error. Any app with one gets no number and no verified stamp, whatever its exit codes said. We added that check partway through this sweep, then re-ran every app measured before it (two of them the next day, on a fresh box of the same shape). Three first-pass failures turned out to be our own box: native services left behind by an earlier app on the same VM (a Zabbix nginx and a Dokku nginx) held ports 80 and 443. KeeWeb, NocoDB, Wiki.js all passed on a clean box, and those are the runs shown here.
Idle RAM: Under 64 MB (25)
Idle RAM: 64–256 MB (34)
Idle RAM: 256 MB – 1 GB (26)
Idle RAM: Over 1 GB (11)
Documented minimum vs measured idle
A project's documented minimum and our idle reading answer different questions, and neither one is wrong. The minimum has to cover real use: imports, indexing, background jobs, a first sync. The idle reading covers none of that. Still, the size of the gap is useful. It tells you how much of the documented figure is headroom for work rather than baseline cost.
The widest gaps (documented ÷ measured, calculated):
- Stalwart: documents 1.0 GB, idled at 10 MB (×102)
- Kopia: documents 512 MB, idled at 7 MB (×73)
- Prometheus: documents 2.0 GB, idled at 37 MB (×55)
- Gotify: documents 256 MB, idled at 5 MB (×51)
- Pi-hole: documents 512 MB, idled at 10 MB (×51)
Do not read a wide gap as 'you can ignore the minimum'. Apps that index or transcode on first use (photo libraries, search, media servers) are exactly the ones whose idle number is least representative.
What did not pass, and why
29 of the 111 installs in the 2026-09-28 sweep did not complete unattended, so they have no measurement. We read every failing transcript rather than trusting the verdict. They fall into these groups:
Needs an interactive terminal (11). The documented command asks a question or uses docker run -it; unattended, it stops. Not a bug — a real constraint for any automation or provisioning script.
- AgentArea — bootstrap script exits 2 without a terminal after printing the install path; the next step then finds no ./agentarea
- Beszel — the hub starts; the agent installer asks for the hub's SSH key, which the reader copies from the UI
- Documenso — `openssl req` prompts for certificate fields; adding -subj would make it unattended
- Ente — quickstart.sh ends by asking "Do you want to start Ente? (y/n)"
- Glances — the curses terminal UI needs a terminal by design
- GoatCounter — the container starts; only the optional `docker exec -it` site-creation step needs a terminal
- LocalAI — the documented `docker run -ti` refuses to start without a terminal
- Ollama — the container starts; only the optional `docker exec -it … ollama run` chat step needs a terminal
- ownCloud — the `ocis init` step is `docker run --rm -it` and prints the admin password to the terminal
- Paperless-ngx — the official installer is an interactive questionnaire (URL, port, paths)
- Zabbix — `mysql -p` prompts for the password the reader chose in the previous step
Needs a hand-edited file first (8). The snippet says to write or edit a config file between steps; our runner executes only what is written, so it fails exactly where a reader who skipped that step would.
- Caddy — compose starts, but Caddy restart-loops until you write ./conf/Caddyfile, which the snippet leaves to you
- Chatwoot — `.env` and the compose file must be edited (secrets, URLs) with nano before start
- Flarum — assumes a root shell and a PHP host with Composer already installed, as the snippet's first comment says
- OpenCloud — `.env` must be edited with your domains and admin password, and each domain needs a DNS record
- Outline — compose exits 0, but Outline and its Postgres exit within seconds until you supply real secrets and an auth provider (the snippet's first line says so)
- Psono — settings.yaml has to be written from the printed keys before the migrate step; without it Docker mounts a directory and the container fails
- Seafile — assumes a root shell (`mkdir /opt/seafile`) and a hand-edited `.env`
- Tandoor Recipes — every step exits 0, but PostgreSQL crash-loops until you fill in POSTGRES_PASSWORD and SECRET_KEY in .env, as the snippet says
Needs a real domain or TLS (1). The installer validates the hostname or email (example.com is rejected) or needs ports 80/443 on a public DNS name.
- ERPNext — easy-install.py rejects example.com addresses: "Emails with example.com not acceptable"
Our snippet was wrong (1). A transcription error on our side; fixed in the same change as this report.
- restic — bunzip2 is not in the stock Ubuntu 26.04 cloud image; snippet now installs bzip2 first. Later steps need your own S3 credentials, which the harness doesn't have
Too big for the test box (1). Ran out of memory or time on the 8 GB box.
- Zammad — first-boot migrations on 2 vCPU outlast the railsserver healthcheck, so compose exits "unhealthy" and leaves nginx unstarted; about 4 minutes later a second `docker compose up -d` brings the UI up (HTTP 200). Snippet now says so
Runs in the foreground (6). The documented command starts the server and never returns. The app came up, but an unattended runner waits forever.
- Frappe CRM — `docker compose up` without -d: the stack comes up but the command never returns
- GitBucket — setup steps pass; `java -jar gitbucket.war` starts the server in the foreground (log shows it initializing) and never returns
- Keycloak — the dev-mode quickstart starts ("Listening on: http://0.0.0.0:8080") but runs in the foreground by design
- OpenHands — the server starts ("Application startup complete") but the documented command runs in the foreground and never returns; native install, so no container to measure
- PocketBase — `./pocketbase serve` starts the server ("Server started" at 19:13:03), which then exited 0 about 2.5 minutes later for a reason the log does not show. A foreground server is not an unattended pass, so the 2026-07-25 stamp stands and no new one is written
- Zoraxy — the binary starts ("Dynamic Reverse Proxy service started") but `./zoraxy_linux_amd64` runs in the foreground; the snippet's systemd step comes after it
Cause not established (1). The install failed and we did not pin down why within this run. Listed so the failure isn't hidden; not counted as upstream's fault or ours.
- Swetrix — the run emitted more than ~512 MB of output and crashed our harness (Node string limit); cause not established — likely a prompt loop in configure.sh, unconfirmed
Not attempted (12). Apps we left out of this sweep up front, because the harness cannot run them: they need a GPU, more than 8 GB, a real domain, or a wizard that has to be answered in a browser or terminal. They are not failures:
- Appwrite — interactive TTY installer (docker run -it)
- Bitwarden — interactive installer needs a bitwarden.com installation id/key
- Discourse — interactive ./discourse-setup needs a real domain + SMTP
- FreeScout — interactive installer
- GitLab CE — 16 GB documented minimum > 8 GB box
- Headscale — needs TLS on 443 with a real domain
- Jan — known broken make target (2026-07-25)
- Mailu — config comes from an interactive web wizard
- NetBird — needs a real domain + TLS
- OpenPanel — interactive ./setup prompts
- Pangolin — interactive installer + wildcard DNS
- vLLM — needs NVIDIA GPU
Fixed along the way
Running the commands found problems in our own install snippets that reading them had not. These were fixed on the spot and re-run to a clean pass on a fresh box, so the numbers above come from the fixed versions:
- DokuWiki — the compose runs as uid 1000, but the storage directory belonged to whoever ran mkdir, so the container crash-looped on /storage. The snippet now chowns it first.
- OpenObserve — newer images refuse to start with a weak root password (our placeholder was CHANGE_ME), and without ZO_DATA_DIR=/data the data never reached the mounted volume. Both are fixed, matching upstream's current example.
- LibreChat — upstream's compose added an admin-panel container that restart-loops unless ADMIN_PANEL_SESSION_SECRET is set. The snippet now generates one.
- Firefly III — the compose gave MariaDB no environment at all, so it refused to initialize and exited; the app then waited forever for a database. The db service now gets its database, user and password, and APP_KEY uses upstream's 32-character placeholder.
- AnythingLLM — the image runs as uid 1000, so a storage directory owned by anyone else leaves SQLite unable to open its file. The container exits, and --rm deletes the evidence. The snippet now chowns the directory.
- Nhost — upstream's example compose pins minio/minio, which MinIO removed from Docker Hub in September 2026, so the whole pull failed. The snippet now swaps in Chainguard's free MinIO build before starting the stack.
- Docmost — Docmost validates APP_URL and exits on a placeholder like http://SERVER_IP:3000 (an underscore is not valid in a hostname). The snippet now fills in the box's own address.
Two more got fixes without a clean pass: restic now installs bzip2, which the stock Ubuntu 26.04 cloud image lacks, and Zammad now warns that on a 2 vCPU box its first start outlasts the health check and needs a second docker compose up -d.
Limits of these numbers
- Idle, not loaded. No users, no data, no traffic. A database-backed app with real content will use more, sometimes much more.
- One reading, 30 seconds after install. JVM, Elasticsearch/OpenSearch and ClickHouse stacks can still be growing their heaps at that point, so their figures are more likely to read low than high.
- Readings move between runs. We installed 31 apps twice on clean boxes, 8 to 60 minutes apart. 26 of the 31 second readings landed within 10% of the first. The largest swings: n8n 828 → 396 MB; Plex 210 → 164 MB; Taiga 898 → 722 MB. Where an app ran twice, the page shows the second reading, the one taken with our container-health check in place. Treat any single figure as ±10% at best, and more for apps that do background work at startup.
- Docker only. Apps installed as native packages or binaries (for example Zabbix, restic, Glances) have no container to read, so they have no figure here, even when they passed.
- One machine shape. Some apps size caches and worker counts from the CPU count or total RAM. The same app on a 1 GB VPS may start smaller, or fail to start at all.
- Older rows had less checking. The 18 readings from 2026-08-28 to 2026-09-13 used the same harness on the same machine shape (Docker 29.7.2 or 29.8.0), but before the container-health check existed. They have not been re-run.
- Point in time. Each row carries its date. A new release can change these numbers, and we re-measure on a new sweep rather than editing them by hand.
- The OS is extra. These figures are the app's containers only. On our boxes, Ubuntu 26.04 with Docker running and no containers already used about 600 MB (596 and 616 MB on two boxes; that includes Google's own guest agents, which a typical VPS image won't run). Add that before comparing against a plan's RAM.
- Disk includes images. The disk figure is mostly container images, which you pay for once. Your data grows on top of it.
Get the data
Every measured row, including the machine and date, is in our open dataset, selfhost-atlas-data (CC BY 4.0, JSON and CSV, DOI 10.5281/zenodo.22983606), as the measured_ram_mb, measured_disk_mb, measured_box and measured_on columns. The documented minimum sits next to it as documented_min_ram_mb and is never overwritten by a measurement.
For how we choose, install and re-check apps, see how we test. For the install side of the same runs, see the install-quality report.