Self-hostingWritten by GLWS22 min readUbuntu 24.04 LTS / AlmaLinux 9
Commands run on your server unless a section says otherwise. Replace example usernames, addresses and paths with your own values. Keep a working session open when changing remote access.

What to plan before you install

Nextcloud gives you file sync, calendars, contacts and shared documents on your own server. It is a PHP application with a database, and it is heavier than it looks. As a guide, plan for at least 2 GB of memory, more with several users or office integration, and plan storage for all the files you will sync plus room for versions and the trash.

Decide where the data will live and how you will back it up before you start. Photos and documents are the kind of data people regret losing. This guide uses Docker Compose with MariaDB and Redis, and publishes the web interface on localhost for a reverse proxy to expose. You need Docker (see the Docker guide) and a domain.

Both systems
free -h
df -h /
sudo docker compose version

Create the project and secrets

Make a project folder and an environment file with random passwords. Keep the file private. The values are read by the Compose file and are needed again when you restore.

Both systems
mkdir -p ~/nextcloud && cd ~/nextcloud
(umask 077; cat > .env <<EOF
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 16)
MYSQL_PASSWORD=$(openssl rand -hex 16)
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=$(openssl rand -base64 18)
EOF
)
sed 's/=.*/=(hidden)/' .env

Write the Compose file

The file defines the database, a Redis cache, the application and a cron container. The application is published on 127.0.0.1:8080 only. Set your real domain in the trusted domains. The two proxy settings tell Nextcloud that visitors arrive over HTTPS through a reverse proxy. Pin the image to the current major version once it is running, so that updates are your decision.

Trust only the Compose gateway address from which the host reverse proxy reaches the container. Recheck the gateway if this network is recreated.

Both systems
cat > compose.yaml <<'EOF'
services:
  db:
    image: mariadb:lts
    command: --transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW
    restart: unless-stopped
    volumes:
      - db:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud

  redis:
    image: redis:alpine
    restart: unless-stopped

  app:
    image: nextcloud:apache
    restart: unless-stopped
    depends_on: [db, redis]
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - nextcloud:/var/www/html
    environment:
      - MYSQL_HOST=db
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - REDIS_HOST=redis
      - NEXTCLOUD_ADMIN_USER=${NEXTCLOUD_ADMIN_USER}
      - NEXTCLOUD_ADMIN_PASSWORD=${NEXTCLOUD_ADMIN_PASSWORD}
      - NEXTCLOUD_TRUSTED_DOMAINS=cloud.example.com
      - OVERWRITEPROTOCOL=https
      - OVERWRITEHOST=cloud.example.com
      - TRUSTED_PROXIES=${NEXTCLOUD_PROXY_IP:-127.0.0.1}

  cron:
    image: nextcloud:apache
    restart: unless-stopped
    depends_on: [db, redis]
    entrypoint: /cron.sh
    volumes:
      - nextcloud:/var/www/html

volumes:
  db:
  nextcloud:
EOF
sudo docker compose config --quiet && echo "Compose file OK"
sudo docker compose create
PROXY_IP=$(sudo docker network inspect nextcloud_default --format '{{(index .IPAM.Config 0).Gateway}}')
printf 'NEXTCLOUD_PROXY_IP=%s\n' "$PROXY_IP" >> .env
sudo docker compose up -d
sudo docker compose logs --tail 20 app

Publish it with HTTPS

Use the reverse proxy guide, or the Caddy guide, to proxy your domain to 127.0.0.1:8080. Nextcloud needs large uploads and long timeouts, so raise the body size limit in Nginx, and forward the standard calendar and contacts discovery addresses. With Caddy the proxy line and certificate need no extra settings for uploads.

Nginx location settings
client_max_body_size 10G;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
location = /.well-known/carddav { return 301 /remote.php/dav/; }
location = /.well-known/caldav { return 301 /remote.php/dav/; }
Check
curl -I https://cloud.example.com/status.php
curl -s https://cloud.example.com/status.php

First sign-in and settings checks

Sign in with the administrator account from your environment file, and change that password. Open Administration settings, then Overview, and read the security and setup warnings, which list missing settings such as the maintenance window or phone region. Set the background jobs mode to Cron, since the cron container handles it.

Both systems
cd ~/nextcloud
sudo docker compose exec -u www-data app php occ status
sudo docker compose exec -u www-data app php occ background:cron
sudo docker compose exec -u www-data app php occ config:system:set maintenance_window_start --type=integer --value=1

