Nextcloud is the most polished open-source replacement for Google Drive, Dropbox, and (with the right apps installed) most of the rest of Google Workspace. Self-hosting it on a VPS gives you the same file sync, calendar, contacts, and collaborative document editing — except the data lives on a machine you control. The official Nextcloud All-in-One installer works, but it takes shortcuts that show up later as performance issues. This guide builds a clean Docker Compose stack with Postgres, Redis, and Nginx that scales properly. About thirty minutes start to finish.
Step 1: Why Docker Compose (and not AIO)
Nextcloud ships three official install paths: the All-in-One Docker container, the snap, and a manual stack. AIO is convenient — one container, one command — but it bundles its own reverse proxy on port 8080 and 8443, manages its own backups in a way that doesn't play well with anything else, and treats your VPS like dedicated hardware. The manual Compose stack we'll build is one config file, runs cleanly behind whatever reverse proxy you already have, and is straightforward to back up and migrate. Pick this every time unless you have a specific reason not to.
Step 2: Set up the project structure
Pick a parent directory for your self-hosted services. We use /srv on production VPSes; you might prefer /opt or your home directory. The exact path doesn't matter as long as it's on a disk with enough free space.
sudo mkdir -p /srv/nextcloud
cd /srv/nextcloud
sudo mkdir -p data db redis-data
sudo chown -R $USER:$USER /srv/nextcloud
The data directory will hold every uploaded file. If you have a separate disk attached to your VPS for storage, mount it here instead — or use a bind mount to point it at /mnt/storage/nextcloud-data. You can change this later, but only with a maintenance-mode shuffle, so think about it now.
Step 3: Write the docker-compose.yml
Create docker-compose.yml in /srv/nextcloud. This stack uses Nextcloud's FPM image (PHP-FPM) behind Nginx — slightly more setup than the apache image, but noticeably faster and the way every production deployment runs.
services:
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- ./db:/var/lib/postgresql/data
env_file: .env
networks:
- nextcloud-internal
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- ./redis-data:/data
networks:
- nextcloud-internal
app:
image: nextcloud:30-fpm
restart: unless-stopped
volumes:
- ./data:/var/www/html
env_file: .env
environment:
- POSTGRES_HOST=db
- REDIS_HOST=redis
- REDIS_HOST_PORT=6379
depends_on:
- db
- redis
networks:
- nextcloud-internal
web:
image: nginx:alpine
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
volumes:
- ./data:/var/www/html:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app
networks:
- nextcloud-internal
cron:
image: nextcloud:30-fpm
restart: unless-stopped
volumes:
- ./data:/var/www/html
entrypoint: /cron.sh
depends_on:
- db
- redis
networks:
- nextcloud-internal
networks:
nextcloud-internal:
driver: bridge
Notice that web binds to 127.0.0.1:8080 only — not the public interface. We're going to put a host-level Nginx in front of it for TLS termination. If you tried to skip that and expose port 80/443 directly, the inner Nginx wouldn't have a TLS cert and you'd be stuck with HTTP-only.
You also need a minimal nginx.conf for the web container. The one in Nextcloud's official docs is verbose; here's a stripped-down version that works:
worker_processes auto;
events { worker_connections 1024; }
http {
upstream php-handler { server app:9000; }
client_max_body_size 10G;
fastcgi_buffers 64 4K;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
include /etc/nginx/mime.types;
server {
listen 80;
root /var/www/html;
index index.php;
location / {
rewrite ^/(.*)$ /index.php?$1 last;
}
location ~ \.php(?:$|/) {
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
try_files $fastcgi_script_name =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTPS on;
fastcgi_pass php-handler;
}
}
}
Step 4: Configure environment variables
Create a .env file in the same directory. Generate real random passwords for each — don't reuse anything.
# Generate fresh passwords
openssl rand -base64 32 # for POSTGRES_PASSWORD
openssl rand -base64 32 # for REDIS_PASSWORD
openssl rand -base64 32 # for NEXTCLOUD_ADMIN_PASSWORD
POSTGRES_DB=nextcloud
POSTGRES_USER=nextcloud
POSTGRES_PASSWORD=paste-generated-password-here
REDIS_PASSWORD=paste-generated-password-here
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=paste-generated-password-here
NEXTCLOUD_TRUSTED_DOMAINS=cloud.example.com
Lock down the file: chmod 600 .env. Anyone who can read this file owns your Nextcloud instance.
Step 5: Start the stack and run the installer
cd /srv/nextcloud
docker compose pull
docker compose up -d
docker compose logs -f app # watch first-run setup; Ctrl-C when it settles
Wait a minute for first-run database initialization. Once the logs go quiet, the installer has run. Test the local endpoint:
curl -I http://127.0.0.1:8080
# Expect: HTTP/1.1 302 Found, Location: /login
Step 6: Set up Nginx and Let's Encrypt
Install Nginx and certbot on the host, then proxy to the Compose stack. This is the same pattern from our Nginx + Let's Encrypt guide, with a Nextcloud-specific config.
sudo apt install -y nginx certbot python3-certbot-nginx
sudo certbot --nginx -d cloud.example.com
Then replace /etc/nginx/sites-available/cloud.example.com with the Nextcloud-tuned config:
server {
listen 443 ssl http2;
server_name cloud.example.com;
ssl_certificate /etc/letsencrypt/live/cloud.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cloud.example.com/privkey.pem;
client_max_body_size 10G;
client_body_timeout 600s;
# Required Nextcloud headers
add_header Strict-Transport-Security "max-age=15768000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer" always;
location /.well-known/carddav { return 301 $scheme://$host/remote.php/dav; }
location /.well-known/caldav { return 301 $scheme://$host/remote.php/dav; }
location / {
proxy_pass http://127.0.0.1:8080;
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 https;
proxy_request_buffering off;
proxy_buffering off;
}
}
server {
listen 80;
server_name cloud.example.com;
return 301 https://$host$request_uri;
}
sudo nginx -t && sudo systemctl reload nginx
Open https://cloud.example.com in a browser. Log in with the admin credentials from .env. You should land on the Dashboard.
VPS that's actually big enough for your photo library
Nextcloud's data directory grows fast — family photos, video backups, scanned documents add up. OliveVPS plans give you NVMe storage with no inode limits and honest disk allocations. From $7.99/mo for 100 GB of pure NVMe.
Compare plans →Step 7: Fix the post-install warnings
Open Administration settings → Overview. Nextcloud will list a handful of warnings. Most are real and worth fixing.
"PHP memory limit is below recommended" — edit data/config/config.php through the container or set PHP_MEMORY_LIMIT=512M in .env and restart the app container.
"You are accessing your instance from an untrusted domain" — already handled by the NEXTCLOUD_TRUSTED_DOMAINS variable. If you see it anyway, check the variable made it into the container with docker compose exec app printenv | grep TRUSTED.
"No memory cache configured" — Redis is up but Nextcloud doesn't know about it yet. Run:
docker compose exec --user www-data app php occ config:system:set memcache.local --value '\OC\Memcache\APCu'
docker compose exec --user www-data app php occ config:system:set memcache.distributed --value '\OC\Memcache\Redis'
docker compose exec --user www-data app php occ config:system:set memcache.locking --value '\OC\Memcache\Redis'
docker compose exec --user www-data app php occ config:system:set redis host --value 'redis'
docker compose exec --user www-data app php occ config:system:set redis port --value 6379 --type integer
docker compose exec --user www-data app php occ config:system:set redis password --value "$REDIS_PASSWORD"
"Strict-Transport-Security HTTP header is not set" — already in the Nginx config above. If you skipped that, add it now.
"The database is missing some indexes" — run the migration:
docker compose exec --user www-data app php occ db:add-missing-indices
docker compose exec --user www-data app php occ db:add-missing-columns
docker compose exec --user www-data app php occ db:add-missing-primary-keys
Step 8: Backups (do this now, not later)
The minimum viable backup of a Nextcloud instance is three things: the Postgres database, the data directory, and the config directory. The script below handles all three and pushes off-VPS via rclone.
sudo tee /usr/local/bin/backup-nextcloud.sh > /dev/null <<'EOF'
#!/bin/bash
set -e
cd /srv/nextcloud
TS=$(date +%Y%m%d-%H%M%S)
DEST=/var/backups/nextcloud
mkdir -p "$DEST"
# Maintenance mode on (prevents writes during backup)
docker compose exec -T --user www-data app php occ maintenance:mode --on
# Database
docker compose exec -T db pg_dump -U nextcloud nextcloud | gzip > "$DEST/db-$TS.sql.gz"
# Data and config (rsync the data directory)
tar czf "$DEST/data-$TS.tar.gz" -C /srv/nextcloud data/config data/data
# Maintenance mode off
docker compose exec -T --user www-data app php occ maintenance:mode --off
# Push off-VPS (configure rclone first)
rclone copy "$DEST" remote:nextcloud-backups/ --include "*-$TS.*"
# Keep last 14 local copies
find "$DEST" -mtime +14 -delete
EOF
sudo chmod +x /usr/local/bin/backup-nextcloud.sh
# Run nightly at 02:30
echo "30 2 * * * /usr/local/bin/backup-nextcloud.sh" | sudo crontab -
Test the script manually before relying on the cron job. The first run will take a while; subsequent runs are bounded by the size of new data.
Common issues and fixes
Desktop client says "untrusted certificate" — the desktop app pins certificates harder than browsers. Make sure you're using a real Let's Encrypt cert, not a self-signed one. If you switched certs recently, restart the desktop client.
Uploads fail at exactly 2 GB — PHP's upload_max_filesize and post_max_size. Add PHP_UPLOAD_LIMIT=10G to .env and restart the app and web containers. Also confirm client_max_body_size 10G is set in both Nginx configs.
"Internal server error" with no obvious cause — tail the Nextcloud log: docker compose exec --user www-data app tail -f /var/www/html/data/nextcloud.log. The browser-facing error is generic; the real error is in there.
Slow file listings, especially with thousands of files — Redis isn't actually being used. Verify with docker compose exec --user www-data app php occ config:system:get memcache.distributed. Should return \OC\Memcache\Redis. If it returns nothing, the cache config from Step 7 didn't apply.
Cron warnings on the dashboard — the cron service should be running. docker compose ps to confirm. If it's there but Nextcloud still complains, check that occ config:app:get core lastcron updates within the last 15 minutes.
FAQ
How much disk space do I need?
Plan for the data your users will store, plus 30% headroom for previews, versions, and trash. Nextcloud generates thumbnail previews of every image and stores them separately, which adds ~10–15% on top of the raw file storage. A family of four with phone photo backup typically uses 200–500 GB after a year.
Should I use Postgres, MySQL, or SQLite?
Postgres is what the Nextcloud team recommends and what the largest deployments use. MySQL/MariaDB works but has had more historical issues with large file tables. SQLite is fine for a single-user instance with light usage; don't pick it for anything you care about.
Can I move from a Hub release to a Talk-only setup if I just want video calls?
Nextcloud Talk is just an app on a regular Nextcloud instance — install it from the Apps page after install. For high-quality group calls (more than 4–5 participants), you'll also want a dedicated TURN server, which is a separate setup. Small calls work without it.
Is Nextcloud secure enough to be my main cloud?
Done right, yes. The pieces that matter: a strong admin password, the data directory outside the web root (already done in this guide), HTTPS with a real cert, automatic security updates on the host OS, and regular backups. The default install does not enable two-factor auth — turn it on under Settings → Security as your first action after logging in.
How do I migrate Nextcloud to a bigger VPS later?
Stop the stack, tar up the entire /srv/nextcloud directory plus the latest database dump, copy to the new VPS, restore. Adjust the trusted domains in config and the Nginx server name if the hostname changes. Total downtime is usually 10–30 minutes depending on data size.