diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php
index e86e30f04..b79709c5a 100644
--- a/app/Actions/Application/StopApplication.php
+++ b/app/Actions/Application/StopApplication.php
@@ -36,10 +36,11 @@ class StopApplication
: getCurrentApplicationContainerStatus($server, $application->id, 0);
$containersToStop = $containers->pluck('Names')->toArray();
+ $timeout = $application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
- "docker stop -t 30 $containerName",
+ "docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php
index bf9fdee72..09de9b628 100644
--- a/app/Actions/Application/StopApplicationOneServer.php
+++ b/app/Actions/Application/StopApplicationOneServer.php
@@ -20,13 +20,15 @@ class StopApplicationOneServer
}
try {
$containers = getCurrentApplicationContainerStatus($server, $application->id, 0);
+ $timeout = $application->settings->stopGracePeriodSeconds();
+
if ($containers->count() > 0) {
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
instant_remote_process(
[
- "docker stop -t 30 $containerName",
+ "docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
],
$server
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 815d6c318..c2be064c4 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -3790,14 +3790,15 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
private function graceful_shutdown_container(string $containerName, bool $skipRemove = false)
{
try {
- $timeout = isDev() ? 1 : 30;
+ $timeout = $this->application->settings->deploymentStopGracePeriodSeconds();
+
if ($skipRemove) {
$this->execute_remote_command(
- ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
+ ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
);
} else {
$this->execute_remote_command(
- ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
+ ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
);
}
diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php
index cf7ef3e0b..618958140 100644
--- a/app/Livewire/Project/Application/Advanced.php
+++ b/app/Livewire/Project/Application/Advanced.php
@@ -4,6 +4,8 @@ namespace App\Livewire\Project\Application;
use App\Models\Application;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -61,6 +63,9 @@ class Advanced extends Component
#[Validate(['string', 'nullable'])]
public ?string $gpuOptions = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $stopGracePeriod = null;
+
#[Validate(['boolean'])]
public bool $isBuildServerEnabled = false;
@@ -145,6 +150,10 @@ class Advanced extends Component
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
}
+
+ // Load stop_grace_period separately since it has its own save handler
+ // Convert null to empty string to prevent dirty detection issues
+ $this->stopGracePeriod = $this->application->settings->stop_grace_period ?? '';
}
private function resetDefaultLabels()
@@ -252,6 +261,31 @@ class Advanced extends Component
}
}
+ public function saveStopGracePeriod()
+ {
+ try {
+ $this->authorize('update', $this->application);
+
+ $validated = Validator::make(
+ ['stopGracePeriod' => $this->stopGracePeriod === '' ? null : $this->stopGracePeriod],
+ ['stopGracePeriod' => ['nullable', 'integer', 'min:'.MIN_STOP_GRACE_PERIOD_SECONDS, 'max:'.MAX_STOP_GRACE_PERIOD_SECONDS]],
+ [],
+ ['stopGracePeriod' => 'stop grace period']
+ )->validate();
+
+ $this->application->settings->stop_grace_period = $validated['stopGracePeriod'] === null
+ ? null
+ : (int) $validated['stopGracePeriod'];
+ $this->application->settings->save();
+
+ $this->dispatch('success', 'Stop grace period updated.');
+ } catch (ValidationException $e) {
+ throw $e;
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
public function render()
{
return view('livewire.project.application.advanced');
diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php
index c887e9b83..59b52f557 100644
--- a/app/Livewire/Project/Application/Previews.php
+++ b/app/Livewire/Project/Application/Previews.php
@@ -338,10 +338,11 @@ class Previews extends Component
private function stopContainers(array $containers, $server)
{
$containersToStop = collect($containers)->pluck('Names')->toArray();
+ $timeout = $this->application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
- "docker stop -t 30 $containerName",
+ "docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php
index 731a9b5da..ef09c0c48 100644
--- a/app/Models/ApplicationSetting.php
+++ b/app/Models/ApplicationSetting.php
@@ -26,6 +26,7 @@ class ApplicationSetting extends Model
'is_git_lfs_enabled' => 'boolean',
'is_git_shallow_clone_enabled' => 'boolean',
'docker_images_to_keep' => 'integer',
+ 'stop_grace_period' => 'integer',
];
protected $fillable = [
@@ -64,8 +65,30 @@ class ApplicationSetting extends Model
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
'docker_images_to_keep',
+ 'stop_grace_period',
];
+ public function stopGracePeriodSeconds(): int
+ {
+ if (
+ $this->stop_grace_period >= MIN_STOP_GRACE_PERIOD_SECONDS &&
+ $this->stop_grace_period <= MAX_STOP_GRACE_PERIOD_SECONDS
+ ) {
+ return $this->stop_grace_period;
+ }
+
+ return DEFAULT_STOP_GRACE_PERIOD_SECONDS;
+ }
+
+ public function deploymentStopGracePeriodSeconds(): int
+ {
+ if (isDev() && $this->stop_grace_period === null) {
+ return MIN_STOP_GRACE_PERIOD_SECONDS;
+ }
+
+ return $this->stopGracePeriodSeconds();
+ }
+
public function isStatic(): Attribute
{
return Attribute::make(
diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php
index 043a3346d..79049e8c7 100644
--- a/bootstrap/helpers/constants.php
+++ b/bootstrap/helpers/constants.php
@@ -35,6 +35,9 @@ const VALID_CRON_STRINGS = [
'@yearly' => '0 0 1 1 *',
];
const RESTART_MODE = 'unless-stopped';
+const DEFAULT_STOP_GRACE_PERIOD_SECONDS = 30;
+const MIN_STOP_GRACE_PERIOD_SECONDS = 1;
+const MAX_STOP_GRACE_PERIOD_SECONDS = 3600;
const DATABASE_DOCKER_IMAGES = [
'bitnami/mariadb',
diff --git a/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php b/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php
new file mode 100644
index 000000000..cc702ce5c
--- /dev/null
+++ b/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php
@@ -0,0 +1,31 @@
+integer('stop_grace_period')
+ ->nullable()
+ ->after('use_build_secrets')
+ ->comment('Seconds to wait for graceful shutdown before forcing container stop (1-3600). Null uses default of 30 seconds.');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('application_settings', function (Blueprint $table) {
+ $table->dropColumn('stop_grace_period');
+ });
+ }
+};
diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php
index a9f8c7233..82ee31933 100644
--- a/resources/views/livewire/project/application/advanced.blade.php
+++ b/resources/views/livewire/project/application/advanced.blade.php
@@ -86,7 +86,21 @@
helper="Readonly labels are disabled. You need to set the labels in the labels section." disabled
instantSave id="isStripprefixEnabled" label="Strip Prefixes" canGate="update" :canResource="$application" />
@endif
-
+
Operations
+
Logs
diff --git a/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php b/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php
new file mode 100644
index 000000000..8d8de1d47
--- /dev/null
+++ b/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php
@@ -0,0 +1,98 @@
+create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+
+ return Application::create([
+ 'name' => 'stop-grace-period-test-app',
+ 'git_repository' => 'https://github.com/coollabsio/coolify',
+ 'git_branch' => 'main',
+ 'build_pack' => 'nixpacks',
+ 'ports_exposes' => '3000',
+ 'environment_id' => $environment->id,
+ 'destination_id' => $server->standaloneDockers()->firstOrFail()->id,
+ 'destination_type' => $server->standaloneDockers()->firstOrFail()->getMorphClass(),
+ ]);
+}
+
+beforeEach(function () {
+ $this->actingAs(User::factory()->create());
+});
+
+it('saves a valid stop grace period', function () {
+ $application = createApplicationForAdvancedStopGracePeriodTest();
+
+ Livewire::test(Advanced::class, ['application' => $application])
+ ->set('stopGracePeriod', '300')
+ ->call('saveStopGracePeriod')
+ ->assertHasNoErrors()
+ ->assertDispatched('success');
+
+ expect($application->settings()->first()->stop_grace_period)->toBe(300);
+});
+
+it('clears the stop grace period when submitted empty', function () {
+ $application = createApplicationForAdvancedStopGracePeriodTest();
+ $application->settings->update(['stop_grace_period' => 300]);
+
+ Livewire::test(Advanced::class, ['application' => $application->fresh()])
+ ->set('stopGracePeriod', '')
+ ->call('saveStopGracePeriod')
+ ->assertHasNoErrors()
+ ->assertDispatched('success');
+
+ expect($application->settings()->first()->stop_grace_period)->toBeNull();
+});
+
+it('rejects invalid stop grace periods', function (string $value, string $rule) {
+ $application = createApplicationForAdvancedStopGracePeriodTest();
+
+ Livewire::test(Advanced::class, ['application' => $application])
+ ->set('stopGracePeriod', $value)
+ ->call('saveStopGracePeriod')
+ ->assertHasErrors(['stopGracePeriod' => [$rule]]);
+
+ expect($application->settings()->first()->stop_grace_period)->toBeNull();
+})->with([
+ 'below minimum' => ['0', 'min'],
+ 'above maximum' => [(string) (MAX_STOP_GRACE_PERIOD_SECONDS + 1), 'max'],
+ 'malformed integer' => ['10abc', 'integer'],
+ 'decimal' => ['1.9', 'integer'],
+]);
+
+it('uses one second deployment timeout in local only when stop grace period is unset', function () {
+ config(['app.env' => 'local']);
+
+ $setting = new ApplicationSetting;
+
+ expect($setting->deploymentStopGracePeriodSeconds())->toBe(MIN_STOP_GRACE_PERIOD_SECONDS);
+
+ $setting->stop_grace_period = 10;
+
+ expect($setting->deploymentStopGracePeriodSeconds())->toBe(10);
+});
+
+it('uses default deployment timeout outside local when stop grace period is unset', function () {
+ config(['app.env' => 'production']);
+
+ $setting = new ApplicationSetting;
+
+ expect($setting->deploymentStopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
+});
diff --git a/tests/Feature/ModelFillableCreationTest.php b/tests/Feature/ModelFillableCreationTest.php
index b72e7381e..e6d340a4f 100644
--- a/tests/Feature/ModelFillableCreationTest.php
+++ b/tests/Feature/ModelFillableCreationTest.php
@@ -299,6 +299,7 @@ it('creates ApplicationSetting with all fillable attributes', function () {
'inject_build_args_to_dockerfile' => true,
'include_source_commit_in_build' => true,
'docker_images_to_keep' => 5,
+ 'stop_grace_period' => 300,
]);
expect($setting->exists)->toBeTrue();
@@ -309,6 +310,7 @@ it('creates ApplicationSetting with all fillable attributes', function () {
expect($setting->custom_internal_name)->toBe('my-custom-app');
expect($setting->is_spa)->toBeTrue();
expect($setting->docker_images_to_keep)->toBe(5);
+ expect($setting->stop_grace_period)->toBe(300);
});
it('creates ServerSetting with all fillable attributes', function () {
diff --git a/tests/Unit/ApplicationSettingStaticCastTest.php b/tests/Unit/ApplicationSettingStaticCastTest.php
index 35ab7faaf..fe0eaec22 100644
--- a/tests/Unit/ApplicationSettingStaticCastTest.php
+++ b/tests/Unit/ApplicationSettingStaticCastTest.php
@@ -11,7 +11,7 @@ use App\Models\ApplicationSetting;
it('casts is_static to boolean when true', function () {
$setting = new ApplicationSetting;
- $setting->is_static = true;
+ $setting->setRawAttributes(['is_static' => true]);
// Verify it's cast to boolean
expect($setting->is_static)->toBeTrue()
@@ -20,7 +20,7 @@ it('casts is_static to boolean when true', function () {
it('casts is_static to boolean when false', function () {
$setting = new ApplicationSetting;
- $setting->is_static = false;
+ $setting->setRawAttributes(['is_static' => false]);
// Verify it's cast to boolean
expect($setting->is_static)->toBeFalse()
@@ -29,7 +29,7 @@ it('casts is_static to boolean when false', function () {
it('casts is_static from string "1" to boolean true', function () {
$setting = new ApplicationSetting;
- $setting->is_static = '1';
+ $setting->setRawAttributes(['is_static' => '1']);
// Should cast string to boolean
expect($setting->is_static)->toBeTrue()
@@ -38,7 +38,7 @@ it('casts is_static from string "1" to boolean true', function () {
it('casts is_static from string "0" to boolean false', function () {
$setting = new ApplicationSetting;
- $setting->is_static = '0';
+ $setting->setRawAttributes(['is_static' => '0']);
// Should cast string to boolean
expect($setting->is_static)->toBeFalse()
@@ -47,7 +47,7 @@ it('casts is_static from string "0" to boolean false', function () {
it('casts is_static from integer 1 to boolean true', function () {
$setting = new ApplicationSetting;
- $setting->is_static = 1;
+ $setting->setRawAttributes(['is_static' => 1]);
// Should cast integer to boolean
expect($setting->is_static)->toBeTrue()
@@ -56,7 +56,7 @@ it('casts is_static from integer 1 to boolean true', function () {
it('casts is_static from integer 0 to boolean false', function () {
$setting = new ApplicationSetting;
- $setting->is_static = 0;
+ $setting->setRawAttributes(['is_static' => 0]);
// Should cast integer to boolean
expect($setting->is_static)->toBeFalse()
@@ -103,3 +103,65 @@ it('casts all boolean fields correctly', function () {
->and($casts[$field])->toBe('boolean');
}
});
+
+it('casts stop_grace_period to integer', function () {
+ $setting = new ApplicationSetting;
+ $casts = $setting->getCasts();
+
+ expect($casts)->toHaveKey('stop_grace_period')
+ ->and($casts['stop_grace_period'])->toBe('integer');
+});
+
+it('handles null stop_grace_period for default behavior', function () {
+ $setting = new ApplicationSetting;
+ $setting->stop_grace_period = null;
+
+ expect($setting->stop_grace_period)->toBeNull();
+});
+
+it('casts stop_grace_period from string to integer', function () {
+ $setting = new ApplicationSetting;
+ $setting->stop_grace_period = '60';
+
+ expect($setting->stop_grace_period)->toBe(60)
+ ->and($setting->stop_grace_period)->toBeInt();
+});
+
+it('casts stop_grace_period zero to integer (documents fallback trigger)', function () {
+ $setting = new ApplicationSetting;
+ $setting->stop_grace_period = 0;
+
+ expect($setting->stop_grace_period)->toBe(0)
+ ->and($setting->stop_grace_period)->toBeInt();
+});
+
+it('casts stop_grace_period negative value to integer (documents fallback trigger)', function () {
+ $setting = new ApplicationSetting;
+ $setting->stop_grace_period = -10;
+
+ expect($setting->stop_grace_period)->toBe(-10)
+ ->and($setting->stop_grace_period)->toBeInt();
+});
+
+it('resolves valid stop grace periods', function (?int $storedValue, int $expectedValue) {
+ $setting = new ApplicationSetting;
+ $setting->stop_grace_period = $storedValue;
+
+ expect($setting->stopGracePeriodSeconds())->toBe($expectedValue);
+})->with([
+ 'minimum' => [MIN_STOP_GRACE_PERIOD_SECONDS, MIN_STOP_GRACE_PERIOD_SECONDS],
+ 'custom' => [300, 300],
+ 'maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS, MAX_STOP_GRACE_PERIOD_SECONDS],
+]);
+
+it('falls back to default stop grace period for invalid stored values', function (?int $storedValue) {
+ $setting = new ApplicationSetting;
+ $setting->stop_grace_period = $storedValue;
+
+ expect($setting->stopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
+})->with([
+ 'null' => [null],
+ 'zero' => [0],
+ 'negative' => [-10],
+ 'above maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS + 1],
+]);