SSD Nodes Learn
Guides Matt ConnorBy Matt Connor · Updated 2026-07-15

Run Nextcloud on a VPS: Docker, TLS, backups

Run Nextcloud on your own VPS with Docker Compose, Postgres, Redis and a TLS reverse proxy, plus the backup and upgrade steps that keep your data recoverable.

What you are actually building

Four containers and a proxy: the official nextcloud image listening on loopback, Postgres holding every piece of file metadata, Redis holding the file locks, a second copy of the Nextcloud image running nothing but the cron loop, and nginx on the host terminating TLS in front of all of it. The install itself takes twenty minutes, and it is not the part that matters. Two decisions made in the first hour decide whether you still have your files in a year: a real database instead of SQLite, and a backup that captures the data directory, the database and config.php as one consistent set.

This assumes Ubuntu 24.04 LTS or Debian 13, Docker Engine with the Compose v2 plugin installed from Docker's own repository, and a DNS A record (plus AAAA if you have IPv6) already pointing cloud.example.com at the VPS. All of it needs a server you control — there is no way to do TLS termination and a database dump on someone else's SaaS.

Sizing: what actually consumes the memory

Nextcloud's memory use is dominated by three things, and none of them is "Nextcloud" as such.

PHP workers. The -apache image serves each concurrent request from a worker process that holds a PHP interpreter. Each worker may grow up to PHP_MEMORY_LIMIT before PHP kills the request. Your worst-case resident memory is roughly concurrent requests × the memory limit, and a desktop sync client opens several parallel connections per user. Concurrency, not user count, sets the ceiling.

The database. Postgres forks a backend per connection and keeps shared buffers resident. Its working set scales with the number of files, not the number of bytes: oc_filecache carries a row per file per user. A hundred thousand small files is a heavier database than a hundred large ones.

Preview generation. Generating a thumbnail decodes the source image into memory at full resolution. Video previews shell out to ffmpeg. Running occ preview:generate-all performs that spike repeatedly, back to back, and is the single most common way to push a small VPS into the OOM killer.

Redis is comparatively cheap. Anything you bolt on later — Collabora, full-text search, an antivirus scanner — is a separate resident service with its own footprint, and belongs in your sizing plan before you enable it.

The levers, if you are tight on RAM: lower PHP_MEMORY_LIMIT, cap preview_max_x / preview_max_y / preview_max_filesize_image, trim enabledPreviewProviders to the formats you actually browse, and set trashbin_retention_obligation and versions_retention_obligation so the data directory does not silently grow to several times the size of your files. Add a swap file. Swap is slow, and an OOM kill mid-upgrade is worse.

Why SQLite breaks

Nextcloud ships with SQLite support and the official image will happily use it. Do not. SQLite serialises writes with a database-wide lock: one writer at a time, for the whole file. Nextcloud writes constantly — file locks, activity rows, cache entries, job state — and a single desktop client syncing a directory tree issues many parallel requests. Under that pattern you get SQLSTATE[HY000]: General error: 5 database is locked and HTTP 500s, and the failure appears exactly when the instance starts being useful.

Converting later is possible with occ db:convert-type, but it is a long, all-or-nothing migration on a live dataset. Start with Postgres or MariaDB.

The Compose file

Put this in /srv/nextcloud/compose.yaml, with secrets in a sibling .env file at mode 600.

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

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --requirepass ${REDIS_PASSWORD}

  app:
    image: nextcloud:31-apache
    restart: unless-stopped
    depends_on: [db, redis]
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - html:/var/www/html
      - /srv/nextcloud/data:/var/www/html/data
    environment:
      POSTGRES_HOST: db
      POSTGRES_DB: nextcloud
      POSTGRES_USER: nextcloud
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      REDIS_HOST: redis
      REDIS_HOST_PASSWORD: ${REDIS_PASSWORD}
      NEXTCLOUD_ADMIN_USER: admin
      NEXTCLOUD_ADMIN_PASSWORD: ${ADMIN_PASSWORD}
      NEXTCLOUD_TRUSTED_DOMAINS: cloud.example.com
      TRUSTED_PROXIES: 172.16.0.0/12
      OVERWRITEPROTOCOL: https
      OVERWRITECLIURL: https://cloud.example.com
      APACHE_DISABLE_REWRITE_IP: "1"
      PHP_MEMORY_LIMIT: 512M
      PHP_UPLOAD_LIMIT: 10G

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

