Skip to Content

Deploy Nextcloud with Docker Compose, Nginx, and Redis on Ubuntu (Production Guide)

A production-ready self-hosted file collaboration stack with PostgreSQL, Redis caching, Nginx reverse proxy, TLS, and full day-2 operations guidance.

Primary keyword: Nextcloud Docker Compose Nginx Redis production deployment

Secondary keywords: self-hosted file sharing, Nextcloud Ubuntu guide, Nextcloud production setup, Nextcloud Nginx reverse proxy

Introduction: real-world use case

When organizations move sensitive documents, design assets, or compliance records off consumer cloud platforms, they need a self-hosted alternative that gives them full ownership of where and how data is stored. Nextcloud is the most widely deployed open-source file sharing platform for exactly this reason: it provides a Google Drive–grade experience — file sync, sharing, Office-compatible document editing, calendar, and contacts — while keeping all data on infrastructure the team controls.

In practice, teams reach for Nextcloud when they need to satisfy data residency requirements, support internal file collaboration without vendor lock-in, or reduce subscription costs as headcount grows. Common deployment contexts include medical offices avoiding HIPAA exposure through consumer storage, construction firms centralizing project blueprints across field and office, and financial services teams replacing SharePoint with something they can audit and operate independently.

This guide covers a production-ready Nextcloud deployment on Ubuntu using Docker Compose for service orchestration, PostgreSQL as the transactional database, Redis for distributed caching and session locking (critical for multi-user reliability), and Nginx as the edge reverse proxy handling TLS termination. The result is a deployment you can hand to another engineer with clear operational boundaries, secret management, and day-2 runbooks built in.

Architecture/flow overview

The stack has four layers working together. At the edge, Nginx terminates HTTPS connections, handles certificate renewal (via Certbot or static cert mount), and forwards requests to the Nextcloud PHP-FPM container on an internal Compose network. Nextcloud writes relational data — users, shares, permissions, metadata — to PostgreSQL. Redis provides two functions: a memory object cache that accelerates file list and metadata queries, and a transactional file locking backend that prevents data corruption when multiple users or sync clients access the same file simultaneously without coordination.

The Compose topology separates concerns cleanly: Nginx handles edge security and routing, Nextcloud handles application logic, PostgreSQL handles data integrity, and Redis handles caching and locking. Only Nginx is exposed publicly. PostgreSQL and Redis are private on the internal Docker network. Static assets are served directly from the Nextcloud container rather than passed through PHP, which keeps page load performance acceptable without a CDN for small and medium deployments.

Persistent volumes protect all stateful data. The Nextcloud data volume holds user file trees. The PostgreSQL volume holds the database. This means container updates or replacements do not cause data loss. Every service version is pinned in the Compose file, which makes rollback straightforward: restore the previous image tag and restart. This architecture is designed to run reliably on a single VPS for teams of 5–100 users while keeping operational complexity low enough for a small IT team to manage without dedicated DevOps resources.

Prerequisites

  • Ubuntu 22.04 or 24.04 VPS with at least 2 vCPU, 4 GB RAM, and 40+ GB SSD. Add more storage as your data volume grows.
  • A DNS A record pointing your domain (e.g., cloud.example.com) to the server's public IP.
  • SSH access with a sudo-capable user.
  • Docker Engine (24+) and Docker Compose v2+ installed.
  • Certbot or an existing TLS certificate. This guide uses the certonly/webroot flow with Nginx as the challenge server.
  • Basic familiarity with Nginx server blocks and Compose YAML syntax.

Step-by-step deployment

Step 1 — Install Docker and Docker Compose

sudo apt update && sudo apt install -y ca-certificates curl gnupg lsb-release
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) 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-compose-plugin
sudo systemctl enable --now docker
sudo usermod -aG docker $USER

If copy button does not work in your browser/editor, select the block and copy manually.

Log out and back in so the group change takes effect, then verify with docker compose version.

Step 2 — Create project directory and environment file

sudo mkdir -p /opt/nextcloud
sudo chown $USER:$USER /opt/nextcloud
cd /opt/nextcloud

If copy button does not work in your browser/editor, select the block and copy manually.

Create the secrets file with restricted permissions:

