fix(proxy): warn about old Caddy images and share the Caddy traffic log path

- Servers with caddy-docker-proxy older than 2.9 show a warning on the
  Proxy page and the traffic analytics settings, with the fix.
- Caddy 2.9+ images get the basic_auth label; older ones keep the
  deprecated basicauth, which Caddy 2.7 still needs.
- Caddy mounts StartSentinel::trafficLogDirectory() as /traffic, so
  Caddy and Sentinel use the same access log, also in development.
- The development data volume is configurable (DEV_COOLIFY_DATA_VOLUME,
  same default as before) and shared by Traefik, Caddy, and Sentinel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-26 21:19:14 +02:00
co-authored by Claude Opus 5.5
parent fbfe342f89
commit dc20ffd92b
15 changed files with 502 additions and 18 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ class StartSentinel
public static function trafficLogDirectory(Server $server): string
{
return isDev()
? '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy'
? devCoolifyDataPath().'/proxy'
: rtrim($server->proxyPath(), '/');
}
@@ -108,7 +108,7 @@ class StartSentinel
if ($customImage && ! empty($customImage)) {
$image = $customImage;
}
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
$mountDir = devCoolifyDataPath().'/sentinel';
}
$dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
+8
View File
@@ -219,6 +219,14 @@ class Proxy extends Component
return $matches[1];
}
/**
* The saved caddy-docker-proxy image when it is older than 2.9 (Caddy 2.7), else null.
*/
public function getOutdatedCaddyImageProperty(): ?string
{
return $this->server->outdatedCaddyProxyImage();
}
public function loadTraefikCertificates(): void
{
$this->traefikCertificates = [];
@@ -112,6 +112,7 @@ class TrafficAnalyticsSettings extends Component
return view('livewire.server.traffic-analytics-settings', [
'unsupportedReason' => $this->server->trafficAnalyticsUnsupportedReason(),
'caddyRedeployNote' => $this->caddyRedeployNote(),
'outdatedCaddyImage' => $this->server->outdatedCaddyProxyImage(),
]);
}
+76 -10
View File
@@ -125,6 +125,17 @@ class Server extends BaseModel
public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::'];
/**
* Default Caddy proxy image. caddy-docker-proxy 2.13 ships Caddy 2.11.
*/
public const RECOMMENDED_CADDY_PROXY_IMAGE = 'lucaslorentz/caddy-docker-proxy:2.13-alpine';
/**
* First caddy-docker-proxy version that ships Caddy 2.8+ (`log_append`, `basic_auth`).
* Version 2.8 of the image still runs Caddy 2.7.6.
*/
public const MINIMUM_CURRENT_CADDY_PROXY_VERSION = [2, 9];
public static $batch_counter = 0;
/**
@@ -1091,26 +1102,81 @@ $siteAddress {
}
/**
* Caddy's `log_append` tags access-log lines with the app UUID for traffic analytics. It needs
* Caddy 2.8+, which caddy-docker-proxy ships from 2.9: the 2.8 image (the default before 2.13)
* runs Caddy 2.7.6, which rejects the whole Caddyfile. A saved change that is not applied yet may still run the
* old image, so it counts as unsupported.
* Major and minor version from a caddy-docker-proxy image tag, for example [2, 8] for
* `lucaslorentz/caddy-docker-proxy:2.8-alpine`. Other images, `latest`, and digests without a tag give null.
*
* @return array{0: int, 1: int}|null
*/
public function caddySupportsLogAppend(): bool
public static function caddyDockerProxyImageVersion(?string $image): ?array
{
if ($this->proxyType() !== ProxyTypes::CADDY->value || $this->hasPendingProxyConfiguration()) {
return false;
if ($image === null || preg_match('#(?:^|/)caddy-docker-proxy:(\d+)\.(\d+)#', $image, $version) !== 1) {
return null;
}
return [(int) $version[1], (int) $version[2]];
}
/**
* Caddy image in the saved proxy configuration. Null for other proxies or a configuration that cannot be read.
*/
public function configuredCaddyProxyImage(): ?string
{
if ($this->proxyType() !== ProxyTypes::CADDY->value) {
return null;
}
try {
$image = data_get(Yaml::parse((string) $this->proxy->get('last_saved_proxy_configuration')), 'services.caddy.image');
} catch (ParseException) {
return null;
}
return is_string($image) && $image !== '' ? $image : null;
}
/**
* The saved Caddy image when it is caddy-docker-proxy older than 2.9 (Caddy 2.7), else null.
* Unknown versions (custom images, `latest`, digests) are not reported.
*/
public function outdatedCaddyProxyImage(): ?string
{
$image = $this->configuredCaddyProxyImage();
$version = self::caddyDockerProxyImageVersion($image);
return $version !== null && $version < self::MINIMUM_CURRENT_CADDY_PROXY_VERSION ? $image : null;
}
/**
* True when the Caddy proxy runs caddy-docker-proxy 2.9+ (Caddy 2.8+). The 2.8 image (the default before 2.13)
* runs Caddy 2.7.6, which rejects the whole Caddyfile when it contains newer directives. A saved change that
* is not applied yet may still run the old image, so it counts as unsupported.
*/
private function caddyRunsCurrentVersion(): bool
{
if ($this->hasPendingProxyConfiguration()) {
return false;
}
return is_string($image)
&& preg_match('#(?:^|/)caddy-docker-proxy:(\d+)\.(\d+)#', $image, $version) === 1
&& [(int) $version[1], (int) $version[2]] >= [2, 9];
$version = self::caddyDockerProxyImageVersion($this->configuredCaddyProxyImage());
return $version !== null && $version >= self::MINIMUM_CURRENT_CADDY_PROXY_VERSION;
}
/**
* Caddy's `log_append` tags access-log lines with the app UUID for traffic analytics. It needs Caddy 2.8+.
*/
public function caddySupportsLogAppend(): bool
{
return $this->caddyRunsCurrentVersion();
}
/**
* Caddy 2.8 renamed `basicauth` to `basic_auth`. Caddy 2.7 knows only `basicauth`, and Caddy 2.8+ still
* accepts it as a deprecated name, so `basicauth` is the safe fallback.
*/
public function caddySupportsBasicAuthDirective(): bool
{
return $this->caddyRunsCurrentVersion();
}
public function isServerApiEnabled(): bool
+8 -2
View File
@@ -558,7 +558,7 @@ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool
->contains(ValidationPatterns::normalizeApplicationDomainUrl($domain));
}
function fqdnLabelsForCaddy(string $network, 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, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false)
function fqdnLabelsForCaddy(string $network, 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, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false)
{
$labels = collect([]);
if ($serviceLabels) {
@@ -624,7 +624,9 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
$labels->push("caddy_{$loop}.redir={$redirect_schema}://{$host_without_www}{uri}");
}
if ($is_http_basic_auth_enabled) {
$labels->push("caddy_{$loop}.basicauth.{$http_basic_auth_username}=\"{$hashedPassword}\"");
// Caddy 2.8 renamed basicauth to basic_auth; see Server::caddySupportsBasicAuthDirective().
$basicAuthDirective = $supports_basic_auth_directive ? 'basic_auth' : 'basicauth';
$labels->push("caddy_{$loop}.{$basicAuthDirective}.{$http_basic_auth_username}=\"{$hashedPassword}\"");
}
if ($is_traffic_analytics_enabled) {
$labels->push("caddy_{$loop}.log.output=file /traffic/access.log");
@@ -1015,6 +1017,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(),
domainPortOverrides: $application->domain_port_overrides ?? [],
));
break;
@@ -1050,6 +1053,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(),
domainPortOverrides: $application->domain_port_overrides ?? [],
));
}
@@ -1096,6 +1100,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(),
domainPortOverrides: $preview->domain_port_overrides ?? [],
));
break;
@@ -1129,6 +1134,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
noindex_domains: $noindexDomains,
is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(),
supports_log_append: $application->destination->server->caddySupportsLogAppend(),
supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(),
domainPortOverrides: $preview->domain_port_overrides ?? [],
));
}
+25 -4
View File
@@ -1,6 +1,7 @@
<?php
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Actions\Server\StartSentinel;
use App\Enums\ProxyTypes;
use App\Models\Application;
use App\Models\Server;
@@ -181,14 +182,16 @@ function applyTrafficAnalyticsToProxyConfigArray(Server $server, array $config):
];
}
} elseif ($server->proxyType() === ProxyTypes::CADDY->value) {
$trafficVolume = $server->proxyPath().':/traffic';
// Caddy writes /traffic/access.log and Sentinel reads <trafficLogDirectory>/access.log, so both use one path.
$trafficVolume = StartSentinel::trafficLogDirectory($server).':/traffic';
$volumes = data_get($config, 'services.caddy.volumes', []);
if (! is_array($volumes)) {
throw new RuntimeException('Caddy volumes must be a YAML list.');
}
$volumes = array_values(array_filter($volumes, fn (mixed $volume): bool => $volume !== $trafficVolume));
// Coolify owns /traffic: replace an older mount with a different source path.
$volumes = array_values(array_filter($volumes, fn (mixed $volume): bool => ! isCaddyTrafficVolume($volume)));
if ($enabled) {
$volumes[] = $trafficVolume;
}
@@ -199,6 +202,24 @@ function applyTrafficAnalyticsToProxyConfigArray(Server $server, array $config):
return $config;
}
/**
* True for a Caddy volume that mounts to the /traffic access-log directory (short or long syntax).
*/
function isCaddyTrafficVolume(mixed $volume): bool
{
if (is_array($volume)) {
return rtrim((string) data_get($volume, 'target'), '/') === '/traffic';
}
if (! is_string($volume)) {
return false;
}
$parts = explode(':', $volume);
return count($parts) >= 2 && rtrim($parts[1], '/') === '/traffic';
}
/**
* Check if a network name is a Docker predefined system network.
* These networks cannot be created, modified, or managed by docker network commands.
@@ -612,7 +633,7 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
$config['services']['traefik']['command'][] = '--api.insecure=true';
$config['services']['traefik']['command'][] = '--log.level=debug';
$config['services']['traefik']['command'][] = '--accesslog.bufferingsize=100';
$config['services']['traefik']['volumes'][] = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/:/traefik';
$config['services']['traefik']['volumes'][] = devCoolifyDataPath().'/proxy/:/traefik';
} else {
$config['services']['traefik']['command'][] = '--api.insecure=false';
$config['services']['traefik']['volumes'][] = "{$proxy_path}:/traefik";
@@ -650,7 +671,7 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
'services' => [
'caddy' => [
'container_name' => 'coolify-proxy',
'image' => 'lucaslorentz/caddy-docker-proxy:2.13-alpine',
'image' => Server::RECOMMENDED_CADDY_PROXY_IMAGE,
'restart' => RESTART_MODE,
'extra_hosts' => [
'host.docker.internal:host-gateway',
+14
View File
@@ -969,6 +969,20 @@ function isDev(): bool
return config('app.env') === 'local';
}
/**
* Host path of the Coolify data volume in development. The proxy and Sentinel on one server both mount
* paths below it, so they must use this value. An invalid volume name falls back to the legacy name.
*/
function devCoolifyDataPath(): string
{
$volume = (string) config('constants.coolify.dev_data_volume');
if (preg_match('/^[A-Za-z0-9][A-Za-z0-9_.-]*$/', $volume) !== 1) {
$volume = 'coolify_dev_coolify_data';
}
return "/var/lib/docker/volumes/{$volume}/_data";
}
function isCloud(): bool
{
return ! config('constants.coolify.self_hosted');
+2
View File
@@ -8,6 +8,8 @@ return [
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
// Development only: Docker volume that holds /data/coolify; the proxy and Sentinel share traffic logs below it.
'dev_data_volume' => env('DEV_COOLIFY_DATA_VOLUME', 'coolify_dev_coolify_data'),
'registry_url' => env('REGISTRY_URL', 'ghcr.io'),
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
@@ -0,0 +1,9 @@
@props(['image'])
@php use App\Models\Server; @endphp
<x-callout type="warning" title="Caddy proxy image is outdated" {{ $attributes }}>
The proxy configuration uses <span class="font-mono">{{ $image }}</span>. Per-resource traffic analytics and
current Caddy features need version 2.9 or newer. To fix this, change the image in the proxy configuration to
<span class="font-mono">{{ Server::RECOMMENDED_CADDY_PROXY_IMAGE }}</span>, then restart the proxy.
</x-callout>
@@ -223,6 +223,8 @@
changes before upgrading.
</x-callout>
@endif
@elseif ($this->outdatedCaddyImage)
<x-server.caddy-image-outdated-callout :image="$this->outdatedCaddyImage" />
@endif
<div wire:loading.flex wire:target="loadProxyConfiguration"
@@ -26,6 +26,10 @@
@endif
</x-slot:actions>
@if ($outdatedCaddyImage)
<x-server.caddy-image-outdated-callout :image="$outdatedCaddyImage" />
@endif
@if ($isTrafficAnalyticsEnabled)
@if ($caddyRedeployNote)
<x-callout type="info" title="Redeploy to start logging">{{ $caddyRedeployNote }}</x-callout>
@@ -0,0 +1,108 @@
<?php
use App\Enums\ProxyTypes;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(fn () => Server::flushIdentityMap());
afterEach(fn () => Server::flushIdentityMap());
function basicAuthCaddyProxy(string $image, array $overrides = []): array
{
return array_merge([
'type' => ProxyTypes::CADDY->value,
'status' => 'running',
'last_saved_settings' => 'applied',
'last_applied_settings' => 'applied',
'last_saved_proxy_configuration' => "services:\n caddy:\n image: '{$image}'\n",
], $overrides);
}
function caddyBasicAuthLabel(iterable $labels): ?string
{
return collect($labels)->first(fn (string $label) => preg_match('/^caddy_\d+\.basic_?auth\./', $label) === 1);
}
it('uses the directive name that the Caddy version knows', function (bool $supportsBasicAuthDirective, string $directive) {
$labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']),
is_http_basic_auth_enabled: true,
http_basic_auth_username: 'admin',
http_basic_auth_password: 'secret',
supports_basic_auth_directive: $supportsBasicAuthDirective,
);
$label = caddyBasicAuthLabel($labels);
expect($label)->toStartWith("caddy_0.{$directive}.admin=\"")
->and(password_verify('secret', trim(str($label)->after('=')->value(), '"')))->toBeTrue();
})->with([
'Caddy 2.8+' => [true, 'basic_auth'],
'Caddy 2.7' => [false, 'basicauth'],
]);
it('keeps the deprecated directive by default', function () {
$labels = fqdnLabelsForCaddy('coolify', 'app-uuid', collect(['https://example.com']),
is_http_basic_auth_enabled: true,
http_basic_auth_username: 'admin',
http_basic_auth_password: 'secret',
);
expect(caddyBasicAuthLabel($labels))->toStartWith('caddy_0.basicauth.admin=');
});
it('uses basic_auth only when the saved Caddy image runs Caddy 2.8 or newer', function (array $proxy, bool $expected) {
$server = Server::factory()->make(['proxy' => $proxy]);
expect($server->caddySupportsBasicAuthDirective())->toBe($expected);
})->with([
'caddy-docker-proxy 2.8 (Caddy 2.7.6)' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.8-alpine'), false],
'caddy-docker-proxy 2.9' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.9'), true],
'caddy-docker-proxy 2.13' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.13-alpine'), true],
'latest tag' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:latest'), false],
'custom image' => [basicAuthCaddyProxy('caddy:2.11'), false],
'new image saved but not applied' => [basicAuthCaddyProxy('lucaslorentz/caddy-docker-proxy:2.13-alpine', ['last_saved_settings' => 'new']), false],
]);
it('emits the matching basic auth directive in application labels', function (string $image, bool $exactLabels, bool $preview, string $directive) {
$team = Team::factory()->create();
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
$server = Server::factory()->create(['team_id' => $team->id, 'proxy' => basicAuthCaddyProxy($image)]);
$server->settings->update(['generate_exact_labels' => $exactLabels]);
$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',
'is_http_basic_auth_enabled' => true,
'http_basic_auth_username' => 'admin',
'http_basic_auth_password' => 'secret',
]);
Server::flushIdentityMap();
$applicationPreview = $preview
? (new ApplicationPreview)->forceFill(['pull_request_id' => 7, 'fqdn' => 'https://pr-7.example.com'])
: null;
$label = caddyBasicAuthLabel(generateLabelsApplication($application->fresh(), $applicationPreview));
expect($label)->toStartWith("caddy_0.{$directive}.admin=\"");
})->with([
'caddy-docker-proxy 2.8, all proxies' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', false, false, 'basicauth'],
'caddy-docker-proxy 2.8, exact labels' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', true, false, 'basicauth'],
'caddy-docker-proxy 2.8, preview, all proxies' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', false, true, 'basicauth'],
'caddy-docker-proxy 2.8, preview, exact labels' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', true, true, 'basicauth'],
'caddy-docker-proxy 2.13, all proxies' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', false, false, 'basic_auth'],
'caddy-docker-proxy 2.13, exact labels' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', true, false, 'basic_auth'],
'caddy-docker-proxy 2.13, preview, all proxies' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', false, true, 'basic_auth'],
'caddy-docker-proxy 2.13, preview, exact labels' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', true, true, 'basic_auth'],
]);
@@ -0,0 +1,144 @@
<?php
use App\Enums\ProxyTypes;
use App\Livewire\Server\Proxy;
use App\Livewire\Server\TrafficAnalyticsSettings;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->team = $this->user->teams()->first();
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
afterEach(fn () => Server::flushIdentityMap());
function caddyImageProxy(?string $image, array $overrides = []): array
{
return array_merge([
'type' => ProxyTypes::CADDY->value,
'status' => 'running',
'last_saved_settings' => 'applied',
'last_applied_settings' => 'applied',
'last_saved_proxy_configuration' => $image === null ? null : "services:\n caddy:\n image: '{$image}'\n",
], $overrides);
}
it('parses the caddy-docker-proxy version from the image', function (?string $image, ?array $expected) {
expect(Server::caddyDockerProxyImageVersion($image))->toBe($expected);
})->with([
'old default 2.8-alpine' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', [2, 8]],
'2.9' => ['lucaslorentz/caddy-docker-proxy:2.9', [2, 9]],
'current default 2.13-alpine' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', [2, 13]],
'registry prefix and patch version' => ['docker.io/lucaslorentz/caddy-docker-proxy:2.11.4-alpine', [2, 11]],
'latest tag' => ['lucaslorentz/caddy-docker-proxy:latest', null],
'no tag' => ['lucaslorentz/caddy-docker-proxy', null],
'digest' => ['lucaslorentz/caddy-docker-proxy@sha256:0a3f8e2b1c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f', null],
'custom image' => ['caddy:2.11', null],
'custom image with a similar name' => ['example/my-caddy-docker-proxy:2.8', null],
'no image' => [null, null],
]);
it('reports an outdated Caddy image only for caddy-docker-proxy older than 2.9', function (?string $image, ?string $expected) {
$server = Server::factory()->make(['proxy' => caddyImageProxy($image)]);
expect($server->outdatedCaddyProxyImage())->toBe($expected);
})->with([
'old default 2.8-alpine' => ['lucaslorentz/caddy-docker-proxy:2.8-alpine', 'lucaslorentz/caddy-docker-proxy:2.8-alpine'],
'2.7' => ['lucaslorentz/caddy-docker-proxy:2.7', 'lucaslorentz/caddy-docker-proxy:2.7'],
'2.9' => ['lucaslorentz/caddy-docker-proxy:2.9', null],
'current default 2.13-alpine' => ['lucaslorentz/caddy-docker-proxy:2.13-alpine', null],
'latest tag' => ['lucaslorentz/caddy-docker-proxy:latest', null],
'digest' => ['lucaslorentz/caddy-docker-proxy@sha256:0a3f8e2b1c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f', null],
'custom image' => ['caddy:2.7', null],
'no saved configuration' => [null, null],
]);
it('does not report an outdated Caddy image for other proxies or invalid YAML', function () {
$traefik = Server::factory()->make(['proxy' => caddyImageProxy('lucaslorentz/caddy-docker-proxy:2.8-alpine', ['type' => ProxyTypes::TRAEFIK->value])]);
$invalid = Server::factory()->make(['proxy' => caddyImageProxy(null, ['last_saved_proxy_configuration' => "services: [\n"])]);
expect($traefik->outdatedCaddyProxyImage())->toBeNull()
->and($invalid->outdatedCaddyProxyImage())->toBeNull();
});
it('reports the saved image as outdated also while the change is not applied', function () {
$server = Server::factory()->make(['proxy' => caddyImageProxy('lucaslorentz/caddy-docker-proxy:2.8-alpine', ['last_saved_settings' => 'new'])]);
expect($server->outdatedCaddyProxyImage())->toBe('lucaslorentz/caddy-docker-proxy:2.8-alpine')
->and($server->caddySupportsLogAppend())->toBeFalse();
});
it('uses the recommended Caddy image as the default proxy image', function () {
expect(Server::RECOMMENDED_CADDY_PROXY_IMAGE)->toBe('lucaslorentz/caddy-docker-proxy:2.13-alpine')
->and(Server::caddyDockerProxyImageVersion(Server::RECOMMENDED_CADDY_PROXY_IMAGE))->toBe([2, 13]);
});
it('shows the outdated Caddy image warning on the proxy page', function () {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'proxy' => caddyImageProxy('lucaslorentz/caddy-docker-proxy:2.8-alpine'),
]);
Livewire::test(Proxy::class, ['server' => $server])
->assertSee('Caddy proxy image is outdated')
->assertSee('lucaslorentz/caddy-docker-proxy:2.8-alpine')
->assertSee('2.9 or newer')
->assertSee('lucaslorentz/caddy-docker-proxy:2.13-alpine')
->assertSee('restart the proxy');
});
it('hides the outdated Caddy image warning on the proxy page', function (string $type, string $image) {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'proxy' => caddyImageProxy($image, ['type' => $type]),
]);
Livewire::test(Proxy::class, ['server' => $server])
->assertDontSee('Caddy proxy image is outdated');
})->with([
'current Caddy image' => [ProxyTypes::CADDY->value, 'lucaslorentz/caddy-docker-proxy:2.13-alpine'],
'Caddy latest tag' => [ProxyTypes::CADDY->value, 'lucaslorentz/caddy-docker-proxy:latest'],
'custom Caddy image' => [ProxyTypes::CADDY->value, 'caddy:2.7'],
'Traefik' => [ProxyTypes::TRAEFIK->value, 'lucaslorentz/caddy-docker-proxy:2.8-alpine'],
]);
it('shows the outdated Caddy image warning on the traffic analytics settings', function (bool $analyticsEnabled) {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'proxy' => caddyImageProxy('lucaslorentz/caddy-docker-proxy:2.8-alpine'),
]);
$server->settings->update(['is_traffic_analytics_enabled' => $analyticsEnabled]);
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server->fresh()])
->assertSee('Caddy proxy image is outdated')
->assertSee('lucaslorentz/caddy-docker-proxy:2.8-alpine')
->assertSee('lucaslorentz/caddy-docker-proxy:2.13-alpine');
})->with([
'analytics enabled' => [true],
'analytics disabled' => [false],
]);
it('hides the outdated Caddy image warning on the traffic analytics settings', function (string $type, string $image) {
$server = Server::factory()->create([
'team_id' => $this->team->id,
'proxy' => caddyImageProxy($image, ['type' => $type]),
]);
$server->settings->update(['is_traffic_analytics_enabled' => true]);
Livewire::test(TrafficAnalyticsSettings::class, ['server' => $server->fresh()])
->assertDontSee('Caddy proxy image is outdated');
})->with([
'current Caddy image' => [ProxyTypes::CADDY->value, 'lucaslorentz/caddy-docker-proxy:2.13-alpine'],
'custom Caddy image' => [ProxyTypes::CADDY->value, 'example/caddy:1.0'],
'Traefik' => [ProxyTypes::TRAEFIK->value, 'lucaslorentz/caddy-docker-proxy:2.8-alpine'],
]);
@@ -1,5 +1,6 @@
<?php
use App\Actions\Server\StartSentinel;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
@@ -58,3 +59,86 @@ it('uses a default caddy image that supports per-app traffic attribution', funct
expect($config['services']['caddy']['image'])->toBe('lucaslorentz/caddy-docker-proxy:2.13-alpine')
->and($server->fresh()->caddySupportsLogAppend())->toBeTrue();
});
function caddyTrafficServer(object $test, bool $analyticsEnabled = true): Server
{
$server = Server::factory()->create(['team_id' => $test->team->id, 'private_key_id' => $test->privateKey->id]);
$server->proxy->set('type', 'CADDY');
$server->save();
$server->settings->is_traffic_analytics_enabled = $analyticsEnabled;
$server->settings->save();
return $server->fresh();
}
it('mounts the Sentinel traffic log directory into Caddy in production', function () {
$server = caddyTrafficServer($this);
$config = Yaml::parse(generateDefaultProxyConfiguration($server));
expect(StartSentinel::trafficLogDirectory($server))->toBe('/data/coolify/proxy/caddy')
->and($config['services']['caddy']['volumes'])->toContain('/data/coolify/proxy/caddy:/traffic');
});
it('mounts the Sentinel traffic log directory into Caddy in development', function () {
config()->set('app.env', 'local');
$server = caddyTrafficServer($this);
$volumes = Yaml::parse(generateDefaultProxyConfiguration($server))['services']['caddy']['volumes'];
$trafficVolumes = array_values(array_filter($volumes, fn (string $volume): bool => str_ends_with($volume, ':/traffic')));
// Caddy writes /traffic/access.log, Sentinel reads <trafficLogDirectory>/access.log.
expect($trafficVolumes)->toBe([StartSentinel::trafficLogDirectory($server).':/traffic'])
->and($trafficVolumes[0])->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy:/traffic');
});
it('uses the configured dev data volume for Caddy, Traefik, and Sentinel', function () {
config()->set('app.env', 'local');
config()->set('constants.coolify.dev_data_volume', 'coolify-dev-feature_coolify_data');
$caddy = caddyTrafficServer($this);
$caddyVolumes = Yaml::parse(generateDefaultProxyConfiguration($caddy))['services']['caddy']['volumes'];
$traefik = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$traefik->proxy->set('type', 'TRAEFIK');
$traefik->save();
$traefikVolumes = Yaml::parse(generateDefaultProxyConfiguration($traefik->fresh()))['services']['traefik']['volumes'];
expect(StartSentinel::trafficLogDirectory($caddy))->toBe('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy')
->and($caddyVolumes)->toContain('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy:/traffic')
->and($traefikVolumes)->toContain('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy/:/traefik');
});
it('falls back to the legacy dev data volume for an invalid volume name', function (?string $volume) {
config()->set('app.env', 'local');
config()->set('constants.coolify.dev_data_volume', $volume);
expect(devCoolifyDataPath())->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data');
})->with([
'empty' => [''],
'null' => [null],
'path traversal' => ['../../etc'],
'shell characters' => ['vol;rm -rf /'],
]);
it('replaces a stale Caddy traffic mount and removes it when analytics is disabled', function () {
config()->set('app.env', 'local');
$server = caddyTrafficServer($this);
$config = ['services' => ['caddy' => ['volumes' => [
'/var/run/docker.sock:/var/run/docker.sock:ro',
'/data/coolify/proxy/caddy:/traffic',
]]]];
$enabled = applyTrafficAnalyticsToProxyConfigArray($server, $config);
$server->settings->is_traffic_analytics_enabled = false;
$server->settings->save();
$disabled = applyTrafficAnalyticsToProxyConfigArray($server->fresh(), $enabled);
expect($enabled['services']['caddy']['volumes'])->toBe([
'/var/run/docker.sock:/var/run/docker.sock:ro',
StartSentinel::trafficLogDirectory($server).':/traffic',
])->and($disabled['services']['caddy']['volumes'])->toBe([
'/var/run/docker.sock:/var/run/docker.sock:ro',
]);
});
@@ -206,3 +206,18 @@ it('keeps the access log commands valid for non-root servers', function () {
expect($syntax->isSuccessful())->toBeTrue($syntax->getErrorOutput());
});
it('mounts the configured dev data volume for traffic logs and Sentinel data', function () {
config()->set('app.env', 'local');
config()->set('constants.coolify.dev_data_volume', 'coolify-dev-feature_coolify_data');
$server = sentinelTrafficServer($this, 'CADDY', analyticsEnabled: true);
$directory = '/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy';
$script = runStartSentinelAndCaptureScript($server);
expect(StartSentinel::trafficLogDirectory($server))->toBe($directory)
->and($script)->toContain(escapeshellarg("{$directory}:{$directory}:ro"))
->toContain(escapeshellarg("TRAFFIC_ACCESS_LOG_PATH={$directory}/access.log"))
->toContain('-v /var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/sentinel:/app/db')
->not->toContain('coolify_dev_coolify_data');
});