Skip to content

Nextcloud

Nextcloud holds documents, calendars and contacts, and it syncs them to desktops and phones. It is the stack in this Blueprint with the most moving parts: an application container, a separate web server, a database, a cache, and a background worker.

Last run on a host 9 Nov 2026 · data restored from a backup

Pinned at 35.0.2-fpm-alpine (released 6 Nov 2026) · the newest upstream release

In the upstream images: 1 CRITICAL · 28 HIGH with a published fix — per image

To try it: Docker, nothing else. See Try it locally.

To deploy it behind the proxy: Traefik running, a domain, and SMTP credentials.

On your own machine, with no proxy, domain or certificate. From apps/nextcloud/:

Terminal window
cp .env.local.example .env.local
# The secrets ship empty; generate the ones this stack needs
{
echo "DB_PASSWORD=$(openssl rand -base64 32 | tr -d '\n')"
echo "DB_ROOT_PASSWORD=$(openssl rand -base64 32 | tr -d '\n')"
echo "REDIS_PASSWORD=$(openssl rand -hex 32)"
echo "ADMIN_PASSWORD=$(openssl rand -base64 24 | tr -d '\n')"
} >> .env.local
mkdir -p volumes/local/mysql volumes/local/redis volumes/local/html
sudo chown 999:999 volumes/local/mysql
docker compose -f docker-compose.local.yml --env-file .env.local up -d

Then open http://localhost:8080 and log in as admin with the ADMIN_PASSWORD from .env.local. Stop it again with the same command and down.

There is no setup wizard. The stack installs itself from environment variables and secret files — repeatable, reviewable, and with no unauthenticated setup form reachable in between.