cat > /opt/nextcloud/.env <<'EOF'
POSTGRES_DB=nextcloud
POSTGRES_USER=nextcloud
POSTGRES_PASSWORD=CHANGEME_strong_postgres_password
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=CHANGEME_strong_admin_password
NEXTCLOUD_TRUSTED_DOMAINS=cloud.example.com
REDIS_HOST_PASSWORD=CHANGEME_redis_password
EOF
chmod 600 /opt/nextcloud/.env

If copy button does not work in your browser/editor, select the block and copy manually.

Replace every CHANGEME_ value with strong, unique passwords before proceeding.

Step 3 — Write the Docker Compose file

version: "3.9"

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    volumes:
      - db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    networks:
      - internal

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

  nextcloud:
    image: nextcloud:29-fpm-alpine
    restart: unless-stopped
    depends_on:
      - db
      - redis
    volumes:
      - nextcloud_data:/var/www/html
    environment:
      POSTGRES_HOST: db
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER}
      NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
      NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS}
      REDIS_HOST: redis
      REDIS_HOST_PASSWORD: ${REDIS_HOST_PASSWORD}
    networks:
      - internal

  nginx:
    image: nginx:1.27-alpine
    restart: unless-stopped
    depends_on:
      - nextcloud
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - nextcloud_data:/var/www/html:ro
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs:/etc/nginx/certs:ro
      - certbot_webroot:/var/www/certbot:ro
    networks:
      - internal

volumes:
  db_data:
  redis_data:
  nextcloud_data:
  certbot_webroot:

networks:
  internal:
    driver: bridge

If copy button does not work in your browser/editor, select the block and copy manually.

Step 4 — Write the Nginx configuration

upstream nextcloud_fpm {
    server nextcloud:9000;
}

server {
    listen 80;
    server_name cloud.example.com;
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    server_name cloud.example.com;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    root /var/www/html;
    client_max_body_size 10G;
    fastcgi_buffers 64 4k;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options nosniff;
    add_header X-Frame-Options SAMEORIGIN;
    add_header X-Permitted-Cross-Domain-Policies none;
    add_header Referrer-Policy no-referrer;

    location = /robots.txt { allow all; log_not_found off; access_log off; }
    location = /favicon.ico { log_not_found off; access_log off; }

    location ^~ /.well-known {
        location = /.well-known/carddav  { return 301 /remote.php/dav/; }
        location = /.well-known/caldav   { return 301 /remote.php/dav/; }
        try_files $uri $uri/ =404;
    }

    location ~ ^\/(?:build|tests|config|lib|3rdparty|templates|data)\/ { deny all; }
    location ~ ^\/(?:\.|autotest|occ|issue|indie|db_|console)          { deny all; }

    location ~ ^\/(?:index|remote|public|cron|core\/ajax\/update|status|ocs\/v[12]|updater\/.+|oc[ms]-provider\/.+)\.php(?:$|\/) {
        fastcgi_split_path_info ^(.+?\.php)(\/.*|)$;
        set $path_info $fastcgi_path_info;
        try_files $fastcgi_script_name =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $path_info;
        fastcgi_param HTTPS on;
        fastcgi_param modHeadersAvailable true;
        fastcgi_param front_controller_active true;
        fastcgi_pass nextcloud_fpm;
        fastcgi_intercept_errors on;
        fastcgi_request_buffering off;
        fastcgi_read_timeout 300;
    }

    location ~ ^\/(?:updater|oc[ms]-provider)(?:$|\/) {
        try_files $uri/ =404;
        index index.php;
    }

    location ~ \.(?:css|js|woff2?|svg|gif|map)$ {
        try_files $uri /index.php$request_uri;
        add_header Cache-Control "public, max-age=15778463, immutable";
        expires 6M;
        access_log off;
    }

    location ~ \.(?:png|html|ttf|ico|jpg|jpeg|bcmap|mp4|webm)$ {
        try_files $uri /index.php$request_uri;
        access_log off;
    }

    location / {
        try_files $uri $uri/ /index.php$request_uri;
    }
}

If copy button does not work in your browser/editor, select the block and copy manually.

Replace cloud.example.com with your actual domain throughout.

Step 5 — Obtain TLS certificates

# Start only the nginx service for the ACME challenge
docker compose up -d nginx

