VPS Tutorials
Build a Nextcloud Private Cloud on a VPS (2026): Docker, PostgreSQL, Redis, Caddy HTTPS and Mobile Sync
Build a Nextcloud private cloud on a VPS from scratch: run Nextcloud, PostgreSQL, Redis and Cron with Docker Compose, configure HTTPS with Caddy, and fully handle large file uploads, mobile sync, WebDAV, backup and restore, upgrades and security hardening.

If you want to keep photos, documents and team files on a server you control, and you also need web access, share links, version history and automatic mobile uploads, Nextcloud is a VPS private cloud solution with very high search volume and real usage demand. It is more complete than a plain WebDAV file listing, but it also depends more than Syncthing or OpenList on the database, cache, scheduled tasks and correct reverse proxy configuration.
This article uses Ubuntu 24.04 LTS, Nextcloud 33, PostgreSQL, Redis, Docker Compose and Caddy as an example to deploy from scratch a single-server private cloud that can be maintained long term. The application only listens on the VPS local port, the database and Redis are not exposed to the public internet, and the guide covers large file uploads, Cron, desktop and mobile sync, backup and restore, upgrades and common alert handling.
All three tools let you access files, but they solve different problems:
| Tool | Better suited needs | Main trade-offs |
|---|---|---|
| Nextcloud | Centralized cloud drive, users, sharing, versions, calendar and collaboration | Full-featured, higher resource and maintenance cost |
| Syncthing | Peer-to-peer folder sync between multiple devices | Efficient sync, but not a complete cloud drive portal |
| OpenList | Aggregating cloud drives, WebDAV and a unified file entry point | Lightweight and convenient, should not be the only data copy |
If you only want to sync files between a computer and a NAS, you can start with the VPS Syncthing tutorial. If your focus is mounting multiple third-party cloud drives, the VPS OpenList tutorial is a better fit. Nextcloud is better suited to individuals or small teams that need accounts, permissions, sharing, version history and mobile photo backup.
Nextcloud's CPU pressure usually comes from thumbnails, full-text search, app upgrades and concurrent users, while disk pressure comes from large numbers of small files, version history and the recycle bin.
| Usage scale | Recommended specs | Notes |
|---|---|---|
| 1-3 people, few documents and photos | 2 cores, 4GB, 100GB SSD | Can run basic file sync and sharing |
| 3-10 people, mobile photo backup | 4 cores, 8GB, 250GB SSD or more | Leaves headroom for preview generation, database and Redis |
| Large photo/video volumes or team collaboration | 4-8 cores, 8-16GB, separate data disk | Requires monitoring capacity, IOPS and backup windows |
The system disk and data disk can be separated, but do not put the Nextcloud data directory directly on an unstable network mount. Object storage is not faster by default either: it suits scenarios that clearly need to scale capacity and decouple host lifecycle, while a small single-server site should prefer reliable local SSD/NVMe and then do off-site backups. See When a VPS needs object storage to judge whether the added complexity is worth it.
Prepare a domain, for example cloud.example.com, and point the A / AAAA records to the VPS. Only open SSH, 80 and 443 to the public internet; do not open PostgreSQL 5432, Redis 6379 or the Nextcloud local port 8080.
Install Docker Engine and Compose v2 from the official Docker repository, then confirm the versions:
docker --version
docker compose version
Create the deployment, configuration and backup directories:
sudo mkdir -p /opt/nextcloud/backups
sudo chown -R "$USER":"$USER" /opt/nextcloud
cd /opt/nextcloud
Generate the PostgreSQL, Redis and Nextcloud admin passwords:
openssl rand -hex 32
openssl rand -hex 32
openssl rand -base64 32
nano .env
chmod 600 .env
Write /opt/nextcloud/.env:
POSTGRES_DB=nextcloud
POSTGRES_USER=nextcloud
POSTGRES_PASSWORD=替换为第一组随机值
REDIS_HOST_PASSWORD=替换为第二组随机值
NEXTCLOUD_ADMIN_USER=ncadmin
NEXTCLOUD_ADMIN_PASSWORD=替换为第三组随机值
NEXTCLOUD_HOST=cloud.example.com
NEXTCLOUD_URL=https://cloud.example.com
PHP_MEMORY_LIMIT=1024M
PHP_UPLOAD_LIMIT=10G
Do not use admin as the admin account, and do not reuse the password from the VPS, email or database. After the first initialization you still need to keep .env, but you must restrict read permissions and include it in encrypted backups.
Create /opt/nextcloud/compose.yaml:
services:
db:
image: postgres:17-alpine
container_name: nextcloud-db
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_DB: "${POSTGRES_DB:?set POSTGRES_DB in .env}"
POSTGRES_USER: "${POSTGRES_USER:?set POSTGRES_USER in .env}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}"
volumes:
- nextcloud_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 20
start_period: 30s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
redis:
image: redis:8-alpine
container_name: nextcloud-redis
restart: unless-stopped
env_file:
- .env
command:
- sh
- -c
- exec redis-server --appendonly yes --requirepass "$$REDIS_HOST_PASSWORD"
volumes:
- nextcloud_redis:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a $$REDIS_HOST_PASSWORD ping | grep -q PONG"]
interval: 10s
timeout: 5s
retries: 20
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
app:
image: nextcloud:33-apache
container_name: nextcloud
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "127.0.0.1:8080:80"
env_file:
- .env
environment:
POSTGRES_HOST: db
POSTGRES_DB: "${POSTGRES_DB:?set POSTGRES_DB in .env}"
POSTGRES_USER: "${POSTGRES_USER:?set POSTGRES_USER in .env}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}"
REDIS_HOST: redis
REDIS_HOST_PASSWORD: "${REDIS_HOST_PASSWORD:?set REDIS_HOST_PASSWORD in .env}"
NEXTCLOUD_ADMIN_USER: "${NEXTCLOUD_ADMIN_USER:?set NEXTCLOUD_ADMIN_USER in .env}"
NEXTCLOUD_ADMIN_PASSWORD: "${NEXTCLOUD_ADMIN_PASSWORD:?set NEXTCLOUD_ADMIN_PASSWORD in .env}"
NEXTCLOUD_TRUSTED_DOMAINS: "${NEXTCLOUD_HOST:?set NEXTCLOUD_HOST in .env}"
TRUSTED_PROXIES: "172.16.0.0/12"
OVERWRITEHOST: "${NEXTCLOUD_HOST:?set NEXTCLOUD_HOST in .env}"
OVERWRITEPROTOCOL: https
OVERWRITECLIURL: "${NEXTCLOUD_URL:?set NEXTCLOUD_URL in .env}"
PHP_MEMORY_LIMIT: "${PHP_MEMORY_LIMIT:-1024M}"
PHP_UPLOAD_LIMIT: "${PHP_UPLOAD_LIMIT:-10G}"
APACHE_BODY_LIMIT: 0
volumes:
- nextcloud_data:/var/www/html
healthcheck:
test: ["CMD-SHELL", "php -r '$$c=@fsockopen(\"127.0.0.1\",80,$$e,$$s,5); exit($$c ? 0 : 1);'"]
interval: 30s
timeout: 10s
retries: 10
start_period: 120s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
cron:
image: nextcloud:33-apache
container_name: nextcloud-cron
restart: unless-stopped
entrypoint: /cron.sh
depends_on:
app:
condition: service_healthy
env_file:
- .env
volumes:
- nextcloud_data:/var/www/html
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
nextcloud_db:
name: nextcloud_db
nextcloud_redis:
name: nextcloud_redis
nextcloud_data:
name: nextcloud_data
Nextcloud only listens on 127.0.0.1:8080. PostgreSQL and Redis have no port mappings and can only be reached from the Compose network. TRUSTED_PROXIES should only trust the Docker private network address; do not enter 0.0.0.0/0. If your Docker address pool is different, first use docker network inspect to find the actual subnet and then modify it.
Check the expanded Compose result before starting:
docker compose config >/dev/null
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=150 app db redis
The first initialization may take a few minutes. Once the service is stable, check the local entry point:
curl -I http://127.0.0.1:8080/status.php
sudo ss -lntp | grep ':8080'
The listening address must be 127.0.0.1:8080, not 0.0.0.0:8080.
Edit /etc/caddy/Caddyfile:
cloud.example.com {
encode zstd gzip
request_body {
max_size 10GB
}
reverse_proxy 127.0.0.1:8080 {
transport http {
read_timeout 1h
write_timeout 1h
}
}
}
Validate and reload:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
curl -I https://cloud.example.com/status.php
Caddy passes X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto. The trusted proxies and override parameters in Compose let Nextcloud generate HTTPS links correctly, avoiding login loops, wrong WebDAV addresses and command-line generation of HTTP URLs. For more complex CDN, multi-domain and certificate issues, see the complete guide to Caddy reverse proxy on a VPS.
Large file uploads are affected simultaneously by Caddy, PHP, Apache, client timeouts and available disk. Setting the limit to 10GB does not mean 10GB of memory will be used at once, but the disk must have room for upload temporary files, version history and the recycle bin.
Open https://cloud.example.com and log in with the admin account from .env. Because Compose already passes the database and admin variables, the installation is usually completed automatically on first start.
Go to "Administration settings → Overview" and check the security and setup warnings one by one. Then run a status check from inside the container:
docker compose exec -u www-data app php occ status
docker compose exec -u www-data app php occ config:system:get trusted_domains
docker compose exec -u www-data app php occ config:system:get trusted_proxies
docker compose exec -u www-data app php occ config:system:get overwriteprotocol
Do not simply disable security checks to make warnings disappear. Domain, proxy, cache, background task or database index warnings should be fixed according to their actual cause.
Nextcloud's file scanning, version cleanup, notifications and app tasks all depend on background jobs. The default AJAX mode only triggers when a user visits a page and is not suitable for production.
Compose already runs a separate cron container, so set the background task mode to Cron:
docker compose exec -u www-data app php occ background:cron
docker compose logs --tail=100 cron
Later, confirm in the administration settings that the "last task execution" time keeps updating. The Cron container and the App must share the same nextcloud_data volume, otherwise the configuration and installed apps will be inconsistent.
The official image configures Redis based on REDIS_HOST, but you should still check after going live:
docker compose exec -u www-data app php occ config:system:get redis
docker compose exec redis sh -c 'redis-cli -a "$REDIS_HOST_PASSWORD" ping'
Redis is used for distributed caching and transactional file locking, which reduces database lock contention during multi-user sync. The Redis data volume is not a replacement for Nextcloud file data; even if Redis is lost, core files and the database should still be recoverable, but in-flight locks and caches will be rebuilt.
Install the official Nextcloud desktop client or the iOS/Android client and enter this server address:
https://cloud.example.com
For automatic mobile uploads, it is recommended to first create a dedicated directory, for example Photos/Phone, and to decide clearly whether uploads only happen on Wi-Fi and while charging. Before the first sync of a large number of photos, check the VPS free space, traffic quota and version retention policy.
The WebDAV address is usually:
https://cloud.example.com/remote.php/dav/files/用户名/
After enabling two-factor authentication, create an app password for WebDAV clients that do not support web login, and do not save the main password in old software.
File versions and the recycle bin significantly increase disk usage. Do not wait until the disk is full to deal with it. Check current space and background status:
df -h
docker system df
docker compose exec -u www-data app php occ status
For instances with many photos, preview generation consumes CPU and disk. Enable only the preview types you actually need and pre-generate them during off-peak hours. Do not install thumbnail scripts of unknown origin, and do not let multiple programs modify the Nextcloud data directory directly.
If external programs write into the data directory, the Nextcloud database will not automatically know about these files. You must use a supported external storage app, or carefully run:
docker compose exec -u www-data app php occ files:scan --all
A full scan can take a long time with many files and should not be used frequently as a sync method.
A complete backup includes at least PostgreSQL and the nextcloud_data volume. The database stores users, shares, file index and app state; the data volume stores config.php, apps, themes and the actual files.
First enable maintenance mode to avoid continuous writes during the backup:
cd /opt/nextcloud
docker compose exec -u www-data app php occ maintenance:mode --on
Back up PostgreSQL:
mkdir -p backups
docker compose exec -T db sh -c \
'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom' \
> "backups/nextcloud-db-$(date +%F-%H%M).dump"
Back up the data volume:
docker run --rm \
-v nextcloud_data:/source:ro \
-v "$PWD/backups:/backup" \
alpine sh -c 'tar -C /source -czf /backup/nextcloud-data.tar.gz .'
Disable maintenance mode:
docker compose exec -u www-data app php occ maintenance:mode --off
Backups must not stay only on the same VPS. Copy them encrypted to object storage or another server, and run restore drills regularly. You can directly follow the off-site verification process in the VPS backup and restore drill tutorial.
When restoring, first stop the App and Cron, restore the data volume and database, then start the services and run:
docker compose exec -u www-data app php occ maintenance:repair
docker compose exec -u www-data app php occ db:add-missing-indices
docker compose exec -u www-data app php occ status
Restore acceptance should check login, file downloads, share links, version history, mobile uploads and WebDAV, not just the home page.
Nextcloud can only be upgraded along supported major version paths; do not replace the image across multiple major versions directly. Before upgrading, check the official release notes, app compatibility and available disk, and complete database and data volume backups.
Update within the same major version:
cd /opt/nextcloud
docker compose config >/dev/null
docker compose pull
docker compose up -d
docker compose exec -u www-data app php occ upgrade
docker compose exec -u www-data app php occ status
docker compose logs --tail=200 app cron
After upgrading, check the administration overview, background tasks, client sync, share links and database warnings. If the new version fails, stop writes and restore the old image tag, database and data volume. Changing only the image tag back without restoring the upgraded database usually cannot achieve a reliable rollback.
- Use a long password for the admin and enable two-factor authentication;
- Group regular users by least privilege and do not share the admin account;
- Do not expose PostgreSQL, Redis or 8080 to the public internet;
- Install only actively maintained Nextcloud apps that you actually need;
- Regularly update Nextcloud, the database, Redis, Caddy and the host system;
- Set reasonable rate limits on the login endpoint, but do not accidentally block WebDAV and sync clients;
- Monitor disk, inodes, database backups, Cron and HTTPS;
- Revoke sessions and app passwords when staff leave or devices are lost.
Self-hosting means you are responsible for security, capacity and recovery; it does not mean files are naturally safe from loss. RAID, snapshots, version history and the recycle bin cannot replace off-site backups.
docker compose ps
curl -I http://127.0.0.1:8080/status.php
docker compose logs --tail=200 app
sudo journalctl -u caddy --no-pager -n 100
If the App is still initializing or performing an upgrade, Caddy will temporarily return 502. If it keeps failing, check the database, Redis, memory and data volume permissions.
Confirm that the domain in .env matches the real entry point, and check:
docker compose exec -u www-data app php occ config:system:get trusted_domains
docker compose exec -u www-data app php occ config:system:get trusted_proxies
docker compose exec -u www-data app php occ config:system:get overwritehost
docker compose exec -u www-data app php occ config:system:get overwriteprotocol
Do not configure conflicting HTTP/HTTPS redirects across the CDN, Caddy and Nextcloud. Trusted proxies must not be relaxed to arbitrary public addresses either.
First confirm Caddy's max_size, then check the container environment and PHP:
docker compose exec app php -i | grep -E 'upload_max_filesize|post_max_size|memory_limit'
docker compose logs --tail=200 app
df -h
If uploads fail at a fixed size, it is usually a proxy or PHP limit; if they time out halfway, continue checking the client, reverse proxy timeouts, disk and network stability.
docker compose ps cron
docker compose logs --tail=200 cron
docker compose exec -u www-data app php occ background:cron
Confirm that Cron and the App use the same image version and data volume. After upgrading the App, also update the Cron container.
First check whether Redis and the database are healthy; do not directly delete lock records in the database:
docker compose ps redis db
docker compose logs --tail=200 redis db app
docker compose exec redis sh -c 'redis-cli -a "$REDIS_HOST_PASSWORD" ping'
Only handle residual locks further when you have confirmed no client is writing, the database is backed up and you understand the impact.
- Nextcloud only listens on
127.0.0.1:8080, and the database and Redis are not exposed to the public internet - Domain, HTTPS, trusted proxies and override URL are configured consistently
- The separate Cron container runs normally and the background task time keeps updating
- Redis cache and transactional file locking work normally
- Desktop client, automatic mobile upload and WebDAV have been tested
- The upload path for 10GB or the required size has been actually tested
- Both PostgreSQL and the Nextcloud data volume have off-site backups
- A restore drill has been done and sharing, versions and client sync verified
- Versions are pinned and backed up before upgrades, and app compatibility is checked after upgrades
After completing these steps, this Nextcloud is not just "a web page that opens", but a VPS private cloud with HTTPS, caching, scheduled tasks, mobile sync, version management and recoverability.