Multi‑VM Nextcloud – Splitting Uploads & Browsing Traffic with Nginx Reverse Proxy

The Basics:

  • Nextcloud Server Version: 31.0.1
  • Operating System: Ubuntu 24.04
  • Web Server: nginx 1.24
  • Reverse Proxy: nginx 1.24
  • PHP Version: PHP 8.4 (FPM)
  • Using Cloudflare/mod_security or similar: No

Summary of the Issue:

I’m running a multi‑VM Nextcloud deployment behind an Nginx reverse proxy. My setup includes two application servers:

  • VM-A: Handles standard Nextcloud operations (file browsing, PHP processing, etc.).
  • VM-B: Dedicated to handling heavy file uploads and chunked uploads

Both servers share the same Nextcloud configuration and data via NFS, with Redis managing file locking and sessions. The goal is to have all file uploads – particularly those using chunked uploads and webdav trafic – routed to VM‑B. This way, if the upload process becomes overloaded or crashes, browsing and file viewing and download functions (served by VM‑A) remain unaffected.

Despite various configuration attempts, uploads still end up on VM‑A (or folder not found when browsing files when webdav uploads were working in VM-B). Additionally, modifying the OPTIONS handling has led to errors (404s, 500s, folder not found, etc.). I’d appreciate any guidance on how to adjust my Nginx config to reliably split the traffic as intended.

Configuration:

Below is my original Nginx configuration :

# /etc/nginx/sites-available/site.conf

