diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php
index f5d5f82b6..a985871dc 100644
--- a/app/Actions/Docker/GetContainersStatus.php
+++ b/app/Actions/Docker/GetContainersStatus.php
@@ -28,6 +28,8 @@ class GetContainersStatus
protected ?Collection $applicationContainerStatuses;
+ protected ?Collection $applicationContainerRestartCounts;
+
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{
$this->containers = $containers;
@@ -136,6 +138,18 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
+
+ // Track restart counts for applications
+ $restartCount = data_get($container, 'RestartCount', 0);
+ if (! isset($this->applicationContainerRestartCounts)) {
+ $this->applicationContainerRestartCounts = collect();
+ }
+ if (! $this->applicationContainerRestartCounts->has($applicationId)) {
+ $this->applicationContainerRestartCounts->put($applicationId, collect());
+ }
+ if ($containerName) {
+ $this->applicationContainerRestartCounts->get($applicationId)->put($containerName, $restartCount);
+ }
} else {
// Notify user that this container should not be there.
}
@@ -291,7 +305,24 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
continue;
}
- $application->update(['status' => 'exited']);
+ // If container was recently restarting (crash loop), keep it as degraded for a grace period
+ // This prevents false "exited" status during the brief moment between container removal and recreation
+ $recentlyRestarted = $application->restart_count > 0 &&
+ $application->last_restart_at &&
+ $application->last_restart_at->greaterThan(now()->subSeconds(30));
+
+ if ($recentlyRestarted) {
+ // Keep it as degraded if it was recently in a crash loop
+ $application->update(['status' => 'degraded (unhealthy)']);
+ } else {
+ // Reset restart count when application exits completely
+ $application->update([
+ 'status' => 'exited',
+ 'restart_count' => 0,
+ 'last_restart_at' => null,
+ 'last_restart_type' => null,
+ ]);
+ }
}
$notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews);
foreach ($notRunningApplicationPreviews as $previewId) {
@@ -340,7 +371,37 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
continue;
}
- $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
+ // Track restart counts first
+ $maxRestartCount = 0;
+ if (isset($this->applicationContainerRestartCounts) && $this->applicationContainerRestartCounts->has($applicationId)) {
+ $containerRestartCounts = $this->applicationContainerRestartCounts->get($applicationId);
+ $maxRestartCount = $containerRestartCounts->max() ?? 0;
+ $previousRestartCount = $application->restart_count ?? 0;
+
+ if ($maxRestartCount > $previousRestartCount) {
+ // Restart count increased - this is a crash restart
+ $application->update([
+ 'restart_count' => $maxRestartCount,
+ 'last_restart_at' => now(),
+ 'last_restart_type' => 'crash',
+ ]);
+
+ // Send notification
+ $containerName = $application->name;
+ $projectUuid = data_get($application, 'environment.project.uuid');
+ $environmentName = data_get($application, 'environment.name');
+ $applicationUuid = data_get($application, 'uuid');
+
+ if ($projectUuid && $applicationUuid && $environmentName) {
+ $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
+ } else {
+ $url = null;
+ }
+ }
+ }
+
+ // Aggregate status after tracking restart counts
+ $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount);
if ($aggregatedStatus) {
$statusFromDb = $application->status;
if ($statusFromDb !== $aggregatedStatus) {
@@ -355,7 +416,7 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
ServiceChecked::dispatch($this->server->team->id);
}
- private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
+ private function aggregateApplicationStatus($application, Collection $containerStatuses, int $maxRestartCount = 0): ?string
{
// Parse docker compose to check for excluded containers
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
@@ -413,6 +474,11 @@ private function aggregateApplicationStatus($application, Collection $containerS
return 'degraded (unhealthy)';
}
+ // If container is exited but has restart count > 0, it's in a crash loop
+ if ($hasExited && $maxRestartCount > 0) {
+ return 'degraded (unhealthy)';
+ }
+
if ($hasRunning && $hasExited) {
return 'degraded (unhealthy)';
}
@@ -421,7 +487,7 @@ private function aggregateApplicationStatus($application, Collection $containerS
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
}
- // All containers are exited
+ // All containers are exited with no restart count - truly stopped
return 'exited (unhealthy)';
}
}
diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php
index dfef6a566..50011c74f 100644
--- a/app/Actions/Service/StartService.php
+++ b/app/Actions/Service/StartService.php
@@ -22,6 +22,10 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
$service->isConfigurationChanged(save: true);
$commands[] = 'cd '.$service->workdir();
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
+ // Ensure .env file exists before docker compose tries to load it
+ // This is defensive programming - saveComposeConfigs() already creates it,
+ // but we guarantee it here in case of any edge cases or manual deployments
+ $commands[] = 'touch .env';
if ($pullLatestImages) {
$commands[] = "echo 'Pulling images.'";
$commands[] = 'docker compose pull';
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index 5ba9c08e7..2aee15a8d 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -246,6 +246,50 @@ public function manual(Request $request)
if ($action === 'closed') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
+ // Cancel any active deployments for this PR immediately
+ $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
+ ->where('pull_request_id', $pull_request_id)
+ ->whereIn('status', [
+ \App\Enums\ApplicationDeploymentStatus::QUEUED->value,
+ \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ])
+ ->first();
+
+ if ($activeDeployment) {
+ try {
+ // Mark deployment as cancelled
+ $activeDeployment->update([
+ 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
+ ]);
+
+ // Add cancellation log entry
+ $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
+
+ // Check if helper container exists and kill it
+ $deployment_uuid = $activeDeployment->deployment_uuid;
+ $server = $application->destination->server;
+ $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
+ $containerExists = instant_remote_process([$checkCommand], $server);
+
+ if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
+ instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
+ $activeDeployment->addLogEntry('Deployment container stopped.');
+ }
+
+ // Kill running process if process ID exists
+ if ($activeDeployment->current_process_id) {
+ try {
+ $processKillCommand = "kill -9 {$activeDeployment->current_process_id}";
+ instant_remote_process([$processKillCommand], $server);
+ } catch (\Throwable $e) {
+ // Process might already be gone
+ }
+ }
+ } catch (\Throwable $e) {
+ // Silently handle errors during deployment cancellation
+ }
+ }
+
DeleteResourceJob::dispatch($found);
$return_payloads->push([
'application' => $application->name,
@@ -481,6 +525,51 @@ public function normal(Request $request)
if ($action === 'closed' || $action === 'close') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
+ // Cancel any active deployments for this PR immediately
+ $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
+ ->where('pull_request_id', $pull_request_id)
+ ->whereIn('status', [
+ \App\Enums\ApplicationDeploymentStatus::QUEUED->value,
+ \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ])
+ ->first();
+
+ if ($activeDeployment) {
+ try {
+ // Mark deployment as cancelled
+ $activeDeployment->update([
+ 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
+ ]);
+
+ // Add cancellation log entry
+ $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
+
+ // Check if helper container exists and kill it
+ $deployment_uuid = $activeDeployment->deployment_uuid;
+ $server = $application->destination->server;
+ $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
+ $containerExists = instant_remote_process([$checkCommand], $server);
+
+ if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
+ instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
+ $activeDeployment->addLogEntry('Deployment container stopped.');
+ }
+
+ // Kill running process if process ID exists
+ if ($activeDeployment->current_process_id) {
+ try {
+ $processKillCommand = "kill -9 {$activeDeployment->current_process_id}";
+ instant_remote_process([$processKillCommand], $server);
+ } catch (\Throwable $e) {
+ // Process might already be gone
+ }
+ }
+ } catch (\Throwable $e) {
+ // Silently handle errors during deployment cancellation
+ }
+ }
+
+ // Clean up any deployed containers
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
if ($containers->isNotEmpty()) {
$containers->each(function ($container) use ($application) {
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index ea8cdff95..d6746b4d1 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -341,20 +341,42 @@ public function handle(): void
$this->fail($e);
throw $e;
} finally {
- $this->application_deployment_queue->update([
- 'finished_at' => Carbon::now()->toImmutable(),
- ]);
-
- if ($this->use_build_server) {
- $this->server = $this->build_server;
- } else {
- $this->write_deployment_configurations();
+ // Wrap cleanup operations in try-catch to prevent exceptions from interfering
+ // with Laravel's job failure handling and status updates
+ try {
+ $this->application_deployment_queue->update([
+ 'finished_at' => Carbon::now()->toImmutable(),
+ ]);
+ } catch (Exception $e) {
+ // Log but don't fail - finished_at is not critical
+ \Log::warning('Failed to update finished_at for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
- $this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
- $this->graceful_shutdown_container($this->deployment_uuid);
+ try {
+ if ($this->use_build_server) {
+ $this->server = $this->build_server;
+ } else {
+ $this->write_deployment_configurations();
+ }
+ } catch (Exception $e) {
+ // Log but don't fail - configuration writing errors shouldn't prevent status updates
+ $this->application_deployment_queue->addLogEntry('Warning: Failed to write deployment configurations: '.$e->getMessage(), 'stderr');
+ }
- ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
+ try {
+ $this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
+ $this->graceful_shutdown_container($this->deployment_uuid);
+ } catch (Exception $e) {
+ // Log but don't fail - container cleanup errors are expected when container is already gone
+ \Log::warning('Failed to shutdown container '.$this->deployment_uuid.': '.$e->getMessage());
+ }
+
+ try {
+ ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
+ } catch (Exception $e) {
+ // Log but don't fail - event dispatch errors shouldn't prevent status updates
+ \Log::warning('Failed to dispatch ServiceStatusChanged for deployment '.$this->deployment_uuid.': '.$e->getMessage());
+ }
}
}
@@ -1146,6 +1168,15 @@ private function generate_runtime_environment_variables()
foreach ($runtime_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
}
+
+ // Check for PORT environment variable mismatch with ports_exposes
+ if ($this->build_pack !== 'dockercompose') {
+ $detectedPort = $this->application->detectPortFromEnvironment(false);
+ if ($detectedPort && ! empty($ports) && ! in_array($detectedPort, $ports)) {
+ ray()->orange("PORT environment variable ({$detectedPort}) does not match configured ports_exposes: ".implode(',', $ports));
+ }
+ }
+
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
@@ -3029,6 +3060,12 @@ private function stop_running_container(bool $force = false)
private function start_by_compose_file()
{
+ // Ensure .env file exists before docker compose tries to load it (defensive programming)
+ $this->execute_remote_command(
+ ["touch {$this->workdir}/.env", 'hidden' => true],
+ ["touch {$this->configuration_dir}/.env", 'hidden' => true],
+ );
+
if ($this->application->build_pack === 'dockerimage') {
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
$this->execute_remote_command(
@@ -3798,10 +3835,8 @@ private function failDeployment(): void
public function failed(Throwable $exception): void
{
$this->failDeployment();
- $this->application_deployment_queue->addLogEntry('Oops something is not okay, are you okay? 😢', 'stderr');
- if (str($exception->getMessage())->isNotEmpty()) {
- $this->application_deployment_queue->addLogEntry($exception->getMessage(), 'stderr');
- }
+ $errorMessage = $exception->getMessage() ?: 'Unknown error occurred';
+ $this->application_deployment_queue->addLogEntry("Deployment failed: {$errorMessage}", 'stderr');
if ($this->application->build_pack !== 'dockercompose') {
$code = $exception->getCode();
diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php
index c82a27ce9..f6f5e8b5b 100644
--- a/app/Jobs/CleanupHelperContainersJob.php
+++ b/app/Jobs/CleanupHelperContainersJob.php
@@ -2,6 +2,8 @@
namespace App\Jobs;
+use App\Enums\ApplicationDeploymentStatus;
+use App\Models\ApplicationDeploymentQueue;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -20,10 +22,51 @@ public function __construct(public Server $server) {}
public function handle(): void
{
try {
+ // Get all active deployments on this server
+ $activeDeployments = ApplicationDeploymentQueue::where('server_id', $this->server->id)
+ ->whereIn('status', [
+ ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ApplicationDeploymentStatus::QUEUED->value,
+ ])
+ ->pluck('deployment_uuid')
+ ->toArray();
+
+ \Log::info('CleanupHelperContainersJob - Active deployments', [
+ 'server' => $this->server->name,
+ 'active_deployment_uuids' => $activeDeployments,
+ ]);
+
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
- $containerIds = collect(json_decode($containers))->pluck('ID');
- if ($containerIds->count() > 0) {
- foreach ($containerIds as $containerId) {
+ $helperContainers = collect(json_decode($containers));
+
+ if ($helperContainers->count() > 0) {
+ foreach ($helperContainers as $container) {
+ $containerId = data_get($container, 'ID');
+ $containerName = data_get($container, 'Names');
+
+ // Check if this container belongs to an active deployment
+ $isActiveDeployment = false;
+ foreach ($activeDeployments as $deploymentUuid) {
+ if (str_contains($containerName, $deploymentUuid)) {
+ $isActiveDeployment = true;
+ break;
+ }
+ }
+
+ if ($isActiveDeployment) {
+ \Log::info('CleanupHelperContainersJob - Skipping active deployment container', [
+ 'container' => $containerName,
+ 'id' => $containerId,
+ ]);
+
+ continue;
+ }
+
+ \Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [
+ 'container' => $containerName,
+ 'id' => $containerId,
+ ]);
+
instant_remote_process_with_timeout(['docker container rm -f '.$containerId], $this->server, false);
}
}
diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php
index b9fbebcc9..ad707d357 100644
--- a/app/Jobs/DeleteResourceJob.php
+++ b/app/Jobs/DeleteResourceJob.php
@@ -124,6 +124,51 @@ private function deleteApplicationPreview()
$this->resource->delete();
}
+ // Cancel any active deployments for this PR (same logic as API cancel_deployment)
+ $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
+ ->where('pull_request_id', $pull_request_id)
+ ->whereIn('status', [
+ \App\Enums\ApplicationDeploymentStatus::QUEUED->value,
+ \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ])
+ ->first();
+
+ if ($activeDeployment) {
+ try {
+ // Mark deployment as cancelled
+ $activeDeployment->update([
+ 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
+ ]);
+
+ // Add cancellation log entry
+ $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
+
+ // Check if helper container exists and kill it
+ $deployment_uuid = $activeDeployment->deployment_uuid;
+ $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
+ $containerExists = instant_remote_process([$checkCommand], $server);
+
+ if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
+ instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
+ $activeDeployment->addLogEntry('Deployment container stopped.');
+ } else {
+ $activeDeployment->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
+ }
+
+ // Kill running process if process ID exists
+ if ($activeDeployment->current_process_id) {
+ try {
+ $processKillCommand = "kill -9 {$activeDeployment->current_process_id}";
+ instant_remote_process([$processKillCommand], $server);
+ } catch (\Throwable $e) {
+ // Process might already be gone
+ }
+ }
+ } catch (\Throwable $e) {
+ // Silently handle errors during deployment cancellation
+ }
+ }
+
try {
if ($server->isSwarm()) {
instant_remote_process(["docker stack rm {$application->uuid}-{$pull_request_id}"], $server);
@@ -133,7 +178,7 @@ private function deleteApplicationPreview()
}
} catch (\Throwable $e) {
// Log the error but don't fail the job
- ray('Error stopping preview containers: '.$e->getMessage());
+ \Log::warning('Error stopping preview containers for application '.$application->uuid.', PR #'.$pull_request_id.': '.$e->getMessage());
}
// Finally, force delete to trigger resource cleanup
@@ -156,7 +201,6 @@ private function stopPreviewContainers(array $containers, $server, int $timeout
"docker stop --time=$timeout $containerList",
"docker rm -f $containerList",
];
-
instant_remote_process(
command: $commands,
server: $server,
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index a83e6f70a..ce7d6b1b7 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -1000,4 +1000,23 @@ private function updateServiceEnvironmentVariables()
}
}
}
+
+ public function getDetectedPortInfoProperty(): ?array
+ {
+ $detectedPort = $this->application->detectPortFromEnvironment();
+
+ if (! $detectedPort) {
+ return null;
+ }
+
+ $portsExposesArray = $this->application->ports_exposes_array;
+ $isMatch = in_array($detectedPort, $portsExposesArray);
+ $isEmpty = empty($portsExposesArray);
+
+ return [
+ 'port' => $detectedPort,
+ 'matches' => $isMatch,
+ 'isEmpty' => $isEmpty,
+ ];
+ }
}
diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php
index 5231438e5..2c20926a3 100644
--- a/app/Livewire/Project/Application/Heading.php
+++ b/app/Livewire/Project/Application/Heading.php
@@ -94,6 +94,14 @@ public function deploy(bool $force_rebuild = false)
return;
}
+
+ // Reset restart count on deployment
+ $this->application->update([
+ 'restart_count' => 0,
+ 'last_restart_at' => null,
+ 'last_restart_type' => null,
+ ]);
+
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
@@ -137,6 +145,14 @@ public function restart()
return;
}
+
+ // Reset restart count on manual restart
+ $this->application->update([
+ 'restart_count' => 0,
+ 'last_restart_at' => now(),
+ 'last_restart_type' => 'manual',
+ ]);
+
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php
index 85cd21a7f..8a7b6e090 100644
--- a/app/Livewire/Project/Service/StackForm.php
+++ b/app/Livewire/Project/Service/StackForm.php
@@ -5,6 +5,7 @@
use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
use Livewire\Component;
class StackForm extends Component
@@ -22,7 +23,7 @@ class StackForm extends Component
public string $dockerComposeRaw;
- public string $dockerCompose;
+ public ?string $dockerCompose = null;
public ?bool $connectToDockerNetwork = null;
@@ -30,7 +31,7 @@ protected function rules(): array
{
$baseRules = [
'dockerComposeRaw' => 'required',
- 'dockerCompose' => 'required',
+ 'dockerCompose' => 'nullable',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'connectToDockerNetwork' => 'nullable',
@@ -140,18 +141,26 @@ public function submit($notify = true)
$this->validate();
$this->syncData(true);
- // Validate for command injection BEFORE saving to database
+ // Validate for command injection BEFORE any database operations
validateDockerComposeForInjection($this->service->docker_compose_raw);
- $this->service->save();
- $this->service->saveExtraFields($this->fields);
- $this->service->parse();
- $this->service->refresh();
- $this->service->saveComposeConfigs();
+ // Use transaction to ensure atomicity - if parse fails, save is rolled back
+ DB::transaction(function () {
+ $this->service->save();
+ $this->service->saveExtraFields($this->fields);
+ $this->service->parse();
+ $this->service->refresh();
+ $this->service->saveComposeConfigs();
+ });
+
$this->dispatch('refreshEnvs');
$this->dispatch('refreshServices');
$notify && $this->dispatch('success', 'Service saved.');
} catch (\Throwable $e) {
+ // On error, refresh from database to restore clean state
+ $this->service->refresh();
+ $this->syncData(false);
+
return handleError($e, $this);
} finally {
if (is_null($this->service->config_hash)) {
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 615e35f68..5e2aaa347 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -121,6 +121,8 @@ class Application extends BaseModel
protected $casts = [
'http_basic_auth_password' => 'encrypted',
+ 'restart_count' => 'integer',
+ 'last_restart_at' => 'datetime',
];
protected static function booted()
@@ -772,6 +774,24 @@ public function main_port()
return $this->settings->is_static ? [80] : $this->ports_exposes_array;
}
+ public function detectPortFromEnvironment(?bool $isPreview = false): ?int
+ {
+ $envVars = $isPreview
+ ? $this->environment_variables_preview
+ : $this->environment_variables;
+
+ $portVar = $envVars->firstWhere('key', 'PORT');
+
+ if ($portVar && $portVar->real_value) {
+ $portValue = trim($portVar->real_value);
+ if (is_numeric($portValue)) {
+ return (int) $portValue;
+ }
+ }
+
+ return null;
+ }
+
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
diff --git a/app/Models/Service.php b/app/Models/Service.php
index 12d3d6a11..ef755d105 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -1287,6 +1287,11 @@ public function workdir()
public function saveComposeConfigs()
{
+ // Guard against null or empty docker_compose
+ if (! $this->docker_compose) {
+ return;
+ }
+
$workdir = $this->workdir();
instant_remote_process([
diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php
index 4aa5aae8b..58ae5f249 100644
--- a/app/Traits/ExecuteRemoteCommand.php
+++ b/app/Traits/ExecuteRemoteCommand.php
@@ -219,9 +219,22 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
$process_result = $process->wait();
if ($process_result->exitCode() !== 0) {
if (! $ignore_errors) {
+ // Check if deployment was cancelled while command was running
+ if (isset($this->application_deployment_queue)) {
+ $this->application_deployment_queue->refresh();
+ if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
+ throw new \RuntimeException('Deployment cancelled by user', 69420);
+ }
+ }
+
// Don't immediately set to FAILED - let the retry logic handle it
// This prevents premature status changes during retryable SSH errors
- throw new \RuntimeException($process_result->errorOutput());
+ $error = $process_result->errorOutput();
+ if (empty($error)) {
+ $error = $process_result->output() ?: 'Command failed with no error output';
+ }
+ $redactedCommand = $this->redact_sensitive_info($command);
+ throw new \RuntimeException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}");
}
}
}
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 5bccb50f1..c62c2ad8e 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -17,24 +17,44 @@ function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pul
if (! $server->isSwarm()) {
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.applicationId={$id}' --format '{{json .}}' "], $server);
$containers = format_docker_command_output_to_json($containers);
+
$containers = $containers->map(function ($container) use ($pullRequestId, $includePullrequests) {
$labels = data_get($container, 'Labels');
- if (! str($labels)->contains('coolify.pullRequestId=')) {
- data_set($container, 'Labels', $labels.",coolify.pullRequestId={$pullRequestId}");
+ $containerName = data_get($container, 'Names');
+ $hasPrLabel = str($labels)->contains('coolify.pullRequestId=');
+ $prLabelValue = null;
+ if ($hasPrLabel) {
+ preg_match('/coolify\.pullRequestId=(\d+)/', $labels, $matches);
+ $prLabelValue = $matches[1] ?? null;
+ }
+
+ // Treat pullRequestId=0 or missing label as base deployment (convention: 0 = no PR)
+ $isBaseDeploy = ! $hasPrLabel || (int) $prLabelValue === 0;
+
+ // If we're looking for a specific PR and this is a base deployment, exclude it
+ if ($pullRequestId !== null && $pullRequestId !== 0 && $isBaseDeploy) {
+ return null;
+ }
+
+ // If this is a base deployment, include it when not filtering for PRs
+ if ($isBaseDeploy) {
return $container;
}
+
if ($includePullrequests) {
return $container;
}
- if (str($labels)->contains("coolify.pullRequestId=$pullRequestId")) {
+ if ($pullRequestId !== null && $pullRequestId !== 0 && str($labels)->contains("coolify.pullRequestId={$pullRequestId}")) {
return $container;
}
return null;
});
- return $containers->filter();
+ $filtered = $containers->filter();
+
+ return $filtered;
}
return $containers;
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index 1deec45d7..9b17e6810 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -59,11 +59,13 @@ function validateDockerComposeForInjection(string $composeYaml): void
if (isset($volume['source'])) {
$source = $volume['source'];
if (is_string($source)) {
- // Allow simple env vars and env vars with defaults (validated in parseDockerVolumeString)
+ // Allow env vars and env vars with defaults (validated in parseDockerVolumeString)
+ // Also allow env vars followed by safe path concatenation (e.g., ${VAR}/path)
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $source);
+ $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $source);
- if (! $isSimpleEnvVar && ! $isEnvVarWithDefault) {
+ if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($source, 'volume source');
} catch (\Exception $e) {
@@ -310,15 +312,17 @@ function parseDockerVolumeString(string $volumeString): array
// Validate source path for command injection attempts
// We validate the final source value after environment variable processing
if ($source !== null) {
- // Allow simple environment variables like ${VAR_NAME} or ${VAR}
- // but validate everything else for shell metacharacters
+ // Allow environment variables like ${VAR_NAME} or ${VAR}
+ // Also allow env vars followed by safe path concatenation (e.g., ${VAR}/path)
$sourceStr = is_string($source) ? $source : $source;
// Skip validation for simple environment variable references
- // Pattern: ${WORD_CHARS} with no special characters inside
+ // Pattern 1: ${WORD_CHARS} with no special characters inside
+ // Pattern 2: ${WORD_CHARS}/path/to/file (env var with path concatenation)
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr);
+ $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceStr);
- if (! $isSimpleEnvVar) {
+ if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceStr, 'volume source');
} catch (\Exception $e) {
@@ -711,9 +715,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
$sourceValue = $source->value();
- // Allow simple environment variable references
+ // Allow environment variable references and env vars with path concatenation
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
- if (! $isSimpleEnvVar) {
+ $isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue);
+ $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue);
+
+ if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
@@ -1293,6 +1300,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($depends_on->count() > 0) {
$payload['depends_on'] = $depends_on;
}
+ // Auto-inject .env file so Coolify environment variables are available inside containers
+ // This makes Applications behave consistently with manual .env file usage
+ $payload['env_file'] = ['.env'];
if ($isPullRequest) {
$serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
}
@@ -1812,9 +1822,12 @@ function serviceParser(Service $resource): Collection
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
$sourceValue = $source->value();
- // Allow simple environment variable references
+ // Allow environment variable references and env vars with path concatenation
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
- if (! $isSimpleEnvVar) {
+ $isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue);
+ $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue);
+
+ if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
@@ -2269,6 +2282,9 @@ function serviceParser(Service $resource): Collection
if ($depends_on->count() > 0) {
$payload['depends_on'] = $depends_on;
}
+ // Auto-inject .env file so Coolify environment variables are available inside containers
+ // This makes Services behave consistently with Applications
+ $payload['env_file'] = ['.env'];
$parsedServices->put($serviceName, $payload);
}
diff --git a/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php b/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php
new file mode 100644
index 000000000..329ac7af9
--- /dev/null
+++ b/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php
@@ -0,0 +1,30 @@
+integer('restart_count')->default(0)->after('status');
+ $table->timestamp('last_restart_at')->nullable()->after('restart_count');
+ $table->string('last_restart_type', 10)->nullable()->after('last_restart_at');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('applications', function (Blueprint $table) {
+ $table->dropColumn(['restart_count', 'last_restart_at', 'last_restart_type']);
+ });
+ }
+};
diff --git a/docker/development/etc/nginx/site-opts.d/http.conf b/docker/development/etc/nginx/site-opts.d/http.conf
index a5bbd78a3..d7855ae80 100644
--- a/docker/development/etc/nginx/site-opts.d/http.conf
+++ b/docker/development/etc/nginx/site-opts.d/http.conf
@@ -13,6 +13,9 @@ charset utf-8;
# Set max upload to 2048M
client_max_body_size 2048M;
+# Set client body buffer to handle Sentinel payloads in memory
+client_body_buffer_size 256k;
+
# Healthchecks: Set /healthcheck to be the healthcheck URL
location /healthcheck {
access_log off;
diff --git a/docker/production/etc/nginx/site-opts.d/http.conf b/docker/production/etc/nginx/site-opts.d/http.conf
index a5bbd78a3..d7855ae80 100644
--- a/docker/production/etc/nginx/site-opts.d/http.conf
+++ b/docker/production/etc/nginx/site-opts.d/http.conf
@@ -13,6 +13,9 @@ charset utf-8;
# Set max upload to 2048M
client_max_body_size 2048M;
+# Set client body buffer to handle Sentinel payloads in memory
+client_body_buffer_size 256k;
+
# Healthchecks: Set /healthcheck to be the healthcheck URL
location /healthcheck {
access_log off;
diff --git a/public/svgs/postgresus.svg b/public/svgs/postgresus.svg
new file mode 100644
index 000000000..a45e81167
--- /dev/null
+++ b/public/svgs/postgresus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/views/components/status/index.blade.php b/resources/views/components/status/index.blade.php
index d592cff79..57e5409c6 100644
--- a/resources/views/components/status/index.blade.php
+++ b/resources/views/components/status/index.blade.php
@@ -12,6 +12,13 @@
@else