volumes:
  db:
  html:

Pin the major tag and check the current one on Docker Hub before you copy 31 verbatim. latest will roll you across a major boundary on some future docker compose pull, and Nextcloud does not support that.

The data directory is a bind mount, not a named volume, on purpose: a path you can point a backup tool at directly is worth more than tidiness. Create it with the image's www-data UID and the permissions Nextcloud demands:

sudo mkdir -p /srv/nextcloud/data
sudo chown -R 33:33 /srv/nextcloud/data
sudo chmod 0770 /srv/nextcloud/data

Note the port publish: 127.0.0.1:8080:80. Docker publishes ports by writing DNAT rules that are evaluated before ufw's INPUT chain ever sees the packet — a bare 8080:80 puts an unencrypted Nextcloud on the public internet no matter what ufw says. Binding to loopback keeps it off the public interface. Then the firewall only has to allow the proxy — and if you would rather not leave SSH open to the whole internet, reaching the VPS over a self-hosted WireGuard VPN lets you drop port 22 from the public rules entirely:

sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Bring it up with docker compose up -d, then watch docker compose logs -f app. First boot copies the whole application tree into the volume and runs the installer; the container answers nothing until that finishes.

TLS and the reverse proxy

Install nginx and certbot from the distro, create a plain port-80 server block with the right server_name, then let certbot rewrite it. The mechanics of the HTTP-01 challenge, the renewal timer and the failure modes are covered in full in issuing Let's Encrypt certificates with certbot and nginx on Ubuntu 24.04:

sudo apt install nginx certbot python3-certbot-nginx
sudo certbot --nginx -d cloud.example.com

Certbot adds the ssl_certificate lines and the :80:443 redirect, and installs a systemd timer that renews the 90-day certificate. Confirm it exists with systemctl list-timers | grep certbot — a renewal timer that was never enabled is a 90-day fuse.

