How to Run Nextcloud for VPS with Docker and TLS
Run Nextcloud for your VPS with Docker Compose, Postgres, Redis and nginx TLS. Learn backup and upgrade steps, plus how to restore your files and database.
Wetin you dey actually build
This guide dey run Nextcloud for VPS with Docker Compose, put Let's Encrypt TLS for front, and set backup wey fit actually restore. Four containers and one proxy: the official nextcloud image wey dey listen for loopback, Postgres wey dey hold every file metadata, Redis wey dey hold file locks, another copy of the Nextcloud image wey dey run only the cron loop, and nginx for the host wey dey terminate TLS for front of everything. The installation itself go take twenty minutes, but na no be the important part. Two decisions wey you make for the first hour go determine whether your files still dey with you after one year: use real database instead of SQLite, and use backup wey capture the data directory, the database, and config.php as one consistent set.
This guide assume Ubuntu 24.04 LTS or Debian 13, Docker Engine with Compose v2 plugin installed from Docker own repository, and DNS A record (plus AAAA if you get IPv6) wey already dey point cloud.example.com to the VPS. You need server wey you control for all this; you no fit do TLS termination and database dump on another person SaaS.
Sizing: wetin dey actually use the memory
Three things dey dominate Nextcloud memory use, and none of dem be “Nextcloud” by itself.
PHP workers. The -apache image dey serve each request wey happen at the same time from one worker process wey hold PHP interpreter. Each worker fit grow reach PHP_MEMORY_LIMIT before PHP kill the request. Worst-case resident memory na roughly number of concurrent requests × memory limit, and desktop sync client dey open several parallel connections for each user. Na concurrency, no be user count, dey set the limit.
The database. Postgres dey fork one backend for each connection and keep shared buffers resident. Its working set dey scale with the number of files, no be the number of bytes: oc_filecache get one row for each file for each user. One hundred thousand small files na heavier database than one hundred large files.
Preview generation. When system dey generate thumbnail, e dey decode the source image into memory for full resolution. Video previews dey call ffmpeg through the shell. Running occ preview:generate-all dey cause that memory spike repeatedly, one after another, and na the most common way to push small VPS enter OOM killer.
Redis comparatively cheap. Anything wey you add later, like Collabora, full-text search, or antivirus scanner, na separate resident service with its own memory footprint. You need include dem for your sizing plan before you enable dem.
If RAM tight, these na the controls wey you fit use: reduce PHP_MEMORY_LIMIT, set limits for preview_max_x / preview_max_y / preview_max_filesize_image, reduce enabledPreviewProviders to only the formats wey you really dey browse, and set trashbin_retention_obligation and versions_retention_obligation so the data directory no go silently grow reach several times the size of your files. Add swap file. Swap slow, and OOM kill during upgrade worse.
Why SQLite no dey work
Nextcloud get SQLite support, and the official image go gladly use am. No use am. SQLite dey serialise writes with one database-wide lock, so na one writer fit write at a time for the whole file. Nextcloud dey write constantly: file locks, activity rows, cache entries, and job state. Even one desktop client wey dey sync directory tree fit send plenty requests at the same time. With this pattern, you go get SQLSTATE[HY000]: General error: 5 database is locked and HTTP 500s, and the failure go happen exactly when the instance start to become useful.
You fit convert am later with occ db:convert-type, but na long, all-or-nothing migration for live dataset. Start with Postgres or MariaDB.
Compose file
Put dis one for /srv/nextcloud/compose.yaml, and keep the secrets for one sibling .env file wey get 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:Pin the major tag and check the current one for Docker Hub before you copy 31 exactly as e be. latest go carry you cross one major boundary for some future docker compose pull, and Nextcloud no support that kind upgrade.
The data directory na bind mount, no be named volume, and na intentional: path wey you fit point backup tool to directly dey more useful than tidiness. Create am with the image's www-data UID and the permissions wey Nextcloud require:
sudo mkdir -p /srv/nextcloud/data
sudo chown -R 33:33 /srv/nextcloud/data
sudo chmod 0770 /srv/nextcloud/dataNotice the port publish: 127.0.0.1:8080:80. Docker publishes ports by writing DNAT rules wey dem evaluate before ufw's INPUT chain ever see the packet. A plain 8080:80 go put unencrypted Nextcloud for public internet, no matter wetin ufw talk. Binding am to loopback keep am off the public interface. Then firewall only need allow the proxy. If you no wan leave SSH open to the whole internet, reach the VPS through self-hosted WireGuard VPN let you remove port 22 from the public rules completely:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableBring am up with docker compose up -d, then monitor docker compose logs -f app. For first boot, e go copy the whole application tree enter the volume and run the installer. The container no go answer anything until that process finish.
TLS and reverse proxy
Install nginx and certbot from your distro. Create a plain port-80 server block with the correct server_name. Then allow certbot rewrite am.
The HTTP-01 challenge, renewal timer, and failure modes dey explained fully for how to issue 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 a systemd timer wey renew the 90-day certificate. Confirm say e dey exist with systemctl list-timers | grep certbot. Renewal timer wey nobody enable na 90-day fuse.
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;
}
}For nginx 1.25 and newer, add http2 on;. Ubuntu 24.04 ships an older build where the equivalent na listen 443 ssl http2;. nginx -t go tell you which one your build accepts.
client_max_body_size and the long read timeouts dey prevent large uploads from dying halfway. proxy_request_buffering off streams the upload through instead of spooling the whole file to the proxy disk first.
nginx for the host na the simplest option wey works for one app. If Nextcloud go share the VPS with other containers, how to run Traefik as a Docker Compose reverse proxy for multiple apps go move routing and certificate issuance into container labels. The same client_max_body_size and timeout concerns go show again there as middleware and transport settings.
trusted_proxies and overwriteprotocol
Na here most self-hosted Nextcloud instances dey get problem, and the symptoms no dey look related to the real cause.
X-Forwarded-Proto: https only dey work when the request come from an address wey dey listed inside trusted_proxies. When e no work, Nextcloud dey believe say the request na plain HTTP and dey generate http:// URLs; the proxy redirect dem go HTTPS; the browser follow the redirect; Nextcloud generate http:// again. Na this be the redirect loop. OVERWRITEPROTOCOL: https force the scheme no matter wetin happen.
The trap for TRUSTED_PROXIES be say the address wey Nextcloud dey see no be 127.0.0.1. nginx dey run for the host and dey connect to a published port, so the container dey see the Docker bridge gateway, something for 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 make am too wide, any client fit spoof X-Forwarded-For; if you set am wrong, every login go look like say e come from the gateway address, brute-force protection go block the 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 important for the cron container, because e no get incoming request wey fit show the hostname. Without am, background jobs go generate links to localhost and email notifications go send URLs wey no useful.
Background jobs: cron, AJAX no
Nextcloud default job runner na AJAX: jobs dey execute as side effect when person load page. Nobody dey browse for 04:00, so trash expiry, versions cleanup, previews, and federated retries go stall. First sign na data directory wey no dey ever stop growing. The cron service wey dey above dey run official /cron.sh loop against the same volumes. Tell Nextcloud make e expect am:
docker compose exec -u www-data app php occ background:cronEvery occ command dey follow that shape: docker compose exec -u www-data app php occ <command>. E make sense to create alias for am.
Backups: three things, or none
Backup wey only cover filesystem go restore to instance wey don spoil. Data directory hold the bytes; Postgres hold file cache, shares, users, and app state; config.php hold database credentials, instance ID and password salt. If you restore the files without the database, Nextcloud no fit see dem. If you restore the database without config.php, e no fit open the database. If you restore old database against newer data directory, you go get shares wey still point to files wey don move.
Back up all three from instance wey you don put for maintenance mode:
#!/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 wetin make the dump and file copy match each other. If you skip am, one day you go capture database wey dey reference file wey rsync never reach. Note say the script dey keep database dumps with timestamp, but e only keep one rolling mirror of the data directory. rsync --delete dey overwrite am for every run, so na only the newest dump go match the file copy.
Then move the backup comot from the box. Backup wey dey the same VPS with the thing wey e dey back up na copy, e no be backup. restic to object storage or second host na the usual answer, and the deduplication dey handle data directory much better than nightly tarball. The full setup, from repository init to nightly timer and restore drill, dey off-box VPS backups with restic.
Restore no be simply to reverse the steps. Freshly-started stack go run installer and write brand-new config.php, new instance ID and password salt. If you import the dump on top of this new identity, broken sessions and share tokens go remain. Put the old identity back first, for 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 reconcile file cache with wetin really dey for disk. Rehearse this once for spare VPS before you need am. This same split between bytes for disk and metadata for Postgres dey control every other app wey get this kind setup. Na why Immich backup wey capture library but no capture database go restore to empty timeline.
Upgrade: do one major version at a time
Nextcloud support upgrade of exactly one major version at a time. If you jump from 29 to 31, e no go fail cleanly. E go fail with Exception: Updates between multiple major versions and downgrades are unsupported. and leave you for maintenance mode.
The Docker upgrade na this: take backup, edit the tag from 31 to 32 for both app and cron services, then run docker compose pull && docker compose up -d, then docker compose logs -f app. The image entrypoint go detect the newer code against the existing data and run occ upgrade by itself. No interrupt am. When the logs quiet down, run docker compose exec -u www-data app php occ status. Check versionstring and confirm say the apps don come back enabled.
Two rules fit save you trouble: increase one major version, verify am, then increase the next one. Also, no ever edit the tag for app service without editing cron to match. If two different Nextcloud versions use one database, e fit corrupt the database.
Errors wey you go actually 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 dey point to somewhere wey Nextcloud never initialise, typo dey the path, or fresh empty directory replace working instance. Check say the host path match the volume line.
"Access through untrusted domain." The hostname for the request no dey inside trusted_domains. NEXTCLOUD_TRUSTED_DOMAINS only apply during 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 inside /var/log/nginx/error.log. nginx reach nothing 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.
Redirect loop, or "insecure" warnings for the admin overview. OVERWRITEPROTOCOL: https dey missing, or TRUSTED_PROXIES no contain the Docker gateway subnet. See the proxy section above.
LockedException: "files/..." is locked. When REDIS_HOST dey set, the image configure Redis as the locking backend and stale locks dey rare. Without am, locks dey live inside database table oc_file_locks and request wey die during write fit leave rows behind. Confirm say Redis dey actually in use; occ config:system:get memcache.locking suppose return the Redis class before you start clearing lock rows by hand.
"The PHP memory limit is below the recommended value of 512MB." Increase PHP_MEMORY_LIMIT and recreate the container. Remember wetin this one do to your worst-case ceiling.
Wetin dey break as scale dey increase
The first wall na when data directory pass the volume capacity. To grow volume for VPS, you need resize plus filesystem grow. E less painful to schedule am before disk reach 100% full. Set disk usage alert now, no be later.
The second wall na oc_filecache. File listings and sync scans dey slow as row count increase. The fix na database work: keep Postgres for fast storage, give am enough shared memory, and prune trash and versions with retention settings instead of allowing dem pile up forever.
The third one na preview generation wey dey compete with every other work. For small box, keep preview providers narrow and never run occ preview:generate-all during working hours. If na phone camera roll make up most of wetin you dey store, thumbnail work suppose dey purpose-built photo server instead. PhotoPrism and Immich comparison for RAM, phone apps and backup commands explain wetin either one go cost beside Nextcloud box.
After that, the honest answer na say the extra services want their own machine. Collabora and full-text search na separate resident services with their own memory requirements. If you put dem for the same box wey dey hold your only copy of files, you make the failure domain bigger without any benefit. If in-browser document editing na the extra wey you want, the vendor RAM minimums and connection limits wey separate OnlyOffice from Collabora go decide whether 2 to 4 GB VPS fit carry any of dem at all. Move file storage go S3-compatible primary storage when the volume no longer get the right shape. But note say this one make backups harder, no be easier: database still hold the metadata, and you must dump am together with the bucket.
Once the instance dey serve real users, put Uptime Kuma in front of am so you go hear about downtime before sync clients notice am. Private cloud dey work well with your own mail server. If you no wan connect services together by hand, Cloudron, CasaOS and Coolify compare the platforms wey fit do am for you. If self-hosted search engine na the next thing for the list, expect different kind problem from the ones above: SearXNG's 429 errors fit come from its own rate limiter or upstream engines blocking your VPS IP. Na only the log fit tell you which one happen.
FAQ
I fit run Nextcloud on SQLite instead of Postgres?
You fit, and the official image go allow am, but one desktop sync client wey dey issue parallel requests go hit SQLSTATE[HY000]: General error: 5 database is locked and HTTP 500s. SQLite dey take write lock for the whole database, and Nextcloud dey write constantly: file locks, activity rows, and job state. Start with Postgres or MariaDB; occ db:convert-type dey available, but na long, all-or-nothing migration for live data.
How much RAM Nextcloud VPS really need?
Size am based on concurrency, no be user count. Worst-case resident memory na roughly number of concurrent requests multiplied by PHP_MEMORY_LIMIT, plus Postgres shared buffers and one backend for each connection, plus any memory wey preview generation fit use during spikes. 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 large uploads dey fail behind nginx reverse proxy?
Two settings for proxy usually dey cause am: client_max_body_size wey remain for the 1 MB default dey truncate the request, and short proxy_read_timeout / proxy_send_timeout values dey stop long transfers halfway. Set both to generous values, set proxy_request_buffering off to stream instead of spool, and increase PHP_UPLOAD_LIMIT for the app container to match.
Why Nextcloud dey redirect in loop or warn about reverse proxy?
The container no dey see nginx at 127.0.0.1. E dey see Docker bridge gateway, somewhere inside 172.x. When that address no dey inside TRUSTED_PROXIES, e ignore the X-Forwarded-Proto: https header, Nextcloud dey generate http:// URLs, and the proxy dey send dem back again. Set TRUSTED_PROXIES to the real bridge subnet and pin OVERWRITEPROTOCOL: https.
I fit upgrade Nextcloud from 29 straight to 31?
No. Nextcloud support one major version for each upgrade. If you skip one, e stop with Updates between multiple major versions and downgrades are unsupported. and leave the instance for maintenance mode. Back up, increase the tag by one major version for both the app and cron services, docker compose pull && docker compose up -d, verify with occ status, then repeat.