All posts

Nextcloud on a VPS in 2026: 2 GB floor, PHP tuning

Nextcloud AIO idled at 760.5 MiB on a 2 GB guest. First-run was 1,136.6 MiB. Warm login TTFB stayed near 45 ms on 4 GB and on 8 GB.

Written by NodeRings Inc.

NodeRings Inc. writes these notes. The company runs the marketplace and the agent that stays on the provider’s side of the hypervisor.

After one reboot, Nextcloud All-in-One on a 2 GB KVM guest held a median 760.5 MiB. The same disk, every optional container still off, held 767.5 MiB on a 4 GB guest and 764.3 MiB on an 8 GB guest with four vCPUs. A warm /login was 45.4 ms on that 2 GB guest, 45.5 ms on the 4 GB guest before the pool edit, and 43.9 ms on the 8 GB guest. The quiet window taken right after first-run, before that reboot, was 1,136.6 MiB. The Nextcloud container was 923.2 MiB of it, and 257.6 MiB once the guest had been rebooted.

The optional-containers screen in AIO states its own floor. "When any optional container is enabled, at least 2GB RAM, a dual-core CPU and 40GB system storage are required. When enabling ClamAV, Nextcloud Talk Recording-server or Fulltextsearch, at least 3GB RAM are required." The same paragraph asks for 5 GB and a quad-core CPU with everything enabled, and recommends at least 1 GB more than that minimum. The Linux install notes say the instructions are valid for a PC with at least 4 GB of RAM and two cores. Collabora, ClamAV, Talk, imaginary, fulltextsearch, and the whiteboard stayed off. The 2 GB line on that screen applies once an optional container is enabled. First-run finished with SwapTotal at 0, and dmesg counted zero out-of-memory lines.

GuestvCPUOptional containersIdle memory.current/login TTFB
Right after first-run2 GB2off1,136.6 MiB, median of 2050.9 ms
After a reboot2 GB2off760.5 MiB, median of 2045.4 ms
Before the pool edit4 GB2off767.5 MiB, median of 2045.5 ms
After the pool edit4 GB2off759.3 MiB, one reading47.3 ms
After the pool edit8 GB4off764.3 MiB, median of 2043.9 ms
Bars for idle memory.current. First-run on 2 GB is 1,136.6 MiB. After a reboot the same disk is 760.5 MiB on 2 GB, 767.5 MiB on 4 GB, and 764.3 MiB on 8 GB. A dashed line marks the 2 GB guest.
Median of 20 sums of cgroup memory.current, optional containers off. The first bar is the quiet window right after first-run. The other three are the same disk after a reboot. The dashed line is the 2 GB guest.

Nextcloud AIO on a small VPS

The guest was Ubuntu 24.04.5, kernel 6.8.0-147-generic, Docker 29.1.3, KVM, on a 48 GB qcow2 disk. The three boots were qemu -m 2048 -smp 2, qemu -m 4096 -smp 2, and qemu -m 8192 -smp 4. MemTotal read 2,014,840 kB, 4,009,852 kB, and 8,131,764 kB. Swap stayed off on all three.

The mastercontainer image was ghcr.io/nextcloud-releases/all-in-one:latest, digest sha256:89e6b84333a6d01236fab07a006fb9b06ed166bbf08cb58446b599900541c3c9. The Nextcloud image was ghcr.io/nextcloud-releases/aio-nextcloud:latest, digest sha256:78d2a376d482277af7db66967dfd767c061fb322062a6f88b20b70ba96d60893. occ status reported version 34.0.5.1 and versionstring 34.0.5. PHP was 8.4.27. The Hub checkbox on the AIO screen was left off.

AIO's base set was Nextcloud, Postgres, Apache, the mastercontainer, Redis, and notify-push.

sudo docker run -d --init --sig-proxy=false \
  --name nextcloud-aio-mastercontainer --restart always \
  --publish 8080:8080 \
  --env APACHE_PORT=11000 \
  --env APACHE_IP_BINDING=0.0.0.0 \
  --env APACHE_ADDITIONAL_NETWORK= \
  --env SKIP_DOMAIN_VALIDATION=true \
  --volume nextcloud_aio_mastercontainer:/mnt/docker-aio-config \
  --volume /var/run/docker.sock:/var/run/docker.sock:ro \
  ghcr.io/nextcloud-releases/all-in-one:latest

