Nextcloud Setup - Your Personal Cloud in Docker

Status: Active
Last Updated: 2026-08-14
Category: Cloud - Phase 1: File Storage & Sync
Prerequisites: cloud-storage-concepts, docker-basics, docker-compose-patterns
Time: 2-3 hours
Tags: nextcloud, docker, docker-compose, self-hosted, file-sync, mariadb, redis, reverse-proxy

Summary

Install Nextcloud โ€” the open-source Dropbox/Google Drive replacement โ€” using Docker Compose, with a proper production-grade stack: MariaDB database, Redis cache, persistent volumes, and HTTPS behind a reverse proxy. By the end you'll have a working personal cloud at https://cloud.example.com with admin account created and sane defaults applied.

๐ŸŽฏ What You'll Learn

By the end of this article, you'll be able to:


Architecture Overview

Nextcloud is a PHP application; production deployments separate concerns:

                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
 Internet โ”€โ”€TLSโ”€โ”€โ–บ  โ”‚  Reverse proxy (Traefik/Caddy/Nginx) โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                   โ”‚ :80 inside docker network
                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚  nextcloud:apache container  โ”‚
                    โ””โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                        โ”‚               โ”‚
              โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
              โ”‚ MariaDB       โ”‚  โ”‚ Redis        โ”‚
              โ”‚ (files DB,    โ”‚  โ”‚ (locks,      โ”‚
              โ”‚  users, share)โ”‚  โ”‚  sessions)   โ”‚
              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                        โ”‚
                 /var/www/html/data โ†’ named volume "nextcloud_data"

Why not the single-container "nextcloud:fpm-alpine + sqlite" quickstart? SQLite collapses under concurrent users, and bundling everything into one container makes backups and upgrades fragile. The compose stack below is the minimum responsible setup.

Component Purpose Resource cost
nextcloud:apache App + embedded web server ~512MBโ€“1GB
mariadb:11 Metadata: files, shares, users ~500MB
redis:7 File locking, session cache ~50MB
Reverse proxy TLS termination existing infra

Prerequisites Check

# Docker + compose plugin present?
docker --version && docker compose version

# A DNS record pointing at this host:
# cloud.example.com  โ†’  your server IP

# Directory layout for config:
mkdir -p ~/cloud-stack && cd ~/cloud-stack

Pick strong secrets now โ€” you'll paste them into .env:

openssl rand -hex 24   # DB password
openssl rand -hex 24   # Redis password
openssl rand -hex 32   # (optional) Redis session password

The Docker Compose Stack

Create .env beside compose.yaml:

# .env โ€” keep OUT of git (or use dotenvx per kb/sysadmin/dotenvx)
MYSQL_ROOT_PASSWORD=REPLACE_WITH_HEX_1
MYSQL_PASSWORD=REPLACE_WITH_HEX_2
MYSQL_DATABASE=nextcloud
MYSQL_USER=nextcloud
REDIS_HOST_PASSWORD=REPLACE_WITH_HEX_3
NEXTCLOUD_TRUSTED_DOMAINS=cloud.example.com
NEXTCLOUD_ADMIN_USER=admin          # optional: pre-seed admin
NEXTCLOUD_ADMIN_PASSWORD=REPLACE_WITH_HEX_4

Create compose.yaml:

services:
  db:
    image: mariadb:11
    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=${MYSQL_DATABASE}
      - MYSQL_USER=${MYSQL_USER}
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_HOST_PASSWORD}
    volumes:
      - redis:/data

  app:
    image: nextcloud:29-apache
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:80"     # bind to loopback only โ€” proxy terminates TLS
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    volumes:
      - nextcloud:/var/www/html            # code+config+custom apps
      - nextcloud_data:/var/www/html/data  # user files (back THIS up)
    environment:
      - MYSQL_HOST=db
      - MYSQL_DATABASE=${MYSQL_DATABASE}
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - REDIS_HOST=redis
      - REDIS_HOST_PASSWORD=${REDIS_HOST_PASSWORD}
      - NEXTCLOUD_TRUSTED_DOMAINS=${NEXTCLOUD_TRUSTED_DOMAINS}
      - NEXTCLOUD_ADMIN_USER=${NEXTCLOUD_ADMIN_USER}        # skips wizard if set
      - NEXTCLOUD_ADMIN_PASSWORD=${NEXTCLOUD_ADMIN_PASSWORD}
      - PHP_MEMORY_LIMIT=512M
      - PHP_UPLOAD_LIMIT=10G

volumes:
  db:
  redis:
  nextcloud:
  nextcloud_data:

Bring it up:

docker compose up -d
docker compose logs -f app     # wait for "Apache/2.4.x configured"

What Happens: on first boot, the entrypoint waits for MariaDB to accept connections, creates the schema, seeds the admin user from env vars, writes /var/www/html/config/config.php, then starts Apache. First page load takes 30โ€“60s while caches warm.


Reverse Proxy + HTTPS

Expose only via TLS. Traefik example (see traefik-setup once written):

# added to the app service
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.nextcloud.rule=Host(`cloud.example.com`)"
      - "traefik.http.routers.nextcloud.entrypoints=websecure"
      - "traefik.http.routers.nextcloud.tls.certresolver=le"
      - "traefik.http.middlewares.nextcloud-hsts.headers.stsSeconds=15552000"
      - "traefik.http.middlewares.nextcloud-hsts.headers.stsIncludeSubdomains=true"
      - "traefik.http.routers.nextcloud.middlewares=nextcloud-hsts"

