in category:HomeLab program:Nix program:Nextcloud process:Troubleshoot ~ read.

When NixOS and Nextcloud's App Store Fight Over the Same Apps

Nextcloud has this idea that apps should be easy to install… click a button in the UI, download from the App Store, done. NixOS has a different idea… pin everything in the Nix store, reproducible, immutable. When you put those two philosophies in the same container, they fight. And the way they fight produces one of the more confusing PHP errors I’ve ever chased.

What Happened

We run Nextcloud on NixOS, deployed via Pulumi across two containers (ct0 and ct1) sharing a single MariaDB database. The setup has been running since October 2024, and for the most part it works. But every time we bumped the Nextcloud version, occ upgrade would sometimes crash with:

Cannot declare class ComposerAutoloaderInitCalendar, because the name is already in use

The first time we saw this was back in August 2025, working through KB#7663: Nextcloud updates. Calendar had an issue; removing it from /var/lib/nextcloud/store-apps worked as a manual fix. We moved on, but it kept coming back on every version bump.

By May 2026, in KB#8166: Fix Nextcloud extraApps + App Store Composer autoloader collision during occ upgrade, I dug in properly. The NixOS nextcloud-setup service runs occ upgrade as part of first boot after a package version bump. The problem was in how NixOS and Nextcloud’s App Store were both trying to manage the same apps.

NixOS has an extraApps option that puts apps like calendar, contacts, notes, and tasks into a readonly path called nix-apps/. These are Nix-pinned versions… reproducible, immutable, tied to the NixOS generation. Meanwhile, appstoreEnable = true tells Nextcloud it’s allowed to download newer versions from the App Store into a writable path called store-apps/.

The apps_paths in config.php looked like this:

Order Path Writable Source
0 /nix/store/.../apps false NC core apps
1 /nix/store/.../nix-apps false Nix extraApps
2 /nix/store/.../store-apps true Symlink to /var/lib/nextcloud/store-apps

When occ upgrade ran, it saw appstoreEnable = true and dutifully downloaded newer App Store versions of calendar, contacts, notes, and tasks into store-apps/. These same apps already existed in nix-apps/. PHP’s Composer autoloader then tried to declare the same autoloader class twice… once from nix-apps (loaded first due to apps_paths ordering) and once from store-apps. PHP doesn’t allow redeclaring classes, so it crashed.

The really insidious part was the version drift. Even when occ upgrade succeeded (which it sometimes did, depending on PHP process state), the resulting state was fragile. The nix-apps versions were older than what the database recorded:

  • nix-apps had calendar 5.5.9, contacts 7.3.8, notes 4.12.4
  • store-apps had calendar 5.5.18, contacts 7.3.17, notes 5.0.0
  • The oc_appconfig table recorded the store-apps versions as installed_version

Nextcloud loads the app from the first apps_paths entry that contains it, which meant it was running the older nix-apps code while the database said it should be on the newer store-apps version. Any code path that checked the loaded version against the database version would see a mismatch and trigger “require upgrade.” The collision was latent… it would resurface on the next upgrade cycle.

Root Cause

The core conflict is that extraApps and appstoreEnable = true are fundamentally at odds. NixOS pins app versions in a readonly path, but appstoreEnable tells Nextcloud it’s allowed to download newer versions from the App Store. The occ upgrade process respects appstoreEnable and downloads updates for all enabled apps, including ones that already exist in nix-apps. There’s no mechanism in the NixOS module to say “manage these apps via Nix, but allow App Store for other apps.”

I tried a few things that should have worked but didn’t:

Setting appstoreenabled = false in config.php before the upgrade. This did not prevent the App Store check during occ upgrade; it still downloaded. My read is that the NixOS nextcloud-setup script or the occ upgrade process itself rewrites config.php during upgrade, overwriting the manual false back to the NixOS-configured true. I didn’t confirm this directly, but it’s the most plausible explanation.

Removing nix-apps from apps_paths in config.php. The crash still happened, which was confusing because calendar should only exist in one place without nix-apps in the paths. I think PHP opcache was still caching the old autoloader from a previous process, or PHP-FPM wasn’t properly restarted between the config change and the upgrade attempt. Given that the manual fix (running occ upgrade directly after rebuilding back to NC 31 and restarting PHP-FPM) worked, stale processes were the most likely culprit. I’m unsure as to if there was something else going on with how Nextcloud’s autoloader registers classes from all paths it discovers, not just the apps_paths entries.

The NixOS module also has a quirk with config.php: it writes the file on first deploy, but subsequent NixOS rebuilds merge with the existing config.php rather than overwriting it. Manual edits persist across rebuilds, which means config drift is easy to introduce and hard to track.

The Fix

