diff --git a/appinfo/info.xml b/appinfo/info.xml
index ec1c2edde..73d838257 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -62,6 +62,7 @@ to join us in shaping a more versatile, stable, and secure app landscape.
OCA\AppAPI\BackgroundJob\ExAppInitStatusCheckJob
+ OCA\AppAPI\BackgroundJob\DockerImageCleanupJob
diff --git a/lib/BackgroundJob/DockerImageCleanupJob.php b/lib/BackgroundJob/DockerImageCleanupJob.php
new file mode 100644
index 000000000..a1af3adf1
--- /dev/null
+++ b/lib/BackgroundJob/DockerImageCleanupJob.php
@@ -0,0 +1,110 @@
+appConfig->getValueString('app_api', 'docker_cleanup_interval_days', (string)self::DEFAULT_INTERVAL_DAYS);
+
+ // If interval is 0, job is disabled
+ if ($intervalDays === 0) {
+ $this->setInterval(0);
+ return;
+ }
+
+ // Convert days to seconds for the job interval
+ $this->setInterval($intervalDays * self::SECONDS_IN_DAY);
+ }
+
+ protected function run($argument): void
+ {
+ // Check if cleanup is enabled
+ $enabled = $this->appConfig->getValueString('app_api', 'docker_cleanup_enabled', 'yes');
+ if ($enabled !== 'yes') {
+ $this->logger->debug('Docker image cleanup is disabled');
+ return;
+ }
+
+ $this->logger->info('Starting Docker image cleanup job');
+
+ try {
+ // Get cleanup filters from config
+ $filters = [];
+
+ // Handle dangling images filter
+ $pruneDangling = $this->appConfig->getValueString('app_api', 'docker_cleanup_dangling', 'yes');
+ if ($pruneDangling === 'yes') {
+ $filters['dangling'] = true;
+ }
+
+ // Handle until timestamp filter
+ $pruneUntil = $this->appConfig->getValueString('app_api', 'docker_cleanup_until', '');
+ if ($pruneUntil !== '') {
+ $filters['until'] = $pruneUntil;
+ }
+
+ // Handle label filters
+ $pruneLabels = $this->appConfig->getValueString('app_api', 'docker_cleanup_labels', '');
+ if ($pruneLabels !== '') {
+ $labels = json_decode($pruneLabels, true);
+ if (is_array($labels)) {
+ $filters['label'] = $labels;
+ }
+ }
+
+ $defaultDaemonConfigName = $this->appConfig->getValueString('app_api', 'default_daemon_config', lazy: true);
+ $daemonConfig = $this->daemonConfigService->getDaemonConfigByName($defaultDaemonConfigName);
+
+ $dockerUrl = $this->dockerActions->buildDockerUrl($daemonConfig);
+ $this->dockerActions->initGuzzleClient($daemonConfig);
+
+ $result = $this->dockerActions->pruneImages($dockerUrl, $filters);
+
+ if (empty($result['imagesDeleted'])) {
+ $this->logger->info(sprintf('No unused Docker images found for daemon %s', $daemonConfig->getName()));
+ return;
+ }
+
+ $this->logger->info(
+ sprintf(
+ 'Successfully pruned %d Docker images from daemon %s, reclaimed %d bytes',
+ count($result['imagesDeleted']),
+ $daemonConfig->getName(),
+ $result['spaceReclaimed']
+ )
+ );
+
+ } catch (\Exception $e) {
+ $this->logger->error('Error during Docker image cleanup: ' . $e->getMessage());
+ }
+ }
+
+}
diff --git a/lib/DeployActions/DockerActions.php b/lib/DeployActions/DockerActions.php
index 09e7b5dd5..8841515d1 100644
--- a/lib/DeployActions/DockerActions.php
+++ b/lib/DeployActions/DockerActions.php
@@ -937,6 +937,47 @@ public function buildDockerUrl(DaemonConfig $daemonConfig): string {
return $url;
}
+ /**
+ * Prune unused Docker images
+ *
+ * @param string $dockerUrl Docker daemon URL
+ * @param array $filters Optional filters to apply:
+ * - dangling: bool - When true, prune only unused and untagged images
+ * - until: string - Prune images created before this timestamp
+ * - label: array - Prune images with specified labels
+ * @return array{imagesDeleted: array, spaceReclaimed: int} Result of the prune operation
+ */
+ public function pruneImages(string $dockerUrl, array $filters = []): array {
+ try {
+ $url = $this->buildApiUrl($dockerUrl, 'images/prune');
+ if (!empty($filters)) {
+ $url .= '?' . http_build_query(['filters' => json_encode($filters)]);
+ }
+
+ $response = $this->guzzleClient->post($url);
+ $result = json_decode($response->getBody()->getContents(), true);
+
+ $this->logger->info(
+ sprintf(
+ 'Pruned %d Docker images, reclaimed %d bytes',
+ count($result['ImagesDeleted'] ?? []),
+ $result['SpaceReclaimed'] ?? 0
+ )
+ );
+
+ return [
+ 'imagesDeleted' => $result['ImagesDeleted'] ?? [],
+ 'spaceReclaimed' => $result['SpaceReclaimed'] ?? 0
+ ];
+ } catch (GuzzleException $e) {
+ $this->logger->error('Error pruning Docker images: ' . $e->getMessage());
+ return [
+ 'imagesDeleted' => [],
+ 'spaceReclaimed' => 0
+ ];
+ }
+ }
+
public function initGuzzleClient(DaemonConfig $daemonConfig): void {
$guzzleParams = [];
if ($this->isLocalSocket($daemonConfig->getHost())) {