SKIP_DOMAIN_VALIDATION=true is in that command because the name was cloud.lab.test. It is a lab name, and the host sits behind NAT. AIO's readme answers "Are self-signed certificates supported for Nextcloud?" with no. Apache listened on port 11000. Every timing is HTTP from the guest to 127.0.0.1:11000, with Host: cloud.lab.test and X-Forwarded-Proto: https.

Each idle window followed the same steps. Finish first-run, open the dashboard once, wait ten minutes, then take twenty readings of cgroup memory.current, thirty seconds apart. The idle column is the median of those twenty sums. The readings are cgroup memory.current.

The Nextcloud container accounts for that high first window, 923.2 MiB against 257.6 MiB after the reboot on the same disk. Postgres went the other way, 86.7 MiB to 253.3 MiB, and the mastercontainer went from 52.5 MiB to 142.8 MiB. The 4 GB guest, still on two vCPUs, and the 8 GB guest on four, landed within a few MiB of 760.

A 2 GB VM is the size this layout fit. The marketplace is where the plan is chosen. Turn an optional container on and that screen asks for the 2 GB line, plus the extra gigabyte it recommends.

PHP-FPM settings Nextcloud AIO ships

The AIO readme says each PHP process defaults to a max of 512 MB, changed with NEXTCLOUD_MEMORY_LIMIT on the mastercontainer. This one had PHP_MEMORY_LIMIT=512M. The pool file was already pm = ondemand, pm.max_children = 5000, and pm.process_idle_timeout = 300s. The server tuning manual describes a stock pool of pm.max_children = 5. That number lives in the generic PHP-FPM story. AIO's www.conf is the file this container was running.

The manual's formula is pm.max_children = floor(available RAM for PHP / average worker RSS), and it puts a typical worker around 50 to 100 MB. It wants dynamic mode for Nextcloud, because desktop and mobile clients poll about every 30 seconds. Its idle timeout for ondemand defaults to 10 seconds, so a poll that arrives after the worker has exited pays a cold start. AIO's timeout is 300 seconds, and the pool was already ondemand. The mode stayed.

One worker, before the edit, was 105,456 KB RSS. The php-fpm master beside it was 64,828 KB. Divide a 512 MB budget by that 103 MB and the floor is 4. Divide the same 512 MB by a 128 MB memory_limit and the floor is 4 again. A process allowed to grow to 512 MB can fill that budget alone, which is what pm.max_children = 5000 next to 512M is willing to start.

Two lines changed. In the container, pm.max_children went from 5000 to 4, and a new file set the FPM admin limit.

docker exec nextcloud-aio-nextcloud \
  sed -i 's/^pm.max_children = 5000/pm.max_children = 4/' \
  /usr/local/etc/php-fpm.d/www.conf
docker exec nextcloud-aio-nextcloud sh -c \
  'printf "%s\n" "php_admin_value[memory_limit] = 128M" > /usr/local/etc/php-fpm.d/zz-memory.conf'

The php-fpm master was then reloaded with kill -USR2. /login still returned HTTP 200. The worker after the reload was 75,672 KB RSS, and the master was 64,784 KB. Each of those RSS figures is a single ps reading.

OPcache was already on, and the Redis lines were already in config.php. opcache.enable was On, opcache.memory_consumption 256, opcache.max_accelerated_files 20000, opcache.revalidate_freq 60, opcache.jit disable. memcache.local pointed at APCu, memcache.distributed at Redis, memcache.locking at Redis. Those were already the running values.

PHP-FPM pool lines pm equals ondemand, pm.max_children equals 4, idle timeout 300 seconds, and memory_limit 128M. OPcache is On, with 256 MiB and 20000 files.
The pool after the edit, and the opcache lines php -i was already printing. The 128M limit is an FPM admin value in zz-memory.conf.

The system requirements say Nextcloud needs a minimum of 128 MB RAM per process, and recommend a minimum of 512 MB. The 128 MB here is that minimum, so four children fit inside the 512 MB the readme already gives one process. A preview or a conversion that wants more than 128 MB will hit the cap. The supported knob, from the readme, is NEXTCLOUD_MEMORY_LIMIT on the mastercontainer, and that was left at 512M. The override sits in the Nextcloud container's writable layer. Recreating the container drops it. The qemu reboots kept the layer, which is why the 8 GB boot and the second 2 GB boot still showed pm.max_children = 4.