The proxy block itself:

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name cloud.example.com;

    # certbot manages ssl_certificate / ssl_certificate_key here

    add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;

    client_max_body_size 10G;
    client_body_timeout 300s;

    location = /.well-known/carddav { return 301 /remote.php/dav; }
    location = /.well-known/caldav  { return 301 /remote.php/dav; }

    location / {
        proxy_pass http://127.0.0.1:8080;
        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_request_buffering off;
        proxy_buffering off;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

On nginx 1.25 and newer, add http2 on;. Ubuntu 24.04 ships an older build where the equivalent is listen 443 ssl http2;. nginx -t will tell you which one your build accepts.

client_max_body_size and the long read timeouts are what stop large uploads dying halfway. proxy_request_buffering off streams the upload through rather than spooling the whole file to the proxy's disk first.

nginx on the host is the simplest thing that works for one app. If Nextcloud is going to share the VPS with other containers, running Traefik as a Docker Compose reverse proxy for multiple apps moves routing and certificate issuance into container labels, and the same client_max_body_size and timeout concerns reappear there as middleware and transport settings.

trusted_proxies and overwriteprotocol

This is where most self-hosted Nextcloud instances go wrong, and the symptoms look unrelated to the cause.

X-Forwarded-Proto: https is only honoured when the request arrives from an address listed in trusted_proxies. When it is not honoured, Nextcloud believes the request is plain HTTP and emits http:// URLs; the proxy redirects those to HTTPS; the browser follows; Nextcloud emits http:// again. That is the redirect loop. OVERWRITEPROTOCOL: https pins the scheme regardless.

The trap in TRUSTED_PROXIES is that the address Nextcloud sees is not 127.0.0.1. nginx runs on the host and connects to a published port, so the container sees the Docker bridge gateway — something in 172.x. Find the real subnet:

docker network inspect nextcloud_default \
  -f '{{range .IPAM.Config}}{{.Subnet}}{{end}}'

Put that CIDR (or the covering 172.16.0.0/12) in TRUSTED_PROXIES. Set it too wide and any client could spoof X-Forwarded-For; set it wrong and every login appears to come from the gateway address, brute-force protection blocks your whole instance at once, and the admin overview shows "The reverse proxy header configuration is incorrect, or you are accessing Nextcloud from a trusted proxy."

OVERWRITECLIURL matters for the cron container, which has no incoming request to infer a hostname from. Without it, background jobs generate links to localhost and email notifications ship unusable URLs.

Background jobs: cron, not AJAX

Nextcloud's default job runner is AJAX: jobs execute as a side effect of someone loading a page. Nobody browses at 04:00, so trash expiry, versions cleanup, previews and federated retries stall, and the first symptom is a data directory that never stops growing. The cron service above runs the official /cron.sh loop against the same volumes. Tell Nextcloud to expect it:

docker compose exec -u www-data app php occ background:cron

Every occ command follows that shape: docker compose exec -u www-data app php occ <command>. It is worth aliasing.

Backups: three things, or none

A filesystem-only backup restores to a broken instance. The data directory holds the bytes; Postgres holds the file cache, shares, users, and app state; config.php holds the database credentials, the instance ID and the password salt. Restore the files without the database and Nextcloud cannot see them. Restore the database without config.php and it cannot open the database. Restore an old database against a newer data directory and you get shares pointing at files that moved.

Back up all three, from a quiesced instance:

#!/usr/bin/env bash
set -euo pipefail
cd /srv/nextcloud
DEST="/var/backups/nextcloud/$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$DEST"

occ() { docker compose exec -T -u www-data app php occ "$@"; }

occ maintenance:mode --on
trap 'occ maintenance:mode --off' EXIT

docker compose exec -T db \
  pg_dump -U nextcloud --clean --if-exists nextcloud | gzip > "$DEST/db.sql.gz"

docker compose exec -T app \
  tar -C /var/www/html -cf - config custom_apps themes > "$DEST/app.tar"

rsync -a --delete /srv/nextcloud/data/ /var/backups/nextcloud/data/

Maintenance mode is what makes the dump and the file copy agree with each other. Skip it and you will eventually capture a database that references a file the rsync had not reached yet.

Then get it off the box. A backup that lives on the same VPS as the thing it backs up is a copy, not a backup. restic against object storage or a second host is the usual answer, and its deduplication handles the data directory far better than a nightly tarball.

Restore is the reverse, into a freshly-started stack:

gunzip -c db.sql.gz | docker compose exec -T db psql -U nextcloud -d nextcloud
docker compose exec -T -u www-data app php occ files:scan --all
docker compose exec -T -u www-data app php occ maintenance:mode --off

files:scan reconciles the file cache with what is actually on disk. Rehearse this once, on a spare VPS, before you need it.

Upgrades: one major at a time

Nextcloud supports upgrading exactly one major version at a time. Jumping from 29 to 31 does not fail gracefully — it fails with Exception: Updates between multiple major versions and downgrades are unsupported. and leaves you in maintenance mode.

The Docker upgrade is: take a backup, edit the tag from 31 to 32 in both the app and cron services, then docker compose pull && docker compose up -d, then docker compose logs -f app. The image entrypoint detects the newer code against the existing data and runs occ upgrade itself. Do not interrupt it. When the logs go quiet, run docker compose exec -u www-data app php occ status and check versionstring and that apps came back enabled.

Two rules that save you: bump one major, verify, then bump the next. And never edit the tag on the app service without editing cron to match — two different Nextcloud versions against one database is a corruption path.

The errors you will actually see

"Your data directory is readable by other users. Please change the permissions to 0770." The bind-mounted directory has group or world read bits. sudo chmod 0770 /srv/nextcloud/data and sudo chown -R 33:33 /srv/nextcloud/data.

"Your data directory is invalid. Ensure there is a file called .ocdata in the root." The bind mount points somewhere Nextcloud never initialised — a typo in the path, or a fresh empty directory swapped under a working instance. Check the host path matches the volume line.

"Access through untrusted domain." The hostname in the request is not in trusted_domains. NEXTCLOUD_TRUSTED_DOMAINS only applies at first install; afterwards set it live: occ config:system:set trusted_domains 1 --value=cloud.example.com.

502 Bad Gateway, with connect() failed (111: Connection refused) while connecting to upstream in /var/log/nginx/error.log. nginx reached nothing on 127.0.0.1:8080. Either the container is still initialising (check docker compose logs app), it exited (docker compose ps), or the publish line does not match the proxy_pass port. Confirm with ss -ltnp | grep 8080.

A redirect loop, or "insecure" warnings in the admin overview. OVERWRITEPROTOCOL: https is missing, or TRUSTED_PROXIES does not contain the Docker gateway subnet. See the proxy section above.

LockedException: "files/..." is locked. With REDIS_HOST set, the image configures Redis as the locking backend and stale locks are rare. Without it, locks live in the database table oc_file_locks and a request killed mid-write leaves rows behind. Confirm Redis is actually in use — occ config:system:get memcache.locking should return the Redis class — before you go clearing lock rows by hand.

"The PHP memory limit is below the recommended value of 512MB." Raise PHP_MEMORY_LIMIT and recreate the container. Remember what that does to your worst-case ceiling.

What breaks at scale

The first wall is the data directory outgrowing the volume. Growing a volume on a VPS is a resize plus a filesystem grow, and it is far less painful scheduled than at 100% full — alert on disk usage now, not later.

The second wall is oc_filecache. File listings and sync scans slow down with row count, and the fix is database work: keep Postgres on fast storage, let it use enough shared memory, and prune trash and versions with retention settings rather than letting them accumulate forever.

The third is preview generation competing with everything else. On a small box, keep preview providers narrow and never run occ preview:generate-all during working hours.

Beyond that, the honest answer is that the extras want their own machine. Collabora and full-text search are separate resident services with their own memory profiles, and putting them on the box that also holds your only copy of your files makes the failure domain bigger for no benefit. Move file storage to S3-compatible primary storage when the volume stops being the right shape — and note that this makes backups harder, not easier: the database still holds the metadata, and it must be dumped in step with the bucket.

FAQ

Can I run Nextcloud on SQLite instead of Postgres?

You can, and the official image will let you, but a single desktop sync client issuing parallel requests will hit SQLSTATE[HY000]: General error: 5 database is locked and HTTP 500s. SQLite takes a database-wide write lock, and Nextcloud writes constantly — file locks, activity rows, job state. Start on Postgres or MariaDB; occ db:convert-type exists but it is a long, all-or-nothing migration on live data.

How much RAM does a Nextcloud VPS actually need?

Size for concurrency, not user count. Worst-case resident memory is roughly the number of concurrent requests multiplied by PHP_MEMORY_LIMIT, plus Postgres shared buffers and one backend per connection, plus whatever preview generation spikes to. A 2 GB box runs a small household instance if you cap previews and add swap; add Collabora or full-text search and you are sizing a second set of resident services.

Why do large uploads fail behind the nginx reverse proxy?

Two settings on the proxy usually explain it: client_max_body_size left at the 1 MB default truncates the request, and short proxy_read_timeout / proxy_send_timeout values kill long transfers midway. Set both generously, turn proxy_request_buffering off to stream rather than spool, and raise PHP_UPLOAD_LIMIT on the app container to match.

Why does Nextcloud redirect in a loop or warn about the reverse proxy?

The container does not see nginx at 127.0.0.1 — it sees the Docker bridge gateway, somewhere in 172.x. When that address is missing from TRUSTED_PROXIES, the X-Forwarded-Proto: https header is ignored, Nextcloud emits http:// URLs, and the proxy bounces them back. Set TRUSTED_PROXIES to the real bridge subnet and pin OVERWRITEPROTOCOL: https.

Can I upgrade Nextcloud from 29 straight to 31?

No. Nextcloud supports one major version per upgrade, and skipping stops with Updates between multiple major versions and downgrades are unsupported., leaving the instance in maintenance mode. Back up, bump the tag one major on both the app and cron services, docker compose pull && docker compose up -d, verify with occ status, then repeat.