diff --git a/README.md b/README.md
index e9ea0e7d4..b2d622167 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,7 @@ ### Big Sponsors
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design
+* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany.
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer
diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php
index 892457925..da94521a8 100644
--- a/app/Http/Controllers/Api/ServersController.php
+++ b/app/Http/Controllers/Api/ServersController.php
@@ -7,6 +7,7 @@
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Http\Controllers\Controller;
+use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\PrivateKey;
use App\Models\Project;
@@ -758,12 +759,22 @@ public function delete_server(Request $request)
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
- if ($server->definedResources()->count() > 0) {
- return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400);
+
+ $force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
+
+ if ($server->definedResources()->count() > 0 && ! $force) {
+ return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400);
}
if ($server->isLocalhost()) {
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
}
+
+ if ($force) {
+ foreach ($server->definedResources() as $resource) {
+ DeleteResourceJob::dispatch($resource);
+ }
+ }
+
$server->delete();
DeleteServer::dispatch(
$server->id,
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index b4fe4e47b..32097443e 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -222,6 +222,7 @@ public function services(Request $request)
),
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
+ 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
],
),
),
@@ -288,7 +289,7 @@ public function services(Request $request)
)]
public function create_service(Request $request)
{
- $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override'];
+ $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -317,6 +318,7 @@ public function create_service(Request $request)
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
+ 'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -429,6 +431,9 @@ public function create_service(Request $request)
$service = Service::create($servicePayload);
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
$service->description = $request->description;
+ if ($request->has('is_container_label_escape_enabled')) {
+ $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
+ }
$service->save();
if ($oneClickDotEnvs?->count() > 0) {
$oneClickDotEnvs->each(function ($value) use ($service) {
@@ -485,7 +490,7 @@ public function create_service(Request $request)
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} elseif (filled($request->docker_compose_raw)) {
- $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override'];
+ $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'project_uuid' => 'string|required',
@@ -503,6 +508,7 @@ public function create_service(Request $request)
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
+ 'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -609,6 +615,9 @@ public function create_service(Request $request)
$service->destination_id = $destination->id;
$service->destination_type = $destination->getMorphClass();
$service->connect_to_docker_network = $connectToDockerNetwork;
+ if ($request->has('is_container_label_escape_enabled')) {
+ $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
+ }
$service->save();
$service->parse(isNew: true);
@@ -835,6 +844,7 @@ public function delete_by_uuid(Request $request)
),
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
+ 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
],
)
),
@@ -923,7 +933,7 @@ public function update_by_uuid(Request $request)
$this->authorize('update', $service);
- $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override'];
+ $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
@@ -936,6 +946,7 @@ public function update_by_uuid(Request $request)
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
+ 'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -1001,6 +1012,9 @@ public function update_by_uuid(Request $request)
if ($request->has('connect_to_docker_network')) {
$service->connect_to_docker_network = $request->connect_to_docker_network;
}
+ if ($request->has('is_container_label_escape_enabled')) {
+ $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
+ }
$service->save();
$service->parse();
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index fcd619fd4..f84cdceb9 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -573,7 +573,8 @@ private function deploy_docker_compose_buildpack()
if (data_get($this->application, 'docker_compose_custom_start_command')) {
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) {
- $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value();
+ $projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir;
+ $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value();
}
}
if (data_get($this->application, 'docker_compose_custom_build_command')) {
diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php
index 9f02f9b78..288904471 100644
--- a/app/Jobs/ValidateAndInstallServerJob.php
+++ b/app/Jobs/ValidateAndInstallServerJob.php
@@ -179,6 +179,9 @@ public function handle(): void
// Mark validation as complete
$this->server->update(['is_validating' => false]);
+ // Auto-fetch server details now that validation passed
+ $this->server->gatherServerMetadata();
+
// Refresh server to get latest state
$this->server->refresh();
diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php
index beb8c0a12..d06543b39 100644
--- a/app/Livewire/Server/Delete.php
+++ b/app/Livewire/Server/Delete.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Server;
use App\Actions\Server\DeleteServer;
+use App\Jobs\DeleteResourceJob;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -15,6 +16,8 @@ class Delete extends Component
public bool $delete_from_hetzner = false;
+ public bool $force_delete_resources = false;
+
public function mount(string $server_uuid)
{
try {
@@ -32,15 +35,22 @@ public function delete($password, $selectedActions = [])
if (! empty($selectedActions)) {
$this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions);
+ $this->force_delete_resources = in_array('force_delete_resources', $selectedActions);
}
try {
$this->authorize('delete', $this->server);
- if ($this->server->hasDefinedResources()) {
- $this->dispatch('error', 'Server has defined resources. Please delete them first.');
+ if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) {
+ $this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".');
return;
}
+ if ($this->force_delete_resources) {
+ foreach ($this->server->definedResources() as $resource) {
+ DeleteResourceJob::dispatch($resource);
+ }
+ }
+
$this->server->delete();
DeleteServer::dispatch(
$this->server->id,
@@ -60,6 +70,15 @@ public function render()
{
$checkboxes = [];
+ if ($this->server->hasDefinedResources()) {
+ $resourceCount = $this->server->definedResources()->count();
+ $checkboxes[] = [
+ 'id' => 'force_delete_resources',
+ 'label' => "Delete all resources ({$resourceCount} total)",
+ 'default_warning' => 'Server cannot be deleted while it has resources.',
+ ];
+ }
+
if ($this->server->hetzner_server_id) {
$checkboxes[] = [
'id' => 'delete_from_hetzner',
diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php
index 1a5bd381b..198d823b9 100644
--- a/app/Livewire/Server/ValidateAndInstall.php
+++ b/app/Livewire/Server/ValidateAndInstall.php
@@ -198,6 +198,9 @@ public function validateDockerVersion()
// Mark validation as complete
$this->server->update(['is_validating' => false]);
+ // Auto-fetch server details now that validation passed
+ $this->server->gatherServerMetadata();
+
$this->dispatch('refreshServerShow');
$this->dispatch('refreshBoardingIndex');
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php
index 0a38e6088..17323fdec 100644
--- a/app/Livewire/Source/Github/Change.php
+++ b/app/Livewire/Source/Github/Change.php
@@ -239,7 +239,7 @@ public function mount()
if (isCloud() && ! isDev()) {
$this->webhook_endpoint = config('app.url');
} else {
- $this->webhook_endpoint = $this->ipv4 ?? '';
+ $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? '';
$this->is_system_wide = $this->github_app->is_system_wide;
}
} catch (\Throwable $e) {
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 82e4d6311..4cc2dcf74 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -1732,7 +1732,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
$this->save();
if (str($e->getMessage())->contains('No such file')) {
- throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile
Check if you used the right extension (.yaml or .yml) in the compose file name.");
+ throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})
Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
if (str($e->getMessage())->contains('fatal: repository') && str($e->getMessage())->contains('does not exist')) {
if ($this->deploymentType() === 'deploy_key') {
@@ -1793,7 +1793,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
$this->base_directory = $initialBaseDirectory;
$this->save();
- throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile
Check if you used the right extension (.yaml or .yml) in the compose file name.");
+ throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile (branch: {$this->git_branch})
Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
}
diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php
index 7373fdb16..3b7bf3030 100644
--- a/app/Models/ApplicationPreview.php
+++ b/app/Models/ApplicationPreview.php
@@ -166,6 +166,16 @@ public function generate_preview_fqdn_compose()
}
$this->docker_compose_domains = json_encode($docker_compose_domains);
+
+ // Populate fqdn from generated domains so webhook notifications can read it
+ $allDomains = collect($docker_compose_domains)
+ ->pluck('domain')
+ ->filter(fn ($d) => ! empty($d))
+ ->flatMap(fn ($d) => explode(',', $d))
+ ->implode(',');
+
+ $this->fqdn = ! empty($allDomains) ? $allDomains : null;
+
$this->save();
}
}
diff --git a/config/constants.php b/config/constants.php
index 5cb924148..9c6454cae 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,7 +2,7 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.468',
+ 'version' => '4.0.0-beta.469',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.11',
'self_hosted' => env('SELF_HOSTED', true),
diff --git a/openapi.json b/openapi.json
index 849dee363..f5d9813b3 100644
--- a/openapi.json
+++ b/openapi.json
@@ -9685,6 +9685,11 @@
"type": "boolean",
"default": false,
"description": "Force domain override even if conflicts are detected."
+ },
+ "is_container_label_escape_enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off."
}
},
"type": "object"
@@ -10011,6 +10016,11 @@
"type": "boolean",
"default": false,
"description": "Force domain override even if conflicts are detected."
+ },
+ "is_container_label_escape_enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off."
}
},
"type": "object"
diff --git a/openapi.yaml b/openapi.yaml
index 226295cdb..81753544f 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -6152,6 +6152,10 @@ paths:
type: boolean
default: false
description: 'Force domain override even if conflicts are detected.'
+ is_container_label_escape_enabled:
+ type: boolean
+ default: true
+ description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'
type: object
responses:
'201':
@@ -6337,6 +6341,10 @@ paths:
type: boolean
default: false
description: 'Force domain override even if conflicts are detected.'
+ is_container_label_escape_enabled:
+ type: boolean
+ default: true
+ description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'
type: object
responses:
'200':
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 7fbe25374..7564f625e 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.468"
+ "version": "4.0.0-beta.469"
},
"nightly": {
- "version": "4.0.0-beta.469"
+ "version": "4.0.0"
},
"helper": {
"version": "1.0.12"
diff --git a/public/svgs/imgcompress.png b/public/svgs/imgcompress.png
new file mode 100644
index 000000000..9eb04c3a7
Binary files /dev/null and b/public/svgs/imgcompress.png differ
diff --git a/public/svgs/librespeed.png b/public/svgs/librespeed.png
new file mode 100644
index 000000000..1405e3c18
Binary files /dev/null and b/public/svgs/librespeed.png differ
diff --git a/resources/views/livewire/server/delete.blade.php b/resources/views/livewire/server/delete.blade.php
index 073849452..dec1d3f6d 100644
--- a/resources/views/livewire/server/delete.blade.php
+++ b/resources/views/livewire/server/delete.blade.php
@@ -14,7 +14,7 @@
back!
@if ($server->definedResources()->count() > 0)
-
You need to delete all resources before deleting this server.
+
This server has resources. You can force delete all resources by checking the option below.