@@ -13,7 +13,6 @@ class CleanupDocker
1313
1414 public function handle (Server $ server , bool $ deleteUnusedVolumes = false , bool $ deleteUnusedNetworks = false )
1515 {
16- $ settings = instanceSettings ();
1716 $ realtimeImage = config ('constants.coolify.realtime_image ' );
1817 $ realtimeImageVersion = config ('constants.coolify.realtime_version ' );
1918 $ realtimeImageWithVersion = "$ realtimeImage: $ realtimeImageVersion " ;
@@ -26,9 +25,25 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
2625 $ helperImageWithoutPrefix = 'coollabsio/coolify-helper ' ;
2726 $ helperImageWithoutPrefixVersion = "coollabsio/coolify-helper: $ helperImageVersion " ;
2827
28+ $ cleanupLog = [];
29+
30+ // Get all application image repositories to exclude from prune
31+ $ applications = $ server ->applications ();
32+ $ applicationImageRepos = collect ($ applications )->map (function ($ app ) {
33+ return $ app ->docker_registry_image_name ?? $ app ->uuid ;
34+ })->unique ()->values ();
35+
36+ // Clean up old application images while preserving N most recent for rollback
37+ $ applicationCleanupLog = $ this ->cleanupApplicationImages ($ server , $ applications );
38+ $ cleanupLog = array_merge ($ cleanupLog , $ applicationCleanupLog );
39+
40+ // Build image prune command that excludes application images
41+ // This ensures we clean up non-Coolify images while preserving rollback images
42+ $ imagePruneCmd = $ this ->buildImagePruneCommand ($ applicationImageRepos );
43+
2944 $ commands = [
3045 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" ' ,
31- ' docker image prune -af --filter "label!=coolify.managed=true" ' ,
46+ $ imagePruneCmd ,
3247 'docker builder prune -af ' ,
3348 "docker images --filter before= $ helperImageWithVersion --filter reference= $ helperImage | grep $ helperImage | awk '{print $3}' | xargs -r docker rmi -f " ,
3449 "docker images --filter before= $ realtimeImageWithVersion --filter reference= $ realtimeImage | grep $ realtimeImage | awk '{print $3}' | xargs -r docker rmi -f " ,
@@ -44,7 +59,6 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
4459 $ commands [] = 'docker network prune -f ' ;
4560 }
4661
47- $ cleanupLog = [];
4862 foreach ($ commands as $ command ) {
4963 $ commandOutput = instant_remote_process ([$ command ], $ server , false );
5064 if ($ commandOutput !== null ) {
@@ -57,4 +71,122 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
5771
5872 return $ cleanupLog ;
5973 }
74+
75+ /**
76+ * Build a docker image prune command that excludes application image repositories.
77+ *
78+ * Since docker image prune doesn't support excluding by repository name directly,
79+ * we use a shell script approach to delete unused images while preserving application images.
80+ */
81+ private function buildImagePruneCommand ($ applicationImageRepos ): string
82+ {
83+ // Step 1: Always prune dangling images (untagged)
84+ $ commands = ['docker image prune -f ' ];
85+
86+ if ($ applicationImageRepos ->isEmpty ()) {
87+ // No applications, add original prune command for all unused images
88+ $ commands [] = 'docker image prune -af --filter "label!=coolify.managed=true" ' ;
89+ } else {
90+ // Build grep pattern to exclude application image repositories
91+ $ excludePatterns = $ applicationImageRepos ->map (function ($ repo ) {
92+ // Escape special characters for grep extended regex (ERE)
93+ // ERE special chars: . \ + * ? [ ^ ] $ ( ) { } |
94+ return preg_replace ('/([. \\\\+*?\[\]^$(){}|])/ ' , '\\\\$1 ' , $ repo );
95+ })->implode ('| ' );
96+
97+ // Delete unused images that:
98+ // - Are not application images (don't match app repos)
99+ // - Don't have coolify.managed=true label
100+ // Images in use by containers will fail silently with docker rmi
101+ // Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build)
102+ $ commands [] = "docker images --format '{{.Repository}}:{{.Tag}}' | " .
103+ "grep -v -E '^( {$ excludePatterns })[_:].+' | " .
104+ "grep -v '<none>' | " .
105+ "xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed \\\"}}}} \" \"{} \" 2>/dev/null | grep -q true || docker rmi \"{} \" 2>/dev/null' || true " ;
106+ }
107+
108+ return implode (' && ' , $ commands );
109+ }
110+
111+ private function cleanupApplicationImages (Server $ server , $ applications = null ): array
112+ {
113+ $ cleanupLog = [];
114+
115+ if ($ applications === null ) {
116+ $ applications = $ server ->applications ();
117+ }
118+
119+ $ disableRetention = $ server ->settings ->disable_application_image_retention ?? false ;
120+
121+ foreach ($ applications as $ application ) {
122+ $ imagesToKeep = $ disableRetention ? 0 : ($ application ->settings ->docker_images_to_keep ?? 2 );
123+ $ imageRepository = $ application ->docker_registry_image_name ?? $ application ->uuid ;
124+
125+ // Get the currently running image tag
126+ $ currentTagCommand = "docker inspect --format='{{.Config.Image}}' {$ application ->uuid } 2>/dev/null | grep -oP '(?<=:)[^:]+$' || true " ;
127+ $ currentTag = instant_remote_process ([$ currentTagCommand ], $ server , false );
128+ $ currentTag = trim ($ currentTag ?? '' );
129+
130+ // List all images for this application with their creation timestamps
131+ // Use wildcard to match both uuid:tag and uuid_servicename:tag (Docker Compose with build)
132+ $ listCommand = "docker images --format '{{.Repository}}:{{.Tag}}#{{.CreatedAt}}' --filter reference=' {$ imageRepository }*' 2>/dev/null || true " ;
133+ $ output = instant_remote_process ([$ listCommand ], $ server , false );
134+
135+ if (empty ($ output )) {
136+ continue ;
137+ }
138+
139+ $ images = collect (explode ("\n" , trim ($ output )))
140+ ->filter ()
141+ ->map (function ($ line ) {
142+ $ parts = explode ('# ' , $ line );
143+ $ imageRef = $ parts [0 ] ?? '' ;
144+ $ tagParts = explode (': ' , $ imageRef );
145+
146+ return [
147+ 'repository ' => $ tagParts [0 ] ?? '' ,
148+ 'tag ' => $ tagParts [1 ] ?? '' ,
149+ 'created_at ' => $ parts [1 ] ?? '' ,
150+ 'image_ref ' => $ imageRef ,
151+ ];
152+ })
153+ ->filter (fn ($ image ) => ! empty ($ image ['tag ' ]));
154+
155+ // Separate images into categories
156+ // PR images (pr-*) and build images (*-build) are excluded from retention
157+ // Build images will be cleaned up by docker image prune -af
158+ $ prImages = $ images ->filter (fn ($ image ) => str_starts_with ($ image ['tag ' ], 'pr- ' ));
159+ $ regularImages = $ images ->filter (fn ($ image ) => ! str_starts_with ($ image ['tag ' ], 'pr- ' ) && ! str_ends_with ($ image ['tag ' ], '-build ' ));
160+
161+ // Always delete all PR images
162+ foreach ($ prImages as $ image ) {
163+ $ deleteCommand = "docker rmi {$ image ['image_ref ' ]} 2>/dev/null || true " ;
164+ $ deleteOutput = instant_remote_process ([$ deleteCommand ], $ server , false );
165+ $ cleanupLog [] = [
166+ 'command ' => $ deleteCommand ,
167+ 'output ' => $ deleteOutput ?? 'PR image removed or was in use ' ,
168+ ];
169+ }
170+
171+ // Filter out current running image from regular images and sort by creation date
172+ $ sortedRegularImages = $ regularImages
173+ ->filter (fn ($ image ) => $ image ['tag ' ] !== $ currentTag )
174+ ->sortByDesc ('created_at ' )
175+ ->values ();
176+
177+ // Keep only N images (imagesToKeep), delete the rest
178+ $ imagesToDelete = $ sortedRegularImages ->skip ($ imagesToKeep );
179+
180+ foreach ($ imagesToDelete as $ image ) {
181+ $ deleteCommand = "docker rmi {$ image ['image_ref ' ]} 2>/dev/null || true " ;
182+ $ deleteOutput = instant_remote_process ([$ deleteCommand ], $ server , false );
183+ $ cleanupLog [] = [
184+ 'command ' => $ deleteCommand ,
185+ 'output ' => $ deleteOutput ?? 'Image removed or was in use ' ,
186+ ];
187+ }
188+ }
189+
190+ return $ cleanupLog ;
191+ }
60192}
0 commit comments