The resolution was to stop fighting Nextcloud’s app management model and let it do what it wants. The fix landed in commit 56610c3 and touched two files: nextcloud/nextcloud-backend.service.nix and config/role_nextcloud-backend.yml.

Removed extraApps entirely. No more extraAppsEnable, no more extraApps block. The nix-apps/ directory no longer appears in apps_paths at all. There’s only one path for third-party apps now (store-apps/), so two copies of the same class can never be loaded.

Kept appstoreEnable = true and added occ app:install + occ app:enable in ExecStartPost for each configured app:

{% for app in args['role_template_args']['nextcloud_extra_apps'].split() %}
''${pkgs.runtimeShell} -c "${config.services.nextcloud.occ}/bin/nextcloud-occ app:install {{ app }} || true"''
''${pkgs.runtimeShell} -c "${config.services.nextcloud.occ}/bin/nextcloud-occ app:enable {{ app }} || true"''
{% endfor %}

Both commands are idempotent… if the app isn’t there, app:install downloads it; if it’s already installed, it’s a no-op. The extra_apps config key in the YAML stayed, but it’s now a list of App Store app names to install on deploy, not Nix package names.

Mounted store-apps/ on GlusterFS so both ct0 and ct1 see the same app files. Without this, occ app:install only writes to one container’s local filesystem, and the second container never gets the app code. ct0 does the actual download; ct1 sees “already installed” and skips.

Added lastupdatedat signaling between containers. Since ct0 and ct1 share a database, we needed a way for ct1 to know when ct0’s setup sequence (including occ upgrade) was complete. ct0 resets lastupdatedat to 0 in oc_appconfig before setup starts, then sets it to the current timestamp in ExecStartPost after everything finishes. ct1 polls that value and blocks until it sees a positive, stable number:

# ct0 reset (ExecStartPre)
mysql -e "UPDATE oc_appconfig SET configvalue=0 WHERE appid='core' AND configkey='lastupdatedat';"

# ct0 signal (ExecStartPost)
mysql -e "UPDATE oc_appconfig SET configvalue=UNIX_TIMESTAMP() WHERE appid='core' AND configkey='lastupdatedat';"

This matters because on first install, maintenance:install sets lastupdatedat mid-run, which would unblock ct1 prematurely. Resetting it first ensures ct1 stays blocked until the entire setup sequence completes.

Disabled DNS pinning (dns_pinning = false) to work around an IPv6 issue. The container has no routable IPv6, and Nextcloud’s DnsPinMiddleware bypasses AI_ADDRCONFIG by resolving DNS via dns_get_record(), which always queries AAAA records regardless. This caused App Store downloads to fail with cURL error 6: Could not resolve host: github.com because the App Store uses a 3-host redirect chain: apps.nextcloud.com (65.21.231.50) returns a 302 to garm3.nextcloud.com (65.108.197.113), which then redirects downloads to github.com via github-releases.githubusercontent.com. The tradeoff of disabling DNS pinning is losing SSRF defense-in-depth from IP address checking, but this is a private network and allow_local_remote_servers already bypasses the middleware for local address requests.

Added firewall rules for the App Store download servers (garm2 at 65.109.114.179, garm3 at 65.108.197.113) since the existing rule only allowed 65.21.231.50.

Bumped Nextcloud to version 32.

What We Learned

  • NixOS reproducibility and Nextcloud’s App Store are fundamentally incompatible for the same apps. NixOS pins versions in a readonly path; the App Store downloads newer versions into a writable path. PHP’s autoloader can’t handle two copies of the same class. If you’re going to use the App Store, don’t also pin those apps in Nix. Pick one model per app.
  • appstoreEnable = false doesn’t prevent App Store downloads during occ upgrade. It might prevent user-initiated installs from the UI, but the upgrade process appears to override it. Don’t rely on it as a guard.
  • PHP opcache will make you doubt your sanity. If you change config.php and the behavior doesn’t change, restart PHP-FPM. Stale autoloader state from a previous process can persist and make it look like your config change didn’t take effect.
  • Shared databases across containers need explicit coordination. lastupdatedat in oc_appconfig is the only signal Nextcloud gives you for “setup is done.” On first install, maintenance:install sets it mid-run, which can unblock secondary containers prematurely. Reset it before setup and set it explicitly after.
  • The App Store redirect chain goes through GitHub. If you’re firewalling Nextcloud containers (and you should be), you need to allow not just apps.nextcloud.com but also the garm download servers and GitHub’s CDN. The redirect chain is not documented anywhere I could find.
  • DNS pinning and IPv6 don’t mix. If your container doesn’t have routable IPv6, Nextcloud’s DnsPinMiddleware will break DNS resolution for external services because it queries AAAA records unconditionally. dns_pinning = false is the pragmatic fix, but understand the SSRF tradeoff.

Tickets