# Install Certbot and obtain certificate (webroot method)
sudo apt install -y certbot
sudo certbot certonly --webroot -w /var/lib/docker/volumes/nextcloud_certbot_webroot/_data \
  -d cloud.example.com \
  --non-interactive --agree-tos -m [email protected]

# Copy certs to the project directory
sudo mkdir -p /opt/nextcloud/certs
sudo cp /etc/letsencrypt/live/cloud.example.com/fullchain.pem /opt/nextcloud/certs/
sudo cp /etc/letsencrypt/live/cloud.example.com/privkey.pem   /opt/nextcloud/certs/
sudo chown -R $USER:$USER /opt/nextcloud/certs
chmod 600 /opt/nextcloud/certs/*.pem

If copy button does not work in your browser/editor, select the block and copy manually.

Step 6 — Bring the full stack up

cd /opt/nextcloud
docker compose up -d
docker compose ps
docker compose logs --follow --tail=60

If copy button does not work in your browser/editor, select the block and copy manually.

Wait for the Nextcloud container to finish its first-run database migration. This typically takes 30–90 seconds on the first boot. Look for a line in the logs indicating the installation completed and the PHP-FPM process is listening on port 9000.

Step 7 — Run post-install configuration commands

# Configure Redis as the memory cache and file locking backend
docker compose exec nextcloud php occ config:system:set memcache.local  --value="\OC\Memcache\Redis"
docker compose exec nextcloud php occ config:system:set memcache.locking --value="\OC\Memcache\Redis"
docker compose exec nextcloud php occ config:system:set redis host       --value=redis
docker compose exec nextcloud php occ config:system:set redis port       --value=6379 --type=integer
docker compose exec nextcloud php occ config:system:set redis password   --value="${REDIS_HOST_PASSWORD}"

# Set correct trusted proxy header
docker compose exec nextcloud php occ config:system:set trusted_proxies 0 --value=nginx

# Remove the default skeleton files for clean new user provisioning (optional)
docker compose exec nextcloud php occ config:system:set skeletondirectory --value=""

# Update .htaccess rules after config changes
docker compose exec nextcloud php occ maintenance:update:htaccess

If copy button does not work in your browser/editor, select the block and copy manually.

Configuration and secrets handling

All environment variables live in /opt/nextcloud/.env with permissions set to 600. Never commit this file to version control. Rotate passwords using the following process: generate a new credential, update .env, restart the affected service with docker compose up -d --no-deps <service>, verify connectivity, then decommission the old credential. For PostgreSQL password rotation, also run the SQL command ALTER USER nextcloud PASSWORD 'new_password'; inside the container before restarting dependent services.

For production environments with stronger secret hygiene requirements, consider mounting secrets from HashiCorp Vault or Docker secrets rather than an .env file. The Nextcloud PHP-FPM container reads standard Compose environment variables, which makes the transition straightforward — replace environment: keys with secrets: mounts and update the entrypoint to read from /run/secrets/. Use separate keys for database credentials, Redis password, and admin credentials to limit blast radius on compromise.

TLS certificates in /opt/nextcloud/certs/ should be renewed automatically. Add a cron job to renew, copy, and reload Nginx:

sudo crontab -e
# Add this line:
0 3 * * * certbot renew --quiet && cp /etc/letsencrypt/live/cloud.example.com/*.pem /opt/nextcloud/certs/ && docker compose -f /opt/nextcloud/docker-compose.yml exec nginx nginx -s reload

If copy button does not work in your browser/editor, select the block and copy manually.

Verification checklist

# Check all containers are running
docker compose ps

# Confirm Nextcloud responds over HTTPS
curl -I https://cloud.example.com

# Check Nextcloud system status
docker compose exec nextcloud php occ status

# Verify Redis connectivity and caching is active
docker compose exec nextcloud php occ config:system:get memcache.locking

# Test file upload via WebDAV
curl -u admin:password -T /etc/hostname https://cloud.example.com/remote.php/dav/files/admin/test.txt

# Check background job queue is processing
docker compose exec nextcloud php occ background:cron

If copy button does not work in your browser/editor, select the block and copy manually.

After verification, log into the web UI, create a test user, upload a file, share it, and confirm the link resolves correctly. This end-to-end smoke test catches permission misconfiguration that unit-level checks might miss.

Common issues/fixes

Issue: 502 Bad Gateway from Nginx

Usually means the Nextcloud PHP-FPM container is not ready or the fastcgi_pass upstream name does not resolve. Confirm Nextcloud is running (docker compose ps), check container logs for PHP-FPM startup errors, and ensure the service name in Compose matches the upstream block in nginx.conf.

Issue: Nextcloud installation loop / infinite loading on first visit

Check the Nextcloud container logs for database connection errors. Verify POSTGRES_HOST matches the service name in Compose (db) and the password in .env matches what PostgreSQL was initialized with. Recreating the PostgreSQL volume and restarting resolves initialization mismatches on fresh deployments.

Issue: Redis connection refused or authentication failure

Verify the REDIS_HOST_PASSWORD value is identical in both .env and in the occ config commands. The redis-server --requirepass argument must match. Restart the Redis container after any password change.

Issue: File locking errors during concurrent sync

This indicates Redis caching or locking is not properly configured. Rerun the occ config:system:set memcache.locking and Redis connection commands from Step 7. Confirm the Redis container is healthy and the network link between Nextcloud and Redis containers is up.

Issue: TLS certificate not renewing

Run certbot renew --dry-run to simulate renewal. Ensure port 80 is reachable externally (required for HTTP-01 challenges) and the Nginx webroot location block is serving /var/www/certbot correctly. Check cron job output with sudo crontab -l and look for errors in /var/log/syslog.

Issue: Large file uploads timing out

Increase client_max_body_size in nginx.conf and fastcgi_read_timeout to accommodate upload size and server processing time. Also adjust PHP_UPLOAD_LIMIT and PHP_MEMORY_LIMIT environment variables on the Nextcloud container if running a custom image.

FAQ

1) Why PostgreSQL instead of MySQL or MariaDB?

Nextcloud officially supports PostgreSQL, MySQL, MariaDB, and SQLite. PostgreSQL is preferred for production workloads because it handles concurrent writes more robustly, enforces stronger data integrity by default, and is better supported in long-term Nextcloud major version upgrades. SQLite is suitable only for single-user evaluations — never for multi-user production.

2) Is Redis strictly required or optional?

For single-user testing it is optional. For any production deployment with multiple users or sync clients, Redis is essential. Without it, Nextcloud uses a PHP-based file locking mechanism that does not work reliably across concurrent requests and causes "file currently locked" errors under normal multi-client usage. Redis locking is a hard operational requirement for team deployments.

3) How do I handle Nextcloud major version upgrades?

Major version upgrades (e.g., Nextcloud 29 → 30) must be done sequentially, one major version at a time. Bump the image tag in docker-compose.yml, run docker compose pull, then docker compose up -d, and immediately run docker compose exec nextcloud php occ upgrade. Back up both the database and the nextcloud_data volume before upgrading. Never skip a major version.

4) How do I back up and restore the Nextcloud instance?

Back up three components: the PostgreSQL database (via pg_dump), the Nextcloud data volume (via rsync or volume snapshot), and the .env and nginx.conf files. Enable maintenance mode with php occ maintenance:mode --on before backup to prevent in-progress writes. Restore by recreating volumes, importing the database dump, and bringing Compose back up. Test restores in a staging environment at least monthly.

5) Can I enable two-factor authentication for users?

Yes. Install the twofactor_totp app via the Nextcloud App Store or via OCC: docker compose exec nextcloud php occ app:enable twofactor_totp. You can also enforce 2FA for all users with occ twofactorauth:enforce --on. This is strongly recommended for any internet-facing production deployment.

6) How do I add more storage without migrating?

Mount an additional volume or network storage path to the Nextcloud container at a path like /mnt/extra_storage, then configure it as an external storage location in the Nextcloud admin panel under Settings → Administration → External storages. The built-in Local storage backend works for paths mounted into the container. For S3-compatible object storage at scale, use the files_external app with S3 configuration. This approach avoids data migration while expanding capacity incrementally.

Internal links

Talk to us

Need help deploying a production-ready self-hosted file collaboration platform, migrating from cloud storage, or integrating Nextcloud with your identity provider and existing workflows? We can help with architecture, secure rollout, data migration planning, and runbook design.

Contact Us

Production Guide: Deploy Plausible with Docker Compose + Caddy + ClickHouse on Ubuntu
A production-ready, privacy-first analytics deployment with secure defaults, verification, and day-2 operations guidance.