Malte Gerth IT-Consulting & Entwicklung Rückruf anfordern
Alle Beiträge

Nextcloud-Updates per Pipeline

Welche Nextcloud-Version läuft, soll jederzeit im Repository nachzulesen sein. Ein Update ist dann nur noch ein Merge Request.

Unscharfer Softwarecode im Hintergrund und Code-Kommentar Platzhalter im Vordergrund

Vor Kurzem habe ich in einem Kundenprojekt eine Nextcloud übernommen, die direkt auf dem Server läuft, ohne Docker oder andere Container. Sie war nicht mehr aktuell. Das Update allein wäre von Hand schneller erledigt gewesen als mit Git und Pipeline. Mir ging es um mehr: Jederzeit soll nachvollziehbar sein, welche Version läuft, und der Server soll genau diese Version haben. Deshalb steht sie jetzt im Repository: Renovate schlägt Updates als Merge Request vor, und das Zusammenführen hebt den Server über eine GitLab-Pipeline auf den neuen Stand.

Im Mittelpunkt dieses Beitrags stehen das Zusammenspiel der einzelnen Schritte, die Grenzen des Updaters und die Kompromisse, die ich dabei eingegangen bin. Ich beschränke mich bewusst auf feste Versionen und automatische Updates. Das Ergebnis ist eine Skizze, keine Anleitung zum Nachbauen: Datensicherung, Zugangsdaten und die Absicherung des Servers gehören zu einem solchen Projekt dazu, kommen hier aber nur am Rand vor.

Eine Variable als Single Source of Truth

Damit das Repository zeigt, welche Version läuft, steht sie an genau einer Stelle: als Variable in der .gitlab-ci.yml. Renovate aktualisiert sie, und der Upgrade-Job vergleicht sie mit der installierten Version und baut daraus die Download-Adresse.

YAML
variables:
  NEXTCLOUD_VERSION: "31.0.14"
  # ...

Um die Variable zu finden, braucht Renovate einen eigenen Regex-Manager (Custom Manager). Er vergleicht ihren Wert mit den Releases von nextcloud/server auf GitHub. Weil die Tags dort mit einem v beginnen, schneidet extractVersionTemplate es ab.

JSON
{
  "customManagers": [
    {
      "customType": "regex",
      "managerFilePatterns": ["/^\\.gitlab-ci\\.yml$/"],
      "matchStrings": ["NEXTCLOUD_VERSION: \"(?<currentValue>[^\"]+)\""],
      "datasourceTemplate": "github-releases",
      "depNameTemplate": "nextcloud/server",
      "extractVersionTemplate": "^v(?<version>.+)$"
    }
  ]
}

Eine Regel in packageRules begrenzt die Hauptversion auf das, was die PHP-Version des Servers unterstützt. Nextcloud 33 setzt PHP 8.2 voraus. Solange der Server mit PHP 8.1 läuft, bleibt Nextcloud deshalb bei Version 32.

JSON
{
  "packageRules": [
    {
      "description": "Nextcloud 33 requires PHP 8.2, the server runs PHP 8.1",
      "matchDepNames": ["nextcloud/server"],
      "allowedVersions": "<33"
    }
  ]
}

Steht eine neuere PHP-Version zur Verfügung, kann die Einschränkung entfernt werden. Eine neue Grenze kenne ich dann noch nicht, sie wäre geraten. Nach meiner Erfahrung verlangt Nextcloud eine neue PHP-Version aber nicht plötzlich, sondern kündigt sie mit Vorlauf an. Ab und zu einen Blick in die Ankündigungen und den Release-Plan zu werfen, genügt, um die Grenze rechtzeitig wieder zu setzen. Pipeline und Ablauf bleiben dabei gleich.

Die übrigen Regeln kommen aus einem zentralen Preset, das ich in allen Projekten nutze. Für Nextcloud zählen daraus vor allem vier Einstellungen:

JSON
{
  "dependencyDashboard": true,
  "separateMultipleMajor": true,
  "major": { "dependencyDashboardApproval": true },
  "patch": { "automerge": true }
}

Patch-Releases führt Renovate automatisch zusammen, die Pipeline spielt sie ohne mein Zutun ein. Einen Merge Request für eine neue Hauptversion öffnet Renovate erst, wenn ich sie im Dependency Dashboard freigebe. Nextcloud erlaubt es nicht, eine Hauptversion zu überspringen: Von 28 auf 30 geht es nur über 29. separateMultipleMajor sorgt lediglich dafür, dass jede Hauptversion einen eigenen Merge Request bekommt, die Reihenfolge setzt es nicht durch. Die stelle ich über die Freigaben im Dashboard selbst sicher. Der Upgrade-Job prüft sie vor der Installation zusätzlich, als Absicherung.

Ein Upgrade-Job, der nur bei Bedarf arbeitet

Der Upgrade-Job läuft in jeder Pipeline auf dem Hauptbranch (main), nicht auf den Branches der Merge Requests. Er liest per SSH die installierte Version vom Server (occ status) und endet sofort, wenn sie bereits der festgelegten entspricht. Mehrfaches Ausführen ändert also nichts (der Job ist idempotent). Liegt die festgelegte Hauptversion unter der installierten oder mehr als eine darüber, bricht er ab, bevor der Updater etwas anfasst. Nach meinem Verständnis fiele ein solcher Sprung sonst erst bei occ upgrade auf, wenn der Code schon ersetzt ist, und eine Rückkehr zur alten Version sieht Nextcloud nicht vor.

Bevor der Updater läuft, sichert der Job die Datenbank. Er startet den vorhandenen systemd-Dienst für den täglichen Dump und legt diesen zusätzlich als Hardlink in einem Ordner für Stände vor Updates ab. Den alten Code sichert der Updater selbst. Hat occ upgrade die Datenbank erst migriert, führt der Weg zurück aber nur über diesen Dump.

YAML
variables:
  # ...
  NEXTCLOUD_PATH: /srv/nextcloud
  BACKUP_PATH: /srv/backup
  # the SSH user has no root privileges and may only sudo the required commands
  PHP_AS_WWW_DATA: sudo --user=www-data php
  # shortcut for every occ call, defined once instead of repeated in each command
  OCC: $PHP_AS_WWW_DATA $NEXTCLOUD_PATH/occ

upgrade:
  stage: upgrade
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  variables:
    RELEASE_URL: https://download.nextcloud.com/server/releases/nextcloud-$NEXTCLOUD_VERSION.zip
  script:
    - apk add --no-cache jq
    - INSTALLED_VERSION=$(ssh "$TARGET_HOST" "$OCC status --output=json" | jq --exit-status --raw-output .versionstring)
    - if [ "$INSTALLED_VERSION" = "$NEXTCLOUD_VERSION" ]; then exit 0; fi
    - MAJOR_STEP=$(( ${NEXTCLOUD_VERSION%%.*} - ${INSTALLED_VERSION%%.*} ))
    - if [ "$MAJOR_STEP" -lt 0 ] || [ "$MAJOR_STEP" -gt 1 ]; then echo "only one major version step at a time"; exit 1; fi
    - ssh "$TARGET_HOST" "sudo systemctl start postgresql-backup.service"
    - ssh "$TARGET_HOST" "ln $BACKUP_PATH/daily/nextcloud-\$(date +%F).dump $BACKUP_PATH/pre-update/nextcloud-$INSTALLED_VERSION-\$(date +%F-%H%M%S).dump"
    # updater 32 requires --no-verify (or --signature) with --url, older updaters don't know it
    - ssh "$TARGET_HOST" "$PHP_AS_WWW_DATA $NEXTCLOUD_PATH/updater/updater.phar --no-interaction --no-verify --url=$RELEASE_URL"

Der Updater der installierten Version