Plan storage

All files live in the nextcloud volume under the data directory, and the database holds metadata and shares. Your plan's disk must hold the files, versions, the trash and a margin. Set per-user quotas so nobody fills the server. For large collections, use an external storage option, or a bigger plan, rather than pushing a small disk to its limit.

Both systems
sudo docker system df -v | grep -E "nextcloud|db"
df -h /
cd ~/nextcloud && sudo docker compose exec -u www-data app php occ user:setting admin files quota "20 GB"

Back up the database and the files

You need three things: the database, the files and the configuration, all from the same moment. Put Nextcloud in maintenance mode, dump the database, archive the volume, then switch maintenance mode off. Copy the results off the server, and keep the .env file with them, because restoring needs the same passwords.

Stop the application and cron after enabling maintenance mode. Keep .env, Compose and both archives together in protected off-server storage; a files-only or database-only copy is insufficient. Resume the application before turning maintenance mode off, then resume cron.

Both systems
cd ~/nextcloud
sudo docker compose exec -u www-data app php occ maintenance:mode --on
set -o pipefail
umask 077
sudo docker compose stop cron app
source .env
sudo docker compose exec -T db mariadb-dump -u nextcloud -p"$MYSQL_PASSWORD" --single-transaction nextcloud | gzip > nextcloud-db-$(date +%F).sql.gz
sudo docker run --rm -v nextcloud_nextcloud:/data -v "$PWD":/backup alpine tar czf /backup/nextcloud-files-$(date +%F).tar.gz -C /data .
sudo docker compose start app
# Wait until php occ status responds before the next command.
sudo docker compose exec -u www-data app php occ maintenance:mode --off
sudo docker compose start cron
ls -lh nextcloud-*-$(date +%F).*
Capture application files and a consistent database backup. Keep an encrypted copy off the VPS, restore it separately, and verify the restored data before relying on the backup.
Capture application files and a consistent database backup. Keep an encrypted copy off the VPS, restore it separately, and verify the restored data before relying on the backup.
Volume names start with the project folder name. Run docker volume ls to confirm nextcloud_nextcloud. A very large archive means a very long maintenance window, so for big libraries use incremental tools such as restic on the volume.

Update step by step

Nextcloud can update only one major version at a time, so skipping versions breaks the upgrade. Take a backup, read the release notes, then pull the new image for the next major version and start it. The container runs the upgrade on start. Check the admin overview afterwards, and update apps from the apps page.

Both systems: edit the image tag to the next major version, then
cd ~/nextcloud
sudo docker compose pull
sudo docker compose up -d
sudo docker compose logs --tail 40 app
sudo docker compose exec -u www-data app php occ status

Restore

Confirm the database name, archive and volume before restoring. Stop app and cron so neither writes while the backup is replayed. Use the same application image version as the backup; restore files and database as a pair. The archive contains the maintenance flag from backup time, so turn it off only after the restored application starts.

The commands replace this application volume, including hidden files. They erase its current contents. Verify the archive first and preserve the current state if you might need to roll back.

Both systems: restore the confirmed application backup
cd ~/nextcloud
set -o pipefail
sudo docker compose stop app cron
sudo docker compose up -d db
source .env
gunzip -c nextcloud-db-YYYY-MM-DD.sql.gz | sudo docker compose exec -T db mariadb -u nextcloud -p"$MYSQL_PASSWORD" nextcloud
sudo docker run --rm -v nextcloud_nextcloud:/data -v "$PWD":/backup alpine sh -c 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar xzf /backup/nextcloud-files-YYYY-MM-DD.tar.gz -C /data'
sudo docker compose start app
# Wait until php occ status responds.
sudo docker compose exec -u www-data app php occ maintenance:mode --off
sudo docker compose exec -u www-data app php occ maintenance:data-fingerprint
sudo docker compose start cron
# Sign in and download a known file; compare its checksum with the original.
RECORDED LAB OUTPUT · Ubuntu 24.04 LTS
Authenticated WebDAV content: byte-identical
Maintenance mode enabled
Maintenance mode disabled
Updated data-fingerprint to f60b32ce0fa1c75c4c55211b77be5a27
Authenticated WebDAV content: byte-identical
Recorded authenticated WebDAV content before and after clearing and restoring the app volume and database. Both downloads matched the original bytes.

Official documentation

These upstream references provide more detail on the commands and workflows in this guide.