How much RAM does a homelab need? 35 stacks measured
Tested on Docker Engine 29.9.2
Test result
4 GB ran 12 stacks at once on this test box, with 378 MiB to spare and every stack answering; 8 GB ran the same set with 4,248 MiB available. 2 GB handled 4 light stacks easily and 8 stacks with only 173 MiB left, and 12 stacks in 2 GB failed in both attempts: once the machine stopped answering over SSH, once only 9 of the 12 stacks started and the kernel killed processes. These are idle figures with no users or content, so treat them as the floor.

Most self-hosted services are smaller than their reputation. The 35 stacks measured here have a median idle use of 85.2 MiB, and the mean of 329.9 MiB is pulled up by a few heavy ones. The three stacks over 1 GiB decide most of the answer: if you want GitLab, Immich or a local AI interface, plan around those first.
The spread across 35 stacks
| Idle RAM | Stacks | Which |
|---|---|---|
| Under 25 MiB | 12 | AdGuard Home, Beszel hub, Caddy, FreshRSS, Gatus, Headscale, NGINX, Pi-hole, Portainer CE, Stalwart, Traefik, Vaultwarden |
| 25 to 100 MiB | 6 | Authelia, Gitea, Miniflux, Nginx Proxy Manager, Wiki.js, wg-easy |
| 100 to 250 MiB | 7 | BookStack, Dockge, Forgejo, Jellyfin, Komodo, Netdata, Uptime Kuma |
| 250 to 500 MiB | 5 | Home Assistant Container, Nextcloud, Prometheus + Grafana + node_exporter, Seafile CE, docker-mailserver |
| 500 MiB to 1 GiB | 2 | Paperless-ngx, authentik |
| Over 1 GiB | 3 | GitLab CE, Immich, Ollama + Open WebUI |
Median across all stacks: 85.2 MiB. Mean: 329.9 MiB. Lowest 4.8 MiB, highest 4428.3 MiB.
The range runs from 4.8 MiB for NGINX to 4,428.3 MiB for GitLab CE, which is about 920 times as much. All of them are on the RAM board with their CPU, disk and start time. Vendor requirements run higher than idle use, because idle is not the job these services do. GitLab’s requirements give 16 GB as the single-node baseline and say it can run with at least 8 GB in memory-constrained environments. Immich’s requirements give a 6 GB minimum and 8 GB recommended. Follow those figures for anything you depend on: an idle test says what a service costs to keep running, not what it needs to do its work.
Some of the weight is structural. Immich’s 1,317.8 MiB splits across four containers: 716.1 MiB for the server, 394.7 for its database, 202.6 for machine learning, and 4.4 for Valkey. Ollama plus Open WebUI’s 1,289.3 MiB is almost all the web interface (1,160.2 MiB), with Ollama at 129.1 MiB. The test did not record whether any model was loaded in either. The CPU-only Ollama guide measures a loaded model.
Do the numbers add up?
Each stack above was measured alone. A real server runs them together, so three combinations were started on one fresh 8 GB machine and compared with the sum of their individual figures.
| Combination | Stacks | Predicted sum, MiB | Measured together, MiB | Difference | VM used (free), MiB | VM available (free), MiB | Containers |
|---|---|---|---|---|---|---|---|
| Starter (4 stacks) | Caddy, Vaultwarden, Uptime Kuma, AdGuard Home | 138 | 137 | -0.9% | 740 | 7201 | 4 up, 0 unhealthy or restarting |
| Household (8 stacks) | Caddy, Vaultwarden, Uptime Kuma, AdGuard Home, Jellyfin, Nextcloud, Paperless-ngx, Home Assistant Container | 1485 | 1697 | +14.3% | 2015 | 5925 | 12 up, 0 unhealthy or restarting |
| Everything (12 stacks) | Caddy, Vaultwarden, Uptime Kuma, AdGuard Home, Jellyfin, Nextcloud, Paperless-ngx, Home Assistant Container, Immich, Forgejo, Prometheus + Grafana + node_exporter, Miniflux | 3298 | 3728 | +13.1% | 3692 | 4248 | 22 up, 0 unhealthy or restarting |
Measured together: median of 12 docker stats samples after 5 minutes of settling. VM columns come from one free -m reading at the end, in MiB of 7941 usable. Fresh install, no users and no content, some apps at their setup wizard.
The small set landed within 1% of the prediction. The larger sets used 13 to 14% more than the sum: 1,697 MiB against 1,485, and 3,728 MiB against 3,298. This test did not establish why. It is one run per set, so read it as a margin to allow rather than a constant. Will it fit? adds up the individual figures, so add that margin to its result for a large set.
The same stacks on smaller machines
The more useful question is where things stop working. The same combinations ran on 2 GB and 4 GB machines with no swap:
| Combination | VM RAM | Containers | Stacks answering HTTP | Slowest response | Available, MiB | OOM kernel log lines | Processes killed |
|---|---|---|---|---|---|---|---|
| Starter (4 stacks) | 2 GB | 4 of 4 up | 4 of 4 | 0.01 s | 1401 | 0 | none |
| Household (8 stacks) | 2 GB | 12 of 12 up | 8 of 8 | 0.52 s | 173 | 0 | none |
| Household (8 stacks) | 4 GB | 12 of 12 up | 8 of 8 | 0.20 s | 1956 | 0 | none |
| Everything (12 stacks) | 2 GB | 16 of 17 up, 2 not healthy; only 9 of 12 stacks started (forgejo, prometheus-grafana, miniflux failed to start) | 7 of 12 | 0.44 s | 145 | 33 | (sd-pam), immich, jellyfin, python3, systemd |
| Everything (12 stacks) | 4 GB | 22 of 22 up | 12 of 12 | 0.53 s | 378 | 0 | none |
One run per row, with no swap configured, fresh install with no users or content. Answering means a 2xx, 3xx or 4xx reply to the same URL used for the readiness check, within 30 s. 'OOM kernel log lines' counts dmesg lines matching out of memory or oom-kill. 'Processes killed' lists the distinct names in the last 12 'Killed process' lines only. A stack counts as started when its `docker compose up -d` returned success (see raw/<run>.events.txt, written by tiers.sh).
- Starter on 2 GB left 1,401 MiB available. Caddy, Vaultwarden, Uptime Kuma and AdGuard Home barely register on a machine this size.
- Household on 2 GB worked, with 8 of 8 stacks answering, but 173 MiB available is no margin at all. Anything that wakes up, such as OCR on one scanned document, would have nowhere to go. On 4 GB the same set had 1,956 MiB available.
- Everything on 4 GB also worked, with 12 of 12 stacks answering and 378 MiB available. That set includes Immich, on a machine below its documented 6 GB minimum, so this shows only that it can idle there. Real photo imports are the kind of load the Immich guide measures.
- Everything on 2 GB is the failure case, and it failed differently in two attempts. In the first, only 11 of the 12 stacks were started (the Miniflux image pull failed with a DNS timeout), and the machine stopped answering SSH at a point the test did not record, so no figures exist. The script then swallowed those failures, so it was changed to log them and the case was run again (the first attempt’s output is kept in
raw/attempt1-everything-2048/). In the second attempt the machine kept answering SSH, but only 9 of 12 stacks started (Forgejo, Prometheus plus Grafana and Miniflux failed to start; Forgejo with “could not set link up for host interface … resource temporarily unavailable”, and an SSH timeout was logged while Prometheus plus Grafana was starting). The kernel logged 33 out-of-memory lines, killed processes including Immich, Jellyfin and Python, Jellyfin was reported unhealthy, and only 7 of the 12 stacks answered HTTP five minutes after the last start attempt, with 145 MiB available. Whether either machine would have recovered is unknown.
No OOM kills appeared in the kernel log for any other run that could be read.
What to buy, or allocate
This is our reading of those results. It is opinion, and it depends on what you plan to do:
- 2 GB suits a handful of small services and a reverse proxy.
- 4 GB suits a general home server without the three heaviest stacks. The idle fit with them is real, but the margin goes once things do work, and it is below what Immich and GitLab document.
- 8 GB ran 12 stacks with 4,248 MiB available. It is the first size in this test with real room for workloads, and it matches Immich’s recommendation. A server with a photo library belongs here at least. GitLab’s 16 GB baseline is above it.
- 16 GB or more was not tested. The usual reasons to want it are local AI models, heavy page caching or ZFS, none of which this test measured.
Memory that looks used is not always in use. The available column from free is the number to watch, because it allows for cache the kernel can reclaim. docker stats subtracts only the inactive file cache from a container’s usage (Docker docs), so it can disagree with free: in the 12-stack run on 8 GB they were within 1% (3,728 MiB against 3,692 MiB used), in the 8-stack run they were not (1,697 against 2,015).
How this was measured
Stack figures come from the standard idle run: one fresh VM per stack, a settling period, then the median of 12 docker stats samples. Combinations ran from a clean base image, started one stack at a time, then rested for five minutes before being read. Only host ports changed from the compose files on each guide, so the stacks could share a machine.
Show the full file
#!/usr/bin/env bash
# Runs the same combos on smaller machines and records whether they still work: containers up,
# OOM kills in the kernel log, available memory, and an HTTP response time from each stack.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"; cd "$ROOT"
export LAB_SSH_PORT="${LAB_SSH_PORT:-2361}" LAB_HTTP_PORT="${LAB_HTTP_PORT:-18361}" LAB_CPUS="${LAB_CPUS:-4}"
# Optional argument: space-separated list of "<combo>-<mem>" cases to run (default: all), e.g. ./tiers.sh "everything-2048". The summary always rebuilds from all raw files.
ONLY="${1:-all}"; want() { [ "$ONLY" = all ] || [[ " $ONLY " == *" $1 "* ]]; }
OUT=evidence/tests/ram-combo/raw; mkdir -p "$OUT"
starter="caddy vaultwarden uptime-kuma adguard"
household="$starter jellyfin nextcloud paperless home-assistant"
everything="$household immich forgejo prometheus-grafana miniflux"
run() { # run <combo-name> <mem-mb> <stacks...>
local combo="$1" mem="$2"; shift 2
set +e # a dying VM must not abort the script: every failure is written to <tag>.events.txt or <tag>.state.txt instead
local tag="tier-$combo-$mem" VM="${LAB_VM_PREFIX:+$LAB_VM_PREFIX-}tier-$combo-$mem"
export LAB_MEM="$mem"
lab/vm.sh up "$VM"
trap 'lab/vm.sh down "$VM"' EXIT
{ date -u +%FT%TZ; uptime; lab/vm.sh ssh 'uname -r; free -m; nproc'; } > "$OUT/$tag.env.txt"
lab/vm.sh ssh 'sudo mkdir -p /opt/stacks /etc/komodo; sudo chown lab /opt/stacks'
local port=8100; : > "$OUT/$tag.urls.txt"; : > "$OUT/$tag.events.txt"
for s in "$@"; do
port=$((port+1))
lab/vm.sh ssh "mkdir -p ~/s/$s"
sed -E "s/127\.0\.0\.1:8080:/127.0.0.1:$port:/; s/127\.0\.0\.1:8081:/127.0.0.1:$((port+100)):/; s/127\.0\.0\.1:8123:/127.0.0.1:$port:/; s/127\.0\.0\.1:5353:/127.0.0.1:$((port+200)):/" "lab/stacks/$s/compose.yml" > "$OUT/.compose.tmp"
lab/vm.sh scp-to "$OUT/.compose.tmp" "s/$s/compose.yml"
if lab/vm.sh ssh "cd ~/s/$s && docker compose -p $s up -d --quiet-pull" >> "$OUT/$tag.up.txt" 2>&1; then echo "$(date -u +%T) $s started" >> "$OUT/$tag.events.txt"
else echo "up failed: $s" >> "$OUT/$tag.up.txt"; echo "$(date -u +%T) $s FAILED to start" >> "$OUT/$tag.events.txt"; fi
if lab/vm.sh ssh true >/dev/null 2>&1; then echo "$(date -u +%T) ssh answers after $s" >> "$OUT/$tag.events.txt"; else echo "$(date -u +%T) ssh does NOT answer after $s" >> "$OUT/$tag.events.txt"; fi
python3 - "lab/stacks/$s/compose.yml" "$s" "$port" >> "$OUT/$tag.urls.txt" <<'P'
import sys, urllib.parse as u, yaml
m = yaml.safe_load(open(sys.argv[1]))["x-lab"]
print(sys.argv[2], f"http://127.0.0.1:{sys.argv[3]}{u.urlparse(m['ready']).path}")
P
done
rm -f "$OUT/.compose.tmp"
lab/vm.sh scp-to "$OUT/$tag.urls.txt" urls.txt
lab/vm.sh ssh 'sleep 300'; rc=$?; echo "$(date -u +%T) 300 s wait ended, ssh rc=$rc" >> "$OUT/$tag.events.txt"
lab/vm.sh ssh 'while read -r s u; do printf "%s %s\n" "$s" "$(curl -s -o /dev/null -m 30 -w "%{http_code} %{time_total}" "$u")"; done < urls.txt' > "$OUT/$tag.http.txt" 2>&1 || true
lab/vm.sh ssh 'free -m; echo ---; docker ps -a --format "{{.Names}}|{{.Status}}"; echo ---; sudo dmesg | grep -ciE "out of memory|oom-kill" || true; sudo dmesg | grep -E "Killed process" | sed -E "s/^\[[^]]*\] //" | cut -c1-120 | tail -12' > "$OUT/$tag.state.txt" 2>&1 || echo "ssh failed: VM unresponsive" > "$OUT/$tag.state.txt"
lab/vm.sh down "$VM"; trap - EXIT
}
want starter-2048 && run starter 2048 $starter
want household-2048 && run household 2048 $household
want household-4096 && run household 4096 $household
want everything-4096 && run everything 4096 $everything
want everything-2048 && run everything 2048 $everything
python3 lab/tests/ram-combo/summarize_tiers.pyWhat this test did not cover
Sources and how this was made
Show the full file
#!/usr/bin/env bash
# Does the sum of individually measured idle figures predict a real shared machine?
# Boots each combo together on one fresh 8 GB VM, then compares `docker stats` and `free`
# against the sum from evidence/stacks.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"; cd "$ROOT"
export LAB_SSH_PORT=2361 LAB_HTTP_PORT=18361 LAB_MEM=8192 LAB_CPUS=4
OUT=evidence/tests/ram-combo/raw; mkdir -p "$OUT"
declare -A COMBOS=(
[starter]="caddy vaultwarden uptime-kuma adguard"
[household]="caddy vaultwarden uptime-kuma adguard jellyfin nextcloud paperless home-assistant"
[everything]="caddy vaultwarden uptime-kuma adguard jellyfin nextcloud paperless home-assistant immich forgejo prometheus-grafana miniflux"
)
for combo in starter household everything; do
VM="ram-$combo"
lab/vm.sh up "$VM"
trap 'lab/vm.sh down "$VM"' EXIT
{ date -u +%FT%TZ; uptime; lab/vm.sh ssh 'uname -r; docker --version; free -m; nproc'; } > "$OUT/$combo.env.txt"
lab/vm.sh ssh 'sudo mkdir -p /opt/stacks /etc/komodo; sudo chown lab /opt/stacks'
port=8100
for s in ${COMBOS[$combo]}; do
port=$((port+1))
lab/vm.sh ssh "mkdir -p ~/s/$s"
# Only host ports change, to let the stacks share one machine. x-lab stays, Compose ignores it.
sed -E "s/127\.0\.0\.1:8080:/127.0.0.1:$port:/; s/127\.0\.0\.1:8081:/127.0.0.1:$((port+100)):/; s/127\.0\.0\.1:8123:/127.0.0.1:$port:/; s/127\.0\.0\.1:5353:/127.0.0.1:$((port+200)):/" "lab/stacks/$s/compose.yml" > "$OUT/.compose.tmp"
lab/vm.sh scp-to "$OUT/.compose.tmp" "s/$s/compose.yml"
lab/vm.sh ssh "cd ~/s/$s && docker compose -p $s up -d --quiet-pull" >> "$OUT/$combo.up.txt" 2>&1
done
rm -f "$OUT/.compose.tmp"
lab/vm.sh ssh 'sleep 300'
lab/vm.sh ssh 'for i in $(seq 1 12); do docker stats --no-stream --format "{{json .}}"; echo ---; sleep 8; done' > "$OUT/$combo.stats.txt"
lab/vm.sh ssh 'free -m; docker ps --format "{{.Names}} {{.Status}}"' > "$OUT/$combo.free.txt"
lab/vm.sh down "$VM"; trap - EXIT
done
python3 lab/tests/ram-combo/summarize.pyHow this page was made: the commands above were run by an AI agent on the test box on November 1, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.