feat(proxy): make the Traefik certificate resolver configurable per server

Coolify always added tls.certresolver=letsencrypt to generated HTTPS
routers, so Traefik logged errors when the proxy used its own
certificates (for example Cloudflare Origin CA) without that resolver.
Add a server setting (default letsencrypt) in the proxy page and API.
An empty value omits the resolver from resource labels, the Coolify
dashboard routes, and the catch-all router.

Closes #12138
This commit is contained in:
Andras Bacsai
2026-10-11 10:19:40 +02:00
parent d4502bc514
commit af589f6da4
13 changed files with 339 additions and 28 deletions
@@ -7,6 +7,7 @@ use App\Enums\ProxyTypes;
use App\Http\Controllers\Controller;
use App\Jobs\RestartProxyJob;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Rules\SafeExternalUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -41,6 +42,7 @@ class ServerProxyController extends Controller
* redirect_enabled: bool,
* redirect_url: string|null,
* generate_exact_labels: bool,
* traefik_certificate_resolver: string|null,
* configuration?: string|null
* }
*/
@@ -52,6 +54,7 @@ class ServerProxyController extends Controller
'redirect_enabled' => (bool) data_get($server->proxy, 'redirect_enabled', true),
'redirect_url' => data_get($server->proxy, 'redirect_url'),
'generate_exact_labels' => (bool) ($server->settings->generate_exact_labels ?? false),
'traefik_certificate_resolver' => $server->traefikCertificateResolver(),
];
// Proxy compose can contain secrets; only expose with read:sensitive (and admin) like other APIs.
@@ -86,6 +89,7 @@ class ServerProxyController extends Controller
new OA\Property(property: 'redirect_enabled', type: 'boolean', example: true),
new OA\Property(property: 'redirect_url', type: 'string', nullable: true, example: 'https://example.com'),
new OA\Property(property: 'generate_exact_labels', type: 'boolean', example: false),
new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true, example: 'letsencrypt', description: 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.'),
new OA\Property(property: 'configuration', type: 'string', nullable: true, description: 'Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.'),
]
)
@@ -114,7 +118,7 @@ class ServerProxyController extends Controller
#[OA\Patch(
summary: 'Update server proxy',
description: 'Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.',
description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.',
path: '/servers/{uuid}/proxy',
operationId: 'update-server-proxy',
security: [['bearerAuth' => []]],
@@ -130,6 +134,7 @@ class ServerProxyController extends Controller
new OA\Property(property: 'redirect_enabled', type: 'boolean'),
new OA\Property(property: 'redirect_url', type: 'string', nullable: true, description: 'Public http(s) redirect URL, or null to clear.'),
new OA\Property(property: 'generate_exact_labels', type: 'boolean'),
new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true, description: 'Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.'),
new OA\Property(property: 'proxy_type', type: 'string', enum: ['traefik', 'caddy', 'nginx', 'none'], description: 'Proxy type (case-insensitive).'),
]
)
@@ -146,6 +151,7 @@ class ServerProxyController extends Controller
new OA\Property(property: 'redirect_enabled', type: 'boolean'),
new OA\Property(property: 'redirect_url', type: 'string', nullable: true),
new OA\Property(property: 'generate_exact_labels', type: 'boolean'),
new OA\Property(property: 'traefik_certificate_resolver', type: 'string', nullable: true),
new OA\Property(property: 'configuration', type: 'string', nullable: true),
]
)
@@ -168,11 +174,12 @@ class ServerProxyController extends Controller
return $return;
}
$allowedFields = ['redirect_enabled', 'redirect_url', 'generate_exact_labels', 'proxy_type'];
$allowedFields = ['redirect_enabled', 'redirect_url', 'generate_exact_labels', 'traefik_certificate_resolver', 'proxy_type'];
$validator = customApiValidator($request->all(), [
'redirect_enabled' => 'boolean',
'redirect_url' => ['nullable', 'string', new SafeExternalUrl],
'generate_exact_labels' => 'boolean',
'traefik_certificate_resolver' => ['nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN],
'proxy_type' => 'string|nullable',
]);
@@ -228,16 +235,29 @@ class ServerProxyController extends Controller
$server->settings->save();
}
$certificateResolverChanged = false;
if ($request->exists('traefik_certificate_resolver')) {
$server->settings->traefik_certificate_resolver = $request->input('traefik_certificate_resolver') ?: null;
$certificateResolverChanged = $server->settings->isDirty('traefik_certificate_resolver');
$server->settings->save();
}
if ($request->has('proxy_type') && filled($request->proxy_type)) {
$server->changeProxy($request->proxy_type, async: true);
$server->refresh();
}
// Apply redirect file on the server only when reachable (DB settings always saved above).
if ($redirectChanged && $server->isFunctional()) {
// The catch-all router in that file uses the certificate resolver too.
if (($redirectChanged || $certificateResolverChanged) && $server->isFunctional()) {
$server->setupDefaultRedirect();
}
// Coolify's own dashboard routes use the resolver too.
if ($certificateResolverChanged && $server->isFunctional()) {
$server->setupDynamicProxyConfiguration();
}
auditLog('api.server.proxy.updated', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
+22 -4
View File
@@ -6,6 +6,7 @@ use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Rules\SafeExternalUrl;
use App\Traits\ListensToTeamChannel;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -28,6 +29,8 @@ class Proxy extends Component
public bool $generateExactLabels = false;
public ?string $certificateResolver = null;
/**
* Cache the versions.json file data in memory for this component instance.
* This avoids multiple file reads during a single request/render cycle.
@@ -49,6 +52,7 @@ class Proxy extends Component
return [
'generateExactLabels' => 'required|boolean',
'redirectUrl' => ['nullable', new SafeExternalUrl],
'certificateResolver' => ['nullable', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN],
];
}
@@ -65,8 +69,10 @@ class Proxy extends Component
{
if ($toModel) {
$this->server->settings->generate_exact_labels = $this->generateExactLabels;
$this->server->settings->traefik_certificate_resolver = $this->certificateResolver ?: null;
} else {
$this->generateExactLabels = $this->server->settings->generate_exact_labels ?? false;
$this->certificateResolver = $this->server->settings->traefik_certificate_resolver;
}
}
@@ -162,16 +168,27 @@ class Proxy extends Component
try {
$this->authorize('update', $this->server);
$this->validate();
$this->syncData(true);
$changedFields = auditChangedFields($this->server->settings);
$this->server->settings->save();
$this->auditProxyUpdate($changedFields);
$this->saveSettings();
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function saveSettings(): void
{
$this->syncData(true);
$changedFields = auditChangedFields($this->server->settings);
$this->server->settings->save();
$this->auditProxyUpdate($changedFields);
// Coolify's own dashboard and catch-all routes use the resolver too.
if (in_array('traefik_certificate_resolver', $changedFields, true)) {
$this->server->setupDynamicProxyConfiguration();
$this->server->setupDefaultRedirect();
}
}
public function instantSaveRedirect()
{
try {
@@ -195,6 +212,7 @@ class Proxy extends Component
SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->proxy->redirect_url = $this->redirectUrl;
$this->server->save();
$this->saveSettings();
auditLog('ui.server.proxy.configuration_saved', $this->proxyAuditContext());
$this->server->setupDefaultRedirect();
$this->dispatch('refreshServerShow');
+17 -15
View File
@@ -667,9 +667,7 @@ class Server extends BaseModel
],
'service' => 'noop',
'rule' => 'PathPrefix(`/`)',
'tls' => [
'certResolver' => 'letsencrypt',
],
'tls' => $this->traefikCertificateResolver() ? ['certResolver' => $this->traefikCertificateResolver()] : [],
'priority' => -1000,
],
],
@@ -806,6 +804,8 @@ class Server extends BaseModel
];
if ($schema === 'https') {
$certificateResolver = $this->traefikCertificateResolver();
$tls = $certificateResolver ? ['certresolver' => $certificateResolver] : [];
$traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = $this->dashboardHttpMiddlewares($settings);
$traefik_dynamic_conf['http']['routers']['coolify-https'] = [
@@ -817,9 +817,7 @@ class Server extends BaseModel
],
'service' => 'coolify',
'rule' => "Host(`{$host}`)",
'tls' => [
'certresolver' => 'letsencrypt',
],
'tls' => $tls,
];
$traefik_dynamic_conf['http']['routers']['coolify-reverb-wss'] = [
'entryPoints' => [
@@ -827,9 +825,7 @@ class Server extends BaseModel
],
'service' => 'coolify-reverb',
'rule' => "Host(`{$host}`) && PathPrefix(`/app`)",
'tls' => [
'certresolver' => 'letsencrypt',
],
'tls' => $tls,
];
$traefik_dynamic_conf['http']['routers']['coolify-reverb-api-https'] = [
'entryPoints' => [
@@ -837,9 +833,7 @@ class Server extends BaseModel
],
'service' => 'coolify-reverb',
'rule' => "Host(`{$host}`) && PathPrefix(`/apps`)",
'tls' => [
'certresolver' => 'letsencrypt',
],
'tls' => $tls,
];
$traefik_dynamic_conf['http']['routers']['coolify-terminal-wss'] = [
'entryPoints' => [
@@ -847,9 +841,7 @@ class Server extends BaseModel
],
'service' => 'coolify-terminal',
'rule' => "Host(`{$host}`) && PathPrefix(`/terminal/ws`)",
'tls' => [
'certresolver' => 'letsencrypt',
],
'tls' => $tls,
];
}
$yaml = Yaml::dump($traefik_dynamic_conf, 12, 2);
@@ -1169,6 +1161,16 @@ $siteAddress {
return $this->settings->is_metrics_enabled;
}
/**
* Traefik certificate resolver for generated HTTPS routers, or null when the proxy uses its own certificates.
*/
public function traefikCertificateResolver(): ?string
{
$resolver = data_get($this, 'settings.traefik_certificate_resolver');
return filled($resolver) ? $resolver : null;
}
public function isTrafficAnalyticsEnabled(): bool
{
return (bool) data_get($this, 'settings.is_traffic_analytics_enabled', false);
+7
View File
@@ -38,6 +38,7 @@ use OpenApi\Attributes as OA;
'is_geoip_enabled' => ['type' => 'boolean'],
'geoip_refresh_days' => ['type' => 'integer'],
'traffic_ip_mode' => ['type' => 'string', 'enum' => ['full', 'anonymized', 'off']],
'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.'],
'is_reachable' => ['type' => 'boolean'],
'is_sentinel_enabled' => ['type' => 'boolean'],
'is_swarm_manager' => ['type' => 'boolean'],
@@ -71,6 +72,11 @@ use OpenApi\Attributes as OA;
)]
class ServerSetting extends Model
{
/**
* Allowed Traefik certificate resolver names; also keeps the value safe inside generated labels.
*/
public const string TRAEFIK_CERTIFICATE_RESOLVER_PATTERN = '/^[A-Za-z0-9_-]+$/';
public const int DEFAULT_SENTINEL_METRICS_REFRESH_RATE_SECONDS = 10;
public const int DEFAULT_SENTINEL_METRICS_HISTORY_DAYS = 7;
@@ -134,6 +140,7 @@ class ServerSetting extends Model
'geoip_refresh_days',
'geoip_maxmind_license_key',
'traffic_ip_mode',
'traefik_certificate_resolver',
'docker_version',
'docker_version_checked_at',
'compose_version',
+8 -2
View File
@@ -924,7 +924,7 @@ function traefikHostRulesWithoutIpv6Brackets(Collection $labels): Collection
) : $label);
}
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [])
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [], ?string $certificate_resolver = 'letsencrypt')
{
$labels = collect([]);
$labels->push('traefik.enable=true');
@@ -1092,7 +1092,9 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
}
}
$labels->push("traefik.http.routers.{$https_label}.tls=true");
$labels->push("traefik.http.routers.{$https_label}.tls.certresolver=letsencrypt");
if (filled($certificate_resolver)) {
$labels->push("traefik.http.routers.{$https_label}.tls.certresolver={$certificate_resolver}");
}
// Set labels for http (redirect to https)
$labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)");
@@ -1226,6 +1228,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains,
domainPortOverrides: $application->domain_port_overrides ?? [],
certificate_resolver: $application->destination->server->traefikCertificateResolver(),
));
break;
case ProxyTypes::CADDY->value:
@@ -1264,6 +1267,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
domainPortOverrides: $application->domain_port_overrides ?? [],
certificate_resolver: $application->destination->server->traefikCertificateResolver(),
));
$labels = $labels->merge(fqdnLabelsForCaddy(
network: $application->destination->network,
@@ -1310,6 +1314,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
domainPortOverrides: $preview->domain_port_overrides ?? [],
certificate_resolver: $application->destination->server->traefikCertificateResolver(),
));
break;
case ProxyTypes::CADDY->value:
@@ -1346,6 +1351,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
domainPortOverrides: $preview->domain_port_overrides ?? [],
certificate_resolver: $application->destination->server->traefikCertificateResolver(),
));
$labels = $labels->merge(fqdnLabelsForCaddy(
network: $application->destination->network,
+6 -2
View File
@@ -2029,6 +2029,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
domainPortOverrides: $domainPortOverrides,
certificate_resolver: $server->traefikCertificateResolver(),
));
break;
case ProxyTypes::CADDY->value:
@@ -2066,6 +2067,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
domainPortOverrides: $domainPortOverrides,
certificate_resolver: $server->traefikCertificateResolver(),
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $labelNetwork,
@@ -3277,7 +3279,8 @@ function serviceParser(Service $resource): Collection
onlyPort: $onlyPort,
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
redirect_direction: $redirectDirection,
certificate_resolver: $server->traefikCertificateResolver(),
));
break;
case ProxyTypes::CADDY->value:
@@ -3314,7 +3317,8 @@ function serviceParser(Service $resource): Collection
onlyPort: $onlyPort,
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
redirect_direction: $redirectDirection,
certificate_resolver: $server->traefikCertificateResolver(),
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $network,
+4
View File
@@ -3488,6 +3488,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
domainPortOverrides: $domainPortOverrides,
certificate_resolver: $resource->server->traefikCertificateResolver(),
));
break;
case ProxyTypes::CADDY->value:
@@ -3525,6 +3526,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
domainPortOverrides: $domainPortOverrides,
certificate_resolver: $resource->server->traefikCertificateResolver(),
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $resource->destination->network,
@@ -4299,6 +4301,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
domainPortOverrides: $domainPortOverrides,
certificate_resolver: $server->traefikCertificateResolver(),
)
);
break;
@@ -4339,6 +4342,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
domainPortOverrides: $domainPortOverrides,
certificate_resolver: $server->traefikCertificateResolver(),
)
);
$serviceLabels = $serviceLabels->merge(
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->string('traefik_certificate_resolver')->nullable()->default('letsencrypt');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('traefik_certificate_resolver');
});
}
};
+27 -1
View File
@@ -16670,6 +16670,14 @@
"type": "boolean",
"example": false
},
"traefik_certificate_resolver": {
"description": "Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.",
"type": [
"string",
"null"
],
"example": "letsencrypt"
},
"configuration": {
"description": "Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.",
"type": [
@@ -16704,7 +16712,7 @@
"Servers"
],
"summary": "Update server proxy",
"description": "Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.",
"description": "Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.",
"operationId": "update-server-proxy",
"parameters": [
{
@@ -16736,6 +16744,13 @@
"generate_exact_labels": {
"type": "boolean"
},
"traefik_certificate_resolver": {
"description": "Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.",
"type": [
"string",
"null"
]
},
"proxy_type": {
"description": "Proxy type (case-insensitive).",
"type": "string",
@@ -16783,6 +16798,12 @@
"generate_exact_labels": {
"type": "boolean"
},
"traefik_certificate_resolver": {
"type": [
"string",
"null"
]
},
"configuration": {
"type": [
"string",
@@ -24387,6 +24408,11 @@
"off"
]
},
"traefik_certificate_resolver": {
"type": "string",
"nullable": true,
"description": "Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates."
},
"is_reachable": {
"type": "boolean"
},
+10 -1
View File
@@ -10571,6 +10571,7 @@ paths:
redirect_enabled: { type: boolean, example: true }
redirect_url: { type: [string, 'null'], example: 'https://example.com' }
generate_exact_labels: { type: boolean, example: false }
traefik_certificate_resolver: { description: 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.', type: [string, 'null'], example: letsencrypt }
configuration: { description: 'Docker Compose proxy configuration when stored in the database. Only present with read:sensitive.', type: [string, 'null'] }
type: object
'401':
@@ -10586,7 +10587,7 @@ paths:
tags:
- Servers
summary: 'Update server proxy'
description: 'Update proxy redirect settings, exact labels generation, and optionally the proxy type for a team-owned server.'
description: 'Update proxy redirect settings, exact labels generation, the Traefik certificate resolver, and optionally the proxy type for a team-owned server.'
operationId: update-server-proxy
parameters:
-
@@ -10609,6 +10610,9 @@ paths:
type: [string, 'null']
generate_exact_labels:
type: boolean
traefik_certificate_resolver:
description: 'Traefik certificate resolver for generated HTTPS routers (letters, digits, _ and -). Null or empty to use your own certificates. Redeploy resources to apply.'
type: [string, 'null']
proxy_type:
description: 'Proxy type (case-insensitive).'
type: string
@@ -10626,6 +10630,7 @@ paths:
redirect_enabled: { type: boolean }
redirect_url: { type: [string, 'null'] }
generate_exact_labels: { type: boolean }
traefik_certificate_resolver: { type: [string, 'null'] }
configuration: { type: [string, 'null'] }
type: object
'401':
@@ -15586,6 +15591,10 @@ components:
- full
- anonymized
- 'off'
traefik_certificate_resolver:
type: string
nullable: true
description: 'Traefik certificate resolver for generated HTTPS routers. Null when the proxy uses its own certificates.'
is_reachable:
type: boolean
is_sentinel_enabled:
@@ -80,6 +80,11 @@
label="Redirect URL"
helper="Leave empty to keep a custom 503 response without redirecting." />
@endif
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
<x-forms.input canGate="update" :canResource="$server" placeholder="letsencrypt"
id="certificateResolver" label="Certificate resolver"
helper="Traefik certificate resolver for generated HTTPS routes. Leave empty if Traefik uses your own certificates (for example Cloudflare Origin CA certificates in a dynamic configuration). Redeploy resources to apply the change." />
@endif
</div>
</x-application.settings-section>
+31
View File
@@ -152,6 +152,37 @@ test('PATCH /api/v1/servers/{uuid}/proxy updates redirect and label settings wit
->and((bool) $server->settings->generate_exact_labels)->toBeTrue();
});
test('PATCH /api/v1/servers/{uuid}/proxy updates the Traefik certificate resolver', function (?string $input, ?string $expected) {
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->getJson("/api/v1/servers/{$this->server->uuid}/proxy")
->assertOk()
->assertJsonPath('traefik_certificate_resolver', 'letsencrypt');
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
'traefik_certificate_resolver' => $input,
])
->assertOk()
->assertJsonPath('traefik_certificate_resolver', $expected);
expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBe($expected);
})->with([
'custom resolver' => ['cloudflare', 'cloudflare'],
'no resolver' => [null, null],
'empty resolver' => ['', null],
]);
test('PATCH /api/v1/servers/{uuid}/proxy rejects unsafe certificate resolver names', function () {
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
'traefik_certificate_resolver' => 'letsencrypt.acme=x',
])
->assertUnprocessable()
->assertJsonValidationErrors('traefik_certificate_resolver');
expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBe('letsencrypt');
});
test('PATCH /api/v1/servers/{uuid}/proxy rejects unknown fields', function () {
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
@@ -0,0 +1,151 @@
<?php
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Livewire\Server\Proxy;
use App\Models\Application;
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 Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Once;
use Livewire\Livewire;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Queue::fake();
config(['constants.ssh.mux_enabled' => false]);
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
$this->team = Team::factory()->create();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
]);
});
function traefikCertificateResolverApplicationLabels(Server $server): array
{
$project = Project::factory()->create(['team_id' => $server->team_id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$application = Application::factory()->createOne([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'fqdn' => 'https://example.com',
'redirect' => 'both',
'is_http_basic_auth_enabled' => false,
]);
return generateLabelsApplication($application);
}
test('new servers keep the letsencrypt resolver on generated HTTPS routers', function () {
$labels = collect(traefikCertificateResolverApplicationLabels($this->server));
expect($labels->filter(fn ($label) => str_contains($label, '.tls.certresolver=letsencrypt')))->not->toBeEmpty();
});
test('servers without a resolver generate HTTPS routers without certresolver labels', function () {
$this->server->settings->update(['traefik_certificate_resolver' => null]);
$labels = collect(traefikCertificateResolverApplicationLabels($this->server->fresh()));
expect($labels->filter(fn ($label) => str_contains($label, '.tls=true')))->not->toBeEmpty()
->and($labels->filter(fn ($label) => str_contains($label, 'certresolver')))->toBeEmpty();
});
test('servers with a custom resolver use it on generated HTTPS routers', function () {
$this->server->settings->update(['traefik_certificate_resolver' => 'cloudflare']);
$labels = collect(traefikCertificateResolverApplicationLabels($this->server->fresh()));
expect($labels->filter(fn ($label) => str_contains($label, '.tls.certresolver=cloudflare')))->not->toBeEmpty()
->and($labels->filter(fn ($label) => str_contains($label, 'certresolver=letsencrypt')))->toBeEmpty();
});
test('coolify dashboard routes follow the server certificate resolver', function (?string $resolver, array $expectedTls) {
Process::fake();
InstanceSettings::query()->whereKey(0)->update(['fqdn' => 'https://coolify.example.com']);
Once::flush();
$this->server->update(['ip' => 'host.docker.internal']);
$this->server->settings->update(['traefik_certificate_resolver' => $resolver]);
$this->server->fresh()->setupDynamicProxyConfiguration();
$command = '';
Process::assertRan(function ($process) use (&$command) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
return str_contains($command, 'coolify.yaml');
});
preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d/", $command, $matches);
$routers = Yaml::parse(base64_decode($matches[1]))['http']['routers'];
foreach (['coolify-https', 'coolify-reverb-wss', 'coolify-reverb-api-https', 'coolify-terminal-wss'] as $router) {
expect($routers[$router])->toHaveKey('tls')
->and($routers[$router]['tls'] ?? [])->toBe($expectedTls);
}
})->with([
'letsencrypt' => ['letsencrypt', ['certresolver' => 'letsencrypt']],
'no resolver' => [null, []],
]);
test('the catch-all router follows the server certificate resolver', function (?string $resolver, array $expectedTls) {
Process::fake();
$this->server->settings->update(['traefik_certificate_resolver' => $resolver]);
$this->server->fresh()->setupDefaultRedirect();
$command = '';
Process::assertRan(function ($process) use (&$command) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
return str_contains($command, 'default_redirect_503.yaml') && str_contains($command, 'base64 -d');
});
preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d/", $command, $matches);
$catchall = Yaml::parse(base64_decode($matches[1]))['http']['routers']['catchall'];
expect($catchall)->toHaveKey('tls')
->and($catchall['tls'] ?? [])->toBe($expectedTls);
})->with([
'letsencrypt' => ['letsencrypt', ['certResolver' => 'letsencrypt']],
'no resolver' => [null, []],
]);
test('proxy page saves an empty resolver as none and rejects unsafe names', function () {
SaveProxyConfiguration::shouldRun();
Process::fake();
$user = User::factory()->create();
$this->team->members()->attach($user->id, ['role' => 'owner']);
$this->actingAs($user);
session(['currentTeam' => $this->team]);
Livewire::test(Proxy::class, ['server' => $this->server])
->assertSet('certificateResolver', 'letsencrypt')
->set('certificateResolver', 'letsencrypt.acme=x')
->call('instantSave')
->assertDispatched('error');
expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBe('letsencrypt');
Livewire::test(Proxy::class, ['server' => $this->server->fresh()])
->set('certificateResolver', '')
->call('instantSave')
->assertDispatched('success');
expect($this->server->settings->fresh()->traefik_certificate_resolver)->toBeNull();
});