server {
    listen 80;
    server_name EXAMPLE.DOMAIN.COM;

    # Allow Prometheus to access nginx_status without redirection
    location /nginx_status {
        stub_status;
        allow 127.0.0.1;
        deny all;
        access_log off;
        add_header Content-Type "text/plain";
    }

    # Redirect everything else to HTTPS, but not /nginx_status
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl http2;
    server_name EXAMPLE.DOMAIN.COM;

    ssl_certificate /path/to/ssl/fullchain.pem;
    ssl_certificate_key /path/to/ssl/privkey.pem;

    # SSL settings
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:50m;
    ssl_session_tickets off;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    # Security headers
    add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
    add_header Referrer-Policy "no-referrer" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Download-Options "noopen" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Permitted-Cross-Domain-Policies "none" always;
    add_header X-XSS-Protection "1; mode=block" always;

    # CORS headers
    add_header Access-Control-Allow-Origin $http_origin always;
    add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, PROPFIND, OPTIONS, REPORT, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK' always;
    add_header Access-Control-Allow-Headers 'Authorization, Content-Type, Depth, Destination, DNT, If-Modified-Since, Keep-Alive, OCS-APIREQUEST, Origin, Referer, User-Agent, X-Requested-With, X-OC-Mtime' always;
    add_header Access-Control-Allow-Credentials 'true' always;

    # Upload limitations
    client_max_body_size 800G;
    client_body_timeout 7200s;

    # Proxy buffer settings for large responses
    proxy_buffers 8 4k;
    proxy_buffer_size 4k;

    # Common proxy settings
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 7200;
    proxy_send_timeout 7200;

    # Handle OPTIONS requests - ONLY match OPTIONS method specifically
    location ~ ^/ {
        if ($request_method = 'OPTIONS') {
            add_header Access-Control-Max-Age 1728000;
            add_header Content-Type 'text/plain charset=UTF-8';
            add_header Content-Length 0;
            return 204;
        }
        # This request isn't OPTIONS, so pass to the next matching location block
        proxy_pass http://VM-A_IP;
    }

    # Static content handling
    location ~* \.(?:css|js|woff2?|svg|gif|map|png|html|ttf|ico|jpg|jpeg|eot)$ {
        proxy_pass http://VM-A_IP;
        proxy_set_header Host $host;
        proxy_buffering off;
        proxy_request_buffering off;
        proxy_cache_bypass $http_pragma;
        proxy_cache_revalidate on;
        expires 7d;
        access_log off;
    }

    # PHP processing - specific to avoid loops
    location ~ ^/(?:index|remote|public|cron|core/ajax/update|status|ocs/v[12]|updater|oc[ms]-provider|ocm-provider)\.php(?:$|/) {
        proxy_pass http://VM-A_IP;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_max_temp_file_size 0;
        proxy_connect_timeout 90;
        proxy_send_timeout 90;
        proxy_read_timeout 90;
        proxy_buffering off;
        proxy_request_buffering off;
        proxy_set_header Authorization $http_authorization;
        proxy_pass_header Authorization;
    }

    # WebDAV routes for all file operations including Photos app
    location ~ ^/remote\.php/(?:webdav|dav|caldav|carddav|davs|files|photos|preview)(?:$|/) {
        proxy_pass http://VM-B_IP;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header OCS-APIRequest "true";
        proxy_set_header X-Requested-With $http_x_requested_with;
        proxy_set_header Authorization $http_authorization;
        proxy_pass_header Authorization;
        proxy_cookie_path / "/; secure; HttpOnly; SameSite=None";
        proxy_buffering off;
        proxy_request_buffering off;
        proxy_max_temp_file_size 0;
        client_body_buffer_size 512k;
        proxy_read_timeout 7200;
        proxy_connect_timeout 300;
        proxy_send_timeout 300;
        proxy_pass_header Content-Type;
        proxy_hide_header set-cookie;
    }

    # Photos app specific handling
    location ~ ^/index\.php/apps/photos/ {
        proxy_pass http://VM-A_IP;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # API access
    location ~ ^/ocs/v[12]\.php/ {
        proxy_pass http://VM-A_IP;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header OCS-APIRequest "true";
        proxy_set_header Authorization $http_authorization;
        proxy_pass_header Authorization;
    }

    # Preview generation
    location ~ ^/(?:core|apps)/preview\.(?:php|png|jpg|webp)(?:$|/) {
        proxy_pass http://VM-B_IP;
        proxy_set_header Host $host;
        proxy_buffering off;
        proxy_request_buffering off;
    }

    # Chunked upload handling
    location ~ ^/remote\.php/dav/uploads {
        proxy_pass http://VM-B_IP;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Requested-With $http_x_requested_with;
        proxy_set_header Authorization $http_authorization;
        proxy_pass_header Authorization;
        proxy_buffering off;
        proxy_request_buffering off;
        proxy_max_temp_file_size 0;
        client_body_buffer_size 512k;
        proxy_connect_timeout 300;
        proxy_send_timeout 300;
        proxy_read_timeout 7200;
        proxy_pass_header Content-Type;
    }

    # Default route 
    location / {
        proxy_pass http://VM-A_IP;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_buffering off;
        proxy_request_buffering off;
        proxy_set_header Authorization $http_authorization;
        proxy_pass_header Authorization;
    }
}

What I’ve Tried:
I’ve attempted to split the traffic by routing:

  • All requests to /remote.php/dav/uploads (and related chunked upload endpoints) to VM‑B.
  • Other Nextcloud operations (static files, PHP processing, previews, etc.) remain on VM‑A.
  • I also tried different OPTIONS handling blocks for CORS preflight, but this either routes uploads incorrectly or produces errors like 404/500, “folder not found,” etc.

Despite these efforts, uploads are not reliably directed to VM‑B, which is critical because heavy uploads can cause VM‑A to slow down or crash—affecting user browsing and downloads.

Any suggestions on how to tweak this configuration or an alternative approach to splitting the load would be greatly appreciated!

I don’t think such load split gonna work. Definitely running multiple instances you could benefit from improved stability if one crashes. I think “even” load distribution (load balancing) is superior at all - all instances handle the same load in opposite to you suggested design when the load on a specific instance is distributed more or less erratic.

the ideea is that all this is in one server, files uploads are on a 60tb raidz2 with special vdev array, and thumbnails are in a nvme ssd, and when users browse pictures they look at small thumbnails generated , could have 10-15+concurent users looking at thumbnails and 1-2 users uploading or syncing files , 10gb nic, could potentially sync from desktop app 100.000 small jpg files or 1 raw video 600-700gb one file, and upload must not “bother” other users that are watching pictures so that vm must not experience slowdown form upload, that is why i want to keep them separated .