feat(proxy): mark unused Traefik certificates and delete them in one step

Traefik renews every certificate in acme.json, also when no route uses
its domain. The TLS certificates page now marks certificates that no
Traefik label, proxy config file, or resource domain on the server uses,
and a "Delete unused" button removes them in one write with one backup.

Closes #12447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-11-03 18:12:40 +01:00
1 parent c94411fb92
commit 777362cd97
9 files changed
+314 -50

No files matched your search

+1
View File
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Cancel the older queued webhook deployments of an application when a newer push arrives, so only the latest commit is built. It is on by default and works per application, pull request, and server. Turn it off in **Advanced > Deployment** or with `is_auto_cancel_queued_deployments_enabled` in the applications API. Manual, API, restart, and rollback deployments are never cancelled ([#10921](https://github.com/coollabsio/coolify/discussions/10921), [#7678](https://github.com/coollabsio/coolify/discussions/7678))
- Add an opt-in setting to also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. The applications API sets it with `is_auto_cancel_building_deployments_enabled`
- Add `cancel_previous` (`none`, `queued`, or `building`) to `/api/v1/deploy` and the MCP `deploy` tool, so a deployment from the API or MCP can cancel the older webhook, API, and MCP deployments of the same application, pull request, and server. The default is `none`. Manual, restart, and rollback deployments are never cancelled
- Show "Not used" on the TLS certificates of the Traefik proxy page when no route and no resource on the server uses the domain, and add a **Delete unused** button that deletes all of them in one step. Traefik renews every certificate in `acme.json`, also after you delete the resource, until you delete the certificate and restart the proxy ([#12447](https://github.com/coollabsio/coolify/issues/12447))
### Changed
+13 -15
View File
@@ -17,8 +17,13 @@ class DeleteTraefikCertificate
private TraefikAcmeService $acmeService,
) {}
/** @return array{id: string, resolver: string, main_domain: string, sans: array<int, string>, store: ?string, expires_at: ?string} */
public function handle(Server $server, string $certificateId): array
/**
* Deletes the certificates in one write, so acme.json gets one backup.
*
* @param array<int, string> $certificateIds
* @return array<int, array{id: string, resolver: string, main_domain: string, sans: array<int, string>, store: ?string, expires_at: ?string}>
*/
public function handle(Server $server, array $certificateIds): array
{
if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) {
throw new RuntimeException('TLS certificates can only be managed for Traefik proxies.');
@@ -29,24 +34,17 @@ class DeleteTraefikCertificate
throw new RuntimeException('The Traefik ACME file could not be found.');
}
$certificate = collect($this->acmeService->certificates($contents))
->firstWhere('id', $certificateId);
if ($certificate === null) {
throw new RuntimeException('The selected certificate could not be found.');
}
$certificates = collect($this->acmeService->certificates($contents))
->whereIn('id', $certificateIds)
->values()
->all();
$updatedContents = $this->acmeService->deleteCertificate(
$contents,
$certificate['resolver'],
$certificateId,
);
SaveTraefikAcmeFile::run($server, $updatedContents);
SaveTraefikAcmeFile::run($server, $this->acmeService->deleteCertificates($contents, $certificateIds));
// Traefik keeps loaded certificates in memory until it restarts.
$server->proxy->certificates_restart_required = true;
$server->save();
return $certificate;
return $certificates;
}
}
+50 -2
View File
@@ -16,7 +16,12 @@ class GetTraefikCertificates
public function __construct(private TraefikAcmeService $acmeService) {}
/** @return array<int, array{id: string, resolver: string, main_domain: string, sans: array<int, string>, store: ?string, expires_at: ?string}> */
/**
* The certificates of acme.json. `in_use` is false when no route of the proxy and no resource
* on the server uses a domain of the certificate, and null when Coolify could not check it.
*
* @return array<int, array{id: string, resolver: string, main_domain: string, sans: array<int, string>, store: ?string, expires_at: ?string, in_use: ?bool}>
*/
public function handle(Server $server): array
{
if ($server->proxyType() !== ProxyTypes::TRAEFIK->value) {
@@ -24,8 +29,51 @@ class GetTraefikCertificates
}
$contents = $this->contents($server);
$certificates = $contents === null ? [] : $this->acmeService->certificates($contents);
if ($certificates === []) {
return [];
}
return $contents === null ? [] : $this->acmeService->certificates($contents);
$routing = $this->routingConfiguration($server);
$hostnames = $routing === null ? null : $this->acmeService->hostnames($routing);
return array_map(fn (array $certificate): array => [
...$certificate,
'in_use' => $hostnames === null ? null : $this->acmeService->isInUse($certificate, $hostnames),
], $certificates);
}
/**
* Text that names the domains the proxy can serve: the Traefik labels of all containers and
* Swarm services, the proxy and dynamic configuration files, and the domains of the resources
* on the server. Stopped resources have no containers, so their domains come from the database.
*/
private function routingConfiguration(Server $server): ?string
{
$proxyPath = escapeshellarg(rtrim($server->proxyPath(), '/'));
$script = implode('; ', [
"docker ps -aq --filter label=traefik.enable=true | xargs -r docker inspect --format '{{json .Config.Labels}}' 2>/dev/null",
"docker service ls -q 2>/dev/null | xargs -r docker service inspect --format '{{json .Spec.Labels}}' 2>/dev/null",
"cat {$proxyPath}/docker-compose.yml {$proxyPath}/dynamic/*.yaml {$proxyPath}/dynamic/*.yml 2>/dev/null",
'true',
]);
try {
$output = instant_remote_process(['sh -c '.escapeshellarg($script)], $server);
} catch (\Throwable) {
return null;
}
$applications = $server->applications();
$previews = $server->previews();
$domains = $applications->pluck('fqdn')
->concat($applications->pluck('docker_compose_domains'))
->concat($previews->pluck('fqdn'))
->concat($previews->pluck('docker_compose_domains'))
->concat($server->services()->with('applications')->get()->flatMap->applications->pluck('fqdn'))
->when($server->isLocalhost(), fn ($domains) => $domains->push(instanceSettings()->fqdn));
return $output."\n".$domains->filter()->implode("\n");
}
public function contents(Server $server): ?string
+39 -7
View File
@@ -118,7 +118,41 @@ class Certificates extends Component
{
try {
$this->authorize('update', $this->server);
$certificate = DeleteTraefikCertificate::run($this->server, $certificateId);
$this->certificatesDeleted(DeleteTraefikCertificate::run($this->server, [$certificateId]));
$this->dispatch('success', 'TLS certificate deleted. Restart Traefik to remove it from the running proxy.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/** Deletes the certificates that no route and no resource on the server uses, so Traefik stops renewing them. */
public function deleteUnusedTraefikCertificates(string $password = ''): void
{
try {
$this->authorize('update', $this->server);
$unusedIds = collect(GetTraefikCertificates::run($this->server))
->where('in_use', false)
->pluck('id')
->all();
if ($unusedIds === []) {
$this->traefikCertificates = GetTraefikCertificates::run($this->server);
$this->dispatch('info', 'No unused TLS certificates found.');
return;
}
$deleted = DeleteTraefikCertificate::run($this->server, $unusedIds);
$this->certificatesDeleted($deleted);
$this->dispatch('success', count($deleted).' unused TLS certificates deleted. Restart Traefik to remove them from the running proxy.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/** @param array<int, array{main_domain: string, resolver: string}> $certificates */
private function certificatesDeleted(array $certificates): void
{
foreach ($certificates as $certificate) {
auditLog('ui.proxy.certificate_deleted', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
@@ -126,13 +160,11 @@ class Certificates extends Component
'domain' => $certificate['main_domain'],
'resolver' => $certificate['resolver'],
]);
$this->traefikCertificates = GetTraefikCertificates::run($this->server);
$this->loadTraefikAcmeBackups();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'TLS certificate deleted. Restart Traefik to remove it from the running proxy.');
} catch (\Throwable $e) {
handleError($e, $this);
}
$this->traefikCertificates = GetTraefikCertificates::run($this->server);
$this->loadTraefikAcmeBackups();
$this->dispatch('refreshServerShow');
}
public function render(): View
+65 -17
View File
@@ -54,37 +54,85 @@ class TraefikAcmeService
return $certificates;
}
public function deleteCertificate(string $contents, string $resolver, string $certificateId): string
/**
* Removes the certificates with the given identifiers from every resolver.
*
* @param array<int, string> $certificateIds
*/
public function deleteCertificates(string $contents, array $certificateIds): string
{
$data = $this->decode($contents);
$certificates = $data[$resolver]['Certificates'] ?? null;
if (! is_array($certificates)) {
throw new RuntimeException('The selected certificate could not be found.');
}
$remaining = [];
$deleted = false;
foreach ($certificates as $certificate) {
if (! $deleted && is_array($certificate) && hash_equals($this->certificateId($resolver, $certificate), $certificateId)) {
$deleted = true;
$pending = array_fill_keys($certificateIds, true);
foreach ($data as $resolver => $resolverData) {
$certificates = is_string($resolver) && is_array($resolverData) ? ($resolverData['Certificates'] ?? null) : null;
if (! is_array($certificates)) {
continue;
}
$remaining[] = $certificate;
$remaining = [];
foreach ($certificates as $certificate) {
$id = is_array($certificate) ? $this->certificateId($resolver, $certificate) : null;
if ($id !== null && isset($pending[$id])) {
unset($pending[$id]);
continue;
}
$remaining[] = $certificate;
}
$data[$resolver]['Certificates'] = $remaining;
}
if (! $deleted) {
if ($certificateIds === [] || $pending !== []) {
throw new RuntimeException('The selected certificate could not be found.');
}
$data[$resolver]['Certificates'] = $remaining;
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR).PHP_EOL;
}
/**
* Every host name in the text, such as the hosts of Traefik rules, labels, and resource URLs.
*
* @return array<string, true>
*/
public function hostnames(string $text): array
{
preg_match_all('/(?<![a-z0-9-])(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}(?![a-z0-9-])/', strtolower($text), $matches);
return array_fill_keys($matches[0], true);
}
/**
* True when a host in use matches the main domain or an alternative name of the certificate.
*
* @param array{main_domain: string, sans: array<int, string>} $certificate
* @param array<string, true> $hostnames
*/
public function isInUse(array $certificate, array $hostnames): bool
{
foreach ([$certificate['main_domain'], ...$certificate['sans']] as $domain) {
$domain = strtolower($domain);
if (isset($hostnames[$domain])) {
return true;
}
// A wildcard certificate covers exactly one label below its domain.
if (str_starts_with($domain, '*.')) {
$suffix = substr($domain, 1);
foreach (array_keys($hostnames) as $hostname) {
$label = str_ends_with($hostname, $suffix) ? substr($hostname, 0, -strlen($suffix)) : '';
if ($label !== '' && ! str_contains($label, '.')) {
return true;
}
}
}
}
return false;
}
/** @return array<string, mixed> */
private function decode(string $contents): array
{
@@ -9,8 +9,24 @@
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
<x-application.settings-section id="server-proxy-certificates-section" title="TLS certificates"
x-init="$wire.loadTraefikCertificates()"
helper="Review certificates stored in Traefik's acme.json file. Deleting an entry removes it from local ACME storage. It does not revoke the certificate at the certificate authority. If a route still uses the domain, Traefik requests a new certificate after the restart.">
helper="Review certificates stored in Traefik's acme.json file. Traefik renews every certificate in this file, also when no route uses its domain any more. Deleting an entry removes it from local ACME storage. It does not revoke the certificate at the certificate authority. If a route still uses the domain, Traefik requests a new certificate after the restart.">
<x-slot:actions>
@php $unusedCertificates = collect($traefikCertificates)->where('in_use', false); @endphp
@if ($unusedCertificates->isNotEmpty())
@can('update', $server)
<x-modal-confirmation title="Delete Unused TLS Certificates?" isErrorButton
buttonTitle="Delete unused ({{ $unusedCertificates->count() }})"
submitAction="deleteUnusedTraefikCertificates"
:actions="[
'Save the current acme.json as a backup that you can restore below.',
'Delete '.$unusedCertificates->count().' certificates that no route and no resource on this server uses: '.$unusedCertificates->pluck('main_domain')->implode(', ').'.',
]"
warningMessage="The proxy keeps using the certificates until you restart it. If a route still uses a domain, Traefik requests a new certificate after the restart. You can restore the backup below to undo this."
step2ButtonText="Delete Certificates"
:confirmWithPassword="false"
:confirmWithText="false" />
@endcan
@endif
<x-forms.button type="button" wire:click="loadTraefikCertificates"
wire:loading.attr="disabled" wire:target="loadTraefikCertificates">
Refresh
@@ -44,7 +60,13 @@
@foreach ($traefikCertificates as $certificate)
<tr wire:key="traefik-certificate-{{ $certificate['id'] }}">
<td class="font-medium text-neutral-950 dark:text-fg">
{{ $certificate['main_domain'] }}
<div class="flex flex-col items-start gap-1">
{{ $certificate['main_domain'] }}
@if (($certificate['in_use'] ?? null) === false)
<x-status-badge label="Not used" type="warning" size="sm"
title="No route and no resource on this server uses this domain. Traefik renews the certificate until you delete it." />
@endif
</div>
</td>
<td>
<span class="font-mono text-xs">{{ $certificate['resolver'] }}</span>
+1 -1
View File
@@ -104,7 +104,7 @@ function filePermissions(string $path): string
}
it('backs up acme.json before a certificate is deleted', function () {
DeleteTraefikCertificate::run($this->server, GetTraefikCertificates::run($this->server)[0]['id']);
DeleteTraefikCertificate::run($this->server, [GetTraefikCertificates::run($this->server)[0]['id']]);
$backups = acmeBackupFiles($this->proxyDirectory);
expect($backups)->toHaveCount(1)
@@ -5,10 +5,14 @@ use App\Actions\Proxy\DeleteTraefikCertificate;
use App\Actions\Proxy\GetTraefikCertificates;
use App\Enums\ProxyTypes;
use App\Livewire\Server\Proxy\Certificates;
use App\Models\Application;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\TraefikAcmeService;
@@ -39,6 +43,8 @@ beforeEach(function () {
$this->acmeContents = json_encode(['letsencrypt' => ['Certificates' => $certificates]]);
$this->uploadedContents = null;
// Every certificate except app1 and app2 is unused unless a test adds a route or a resource.
$this->routingOutput = '{"traefik.http.routers.http-0-app1.rule":"Host(`app1.example.com`)"}';
Process::fake(function ($process) {
$command = remoteProcessScript($process);
if (str_starts_with($command, 'timeout') && str_contains($command, ' scp ')
@@ -48,6 +54,10 @@ beforeEach(function () {
return Process::result();
}
if (str_contains($command, 'docker ps -aq --filter label=traefik.enable=true')) {
return Process::result(output: $this->routingOutput);
}
return Process::result(output: str_contains($command, 'head -c') ? base64_encode($this->acmeContents) : '');
});
});
@@ -63,7 +73,7 @@ it('reads the ACME file with sudo on non-root servers', function () {
it('uploads a large ACME file instead of passing it as a shell argument', function () {
$certificate = GetTraefikCertificates::run($this->server)[0];
DeleteTraefikCertificate::run($this->server, $certificate['id']);
DeleteTraefikCertificate::run($this->server, [$certificate['id']]);
$remaining = json_decode($this->uploadedContents, true)['letsencrypt']['Certificates'];
expect(strlen($this->acmeContents))->toBeGreaterThan(128 * 1024)
@@ -81,7 +91,7 @@ it('uploads a large ACME file instead of passing it as a shell argument', functi
it('asks for a proxy restart after a certificate is deleted until the proxy is restarted', function () {
expect($this->server->hasPendingProxyConfiguration())->toBeFalse();
DeleteTraefikCertificate::run($this->server, GetTraefikCertificates::run($this->server)[0]['id']);
DeleteTraefikCertificate::run($this->server, [GetTraefikCertificates::run($this->server)[0]['id']]);
expect($this->server->fresh()->hasPendingProxyConfiguration())->toBeTrue();
@@ -221,3 +231,70 @@ it('does not load certificate controls for a Caddy server', function () {
->assertDontSeeLivewire(Certificates::class)
->assertSee('Traefik required');
});
it('marks certificates that no route and no resource on the server uses', function () {
$destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$project = Project::factory()->create(['team_id' => $this->server->team_id]);
// A stopped application has no container, so only the database names its domain.
Application::factory()->create([
'environment_id' => Environment::factory()->create(['project_id' => $project->id])->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'fqdn' => 'https://app2.example.com',
]);
$usage = collect(GetTraefikCertificates::run($this->server))->pluck('in_use', 'main_domain');
expect($usage['app1.example.com'])->toBeTrue()
->and($usage['app2.example.com'])->toBeTrue()
->and($usage->filter(fn (bool $inUse): bool => ! $inUse))->toHaveCount(38);
});
it('does not mark certificates as unused when the routes cannot be read', function () {
Process::fake(function ($process) {
$command = remoteProcessScript($process);
if (str_contains($command, 'docker ps -aq')) {
return Process::result(errorOutput: 'Connection refused', exitCode: 255);
}
return Process::result(output: str_contains($command, 'head -c') ? base64_encode($this->acmeContents) : '');
});
expect(collect(GetTraefikCertificates::run($this->server))->pluck('in_use')->unique()->all())->toBe([null]);
});
it('lets an admin delete all unused certificates in one write', function () {
$this->withoutDefer();
actingAsTraefikCertificateUser($this->server->team, 'admin');
Livewire::test(Certificates::class, ['server' => $this->server])
->call('loadTraefikCertificates')
->assertSee('Delete unused (39)')
->assertSee('Not used')
->call('deleteUnusedTraefikCertificates')
->assertDispatched('success')
->assertDispatched('refreshServerShow');
$remaining = json_decode($this->uploadedContents, true)['letsencrypt']['Certificates'];
expect(collect($remaining)->pluck('domain.main')->all())->toBe(['app1.example.com'])
->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeTrue()
->and(AuditEvent::query()->where('event', 'ui.proxy.certificate_deleted')->count())->toBe(39);
Process::assertRanTimes(fn ($process) => str_contains(remoteProcessScript($process), 'mv --'), 1);
});
it('does not let a member or another team delete unused certificates', function (string $role, bool $ownTeam) {
actingAsTraefikCertificateUser($ownTeam ? $this->server->team : Team::factory()->create(), $role);
Livewire::test(Certificates::class, ['server' => $this->server])
->call('loadTraefikCertificates')
->assertDontSeeHtml('deleteUnusedTraefikCertificates')
->call('deleteUnusedTraefikCertificates')
->assertDispatched('error')
->assertNotDispatched('success');
expect($this->uploadedContents)->toBeNull()
->and($this->server->fresh()->hasPendingProxyConfiguration())->toBeFalse();
})->with([
'member' => ['member', true],
'owner of another team' => ['owner', false],
]);
+42 -4
View File
@@ -53,10 +53,9 @@ it('removes only the selected certificate and preserves all other ACME data', fu
$service = app(TraefikAcmeService::class);
$certificates = $service->certificates(traefikAcmeFixture());
$updated = json_decode($service->deleteCertificate(
$updated = json_decode($service->deleteCertificates(
traefikAcmeFixture(),
'letsencrypt',
$certificates[0]['id'],
[$certificates[0]['id']],
), true, flags: JSON_THROW_ON_ERROR);
expect($updated['letsencrypt']['Account']['Email'])->toBe('admin@example.com')
@@ -70,7 +69,7 @@ it('rejects invalid files and unknown certificate identifiers', function () {
expect(fn () => $service->certificates('{invalid'))
->toThrow(RuntimeException::class, 'invalid JSON')
->and(fn () => $service->deleteCertificate(traefikAcmeFixture(), 'letsencrypt', 'missing'))
->and(fn () => $service->deleteCertificates(traefikAcmeFixture(), ['missing']))
->toThrow(RuntimeException::class, 'could not be found');
});
@@ -88,3 +87,42 @@ it('reads the expiry date from the base64-encoded PEM chain Traefik stores', fun
expect(app(TraefikAcmeService::class)->certificates($contents)[0]['expires_at'])->toBe($expiresAt);
});
it('removes several certificates of different resolvers in one pass', function () {
$service = app(TraefikAcmeService::class);
$certificates = $service->certificates(traefikAcmeFixture());
$updated = json_decode($service->deleteCertificates(
traefikAcmeFixture(),
[$certificates[1]['id'], $certificates[2]['id']],
), true, flags: JSON_THROW_ON_ERROR);
expect(collect($updated['letsencrypt']['Certificates'])->pluck('domain.main')->all())->toBe(['example.com'])
->and($updated['zerossl']['Certificates'])->toBe([])
->and($updated['zerossl']['Account']['Email'])->toBe('admin@example.org')
->and(fn () => $service->deleteCertificates(traefikAcmeFixture(), [$certificates[0]['id'], 'missing']))
->toThrow(RuntimeException::class, 'could not be found');
});
it('finds the host names in Traefik labels, configuration files, and resource URLs', function () {
$text = '{"traefik.http.routers.http-0-abc.rule":"Host(`App.Example.com`) && PathPrefix(`/`)"}'."\n"
."rule: Host(`api.example.net`)\ndomains:\n - main: '*.wild.example.org'\nhttps://shop.example.com:8443/path,http://www.example.com";
expect(app(TraefikAcmeService::class)->hostnames($text))
->toHaveKeys(['app.example.com', 'api.example.net', '*.wild.example.org', 'shop.example.com', 'www.example.com']);
});
it('treats a certificate as used when a host matches its domain, an alternative name, or its wildcard', function (array $hosts, bool $inUse) {
$certificate = ['main_domain' => 'Example.com', 'sans' => ['www.example.com', '*.apps.example.com']];
expect(app(TraefikAcmeService::class)->isInUse($certificate, array_fill_keys($hosts, true)))->toBe($inUse);
})->with([
'main domain' => [['example.com'], true],
'alternative name' => [['www.example.com'], true],
'host below the wildcard' => [['shop.apps.example.com'], true],
'wildcard named in the configuration' => [['*.apps.example.com'], true],
'host two levels below the wildcard' => [['a.shop.apps.example.com'], false],
'parent of the wildcard' => [['apps.example.com'], false],
'other domain' => [['example.net', 'other.example.com'], false],
'no hosts' => [[], false],
]);