From apps/nextcloud/:

  1. Terminal window
    cp .env.example .env
  2. Nextcloud has to be told which address Traefik talks to it from, or it will think every visitor is Traefik. Print it:

    Terminal window
    docker network inspect nextcloud-edge \
    --format '{{range .IPAM.Config}}{{.Subnet}} {{end}}'

    nextcloud-edge is the stack’s own edge network (TRAEFIK_NETWORK), which Traefik joins once EDGE_NETWORKS in core/traefik/.env lists it. Copy whatever that prints into NC_TRUSTED_PROXIES — all of it. If you enabled IPv6 there will be two entries, and leaving one out means everyone shares a single address as far as the login protection is concerned: one person mistyping a password locks out everybody.

    APP_TRAEFIK_HOST=cloud.example.com
    NC_TRUSTED_PROXIES=172.19.0.0/16
    TZ=Europe/Vienna
  3. Set mail now rather than afterwards — Nextcloud cannot reset a password without it. Use port 465 — it is encrypted from the first byte, where port 587 starts unencrypted and switches over partway:

    SMTP_HOST=smtp.example.com
    SMTP_PORT=465
    SMTP_SECURE=ssl
    SMTP_NAME=your-smtp-login
    MAIL_FROM_ADDRESS=nextcloud
    MAIL_DOMAIN=example.com

    Note that MAIL_FROM_ADDRESS is only the part before the @. Together with MAIL_DOMAIN above it makes [email protected], which has to be an address your mail provider has verified for sending.

    Many hosting companies block port 465 outbound. If yours does, use SMTP_PORT=587 with SMTP_SECURE=tls — the weaker of the two, and it works.

  4. Create the secrets. All six before the first start:

    Terminal window
    mkdir -p .secrets
    openssl rand -base64 32 | tr -d '\n' > .secrets/db_pwd.txt
    openssl rand -base64 32 | tr -d '\n' > .secrets/db_root_pwd.txt
    openssl rand -hex 32 | tr -d '\n' > .secrets/redis_pwd.txt
    printf 'admin' > .secrets/admin_user.txt
    openssl rand -base64 24 | tr -d '\n' > .secrets/admin_pwd.txt
    printf 'your-smtp-key' > .secrets/smtp_pwd.txt
    chmod 600 .secrets/*.txt

    Two of the six are read by Nextcloud itself while it runs, so it needs permission for those:

    Terminal window
    sudo chown "$USER":82 .secrets/redis_pwd.txt .secrets/smtp_pwd.txt
    chmod 640 .secrets/redis_pwd.txt .secrets/smtp_pwd.txt

    This keeps the files editable for you as well, which matters the day you change a password. 82 is the user Nextcloud runs as in these images; some variants use 33, which docker compose exec -u root app id www-data reports.

  5. Read the administrator password once and store it in your password manager — it is generated, never displayed:

    Terminal window
    cat .secrets/admin_pwd.txt
  6. Start, and watch the install run:

    Terminal window
    docker compose up -d
    docker compose logs -f app

    Expect Initializing nextcloud followed by Nextcloud was successfully installed. The database has a health check the application waits on, so the first start takes a minute.

Terminal window
docker compose ps
docker compose exec -u www-data app php occ status
docker compose exec -u www-data app php occ config:system:get trusted_proxies

Expected: every container healthy, installed: true, and your proxy subnets listed.

Then log in and open /settings/admin/overview. That page is the acceptance test — it runs the project’s own checks against the running instance and it is more thorough than anything written here. Work until it reports no warnings.

Send a test message before handing the instance to anyone:

Terminal window
docker compose exec -u www-data app php occ user:welcome admin

Run these once. Each is occ config:system:set, so re-running is harmless.

  1. Set the region, so phone numbers in contacts are parsed:

    Terminal window
    docker compose exec -u www-data app php occ \
    config:system:set default_phone_region --value="AT"
  2. Move background jobs to a quiet hour — the value is an hour, UTC:

    Terminal window
    docker compose exec -u www-data app php occ \
    config:system:set maintenance_window_start --value=1 --type=integer
  3. Stop new accounts being seeded with example files:

    Terminal window
    docker compose exec -u www-data app php occ \
    config:system:set skeletondirectory --value=""
  4. Bound preview generation. Thumbnails are produced by PHP workers; without a limit, one large image occupies a worker long enough for users to notice:

    Terminal window
    docker compose exec -u www-data app php occ \
    config:system:set preview_max_x --value=2048 --type=integer
    docker compose exec -u www-data app php occ \
    config:system:set preview_max_y --value=2048 --type=integer
    docker compose exec -u www-data app php occ \
    config:system:set preview_max_filesize_image --value=25 --type=integer
  5. Restrict the administration settings to the networks you administer from. Everything else stays reachable; only /settings/admin narrows:

    Terminal window
    docker compose exec -u www-data app php occ \
    config:system:set allowed_admin_ranges 0 --value="100.64.0.0/10"
    docker compose exec -u www-data app php occ \
    config:system:set allowed_admin_ranges 1 --value="fd7a:115c:a1e0::/48"

    Those two ranges are Tailscale’s. Substitute your own, and confirm you can still reach the page from where you work before you rely on it.

The stack ships APP_TRAEFIK_ACCESS=access-trusted-vpn — reachable from the ranges you declare and the VPN. No closed policy admits a Docker network, so the instance’s own setup checks, which call its public name, report failures until the route is opened; nothing of its function depends on them. Switch to access-public once it is configured, verified, and you have logged in successfully. That is a deliberate change to your attack surface.

An office server — OnlyOffice, Euro-Office, Collabora — calls Nextcloud back past the closed route: the opt-in office.yml joins Nextcloud’s nginx, and only nginx, to the office stack’s edge network (OFFICE_NETWORK), and the connector hands out nginx as its internal address. The settings — StorageUrl and jwt_secret for OnlyOffice and Euro-Office, richdocuments:activate-config for Collabora — are in the stack README, “Office servers behind a closed route”. Collabora additionally needs office-collabora.yml in COMPOSE_FILE, which lets Nextcloud reach Collabora’s discovery and /cool/convert-to through nginx for previews, and NC_OVERWRITECONDADDR in .env, which limits Nextcloud’s URL overwrites to requests from Traefik so Collabora’s template fetch gets internal URLs. With those, documents in all three open, save and reopen behind the closed route, and Collabora’s public route still refuses /cool/convert-to.

Most editors save by writing a new file and renaming it over the old one. The container’s mount still points at the file that was replaced, so it keeps reading the previous value and restart does not help:

Terminal window
docker compose up -d --force-recreate app cron

Writing in place — printf '%s' "$KEY" > .secrets/smtp_pwd.txt — avoids this.

Nextcloud states its requirements per PHP process — 128 MB minimum, 512 MB recommended, 256 MB for the built-in updater (system requirements). For a small team with this stack alone on the host:

Minimum Comfortable
CPU 2 cores 4 cores
RAM 4 GB 8 GB
Disk 20 GB plus your data SSD, 20 GB plus data

The 4 GB floor is arithmetic: MariaDB with the buffer pool this stack configures takes about 1.5 GB, Redis 0.5 GB, the OS and nginx 0.5 GB, and the rest holds the PHP workers. Below it, cron and interactive requests compete for the pool and answer 502. An office server on the same host adds about 1.5 GB of its own.

For document collaboration, keep Files, Group Folders, Guests, Activity and the office connector. Photos and Recommendations run background machine-learning jobs; Survey Client and Support phone home; Weather Status calls an external API from the server; First Run Wizard is done after setup; Contacts is only needed when Nextcloud is also the address book. Disabling what you do not use shortens cron runs and keeps the PHP worker pool predictable.

This section covers what Borgmatic needs to know about Nextcloud. Installing it, the repository, the encryption keys, the schedule and the restore mechanics are all on Backup and restore — set that up first, then come back here.

Nextcloud keeps its file index in the database and the files on disk. Capture the two at different moments under load and the restore shows files the index does not know about, or index entries pointing at nothing. occ files:scan repairs the first case and not the second. This is the stack in the Blueprint where quiescing genuinely matters.

Three things must be captured together — a database dump alone restores an instance that cannot read its own files:

Where
Database dumped through Borgmatic’s hook, not copied
Data directory and config/config.php the nextcloud_html volume
.env and .secrets/ the deployment directory

config.php carries the instance secret and the password salt. Restoring the database against a regenerated config invalidates sessions and can make encrypted content unreadable.

  1. The data. Deliberately not the database volume — the dump below is the consistent copy, the raw files would be a torn one:

    source_directories:
    - /srv/docker
    - /var/lib/docker/volumes/nextcloud_nextcloud_html/_data

    Confirm the volume’s real name with docker volume ls; it is prefixed with your Compose project name.

  2. The database. The password is read from the same file the stack already mounts as a secret, so there is no second copy to rotate:

    mariadb_databases:
    - name: nextcloud
    container: nextcloud-db
    username: nextcloud
    password: "{credential file /srv/docker/apps/nextcloud/.secrets/db_pwd.txt}"
    tls: false
  3. Maintenance mode around the capture. The error hook is what stops a failed run leaving the instance offline:

    commands:
    - before: action
    when: [create]
    run:
    - docker exec -u www-data nextcloud-app php occ maintenance:mode --on
    - after: action
    when: [create]
    run:
    - docker exec -u www-data nextcloud-app php occ maintenance:mode --off
    - after: error
    run:
    - docker exec -u www-data nextcloud-app php occ maintenance:mode --off
  4. Validate, then run it once by hand:

    Terminal window
    sudo borgmatic config validate
    sudo borgmatic create --verbosity 1 --stats

Database first, then the file tree, then the application — and leave maintenance mode on until both halves are in place. The two come from the same archive; a database restored against an older file tree produces exactly the divergence maintenance mode exists to prevent.

The flag itself is inside the backup. Any archive taken while the quiescing hook was active holds 'maintenance' => true in config.php, so a restored instance refuses to serve until you clear it — nothing is served before somebody has looked at it:

Terminal window
docker compose exec -u www-data app php occ maintenance:mode --off

Rehearse into a scratch database first and read the result, not the exit code — the procedure is on Backup and restore. A table count in the low hundreds, the accounts you expect in oc_users and a non-empty oc_filecache are the signs of a good dump.

For a real restore into live service, take a dump of the current state first, even a broken one. Then:

  1. Switch on maintenance mode and stop the application, leaving the database running:

    Terminal window
    docker compose exec -u www-data app php occ maintenance:mode --on
    docker compose stop app cron nextcloud-nginx
  2. Restore the database, then the file tree from the same archive.

  3. Start the stack, clear maintenance mode, reconcile the file index with what is on disk, and check /settings/admin/overview before letting anyone back in:

    Terminal window
    docker compose up -d
    docker compose exec -u www-data app php occ maintenance:mode --off
    docker compose exec -u www-data app php occ files:scan --all

Nextcloud upgrades one major version at a time. Skipping a version is not supported, and the web updater is disabled in this stack in favour of replacing the image.

  1. Read the release notes for the version you are moving to.
  2. Back up, and confirm the backup is readable.
  3. Raise APP_TAG in .env by one major version.
  4. Terminal window
    docker compose pull
    docker compose up -d
    docker compose logs -f app
  5. Check occ status for needsDbUpgrade: false, then re-open /settings/admin/overview.

Permission denied reading a secret, while the file looks correct on the host — the file was replaced by an editor. Recreate the containers rather than restarting them.

Mail silently does not send, occ reports NOAUTH — the web user cannot read redis_pwd.txt or smtp_pwd.txt. Check the group ownership from the installation steps. The container still reports healthy in this state.

Mail never arrives and the log shows no error — port 465 is blocked outbound, which many hosting companies do. Check it from the host:

Terminal window
openssl s_client -connect smtp.example.com:465 -brief </dev/null

CONNECTION ESTABLISHED means the port is open. If it hangs or is refused, set SMTP_PORT=587 and SMTP_SECURE=tls, then recreate both containers that send mail:

Terminal window
docker compose up -d --force-recreate app cron

Brute-force protection locks out everyone at once — NC_TRUSTED_PROXIES is incomplete, so every request appears to come from the proxy. Add the missing subnet, including the IPv6 one.

Setup checks fail to reach the instance itself — no closed policy admits a Docker network. They pass once the route is access-public; an office server does not depend on them (the stack README, “Office servers behind a closed route”).