All articles
TutorialsMar 25, 2026 · 19 min read

Self-Host Nextcloud on a VPS with Docker and Caddy

Self-Host Nextcloud on a VPS with Docker and Caddy

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.

Nextcloud's `data/` directory holds every file every user has ever uploaded. Lose it without a backup and there is no recovery path. Set up the backup script in Step 11 the same day you go live, not "next week."

TL;DR

  • Install Docker and Docker Compose on a fresh VPS
  • Point a subdomain like cloud.example.com at your server
  • Run Nextcloud + PostgreSQL + Redis + Caddy with one docker-compose.yml
  • Add the public hostname to trusted_domains and bump PHP limits
  • Schedule a daily Postgres dump plus a tar of the data directory

Total time: around 20 minutes.

What You Need

  • A VPS with at least 2 GB RAM (4 GB recommended once you add a few users) running Ubuntu 22.04 or 24.04
  • Plenty of disk. Plan for the full size of every file you plan to store, plus 20 percent for previews, versions, and the trashbin
  • A domain you can add DNS records to
  • Ports 80 and 443 open to the internet
  • Root or sudo access

Nextcloud 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.

Step 1: Point a Subdomain at Your VPS

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.

Step 2: Install Docker and Docker Compose

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

Step 3: Open the Firewall

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.

Step 4: Create the Project Directory

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.

Step 5: Generate Secrets

# 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.

Step 6: Write the Environment File

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

Step 7: Tune PHP Limits

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.

Step 8: Write the Compose File

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:

  • The 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.
  • The nextcloud-data volume holds the application code, config, and user files. It's where the actual cloud lives.

Step 9: Write the Caddyfile

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.

Step 10: Start the Stack

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.

The `trusted_domains` array in `config/config.php` must include every hostname you actually use to reach Nextcloud. If you log in via IP, via a VPN hostname, or both an apex and a `www` subdomain, add all of them. Nextcloud refuses requests from any host it doesn't recognize.

Step 11: Add a Backup Script

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.

Step 12: Install the Sync Clients

Nextcloud has first-party clients for every platform that matters:

  • Desktop (Windows / macOS / Linux): download from nextcloud.com/install. On first launch, set the server URL to https://cloud.example.com.
  • iOS / Android: install the official Nextcloud app from the App Store or Play Store. Same login flow.
  • CalDAV / CardDAV: point your calendar or contacts app at 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.

Step 13: Optional Office Suite

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:

  • Install the Nextcloud Office app from the in-app marketplace (this is the Collabora-based option, maintained by Nextcloud GmbH).
  • Or install the ONLYOFFICE app and point it at a separate 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.

Troubleshooting

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.

Going Further

  • Collabora or OnlyOffice for full collaborative editing in the browser.
  • S3-compatible object storage as primary storage if your server's local disk is small. Nextcloud can write user data straight to S3, MinIO, Wasabi, or B2 instead of nextcloud-data/data.
  • Full-text search with the Elasticsearch app, so users can search file contents and not just filenames.
  • Talk for self-hosted video calls (it's the same project; install the app from the marketplace and add a TURN server for NAT traversal).
  • Imaginary as a sidecar for faster image previews of HEIC, RAW, and large photos.

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.