fix(dev): rewrite host paths only for the testing-host dev server

Development mode rewrote /data/coolify paths to the dev Docker volume
for every server. That is only right for the testing-host backend,
which uses the host Docker daemon. The KVM localhost and other dev VMs
have their own /data/coolify, so bind mounts, Compose content files,
the Traefik and Caddy traffic mounts, Sentinel, and database proxies
pointed to wrong paths there.

- Server::sharesDevHostDocker() and devHostDockerPath() decide per
  server; only the testing-host server (ip coolify-testing-host) gets
  the volume path. Production is unchanged.
- The testing-host volume names come from the Compose project
  (DEV_COOLIFY_DATA_VOLUME, DEV_COOLIFY_BACKUPS_VOLUME), with the
  legacy names as fallback.
- In development, saved proxy configurations with the old path are
  fixed when they load and ask for a proxy restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-27 15:21:39 +02:00
co-authored by Claude Opus 5.5
parent eeda90a8a4
commit 700d476bac
16 changed files with 442 additions and 81 deletions
+1 -4
View File
@@ -56,10 +56,7 @@ class StartDatabaseProxy
}
$configuration_dir = database_proxy_dir($database->uuid);
$host_configuration_dir = $configuration_dir;
if (isDev()) {
$host_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy';
}
$host_configuration_dir = devHostDockerPath($server, $configuration_dir);
$timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout);
$nginxconf = <<<EOF
user nginx;
@@ -48,6 +48,10 @@ class GetProxyConfiguration
if (! empty(trim($proxy_configuration ?? '')) && removeLegacyTraefikDashboardExposure($server)) {
$proxy_configuration = $server->proxy->get('last_saved_proxy_configuration');
}
if (! empty(trim($proxy_configuration ?? '')) && replaceDevHostDockerProxyPaths($server)) {
$proxy_configuration = $server->proxy->get('last_saved_proxy_configuration');
}
}
// Generate default configuration as last resort
-6
View File
@@ -2,7 +2,6 @@
namespace App\Actions\Proxy;
use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChanged;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
@@ -51,11 +50,6 @@ class StartProxy
"echo 'Successfully started coolify-proxy.'",
]);
} else {
if (isDev()) {
if ($proxyType === ProxyTypes::CADDY->value) {
$proxy_path = '/data/coolify/proxy/caddy';
}
}
$caddyfile = 'import /dynamic/*.caddy';
$commands = $commands->merge([
"mkdir -p $proxy_path/dynamic",
+5 -5
View File
@@ -10,11 +10,12 @@ class StartSentinel
{
use AsAction;
/**
* Sentinel and the proxy both mount this host path, and Sentinel reads the access log at the same path.
*/
public static function trafficLogDirectory(Server $server): string
{
return isDev()
? devCoolifyDataPath().'/proxy'
: rtrim($server->proxyPath(), '/');
return devHostDockerPath($server, rtrim($server->proxyPath(), '/'));
}
/**
@@ -88,7 +89,7 @@ class StartSentinel
$token = $server->settings->ensureValidSentinelToken();
$endpoint = $server->settings->ensureSentinelUrl();
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
$mountDir = devHostDockerPath($server, base_configuration_dir().'/sentinel');
$image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version;
$environments = [
'TOKEN' => $token,
@@ -108,7 +109,6 @@ class StartSentinel
if ($customImage && ! empty($customImage)) {
$image = $customImage;
}
$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));
+10 -10
View File
@@ -801,6 +801,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
return instant_remote_process(['du -b '.escapeshellarg($this->backup_location).' | cut -f1'], $this->server, false, false, null, disableMultiplexing: true);
}
/**
* Host path of the backup file for the upload container. It differs from backup_location only for the
* development testing-host server (see devHostDockerPath()).
*/
private function backupMountSource(): string
{
return devHostDockerPath($this->server, $this->backup_location);
}
private function upload_to_s3(): void
{
if (is_null($this->s3)) {
@@ -835,16 +844,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true);
}
if (isDev()) {
if ($this->database->name === 'coolify-db') {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file;
} else {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file;
}
} else {
$backup_location_from = $this->backup_location;
}
$mount = escapeshellarg($backup_location_from.':'.$this->backup_location.':ro');
$mount = escapeshellarg($this->backupMountSource().':'.$this->backup_location.':ro');
$commands[] = "docker run -d --network {$safeNetwork} --name backup-of-{$this->backup_log_uuid} --rm -v {$mount} {$fullImageName}";
// Escape S3 credentials to prevent command injection
-5
View File
@@ -4,7 +4,6 @@ namespace App\Jobs;
use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
@@ -87,7 +86,6 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
*/
private function buildRestartCommands(string $configuration): array
{
$proxyType = $this->server->proxyType();
$containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
$proxy_path = $this->server->proxyPath();
$stopTimeout = 30;
@@ -136,9 +134,6 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
"echo 'Successfully started coolify-proxy.'",
]);
} else {
if (isDev() && $proxyType === ProxyTypes::CADDY->value) {
$proxy_path = '/data/coolify/proxy/caddy';
}
$caddyfile = 'import /dynamic/*.caddy';
$commands = $commands->merge([
"echo 'Starting proxy...'",
+19 -5
View File
@@ -136,6 +136,11 @@ class Server extends BaseModel
*/
public const MINIMUM_CURRENT_CADDY_PROXY_VERSION = [2, 9];
/**
* Address of the development `testing-host` server (docker-compose.dev*.yml, ServerSeeder).
*/
public const DEV_TESTING_HOST_IP = 'coolify-testing-host';
public static $batch_counter = 0;
/**
@@ -594,11 +599,6 @@ class Server extends BaseModel
$proxy_type = $this->proxyType();
$redirect_enabled = $this->proxy->redirect_enabled ?? true;
$redirect_url = $this->proxy->redirect_url;
if (isDev()) {
if ($proxy_type === ProxyTypes::CADDY->value) {
$dynamic_conf_path = '/data/coolify/proxy/caddy/dynamic';
}
}
if ($proxy_type === ProxyTypes::TRAEFIK->value) {
$default_redirect_file = "$dynamic_conf_path/default_redirect_503.yaml";
} elseif ($proxy_type === ProxyTypes::CADDY->value) {
@@ -956,6 +956,20 @@ $siteAddress {
return $this->ip === 'host.docker.internal' || $this->id === 0;
}
/**
* True only in development for the `testing-host` server. That container runs containers on the
* host Docker daemon (/var/run/docker.sock), but its /data/coolify is a Docker named volume. The host
* daemon must therefore mount the volume's host path instead of /data/coolify (see devHostDockerPath()).
*
* Dev KVM VMs and all other servers have their own Docker daemon and their own /data/coolify.
* A `host.docker.internal` server writes to the real host /data/coolify, so it also needs no change.
*/
public function sharesDevHostDocker(): bool
{
// The saving hook can leave a Stringable in `ip`, so compare the string value.
return isDev() && (string) $this->ip === self::DEV_TESTING_HOST_IP;
}
/**
* Usable dedicated (build-only) servers of a team. Servers with the combined role
* host deployments, so they are never picked as build servers.
+4 -14
View File
@@ -1111,13 +1111,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resource_type' => get_class($originalResource),
]
);
if (isDev()) {
if ((int) $resource->compose_parsing_version >= 4) {
$source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
} else {
$source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
}
}
// The file storage keeps the path that Coolify writes; the Docker daemon may need another one.
$source = $source->replace($mainDirectory, devHostDockerPath($server, $mainDirectory->value()));
$volume = "$source:$target";
if (isset($parsed['mode']) && $parsed['mode']) {
$volume .= ':'.$parsed['mode']->value();
@@ -2465,13 +2460,8 @@ function serviceParser(Service $resource): Collection
'resource_type' => get_class($originalResource),
]
);
if (isDev()) {
if ((int) $resource->compose_parsing_version >= 4) {
$source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/services/'.$uuid);
} else {
$source = $source->replace($mainDirectory, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/applications/'.$uuid);
}
}
// The file storage keeps the path that Coolify writes; the Docker daemon may need another one.
$source = $source->replace($mainDirectory, devHostDockerPath($server, $mainDirectory->value()));
$volume = "$source:$target";
if (isset($parsed['mode']) && $parsed['mode']) {
$volume .= ':'.$parsed['mode']->value();
+47 -7
View File
@@ -164,8 +164,8 @@ function applyTrafficAnalyticsToProxyConfigArray(Server $server, array $config):
data_set($config, 'services.traefik.command', applyTraefikAccessLogCommands($server, $commands, $enabled));
unset($config['services']['traefik-logrotate']);
if ($enabled && ! $server->isSwarm() && ! isDev()) {
$proxyPath = $server->proxyPath();
if ($enabled && ! $server->isSwarm()) {
$proxyPath = devHostDockerPath($server, $server->proxyPath());
$config['services']['traefik-logrotate'] = [
'container_name' => 'coolify-proxy-logrotate',
'image' => 'alpine:3.24',
@@ -534,6 +534,47 @@ function removeLegacyTraefikDashboardExposure(Server $server): bool
return true;
}
/**
* Development only. Older dev builds wrote the dev data volume path (/var/lib/docker/volumes/<name>_coolify_data/_data)
* into the proxy configuration of every server. Replace it with the path for this server (devHostDockerPath()):
* /data/coolify on a dev KVM VM, the configured volume path on the testing-host server. The next proxy restart applies it.
*/
function replaceDevHostDockerProxyPaths(Server $server): bool
{
if (! app()->bound('config') || ! isDev()) {
return false;
}
$configuration = $server->proxy->get('last_saved_proxy_configuration');
if (! is_string($configuration) || blank($configuration)) {
return false;
}
$configuredVolume = preg_quote(basename(dirname(devDockerVolumeDataPath('constants.coolify.dev_data_volume', 'coolify_dev_coolify_data'))), '#');
$pattern = "#/var/lib/docker/volumes/(?:[A-Za-z0-9][A-Za-z0-9_.-]*_coolify_data|{$configuredVolume})/_data(?=[/:'\"\\s]|$)#m";
$fixed = preg_replace($pattern, devHostDockerPath($server, base_configuration_dir()), $configuration);
if (! is_string($fixed) || $fixed === $configuration) {
return false;
}
try {
// The Caddy /traffic mount and the Traefik log rotation mount depend on the proxy type, so rebuild them.
$fixed = applyTrafficAnalyticsToProxyConfiguration($server, $fixed);
} catch (Throwable) {
// Keep the plain path replacement for a configuration that is not a YAML mapping.
}
if (blank($server->proxy->get('last_applied_settings'))) {
$server->proxy->last_applied_settings = md5(base64_encode($configuration));
}
$server->proxy->last_saved_proxy_configuration = $fixed;
$server->proxy->last_saved_settings = md5(base64_encode($fixed));
$server->save();
Log::info('Replaced the development data volume path in the proxy configuration', ['server_id' => $server->id]);
return true;
}
function generateDefaultProxyConfiguration(Server $server, array $custom_commands = [])
{
Log::info('Generating default proxy configuration', [
@@ -633,11 +674,10 @@ 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'][] = devCoolifyDataPath().'/proxy/:/traefik';
} else {
$config['services']['traefik']['command'][] = '--api.insecure=false';
$config['services']['traefik']['volumes'][] = "{$proxy_path}:/traefik";
}
$config['services']['traefik']['volumes'][] = devHostDockerPath($server, $proxy_path).':/traefik';
if ($server->isSwarm()) {
data_forget($config, 'services.traefik.container_name');
data_forget($config, 'services.traefik.restart');
@@ -692,9 +732,9 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
],
'volumes' => [
'/var/run/docker.sock:/var/run/docker.sock:ro',
"{$proxy_path}/dynamic:/dynamic",
"{$proxy_path}/config:/config",
"{$proxy_path}/data:/data",
devHostDockerPath($server, "{$proxy_path}/dynamic").':/dynamic',
devHostDockerPath($server, "{$proxy_path}/config").':/config',
devHostDockerPath($server, "{$proxy_path}/data").':/data',
],
],
],
+35 -5
View File
@@ -971,14 +971,44 @@ function isDev(): bool
}
/**
* 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.
* Path that the Docker daemon of $server must use as a bind mount source for $path, a path that Coolify
* writes through SSH (below base_configuration_dir()).
*
* Only the development `testing-host` server needs a different path (Server::sharesDevHostDocker()):
* it writes to Docker named volumes, but it starts containers on the host Docker daemon. The returned
* paths match its mounts in docker-compose.dev*.yml:
* - /data/coolify/backups/... -> /var/lib/docker/volumes/<DEV_COOLIFY_BACKUPS_VOLUME>/_data/...
* - /data/coolify/... -> /var/lib/docker/volumes/<DEV_COOLIFY_DATA_VOLUME>/_data/...
*
* For all other servers (production, dev KVM VMs, remote servers) the function returns $path unchanged.
*/
function devCoolifyDataPath(): string
function devHostDockerPath(?Server $server, string $path): string
{
$volume = (string) config('constants.coolify.dev_data_volume');
if (! $server?->sharesDevHostDocker()) {
return $path;
}
$mounts = [
backup_dir() => devDockerVolumeDataPath('constants.coolify.dev_backups_volume', 'coolify_dev_backups_data'),
base_configuration_dir() => devDockerVolumeDataPath('constants.coolify.dev_data_volume', 'coolify_dev_coolify_data'),
];
foreach ($mounts as $containerPath => $hostPath) {
if ($path === $containerPath || str_starts_with($path, $containerPath.'/')) {
return $hostPath.substr($path, strlen($containerPath));
}
}
return $path;
}
/**
* Host path of a development Docker volume. An invalid volume name falls back to the legacy name.
*/
function devDockerVolumeDataPath(string $configKey, string $fallbackVolume): string
{
$volume = (string) config($configKey);
if (preg_match('/^[A-Za-z0-9][A-Za-z0-9_.-]*$/', $volume) !== 1) {
$volume = 'coolify_dev_coolify_data';
$volume = $fallbackVolume;
}
return "/var/lib/docker/volumes/{$volume}/_data";
+3 -1
View File
@@ -8,8 +8,10 @@ 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.
// Development only: Docker volumes that the testing-host server mounts at /data/coolify and /data/coolify/backups.
// devHostDockerPath() uses them only for that server. The defaults are the legacy docker-compose.dev.yml names.
'dev_data_volume' => env('DEV_COOLIFY_DATA_VOLUME', 'coolify_dev_coolify_data'),
'dev_backups_volume' => env('DEV_COOLIFY_BACKUPS_VOLUME', 'coolify_dev_backups_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),
+4
View File
@@ -60,6 +60,10 @@ services:
DEVELOPMENT_QEMU_SLOT: "${DEVELOPMENT_QEMU_SLOT:-}"
DEVELOPMENT_QEMU_DOCKER_NETWORK: "${COMPOSE_PROJECT_NAME:-coolify-dev}"
DEVELOPMENT_QEMU_COOLIFY_CONTAINER: "${COMPOSE_PROJECT_NAME:-coolify-dev}"
# Host paths of the volumes that testing-host mounts at /data/coolify and /data/coolify/backups.
# Coolify uses them only for the testing-host server (it shares the host Docker daemon), not for KVM servers.
DEV_COOLIFY_DATA_VOLUME: "${COMPOSE_PROJECT_NAME:-coolify-dev}_coolify_data"
DEV_COOLIFY_BACKUPS_VOLUME: "${COMPOSE_PROJECT_NAME:-coolify-dev}_backups_data"
healthcheck:
test: curl -sf http://127.0.0.1:8080/api/health || exit 1
interval: 5s
@@ -47,6 +47,7 @@ networks:
YAML;
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
config(['constants.ssh.mux_enabled' => false]);
Queue::fake();
+272
View File
@@ -0,0 +1,272 @@
<?php
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Proxy\GetProxyConfiguration;
use App\Actions\Proxy\StartProxy;
use App\Actions\Server\StartSentinel;
use App\Events\ProxyStatusChanged;
use App\Events\ProxyStatusChangedUI;
use App\Events\SentinelRestarted;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\RestartProxyJob;
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\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
const DEV_PATH_DATA = '/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data';
const DEV_PATH_BACKUPS = '/var/lib/docker/volumes/coolify-dev-feature_backups_data/_data';
const DEV_PATH_COMPOSE = <<<'YAML'
services:
app:
image: nginx:alpine
volumes:
- ./data:/app/data
- type: bind
source: ./config/app.conf
target: /etc/app.conf
content: |
listen 8080;
YAML;
beforeEach(function () {
// StandaloneDocker::server uses a static identity map that other tests can fill with the same server id.
Server::flushIdentityMap();
Bus::fake();
Notification::fake();
Event::fake([SentinelRestarted::class, ProxyStatusChanged::class, ProxyStatusChangedUI::class]);
InstanceSettings::forceCreate(['id' => 0]);
config([
'app.maintenance.store' => 'array',
'constants.ssh.mux_enabled' => false,
'constants.coolify.dev_data_volume' => 'coolify-dev-feature_coolify_data',
'constants.coolify.dev_backups_volume' => 'coolify-dev-feature_backups_data',
]);
$this->commands = [];
Process::fake(function ($process) {
$this->commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command;
return Process::result(output: '');
});
$this->team = Team::factory()->create();
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
});
/**
* Environment and server kinds:
* - kvm: development, a dev KVM VM with its own Docker daemon and its own /data/coolify.
* - testing-host: development, the testing-host container that uses the host Docker daemon.
* - production: production, with the testing-host address (the address alone must not change paths).
*
* @return array<string, array{0: string, 1: string}>
*/
function devPathKinds(): array
{
return [
'dev KVM server' => ['kvm', '/data/coolify'],
'dev testing-host server' => ['testing-host', DEV_PATH_DATA],
'production' => ['production', '/data/coolify'],
];
}
function devPathServer(string $kind, ?string $proxyType = null, bool $analytics = false): Server
{
config()->set('app.env', $kind === 'production' ? 'production' : 'local');
$server = Server::factory()->create([
'team_id' => test()->team->id,
'private_key_id' => test()->privateKey->id,
'ip' => $kind === 'kvm' ? '10.221.1.10' : 'coolify-testing-host',
]);
if ($proxyType) {
$server->proxy->set('type', $proxyType);
$server->save();
}
$server->settings->is_traffic_analytics_enabled = $analytics;
$server->settings->sentinel_custom_url = 'https://coolify.example.com';
$server->settings->save();
return $server->fresh();
}
function devPathDestination(Server $server): StandaloneDocker
{
return StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
}
function devPathAllCommands(): string
{
return implode("\n", test()->commands);
}
it('detects only the development testing-host server as a host Docker server', function (string $kind, string $base) {
$server = devPathServer($kind);
expect($server->sharesDevHostDocker())->toBe($kind === 'testing-host')
->and(devHostDockerPath($server, '/data/coolify'))->toBe($base);
})->with(devPathKinds());
it('maps Coolify data and backup paths to the dev volumes on the testing-host server', function () {
$server = devPathServer('testing-host');
expect(devHostDockerPath($server, '/data/coolify/applications/abc/data'))->toBe(DEV_PATH_DATA.'/applications/abc/data')
->and(devHostDockerPath($server, '/data/coolify/proxy/'))->toBe(DEV_PATH_DATA.'/proxy/')
->and(devHostDockerPath($server, '/data/coolify/backups/databases/x/file.dmp'))->toBe(DEV_PATH_BACKUPS.'/databases/x/file.dmp')
->and(devHostDockerPath($server, '/data/coolify-other/x'))->toBe('/data/coolify-other/x')
->and(devHostDockerPath($server, '/etc/localtime'))->toBe('/etc/localtime')
->and(devHostDockerPath(null, '/data/coolify/proxy'))->toBe('/data/coolify/proxy');
});
it('mounts application compose bind and content volumes from the server data path', function (string $kind, string $base) {
$server = devPathServer($kind);
$destination = devPathDestination($server);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'build_pack' => 'dockercompose',
'docker_compose_raw' => DEV_PATH_COMPOSE,
]);
$volumes = applicationParser($application)->get('services')->get('app')['volumes'];
$directory = "{$base}/applications/{$application->uuid}";
expect(collect($volumes)->all())->toContain("{$directory}/data:/app/data", "{$directory}/config/app.conf:/etc/app.conf")
// Coolify writes the files through SSH to the normal path.
->and($application->fileStorages()->pluck('fs_path')->all())
->toContain("/data/coolify/applications/{$application->uuid}/config/app.conf");
})->with(devPathKinds());
it('mounts service compose bind and content volumes from the server data path', function (string $kind, string $base) {
$server = devPathServer($kind);
$destination = devPathDestination($server);
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'docker_compose_raw' => DEV_PATH_COMPOSE,
]);
ServiceApplication::create(['name' => 'app', 'service_id' => $service->id]);
$volumes = serviceParser($service->fresh())->get('services')->get('app')['volumes'];
$directory = "{$base}/services/{$service->uuid}";
expect(collect($volumes)->all())->toContain("{$directory}/data:/app/data", "{$directory}/config/app.conf:/etc/app.conf");
})->with(devPathKinds());
it('mounts the Traefik proxy and log rotation directory from the server data path', function (string $kind, string $base) {
$server = devPathServer($kind, 'TRAEFIK', analytics: true);
$config = Yaml::parse(generateDefaultProxyConfiguration($server));
expect($config['services']['traefik']['volumes'])->toContain("{$base}/proxy/:/traefik")
->and($config['services']['traefik-logrotate']['volumes'])->toBe(["{$base}/proxy/:/traefik"]);
})->with(devPathKinds());
it('mounts the Caddy proxy and traffic directories from the server data path', function (string $kind, string $base) {
$server = devPathServer($kind, 'CADDY', analytics: true);
$volumes = Yaml::parse(generateDefaultProxyConfiguration($server))['services']['caddy']['volumes'];
expect($volumes)->toContain(
"{$base}/proxy/caddy/dynamic:/dynamic",
"{$base}/proxy/caddy/config:/config",
"{$base}/proxy/caddy/data:/data",
"{$base}/proxy/caddy:/traffic",
)->and(StartSentinel::trafficLogDirectory($server))->toBe("{$base}/proxy/caddy");
})->with(devPathKinds());
it('mounts the Sentinel database and traffic log directory from the server data path', function (string $kind, string $base) {
$server = devPathServer($kind, 'TRAEFIK', analytics: true);
StartSentinel::run($server, latestVersion: '1.0.1');
$directory = "{$base}/proxy";
$script = collect($this->commands)->first(fn (string $command): bool => str_contains($command, 'docker run -d'));
expect(StartSentinel::trafficLogDirectory($server))->toBe($directory)
->and($script)->toContain("-v {$base}/sentinel:/app/db")
->toContain(escapeshellarg("{$directory}:{$directory}:ro"))
->toContain(escapeshellarg("TRAFFIC_ACCESS_LOG_PATH={$directory}/access.log"));
if ($kind !== 'testing-host') {
expect($script)->not->toContain('/var/lib/docker/volumes');
}
})->with(devPathKinds());
it('mounts the database proxy configuration from the server data path', function (string $kind, string $base) {
$server = devPathServer($kind);
$database = create_standalone_postgresql($this->environment->id, devPathDestination($server));
$database->update(['is_public' => true, 'public_port' => 15432]);
StartDatabaseProxy::run($database->fresh());
$script = devPathAllCommands();
preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d \| tee [^ ]+docker-compose\.yaml/", $script, $matches);
$compose = Yaml::parse(base64_decode($matches[1]));
$volume = $compose['services']["{$database->uuid}-proxy"]['volumes'][0];
expect($volume['source'])->toBe("{$base}/databases/{$database->uuid}/proxy/nginx.conf")
// Coolify writes the file through SSH to the normal path.
->and($script)->toContain("mkdir -p /data/coolify/databases/{$database->uuid}/proxy");
})->with(devPathKinds());
it('mounts the database backup file from the server backups path', function (string $kind, string $base) {
$server = devPathServer($kind);
$backupsBase = $kind === 'testing-host' ? DEV_PATH_BACKUPS : '/data/coolify/backups';
$job = (new ReflectionClass(DatabaseBackupJob::class))->newInstanceWithoutConstructor();
$job->server = $server;
$job->backup_location = '/data/coolify/backups/databases/team-1/postgres-abc/pg-dump-1.dmp';
$source = (new ReflectionMethod($job, 'backupMountSource'))->invoke($job);
expect($source)->toBe("{$backupsBase}/databases/team-1/postgres-abc/pg-dump-1.dmp");
})->with(devPathKinds());
it('uses the normal Caddy proxy path on start and restart in development', function () {
config()->set('constants.coolify.base_config_path', '/srv/coolify');
$server = devPathServer('kvm', 'CADDY');
StartProxy::run($server, async: false, force: true);
$restartCommands = (new ReflectionMethod(RestartProxyJob::class, 'buildRestartCommands'))
->invoke(new RestartProxyJob($server), 'services: {}');
expect(devPathAllCommands())->toContain('mkdir -p /srv/coolify/proxy/caddy/dynamic')
->not->toContain('mkdir -p /data/coolify/proxy/caddy/dynamic')
->and(implode("\n", $restartCommands))->toContain('mkdir -p /srv/coolify/proxy/caddy/dynamic')
->not->toContain('/data/coolify/proxy/caddy');
});
it('replaces an old dev volume path in a saved proxy configuration in development', function (string $kind, string $expected) {
$server = devPathServer($kind, 'TRAEFIK');
$saved = "services:\n traefik:\n image: 'traefik:v3.6'\n volumes:\n - '/var/run/docker.sock:/var/run/docker.sock:ro'\n - '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/:/traefik'\n";
$server->proxy->last_saved_proxy_configuration = $saved;
$server->save();
$configuration = GetProxyConfiguration::run($server->fresh());
expect(Yaml::parse($configuration)['services']['traefik']['volumes'])->toContain("{$expected}:/traefik");
})->with([
'dev KVM server' => ['kvm', '/data/coolify/proxy/'],
'dev testing-host server' => ['testing-host', DEV_PATH_DATA.'/proxy/'],
'production keeps the saved configuration' => ['production', '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/'],
]);
@@ -60,9 +60,13 @@ it('uses a default caddy image that supports per-app traffic attribution', funct
->and($server->fresh()->caddySupportsLogAppend())->toBeTrue();
});
function caddyTrafficServer(object $test, bool $analyticsEnabled = true): Server
function caddyTrafficServer(object $test, bool $analyticsEnabled = true, ?string $ip = null): Server
{
$server = Server::factory()->create(['team_id' => $test->team->id, 'private_key_id' => $test->privateKey->id]);
$server = Server::factory()->create(array_filter([
'team_id' => $test->team->id,
'private_key_id' => $test->privateKey->id,
'ip' => $ip,
]));
$server->proxy->set('type', 'CADDY');
$server->save();
$server->settings->is_traffic_analytics_enabled = $analyticsEnabled;
@@ -80,40 +84,48 @@ it('mounts the Sentinel traffic log directory into Caddy in production', functio
->and($config['services']['caddy']['volumes'])->toContain('/data/coolify/proxy/caddy:/traffic');
});
it('mounts the Sentinel traffic log directory into Caddy in development', function () {
it('mounts the Sentinel traffic log directory into Caddy in development', function (?string $ip, string $directory) {
config()->set('app.env', 'local');
$server = caddyTrafficServer($this);
$server = caddyTrafficServer($this, ip: $ip);
$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');
});
->and($trafficVolumes[0])->toBe("{$directory}:/traffic");
})->with([
// A dev KVM server has its own Docker daemon and its own /data/coolify, like production.
'dev KVM server' => ['10.221.1.10', '/data/coolify/proxy/caddy'],
// The testing-host server uses the host Docker daemon, so it mounts the dev data volume.
'dev testing-host server' => [Server::DEV_TESTING_HOST_IP, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/caddy'],
]);
it('uses the configured dev data volume for Caddy, Traefik, and Sentinel', function () {
it('uses the configured dev data volume for Caddy, Traefik, and Sentinel on the testing-host server', function () {
config()->set('app.env', 'local');
config()->set('constants.coolify.dev_data_volume', 'coolify-dev-feature_coolify_data');
$caddy = caddyTrafficServer($this);
$caddy = caddyTrafficServer($this, ip: Server::DEV_TESTING_HOST_IP);
$caddyVolumes = Yaml::parse(generateDefaultProxyConfiguration($caddy))['services']['caddy']['volumes'];
$traefik = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$traefik = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id, 'ip' => Server::DEV_TESTING_HOST_IP]);
$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')
expect(StartSentinel::trafficLogDirectory($caddy))->toBe('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy/caddy')
->and($caddyVolumes)->toContain('/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy/caddy:/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) {
it('falls back to the legacy dev volumes for an invalid volume name', function (?string $volume) {
config()->set('app.env', 'local');
config()->set('constants.coolify.dev_data_volume', $volume);
config()->set('constants.coolify.dev_backups_volume', $volume);
$server = caddyTrafficServer($this, ip: Server::DEV_TESTING_HOST_IP);
expect(devCoolifyDataPath())->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data');
expect(devHostDockerPath($server, '/data/coolify'))->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data')
->and(devHostDockerPath($server, '/data/coolify/backups/x'))->toBe('/var/lib/docker/volumes/coolify_dev_backups_data/_data/x');
})->with([
'empty' => [''],
'null' => [null],
@@ -47,17 +47,21 @@ it('produces traffic + geoip env when enabled', function () {
expect($env)->toHaveKey('TRAFFIC_ACCESS_LOG_PATH');
});
it('uses the dev proxy volume for traffic logs locally', function () {
it('uses the dev proxy volume for traffic logs only on the testing-host server locally', function (string $ip, string $directory) {
config()->set('app.env', 'local');
$server = trafficProxyServer($this);
$server->update(['ip' => $ip]);
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
expect(StartSentinel::trafficLogDirectory($server->fresh()))
->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy');
->toBe($directory);
expect(StartSentinel::sentinelTrafficEnvironment($server->fresh())['TRAFFIC_ACCESS_LOG_PATH'])
->toBe('/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy/access.log');
});
->toBe("{$directory}/access.log");
})->with([
'dev KVM server' => ['10.221.1.10', '/data/coolify/proxy'],
'dev testing-host server' => [Server::DEV_TESTING_HOST_IP, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy'],
]);
it('passes custom traffic settings as sentinel env', function () {
$server = trafficProxyServer($this);
@@ -207,11 +211,13 @@ 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 () {
it('mounts the configured dev data volume for traffic logs and Sentinel data on the testing-host server', 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';
$server->update(['ip' => Server::DEV_TESTING_HOST_IP]);
$server = $server->fresh();
$directory = '/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy/caddy';
$script = runStartSentinelAndCaptureScript($server);