feat(deployments): cancel superseded deployments when a newer one is queued

This commit is contained in:
Andras Bacsai committed 2026-11-03 14:31:52 +01:00
1 parent daf0e1a70c
commit cf1e0e70c5
17 files changed
+409 -21

No files matched your search

+6
View File
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
### Added
- Cancel the older queued webhook deployments of an application when a newer push arrives, so only the latest commit is built. It is on by default and works per application, pull request, and server. Turn it off in **Advanced > Deployment** or with `is_auto_cancel_queued_deployments_enabled` in the applications API. Manual, API, restart, and rollback deployments are never cancelled ([#10921](https://github.com/coollabsio/coolify/discussions/10921), [#7678](https://github.com/coollabsio/coolify/discussions/7678))
- Add an opt-in setting to also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. The applications API sets it with `is_auto_cancel_building_deployments_enabled`
- Add `cancel_previous` (`none`, `queued`, or `building`) to `/api/v1/deploy` and the MCP `deploy` tool, so a deployment from the API or MCP can cancel the older webhook, API, and MCP deployments of the same application, pull request, and server. The default is `none`. Manual, restart, and rollback deployments are never cancelled
### Fixed
- Handle an existing Cloudflare DNS record with a different IP when Coolify adds the DNS record of a new domain automatically. Coolify now replaces the record when it created the record and no other resource uses it. Otherwise it opens the **Configure DNS** dialog, where you can replace the record, and the domain shows the conflict instead of "Not checked"
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
/**
* Which older deployments a new deployment cancels. A building deployment is cancelled only before it
* starts to replace containers, and cancelling builds also cancels queued deployments.
*/
enum CancelPreviousDeployments: string
{
case NONE = 'none';
case QUEUED = 'queued';
case BUILDING = 'building';
}
@@ -50,6 +50,8 @@ class ApplicationsController extends Controller
'is_git_submodules_enabled',
'is_git_lfs_enabled',
'is_git_shallow_clone_enabled',
'is_auto_cancel_queued_deployments_enabled',
'is_auto_cancel_building_deployments_enabled',
'disable_build_cache',
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
@@ -81,6 +83,8 @@ class ApplicationsController extends Controller
'is_git_submodules_enabled',
'is_git_lfs_enabled',
'is_git_shallow_clone_enabled',
'is_auto_cancel_queued_deployments_enabled',
'is_auto_cancel_building_deployments_enabled',
'disable_build_cache',
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
@@ -464,6 +468,8 @@ class ApplicationsController extends Controller
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'is_auto_cancel_queued_deployments_enabled' => ['type' => 'boolean', 'description' => 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'],
'is_auto_cancel_building_deployments_enabled' => ['type' => 'boolean', 'description' => 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
@@ -674,6 +680,8 @@ class ApplicationsController extends Controller
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'is_auto_cancel_queued_deployments_enabled' => ['type' => 'boolean', 'description' => 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'],
'is_auto_cancel_building_deployments_enabled' => ['type' => 'boolean', 'description' => 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
@@ -884,6 +892,8 @@ class ApplicationsController extends Controller
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'is_auto_cancel_queued_deployments_enabled' => ['type' => 'boolean', 'description' => 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'],
'is_auto_cancel_building_deployments_enabled' => ['type' => 'boolean', 'description' => 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
@@ -1064,6 +1074,8 @@ class ApplicationsController extends Controller
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'is_auto_cancel_queued_deployments_enabled' => ['type' => 'boolean', 'description' => 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'],
'is_auto_cancel_building_deployments_enabled' => ['type' => 'boolean', 'description' => 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
@@ -1240,6 +1252,8 @@ class ApplicationsController extends Controller
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'is_auto_cancel_queued_deployments_enabled' => ['type' => 'boolean', 'description' => 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'],
'is_auto_cancel_building_deployments_enabled' => ['type' => 'boolean', 'description' => 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
@@ -3294,6 +3308,8 @@ class ApplicationsController extends Controller
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
'is_auto_cancel_queued_deployments_enabled' => ['type' => 'boolean', 'description' => 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'],
'is_auto_cancel_building_deployments_enabled' => ['type' => 'boolean', 'description' => 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'],
'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'],
'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'],
+14 -7
View File
@@ -6,6 +6,7 @@ use App\Actions\Application\CleanupCancelledDeployment;
use App\Actions\Database\StartDatabase;
use App\Actions\Service\StartService;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\CancelPreviousDeployments;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
@@ -328,6 +329,7 @@ class DeployController extends Controller
new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer', maximum: 2147483647)),
new OA\Parameter(name: 'pull_request_id', in: 'query', description: 'Preview deployment identifier. Alias of pr.', schema: new OA\Schema(type: 'integer', maximum: 2147483647)),
new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'cancel_previous', in: 'query', description: 'Cancel the older webhook, API, and MCP deployments of the same application, pull request, and server. `queued` cancels the ones that wait in the queue. `building` also stops the ones that are still building; a deployment that already started to replace containers always finishes. Manual, restart, and rollback deployments are never cancelled. Default: `none`.', schema: new OA\Schema(type: 'string', enum: ['none', 'queued', 'building'], default: 'none')),
],
responses: [
@@ -381,7 +383,11 @@ class DeployController extends Controller
$pullRequestId = $request->input('pull_request_id', $request->input('pr'));
$pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0;
$dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
$cancelPrevious = CancelPreviousDeployments::tryFrom($request->string('cancel_previous', 'none')->trim()->lower()->value());
if (! $cancelPrevious) {
return response()->json(['message' => 'cancel_previous must be one of: none, queued, building.'], 400);
}
if ($pr > 2147483647) {
return response()->json(['message' => 'pull_request_id must be between 1 and 2147483647.'], 400);
}
@@ -398,15 +404,15 @@ class DeployController extends Controller
return response()->json(['message' => 'You can only use tag or docker_tag, not both.'], 400);
}
if ($tags) {
return $this->by_tags($tags, $teamId, $force);
return $this->by_tags($tags, $teamId, $force, $cancelPrevious);
} elseif ($uuids) {
return $this->by_uuids($uuids, $teamId, $force, $pr, $dockerTag);
return $this->by_uuids($uuids, $teamId, $force, $pr, $dockerTag, $cancelPrevious);
}
return response()->json(['message' => 'You must provide uuid or tag.'], 400);
}
private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0, ?string $dockerTag = null)
private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0, ?string $dockerTag = null, CancelPreviousDeployments $cancelPrevious = CancelPreviousDeployments::NONE)
{
$uuids = explode(',', $uuid);
$uuids = collect(array_filter($uuids));
@@ -434,7 +440,7 @@ class DeployController extends Controller
continue;
}
}
$result = $this->deploy_resource($resource, $force, $pr, $dockerTagForResource, $preview?->git_type);
$result = $this->deploy_resource($resource, $force, $pr, $dockerTagForResource, $preview?->git_type, $cancelPrevious);
if (isset($result['status']) && $result['status'] === 429) {
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
}
@@ -455,7 +461,7 @@ class DeployController extends Controller
return response()->json(['message' => 'No resources found.'], 404);
}
public function by_tags(string $tags, int $team_id, bool $force = false)
public function by_tags(string $tags, int $team_id, bool $force = false, CancelPreviousDeployments $cancelPrevious = CancelPreviousDeployments::NONE)
{
$tags = explode(',', $tags);
$tags = collect(array_filter($tags));
@@ -480,7 +486,7 @@ class DeployController extends Controller
continue;
}
foreach ($applications as $resource) {
$result = $this->deploy_resource($resource, $force);
$result = $this->deploy_resource($resource, $force, cancelPrevious: $cancelPrevious);
if (isset($result['status']) && $result['status'] === 429) {
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
}
@@ -507,7 +513,7 @@ class DeployController extends Controller
return response()->json(['message' => 'No resources found with this tag.'], 404);
}
public function deploy_resource($resource, bool $force = false, int $pr = 0, ?string $dockerTag = null, ?string $gitType = null): array
public function deploy_resource($resource, bool $force = false, int $pr = 0, ?string $dockerTag = null, ?string $gitType = null, CancelPreviousDeployments $cancelPrevious = CancelPreviousDeployments::NONE): array
{
$message = null;
$deployment_uuid = null;
@@ -534,6 +540,7 @@ class DeployController extends Controller
is_api: true,
git_type: $gitType,
docker_registry_image_tag: $dockerTag,
cancel_previous: $cancelPrevious,
);
if ($result['status'] === 'queue_full') {
return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429];
+32 -5
View File
@@ -385,10 +385,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
throw $e;
}
$this->application_deployment_queue->update([
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'horizon_job_worker' => gethostname(),
]);
// A conditional update, so a cancel that came after the check above is not overwritten.
ApplicationDeploymentQueue::query()
->whereKey($this->application_deployment_queue->getKey())
->where('status', '!=', ApplicationDeploymentStatus::CANCELLED_BY_USER->value)
->update([
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
'horizon_job_worker' => gethostname(),
]);
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
$this->application_deployment_queue->addLogEntry('Deployment was cancelled before starting.');
$this->handleCancelledDeployment();
return;
}
if ($this->server->isFunctionalAfterRecheck() === false) {
$this->application_deployment_queue->addLogEntry('Server is not functional.');
$this->fail('Server is not functional.');
@@ -893,6 +904,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->pull_docker_compose_images();
$this->startDeployPhase();
$this->stop_running_container(force: true);
$this->application_deployment_queue->addLogEntry('Starting new application.');
$networkId = $this->application->uuid;
@@ -2894,7 +2906,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function rolling_update()
{
try {
$this->checkForCancellation();
$this->startDeployPhase();
$this->write_compose_file_to_workdir();
if ($this->server->isSwarm()) {
$this->application_deployment_queue->addLogEntry('Rolling update started.');
@@ -6312,6 +6324,21 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
}
/**
* Marks that the deployment starts to change containers. From here on, a newer webhook deployment
* does not cancel it, so the application is never left half deployed. The update is conditional,
* so it cannot race with that cancel: exactly one of them wins.
*/
private function startDeployPhase(): void
{
ApplicationDeploymentQueue::query()
->whereKey($this->application_deployment_queue->getKey())
->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
->whereNull('deploy_started_at')
->update(['deploy_started_at' => Carbon::now()]);
$this->checkForCancellation();
}
/**
* Transition deployment to a new status with proper validation and side effects.
* This is the single source of truth for status transitions.
@@ -0,0 +1,43 @@
<?php
namespace App\Jobs;
use App\Actions\Application\CleanupCancelledDeployment;
use App\Models\ApplicationDeploymentQueue;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
/**
* Stops the build of a running deployment that a newer webhook deployment replaced, then starts the
* queued deployments. It runs on the queue, because one push can replace builds of many applications
* and the webhook request must answer fast.
*/
class CancelSupersededDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public function __construct(public ApplicationDeploymentQueue $deployment, public int $teamId)
{
$this->onQueue(deployment_queue());
}
public function handle(): void
{
try {
CleanupCancelledDeployment::run($this->deployment, $this->teamId);
} catch (Throwable $e) {
\Log::warning("Failed to clean up superseded deployment {$this->deployment->deployment_uuid}: {$e->getMessage()}");
} finally {
if ($this->deployment->application) {
queue_next_deployment($this->deployment->application, $this->deployment->server_id);
}
}
}
}
@@ -38,6 +38,12 @@ class Advanced extends Component
#[Validate(['boolean'])]
public bool $isAutoDeployEnabled = true;
#[Validate(['boolean'])]
public bool $isAutoCancelQueuedDeploymentsEnabled = true;
#[Validate(['boolean'])]
public bool $isAutoCancelBuildingDeploymentsEnabled = false;
#[Validate(['boolean'])]
public bool $disableBuildCache = false;
@@ -121,6 +127,10 @@ class Advanced extends Component
$this->application->settings->is_git_lfs_enabled = $this->isGitLfsEnabled;
$this->application->settings->is_git_shallow_clone_enabled = $this->isGitShallowCloneEnabled;
$this->application->settings->is_auto_deploy_enabled = $this->isAutoDeployEnabled;
// Stopping older builds also cancels older queued deployments.
$this->isAutoCancelQueuedDeploymentsEnabled = $this->isAutoCancelQueuedDeploymentsEnabled || $this->isAutoCancelBuildingDeploymentsEnabled;
$this->application->settings->is_auto_cancel_queued_deployments_enabled = $this->isAutoCancelQueuedDeploymentsEnabled;
$this->application->settings->is_auto_cancel_building_deployments_enabled = $this->isAutoCancelBuildingDeploymentsEnabled;
$this->application->settings->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->application->settings->is_gpu_enabled = $this->isGpuEnabled;
$this->application->settings->gpu_driver = $this->gpuDriver;
@@ -152,6 +162,8 @@ class Advanced extends Component
$this->isGitLfsEnabled = $this->application->settings->is_git_lfs_enabled;
$this->isGitShallowCloneEnabled = $this->application->settings->is_git_shallow_clone_enabled ?? false;
$this->isAutoDeployEnabled = $this->application->settings->is_auto_deploy_enabled;
$this->isAutoCancelQueuedDeploymentsEnabled = $this->application->settings->is_auto_cancel_queued_deployments_enabled ?? true;
$this->isAutoCancelBuildingDeploymentsEnabled = $this->application->settings->is_auto_cancel_building_deployments_enabled ?? false;
$this->isGpuEnabled = $this->application->settings->is_gpu_enabled;
$this->gpuDriver = $this->application->settings->gpu_driver;
$this->gpuCount = $this->application->settings->gpu_count;
+11 -4
View File
@@ -2,6 +2,7 @@
namespace App\Mcp\Tools;
use App\Enums\CancelPreviousDeployments;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\CallsRestApi;
use App\Mcp\Concerns\ChecksTokenAbilities;
@@ -20,7 +21,7 @@ class Deploy extends Tool
{
protected string $name = 'deploy';
protected string $description = 'Queue a deployment for a team-owned application by UUID, or deploy every resource of the authenticated team that has a tag (tag + confirm=true). Give exactly one of uuid or tag. Requires deploy ability. Optional force rebuild and pull_request_id for previews (uuid only).';
protected string $description = 'Queue a deployment for a team-owned application by UUID, or deploy every resource of the authenticated team that has a tag (tag + confirm=true). Give exactly one of uuid or tag. Requires deploy ability. Optional force rebuild, pull_request_id for previews (uuid only), and cancel_previous to cancel older queued or building deployments.';
use BuildsResponse;
use CallsRestApi;
@@ -50,8 +51,12 @@ class Deploy extends Tool
if ($hasUuid === $hasTag) {
return $this->mcpError($request, 'Give exactly one of uuid or tag.');
}
$cancelPrevious = CancelPreviousDeployments::tryFrom(strtolower(trim((string) ($request->get('cancel_previous') ?? 'none'))));
if (! $cancelPrevious) {
return $this->mcpError($request, 'cancel_previous must be one of: none, queued, building.');
}
if ($hasTag) {
return $this->deployByTag($request, $teamId, $tag);
return $this->deployByTag($request, $teamId, $tag, $cancelPrevious);
}
if (! is_string($uuid)) {
return $this->mcpError($request, 'uuid argument is required.');
@@ -76,6 +81,7 @@ class Deploy extends Tool
force_rebuild: $force,
is_mcp: true,
no_questions_asked: true,
cancel_previous: $cancelPrevious,
);
if (($result['status'] ?? null) === 'skipped' || ($result['status'] ?? null) === 'queue_full') {
@@ -110,7 +116,7 @@ class Deploy extends Tool
/**
* Deploys every resource of the token team with the tag through the REST deploy route.
*/
private function deployByTag(Request $request, int $teamId, mixed $tag): Response
private function deployByTag(Request $request, int $teamId, mixed $tag, CancelPreviousDeployments $cancelPrevious): Response
{
if (! is_string($tag)) {
return $this->mcpError($request, 'tag must be a string.');
@@ -125,7 +131,7 @@ class Deploy extends Tool
$force = filter_var($request->get('force'), FILTER_VALIDATE_BOOLEAN);
try {
$result = $this->callRestApi('POST', 'deploy', [], ['tag' => $tag, 'force' => $force]);
$result = $this->callRestApi('POST', 'deploy', [], ['tag' => $tag, 'force' => $force, 'cancel_previous' => $cancelPrevious->value]);
} catch (\InvalidArgumentException $e) {
return $this->mcpError($request, $e->getMessage(), ['tag' => $tag]);
}
@@ -159,6 +165,7 @@ class Deploy extends Tool
'force' => $schema->boolean()->description('Force rebuild (default false).'),
'pull_request_id' => $schema->integer()->description('Optional PR id for preview deploy (uuid only).'),
'confirm' => $schema->boolean()->description('Required true when deploying by tag.'),
'cancel_previous' => $schema->string()->enum(['none', 'queued', 'building'])->description('Cancel older webhook, API, and MCP deployments of the same application, pull request, and server: queued ones, or also ones still building. A deployment that started to replace containers always finishes. Manual, restart, and rollback deployments are never cancelled. Default none.'),
];
}
}
@@ -155,6 +155,7 @@ class ApplicationDeploymentQueue extends Model
'horizon_job_id',
'horizon_job_worker',
'finished_at',
'deploy_started_at',
];
/**
@@ -174,6 +175,7 @@ class ApplicationDeploymentQueue extends Model
protected $casts = [
'pull_request_id' => 'integer',
'finished_at' => 'datetime',
'deploy_started_at' => 'datetime',
'configuration_snapshot' => EncryptedArrayCast::class,
'configuration_diff' => EncryptedArrayCast::class,
];
+4
View File
@@ -101,6 +101,8 @@ class ApplicationSetting extends Model
'include_coolify_default_variables' => 'boolean',
'redact_all_env_values_in_logs' => 'boolean',
'disable_build_cache' => 'boolean',
'is_auto_cancel_queued_deployments_enabled' => 'boolean',
'is_auto_cancel_building_deployments_enabled' => 'boolean',
];
protected $fillable = [
@@ -146,6 +148,8 @@ class ApplicationSetting extends Model
'docker_images_to_keep',
'stop_grace_period',
'rolling_update_drain_seconds',
'is_auto_cancel_queued_deployments_enabled',
'is_auto_cancel_building_deployments_enabled',
];
/**
+4
View File
@@ -237,6 +237,8 @@ function sharedDataApplications(?Application $application = null): array
'is_git_submodules_enabled' => 'boolean',
'is_git_lfs_enabled' => 'boolean',
'is_git_shallow_clone_enabled' => 'boolean',
'is_auto_cancel_queued_deployments_enabled' => 'boolean',
'is_auto_cancel_building_deployments_enabled' => 'boolean',
'disable_build_cache' => 'boolean',
'inject_build_args_to_dockerfile' => 'boolean',
'include_source_commit_in_build' => 'boolean',
@@ -522,6 +524,8 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('is_git_submodules_enabled');
$request->offsetUnset('is_git_lfs_enabled');
$request->offsetUnset('is_git_shallow_clone_enabled');
$request->offsetUnset('is_auto_cancel_queued_deployments_enabled');
$request->offsetUnset('is_auto_cancel_building_deployments_enabled');
$request->offsetUnset('disable_build_cache');
$request->offsetUnset('inject_build_args_to_dockerfile');
$request->offsetUnset('is_env_sorting_enabled');
+90 -4
View File
@@ -2,7 +2,9 @@
use App\Actions\Shared\CloneVolumeDataWithSourceStopped;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\CancelPreviousDeployments;
use App\Jobs\ApplicationDeploymentJob;
use App\Jobs\CancelSupersededDeploymentJob;
use App\Jobs\VolumeCloneJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
@@ -12,11 +14,12 @@ use App\Models\EnvironmentVariable;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Notifications\Application\DeploymentFailed;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Spatie\Url\Url;
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null, ?string $parent_deployment_uuid = null, bool $is_mcp = false)
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null, ?string $parent_deployment_uuid = null, bool $is_mcp = false, ?CancelPreviousDeployments $cancel_previous = null)
{
$commit = $commit ?: ($application->git_commit_sha ?: 'HEAD');
$commit = validateGitRef($commit, 'deployment commit');
@@ -39,7 +42,7 @@ function queue_application_deployment(Application $application, string $deployme
$is_mcp = $is_mcp || request()->attributes->get('mcp_rest_bridge') === true;
$is_api = $is_api && ! $is_mcp;
$admission = DB::transaction(function () use ($application, $application_id, $commit, $deployment_uuid, $deployment_url, $destination_id, $docker_registry_image_tag, $force_rebuild, $git_type, $is_api, $is_mcp, $is_webhook, $no_questions_asked, $only_this_server, $parent_deployment_uuid, $pull_request_id, $restart_only, $rollback, $server_id, $server_name) {
$admission = DB::transaction(function () use ($application, $application_id, $cancel_previous, $commit, $deployment_uuid, $deployment_url, $destination_id, $docker_registry_image_tag, $force_rebuild, $git_type, $is_api, $is_mcp, $is_webhook, $no_questions_asked, $only_this_server, $parent_deployment_uuid, $pull_request_id, $restart_only, $rollback, $server_id, $server_name) {
// Lock stable rows because an empty deployment queue has no row to lock.
Application::query()->whereKey($application_id)->lockForUpdate()->firstOrFail();
$serverForQueueCheck = Server::query()->whereKey($server_id)->lockForUpdate()->firstOrFail();
@@ -93,16 +96,19 @@ function queue_application_deployment(Application $application, string $deployme
'parent_deployment_uuid' => $parent_deployment_uuid,
]);
$superseded = cancel_superseded_deployments($deployment, $application, $cancel_previous);
// Decide whether to start while the application and server rows are still locked,
// so concurrent requests cannot both see an idle queue and start two deployments.
$started = $no_questions_asked || next_queuable($server_id, $application_id, $commit, $pull_request_id);
// After a cancelled build, CancelSupersededDeploymentJob starts it once the build is stopped.
$started = $superseded['building']->isEmpty() && ($no_questions_asked || next_queuable($server_id, $application_id, $commit, $pull_request_id));
if ($started) {
$deployment->update([
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
}
return ['deployment' => $deployment, 'started' => $started];
return ['deployment' => $deployment, 'started' => $started, 'superseded' => $superseded];
});
if (! isset($admission['deployment'])) {
@@ -111,6 +117,21 @@ function queue_application_deployment(Application $application, string $deployme
$deployment = $admission['deployment'];
foreach ($admission['superseded'] as $kind => $cancelled) {
foreach ($cancelled as $old) {
$old->addLogEntry("Deployment cancelled: the newer deployment {$deployment_uuid} (commit {$commit}) replaces it.", 'stderr');
if ($kind !== 'building') {
continue;
}
try {
CancelSupersededDeploymentJob::dispatch($old, (int) $application->team()->id);
} catch (Throwable $e) {
Log::warning("Failed to dispatch the cleanup of superseded deployment {$old->deployment_uuid}: {$e->getMessage()}");
queue_next_deployment($application, $old->server_id);
}
}
}
if (auth()->check() && ! $is_webhook && ! $is_api && ! $is_mcp && ! $rollback) {
auditLog($restart_only ? 'ui.application.restarted' : 'ui.application.deployed', [
'application_uuid' => $application->uuid,
@@ -138,6 +159,71 @@ function queue_application_deployment(Application $application, string $deployme
'deployment_uuid' => $deployment_uuid,
];
}
/**
* A new deployment can replace older deployments of the same application, pull request, and server:
* - A webhook deployment replaces older webhook deployments, as the application settings allow.
* - An API or MCP deployment replaces older webhook, API, and MCP deployments, as its caller asks
* with $cancelPrevious. Without it, nothing is cancelled, so callers that wait for their
* deployment keep working.
*
* A running deployment is cancelled only before it starts to deploy containers, so a cancel never
* leaves an application half deployed. Manual, restart, and rollback deployments are never cancelled,
* and restarts and rollbacks never cancel others.
*
* Call it in the admission transaction, after the new deployment is created.
*
* @return array{queued: Collection<int, ApplicationDeploymentQueue>, building: Collection<int, ApplicationDeploymentQueue>}
*/
function cancel_superseded_deployments(ApplicationDeploymentQueue $deployment, Application $application, ?CancelPreviousDeployments $cancelPrevious = null): array
{
$none = ['queued' => collect(), 'building' => collect()];
if ($deployment->rollback || $deployment->restart_only) {
return $none;
}
$settings = $application->settings;
$fromWebhook = (bool) $deployment->is_webhook;
$mode = match (true) {
$fromWebhook && (bool) $settings?->is_auto_cancel_building_deployments_enabled => CancelPreviousDeployments::BUILDING,
$fromWebhook && (bool) $settings?->is_auto_cancel_queued_deployments_enabled => CancelPreviousDeployments::QUEUED,
(bool) $deployment->is_api || (bool) $deployment->is_mcp => $cancelPrevious ?? CancelPreviousDeployments::NONE,
default => CancelPreviousDeployments::NONE,
};
if ($mode === CancelPreviousDeployments::NONE) {
return $none;
}
$cancelBuilding = $mode === CancelPreviousDeployments::BUILDING;
$candidates = ApplicationDeploymentQueue::query()
->where('application_id', $deployment->application_id)
->where('pull_request_id', $deployment->pull_request_id)
->where('server_id', $deployment->server_id)
->where(fn ($query) => $fromWebhook
? $query->where('is_webhook', true)
: $query->where('is_webhook', true)->orWhere('is_api', true)->orWhere('is_mcp', true))
->where('rollback', false)
->where('restart_only', false)
->whereKeyNot($deployment->getKey())
->where(function ($query) use ($cancelBuilding) {
$query->where('status', ApplicationDeploymentStatus::QUEUED->value);
if ($cancelBuilding) {
$query->orWhere(fn ($query) => $query
->where('status', ApplicationDeploymentStatus::IN_PROGRESS->value)
->whereNull('deploy_started_at'));
}
})
->get();
// A conditional update, so a build that starts to deploy at the same moment is never cancelled.
$cancelled = $candidates->filter(fn (ApplicationDeploymentQueue $old) => ApplicationDeploymentQueue::query()
->whereKey($old->getKey())
->where('status', $old->status)
->whereNull('deploy_started_at')
->update(['status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value]) === 1);
[$building, $queued] = $cancelled->partition(fn (ApplicationDeploymentQueue $old) => $old->status === ApplicationDeploymentStatus::IN_PROGRESS->value);
$cancelled->each(fn (ApplicationDeploymentQueue $old) => $old->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value);
return ['queued' => $queued->values(), 'building' => $building->values()];
}
/**
* Start a queued deployment right away, ignoring the per-application and per-server
* concurrency limits. It returns false without dispatching when the deployment is no
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('application_settings', function (Blueprint $table) {
if (! Schema::hasColumn('application_settings', 'is_auto_cancel_queued_deployments_enabled')) {
$table->boolean('is_auto_cancel_queued_deployments_enabled')->default(true);
}
if (! Schema::hasColumn('application_settings', 'is_auto_cancel_building_deployments_enabled')) {
$table->boolean('is_auto_cancel_building_deployments_enabled')->default(false);
}
});
if (! Schema::hasColumn('application_deployment_queues', 'deploy_started_at')) {
Schema::table('application_deployment_queues', function (Blueprint $table) {
$table->timestamp('deploy_started_at')->nullable();
});
}
}
public function down(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->dropColumn(['is_auto_cancel_queued_deployments_enabled', 'is_auto_cancel_building_deployments_enabled']);
});
Schema::table('application_deployment_queues', function (Blueprint $table) {
$table->dropColumn('deploy_started_at');
});
}
};
+1 -1
View File
@@ -96,7 +96,7 @@ When you add a model or a setting to the transfer, add it to this file too.
| ✅ | Limits | memory, swap, swappiness, reservation, CPUs, cpuset, CPU shares |
| ➕ | Advanced | custom labels, custom docker run options, pre/post deployment command + container, manual webhook secrets |
| ✅ | Maintenance | maintenance mode on + custom maintenance page |
| ✅ | All application settings that are not the default | git submodules, LFS, shallow clone, auto deploy off, force HTTPS, debug, preview deployments on, PR deployments public, log drain, GPU (driver, count, device IDs, options), include timestamps, swarm-only worker nodes, build server, consistent container name, gzip off, strip prefix off, connect to Docker network, custom internal name, container label escape off, env sorting, read-only labels, preserve repository, disable build cache, use build secrets, stop grace period 30, rolling update drain time 12, inject build args off, include source commit, Docker images to keep 5, custom container name prefix, redact env values in logs, Coolify default variables off, Traefik certificate resolver |
| ✅ | All application settings that are not the default | git submodules, LFS, shallow clone, auto deploy off, force HTTPS, debug, preview deployments on, PR deployments public, log drain, GPU (driver, count, device IDs, options), include timestamps, swarm-only worker nodes, build server, consistent container name, gzip off, strip prefix off, connect to Docker network, custom internal name, container label escape off, env sorting, read-only labels, preserve repository, disable build cache, use build secrets, stop grace period 30, rolling update drain time 12, inject build args off, include source commit, Docker images to keep 5, custom container name prefix, redact env values in logs, Coolify default variables off, Traefik certificate resolver, auto-cancel queued webhook deployments off, auto-cancel building webhook deployments on |
| ✅ | Environment variables | normal, multiline, literal, shown once, build time only, runtime only, required, with comment, preview-only, and references to `{{team.*}}`, `{{project.*}}`, `{{environment.*}}`, `{{server.*}}` |
| ✅ | Persistent volume | the seed has one; add a host-path bind mount and preview suffix on |
| ✅ | File mount + directory mount | content, chown, chmod |
+62
View File
@@ -826,6 +826,14 @@
"type": "boolean",
"description": "Use a shallow Git clone."
},
"is_auto_cancel_queued_deployments_enabled": {
"type": "boolean",
"description": "Cancel older queued webhook deployments when a newer push arrives. Default: true."
},
"is_auto_cancel_building_deployments_enabled": {
"type": "boolean",
"description": "Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false."
},
"disable_build_cache": {
"type": "boolean",
"description": "Disable the build cache."
@@ -1482,6 +1490,14 @@
"type": "boolean",
"description": "Use a shallow Git clone."
},
"is_auto_cancel_queued_deployments_enabled": {
"type": "boolean",
"description": "Cancel older queued webhook deployments when a newer push arrives. Default: true."
},
"is_auto_cancel_building_deployments_enabled": {
"type": "boolean",
"description": "Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false."
},
"disable_build_cache": {
"type": "boolean",
"description": "Disable the build cache."
@@ -2138,6 +2154,14 @@
"type": "boolean",
"description": "Use a shallow Git clone."
},
"is_auto_cancel_queued_deployments_enabled": {
"type": "boolean",
"description": "Cancel older queued webhook deployments when a newer push arrives. Default: true."
},
"is_auto_cancel_building_deployments_enabled": {
"type": "boolean",
"description": "Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false."
},
"disable_build_cache": {
"type": "boolean",
"description": "Disable the build cache."
@@ -2684,6 +2708,14 @@
"type": "boolean",
"description": "Use a shallow Git clone."
},
"is_auto_cancel_queued_deployments_enabled": {
"type": "boolean",
"description": "Cancel older queued webhook deployments when a newer push arrives. Default: true."
},
"is_auto_cancel_building_deployments_enabled": {
"type": "boolean",
"description": "Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false."
},
"disable_build_cache": {
"type": "boolean",
"description": "Disable the build cache."
@@ -3210,6 +3242,14 @@
"type": "boolean",
"description": "Use a shallow Git clone."
},
"is_auto_cancel_queued_deployments_enabled": {
"type": "boolean",
"description": "Cancel older queued webhook deployments when a newer push arrives. Default: true."
},
"is_auto_cancel_building_deployments_enabled": {
"type": "boolean",
"description": "Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false."
},
"disable_build_cache": {
"type": "boolean",
"description": "Disable the build cache."
@@ -4011,6 +4051,14 @@
"type": "boolean",
"description": "Use a shallow Git clone."
},
"is_auto_cancel_queued_deployments_enabled": {
"type": "boolean",
"description": "Cancel older queued webhook deployments when a newer push arrives. Default: true."
},
"is_auto_cancel_building_deployments_enabled": {
"type": "boolean",
"description": "Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false."
},
"disable_build_cache": {
"type": "boolean",
"description": "Disable the build cache."
@@ -10859,6 +10907,20 @@
"schema": {
"type": "string"
}
},
{
"name": "cancel_previous",
"in": "query",
"description": "Cancel the older webhook, API, and MCP deployments of the same application, pull request, and server. `queued` cancels the ones that wait in the queue. `building` also stops the ones that are still building; a deployment that already started to replace containers always finishes. Manual, restart, and rollback deployments are never cancelled. Default: `none`.",
"schema": {
"type": "string",
"default": "none",
"enum": [
"none",
"queued",
"building"
]
}
}
],
"responses": {
+47
View File
@@ -533,6 +533,12 @@ paths:
is_git_shallow_clone_enabled:
type: boolean
description: 'Use a shallow Git clone.'
is_auto_cancel_queued_deployments_enabled:
type: boolean
description: 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'
is_auto_cancel_building_deployments_enabled:
type: boolean
description: 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'
disable_build_cache:
type: boolean
description: 'Disable the build cache.'
@@ -969,6 +975,12 @@ paths:
is_git_shallow_clone_enabled:
type: boolean
description: 'Use a shallow Git clone.'
is_auto_cancel_queued_deployments_enabled:
type: boolean
description: 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'
is_auto_cancel_building_deployments_enabled:
type: boolean
description: 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'
disable_build_cache:
type: boolean
description: 'Disable the build cache.'
@@ -1405,6 +1417,12 @@ paths:
is_git_shallow_clone_enabled:
type: boolean
description: 'Use a shallow Git clone.'
is_auto_cancel_queued_deployments_enabled:
type: boolean
description: 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'
is_auto_cancel_building_deployments_enabled:
type: boolean
description: 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'
disable_build_cache:
type: boolean
description: 'Disable the build cache.'
@@ -1778,6 +1796,12 @@ paths:
is_git_shallow_clone_enabled:
type: boolean
description: 'Use a shallow Git clone.'
is_auto_cancel_queued_deployments_enabled:
type: boolean
description: 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'
is_auto_cancel_building_deployments_enabled:
type: boolean
description: 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'
disable_build_cache:
type: boolean
description: 'Disable the build cache.'
@@ -2137,6 +2161,12 @@ paths:
is_git_shallow_clone_enabled:
type: boolean
description: 'Use a shallow Git clone.'
is_auto_cancel_queued_deployments_enabled:
type: boolean
description: 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'
is_auto_cancel_building_deployments_enabled:
type: boolean
description: 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'
disable_build_cache:
type: boolean
description: 'Disable the build cache.'
@@ -2673,6 +2703,12 @@ paths:
is_git_shallow_clone_enabled:
type: boolean
description: 'Use a shallow Git clone.'
is_auto_cancel_queued_deployments_enabled:
type: boolean
description: 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'
is_auto_cancel_building_deployments_enabled:
type: boolean
description: 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'
disable_build_cache:
type: boolean
description: 'Disable the build cache.'
@@ -7105,6 +7141,17 @@ paths:
description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.'
schema:
type: string
-
name: cancel_previous
in: query
description: 'Cancel the older webhook, API, and MCP deployments of the same application, pull request, and server. `queued` cancels the ones that wait in the queue. `building` also stops the ones that are still building; a deployment that already started to replace containers always finishes. Manual, restart, and rollback deployments are never cancelled. Default: `none`.'
schema:
type: string
default: none
enum:
- none
- queued
- building
responses:
'200':
description: "Get deployment(s) UUID's"
@@ -43,6 +43,20 @@
['value' => true, 'label' => 'Deploy on push (webhooks)'],
['value' => false, 'label' => 'Manual deployments only'],
]" :disabled="! $canUpdate" />
<x-forms.listbox id="isAutoCancelQueuedDeploymentsEnabled" label="Queued webhook deployments"
onChange="instantSave"
helper="When a newer push arrives, cancel the older webhook deployments that still wait in the queue, so only the latest commit is built. Manual, API, restart, and rollback deployments are never cancelled."
:options="[
['value' => true, 'label' => 'Cancel when a newer push arrives'],
['value' => false, 'label' => 'Deploy every push'],
]" :disabled="! $canUpdate || $isAutoCancelBuildingDeploymentsEnabled" />
<x-forms.listbox id="isAutoCancelBuildingDeploymentsEnabled" label="Building webhook deployments"
onChange="instantSave"
helper="When a newer push arrives, also stop the older webhook deployment that is still building. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments."
:options="[
['value' => false, 'label' => 'Let the build finish'],
['value' => true, 'label' => 'Stop the build when a newer push arrives'],
]" :disabled="! $canUpdate" />
</div>
</x-application.settings-subsection>