Die Verwaltung eines Servers mit mehreren Diensten kann knifflig sein. Nicht jede Software ist miteinander kompatibel. Beispielsweise gibt es Anforderungen an Datenbank- oder PHP-Versionen.
Außerdem sollte die Software auf dem neuesten Stand sein, um mögliche Sicherheitsrisiken zu minimieren.
Es gibt viele Möglichkeiten, damit umzugehen, wie etwa Ansible, Chef usw.
Unser Ziel war eine einfach zu bedienende, automatisierte und kostenlose Lösung.
Hier sind die Ziele:
- den GitOps-Ansatz nutzen, um die Konfiguration zu speichern und per Versionskontrolle nachvollziehbar zu machen
- Container verwenden, um die Software auszuführen
- Sicherheitsupdates automatisch ausspielen und Minor-/Major-Updates per Opt-in steuern
Wir haben uns am Ende für den folgenden Stack entschieden:
- Docker und Docker Compose zum Verwalten der Software
- GitLab zum Speichern aller Compose-Dateien
- Renovate Bot, um die Software aktuell zu halten
Über den Stack
Wir verwenden Docker bereits seit einiger Zeit in der Produktion. Je nach Situation erstellen wir die Compose-Datei direkt auf dem Server, verwalten sie über Portainer oder übertragen sie per scp in eine Pipeline.
GitLab ist unser primäres Tool für die Versionskontrolle. Zusätzlich kümmert sich ein GitLab Runner um das Ausführen der Pipelines.
Renovate automatisiert Abhängigkeitsupdates. PHP, Go, Python, Docker – um nur ein paar zu nennen. Wir nutzen es bereits in verschiedenen Projekten.
Container mit Docker und Docker Compose
Der Hauptgrund, warum wir Docker gewählt haben, ist die Möglichkeit, auf einen remoten Docker-Host zuzugreifen und Docker-Befehle ausführen zu können. Weitere Informationen findest du in der offiziellen Dokumentation.
Wir verwenden ssh, um auf unseren Zielserver zuzugreifen.
DOCKER_HOST=ssh://[username@]<IP or host>[:port] docker compose up --wait
GitOps mit GitLab
Die Idee hinter GitOps ist, ein Git-Repository zur Ablage der Konfiguration zu verwenden. Hier ein Beispiel:
.
├──.gitlab-ci.yml             # Pipeline-Definition
├── renovate.json             # Renovate-Konfiguration
├── nextcloud
│   ├── docker-compose.yml   # Nextcloud Datei-Hosting und Zusammenarbeit
└── traefik
    └── docker-compose.yml   # Traefik Reverse-Proxy-Konfiguration
nextcloud/docker-compose.yamlvolumes:
  nextcloud:
  db:
services:
  db:
    image: mariadb:11.8
    restart: unless-stopped
    volumes:
      - db:/var/lib/mysql
    environment:
      - MARIADB_ROOT_PASSWORD=${NEXTCLOUD_MARIADB_ROOT_PASSWORD:?error}
      - MARIADB_PASSWORD=${NEXTCLOUD_MARIADB_PASSWORD:?error}
      - MARIADB_DATABASE=nextcloud
      - MARIADB_USER=nextcloud
    command:
      - --transaction-isolation=READ-COMMITTED
      - --log-bin=binlog
      - --binlog-format=ROW
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 15s
      timeout: 5s
      retries: 6
  nextcloud:
    image: nextcloud:32.0.0
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - nextcloud:/var/www/html
    environment:
      - MYSQL_PASSWORD=${NEXTCLOUD_MARIADB_PASSWORD:?error}
      - MYSQL_DATABASE=nextcloud
      - MYSQL_USER=nextcloud
      - MYSQL_HOST=db
NEXTCLOUD_MARIADB_ROOT_PASSWORD=
NEXTCLOUD_MARIADB_PASSWORD=
Werden als CI/CD-Variablen gespeichert.
Um den Stack zu deployen, haben wir folgende Pipeline:
.gitlab-ci.ymlstages:
  - deploy
deploy:
  stage: deploy
  image: docker:28
  variables:
    DOCKER_HOST: ssh://[username@]<IP or host>[:port]
  script:
    - for file in $(find . -type f -name docker-compose.yml); do docker compose -f $file up --remove-orphans --wait; done
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Die Pipeline läuft bei jedem Commit auf dem Standard-Branch, iteriert über alle docker-compose.yml-Dateien und deployt sie.
Halte deine Software aktuell mit Renovate-Bot
Hier kommt Renovate ins Spiel.
renovate.json{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "config:best-practices"
  ]
}
Renovate erstellt für jedes Update einen Merge-Request. Super!
Automatisierte Sicherheitsupdates und Opt-in für Minor-/Major-Versionen
Die aktuelle Konfiguration erstellt für jedes Update einen Merge-Request, aber wir möchten, dass Sicherheitsupdates ohne Benutzerinteraktion erfolgen.
Es ist wichtig zu verstehen, wie Docker-Images versioniert bzw. getaggt werden. Es hängt vom jeweiligen Image ab, aber nehmen wir die offizielle MariaDB als Beispiel.
Es gibt 11.8.3-noble, 11.8-noble, 11-noble, lts-noble, 11.8.3, 11.8, 11, lts, die alle auf dasselbe Image verweisen.
11.8.3-noble bedeutet, dass wir MariaDB in Version 11.8.3 auf Basis von Ubuntu Noble erhalten.
11.8-noble bedeutet, dass wir MariaDB in Version 11.8.<latest_patch> auf Basis von Ubuntu Noble erhalten.
Wenn eine neue Version von MariaDB veröffentlicht wird, z. B. 11.8.4-noble, wird ein neuer Tag 11.8.4-noble veröffentlicht, aber der Tag 11.8-noble wird aktualisiert.
Gleiches gilt für das Ubuntu-Update. Der Tag 11.8.3-noble kann aktualisiert werden, wenn das Image mit dem neuesten Ubuntu-Image erneut gebaut wird.
docker compose up mit mariadb:11.8-noble wird nichts bewirken, weil Docker sich dieser Änderung nicht bewusst ist.
Im Beispiel oben verweisen wir auf mariadb:11.8, weil wir die neueste Patch-Version auf Basis des neuesten Betriebssystems verwenden möchten.
Wie soll Docker mitgeteilt werden, dass es eine neue Version gibt?
Die Hauptidee ist, das Docker-Image zusätzlich mit einem Digest anzugeben.
Wenn Renovate das erste Mal läuft, findet es den Verweis auf mariadb:11.8 und erstellt einen Merge-Request, um den Digest auf so etwas wie mariadb:11.8@sha256:ae6119716edac6998ae85508431b3d2e666530ddf4e94c61a10710caec9b0f71 festzulegen.
Es überwacht das auch, sodass bei jedem Update des Images der Digest wechselt und Renovate einen Merge-Request erstellt.
Damit diese Updates automatisch gemergt werden, müssen wir ein paar Anpassungen vornehmen.
renovate.json{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "config:best-practices",
    "default:automergeDigest"
  ],
  "automergeType": "branch",
  "ignoreTests": true
}
Das weist Renovate an, die Digest-Updates automatisch zu mergen, ohne einen Merge-Request zu erstellen. Das reduziert das "Rauschen", weil es keine Merge-Request-Benachrichtigung gibt. Mehr dazu findest du unter automergeType und ignoreTests.