One reading on the 4 GB guest, taken after the reload, summed to 759.3 MiB. The pool change left the idle sum where the twenty-sample median had been.

Nextcloud login time to first byte

curl -s -o /dev/null -w "%{http_code} %{time_starttransfer}\n" \
  -H "Host: cloud.lab.test" \
  -H "X-Forwarded-Proto: https" \
  http://127.0.0.1:11000/login

Five samples, the median of time_starttransfer, every one HTTP 200, run on the guest. The login document was about 19 KB. The timer is the wait before Apache sent the first byte.

On the 4 GB boot the first sample after startup was 0.570 seconds. The next four sat between 0.044 and 0.047. A warm set, taken on that same boot just before the pool edit, was 0.059588, 0.045459, 0.044805, 0.044686, 0.045562. The median is 0.045459 seconds, 45.5 ms. After the USR2 reload the five samples were 0.407990, 0.045890, 0.047316, 0.054629, 0.044149, all HTTP 200. The median is 0.047316 seconds, 47.3 ms. The 0.408 second sample is the first request after the workers were replaced. That is the cold start the tuning manual describes, on that one request. The steady 2 GB median was 45.4 ms. The 8 GB median was 43.9 ms.

Bars for warm login time to first byte. 45.5 ms on 4 GB before the pool edit, 47.3 ms after, 45.4 ms on 2 GB, and 43.9 ms on 8 GB.
Median of five warm samples of time_starttransfer on /login. The first request after the 4 GB boot was 0.570 seconds, and the first request after the PHP reload was 0.408 seconds. Those two stay off the bars, because a mean of the five would be pulled up by them.

Why the Nextcloud Files app stays heavy

Õunapuu measured a clean page load at about 15 to 20 MB of JavaScript, compressing to about 4 to 5 MB on the wire. core-common.js on that install was 4.71 MB. The Files app was 18.8 MB of JavaScript.

On this install, /apps/files/ was 43,936 bytes of HTML and 49 script URLs. The scripts summed to 14,233,566 bytes, 13.57 MiB, counted as the response bodies. core-common.js was 5.53 MiB of that. The dashboard's scripts summed to 10,137,937 bytes. These sizes are the response bodies, uncompressed, from a different install than the 4.71 MB and 18.8 MB figures.

The Nextcloud Files app, showing the sample documents that ship with the install, including Nextcloud.png, a manual PDF, and the Documents, Photos, and Templates folders.
Files on cloud.lab.test, with the documents the install ships. AIO requires a valid certificate for Nextcloud. The one in front of this page was minted for the lab, so the screenshot could be taken.

The pool, opcache, and Redis are the wait before that first byte. The browser then fetches the scripts. Photos belong in Immich, and passwords in Vaultwarden. Files and the calendar are what this guest was keeping.

Is 2 GB enough for two people on Nextcloud?

The 2 GB guest, after the reboot, one dashboard login, and ten quiet minutes, sat at 760.5 MiB out of a MemTotal of 2,014,840 kB. Swap was off. The out-of-memory count in dmesg was 0. The warm login was 45.4 ms, from one admin session. The pool will spawn at most four workers, and the one worker measured on a login was 74 MB. Discussion 1335 is where the requirements paragraph sends you for further sizing. That page is a separate recommendation from these readings.

Does a VPS memory graph show PHP?

The Graphs tab on a VM is CPU, memory, and network for the whole guest. Container splits and PHP children sit outside that tab. The figure is the six containers on the steady 2 GB guest. Ubuntu and the page cache live in the same machine the graph draws, and outside these cgroups.

Median memory.current on the 2 GB guest. Nextcloud 257.6 MiB, database 253.3 MiB, mastercontainer 142.8 MiB, Apache 72.1 MiB, notify-push 18.2 MiB, Redis 16.5 MiB, sum 760.5 MiB. MemTotal 2,014,840 kB, swap 0.
Median memory.current for each container on the steady 2 GB guest, twenty rounds. MemTotal and SwapTotal are the guest. A provider memory graph is that guest, summed.

What if ClamAV or Collabora is enabled?

The screen's own sentence becomes the floor. Any optional container means at least 2 GB, two cores, and 40 GB of disk, and the recommendation on that screen is 1 GB more. ClamAV, Talk recording, or fulltextsearch moves the printed line to 3 GB. The ClamAV checkbox says it needs about 1 GB of additional RAM. Everything enabled at once is 5 GB and four cores. Those containers stayed off for every reading in this run.