fix(compose): use explicit compose file paths and remove volumes by name (#12199)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 authored and GitHub committed 2026-10-19 00:00:48 +02:00
1 parent 9355728d03
commit 0eeb630ccd
6 files changed
+87 -25

No files matched your search

+3 -2
View File
@@ -127,8 +127,9 @@ class StartDatabaseProxy
"mkdir -p $configuration_dir",
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
"docker compose --project-directory {$configuration_dir} pull",
"docker compose --project-directory {$configuration_dir} up -d",
// Pass -f so Compose uses only the file that Coolify wrote.
"docker compose --project-directory {$configuration_dir} -f {$configuration_dir}/docker-compose.yaml pull",
"docker compose --project-directory {$configuration_dir} -f {$configuration_dir}/docker-compose.yaml up -d",
], $server);
} catch (\RuntimeException $e) {
if ($this->isNonTransientError($e->getMessage())) {
+3 -2
View File
@@ -45,16 +45,17 @@ class StartService
// doesn't match configuration. Recreate?") would read the next script lines as the answer.
// Give Compose no stdin so it uses the default answer (keep the volume). Never pass --yes:
// it recreates the volume and deletes its data.
// Pass -f so Compose uses only the file that Coolify wrote.
$compose = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid}";
if ($pullLatestImages) {
$commands[] = "echo 'Pulling images.'";
$commands[] = "docker compose --project-directory {$workdir} pull < /dev/null";
$commands[] = "{$compose} pull < /dev/null";
}
if ($service->networks()->count() > 0) {
$commands[] = "echo 'Creating Docker network.'";
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
}
$commands[] = 'echo Starting service.';
$compose = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid}";
if (data_get($service, 'connect_to_docker_network')) {
// Connect the created containers to the predefined network before they start. A container
// that needs this network during a service_healthy wait never gets it after `up -d`.
+12 -15
View File
@@ -546,24 +546,21 @@ class Application extends BaseModel
}
}
/**
* Removes the volumes by the names that Coolify stored.
*/
public function deleteVolumes()
{
$persistentStorages = $this->persistentStorages()->get() ?? collect();
if ($this->build_pack === 'dockercompose') {
$server = data_get($this, 'destination.server');
// --project-directory instead of cd: a non-root SSH user cannot enter the directory on the Coolify host.
instant_remote_process(["docker compose --project-directory {$this->dirOnServer()} down -v"], $server, false);
} else {
if ($persistentStorages->count() === 0) {
return;
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
if ($storage->isSharedWithAnotherResource()) {
continue;
}
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
$persistentStorages = $this->persistentStorages()->get();
if ($persistentStorages->isEmpty()) {
return;
}
$server = data_get($this, 'destination.server');
foreach ($persistentStorages as $storage) {
if ($storage->isSharedWithAnotherResource()) {
continue;
}
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
}
}
+26 -6
View File
@@ -12,6 +12,7 @@ use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
@@ -794,8 +795,9 @@ describe('delete', function () {
->not->toContain("{$uuid}_shared-data");
});
it('lets Docker Compose keep the external volume when a Compose application is deleted with its volumes', function () {
it('never removes the external volume when a Compose application is deleted with its volumes', function () {
$application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE);
$uuid = $application->uuid;
applicationParser($application);
$application->delete();
$commands = [];
@@ -804,11 +806,29 @@ describe('delete', function () {
(new DeleteResourceJob($application))->handle();
$all = implode("\n", $commands);
// --project-directory, not cd: a non-root SSH user cannot enter the directory on the Coolify host.
expect($all)->toContain("docker compose --project-directory {$application->dirOnServer()} down -v")
->not->toContain("cd {$application->dirOnServer()}")
->not->toContain('docker volume rm')
->and($application->docker_compose)->toContain("shared-data:\n external: true");
expect($all)->toContain("docker volume rm -f '{$uuid}_app-data'")
->not->toContain("'shared-data'")
->not->toContain("{$uuid}_shared-data");
});
it('removes the volumes by name without Docker Compose when a Compose application is deleted', function () {
$application = externalVolumeApplication(EXTERNAL_VOLUME_SHORT_COMPOSE);
applicationParser($application);
LocalFileVolume::create([
'fs_path' => $application->dirOnServer().'/compose.yaml',
'mount_path' => '/compose.yaml',
'content' => "services:\n other:\n image: busybox\n",
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$application->delete();
$commands = [];
fakeExternalVolumeServer($commands);
(new DeleteResourceJob($application, deleteConfigurations: false))->handle();
expect(implode("\n", $commands))->not->toContain('docker compose')
->toContain("docker volume rm -f '{$application->uuid}_app-data'");
});
it('removes only the old prefixed volume when a service with an old storage entry is deleted with its volumes', function () {
@@ -118,3 +118,34 @@ test('database proxy resolves the database container on every connection', funct
->toContain('proxy_pass $upstream;')
->not->toContain("proxy_pass {$database->uuid}:5432;");
});
test('database proxy runs Docker Compose only with the compose file that Coolify wrote', function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0]);
config(['constants.ssh.mux_enabled' => false]);
$commands = [];
Process::fake(function ($process) use (&$commands) {
$commands[] = is_array($process->command) ? implode(' ', $process->command) : $process->command;
return Process::result(output: '');
});
$team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
$server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]);
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$database = create_standalone_postgresql($environment->id, $destination);
$database->update(['is_public' => true, 'public_port' => 15432]);
StartDatabaseProxy::run($database->fresh());
$proxyDir = database_proxy_dir($database->uuid);
preg_match_all('/docker compose [^\n]*/', implode("\n", $commands), $matches);
expect($matches[0])->toHaveCount(2);
foreach ($matches[0] as $composeCommand) {
expect($composeCommand)->toContain("--project-directory {$proxyDir} -f {$proxyDir}/docker-compose.yaml ");
}
});
+12
View File
@@ -142,6 +142,18 @@ it('gives service compose up and pull no stdin and never answers the recreate pr
}
});
it('runs every service compose command with the compose file that Coolify wrote', function () {
$service = serviceComposeStdinService('deploy');
$workdir = $service->workdir();
$lines = serviceComposeCommandLines(StartService::run($service, pullLatestImages: true));
expect($lines)->toHaveCount(3);
foreach ($lines as $line) {
expect($line)->toContain("-f {$workdir}/docker-compose.yml --project-name {$service->uuid} ");
}
});
/**
* @param list<string> $calls
*/