On this page (7)
This Nextcloud Docker Compose file runs the official nextcloud image (35.0.2, Apache variant) with PostgreSQL 18, Redis and a cron container, and installs itself from environment variables, so there is no web wizard. We verified it on Oct 3, 2026 (on 35.0.1) and measured 35.0.2 as the median of three runs on Oct 18, 2026, on a GitHub Actions runner. Every health check passed 26.8 s after docker compose up (26.8 to 26.9 s over the three runs). The stack idled at 297 MiB (range 295 to 301) and peaked at 452 MiB (range 402 to 479) while one user uploaded about 200 MB over WebDAV on a 2-vCPU lab host, so by our sizing rule it needs 1 GB of RAM. Idle is measured after the cron container’s first background run, when it holds about 34 MiB (0.6 MiB before it). Our Oct 17 figures, 265 and 367 MiB, were taken before that first run in two of three runs; the Oct 8 runs on 35.0.1 gave 299 and 454 MiB. We do not claim 35.0.2 uses less memory (see Measured resources). Three settings decide whether it works properly: the trusted domain, REDIS_HOST and the cron container. We tested what each one changes. We also found that a plain pg_dump does not restore this database (see Back up and restore).
Measured readings
Test box: Nextcloud 35.0.2
Measured in our lab on : 3 full runs, each from a fresh stack. Each figure is the median, with the range over the runs under it. The numbers come from the raw result file.
- Idle RAM
- 297 MiBrange 295–301 · 3 runs all containers, median of 3 runs; each run is the mean over 30 s after the stack settled
- Startup peak RAM
- 302 MiBrange 295–302 · 3 runs highest total between healthy and settled
- Peak RAM
- 452 MiBrange 402–479 · 3 runs under load, median of the run peaks
- Settled after
- 190 srange 187–217 · 3 runs after the first healthy response
- Idle CPU
- 2.5 %range 1.8–2.6 · 3 runs of one core, lab host
- Startup
- 27 ssame when rounded in all 3 runs to first healthy response
- App version
- 35.0.2
- Tested
- , 3 runs (compose file verified the same day)
- How the figures are made
- Each run starts a fresh stack and goes through setup, settling, the idle window and the load step. We publish the median over the runs and the lowest and highest value, because the lab host and some apps vary from run to run. Runs, median and range
- Lab host
- AMD EPYC 7763 64-Core Processor, 2 cores, 7.8 GiB RAM, x86_64.Ubuntu 24.04.5 LTS, Docker 28.0.4.
- Disk for the images
- About 2.9 GB with the containerd image store, the default on fresh installs of Docker Engine 29 and later (our estimate: unpacked plus compressed size, plus 10%; not measured on a containerd host). About 1.9 GB with the older overlay2 store (the unpacked column, as measured on this lab host). Plan for the larger figure. How we size disk
- How idle was measured
- After the stack was healthy and set up, we sampled total RAM until it stayed within max(2% of its mean, 10 MiB) over 60 s (at least 120 s, at most 600 s), then measured idle over the next 30 s. Rule in full
- Peak RAM measured
- Under load. One user (the admin), over plain HTTP on the Compose network, one request at a time. A Python script generates 103 random files (100 small ones of 1 KB to 1 MB, about 50 MB together, and three of 50 MB), uploads them over WebDAV (MKCOL, then one PUT per file), lists the folder with PROPFIND, and downloads five files (three small, two large) and checks their SHA-256. About 200 MB in total. No browser, no sync client, no chunked upload, no previews, no sharing. `occ files:scan` is not part of this load (the harness cannot run commands inside the stack); it was run by hand, see the article.
| Container | Idle RAM | Peak RAM (under load) | Image, compressed (download) | Image, unpacked (disk, overlay2 store) |
|---|---|---|---|---|
| app | 204 MiBrange 204–211 · 3 runs | 309 MiBrange 308–317 · 3 runs | 517 MB | 1.5 GB |
| cron | 34 MiBsame when rounded in all 3 runs | 147 MiBrange 34–167 · 3 runs | 517 MB (same image as app) | 1.5 GB (same image as app) |
| db | 46 MiBsame when rounded in all 3 runs | 51 MiBrange 49–51 · 3 runs | 120 MB | 304 MB |
| redis | 10 MiBrange 10–12 · 3 runs | 11 MiBrange 11–13 · 3 runs | 39 MB | 119 MB |
| Total | 297 MiBrange 295–301 · 3 runs | 452 MiBrange 402–479 · 3 runs | 676 MB | 1.9 GB |
Every run (3)
| Run | Idle RAM | Peak RAM | Startup peak | Startup | Lab host CPU |
|---|---|---|---|---|---|
| 1 | 301 MiB | 402 MiB | 302 MiB | 27 s | AMD EPYC 7763 64-Core Processor |
| 2 | 295 MiB | 452 MiB | 295 MiB | 27 s | AMD EPYC 7763 64-Core Processor |
| 3 (closest to median idle) | 297 MiB | 479 MiB | 302 MiB | 27 s | AMD EPYC 7763 64-Core Processor |
0 s to 220 s after healthy; the line spans 0 to 298 MiB. Dashed line: settled.
Timeline as a table
| Seconds after healthy | Total RAM |
|---|---|
| 30 | 260 MiB |
| 60 | 256 MiB |
| 90 | 258 MiB |
| 120 | 259 MiB |
| 150 | 298 MiB |
| 180 | 297 MiB |
| 210 | 297 MiB |
| 220 | 297 MiB |
RAM is MiB (220 bytes); image sizes are MB (106 bytes). The unpacked column is what our lab host's Docker reported; see Disk for the images for other setups. Per-container peaks happen at different moments, so they do not add up to the total peak. Medians of separate columns need not add up either.
Download verified compose fileDownload .envRaw result JSONHow we measure
Lab notes (3)
- Image: the official `nextcloud` apache variant. The `cron` service is the same image started with /cron.sh: two containers in memory, one image on disk.
- The first install is automatic (database and admin variables in compose.yaml), so there is no web wizard and no installed apps beyond the image's defaults.
- Periodic job: service cron runs a scheduled job every 300 s on the wall-clock grid. After the settle rule is met, the idle window starts only once the job has ticked at least once since the stack became healthy and the last tick is 60 s old (extra wait capped at 360 s), so idle reflects a stack after one run of the job. The wait is part of settle_seconds_used; runs[].periodic has the tick times (computed from the clock, not read from the job).
Verified compose file
Four services. db is PostgreSQL 18.6, redis is Redis 8.10.2, app is Nextcloud on Apache, and cron is the same Nextcloud image started with /cron.sh. app and cron mount one volume, nextcloud, which holds the Nextcloud code, config.php and every user’s files. Only app publishes a port, and only on 127.0.0.1:8080, so a reverse proxy on the host is the way in. The comments in the file are the lab’s.
It follows the Compose examples in the nextcloud/docker repository, which use postgres:alpine, redis:alpine and a cron service with entrypoint: /cron.sh. What we changed:
- PostgreSQL 18, pinned. The admin manual’s system requirements list PostgreSQL 14, 15, 16, 17 and 18 for Nextcloud 35 and mark 18 as recommended (read Oct 3, 2026). The Postgres 18 image has
PGDATA=/var/lib/postgresql/18/dockerand declares/var/lib/postgresqlas its volume, so the file mounts the volume there, as the Nextcloud example does. - Health checks.
dbandredishave their own.apppasses only whenstatus.phpreports"installed":true, socron, which waits forappto be healthy, starts only after the install has finished. - Required passwords. The two passwords are
${VAR:?...}references, so Compose refuses to start until both are in.env. - First install from the environment. The README says that a complete database and admin credential set at first deployment gives a fully automated install that bypasses the wizard. We set
POSTGRES_*,NEXTCLOUD_ADMIN_USER,NEXTCLOUD_ADMIN_PASSWORD,NEXTCLOUD_TRUSTED_DOMAINSandREDIS_HOST. - Tags. The lab replaced
nextcloud:apachewithnextcloud:35.0.2, and the image digest is in the file’s comment. On Oct 17, 2026 the Docker Hub tags35.0.2,35.0.2-apache,apacheandlatestwere all that same digest (registry check,lab/notes/nextcloud-2026-10-17.md, section 4), so35.0.2is the Apache variant. The Oct 18 runs used the same digest. The same check for 35.0.1 on Oct 3 is inlab/notes/nextcloud-2026-10-03.md, section 38. Postgres and Redis are pinned by tag and digest.restartisunless-stoppedwhere the example hasalways.
# Verified compose for Nextcloud, tested 2026-10-18 by the SelfHostBench lab (3 runs; the figures are the median).
# Lab host: AMD EPYC 7763 64-Core Processor, 2 vCPU, 7.8 GiB RAM, x86_64, Docker 28.0.4.
# Floating image tags below were replaced by the version resolved at test time;
# the registry digest is in the comment on each image line.
# Measurements: nextcloud.json (schema 3).
# Companion env file: nextcloud.env (save it as .env next to this file).
# Secrets are not published: the lab's values are public. In nextcloud.env:
# NEXTCLOUD_ADMIN_PASSWORD is commented out. Uncomment it and set your own value (the comment above it explains how).
# POSTGRES_PASSWORD is commented out. Uncomment it and set your own value (the comment above it explains how).
# Nextcloud (official image, apache variant) with PostgreSQL, Redis and a cron container.
# Follows the examples in https://github.com/nextcloud/docker (.examples/docker-compose/insecure/postgres/apache):
# the app and cron containers share one volume, and cron is the same image started with /cron.sh.
# Differences from that example, all marked below: Postgres 18 and its volume path, healthchecks,
# required passwords, and the first install done from environment variables (no web wizard).
# There is no TLS in this file. Put a reverse proxy in front before you expose it (see the article).
name: nextcloud
services:
db:
# The admin manual lists PostgreSQL 14 to 18 for Nextcloud 35 (recommended: 18).
# https://docs.nextcloud.com/server/35/admin_manual/installation/system_requirements.html
image: postgres:18-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873 # version 18.6 (from exec); no registry tag for it could be verified, so the digest is pinned instead
restart: unless-stopped
environment:
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}"
volumes:
# The Nextcloud database. PostgreSQL 18 images keep data under /var/lib/postgresql (not .../data).
# Back it up with pg_dump AND pg_dumpall --globals-only: the installer creates a second database
# role (oc_admin) that a plain pg_dump does not contain. See the article.
- db:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nextcloud -d nextcloud"]
interval: 5s
timeout: 5s
retries: 12
redis:
# File locking and memory cache. No password and no published port: only the app and cron
# containers on this Compose network can reach it.
image: redis:alpine@sha256:3811787313eba226a2ef38658c6ccb91cd5e110edc89c37767de373120a0e5a0 # version 8.10.2 (from exec); no registry tag for it could be verified, so the digest is pinned instead
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep -q PONG"]
interval: 5s
timeout: 3s
retries: 12
app:
image: nextcloud:35.0.2 # sha256:a588aa5353703b44106a6129adaaf7bf22521b7a4754f9f529b3228d6aa390c5 (version 35.0.2, from exec)
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
ports:
# Localhost only. A reverse proxy on the host sends traffic here.
- "127.0.0.1:8080:80"
environment:
# Database. With all of these set, the image installs Nextcloud on first start (no wizard).
POSTGRES_HOST: db
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}"
# First admin account. Used on the first start only; later changes here do nothing.
NEXTCLOUD_ADMIN_USER: "${NEXTCLOUD_ADMIN_USER:-admin}"
NEXTCLOUD_ADMIN_PASSWORD: "${NEXTCLOUD_ADMIN_PASSWORD:?set NEXTCLOUD_ADMIN_PASSWORD in .env}"
# The host name you type in the browser. Nextcloud refuses requests for any other name.
# Space-separated list. Added to config.php after the first install ("localhost" is there already).
NEXTCLOUD_TRUSTED_DOMAINS: "${NEXTCLOUD_DOMAIN}"
# Redis for file locking, memory cache and PHP sessions. Without this line the redis
# container above runs but Nextcloud never connects to it.
REDIS_HOST: redis
# Behind a reverse proxy you will also need OVERWRITEPROTOCOL and TRUSTED_PROXIES.
# They are not set here because this file has no proxy. See the article.
volumes:
# Nextcloud code, config.php and every user's files. This is your data: back it up.
# The app and cron containers must mount the same volume.
- nextcloud:/var/www/html
healthcheck:
# Passes when status.php reports the install finished (not just that Apache answers).
test: ["CMD-SHELL", "curl -fsS http://localhost/status.php | grep -q '\"installed\":true'"]
interval: 5s
timeout: 5s
retries: 60
start_period: 20s
cron:
# The same image, started as the background-job runner: busybox crond runs cron.php every 5 minutes.
# After its first run Nextcloud switches the background-jobs mode from AJAX to Cron by itself.
image: nextcloud:35.0.2 # sha256:a588aa5353703b44106a6129adaaf7bf22521b7a4754f9f529b3228d6aa390c5 (version 35.0.2, from exec)
restart: unless-stopped
entrypoint: /cron.sh
depends_on:
app:
condition: service_healthy
volumes:
- nextcloud:/var/www/html
volumes:
db:
nextcloud:The .env goes next to it.
The published .env has no passwords, on purpose. A placeholder you forget to replace would become the real admin password, so we publish none. The lab ran with its own values, which are public in our test configuration. With both lines commented out, docker compose config stops with required variable POSTGRES_PASSWORD is missing a value: set POSTGRES_PASSWORD in .env (notes, section 1). Step 3 below sets them. So the files you download are not byte for byte the files the lab ran: the .env has the two password lines commented out, and the compose header has three extra comment lines about that. We also reworded the comment above NEXTCLOUD_DOMAIN after the run, without a re-run (notes, header).
# Host name you will open in the browser: no https://, no port, no path. Nextcloud refuses requests
# for names it does not trust (localhost is trusted by default). Add more names by separating them
# with spaces. Used on first install.
NEXTCLOUD_DOMAIN=cloud.example.com
# Name of the first admin account (first install only).
NEXTCLOUD_ADMIN_USER=admin
# Admin password for the first install (it is only read on the first start). Choose your own; the stack does not start without it.
# A placeholder would become a real, known admin password, so the lab's value is not published and this line is commented out.
# Generate one with: openssl rand -hex 16
# NEXTCLOUD_ADMIN_PASSWORD=
# Database password. Choose your own; the stack does not start without it. Letters and digits only is safest.
# Generate one with: openssl rand -hex 24
# POSTGRES_PASSWORD=NEXTCLOUD_DOMAIN is the name you will type in the browser. It becomes NEXTCLOUD_TRUSTED_DOMAINS in the file, and the trusted-domain section below shows what happens to a request that uses any other name.
Install steps
We ran these steps on our earlier sandbox lab host on Oct 3, 2026, on Nextcloud 35.0.1 (Docker 29.6.2, Compose 5.3.1), and did not repeat them on the Actions runner or on 35.0.2. The numbers on this page come from the lab run in nextcloud.json (Oct 18, 35.0.2). The raw output of every manual step is in lab/notes/nextcloud-2026-10-03.md in the lab repository. We ran the manual steps with -p nc-manual, which names the Compose project and so the volumes (nc-manual_nextcloud, nc-manual_db). You can use any name, or leave -p out; the file’s own name: nextcloud then applies and the volumes are nextcloud_nextcloud and nextcloud_db.
-
Install Docker Engine with the Compose plugin.
-
Make a folder and save the two files above as
compose.yamland.envin it. The download links are on each block.bash mkdir nextcloud cd nextcloud -
Set the two passwords. This replaces the commented-out lines in
.envwith random values. Keep the admin password: it is the login for the first account.bash sed -i "s/^# NEXTCLOUD_ADMIN_PASSWORD=.*/NEXTCLOUD_ADMIN_PASSWORD=$(openssl rand -hex 16)/; s/^# POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(openssl rand -hex 24)/" .env -
Set
NEXTCLOUD_DOMAINin.envif you are not testing locally. We leftcloud.example.com, a name that only exists in ourHostheaders. -
Start the stack. The first start downloads the images: 676.4 MB compressed in total (table below).
--waitreturns when the containers are healthy.bash docker compose -p nc-manual up -d --waitOn the sandbox host, with the images already pulled, this returned after 43.1 s. The lab’s own start, from
upto every health check passing, took 26.8 to 26.9 s on the Actions runner on Oct 18 (26.8 s in all three runs on Oct 17 and on Oct 8), against 42.9 s in the median of three runs on the sandbox host on Oct 6 (42.9 to 44.6 s) and 38.1 s in its one run on Oct 3. -
Check the containers.
bash docker compose -p nc-manual ps --format 'table {{.Name}}\t{{.Status}}\t{{.Ports}}'Our output:
text NAME STATUS PORTS nc-manual-app-1 Up 36 seconds (healthy) 127.0.0.1:8080->80/tcp nc-manual-cron-1 Up Less than a second 80/tcp nc-manual-db-1 Up 42 seconds (healthy) 5432/tcp nc-manual-redis-1 Up 42 seconds (healthy) 6379/tcpcronhas no health check, so--waitreturned as soon as it was running. Onlyapppublishes a port. -
Check that the install finished. On our earlier sandbox lab host (Oct 3, 2026), which reached the internet through an HTTPS proxy, these
curlcalls also passed--noproxy '*'. You will not need that unless your own shell sets a proxy.bash curl -s http://127.0.0.1:8080/status.phpOur output on 35.0.1 (Oct 3), on one line:
json {"installed":true,"maintenance":false,"needsDbUpgrade":false,"version":"35.0.1.1","versionstring":"35.0.1","edition":"","productname":"Nextcloud","extendedSupport":false}The lab’s own install check on 35.0.2 (Oct 18,
nextcloud.json, setup step) printed"installed": true,"version": "35.0.2.2"and"versionstring": "35.0.2". -
Run the setup checks and fix what has a command. The README says to run
occaswww-datathroughdocker compose exec(occ section). We added-Tbecause our commands ran from a script without a terminal; leave it out in an interactive shell.bash docker compose -p nc-manual exec -T -u www-data app php occ setupchecksThe setup checks section below lists what it printed and which warnings are left. Three of them have a documented fix, which we ran:
bash docker compose -p nc-manual exec -T -u www-data app php occ config:system:set maintenance_window_start --type=integer --value=1 docker compose -p nc-manual exec -T -u www-data app php occ config:system:set default_phone_region --value=US docker compose -p nc-manual exec -T -u www-data app php occ maintenance:repair --include-expensiveUse your own two-letter ISO 3166-1 region code for
default_phone_region, and your own low-usage hour (UTC) formaintenance_window_start. The admin manual’s background jobs page says that hour starts a four-hour window for the jobs that are not time-sensitive, and that the setting only applies in cron mode. -
Log in. Not run in our lab: a browser login. The lab logged in as the admin user over WebDAV with Basic authentication (103 uploads returned 201, see the load description in the Test box). Browsing to
http://127.0.0.1:8080from the same machine gave a login page with HTTP 200 in ourcurlcheck. From another machine, the name or IP you type must be a trusted domain (next section).
To stop the stack, docker compose down removes the containers and keeps the named volumes unless you add -v, which deletes the volumes declared in the file, including your files and the database. Not run in our lab: the plain command on the long-lived stack. The lab’s own run used down -v at the end.
docker compose -p nc-manual downMeasured resources
Source: nextcloud.json, three runs of Nextcloud 35.0.2 on Oct 18, 2026, each from a fresh stack, on the lab host (a GitHub Actions ubuntu-latest runner: 2 vCPU AMD EPYC 7763, 7.8 GiB RAM, Docker 28.0.4; the same runner type and CPU model as the Oct 8 runs on 35.0.1 and the Oct 17 runs on 35.0.2). Every figure below is the median of the three runs, with the range (lowest to highest run) in brackets where it matters (why we repeat runs). In each run idle is the mean of 11 samples over 30 s, taken after the stack’s total memory had settled and after the cron container’s first background run (a median of 190 s after the stack was healthy, 187 to 217 s over the three; rules on the methodology page and in its 2026-10-18 entry). The install had finished and the admin account held only Nextcloud’s default welcome files. Startup peak is the highest total between healthy and settled; it includes cron’s first run (about 34.5 MiB in each run). Peak is the highest value seen while the load ran.
The load was one user (the admin), over plain HTTP on the Compose network, one request at a time. A Python script in a throwaway container generated 103 random files, 100 small ones of 1 KB to 1 MB and three of 50 MB, 194.5 MB in all. It created a folder over WebDAV, uploaded each file with one PUT (all 103 returned 201 in every run, median 32.9 s on the lab host), listed the folder with PROPFIND (104 entries, 0.23 s) and downloaded five files, 101.5 MB, checking their SHA-256 in every run (1.7 s). It used no browser, no sync client, no chunked upload, no previews and no sharing, and the memory window ran about 36 s (34.0 to 37.7 s).
| Container | Idle RAM, settled (MiB) | Startup peak (MiB) | Peak RAM under load (MiB) | Image compressed (MB) | Image unpacked (MB) |
|---|---|---|---|---|---|
| app | 204.31 (204.19 to 210.65) | 209.80 (207.50 to 217.60) | 308.80 (307.50 to 317.00) | 517.3 | 1,480 |
| cron | 34.39 (34.32 to 34.42) | 34.56 (34.55 to 34.57) | 147.30 (34.35 to 167.30) | same image as app | same image as app |
| db | 46.10 (45.97 to 46.17) | 46.22 (46.19 to 46.42) | 50.96 (49.48 to 51.44) | 120.0 | 304 |
| redis | 10.29 (10.27 to 12.16) | 10.50 (10.48 to 12.41) | 10.69 (10.54 to 12.52) | 39.1 | 119 |
| Stack, highest simultaneous total | 296.93 (294.95 to 301.33) | 301.63 (295.39 to 302.46) | 451.78 (401.61 to 478.96) | 676.4 | 1,903 |
Each cell is the median of the three runs with the range in brackets. The stack peak is the highest sum of all four containers at one instant, so it is lower than the sum of the per-container peaks (in run 2, the run with the median peak, 451.8 MiB against 516.3 MiB, because the containers do not peak at the same instant), and medians of separate columns need not add up. app and cron share one image, so the image totals count it once. The unpacked sizes are what Docker reports on the lab host, which has Docker 28.0.4 with the classic overlay2 image store. Docker 29 and later uses the containerd image store on fresh installs, and that store also keeps the compressed layers: the earlier host (Docker 29.6.2, containerd store) showed 2,714 MB for these images, on an earlier build of the 35.0.1 tag (not re-measured on 35.0.2). For disk we plan for unpacked plus compressed size plus 10%, about 2.9 GB for the images against 1.9 GB on overlay2 (how we size disk). No run was flagged as varying (the flag needs the highest run to be more than 1.5 times the lowest). CPU, medians of the three runs; this runner has 2 vCPUs, so 200% is the most the stack can use:
| Container | Idle CPU, mean (% of one core, lab host) | Load CPU, mean (% of one core, lab host) | Load CPU, max (% of one core, lab host) |
|---|---|---|---|
| app | 0.5 | 65 | 83 |
| cron | 0.0 | 12.8 | 97 |
| db | 0.8 | 23 | 34 |
| redis | 0.9 | 2.0 | 5.1 |
| Stack | 2.5 | 104 | 143 |
What the numbers say:
- Nextcloud itself is most of it:
appheld 204 MiB at idle and reached 309 MiB under the upload. Postgres used 46 MiB and Redis 10 MiB at idle. The Oct 8 runs on 35.0.1 (same runner type) hadappat 209 MiB idle and 305 MiB peak, Postgres at 46 MiB and Redis at 10 MiB; the Oct 17 runs on 35.0.2 had 209 and 307, 43 and 10. The earlier sandbox host’s runs (Oct 6) hadappat 283 MiB idle and 384 MiB peak with Postgres at 53 MiB and Redis at 6 MiB; the new image digest, the host and Docker 28 changed together, so we cannot say which moved them. - Three batches of one stack: what moved is the cron container. The Oct 8 runs on 35.0.1 gave 299 MiB idle and 454 MiB peak, the Oct 17 runs on 35.0.2 gave 265 and 367, and these give 297 and 452.
app,dbandredistogether idled at 262.5 MiB (264.2 on Oct 17, 264.7 on Oct 8), and the sum of their own load peaks is 369.0 MiB (367.5 and 365.9), all medians of three runs. The differences between the batches are in the cron container, whose state differed between the dates (next bullets). We do not claim that 35.0.2 uses less memory. The 35.0.2 changelog (read Oct 17, 2026) has no item about the memory use of the image or of PHP; its performance items are for setup checks, file search and share cleanup (server#64609, #64916, #65137,lab/notes/nextcloud-2026-10-17.md, section 3). - Cron at idle: about 34 MiB after its first background run, 0.6 MiB before it. The cron container runs
cron.phpat every 5-minute mark of the clock. Since Oct 18 the lab does not start the idle window until the stack has had one mark since it became healthy and 60 s have passed since that mark (methodology entry). In all three runs the first mark came 126 to 156 s after healthy, cron’s memory rose from 0.6 MiB to 34.3 MiB within 15 s of it, and the idle window started 61 s after the mark. Cron then read 34.3 to 34.4 MiB in all three idle windows, so 297 MiB is where our stack sat after its first background run, and the figure to plan on for a stack that has had one (with the caveats in the next bullets). The Oct 17 figure of 265 MiB was lower because in two of its three runs no mark had come before the idle window (cron at 0.6 MiB), and in the third cron had dropped back to 1.0 MiB after its first run. In the Oct 8 runs a mark had also come first (our reconstruction,lab/notes/nextcloud-2026-10-17.md, section 2), and cron read 34 MiB. - The 34 MiB is anonymous memory, not file cache. Right after each idle window the lab now reads each container’s cgroup
memory.stat, which splits the one numberdocker statsshows. Cron’s was anon 33.2, 33.2 and 33.3 MiB and file cache 0.23 MiB in the three runs, so it is memory that a process in the container holds. We did not record which process, so we cannot say whether it iscron.phpitself or something else. We also cannot say how long it keeps holding it. - Cron sometimes drops to about 1 MiB. It did in run 3 on Oct 17 (while the stack was settling, after a 189 MiB burst) and in run 3 on Oct 18 (inside the upload, 2 s after its highest sample of 167 MiB). In both it had burst first. Across the 7 runs measured after a first cron run (all three on Oct 8, run 3 on Oct 17, all three on Oct 18), 6 read 34 MiB at idle and 1 read 1.0 MiB. We do not know why. Read the idle figure as 297 MiB, or up to about 33 MiB lower if cron drops back.
- Cron bursts in the load step: 4 of 9 runs over the three dates, and the marks do not predict them. On Oct 18 cron reached 147 MiB in run 2 and 167 MiB in run 3 (CPU up to 99% of one core on the lab host); in run 1 it stayed at 34.3 MiB. In both burst runs it began rising about 18 s into the sampling window, from 34 MiB to 38 or 41 MiB, and jumped to about 143 MiB 16 s later in run 2 and 4 s later in run 3.
memory.statat the highest stack sample shows the burst is anonymous memory too (cron anon 143.8 and 165.2 MiB, file cache 6.0 and 8.3 MiB). The burst is inside the peak: run 1, without one, peaked at 402 MiB, and runs 2 and 3, with one, at 452 and 479 MiB. No 5-minute mark fell inside any upload window: each window began 92 or 93 s after a mark and ended 167 to 170 s before the next, and the bursts began 115 and 128 s after the mark. Run 3’s burst began about 23 s into the load step, during the upload (35.4 s long). Run 2’s jump came at the very end of the 35.7 s load step, around when its 32.9 s upload finished, so we cannot say the upload started it. The lab computes the marks from the clock and does not read them from cron, and we did not capture cron’s logs, so we do not know which job ran. The same pattern held on Oct 8 (154 and 182 MiB in two of three runs, no mark inside the window) and not on Oct 17 (none during the upload; one 189 MiB burst while the stack was settling). Five bursts in nine runs (four in the load step, one while settling) is too few to say the version matters. The changelog adds a code integrity background job (server#64974), and we did not check whether it ran. The image sets PHP’s memory limit to 512M (Dockerfile), so 167 MiB is within what onecron.phpprocess may use. Our load used no previews or sharing, so it says nothing about heavier background jobs. - By our sizing rule (the highest reading in any run, 479.0 MiB, which is run 3’s peak under load and includes a 167 MiB cron burst, times 1.5, rounded up to 1, 2, 4, 8, 16 or 32 GB) the stack needs 1 GB; the Oct 17 reading of 449.2 MiB and the Oct 8 reading of 496.6 MiB gave the same tier. That rule is for this one-user load. Our reading, not a measurement: the image’s PHP memory limit is 512 MB per request (the
PHP memory limitline inocc setupchecks), and our load made one request at a time, so several users at once need more than 1 GB. The admin manual says Nextcloud needs at least 128 MB RAM per process and recommends 512 MB (system requirements). - The images are large: the Nextcloud image is 517.3 MB to download and 1,480 MB unpacked on this runner’s overlay2 store (about 2.9 GB of disk for all the images on Docker 29 and later, see the table notes). On the earlier host after a 103-file load the
nextcloudvolume was 1,140 MB, of whichdata/was 302 MB andapps/, which the image ships, was 522 MB (notes, section 36); not re-measured on the runner.
What we did not measure: real clients (a browser, the desktop and mobile sync apps), previews and thumbnails, several users at once, chunked uploads, files over 50 MB, Nextcloud apps beyond the defaults, ARM hosts, and memory growth over days.
Gotchas, upgrade and backup
The manual checks in this section ran on Nextcloud 35.0.1 on our earlier sandbox host (Oct 3, 2026, Docker 29.6.2), on the image digest of that day. The 35.0.1 tag was rebuilt since (same version, new digest), and the verified file now pins 35.0.2. We did not repeat these checks on the Actions runner or on 35.0.2. The Oct 18 runs repeated only the lab’s own install check: status.php reported installed, and /login answered 200 under cloud.example.com and localhost and 400 under an untrusted name (nextcloud.json, setup step).
Trusted domains and the reverse proxy
Nextcloud answers only to host names it trusts. The admin manual’s configuration reference says the list prevents host header poisoning and that you should not remove it. The README says NEXTCLOUD_TRUSTED_DOMAINS is a space-separated list that is added to the configuration after install.
In our run, occ config:system:get trusted_domains printed localhost (added by the installer) and cloud.example.com (from our .env). Then we sent requests with different Host headers to the app’s port:
| Host header | GET /login |
GET /status.php |
|---|---|---|
cloud.example.com |
200 | 200 (from the lab’s setup step, Host cloud.example.com) |
localhost |
200 | 200 (the container health check, which uses this name) |
127.0.0.1:8080 |
200 | 200 (no Host header override) |
192.0.2.10 |
400 | not tried |
evil.example.org |
400 | 400 |
Two practical points from that. Opening Nextcloud by a LAN IP or a name you did not list fails with HTTP 400, so add every name you use. And status.php is not exempt: a monitor that calls it by an untrusted name gets 400. Our own health check calls http://localhost/status.php inside the container, which is trusted.
Not run in our lab: adding a name to an installed instance. We did not test whether changing NEXTCLOUD_TRUSTED_DOMAINS after the install adds the name; the README documents the variable for the install. The occ docs show config:system:set trusted_domains 2 --value=example.com for an installed instance. We ran config:system:set only for other keys.
Behind a reverse proxy over HTTPS. We put Caddy 2.11.4 in front of the stack in a scratch override, with tls internal (its own CA), on the same Compose network. With Nextcloud not told about the proxy, GET / over HTTPS answered 302 with location: http://cloud.example.com/login: the redirect dropped HTTPS. The README and the admin manual’s reverse proxy page name the settings: overwriteprotocol for the protocol, trusted_proxies for the proxy addresses, and the image’s OVERWRITEPROTOCOL and TRUSTED_PROXIES variables set them. We added this override to the app container, with the Compose network’s subnet (from docker network inspect) as the trusted proxy range:
services:
app:
environment:
OVERWRITEPROTOCOL: https
TRUSTED_PROXIES: 172.18.0.0/16After recreating the app container, occ config:system:get printed https and 172.18.0.0/16, and GET / answered 302 with location: https://cloud.example.com/login. When we removed the override and recreated the container again, overwriteprotocol was unset again. The Caddyfile we used, which follows the admin manual’s Caddy example for the CalDAV and CardDAV redirects (our requests for /.well-known/carddav got 301 to /remote.php/dav/):
cloud.example.com {
tls internal
redir /.well-known/carddav /remote.php/dav/ 301
redir /.well-known/caldav /remote.php/dav/ 301
header Strict-Transport-Security "max-age=15552000; includeSubDomains"
reverse_proxy app:80
}What we did not verify, so treat it as unverified: the effect of TRUSTED_PROXIES on the client address Nextcloud logs and on brute-force throttling (occ setupchecks run from the shell cannot see a client address and prints “Your remote address could not be determined”); the admin page’s HTTP-header warning after we added the HSTS header at Caddy (we saw the header in the response headers from curl only); OVERWRITECLIURL, which the README documents for the URL used by command-line tools; and a real domain with Let’s Encrypt. For the Let’s Encrypt side, the Caddy steps are the same as in our Vaultwarden Docker Compose guide, which we did not run either.
Cron and background jobs
Nextcloud’s admin manual says background jobs run by AJAX by default (one job per page visit), and recommends cron. Our cron container is the documented pattern: the same image started with /cron.sh, which runs busybox crond. The crontab in the image, which we read with cat in the cron container, was */5 * * * * php -f /var/www/html/cron.php. The README’s example files say the volumes of the app and cron containers must match, so ours share one volume.
What we saw on a fresh install:
| Moment | backgroundjobs_mode |
lastcron |
occ setupchecks, “Cron last run” |
|---|---|---|---|
| Right after install, before the first tick | not set (empty output) | empty | error: “Last background job execution ran 56 years ago” |
After the first cron.php run |
cron |
set | passes: “ran seconds ago” |
You do not have to switch the mode by hand: the admin manual says that once cron.php runs from the command line or a cron service, it sets the mode to Cron by itself, and we saw that. The occ background:cron command in the occ docs does the same. We did not run it. The first error clears after the first tick, so do not chase it in the first minutes. In our manual run the first tick came at 07:38:03 UTC, about 7 minutes after the install and not on a 5-minute boundary, then at 07:40:02, 07:45:01 and 07:50:01. We do not know why the first run was off the grid (the sandbox clock may have stepped; notes, section 37).
Redis
The README says REDIS_HOST makes the image use Redis for memory caching and for PHP session storage. With it set, our install had these values (all read with occ config:system:get): memcache.local is \OC\Memcache\APCu, memcache.distributed and memcache.locking are \OC\Memcache\Redis, and redis has host redis. The image writes redis-session.ini, which sets the PHP session handler to Redis. The admin manual’s caching page recommends APCu for the local cache and Redis for locking when you have enough memory, which is the single-server layout the manual shows: APCu local, Redis distributed and locking.
Is it used? During our WebDAV load, Redis’s command counter went from 2,182 to 12,298 and its key count from 71 to 183. Of the 183 keys, 152 were PHPREDIS_SESSION:*, PHP sessions. The other keys we listed were cache entries (theming and the user backend map). Redis held 1.98 MiB at its peak in that run.
What the README’s own examples leave out: the README’s base Apache example (MariaDB and a redis container, read Oct 3, 2026) never sets REDIS_HOST on the app, so Nextcloud never connects to that Redis. The .examples Compose files in the same repository do set it. We tested what that omission looks like, in a separate project with REDIS_HOST removed from the app container:
| State | memcache.locking |
occ setupchecks |
Redis keys |
|---|---|---|---|
Installed without REDIS_HOST |
not set | info: “The database is used for transactional file locking. To enhance performance, please configure memcache, if available.” | 0 |
Same instance, REDIS_HOST added, app container recreated |
\OC\Memcache\Redis |
passes (“Transactional File Locking”) | in use; session handler is Redis |
So you can add REDIS_HOST later and it takes effect on the next start. The reverse is not true. On an instance installed with REDIS_HOST, we removed the variable and recreated the container, and memcache.locking still printed \OC\Memcache\Redis, Our reading, which we did not confirm by reading config.php on that instance: the install had written the Redis settings (memcache.distributed, memcache.locking and redis) into config.php, so they no longer depend on the variable. Not tested in our lab: removing those lines.
Not tested in our lab: a Redis password (REDIS_HOST_PASSWORD), Valkey, and what Nextcloud does when the Redis container is down. The admin manual says Redis 4.0.0 or newer is required and that formal testing is against Redis Open Source and Valkey; we ran Redis 8.10.2.
Setup checks that stay
occ setupchecks printed 1 error and 4 warnings on the fresh install, and 58 checks passed at the end. After the three fixes in step 8 and the first cron run, this is what was left on the live stack, and why:
| Check | Status | Why |
|---|---|---|
| Internet connectivity | warning | “This server has no working internet connection: Multiple endpoints could not be reached.” On our earlier sandbox host (Oct 3) the lab reached the internet only through an HTTPS proxy that re-signs TLS (we had to mount an extra CA bundle there), which we think is the cause. We did not read the failed requests. Not tested in our lab: whether the warning appears on a host with direct internet access, because we did not run occ setupchecks on the Actions runner. |
| Errors in the log | warning | 15 errors at the end of our checks. We did not read them; we assume most are the failed connectivity check above, which is our guess, not a finding. |
| HTTP headers | warning | Strict-Transport-Security is not set. That header comes from the proxy (our Caddy test set it). Not checked in the admin page. |
| HTTPS access and URLs | info | Nextcloud says it is generating insecure URLs: this stack has no HTTPS and no overwrite* values. The proxy section above covers it. |
| Brute-force throttle, Forwarded for headers | info | A shell cannot see a client address. |
| AppAPI deploy daemon, second factor, e-mail test, server ID | info | Features we did not configure. |
The three we fixed: the maintenance window, the mimetype migrations (occ maintenance:repair --include-expensive ran in 1.8 s on an empty instance) and the default phone region. After the three commands, the maintenance-window, mimetype-migration and default-phone-region lines no longer appeared in the output.
Update
The file pins the version, so an update is an edit. Back up first (next section). The README says to pull the new image and recreate the container, and that “it is only possible to upgrade one major version at a time”. The admin manual’s upgrade page adds that you first move to the latest point release of your current major version, that you cannot skip major releases, and that downgrading is not supported. Because app and cron use the same tag, one edit and one command recreate both.
Patch update, 35.0.0 to 35.0.1. In a separate project we installed 35.0.0 from the verified file with the tag changed, uploaded 20 small files, changed both image: nextcloud: lines to 35.0.1 with sed, then ran:
docker compose -p nc-upd pull
docker compose -p nc-upd up -d --waitThe second command took 44.6 s. The app log showed Upgrading nextcloud from 35.0.0.10 ..., Updated database and Update successful; occ status printed 35.0.1, occ upgrade printed “No upgrade required.”, and occ files:scan --all found no changes. We saw no “config differs from the latest version of this image” warning, which the README describes for config files copied at install.
Patch update, 35.0.1 to 35.0.2. Not run in our lab. The Oct 17 and Oct 18 runs are fresh installs of 35.0.2, so they say nothing about updating an existing instance. Nextcloud’s changelog dates 35.0.2 to Oct 15, 2026 (35.0.1: Sep 24, 2026; read Oct 17, 2026). It has no breaking-changes or upgrade-notes heading. It lists a deprecation of long, non-lazy app and user config (server#65012) and a database item (“Chore: drop DropFederatedInvitesTable”, server#64689), so read the list before you update. It also lists authentication and sharing fixes, for example for remember-me cookies (server#64649) and reset-link expiry (server#65245). Our expectation, not a test: the steps are the ones above with 35.0.2 in both image: lines. Take a backup first.
Major updates, one at a time. We installed an empty 33.0.9 and pointed the same stack straight at 35.0.1. The app container restarted in a loop, and its log said:
Can't start Nextcloud because upgrading from 33.0.9.1 to 35.0.1.1 is not supported.
It is only possible to upgrade one major version at a time. For example, if you want to upgrade from version 14 to 16, you will have to upgrade from version 14 to 15, then from 15 to 16.Changing the tag to 34.0.4 and running docker compose -p nc-maj up -d --wait upgraded it (66 s), and then 35.0.1 upgraded it again (45 s). The refusal left the install usable: after the tag was corrected, the stack upgraded normally. We tested this on an empty install, so we have no data-migration result for an instance with data. Not run in our lab: the admin manual’s advice to run cron.php two or three times between major upgrades so that background migrations finish.
A floating tag can cross that line. On Oct 3, 2026 the Docker Hub tag latest was 35.0.1, while stable and production still pointed to 34.0.4 (notes, section 38). On Oct 17, 2026 latest was 35.0.2 and stable and production were 34.0.5, by their registry digests (lab/notes/nextcloud-2026-10-17.md, section 4), so the gap is still there. That is why the file pins a version. If you ever move from latest, check the major number first.
The image sets 'upgrade.disable-web' => true in its upgrade-disable-web.config.php (we read the file in the image; the README names it as one of its container-specific config files), so updates happen by replacing the image. We did not test the built-in updater.
Back up and restore
The admin manual’s backup page lists what to keep: the config folder, the data folder, the theme folder, custom apps (only if you have any) and the database, with maintenance mode on while you copy. Its restore page says you need the database, data and config together. For PostgreSQL it shows pg_dump and psql -f.
We backed up the running stack after the WebDAV load. It held 103 loaded files and 5 more we wrote straight into the data folder. Create a backup folder next to the compose file first (mkdir -p backup; it already existed in our scratch directory). Then maintenance mode on, then the dump:
docker compose -p nc-manual exec -T -u www-data app php occ maintenance:mode --on
docker compose -p nc-manual exec -T db pg_dump -U nextcloud nextcloud > backup/nextcloud-sql.bakThe dump took 0.45 s and was 978 KB with 137 tables. Then an archive of the whole nextcloud volume (47 s on the lab host, 591 MB for the 1,140 MB volume; we pulled the throwaway image as mirror.gcr.io/library/alpine:3 because Docker Hub was rate-limiting the host), and maintenance mode off:
docker run --rm -v nc-manual_nextcloud:/data:ro -v "$PWD/backup:/backup" alpine:3 tar czf /backup/nextcloud-html.tar.gz -C /data .
docker compose -p nc-manual exec -T -u www-data app php occ maintenance:mode --offThe dump and the archive are not a complete backup: the roles file described below belongs with them. We archived the whole volume, which includes the 522 MB of apps the image ships, not just config and data. A smaller archive is possible. Not tested in our lab: restoring from only config, data, custom apps and themes.
The restore failed with only that dump. We restored into a separate Compose project, nc-restore, so the live stack was never touched. The -f restore.yaml override only changes the published port, so the copy does not clash with the live stack on 8080:
services:
app:
ports: !override
- "127.0.0.1:8081:80"We created the containers and volumes, unpacked the archive into the new volume, started db, and loaded the dump. The app container then never became healthy. occ printed FATAL: password authentication failed for user "oc_admin". The reason: Nextcloud does not connect as the nextcloud user from our file. On a live stack, occ config:system:get dbuser printed oc_admin, and \du in Postgres listed two roles, nextcloud (superuser) and oc_admin. The installer created oc_admin, we did not. Our admin user is named admin, so the role name probably follows your admin user name (oc_<name>). We have not tested that, so run the same dbuser command on your own stack to see yours. All 137 tables belong to it. pg_dump of one database does not include roles, so the restored database had no oc_admin role for the app to connect as.
The fix we ran: also dump the cluster’s roles with pg_dumpall --globals-only, and load them before the database.
docker compose -p nc-manual exec -T db pg_dumpall -U nextcloud --globals-only > backup/nextcloud-globals.sqlThe roles file was 940 bytes. It holds the roles’ password hashes, so treat it as a secret. The restore, run from the folder with the files, into a clean nc-restore project:
docker compose -p nc-restore -f compose.yaml -f restore.yaml create db redis app
docker run --rm -v nc-restore_nextcloud:/data -v "$PWD/backup:/backup:ro" alpine:3 tar xzf /backup/nextcloud-html.tar.gz -C /data
docker compose -p nc-restore -f compose.yaml -f restore.yaml up -d --wait db
docker compose -p nc-restore -f compose.yaml -f restore.yaml exec -T db psql -U nextcloud -d postgres -q -f - < backup/nextcloud-globals.sql
docker compose -p nc-restore -f compose.yaml -f restore.yaml exec -T db psql -U nextcloud -d nextcloud -q -v ON_ERROR_STOP=1 -f - < backup/nextcloud-sql.bak
docker compose -p nc-restore -f compose.yaml -f restore.yaml up -d --wait db redis app
docker compose -p nc-restore -f compose.yaml -f restore.yaml exec -T -u www-data app php occ maintenance:mode --offThe roles file printed one error, role "nextcloud" already exists, which is harmless: that role is created by the Postgres image. The database dump loaded with ON_ERROR_STOP=1 and no error. Until the last command, status.php on the copy reported "maintenance":true, because we took the archive in maintenance mode and config.php carries that flag. After it, status.php reported installed and maintenance:false.
What the restored copy did, compared with the live stack over WebDAV on both ports:
- The loaded folder listed 104 entries (the folder and 103 files) on both.
- Five files, four from the load and one written directly into the data folder, had the same SHA-256 on both (we compared the first 16 characters of the hash).
- The admin login worked on the copy, and a wrong password got 401.
- The live stack was unchanged: the same four container IDs and creation times, and the same two volumes before and after. Only
nc-restore_*volumes appeared and then disappeared.
We removed only the copy; the -p nc-restore keeps -v away from the live volumes:
docker compose -p nc-restore -f compose.yaml -f restore.yaml down -vNot run in our lab: restoring over an existing install, which is what replacing a lost live stack involves (the restore page says to drop and recreate the database first), and restoring a backup into a newer image version. Your .env holds the passwords, and config.php holds secrets (the README notes that occ config:list system --private prints “passwords and secrets”), so encrypt backups that leave the machine.
The same rule as in our Immich restore test applies: a backup you have not restored into an empty volume is a guess. The Nextcloud case showed why: the dump alone looked complete and was not. To copy the backup folder off the host on a schedule, see how restic, Borg and Kopia compared on speed, RAM and repository size in our restic vs Borg vs Kopia test. That test used a 334 MB generated dataset, not this stack.
Nextcloud AIO vs this compose
Researched, not tested. We did not run Nextcloud AIO, so we have no RAM figure for it and no comparison. AIO’s mastercontainer needs the Docker socket and starts its own containers from a web interface, which the lab’s scripts do not drive. What its README says (read Oct 3, 2026):
- What it is. “The official Nextcloud installation method.” One
mastercontainermanages the rest: Nextcloud, Redis and APCu, PostgreSQL and a proxy, plus optional containers for Office, Talk, Imaginary previews, ClamAV, full-text search and a Borg-based backup you run from the AIO interface. - What it needs. The container runs with
--volume /var/run/docker.sock:/var/run/docker.sock:roand publishes ports 80, 8080 and 8443. You open the AIO interface on port 8080 by IP address, and you enter a domain; the README describes a domain validation step and an environment variable,SKIP_DOMAIN_VALIDATION, to skip it. It says other ports than 443 for Nextcloud are not supported, and that Snap-based Docker installations are not supported. The README lists x86_64 and aarch64 as supported CPU architectures. It also says up to 100 users are free and more need Nextcloud Enterprise. - Reverse proxies. Supported, with its own reverse proxy documentation.
When to choose which, as our opinion based only on those documented differences: choose AIO if you want updates and backups managed from its interface and the optional components (Talk, Office) without wiring them yourself. Choose this compose file if you want a plain Compose project you can read in one screen, no container with Docker socket access, your own proxy and your own backup tool, and numbers we measured. The AIO README also documents a manual install without the Docker socket, which we did not read in detail.
What we did not measure
- Real clients: a browser, the desktop and mobile apps, and the admin page. The lab script speaks WebDAV, which is not a client.
- Previews, several users at once, chunked uploads, files over 50 MB and Nextcloud apps beyond the image’s defaults.
- A real domain, Let’s Encrypt, HSTS as the admin page sees it, and the effect of
TRUSTED_PROXIESon client addresses. - The setup checks on a host with direct internet access. Our setup checks ran on the earlier sandbox host (Oct 3), where the connectivity check failed, we think because of its proxy. The Actions runner has direct internet access, but we did not run
occ setupchecksthere, so we have no result for a host with direct access. - Nextcloud AIO, MariaDB, Valkey, a Redis password, the built-in updater, S3 primary storage and external storage.
- The update from 35.0.1 to 35.0.2 (the Oct 17 and Oct 18 runs are fresh installs), what starts the cron container’s bursts and which process holds its 33 MiB between runs (no cron logs, no process list), and why cron sometimes drops back to 1 MiB.
- ARM hosts, and memory growth over days.
Where to run it
- Hardware. Nextcloud idled at 297 MiB and peaked at 452 MiB under load (medians of 3 runs) in our lab. The highest reading in any run was 479 MiB. Plan for at least 1 GB of RAM, sized from the highest run.
- VPS. The smallest plan that runs this comfortably has 1 GB of RAM, plus disk for your data and the images. Plan for about 2.9 GB for the images (Docker 29 and later with the containerd image store, the default on fresh installs; 1.9 GB with the older overlay2 store).
- Keep it safe. Back up the data volume before you rely on it, and put the app behind a reverse proxy with HTTPS before you expose it to the internet. See backing up self-hosted apps.
How we size: recommended RAM is the highest reading in any of our runs (under load or during startup, not the median) times 1.5, rounded up to the next of 1, 2, 4, 8, 16 or 32 GB. The extra half covers the operating system, Docker, file cache and spikes our sampling can miss. Your own data and extra apps need more. Disk for images: Docker Engine 29 and later uses the containerd image store on fresh installs, which keeps each image twice (as downloaded and unpacked); older installs use overlay2, which keeps it unpacked only. We plan for the larger figure: unpacked plus compressed size, plus 10%, rounded up. Your data volumes and growth come on top.
Related
- Comparing file-sync servers: Nextcloud vs ownCloud vs Seafile sets this stack’s measured RAM beside Seafile 13.0.28 and ownCloud Infinite Scale 8.2.1, with the host and API differences stated, and says which ownCloud product we tested.
- Replacing Google Drive: Self-hosted alternative to Google Drive puts this stack’s idle and peak RAM beside five other apps, including Syncthing for device-to-device sync with no server, and says what each replaces from Drive.
- Photos or files: this guide runs Nextcloud as a file server (WebDAV and the files folder). If your goal is a photo and video library with machine-learning search, see the Immich Docker Compose guide and its RAM numbers. We did not compare the two apps’ features, and we did not test Nextcloud’s own photo features.
- HTTPS in front of a Compose stack with Caddy, as in our proxy test above: the Vaultwarden Docker Compose guide.
- A media library rather than files: the Jellyfin Docker Compose guide, another single-host stack with a backup restored into a separate Compose project.
- Home automation on the same box: the Home Assistant Docker Compose guide, which needs host networking instead of a published port and has its own backup, restore and update tests.
- How the lab measures RAM, CPU and image sizes: methodology. Other apps in the same format: the self-hosted apps index.
- Official sources read Oct 17, 2026: Nextcloud’s changelog (the 35.0.2 section) and the Docker Hub nextcloud tags, checked through the registry API.
- Official sources used on this page, read Oct 3, 2026: the nextcloud/docker README and its Compose examples, the Nextcloud 35 admin manual pages on system requirements, reverse proxies, background jobs, caching,
occ, upgrading, backup and restore, and the Nextcloud All-in-One README.
Update log
- Linked the new restic vs Borg vs Kopia page from the backup section (a measured comparison of three backup tools on one generated dataset). No measurements changed.
- Re-measured on Nextcloud 35.0.2 with a changed idle rule: the lab now waits for the cron container's first background run before it measures idle (methodology page, 2026-10-18 entry). Median of 3 runs, same runner type, CPU model and image digest as Oct 17. Idle 297 MiB (range 295 to 301; was 265 on Oct 17, 299 on Oct 8 on 35.0.1), peak 452 MiB (range 402 to 479; was 367 on Oct 17 and 454 on Oct 8), start-up 26.8 s (unchanged), 676.4 MB of images to download (unchanged). The Oct 17 idle figure was taken before cron's first run in two of its three runs. In all three new runs cron held 34.3 to 34.4 MiB after its first run, and cgroup memory.stat shows that is anonymous memory (33.2 to 33.3 MiB), not file cache (0.23 MiB). app, db and redis idled at 262.5 MiB together (was 264.2). Two of three upload windows had a cron burst of 147 and 167 MiB although no 5-minute mark fell inside them; we still do not know what starts the bursts, or why cron sometimes drops back to 1 MiB. Highest reading used for sizing 479 MiB (was 449), still 1 GB. The manual install, proxy, update and restore steps are still from Oct 3 on 35.0.1.
- Re-tested on Nextcloud 35.0.2 (was 35.0.1; released Oct 15, 2026), median of 3 runs on the same runner type and CPU model as Oct 8. Idle 265 MiB (range 253 to 266; was 299), peak 367 MiB (range 362 to 367; was 454), start-up 26.8 s (unchanged), 676.4 MB of images to download (was 674.7). The change is in the cron container, not the app: app, db and redis idled at 264.2 MiB together (was 264.7) and their own peaks add up to 367.5 MiB (was 365.9). Cron idled at 0.6 to 1.0 MiB (was 34) and no run had a cron burst during the upload; one run had a 189 MiB cron burst while the stack was settling, with no upload running, 30 to 45 s after a 5-minute mark. Highest reading used for sizing 449 MiB (was 497), still 1 GB. The 35.0.1 to 35.0.2 update was not run, and the manual install, proxy, update and restore steps are still from Oct 3 on 35.0.1.
- Linked the new Google Drive alternatives page from Related. It sets this stack's Oct 8 figures beside Seafile, ownCloud Infinite Scale, Syncthing and File Browser. No measurements changed.
- Linked the new Nextcloud vs ownCloud vs Seafile page from Related. It compares this stack's Oct 8 figures with Seafile and ownCloud Infinite Scale, which ran on Oct 16 in separate jobs. No measurements changed.
- Explained the cron container's memory from the existing runs (no new measurements): it rises from 0.6 to 34 MiB at its first 5-minute `cron.php` run, and the 154 and 182 MiB bursts are already inside the stack peak. We still do not know what started the bursts, because no 5-minute mark fell in the upload windows.
- Disk sizing now covers both Docker image stores: plan for about 2.9 GB for the images (unpacked plus compressed size plus 10%, for Docker 29 and later with the containerd store), 1.9 GB on overlay2; was 1.9 GB only. Reworded the proxy remarks: the Actions runner has direct internet access, and we did not run the setup checks there. No new measurements.
- Re-tested on our new standard lab host (GitHub Actions runner, 2 vCPU / 8 GB), median of 3 runs, same Nextcloud 35.0.1, PostgreSQL 18.6 and Redis 8.10.2. Idle 299 MiB (range 298 to 301; was 343), peak 454 MiB (range 398 to 497; was 446), start-up 26.8 s (was 42.9). The Docker Hub tag 35.0.1 was rebuilt again (new image digest, same Nextcloud 35.0.1, still 515.6 MB to download), so the verified file's digest changed; the manual tests further down ran on the Oct 3 digest. The cron container now idled at 34 MiB and reached 182 MiB in one run (it stayed under 1.1 MiB on the earlier host); we did not find out why. The manual install, proxy, update and restore steps are from the earlier sandbox host and were not repeated.
- Linked the new Home Assistant Docker Compose page from Related. No measurements changed.
- Results re-measured as the median of 3 runs (methodology change 2026-10-06): idle 343 MiB (range 332 to 360), peak 446 MiB (range 431 to 458), startup 42.9 s, settled 125 s after healthy. Before: 333 and 435 MiB from one run on a different lab host (2.80GHz CPU model; the new runs were on a 2.10GHz one). The Docker Hub tag 35.0.1 was rebuilt between the two dates (same Nextcloud 35.0.1, new image digest, 515.6 MB to download instead of 515.1), so the verified file's digest and the image totals changed; the manual tests further down ran on the Oct 3 digest.
- Linked the new Jellyfin Docker Compose page from Related (a media server, with its own restore test). No measurements changed.
- Fixed the image totals in the Test box and the Where to run it module, which counted the Nextcloud image twice (it showed 1,189 MB to download and 4,834 MB on disk). The correct totals are 674.2 MB compressed and 2,714 MB unpacked, as in the table.
- First published. Nextcloud 35.0.1 with PostgreSQL 18.6, Redis 8.10.2 and a cron container verified in the lab. Setup checks, trusted domains, Redis, cron, a proxy test, a backup restored into a separate Compose project, and patch and major updates were run the same day. Nextcloud AIO is researched, not run. The published .env carries no passwords.