Then remove the ports: mapping entirely โ€” traffic flows only through the proxy network.

Caddy equivalent is two lines in a Caddyfile:

cloud.example.com {
    reverse_proxy 127.0.0.1:8080
}

Verify: curl -I https://cloud.example.com โ†’ HTTP/2 200, and the login page loads with a valid certificate.


Post-Install Configuration

Exec into the container and use occ โ€” Nextcloud's CLI (all admin tasks scriptable):

alias occ='docker compose exec -u www-data app php occ'

# 1. Background jobs via cron mode (not AJAX!)
occ background:cron

# 2. Verify redis is used for locking (should show Redis, not Database)
occ config:system:get memcache.locking

# 3. Add server's own IP to trusted proxies if behind local proxy
occ config:system:set trusted_proxies 0 --value=172.18.0.0/16

# 4. Pretty URLs
occ config:system:set overwrite.cli.url --value=https://cloud.example.com
occ config:system:set htaccess.RewriteBase --value=/
occ maintenance:update:htaccess

# 5. Maintenance window for housekeeping
occ config:system:set maintenance_window_start --type=integer --value=2

Add a host cron job for the 5-minute background worker:

sudo crontab -u www-data -e
# add:
*/5 * * * * docker compose -f /home/YOU/cloud-stack/compose.yaml exec -T -u www-data app php cron.php

What Happens: without cron mode, expensive jobs (file scanning, trash cleanup) run when a random user loads a page โ€” causing mysterious slowdowns. With cron, they run at 02:00โ€“04:00 (maintenance_window_start).

Recommended privacy opt-outs

occ config:system:set lookup_server --value=""           # no global user directory
occ config:system:set default_phone_region --value="US"   # silence setup warning
occ app:disable survey_client recommendations             # telemetry apps
occ config:system:set updatechecker --value=false         # if you patch via watchtower instead

User Management Basics

occ user:add alice                       # interactive password set
occ user:add --password-from-env bob     # scripted creation
occ group:add family
occ group:adduser family alice
occ user:setting alice settings email alice@example.com

# Quotas โ€” critical on finite disks:
occ user:setquota alice 100 GB
occ group:setquota family 500 GB

Full web UI management (sharing defaults, federation) continues in nextcloud-clients.


External Storage Preview

Nextcloud can mount S3/SMB/WebDAV locations into users' file trees. Quick sanity check that it works โ€” enable the app and list mount types:

occ app:enable files_external
occ files_external:list
# Full MinIO integration comes in lesson minio-bucket-management

Upgrades That Don't Lose Data

Because all state lives in volumes, upgrading is version-bump + recreate:

# 0. BACKUP FIRST (lesson restic-setup automates this):
docker compose exec -u www-data app php occ maintenance:mode --on
docker run --rm -v cloud_nextcloud:/src -v $PWD/bak:/dst alpine tar czf /dst/nc-data.tgz -C /src .
docker compose exec db sh -c 'exec mysqldump --single-transaction -unextcloud -p"$MYSQL_PASSWORD" nextcloud' > bak/nc-db.sql
docker compose exec -u www-data app php occ maintenance:mode --off

# 1. Bump one major version at a time (28โ†’29โ†’30), never skip
sed -i 's/nextcloud:29-apache/nextcloud:30-apache/' compose.yaml
docker compose up -d
docker compose exec -u www-data app php occ upgrade
docker compose exec -u www-data app php occ status

Common Gotchas & Troubleshooting

"Trusted domain" error on first login. NEXTCLOUD_TRUSTED_DOMAINS must match exactly what's in your browser bar. Fix live: occ config:system:set trusted_domains 1 --value=cloud.example.com

502 from the proxy but curl localhost:8080 works inside host. The proxy container isn't on the same Docker network as app. Attach it: docker network connect <proxy-net> cloud-stack-app-1.

Uploads fail over ~100MB through the proxy. Proxy body-size limit. Nginx: client_max_body_size 10G;. Also confirm PHP_UPLOAD_LIMIT took effect: occ config:system:get upload_max_filesize... actually check PHP directly: php -i | grep upload_max.

Login loops after restart. Almost always Redis auth mismatch โ€” the app container can't reach redis with the configured password. Compare config.php redis => password against .env.

Slow page loads, DB CPU pegged. Missing indexes after big import: occ db:add-missing-indices. And confirm background mode is cron (occ background:cron).

Where did my files go on disk? Named volume path on host: docker volume inspect cloud-stack_nextcloud_data โ†’ typically /var/lib/docker/volumes/cloud-stack_nextcloud_data/_data. Never edit files there manually โ€” use WebDAV/occ to avoid DB drift.


Practical Exercises

  1. Deploy the stack and create two users with quotas (one 5GB, one unlimited).
  2. Break it on purpose: stop the db container and observe the error page โ€” then restore and verify recovery.
  3. Move the data directory off Docker volumes onto a dedicated ZFS/LVM dataset, using the documented maintenance:mode โ†’ copy โ†’ update datadirectory procedure.
  4. Time an end-to-end upgrade backup+restore drill; record how long a full restore takes (you'll need that number in lesson disaster-recovery-testing).
  5. Set up a second instance on port 8081 for staging experiments.

๐Ÿ”— Related

Change Log

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse