how to run Nextcloud for VPS with Docker
Learn how to setup Nextcloud for VPS using Docker Compose, Postgres, and Redis. We cover TLS with nginx and how to backup your data so nothing go lost.
Wetin you dey build
Dis guide dey run Nextcloud for VPS using Docker Compose. E dey put Let's Encrypt TLS for front, and e dey set up backup wey go work when you wan restore. You go get four containers and one proxy: di official nextcloud image wey dey listen for loopback, Postgres wey go hold all file metadata, Redis wey go hold file locks, another Nextcloud image wey dey run only di cron loop, and nginx for host wey dey handle TLS for front of everything. Di installation go take twenty minutes, but dat one no be di important part. Two decisions wey you must make for first hour go determine if you go still get your files for one year: use real database instead of SQLite, and use backup wey go capture di data directory, di database, and config.php as one single set.
Dis assume say you dey use Ubuntu 24.04 LTS or Debian 13, Docker Engine wey get Compose v2 plugin wey you download from Docker repository, and DNS A record (plus AAAA if you get IPv6) wey don already point cloud.example.com to di VPS. You need server wey you control — you no fit do TLS termination and database dump for somebody else SaaS.
Sizing: wetin dey chop memory
Three main things dey consume Nextcloud memory, and none of dem be "Nextcloud" itself.
PHP workers. -apache image dey use worker process serve every concurrent request, and that process dey hold PHP interpreter. PHP fit allow worker grow up to PHP_MEMORY_LIMIT before e kill the request. Your worst-case resident memory na roughly concurrent requests × the memory limit, and desktop sync client dey open many parallel connections for every user. Concurrency, no be user count, na wetin dey set the ceiling.
The database. Postgres dey fork one backend for every connection and e dey keep shared buffers resident. Its working set dey scale with the number of files, no be the number of bytes: oc_filecache dey carry one row for every file for every user. One hundred thousand small files go heavy pass one hundred large files for database.
Preview generation. To generate thumbnail, system dey decode the source image into memory for full resolution. Video previews dey use ffmpeg. When you run occ preview:generate-all, e dey cause that spike repeatedly, back to back, and na the main way small VPS dey enter OOM killer.
Redis cheap when you compare am with others. Anything wey you go add later — Collabora, full-text search, or antivirus scanner — na separate resident service with its own footprint, and you must include dem for your sizing plan before you enable dem.
If RAM tight for you, use these levers: lower PHP_MEMORY_LIMIT, cap preview_max_x / preview_max_y / preview_max_filesize_image, trim enabledPreviewProviders to the formats wey you dey browse, and set trashbin_retention_obligation and versions_retention_obligation make the data directory no go grow pass the size of your files without you knowing. Add swap file. Swap slow, and OOM kill mid-upgrade go worse pass.
Why SQLite dey break
Nextcloud come with SQLite support and the official image go use am easy. No do that thing. SQLite dey lock the whole database when e dey write: only one writer fit work at a time for the whole file. Nextcloud dey write many things constantly — file locks, activity rows, cache entries, and job state — and one single desktop client wey dey sync directory tree go send many parallel requests. Because of how dem dey work, you go see SQLSTATE[HY000]: General error: 5 database is locked and HTTP 500s, and the problem go start exactly when the instance start to work well.
You fit convert later with occ db:convert-type, but na long migration wey must finish completely for live dataset. Start with Postgres or MariaDB.
The Compose file
Put this inside /srv/nextcloud/compose.yaml, and put your secrets for another .env file wey dey beside am for mode 600.
services:
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- db:/var/lib/postgresql/data
environment:
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: ${DB_PASSWORD}
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
app:
image: nextcloud:31-apache
restart: unless-stopped
depends_on: [db, redis]
ports:
- "127.0.0.1:8080:80"
volumes:
- html:/var/www/html
- /srv/nextcloud/data:/var/www/html/data
environment:
POSTGRES_HOST: db
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: ${DB_PASSWORD}
REDIS_HOST: redis
REDIS_HOST_PASSWORD: ${REDIS_PASSWORD}
NEXTCLOUD_ADMIN_USER: admin
NEXTCLOUD_ADMIN_PASSWORD: ${ADMIN_PASSWORD}
NEXTCLOUD_TRUSTED_DOMAINS: cloud.example.com
TRUSTED_PROXIES: 172.16.0.0/12
OVERWRITEPROTOCOL: https
OVERWRITECLIURL: https://cloud.example.com
APACHE_DISABLE_REWRITE_IP: "1"
PHP_MEMORY_LIMIT: 512M
PHP_UPLOAD_LIMIT: 10G
cron:
image: nextcloud:31-apache
restart: unless-stopped
entrypoint: /cron.sh
depends_on: [db, redis]
volumes:
- html:/var/www/html
- /srv/nextcloud/data:/var/www/html/data
volumes:
db:
html:Make sure say you pin the major tag, and check wetin current tag for Docker Hub before you copy 31 exactly as e dey. latest fit carry you go different major version when some future docker compose pull come, and Nextcloud no dey support that kind thing.
Dem design the data directory as bind mount instead of named volume by choice: e better to get path wey backup tool fit point to directly than to follow tidiness. Create the directory with the image's www-data UID and the permissions wey Nextcloud need:
sudo mkdir -p /srv/nextcloud/data
sudo chown -R 33:33 /srv/nextcloud/data
sudo chmod 0770 /srv/nextcloud/dataWatch the port publish: 127.0.0.1:8080:80. Docker dey publish ports by write DNAT rules wey go work before ufw's INPUT chain even see the packet — if you use bare 8080:80, you go put unencrypted Nextcloud for public internet no matter wetin ufw talk. If you bind am to loopback, e go stay safe from public interface. After that, firewall only need to allow the proxy — and if you no want leave SSH open for everybody for internet, wey you fit reach the VPS via self-hosted WireGuard VPN go help you remove port 22 from public rules altogether:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableRun am with docker compose up -d, then watch docker compose logs -f app. First time wey e boot, e go copy the whole application tree inside the volume and run the installer; the container no go answer anything until that work finish.
TLS and the reverse proxy
Install nginx and certbot from your distro. Create one plain port-80 server block with the correct server_name, then make certbot rewrite am. The way HTTP-01 challenge work, the renewal timer, and why e dey fail, dem explain am well for issuing Let's Encrypt certificates with certbot and nginx on Ubuntu 24.04:
sudo apt install nginx certbot python3-certbot-nginx
sudo certbot --nginx -d cloud.example.comCertbot go add the ssl_certificate lines and the :80 → :443 redirect. E go also install systemd timer wey go renew the 90-day certificate. Use systemctl list-timers | grep certbot check if e dey there — if you no enable renewal timer, you dey carry 90-day fuse for head.
The proxy block itself:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name cloud.example.com;
# certbot manages ssl_certificate / ssl_certificate_key here
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
client_max_body_size 10G;
client_body_timeout 300s;
location = /.well-known/carddav { return 301 /remote.php/dav; }
location = /.well-known/caldav { return 301 /remote.php/dav; }
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}If you dey use nginx 1.25 or newer, add http2 on;. Ubuntu 24.04 dey use older build wey dey use listen 443 ssl http2; for that one. nginx -t go tell you which one your build dey accept.
client_max_body_size and the long read timeouts na wetin dey stop large uploads from die halfway. proxy_request_buffering off dey stream the upload directly instead of to save the whole file for proxy disk first.
nginx for host na the simplest way to work if you get only one app. If Nextcloud go share VPS with other containers, running Traefik as a Docker Compose reverse proxy for multiple apps dey move routing and certificate issuance inside container labels. The same client_max_body_size and timeout wahala go still dey there as middleware and transport settings.
trusted_proxies and overwriteprotocol
Na for here most self-hosted Nextcloud instances dey get wahala, and the symptoms no go look like say dem dey connect to the real cause.
Nextcloud go only honor X-Forwarded-Proto: https if the request come from one address wey dey inside trusted_proxies. If e no honor am, Nextcloud go think say the request na plain HTTP and e go start to dey give http:// URLs; the proxy go redirect those ones to HTTPS; the browser go follow; Nextcloud go give http:// again. Na dat one be the redirect loop. OVERWRITEPROTOCOL: https go fix the scheme regardless.
The trap for TRUSTED_PROXIES na say the address wey Nextcloud dey see no be 127.0.0.1. nginx dey run for host and e dey connect to one published port, so the container dey see the Docker bridge gateway — something wey dey 172.x. Find the real subnet:
docker network inspect nextcloud_default \
-f '{{range .IPAM.Config}}{{.Subnet}}{{end}}'Put that CIDR (or the covering 172.16.0.0/12) inside TRUSTED_PROXIES. If you set am too wide, any client fit spoof X-Forwarded-For; if you set am wrong, every login go look like say e dey come from the gateway address, brute-force protection go block your whole instance at once, and the admin overview go show "The reverse proxy header configuration is incorrect, or you are accessing Nextcloud from a trusted proxy."
OVERWRITECLIURL dey matter for the cron container, because e no get incoming request wey e fit use take know hostname. Without am, background jobs go dey generate links to localhost and email notifications go send URLs wey no go work.
Background jobs: cron, no be AJAX
Nextcloud default job runner na AJAX: jobs dey run because person load page. Nobody dey browse at 04:00, so trash expiry, versions cleanup, previews, and federated retries go jam. The first sign na say your data directory go dey grow without stop. The cron service wey we talk above dey run official /cron.sh loop for the same volumes. Tell Nextcloud make e expect am:
docker compose exec -u www-data app php occ background:cronEvery occ command follow that shape: docker compose exec -u www-data app php occ <command>. E good make you alias am.
Backups: three things, or none
If you backup only filesystem, restore go fail for broken instance. Data directory hold all the bytes; Postgres hold file cache, shares, users, and app state; config.php hold database credentials, instance ID, and password salt. If you restore files without the database, Nextcloud no go see dem. If you restore database without config.php, e no go fit open the database. If you restore old database for new data directory, shares go point to files wey don move.
Backup all three, and make sure say instance dey quiesced:
#!/usr/bin/env bash
set -euo pipefail
cd /srv/nextcloud
DEST="/var/backups/nextcloud/$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$DEST"
occ() { docker compose exec -T -u www-data app php occ "$@"; }
occ maintenance:mode --on
trap 'occ maintenance:mode --off' EXIT
docker compose exec -T db \
pg_dump -U nextcloud --clean --if-exists nextcloud | gzip > "$DEST/db.sql.gz"
docker compose exec -T app \
tar -C /var/www/html -cf - config custom_apps themes > "$DEST/app.tar"
rsync -a --delete /srv/nextcloud/data/ /var/backups/nextcloud/data/Maintenance mode na him dey make dump and file copy match. If you skip am, you go eventually capture database wey point to file wey rsync never reach yet. Note say script dey keep database dumps with timestamp, but e only keep one rolling mirror for data directory — rsync --delete dey overwrite am every time — so only the newest dump go match with the file copy.
After that, move am off the box. Backup wey dey stay for same VPS as the thing wey e dey backup na copy, no be backup. Use restic against object storage or second host; na him be the standard way, and e deduplication dey handle data directory better than nightly tarball. Full setup, from repository init to nightly timer and restore drill, dey for off-box VPS backups with restic.
Restore no be just to do the reverse. New stack wey just start go run installer and write brand-new config.php — new instance ID and password salt — and if you import dump for top of that new identity, session and share tokens go break. Put the old identity back first, follow this order:
docker compose up -d && docker compose stop app cron # create the volumes, then halt the app
sudo rsync -a --delete /var/backups/nextcloud/data/ /srv/nextcloud/data/
docker compose run --rm -T --entrypoint "" app \
tar -C /var/www/html -xf - < app.tar # the original config.php returns
gunzip -c db.sql.gz | docker compose exec -T db psql -U nextcloud -d nextcloud
docker compose start app cron
docker compose exec -T -u www-data app php occ maintenance:mode --off
docker compose exec -T -u www-data app php occ files:scan --allfiles:scan dey fix file cache make e match wetin dey for disk. Practice this one once for spare VPS before you really need am.
Upgrades: one major at a time
Nextcloud support upgrade for only one major version at a time. If you try to jump from 29 to 31, e no go work well — e go fail with Exception: Updates between multiple major versions and downgrades are unsupported. and leave your system for maintenance mode.
Docker upgrade process na: take backup, change tag from 31 to 32 for both app and cron services, then run docker compose pull && docker compose up -d, then run docker compose logs -f app. The image entrypoint go check the new code against the old data and run occ upgrade by itself. No disturb am while e dey work. When the logs stop moving, run docker compose exec -u www-data app php occ status and check versionstring and make sure say apps don come back online.
Two rules wey go save you: upgrade one major version, check everything, then upgrade the next one. And no ever change tag for app service without change cron make e match — if you use two different Nextcloud versions for one database, you go corrupt your data.
The errors wey you go see
"Your data directory is readable by other users. Please change the permissions to 0770." The bind-mounted directory get group or world read bits. sudo chmod 0770 /srv/nextcloud/data and sudo chown -R 33:33 /srv/nextcloud/data.
"Your data directory is invalid. Ensure there is a file called .ocdata in the root." The bind mount point to place wey Nextcloud never initialise — maybe typo for the path, or person swap empty directory under working instance. Check if host path match the volume line.
"Access through untrusted domain." The hostname for the request no dey inside trusted_domains. NEXTCLOUD_TRUSTED_DOMAINS na only for first install; after that, set am live: occ config:system:set trusted_domains 1 --value=cloud.example.com.
502 Bad Gateway, with connect() failed (111: Connection refused) while connecting to upstream for /var/log/nginx/error.log. nginx no reach anything for 127.0.0.1:8080. Either the container still dey initialise (check docker compose logs app), e don exit (docker compose ps), or the publish line no match the proxy_pass port. Confirm am with ss -ltnp | grep 8080.
A redirect loop, or "insecure" warnings for admin overview. OVERWRITEPROTOCOL: https missing, or TRUSTED_PROXIES no get Docker gateway subnet inside. See the proxy section wey dey above.
LockedException: "files/..." is locked. If you set REDIS_HOST, the image go use Redis as the locking backend and stale locks no go dey many. Without am, locks go dey inside oc_file_locks database table and if request die mid-write, e go leave rows behind. Confirm say Redis dey work — occ config:system:get memcache.locking for return the Redis class — before you start to clear lock rows manually.
"The PHP memory limit is below the recommended value of 512MB." Increase PHP_MEMORY_LIMIT and recreate the container. Remember wetin that one go do to your worst-case ceiling.
Wetin dey break when scale big
The first wall na when data directory pass the size of the volume. To grow volume for VPS, you must resize am plus grow filesystem. E dey better make you do am before e reach 100% full — set alert for disk usage now, no wait till later.
The second wall na oc_filecache. File listings and sync scans dey slow down as row count dey increase. The fix na to do database work: keep Postgres for fast storage, give am enough shared memory, and use retention settings to prune trash and versions so dem no go accumulate forever.
The third one na preview generation wey dey fight with everything else. For small box, keep preview providers small and no ever run occ preview:generate-all during working hours.
Beyond that, the real answer na say extra services need their own machine. Collabora and full-text search na separate resident services wey get their own memory profiles. If you put dem for the same box wey hold your only copy of files, you dey make failure domain bigger for nothing. Move file storage to S3-compatible primary storage when the volume no dey fit the right shape again — but note say this one dey make backups harder, no be easier: the database still hold metadata, and you must dump am at the same time as the bucket.
Once the instance dey serve real users, put Uptime Kuma for front so you go hear say downtime don start before the sync clients do. Private cloud dey work well with your own mail server, and if you no want wire services together by hand, Cloudron, CasaOS and Coolify na the platforms wey dey do am for you.
FAQ
I fit run Nextcloud for SQLite instead of Postgres?
You fit do am, and the official image go allow you, but if one desktop sync client start to send many requests at once, e go hit SQLSTATE[HY000]: General error: 5 database is locked and HTTP 500s. SQLite dey lock the whole database for write operations, and Nextcloud dey write many things constantly — file locks, activity rows, and job state. Start with Postgres or MariaDB; occ db:convert-type dey exist, but the migration for live data long and e no dey easy.
How much RAM Nextcloud VPS really need?
Size am based on how many requests dey happen at once, no be based on number of users. For worst-case scenario, resident memory go be roughly the number of concurrent requests multiplied by PHP_MEMORY_LIMIT, plus Postgres shared buffers and one backend per connection, plus whatever amount preview generation dey take. One 2 GB box fit run small household instance if you limit previews and add swap; if you add Collabora or full-text search, you dey size another set of resident services.
Why big uploads dey fail for behind nginx reverse proxy?
Two settings for proxy dey usually cause this: client_max_body_size dey at 1 MB default wey dey cut the request, and short proxy_read_timeout / proxy_send_timeout values dey kill long transfers midway. Set both of dem generously, turn proxy_request_buffering off to stream instead of spool, and increase PHP_UPLOAD_LIMIT for the app container make e match.
Why Nextcloud dey redirect in a loop or dey warn about reverse proxy?
The container no dey see nginx for 127.0.0.1 — e dey see the Docker bridge gateway, somewhere for 172.x. When that address no dey for TRUSTED_PROXIES, the X-Forwarded-Proto: https header go dey ignored, Nextcloud go dey produce http:// URLs, and the proxy go dey bounce dem back. Set TRUSTED_PROXIES to the real bridge subnet and pin OVERWRITEPROTOCOL: https.
I fit upgrade Nextcloud from 29 straight to 31?
No. Nextcloud only support one major version for every upgrade, and if you skip, e go stop for Updates between multiple major versions and downgrades are unsupported., and e go leave the instance for maintenance mode. Back up everything, change the tag one major version for both app and cron services, docker compose pull && docker compose up -d, check am with occ status, then repeat the process.