Ohne --url installiert updater.phar einfach die Version, die der Update-Server ihm gerade anbietet. Das kann ein neueres Patch-Release sein als das festgelegte oder sogar schon die nächste Hauptversion, und dann passen Repository und Server nicht mehr zusammen. Der Wunsch nach einer festen Zielversion ist alt (Issue #336 von 2021). Die Option --url kam erst im Juli 2025 (PR #638) und wurde im August 2025 bis Version 25 zurückportiert.

Die Pipeline führt immer den Updater der installierten Version aus, nicht den der Zielversion.

Welche Optionen zur Verfügung stehen, hängt von der Version ab, von der aus aktualisiert wird. Der Updater der übernommenen Instanz (29.0.16) war älter als die Rückportierung und brach ab:

The "--url" option does not exist.

Den Sprung von 29 auf 30 habe ich deshalb einmal von Hand gemacht, nach einer Sicherung der Datenbank und ohne --url. Die Updater von 30.0.17 und 31.0.14 kennen --url, damit liefen die Sprünge auf 31 und 32 wie geplant in der Pipeline durch:

Update check forced with URL override: https://download.nextcloud.com/server/releases/nextcloud-31.0.14.zip

Freigabe im Dashboard, Merge Request zusammenführen, fertig: Der Server lief auf der neuen Hauptversion. Die Signatur des Archivs prüfen diese Updater bei einer eigenen URL allerdings nicht. In ihrer Logdatei (updater.log) steht dazu nur ein Info-Eintrag (custom download url provided, cannot verify signature), in der Ausgabe der Pipeline trotzdem ein Haken.

Erst der Updater von 32.0.15 kennt --no-verify und --signature. Laut aktuellem Administrationshandbuch kann er mit --url die offizielle Signatur nicht selbst nachschlagen, ab 32 braucht der Aufruf deshalb eine der beiden Optionen.

--signature behält die Prüfung bei, aber ohne Update-Server muss die Signatur von außen kommen. Sie müsste für jede Version neben der Versionsnummer im Repository stehen, und die aktualisiert Renovate nicht von selbst. Machbar wäre das schon, aber nur mit eigenen Skripten, die bei jedem Update die Signatur aus dem Netz holen. Die GPG-Signatur (.asc), die Nextcloud neben jedem Archiv veröffentlicht, ließe sich zwar in der Pipeline prüfen, aber updater.phar lädt das Archiv auf dem Server selbst noch einmal herunter. Das geprüfte Archiv zu übertragen oder Signaturen zusätzlich zu verwalten, wäre deutlich mehr Code für wenig Gewinn und obendrein fragil.

Ich habe mich deshalb bewusst für --no-verify entschieden, die Option steht im Upgrade-Job oben. Ich verzichte damit auf die Prüfung, ob das Archiv wirklich aus der offiziellen Quelle stammt, und verlasse mich auf HTTPS und die Download-Infrastruktur von Nextcloud. Mit --url war das bei den Updatern von 30 und 31 ohnehin schon so, nur unsichtbar. Eine Instanz, die sich automatisch aktuell hält, ist mir mehr wert als diese zusätzliche Prüfung.

Eine weitere Prüfung fällt mit --url weg: Sonst schickt der Updater dem Update-Server die PHP-Version mit und bekommt nur eine passende Version angeboten. Mit fester URL fragt er den Server gar nicht erst, diese Aufgabe übernimmt deshalb die Regel in Renovate. Ohne sie fiele eine nicht unterstützte PHP-Version nach meinem Verständnis erst auf, wenn der Code schon ersetzt ist und jeder Aufruf von occ fehlschlägt. Ausprobiert habe ich das in diesem Projekt lieber nicht.

Nach dem Update: Apps und Wartung

Beim Sprung von 29 auf 30 hat Nextcloud drei Apps als inkompatibel deaktiviert und danach aus dem App Store aktualisiert. Anschließend liefen sie wieder, nur stand davon nichts in der Ausgabe. Beim Sprung auf 31 wiederholte sich das bei einer App. Die Pipeline aktiviert die nötigen Apps deshalb im Wartungsjob ausdrücklich, und erst dieser Schritt bestätigt, dass sie wieder aktiv sind. occ app:enable nimmt mehrere Apps auf einmal und lädt fehlende aus dem App Store, ein eigenes app:install ist nicht nötig. Bereits aktive Apps meldet es mit already enabled und Exit-Code 0, ein erneuter Lauf schadet also nicht. Gibt es für eine App keine kompatible Version, schlägt der Befehl ohne --force fehl, und der Job wird rot. Genau das soll an dieser Stelle passieren.

Der Wartungsjob läuft in jeder Pipeline, nicht nur nach einem Update. Eine Prüfung vorab wie im Upgrade-Job braucht er nicht: Seine Befehle ändern nur etwas, wenn es etwas zu tun gibt, und lassen sich deshalb beliebig oft wiederholen. Auswahl und Reihenfolge beruhen auf meinen Erfahrungen mit anderen Nextcloud-Instanzen, die ich betreue. Eine offizielle Vorgabe oder Empfehlung dafür habe ich nicht gefunden.

YAML
maintenance:
  stage: maintenance
  script:
    - ssh "$TARGET_HOST" "$OCC app:update --all"
    - ssh "$TARGET_HOST" "$OCC upgrade --no-interaction"
    - ssh "$TARGET_HOST" "$OCC app:enable activity calendar contacts tasks"
    - ssh "$TARGET_HOST" "$OCC background:cron"
    - ssh "$TARGET_HOST" "$OCC db:add-missing-columns --no-interaction"
    - ssh "$TARGET_HOST" "$OCC db:add-missing-primary-keys --no-interaction"
    - ssh "$TARGET_HOST" "$OCC db:add-missing-indices --no-interaction"
    - ssh "$TARGET_HOST" "$OCC maintenance:repair --include-expensive"
    - ssh "$TARGET_HOST" "$OCC maintenance:mode --off"
    - ssh "$TARGET_HOST" "$OCC status --exit-code"

occ upgrade folgt auf app:update --all, weil App-Updates die Instanz nach meinem Eindruck in den Zustand „Upgrade erforderlich“ bringen können; dokumentiert ist das nicht. Nach einem Update melden occ upgrade und maintenance:mode --off meist nur, dass nichts zu tun ist: Im nicht interaktiven Modus (--no-interaction) startet der Updater das Upgrade selbst und schaltet danach den Wartungsmodus ab. Die beiden Schritte bleiben trotzdem stehen, als Sicherheitsnetz. background:cron stellt nur die Art der Hintergrundaufgaben (Background Jobs) auf Cron um, ausgeführt werden sie über einen systemd-Timer. Am Ende steht occ status --exit-code als Health Check: Der Befehl schlägt fehl, solange Nextcloud im Wartungsmodus ist oder noch ein Upgrade braucht.

Der Wartungsjob eignet sich auch für Einstellungen von Apps: Mit occ config:app:set lägen sie ebenfalls im Repository und würden bei jedem Lauf neu gesetzt.

Ein Kompromiss bleibt: Die Apps sind nicht festgelegt. app:update --all holt bei jedem Lauf die neueste kompatible Version aus dem App Store, und occ kennt keinen Weg, eine bestimmte Version zu installieren. Feste App-Versionen hießen, die Archive selbst herunterzuladen und zu verwalten.

Für mich heißt das: Bei einem Patch-Release genügt ein Blick auf die Pipeline, bei einer neuen Hauptversion gebe ich sie im Dependency Dashboard frei und führe den Merge Request selbst zusammen, sobald dessen Pipeline grün ist. Welche Version laufen soll und welche Updates anstehen, zeigt das Dashboard jederzeit, und ob der Server auf diesem Stand ist, zeigt die Pipeline.

Über Austausch freue ich mich jederzeit – am liebsten bei einem guten Espresso, im Café oder virtuell.

Kontakt aufnehmen