This is Hardening Series 0: the migration that made the rest of the homelab security series more concrete.
Nextcloud is one of the applications where “pull the latest image” is not a sufficient upgrade plan. The container contains the PHP runtime and application code, while the persistent mount contains the database-facing configuration, bundled applications, uploaded files, and user data. A major-version upgrade has to keep those layers compatible.
This migration started from Nextcloud 32.0.5 and ended at 35.0.2. It completed successfully, but only after several failures made the real operational boundaries visible.
The Upgrade Path
Nextcloud major releases were upgraded sequentially:
32.0.5 -> 33.0.3 -> 34.0.5 -> 35.0.2
The final image reported Nextcloud 35.0.2.2 and PHP 8.5.12. The available registry tag was 35.0.2, so I used that instead of inventing a nonexistent 35.0.5 tag.
The important rule is simple: do not skip major versions. Change the Compose image, pull the target image, recreate only the application service, run the upgrade, and verify before moving to the next major version.
docker compose -f 2602_docker-compose.yml pull nextcloud-app
docker compose -f 2602_docker-compose.yml up -d nextcloud-app
The database and data mounts stay in place while the application image changes. That makes the process recoverable, provided the persistent data and configuration are backed up first.
Backups Are Part of the Upgrade
The MariaDB dump completed successfully and was transaction-consistent. That was useful, but it was not a complete rollback by itself. A database dump does not restore the uploaded files, configuration, application code, permissions, or custom apps.
For a complete rollback plan, identify and preserve:
- the database dump;
config/, especiallyconfig.phpand instance-specific integrations;- the Nextcloud data directory;
custom_apps/and any local application code;- the application tree when the deployment depends on a bind-mounted
/var/www/html; - secrets and the exact Compose/image versions.
Before changing the image, verify that the backup is real rather than trusting a command that merely started copying:
test -f backup/html/config/config.php
test -d backup/html/data
du -sh backup/html
The migration also exposed a more basic failure mode: a large full backup stored on the same filesystem as the live Nextcloud installation filled the disk. Login requests then failed because database writes for sessions, preferences, and tokens could not complete. The data was not corrupted; the filesystem simply had no free blocks left.
Check capacity before and after large backups:
df -h
du -sh /path/to/nextcloud
docker system df
For a large instance, send the backup to another filesystem from the beginning. Keep it until a second copy or integrity check is confirmed.
When the Image and Mount Disagree
The first upgrade failures were not caused by the database. The mounted application tree retained stale or incomplete code from an earlier release.
The 33 image reported a missing Composer file under 3rdparty/. After that was repaired, old bundled apps from the previous version caused a type error during the 34 upgrade. The matching apps/ and 3rdparty/ trees were synchronized from the same official image that supplied the new application code.
The safe boundary was:
replace or synchronize: application code, bundled apps, 3rdparty
preserve: config, data, custom_apps, instance integrations
Do not copy a random apps/ directory from another release. Use the code from the exact Nextcloud image being deployed, and check the result with occ status and the code-integrity checks available for the instance.
Permissions Matter During Recreate
The second Nextcloud instance ran the application as UID 1000:1000. During the 35 upgrade, that user could not replace root-owned application files left by the old image. The official entrypoint logged rsync permission errors and the container entered a restart loop.
The repair used a temporary root helper container to synchronize only the matching apps/ and 3rdparty/ trees. The config/ and data/ directories were not overwritten.
This is a useful distinction:
- do not make the whole Nextcloud data tree broadly writable just to get an upgrade through;
- identify whether the problem is application code ownership or user data ownership;
- repair only the code directories that the image must replace;
- preserve instance-specific configuration files even when they differ from image defaults.
Migration Errors Are Not Always New Damage
Two errors looked serious but turned out to be partially completed migrations.
The 33 upgrade reported that oc_webhook_tokens already existed. The table and expected schema were present, so retrying the migration after recording the installed component version allowed occ upgrade to finish.
The first 35 attempt reported that oc_sharing_classmap already existed. The table and related migration records were already present. A retry returned No upgrade required, and the instance reported needsDbUpgrade: false.
The lesson is not to ignore migration errors. Inspect the schema and migration state before deleting tables or replaying destructive commands. A failed command may have completed the database operation immediately before the process stopped.
Verify the PHP Runtime, Not the Tag
The security issue covered in the PHP and SSL certificate article is exactly why the runtime must be checked inside the serving container.
docker exec nextcloud php occ status --output=json
docker exec nextcloud php -v
curl -fsS http://127.0.0.1:8099/status.php
docker inspect nextcloud --format '{{.Config.Image}}'
The checks should confirm:
- the application is installed;
- maintenance mode is disabled;
needsDbUpgradeis false;- the expected Nextcloud and PHP versions are active;
- the status endpoint returns HTTP 200;
- the container uses the intended image or digest.
“Container is running” is not the same as “Nextcloud is upgraded.”
Cloudflare Tunnel Is a Separate Layer
The Cloudflare Tunnel connector was upgraded independently from Nextcloud. The tunnel image was pulled, its version and digest were checked, and only the cloudflared service was recreated. The tunnel registered three connections and both Nextcloud routes returned HTTP 200 after the brief connector restart.
That separation matters:
Cloudflare Tunnel = public ingress and connector runtime
Nextcloud = application, PHP runtime, database, and files
Updating Cloudflared does not patch PHP. Updating Nextcloud does not automatically update the tunnel connector or the CA bundle inside unrelated containers. Verify each layer on its own, as described in the Cloudflare Tunnel guide.
FAQ
Can I skip Nextcloud major versions if the Docker image is available?
Is a database dump enough to roll back Nextcloud?
config/, custom apps, application code, or permissions. Treat a database-only dump as a partial rollback aid unless the rest of the persistent state is independently preserved.
Why did login fail after the upgrade?
df -h before revoking tokens or changing application data; disk pressure can produce misleading database symptoms.
Should I update Cloudflared at the same time as Nextcloud?
What is the main hardening lesson?
Conclusion
The Nextcloud migration is the starting point for this hardening series because it connects several controls that are often discussed separately: runtime patching, image hygiene, database migrations, filesystem capacity, permissions, backups, and tunnel validation.
The successful result was not “the container restarted.” It was a verified Nextcloud instance with the expected PHP version, completed database migrations, a working status endpoint, a tested recovery path, and a separately validated Cloudflare Tunnel.
Comments