Merge remote-tracking branch 'origin/next' into fix/ssh-sporadic-permission-denied

This commit is contained in:
Andras Bacsai 2026-03-16 20:26:56 +01:00
commit fe1aa94144
34 changed files with 390 additions and 45 deletions

View file

@ -77,6 +77,7 @@ ### Big Sponsors
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers * [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 * [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design * [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 * [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 * [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 * [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer

View file

@ -7,6 +7,7 @@
use App\Enums\ProxyStatus; use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\Application; use App\Models\Application;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
@ -758,12 +759,22 @@ public function delete_server(Request $request)
if (! $server) { if (! $server) {
return response()->json(['message' => 'Server not found.'], 404); 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()) { if ($server->isLocalhost()) {
return response()->json(['message' => 'Local server cannot be deleted.'], 400); return response()->json(['message' => 'Local server cannot be deleted.'], 400);
} }
if ($force) {
foreach ($server->definedResources() as $resource) {
DeleteResourceJob::dispatch($resource);
}
}
$server->delete(); $server->delete();
DeleteServer::dispatch( DeleteServer::dispatch(
$server->id, $server->id,

View file

@ -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.'], '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) 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(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -317,6 +318,7 @@ public function create_service(Request $request)
'urls.*.name' => 'string|required', 'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable', 'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean', 'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
]; ];
$validationMessages = [ $validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', '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 = Service::create($servicePayload);
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid; $service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
$service->description = $request->description; $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(); $service->save();
if ($oneClickDotEnvs?->count() > 0) { if ($oneClickDotEnvs?->count() > 0) {
$oneClickDotEnvs->each(function ($value) use ($service) { $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); return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} elseif (filled($request->docker_compose_raw)) { } 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 = [ $validationRules = [
'project_uuid' => 'string|required', 'project_uuid' => 'string|required',
@ -503,6 +508,7 @@ public function create_service(Request $request)
'urls.*.name' => 'string|required', 'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable', 'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean', 'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
]; ];
$validationMessages = [ $validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', '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_id = $destination->id;
$service->destination_type = $destination->getMorphClass(); $service->destination_type = $destination->getMorphClass();
$service->connect_to_docker_network = $connectToDockerNetwork; $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->save();
$service->parse(isNew: true); $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.'], '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); $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 = [ $validationRules = [
'name' => 'string|max:255', 'name' => 'string|max:255',
@ -936,6 +946,7 @@ public function update_by_uuid(Request $request)
'urls.*.name' => 'string|required', 'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable', 'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean', 'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
]; ];
$validationMessages = [ $validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', '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')) { if ($request->has('connect_to_docker_network')) {
$service->connect_to_docker_network = $request->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->save();
$service->parse(); $service->parse();

View file

@ -573,7 +573,8 @@ private function deploy_docker_compose_buildpack()
if (data_get($this->application, 'docker_compose_custom_start_command')) { if (data_get($this->application, 'docker_compose_custom_start_command')) {
$this->docker_compose_custom_start_command = $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')) { 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')) { if (data_get($this->application, 'docker_compose_custom_build_command')) {

View file

@ -179,6 +179,9 @@ public function handle(): void
// Mark validation as complete // Mark validation as complete
$this->server->update(['is_validating' => false]); $this->server->update(['is_validating' => false]);
// Auto-fetch server details now that validation passed
$this->server->gatherServerMetadata();
// Refresh server to get latest state // Refresh server to get latest state
$this->server->refresh(); $this->server->refresh();

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Server; namespace App\Livewire\Server;
use App\Actions\Server\DeleteServer; use App\Actions\Server\DeleteServer;
use App\Jobs\DeleteResourceJob;
use App\Models\Server; use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
@ -15,6 +16,8 @@ class Delete extends Component
public bool $delete_from_hetzner = false; public bool $delete_from_hetzner = false;
public bool $force_delete_resources = false;
public function mount(string $server_uuid) public function mount(string $server_uuid)
{ {
try { try {
@ -32,15 +35,22 @@ public function delete($password, $selectedActions = [])
if (! empty($selectedActions)) { if (! empty($selectedActions)) {
$this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions);
$this->force_delete_resources = in_array('force_delete_resources', $selectedActions);
} }
try { try {
$this->authorize('delete', $this->server); $this->authorize('delete', $this->server);
if ($this->server->hasDefinedResources()) { if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) {
$this->dispatch('error', 'Server has defined resources. Please delete them first.'); $this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".');
return; return;
} }
if ($this->force_delete_resources) {
foreach ($this->server->definedResources() as $resource) {
DeleteResourceJob::dispatch($resource);
}
}
$this->server->delete(); $this->server->delete();
DeleteServer::dispatch( DeleteServer::dispatch(
$this->server->id, $this->server->id,
@ -60,6 +70,15 @@ public function render()
{ {
$checkboxes = []; $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) { if ($this->server->hetzner_server_id) {
$checkboxes[] = [ $checkboxes[] = [
'id' => 'delete_from_hetzner', 'id' => 'delete_from_hetzner',

View file

@ -198,6 +198,9 @@ public function validateDockerVersion()
// Mark validation as complete // Mark validation as complete
$this->server->update(['is_validating' => false]); $this->server->update(['is_validating' => false]);
// Auto-fetch server details now that validation passed
$this->server->gatherServerMetadata();
$this->dispatch('refreshServerShow'); $this->dispatch('refreshServerShow');
$this->dispatch('refreshBoardingIndex'); $this->dispatch('refreshBoardingIndex');
ServerValidated::dispatch($this->server->team_id, $this->server->uuid); ServerValidated::dispatch($this->server->team_id, $this->server->uuid);

View file

@ -239,7 +239,7 @@ public function mount()
if (isCloud() && ! isDev()) { if (isCloud() && ! isDev()) {
$this->webhook_endpoint = config('app.url'); $this->webhook_endpoint = config('app.url');
} else { } else {
$this->webhook_endpoint = $this->ipv4 ?? ''; $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? '';
$this->is_system_wide = $this->github_app->is_system_wide; $this->is_system_wide = $this->github_app->is_system_wide;
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {

View file

@ -1732,7 +1732,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
$this->save(); $this->save();
if (str($e->getMessage())->contains('No such file')) { if (str($e->getMessage())->contains('No such file')) {
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>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})<br><br>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 (str($e->getMessage())->contains('fatal: repository') && str($e->getMessage())->contains('does not exist')) {
if ($this->deploymentType() === 'deploy_key') { if ($this->deploymentType() === 'deploy_key') {
@ -1793,7 +1793,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory =
$this->base_directory = $initialBaseDirectory; $this->base_directory = $initialBaseDirectory;
$this->save(); $this->save();
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>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})<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
} }
} }

View file

@ -166,6 +166,16 @@ public function generate_preview_fqdn_compose()
} }
$this->docker_compose_domains = json_encode($docker_compose_domains); $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(); $this->save();
} }
} }

View file

@ -2,7 +2,7 @@
return [ return [
'coolify' => [ 'coolify' => [
'version' => '4.0.0-beta.468', 'version' => '4.0.0-beta.469',
'helper_version' => '1.0.12', 'helper_version' => '1.0.12',
'realtime_version' => '1.0.11', 'realtime_version' => '1.0.11',
'self_hosted' => env('SELF_HOSTED', true), 'self_hosted' => env('SELF_HOSTED', true),

View file

@ -9685,6 +9685,11 @@
"type": "boolean", "type": "boolean",
"default": false, "default": false,
"description": "Force domain override even if conflicts are detected." "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" "type": "object"
@ -10011,6 +10016,11 @@
"type": "boolean", "type": "boolean",
"default": false, "default": false,
"description": "Force domain override even if conflicts are detected." "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" "type": "object"

View file

@ -6152,6 +6152,10 @@ paths:
type: boolean type: boolean
default: false default: false
description: 'Force domain override even if conflicts are detected.' 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 type: object
responses: responses:
'201': '201':
@ -6337,6 +6341,10 @@ paths:
type: boolean type: boolean
default: false default: false
description: 'Force domain override even if conflicts are detected.' 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 type: object
responses: responses:
'200': '200':

View file

@ -1,10 +1,10 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.0.0-beta.468" "version": "4.0.0-beta.469"
}, },
"nightly": { "nightly": {
"version": "4.0.0-beta.469" "version": "4.0.0"
}, },
"helper": { "helper": {
"version": "1.0.12" "version": "1.0.12"

BIN
public/svgs/imgcompress.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
public/svgs/librespeed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View file

@ -14,7 +14,7 @@
back! back!
</div> </div>
@if ($server->definedResources()->count() > 0) @if ($server->definedResources()->count() > 0)
<div class="pb-2 text-red-500">You need to delete all resources before deleting this server.</div> <div class="pb-2 text-red-500">This server has resources. You can force delete all resources by checking the option below.</div>
@endif @endif
<x-modal-confirmation title="Confirm Server Deletion?" isErrorButton buttonTitle="Delete" <x-modal-confirmation title="Confirm Server Deletion?" isErrorButton buttonTitle="Delete"

View file

@ -62,7 +62,7 @@ class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<div class="subtitle">{{ data_get($server, 'name') }}</div> <div class="subtitle">{{ data_get($server, 'name') }}</div>
<div class="navbar-main"> <div class="navbar-main">
<nav <nav
class="flex items-center gap-4 overflow-x-scroll sm:overflow-x-hidden scrollbar min-h-10 whitespace-nowrap pt-2"> class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar min-h-10 whitespace-nowrap pt-2">
<a class="{{ request()->routeIs('server.show') ? 'dark:text-white' : '' }}" href="{{ route('server.show', [ <a class="{{ request()->routeIs('server.show') ? 'dark:text-white' : '' }}" href="{{ route('server.show', [
'server_uuid' => data_get($server, 'uuid'), 'server_uuid' => data_get($server, 'uuid'),
]) }}" {{ wireNavigate() }}> ]) }}" {{ wireNavigate() }}>

View file

@ -242,15 +242,15 @@ class=""
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2"> <div class="flex flex-col sm:flex-row items-start sm:items-end gap-2">
<x-forms.select wire:model.live='webhook_endpoint' label="Webhook Endpoint" <x-forms.select wire:model.live='webhook_endpoint' label="Webhook Endpoint"
helper="All Git webhooks will be sent to this endpoint. <br><br>If you would like to use domain instead of IP address, set your Coolify instance's FQDN in the Settings menu."> helper="All Git webhooks will be sent to this endpoint. <br><br>If you would like to use domain instead of IP address, set your Coolify instance's FQDN in the Settings menu.">
@if ($fqdn)
<option value="{{ $fqdn }}">Use {{ $fqdn }}</option>
@endif
@if ($ipv4) @if ($ipv4)
<option value="{{ $ipv4 }}">Use {{ $ipv4 }}</option> <option value="{{ $ipv4 }}">Use {{ $ipv4 }}</option>
@endif @endif
@if ($ipv6) @if ($ipv6)
<option value="{{ $ipv6 }}">Use {{ $ipv6 }}</option> <option value="{{ $ipv6 }}">Use {{ $ipv6 }}</option>
@endif @endif
@if ($fqdn)
<option value="{{ $fqdn }}">Use {{ $fqdn }}</option>
@endif
@if (config('app.url')) @if (config('app.url'))
<option value="{{ config('app.url') }}">Use {{ config('app.url') }}</option> <option value="{{ config('app.url') }}">Use {{ config('app.url') }}</option>
@endif @endif

View file

@ -3,15 +3,15 @@
# category: media # category: media
# tags: podcast, media, audio, video, streaming, hosting, platform, castopod # tags: podcast, media, audio, video, streaming, hosting, platform, castopod
# logo: svgs/castopod.svg # logo: svgs/castopod.svg
# port: 8000 # port: 8080
services: services:
castopod: castopod:
image: castopod/castopod:latest image: castopod/castopod:1.15.4
volumes: volumes:
- castopod-media:/var/www/castopod/public/media - castopod-media:/var/www/castopod/public/media
environment: environment:
- SERVICE_URL_CASTOPOD_8000 - SERVICE_URL_CASTOPOD_8080
- MYSQL_DATABASE=castopod - MYSQL_DATABASE=castopod
- MYSQL_USER=$SERVICE_USER_MYSQL - MYSQL_USER=$SERVICE_USER_MYSQL
- MYSQL_PASSWORD=$SERVICE_PASSWORD_MYSQL - MYSQL_PASSWORD=$SERVICE_PASSWORD_MYSQL
@ -27,7 +27,7 @@ services:
"CMD", "CMD",
"curl", "curl",
"-f", "-f",
"http://localhost:8000/health" "http://localhost:8080/health"
] ]
interval: 5s interval: 5s
timeout: 20s timeout: 20s

View file

@ -7,7 +7,7 @@
services: services:
databasus: databasus:
image: 'databasus/databasus:v2.18.0' # Released on 28 Dec, 2025 image: 'databasus/databasus:v3.16.2' # Released on 23 February, 2026
environment: environment:
- SERVICE_URL_DATABASUS_4005 - SERVICE_URL_DATABASUS_4005
volumes: volumes:

View file

@ -3,7 +3,7 @@
# category: productivity # category: productivity
# tags: form, builder, forms, survey, quiz, open source, self-hosted, docker # tags: form, builder, forms, survey, quiz, open source, self-hosted, docker
# logo: svgs/heyform.svg # logo: svgs/heyform.svg
# port: 8000 # port: 9157
services: services:
heyform: heyform:
@ -16,7 +16,7 @@ services:
keydb: keydb:
condition: service_healthy condition: service_healthy
environment: environment:
- SERVICE_URL_HEYFORM_8000 - SERVICE_URL_HEYFORM_9157
- APP_HOMEPAGE_URL=${SERVICE_URL_HEYFORM} - APP_HOMEPAGE_URL=${SERVICE_URL_HEYFORM}
- SESSION_KEY=${SERVICE_BASE64_64_SESSION} - SESSION_KEY=${SERVICE_BASE64_64_SESSION}
- FORM_ENCRYPTION_KEY=${SERVICE_BASE64_64_FORM} - FORM_ENCRYPTION_KEY=${SERVICE_BASE64_64_FORM}
@ -25,7 +25,7 @@ services:
- REDIS_PORT=6379 - REDIS_PORT=6379
- REDIS_PASSWORD=${SERVICE_PASSWORD_KEYDB} - REDIS_PASSWORD=${SERVICE_PASSWORD_KEYDB}
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000 || exit 1"] test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9157 || exit 1"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View file

@ -7,7 +7,7 @@
services: services:
backend: backend:
image: hoppscotch/hoppscotch:latest image: hoppscotch/hoppscotch:2026.2.1
environment: environment:
- SERVICE_URL_HOPPSCOTCH_80 - SERVICE_URL_HOPPSCOTCH_80
- VITE_ALLOWED_AUTH_PROVIDERS=${VITE_ALLOWED_AUTH_PROVIDERS:-GOOGLE,GITHUB,MICROSOFT,EMAIL} - VITE_ALLOWED_AUTH_PROVIDERS=${VITE_ALLOWED_AUTH_PROVIDERS:-GOOGLE,GITHUB,MICROSOFT,EMAIL}
@ -34,7 +34,7 @@ services:
retries: 10 retries: 10
hoppscotch-db: hoppscotch-db:
image: postgres:latest image: postgres:15
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
environment: environment:
@ -51,7 +51,7 @@ services:
db-migration: db-migration:
exclude_from_hc: true exclude_from_hc: true
image: hoppscotch/hoppscotch:latest image: hoppscotch/hoppscotch:2026.2.1
depends_on: depends_on:
hoppscotch-db: hoppscotch-db:
condition: service_healthy condition: service_healthy

View file

@ -0,0 +1,19 @@
# documentation: https://imgcompress.karimzouine.com
# slogan: Offline image compression, conversion, and AI background removal for Docker homelabs.
# category: media
# tags: compress,photo,server,metadata
# logo: svgs/imgcompress.png
# port: 5000
services:
imgcompress:
image: karimz1/imgcompress:0.6.0
environment:
- SERVICE_URL_IMGCOMPRESS_5000
- DISABLE_LOGO=${DISABLE_LOGO:-false}
- DISABLE_STORAGE_MANAGEMENT=${DISABLE_STORAGE_MANAGEMENT:-false}
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:5000"]
interval: 30s
timeout: 10s
retries: 3

View file

@ -0,0 +1,22 @@
# documentation: https://github.com/librespeed/speedtest
# slogan: Self-hosted lightweight Speed Test.
# category: devtools
# tags: speedtest, internet-speed
# logo: svgs/librespeed.png
# port: 82
services:
librespeed:
container_name: librespeed
image: 'ghcr.io/librespeed/speedtest:latest'
environment:
- SERVICE_URL_LIBRESPEED_82
- MODE=standalone
- TELEMETRY=false
- DISTANCE=km
- WEBPORT=82
healthcheck:
test: 'curl 127.0.0.1:82 || exit 1'
timeout: 1s
interval: 1m0s
retries: 1

View file

@ -7,7 +7,7 @@
services: services:
n8n: n8n:
image: n8nio/n8n:2.10.2 image: n8nio/n8n:2.10.4
environment: environment:
- SERVICE_URL_N8N_5678 - SERVICE_URL_N8N_5678
- N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N} - N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N}
@ -54,7 +54,7 @@ services:
retries: 10 retries: 10
n8n-worker: n8n-worker:
image: n8nio/n8n:2.10.2 image: n8nio/n8n:2.10.4
command: worker command: worker
environment: environment:
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC} - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC}
@ -122,7 +122,7 @@ services:
retries: 10 retries: 10
task-runners: task-runners:
image: n8nio/runners:2.10.2 image: n8nio/runners:2.10.4
environment: environment:
- N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n-worker:5679} - N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n-worker:5679}
- N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N - N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N

View file

@ -7,7 +7,7 @@
services: services:
seaweedfs-master: seaweedfs-master:
image: chrislusf/seaweedfs:4.05 image: chrislusf/seaweedfs:4.13
environment: environment:
- SERVICE_URL_S3_8333 - SERVICE_URL_S3_8333
- AWS_ACCESS_KEY_ID=${SERVICE_USER_S3} - AWS_ACCESS_KEY_ID=${SERVICE_USER_S3}
@ -61,7 +61,7 @@ services:
retries: 10 retries: 10
seaweedfs-admin: seaweedfs-admin:
image: chrislusf/seaweedfs:4.05 image: chrislusf/seaweedfs:4.13
environment: environment:
- SERVICE_URL_ADMIN_23646 - SERVICE_URL_ADMIN_23646
- SEAWEED_USER_ADMIN=${SERVICE_USER_ADMIN} - SEAWEED_USER_ADMIN=${SERVICE_USER_ADMIN}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,77 @@
<?php
use App\Models\Application;
use App\Models\ApplicationPreview;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('populates fqdn from docker_compose_domains after generate_preview_fqdn_compose', function () {
$application = Application::factory()->create([
'build_pack' => 'dockercompose',
'docker_compose_domains' => json_encode([
'web' => ['domain' => 'https://example.com'],
]),
]);
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 42,
'docker_compose_domains' => $application->docker_compose_domains,
]);
$preview->generate_preview_fqdn_compose();
$preview->refresh();
expect($preview->fqdn)->not->toBeNull();
expect($preview->fqdn)->toContain('42');
expect($preview->fqdn)->toContain('example.com');
});
it('populates fqdn with multiple domains from multiple services', function () {
$application = Application::factory()->create([
'build_pack' => 'dockercompose',
'docker_compose_domains' => json_encode([
'web' => ['domain' => 'https://web.example.com'],
'api' => ['domain' => 'https://api.example.com'],
]),
]);
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 7,
'docker_compose_domains' => $application->docker_compose_domains,
]);
$preview->generate_preview_fqdn_compose();
$preview->refresh();
expect($preview->fqdn)->not->toBeNull();
$domains = explode(',', $preview->fqdn);
expect($domains)->toHaveCount(2);
expect($preview->fqdn)->toContain('web.example.com');
expect($preview->fqdn)->toContain('api.example.com');
});
it('sets fqdn to null when no domains are configured', function () {
$application = Application::factory()->create([
'build_pack' => 'dockercompose',
'docker_compose_domains' => json_encode([
'web' => ['domain' => ''],
]),
]);
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 99,
'docker_compose_domains' => $application->docker_compose_domains,
]);
$preview->generate_preview_fqdn_compose();
$preview->refresh();
expect($preview->fqdn)->toBeNull();
});

View file

@ -1,9 +1,11 @@
<?php <?php
use App\Livewire\Server\ValidateAndInstall;
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -94,3 +96,24 @@
expect($this->server->server_metadata['os'])->toBe('Ubuntu 22.04') expect($this->server->server_metadata['os'])->toBe('Ubuntu 22.04')
->and($this->server->server_metadata['cpus'])->toBe(4); ->and($this->server->server_metadata['cpus'])->toBe(4);
}); });
it('calls gatherServerMetadata during ValidateAndInstall when docker version is valid', function () {
$serverMock = Mockery::mock($this->server)->makePartial();
$serverMock->shouldReceive('isSwarm')->andReturn(false);
$serverMock->shouldReceive('validateDockerEngineVersion')->once()->andReturn('24.0.0');
$serverMock->shouldReceive('gatherServerMetadata')->once();
$serverMock->shouldReceive('isBuildServer')->andReturn(false);
Livewire::test(ValidateAndInstall::class, ['server' => $serverMock])
->call('validateDockerVersion');
});
it('does not call gatherServerMetadata when docker version validation fails', function () {
$serverMock = Mockery::mock($this->server)->makePartial();
$serverMock->shouldReceive('isSwarm')->andReturn(false);
$serverMock->shouldReceive('validateDockerEngineVersion')->once()->andReturn(false);
$serverMock->shouldNotReceive('gatherServerMetadata');
Livewire::test(ValidateAndInstall::class, ['server' => $serverMock])
->call('validateDockerVersion');
});

View file

@ -0,0 +1,75 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = $this->project->environments()->first();
});
function serviceContainerLabelAuthHeaders($bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('PATCH /api/v1/services/{uuid}', function () {
test('accepts is_container_label_escape_enabled field', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders(serviceContainerLabelAuthHeaders($this->bearerToken))
->patchJson("/api/v1/services/{$service->uuid}", [
'is_container_label_escape_enabled' => false,
]);
$response->assertStatus(200);
$service->refresh();
expect($service->is_container_label_escape_enabled)->toBeFalse();
});
test('rejects invalid is_container_label_escape_enabled value', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders(serviceContainerLabelAuthHeaders($this->bearerToken))
->patchJson("/api/v1/services/{$service->uuid}", [
'is_container_label_escape_enabled' => 'not-a-boolean',
]);
$response->assertStatus(422);
});
});

View file

@ -75,6 +75,55 @@
expect($startCommand)->toContain("-f {$serverWorkdir}{$composeLocation}"); expect($startCommand)->toContain("-f {$serverWorkdir}{$composeLocation}");
}); });
it('injects --project-directory with host path when preserveRepository is true', function () {
$serverWorkdir = '/data/coolify/applications/app-uuid';
$containerWorkdir = '/artifacts/deployment-uuid';
$preserveRepository = true;
$customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d';
// Simulate the --project-directory injection from deploy_docker_compose_buildpack()
if (! str($customStartCommand)->contains('--project-directory')) {
$projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir;
$customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value();
}
// When preserveRepository is true, --project-directory must point to host path
expect($customStartCommand)->toContain("--project-directory {$serverWorkdir}");
expect($customStartCommand)->not->toContain('/artifacts/');
});
it('injects --project-directory with container path when preserveRepository is false', function () {
$serverWorkdir = '/data/coolify/applications/app-uuid';
$containerWorkdir = '/artifacts/deployment-uuid';
$preserveRepository = false;
$customStartCommand = 'docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d';
// Simulate the --project-directory injection from deploy_docker_compose_buildpack()
if (! str($customStartCommand)->contains('--project-directory')) {
$projectDir = $preserveRepository ? $serverWorkdir : $containerWorkdir;
$customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value();
}
// When preserveRepository is false, --project-directory must point to container path
expect($customStartCommand)->toContain("--project-directory {$containerWorkdir}");
expect($customStartCommand)->not->toContain('/data/coolify/applications/');
});
it('does not override explicit --project-directory in custom start command', function () {
$customProjectDir = '/custom/path';
$customStartCommand = "docker compose --project-directory {$customProjectDir} up -d";
// Simulate the --project-directory injection — should be skipped
if (! str($customStartCommand)->contains('--project-directory')) {
$customStartCommand = str($customStartCommand)->replaceFirst('compose', 'compose --project-directory /should-not-appear')->value();
}
expect($customStartCommand)->toContain("--project-directory {$customProjectDir}");
expect($customStartCommand)->not->toContain('/should-not-appear');
});
it('uses container paths for env-file when preserveRepository is false', function () { it('uses container paths for env-file when preserveRepository is false', function () {
$workdir = '/artifacts/deployment-uuid/backend'; $workdir = '/artifacts/deployment-uuid/backend';
$composeLocation = '/compose.yml'; $composeLocation = '/compose.yml';

View file

@ -1,10 +1,10 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.0.0-beta.468" "version": "4.0.0-beta.469"
}, },
"nightly": { "nightly": {
"version": "4.0.0-beta.469" "version": "4.0.0"
}, },
"helper": { "helper": {
"version": "1.0.12" "version": "1.0.12"