Nextcloud is the open-source answer to Dropbox, Google Drive, and iCloud rolled into one. Files, photos, contacts, calendars, notes, and a small army of optional apps, all running on hardware you control.
The official Docker images make it surprisingly painless to get a production-grade install going. This tutorial walks through the nextcloud:apache image (the simpler of the two official options), with PostgreSQL 15 as the database, Redis for file locking and memory caching, and Caddy in front for automatic HTTPS.
By the end you'll have a working cloud.example.com that syncs files from your laptop, phone, and desktop, plus a daily backup script that captures both the database and the data directory.
cloud.example.com at your serverdocker-compose.ymltrusted_domains and bump PHP limitsTotal time: around 20 minutes.
80 and 443 open to the internetNextcloud is heavier than most self-hosted apps. The Apache image with PHP-FPM, plus a Postgres and Redis container, idles around 400 to 600 MB of RAM. Under load with a handful of active users it can climb past 1 GB easily, which is why 2 GB is the realistic floor.
In your DNS provider, add an A record:
cloud.example.com → YOUR_VPS_IPV4
Add an AAAA record if you use IPv6. Verify:
dig +short cloud.example.com
The output must match your VPS IP before Caddy can issue a Let's Encrypt certificate.
On a fresh Ubuntu box:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Confirm it's working:
docker --version
docker compose version
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Caddy needs port 80 for the ACME HTTP challenge and 443 for HTTPS.
sudo mkdir -p /opt/nextcloud
cd /opt/nextcloud
sudo mkdir -p nextcloud-data postgres-data redis-data caddy-data caddy-config php-config
Everything persistent lives under /opt/nextcloud. Back up this directory along with a Postgres dump and you can rebuild the stack on any host.
# PostgreSQL password
openssl rand -base64 32
# Nextcloud admin password (change after first login if you prefer)
openssl rand -base64 24
Save these somewhere safe. They go into the .env file in the next step.
Create /opt/nextcloud/.env:
# Domain
NEXTCLOUD_DOMAIN=cloud.example.com
NEXTCLOUD_TRUSTED_DOMAINS=cloud.example.com
# Database
POSTGRES_USER=nextcloud
POSTGRES_PASSWORD=REPLACE_WITH_POSTGRES_PASSWORD
POSTGRES_DB=nextcloud
# Admin user (created on first boot)
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=REPLACE_WITH_ADMIN_PASSWORD
Lock down the file:
sudo chmod 600 /opt/nextcloud/.env
Out of the box, the Nextcloud image ships with a 512 MB PHP memory limit and a 512 MB upload cap. Both are too small once you start uploading photos or videos from a phone. Create /opt/nextcloud/php-config/zz-nextcloud.ini:
memory_limit = 1024M
upload_max_filesize = 10G
post_max_size = 10G
max_execution_time = 3600
max_input_time = 3600
output_buffering = 0
opcache.enable = 1
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 10000
opcache.memory_consumption = 256
opcache.save_comments = 1
opcache.revalidate_freq = 60
The OPCache values matter: without them, Nextcloud's admin panel shows a permanent yellow warning, and PHP recompiles every page on every request. The defaults are fine for a tiny WordPress site, not for an app this size.
Create /opt/nextcloud/docker-compose.yml:
services:
postgres:
image: postgres:15
container_name: nextcloud-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ./postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- cloud-net
redis:
image: redis:7-alpine
container_name: nextcloud-redis
restart: unless-stopped
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
volumes:
- ./redis-data:/data
networks:
- cloud-net
nextcloud:
image: nextcloud:apache
container_name: nextcloud
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
environment:
POSTGRES_HOST: postgres
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
REDIS_HOST: redis
REDIS_HOST_PORT: "6379"
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER}
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS}
OVERWRITEPROTOCOL: https
OVERWRITEHOST: ${NEXTCLOUD_DOMAIN}
TRUSTED_PROXIES: 172.16.0.0/12
APACHE_DISABLE_REWRITE_IP: "1"
volumes:
- ./nextcloud-data:/var/www/html
- ./php-config/zz-nextcloud.ini:/usr/local/etc/php/conf.d/zz-nextcloud.ini:ro
networks:
- cloud-net
caddy:
image: caddy:2
container_name: nextcloud-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- cloud-net
networks:
cloud-net:
A few notes:
nextcloud:apache image bundles PHP and Apache together. It's the easiest path. The alternative fpm-alpine image is leaner but needs a separate nginx container in front. Stick with Apache unless you have a reason not to.OVERWRITEPROTOCOL and OVERWRITEHOST tell Nextcloud it's running behind a proxy that terminates TLS. Without these, generated links use http:// and break clients.TRUSTED_PROXIES covers the default Docker bridge subnet. If you use a custom network with a different range, adjust it.nextcloud-data volume holds the application code, config, and user files. It's where the actual cloud lives.Create /opt/nextcloud/Caddyfile:
cloud.example.com {
encode zstd gzip
header Strict-Transport-Security "max-age=31536000;"
# Nextcloud's well-known redirects for CalDAV / CardDAV
redir /.well-known/carddav /remote.php/dav/ 301
redir /.well-known/caldav /remote.php/dav/ 301
reverse_proxy nextcloud:80
}
Caddy will request and renew the Let's Encrypt certificate automatically. The well-known redirects keep CalDAV and CardDAV clients happy; without them the Nextcloud admin panel flags a security warning.
cd /opt/nextcloud
sudo docker compose up -d
sudo docker compose logs -f
The first boot takes a minute or two. Nextcloud copies itself into the nextcloud-data volume, runs database migrations, and creates the admin user. You'll see a lot of Initializing nextcloud x.x.x ... lines, then a apache2 -D FOREGROUND line, which means it's ready.
Open https://cloud.example.com in a browser. Log in with the admin user and password from your .env file. You should land on the file dashboard.
Two things need backing up: the Postgres database, and the nextcloud-data directory (which holds both the user files and the app config). Create /opt/nextcloud/backup.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/nextcloud"
DATE="$(date +%F)"
mkdir -p "$BACKUP_DIR"
# Put Nextcloud into maintenance mode for a consistent snapshot
docker exec -u www-data nextcloud php occ maintenance:mode --on
trap 'docker exec -u www-data nextcloud php occ maintenance:mode --off' EXIT
# Dump Postgres
docker exec nextcloud-postgres \
pg_dump -U nextcloud -d nextcloud \
| gzip > "$BACKUP_DIR/nextcloud-db-$DATE.sql.gz"
# Tar the data directory (config + user files)
tar -czf "$BACKUP_DIR/nextcloud-data-$DATE.tar.gz" \
-C /opt/nextcloud nextcloud-data
# Keep 14 days of backups
find "$BACKUP_DIR" -name "nextcloud-*-*.tar.gz" -mtime +14 -delete
find "$BACKUP_DIR" -name "nextcloud-*-*.sql.gz" -mtime +14 -delete
The trap line guarantees maintenance mode is turned off again even if tar fails halfway. Skipping maintenance mode usually works, but a snapshot taken mid-write can include a half-uploaded file with no row in the database.
Make it executable and schedule it:
sudo chmod +x /opt/nextcloud/backup.sh
echo "30 3 * * * root /opt/nextcloud/backup.sh" | \
sudo tee /etc/cron.d/nextcloud-backup
For off-site safety, sync /var/backups/nextcloud to S3, Backblaze B2, or another VPS with rclone on the same schedule.
Nextcloud has first-party clients for every platform that matters:
https://cloud.example.com.https://cloud.example.com/remote.php/dav/principals/users/YOURUSER/.The desktop client supports selective sync, on-demand virtual files (Windows and macOS), and end-to-end encryption for specific folders.
If you want Google Docs-style collaborative editing, you can attach an office sidecar.
The two common options are Collabora Online and OnlyOffice Document Server. Both run as a separate container, both register with Nextcloud through an app in the marketplace, and both are heavier than the rest of the stack put together. A minimal Collabora sidecar adds about 600 MB of RAM, OnlyOffice closer to 1 GB.
The integration paths are well documented:
onlyoffice/documentserver container.We won't go deep on either here. If you only need viewing and lightweight edits, skip the sidecar entirely; Nextcloud renders Office files in read-only mode out of the box.
Caddy returns 502 for the first minute or two. Nextcloud is still copying itself into the volume on first boot. Wait for the apache2 -D FOREGROUND line in the logs. After that, 502s usually mean the nextcloud container exited; check docker compose logs nextcloud.
"Access through untrusted domain" page. The hostname you typed isn't in trusted_domains. Either add it to the env var and recreate the container, or run docker exec -u www-data nextcloud php occ config:system:set trusted_domains 1 --value=cloud.example.com.
Uploads cap out at 512 MB or fail with "request entity too large." Either the PHP upload_max_filesize ini override didn't load, or Caddy is in the way. Caddy doesn't have a default body limit, so the suspect is almost always PHP. Check docker exec nextcloud php -i | grep upload_max_filesize to confirm.
Redis connection refused in the admin panel. The redis container hasn't started yet, or REDIS_HOST doesn't match the service name. Inside the same Docker network, the hostname is the service name (redis), not localhost.
Background jobs warning in the admin panel. Nextcloud wants its cron job to run every five minutes. The simplest fix is a host cron entry: */5 * * * * root docker exec -u www-data nextcloud php -f /var/www/html/cron.php.
nextcloud-data/data.That's it. A self-hosted cloud that syncs your files, calendar, and contacts without leaking metadata to anyone, running on a VPS that costs less per month than most paid sync services.
Need a VPS that can comfortably handle Nextcloud, Postgres, and Redis side by side? Our Linux plans come with NVMe storage, IPv6, and snapshots out of the box. See the options.