|null */ private ?array $containerNamesBeforeRollingUpdate = null; private string $basedir; private string $workdir; private ?string $build_pack = null; private string $configuration_dir; private string $build_image_name; private string $production_image_name; private bool $is_debug_enabled; private Collection|string $build_args; private $env_args; /** @var array|null */ private ?array $remote_secrets_cache = null; /** @var array|null */ private ?array $previewVariableIdsWithProductionValues = null; private ?bool $has_secret_manager_source = null; private $env_nixpacks_args; private $env_railpack_args; private $docker_compose; private ?string $generated_docker_compose = null; private ?string $nixpacks_plan = null; private Collection $nixpacks_plan_json; private ?string $nixpacks_type = null; private string $dockerfile_location = '/Dockerfile'; private string $docker_compose_location = '/docker-compose.yaml'; private ?string $docker_compose_custom_start_command = null; private ?string $docker_compose_custom_build_command = null; private ?string $addHosts = null; private ?string $buildTarget = null; private bool $disableBuildCache = false; private Collection $saved_outputs; private ?string $secrets_hash_key = null; private ?string $full_healthcheck_url = null; private string $serverUser = 'root'; private string $serverUserHomeDir = '/root'; private string $dockerConfigFileExists = 'NOK'; private int $customPort = 22; private ?string $customRepository = null; private ?string $fullRepoUrl = null; private ?string $branch = null; private ?string $coolify_variables = null; private bool $preserveRepository = false; private bool $dockerBuildkitSupported = false; private bool $dockerBuildxAvailable = false; private bool $dockerSecretsSupported = false; private bool $useBuildtimeEnvironmentLauncher = false; private bool $skip_build = false; private Collection|string $build_secrets; /** * Build-time variables that the helper container has in its environment for build secrets. * * @var array */ private array $helperBuildSecretEnvironmentKeys = []; public function tags() { // Do not remove this one, it needs to properly identify which worker is running the job return ['App\Models\ApplicationDeploymentQueue:'.$this->application_deployment_queue_id]; } public function __construct(public int $application_deployment_queue_id) { $this->onQueue(deployment_queue()); $this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id); $this->nixpacks_plan_json = collect([]); $this->application = Application::find($this->application_deployment_queue->application_id); $this->build_pack = data_get($this->application, 'build_pack'); $this->build_args = collect([]); $this->build_secrets = ''; $this->deployment_uuid = $this->application_deployment_queue->deployment_uuid; $this->pull_request_id = $this->application_deployment_queue->pull_request_id; $this->commit = validateGitRef($this->application_deployment_queue->commit, 'deployment commit'); $this->rollback = $this->application_deployment_queue->rollback; $this->disableBuildCache = $this->application->settings->disable_build_cache; $this->force_rebuild = $this->application_deployment_queue->force_rebuild; if ($this->disableBuildCache) { $this->force_rebuild = true; } $this->restart_only = $this->application_deployment_queue->restart_only; // These build packs need the cloned repository to start, so a restart runs a regular deployment. // The deployment skips the build when the image for the commit exists. $this->restart_only = $this->restart_only && ! in_array($this->application->build_pack, ['dockerimage', 'dockerfile', 'dockercompose'], true); $this->only_this_server = $this->application_deployment_queue->only_this_server; $this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag; $this->validateDockerRegistryImageConfiguration(); $this->git_type = data_get($this->application_deployment_queue, 'git_type'); $source = data_get($this->application, 'source'); if ($source) { $this->source = $source->getMorphClass()::where('id', $this->application->source->id)->first(); // Share one source instance so the job reuses its in-memory GitHub installation token. $this->application->setRelation('source', $this->source); } $this->server = Server::find($this->application_deployment_queue->server_id); $this->timeout = $this->server->settings->dynamic_timeout; $this->destination = $this->server->destinations()->where('id', $this->application_deployment_queue->destination_id)->first(); $this->server = $this->mainServer = $this->destination->server; $this->serverUser = $this->server->user; $this->is_this_additional_server = $this->application->additional_servers()->wherePivot('server_id', $this->server->id)->count() > 0; $this->preserveRepository = $this->application->settings->is_preserve_repository_enabled; $this->basedir = $this->application->generateBaseDir($this->deployment_uuid); $baseDir = $this->application->base_directory; if ($baseDir && $baseDir !== '/') { $this->validatePathField($baseDir, 'base_directory'); } $this->workdir = "{$this->basedir}".rtrim($baseDir, '/'); $this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}"; $this->is_debug_enabled = $this->application->settings->is_debug_enabled; $this->container_name = generateApplicationContainerName($this->application, $this->pull_request_id); $this->saved_outputs = collect(); // Set preview fqdn if ($this->pull_request_id !== 0) { $this->preview = ApplicationPreview::findPreviewByApplicationAndPullId($this->application->id, $this->pull_request_id); if ($this->application->build_pack === 'dockerimage' && str($this->dockerImagePreviewTag)->isEmpty()) { $this->dockerImagePreviewTag = $this->preview?->docker_registry_image_tag; } if ($this->preview) { if ($this->application->build_pack === 'dockercompose') { $this->preview->generate_preview_fqdn_compose(keepExistingDomains: true); } else { $this->preview->generate_preview_fqdn(keepExistingDomains: true); } } if ($this->application->isGithubAppSource()) { ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::IN_PROGRESS); } if ($this->application->build_pack === 'dockerfile') { if (data_get($this->application, 'dockerfile_location')) { $this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location'); } } } } /** * Build the --add-host flags for the containers on the destination network. * * Build helper containers are named after their deployment uuid and only live for the * duration of that deployment. BuildKit keys every RUN layer on the --add-host set, * so letting one in gives the next build a different set and forces a full rebuild. */ private function addHostFlags(Collection $containers): string { $containers = $containers->sort()->values(); $buildHelperNames = ApplicationDeploymentQueue::query() ->whereIn('deployment_uuid', $containers->pluck('Name')->filter()) ->pluck('deployment_uuid'); $ips = collect([]); foreach ($containers as $container) { $containerName = data_get($container, 'Name'); if ($containerName === 'coolify-proxy') { continue; } if (isGeneratedContainerName($containerName)) { continue; } if ($buildHelperNames->contains($containerName)) { continue; } $containerIp = data_get($container, 'IPv4Address'); if ($containerName && $containerIp) { $ips->put($containerName, str($containerIp)->before('/')->value()); } } return $ips->map(function ($ip, $name) { return "--add-host $name:$ip"; })->implode(' '); } public function handle(): void { // Check if deployment was cancelled before we even started $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; } try { $this->validateDeploymentEnvironmentVariableKeys(); $this->ensureRegistryImageForMultipleServers(); $this->ensureRegistryImageIsNotShared(); $this->warnAboutVolumesOnMultipleServers(); $this->warnAboutMixedProxiesOnMultipleServers(); } catch (Exception $e) { $this->fail($e); throw $e; } // 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.'); return; } try { // Make sure the private key is stored in the filesystem $this->server->privateKey->storeInFileSystem(); // Generate custom host<->ip mapping $safeNetwork = escapeshellarg($this->destination->network); $allContainers = instant_remote_process(["docker network inspect {$safeNetwork} -f '{{json .Containers}}' "], $this->server); if (! is_null($allContainers)) { $allContainers = format_docker_command_output_to_json($allContainers); $this->addHosts = count($allContainers) > 0 ? $this->addHostFlags(collect($allContainers[0])) : ''; } if ($this->application->dockerfile_target_build) { $target = $this->application->dockerfile_target_build; if (! preg_match(ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) { throw new \RuntimeException('Invalid dockerfile_target_build: contains forbidden characters.'); } $this->buildTarget = " --target {$target} "; } // Check custom port ['repository' => $this->customRepository, 'port' => $this->customPort] = $this->application->customRepository(); $this->selectBuildServer(); $this->detectBuildKitCapabilities(); $this->decide_what_to_do(); } catch (Exception $e) { if ($this->pull_request_id !== 0 && $this->application->isGithubAppSource()) { ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::ERROR); } $this->fail($e); throw $e; } finally { // 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()); } 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'); } try { $this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}"); $this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true); } 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()); } } } private function selectBuildServer(): void { $this->build_server = $this->server; // An additional server of a multi-server application does not build: it pulls the image the main server pushed. if ($this->is_this_additional_server && str($this->application->docker_registry_image_name)->isNotEmpty()) { // The main deployment already built the image (also for a forced rebuild or disabled build cache). // A forced rebuild would skip the registry pull. Compose applications build on each server that can build. if ($this->application->build_pack !== 'dockercompose' || ! $this->server->canBuildApplications()) { $this->force_rebuild = false; } $this->application_deployment_queue->addLogEntry('Additional server: pulls the image from the registry, no build server needed.'); return; } // A deployments-only server never builds. Docker image and Compose applications are exempt: // the first builds nothing and the second does not support build servers. $mustBuildElsewhere = ! $this->server->canBuildApplications() && ! in_array($this->application->build_pack, ['dockerimage', 'dockercompose'], true); if (! $mustBuildElsewhere && ! data_get($this->application, 'settings.is_build_server_enabled')) { return; } if ($mustBuildElsewhere && ! $this->restart_only && str($this->application->docker_registry_image_name)->isEmpty()) { throw new DeploymentException("The deployment server ({$this->server->name}) is set to deployments only, so this application is built on a build server. Set a Docker image name in the application's General settings so the deployment server can pull the built image."); } $team = $this->application->environment->project->team; $buildServers = Server::buildServers($team->id)->whereKeyNot($this->server->id)->get(); if ($buildServers->isEmpty()) { // A restart only rebuilds when the image is missing, so it may still run on the deployment server. if ($mustBuildElsewhere && ! $this->restart_only) { throw new DeploymentException("The deployment server ({$this->server->name}) is set to deployments only, and no usable build server was found. Add a build server or change the server role."); } // A restart that has to rebuild selects again in should_skip_build(). if (! $team->is_build_server_fallback_enabled && ! $this->restart_only) { throw new DeploymentException('No available dedicated build server was found. Enable a usable build server for this team or allow fallback to the deployment server in the team settings.'); } $this->application_deployment_queue->addLogEntry('No suitable build server found. Using the deployment server.'); return; } $this->build_server = $buildServers->random(); // Saved now: the cancel action looks for the helper container on this server. $this->application_deployment_queue->update(['build_server_id' => $this->build_server->id]); $this->application_deployment_queue->addLogEntry("Found a suitable build server ({$this->build_server->name})."); $this->use_build_server = true; } private function detectBuildKitCapabilities(): void { $this->dockerBuildkitSupported = false; $this->dockerBuildxAvailable = false; $this->dockerSecretsSupported = false; $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; try { $dockerVersion = instant_remote_process( ["docker version --format '{{.Server.Version}}'"], $serverToCheck ); $serverToCheck->rememberDockerVersion($dockerVersion); $versionParts = explode('.', $dockerVersion); $majorVersion = (int) $versionParts[0]; $minorVersion = (int) ($versionParts[1] ?? 0); if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) { $this->dockerBuildkitSupported = false; $this->dockerBuildxAvailable = false; $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+)."); return; } // Check buildx availability (always installed by Coolify on Docker 24.0+) $buildxAvailable = instant_remote_process( ["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"], $serverToCheck ); if (trim($buildxAvailable) === 'available') { $this->dockerBuildkitSupported = true; $this->dockerBuildxAvailable = true; $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}."); } else { $this->dockerBuildxAvailable = false; // Fallback: test DOCKER_BUILDKIT=1 support via --progress flag $buildkitTest = instant_remote_process( ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"], $serverToCheck ); if (trim($buildkitTest) === 'supported') { $this->dockerBuildkitSupported = true; $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit support detected on {$serverName}."); } else { $this->dockerBuildkitSupported = false; $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit. Build output progress will be limited."); } } if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) { $secretsTest = instant_remote_process( ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], $serverToCheck ); if (trim($secretsTest) === 'supported') { $this->dockerSecretsSupported = true; $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); } else { $this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments."); } } } catch (Exception $e) { $this->dockerBuildkitSupported = false; $this->dockerBuildxAvailable = false; $this->dockerSecretsSupported = false; $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); } } private function decide_what_to_do() { if ($this->restart_only) { $this->just_restart(); return; } elseif ($this->application->build_pack === 'dockerimage') { $this->deploy_dockerimage_buildpack(); } elseif ($this->pull_request_id !== 0) { $this->deploy_pull_request(); } elseif ($this->application->dockerfile) { $this->deploy_simple_dockerfile(); } elseif ($this->application->build_pack === 'dockercompose') { $this->deploy_docker_compose_buildpack(); } elseif ($this->application->build_pack === 'dockerfile') { $this->deploy_dockerfile_buildpack(); } elseif ($this->application->build_pack === 'static') { $this->deploy_static_buildpack(); } elseif ($this->application->build_pack === 'nixpacks') { $this->deploy_nixpacks_buildpack(); } elseif ($this->application->build_pack === 'railpack') { $this->deploy_railpack_buildpack(); } else { throw new DeploymentException("Unsupported build pack: {$this->application->build_pack}"); } $this->post_deployment(); } private function post_deployment() { // Mark deployment as complete FIRST, before any other operations // This ensures the deployment status is FINISHED even if subsequent operations fail $this->completeDeployment(); // Then handle side effects - these should not fail the deployment try { GetContainersStatus::dispatch($this->server); } catch (Exception $e) { \Log::warning('Failed to dispatch GetContainersStatus for deployment '.$this->deployment_uuid.': '.$e->getMessage()); } if ($this->pull_request_id !== 0) { if ($this->application->isGithubAppSource()) { try { ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED); } catch (Exception $e) { \Log::warning('Failed to dispatch PR update for deployment '.$this->deployment_uuid.': '.$e->getMessage()); } } } try { $this->run_post_deployment_command(); } catch (Exception $e) { \Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage()); } // Domains can change with a deployment, so the maintenance routes follow them. if ($this->pull_request_id === 0 && $this->application->is_maintenance_enabled) { try { $this->application->syncMaintenancePage(); } catch (Exception $e) { \Log::warning('Maintenance page update failed for '.$this->deployment_uuid.': '.$e->getMessage()); } } } private function deploy_simple_dockerfile() { if ($this->use_build_server) { $this->server = $this->build_server; } $dockerfile_base64 = base64_encode($this->application->dockerfile); $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->application->name} to {$this->server->name}."); $this->prepare_builder_image(); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '$dockerfile_base64' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), ], ); $this->generate_image_names(); $this->generate_compose_file(); if ($this->pull_image_for_additional_server()) { $this->save_runtime_environment_variables(); $this->rolling_update(); return; } $this->validateDeploymentEnvironmentVariableKeys(buildsImage: true); // Save build-time .env file BEFORE the build $this->save_buildtime_environment_variables(); $this->generate_build_env_variables(); $this->add_build_env_variables_to_dockerfile(); $this->build_image(); // Save runtime environment variables AFTER the build // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } /** * An inline Dockerfile image has no commit tag. The main server builds it and pushes it with the * `latest` tag just before it queues the additional servers, so an additional server pulls that * image instead of building its own. A missing image fails the deployment with a clear message. */ private function pull_image_for_additional_server(): bool { if (! $this->is_this_additional_server || str($this->application->docker_registry_image_name)->isEmpty()) { return false; } $image = escapeshellarg($this->production_image_name); $this->application_deployment_queue->addLogEntry("Pulling image ({$this->production_image_name}) that the main server pushed. Build step skipped."); $this->execute_remote_command( [ serverDockerConfigPrefix($this->server)."docker pull {$image}", 'hidden' => true, ], [ "docker images -q {$image} 2>/dev/null", 'hidden' => true, 'save' => 'local_image_found', ], ); if (str($this->saved_outputs->get('local_image_found'))->isEmpty()) { $message = "Image ({$this->production_image_name}) not found in the registry; the main server must push it first."; if (! $this->server->canBuildApplications()) { $message .= " The server ({$this->server->name}) is set to deployments only and cannot build it."; } throw new DeploymentException($message); } return true; } private function deploy_dockerimage_buildpack() { $this->dockerImage = $this->application->docker_registry_image_name; $this->dockerImageTag = $this->resolveDockerImageTag(); // Check if this is an image hash deployment $isImageHash = str($this->dockerImageTag)->startsWith('sha256-'); $displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}"; $this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}."); $this->generate_image_names(); $this->prepare_builder_image(); $this->generate_compose_file(); // Save runtime environment variables (including empty .env file if no variables defined) $this->save_runtime_environment_variables(); $this->rolling_update(); } private function resolveDockerImageTag(): string { if ($this->pull_request_id !== 0 && str($this->dockerImagePreviewTag)->isNotEmpty()) { return $this->dockerImagePreviewTag; } if ($this->rollback && str($this->commit)->isNotEmpty()) { return $this->commit; } if (str($this->application->docker_registry_image_tag)->isNotEmpty()) { return $this->application->docker_registry_image_tag; } return 'latest'; } private function deploy_docker_compose_buildpack() { if (data_get($this->application, 'docker_compose_location')) { $this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location'); } if (data_get($this->application, 'docker_compose_custom_start_command')) { $this->validateShellSafeCommand($this->application->docker_compose_custom_start_command, 'docker_compose_custom_start_command'); $this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command; if (! str($this->docker_compose_custom_start_command)->contains('--project-directory')) { $projectDir = $this->preserveRepository ? $this->application->workdir() : $this->workdir; $this->docker_compose_custom_start_command = str($this->docker_compose_custom_start_command)->replaceFirst('compose', 'compose --project-directory '.$projectDir)->value(); } } if (data_get($this->application, 'docker_compose_custom_build_command')) { $this->validateShellSafeCommand($this->application->docker_compose_custom_build_command, 'docker_compose_custom_build_command'); $this->docker_compose_custom_build_command = $this->application->docker_compose_custom_build_command; if (! str($this->docker_compose_custom_build_command)->contains('--project-directory')) { $this->docker_compose_custom_build_command = str($this->docker_compose_custom_build_command)->replaceFirst('compose', 'compose --project-directory '.$this->workdir)->value(); } } if ($this->pull_request_id === 0) { $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->application->name} to {$this->server->name}."); } else { $this->application_deployment_queue->addLogEntry("Starting pull request (#{$this->pull_request_id}) deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}."); } $this->prepare_builder_image(); $this->check_git_if_build_needed(); $this->clone_repository(); if ($this->preserveRepository) { foreach ($this->application->fileStorages as $fileStorage) { $path = $fileStorage->fs_path; $saveName = 'file_stat_'.$fileStorage->id; $realPathInGit = str($path)->replace($this->application->workdir(), $this->workdir)->value(); // check if the file is a directory or a file inside the repository $this->execute_remote_command( [executeInDocker($this->deployment_uuid, "stat -c '%F' ".escapeshellarg($realPathInGit)), 'hidden' => true, 'ignore_errors' => true, 'save' => $saveName] ); if ($this->saved_outputs->has($saveName)) { $fileStat = $this->trimmedSavedOutput($saveName); if ($fileStat->value() === 'directory' && ! $fileStorage->is_directory) { $fileStorage->is_directory = true; $fileStorage->content = null; $fileStorage->save(); $fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->deleteStorageOnServer($this->mainServer); $fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer); } elseif ($fileStat->value() === 'regular file' && $fileStorage->is_directory) { $fileStorage->is_directory = false; $fileStorage->is_based_on_git = true; $fileStorage->save(); $fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->deleteStorageOnServer($this->mainServer); $fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer); } } } } $this->generate_image_names(); $this->cleanup_git(); $this->validateDeploymentEnvironmentVariableKeys(buildsImage: true); $this->generate_build_env_variables(); $this->loadComposeFileForDeployment(); $buildEnvironment = null; if ($this->application->settings->is_raw_compose_deployment_enabled) { $this->application->oldRawParser(); $yaml = $composeFile = $this->application->docker_compose_raw; // For raw compose, we cannot automatically add secrets configuration // User must define it manually in their docker-compose file if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) { $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.'); } } else { $composeFile = $this->parseComposeFileForDeployment(); // Always add .env file to services $services = collect(data_get($composeFile, 'services', [])); $services = $services->map(function ($service, $name) { $service['env_file'] = ['.env']; return $service; }); $composeFile['services'] = $services->toArray(); // Add build secrets to compose file if enabled and BuildKit is supported if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) { $composeFile = $this->add_build_secrets_to_compose($composeFile); } $buildEnvironment = $this->generate_buildtime_environment_variables(); $this->docker_compose_build_fingerprint = $this->docker_compose_build_fingerprint($buildEnvironment); $composeFile = $this->add_build_fingerprint_to_compose(convertToArray($composeFile)); $yaml = Yaml::dump(convertToArray($composeFile), 10); } $this->generated_docker_compose = $yaml; $this->execute_remote_command([ "docker exec {$this->deployment_uuid} rm -f {$this->workdir}{$this->docker_compose_location}", 'hidden' => true, ], [ "docker exec -i {$this->deployment_uuid} tee {$this->workdir}{$this->docker_compose_location} > /dev/null", 'input' => $yaml, 'hidden' => true, ]); if ($this->can_reuse_docker_compose_images($composeFile)) { $this->skip_build = true; } else { $this->build_docker_compose_images($composeFile, $buildEnvironment); } // Save runtime environment variables AFTER the build // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); $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; if ($this->pull_request_id !== 0) { $networkId = "{$this->application->uuid}-{$this->pull_request_id}"; } if ($this->server->isSwarm()) { // TODO } else { $this->execute_remote_command([ "docker network inspect '{$networkId}' >/dev/null 2>&1 || docker network create --attachable '{$networkId}' >/dev/null || true", 'hidden' => true, 'ignore_errors' => true, ], [ "docker network connect {$networkId} coolify-proxy >/dev/null 2>&1 || true", 'hidden' => true, 'ignore_errors' => true, ]); } $this->start_docker_compose_services(); $this->application_deployment_queue->addLogEntry('New container started.'); } /** * Builds the images of the Compose services that have a build section. */ private function build_docker_compose_images(mixed $composeFile, ?Collection $buildEnvironment): void { // Modify Dockerfiles for ARGs and build secrets $this->modify_dockerfiles_for_compose($composeFile); // Build new container to limit downtime. $this->application_deployment_queue->addLogEntry('Pulling & building required images.'); // Save build-time .env file BEFORE the build $buildEnvironment ??= $this->generate_buildtime_environment_variables(); $this->save_buildtime_environment_variables($buildEnvironment->merge($this->compose_build_interpolation_variables($composeFile, $buildEnvironment))); if ($this->docker_compose_custom_build_command) { // Auto-inject -f (compose file) and --env-file flags using helper function $build_command = injectDockerComposeFlags( $this->docker_compose_custom_build_command, "{$this->workdir}{$this->docker_compose_location}", self::BUILD_TIME_ENV_PATH ); // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported if ($this->dockerBuildkitSupported) { $build_command = "DOCKER_BUILDKIT=1 {$build_command}"; } // Inject build arguments after build subcommand if not using build secrets if (! $this->application->settings->use_build_secrets && $this->build_args instanceof Collection && $this->build_args->isNotEmpty()) { $build_args_string = $this->build_args->implode(' '); // Inject build args right after 'build' subcommand (not at the end) $original_command = $build_command; $build_command = injectDockerComposeBuildArgs($build_command, $build_args_string); // Only log if build args were actually injected (command was modified) if ($build_command !== $original_command) { $this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.'); } } try { $this->execute_remote_command( [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true], ); } catch (\RuntimeException $e) { if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) { throw new DeploymentException("Custom build command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_build_command}"); } throw $e; } } else { $command = "{$this->coolify_variables} docker compose"; // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported if ($this->dockerBuildkitSupported) { $command = "DOCKER_BUILDKIT=1 {$command}"; } // Use build-time .env file from /artifacts (outside Docker context to prevent it from being in the image) $command .= ' --env-file '.self::BUILD_TIME_ENV_PATH; if ($this->force_rebuild) { $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache"; } else { $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull"; } if (! $this->application->settings->use_build_secrets && $this->build_args instanceof Collection && $this->build_args->isNotEmpty()) { $build_args_string = $this->build_args->implode(' '); $command .= " {$build_args_string}"; $this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.'); } $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $command), 'hidden' => true], ); } } /** * `docker compose build` interpolates the whole Compose file, so a required runtime-only variable * (`${KEY:?}` in `environment:`) fails the build when the build-time .env file does not define it. * This gives the build the runtime .env lines of the variables that the file uses outside its build * sections. A variable that a build section or a build secret can read is left out, so a * runtime-only value never becomes a build argument or part of an image. * * @return Collection */ private function compose_build_interpolation_variables(mixed $composeFile, Collection $buildEnvironment): Collection { $compose = is_string($composeFile) ? parseDockerComposeYaml($composeFile) : convertToArray($composeFile); $references = fn (array $values): Collection => collect(Arr::flatten($values)) ->flatMap(fn (mixed $value): Collection => extractDockerComposeEnvironmentVariableReferences($value)); $readByBuilds = collect(data_get($compose, 'secrets', []))->pluck('environment')->filter(); foreach (data_get($compose, 'services', []) as $serviceName => $service) { $build = data_get($service, 'build'); if ($build === null) { continue; } $args = data_get($build, 'args', []); $argNames = is_array($args) && array_is_list($args) ? array_map(fn (mixed $arg): string => explode('=', (string) $arg, 2)[0], $args) : array_keys((array) $args); $readByBuilds = $readByBuilds->merge($argNames)->merge($references((array) $build)); unset($compose['services'][$serviceName]['build']); } $buildKeys = $buildEnvironment->map(fn (string $line): string => explode('=', $line, 2)[0]); $keys = $references($compose)->unique()->diff($readByBuilds)->diff($buildKeys); if ($keys->isEmpty()) { return collect(); } return $this->generate_runtime_environment_variables() ->filter(fn (string $line): bool => $keys->contains(explode('=', $line, 2)[0])) ->values(); } /** * Parses the Compose file for this deployment and writes the volume warnings of the parser * (for example an external volume that the resource does not use yet) to the deployment log. * A deployment of the application also queues one sync of the file storages with the server, which * loads the content of the files that come from the repository. A preview uses its own copies of the * files, so its deployment does not sync the storages of the application. * The parser gives no services when it cannot parse the file. The deployment stops then, because * an empty Compose file fails later with the unclear Docker error "no service selected". * * @return Collection * * @throws DeploymentException When the parsed Compose file has no services */ private function parseComposeFileForDeployment(): Collection { $composeFile = collect($this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit)); if (blank(data_get($composeFile, 'services'))) { throw new DeploymentException("The Docker Compose file has no services, or Coolify could not parse it. Check that {$this->application->docker_compose_location} is valid YAML and has a services section."); } if ($this->pull_request_id === 0) { ServerFilesFromServerJob::dispatch($this->application); } foreach ($this->application->composeVolumeWarnings() as $warning) { $this->application_deployment_queue->addLogEntry("Warning: {$warning}", 'stderr'); } return $composeFile; } /** * Pulls the registry images of the Compose services before the current deployment stops. Images that * this deployment builds are not pulled: a pull fails for an image that is only local, and a pull of * an image that is also in a registry replaces the new build with the old registry image. */ private function pull_docker_compose_images(): void { if ($this->docker_compose_custom_build_command && ! $this->use_build_server) { $this->application_deployment_queue->addLogEntry('Skipping the image pull before stopping the current deployment, because the custom build command can build images outside of Docker Compose.'); return; } $services = $this->registry_image_compose_services(); if ($services->isEmpty()) { return; } $this->application_deployment_queue->addLogEntry('Pulling image-based services before stopping the current deployment.'); $services = $services->map(fn (string $service) => escapeshellarg($service))->implode(' '); if ($this->use_build_server) { $this->write_deployment_configurations(); $this->server = $this->mainServer; $workdir = $this->application->workdir(); $command = serverDockerConfigPrefix($this->server)."{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}"; } else { $workdir = $this->workdir; $command = executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment("{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}")); } $this->execute_remote_command([ $command, 'hidden' => true, ]); } /** * Returns the names of the Compose services that get their image from a registry. A service with a * `build` section, or a service that uses the image of such a service, gets its image from the build. * * @return Collection */ private function registry_image_compose_services(): Collection { $services = collect(data_get(Yaml::parse($this->generated_docker_compose), 'services', [])); $builtImages = $services->filter(fn ($service) => filled(data_get($service, 'build')))->pluck('image')->filter(); return $services ->filter(fn ($service) => filled(data_get($service, 'image')) && blank(data_get($service, 'build')) && ! $builtImages->contains(data_get($service, 'image'))) ->keys() ->map(fn ($service) => (string) $service) ->values(); } /** * Starts the Compose containers. Content files are written first, because `docker compose up` * creates a missing bind source as an empty directory. */ private function start_docker_compose_services(): void { $this->write_missing_content_files(); $server_workdir = $this->application->workdir(); if ($this->application->settings->is_raw_compose_deployment_enabled) { if ($this->docker_compose_custom_start_command) { // Auto-inject -f (compose file) and --env-file flags using helper function $start_command = injectDockerComposeFlags( $this->docker_compose_custom_start_command, "{$server_workdir}{$this->docker_compose_location}", "{$server_workdir}/.env" ); $this->write_deployment_configurations(); try { $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment("cd {$this->workdir} && {$start_command}")), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); } catch (\RuntimeException $e) { if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) { throw new DeploymentException("Custom start command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_start_command}"); } throw $e; } } else { $this->write_deployment_configurations(); $this->docker_compose_location = '/docker-compose.yaml'; $command = "{$this->coolify_variables} docker compose"; // Always use .env file $command .= " --env-file {$server_workdir}/.env"; $command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( ['command' => $command, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); } } else { if ($this->docker_compose_custom_start_command) { // Auto-inject -f (compose file) and --env-file flags using helper function // Use $this->workdir for non-preserve-repository mode $workdir_path = $this->preserveRepository ? $server_workdir : $this->workdir; $start_command = injectDockerComposeFlags( $this->docker_compose_custom_start_command, "{$workdir_path}{$this->docker_compose_location}", "{$workdir_path}/.env" ); $this->write_deployment_configurations(); if ($this->preserveRepository) { $start_in_workdir = "cd {$server_workdir} && {$start_command}"; if ($this->server->isNonRoot()) { // A non-root SSH user cannot cd into the resource directory on the Coolify host. As one // `sh -c` script, the sudo parser runs the cd and the start command as root. $start_in_workdir = 'sh -c '.escapeshellarg($start_in_workdir); } $this->execute_remote_command( ['command' => $start_in_workdir, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); } else { $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment("cd {$this->basedir} && {$start_command}")), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); } } else { $command = "{$this->coolify_variables} docker compose"; if ($this->preserveRepository) { // Always use .env file $command .= " --env-file {$server_workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $this->write_deployment_configurations(); $this->execute_remote_command( ['command' => $command, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); } else { // Always use .env file $command .= " --env-file {$this->workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $this->withoutHelperBuildSecretEnvironment($command)), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true], ); $this->write_deployment_configurations(); } } } } /** * The pull request whose file storage paths this deployment writes. Only the current Compose parser * mounts the `-pr-` copies in a preview; other build packs and the legacy parser mount the * paths of the application. */ private function previewFileStoragePullRequestId(): int { return $this->application->build_pack === 'dockercompose' && (int) $this->application->compose_parsing_version >= 3 ? $this->pull_request_id : 0; } /** * The queued ServerStorageSaveJob writes new content files, but it can run after the containers * start. Preserve-repository deployments write all file storages in write_deployment_configurations(). * Each deployment writes the missing files on its own server before the containers start, so * additional servers also get them. */ private function write_missing_content_files(): void { if ($this->preserveRepository) { return; } EnsureContentFilesOnServer::run( $this->application->fileStorages()->get()->map->forPullRequest($this->previewFileStoragePullRequestId()), $this->mainServer, function (string $message, string $type): void { $this->application_deployment_queue->addLogEntry($message, $type); }, ); } private function deploy_dockerfile_buildpack() { $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}."); if ($this->use_build_server) { $this->server = $this->build_server; } if (data_get($this->application, 'dockerfile_location')) { $this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location'); } $this->prepare_builder_image(); $this->check_git_if_build_needed(); $this->generate_image_names(); $this->clone_repository(); if (! $this->force_rebuild) { $this->check_image_locally_or_remotely(); if ($this->should_skip_build()) { return; } } $this->validateDeploymentEnvironmentVariableKeys(buildsImage: true); $this->cleanup_git(); $this->generate_compose_file(); // Save build-time .env file BEFORE the build $this->save_buildtime_environment_variables(); $this->generate_build_env_variables(); $this->add_build_env_variables_to_dockerfile(); $this->build_image(); // Save runtime environment variables AFTER the build // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } private function deploy_nixpacks_buildpack() { if ($this->use_build_server) { $this->server = $this->build_server; } $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}."); $this->prepare_builder_image(); $this->check_git_if_build_needed(); $this->generate_image_names(); if (! $this->force_rebuild) { $this->check_image_locally_or_remotely(); if ($this->should_skip_build()) { return; } } $this->validateDeploymentEnvironmentVariableKeys(buildsImage: true); $this->clone_repository(); $this->cleanup_git(); $this->generate_nixpacks_confs(); $this->generate_compose_file(); // Save build-time .env file BEFORE the build for Nixpacks $this->save_buildtime_environment_variables(); $this->generate_build_env_variables(); $this->build_image(); // For Nixpacks, save runtime environment variables AFTER the build // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } private function deploy_railpack_buildpack(): void { if ($this->use_build_server) { $this->server = $this->build_server; } $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}."); $this->prepare_builder_image(); $this->check_git_if_build_needed(); $this->generate_image_names(); if (! $this->force_rebuild) { $this->check_image_locally_or_remotely(); if ($this->should_skip_build()) { return; } } $this->validateDeploymentEnvironmentVariableKeys(buildsImage: true); $this->clone_repository(); $this->cleanup_git(); $this->generate_compose_file(); // Save build-time .env file BEFORE the build $this->save_buildtime_environment_variables(); $this->generate_build_env_variables(); $this->build_railpack_image(); // Save runtime environment variables AFTER the build $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } private function deploy_static_buildpack() { if ($this->use_build_server) { $this->server = $this->build_server; } $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}."); $this->prepare_builder_image(); $this->check_git_if_build_needed(); $this->generate_image_names(); if (! $this->force_rebuild) { $this->check_image_locally_or_remotely(); if ($this->should_skip_build()) { return; } } $this->validateDeploymentEnvironmentVariableKeys(buildsImage: true); $this->clone_repository(); $this->cleanup_git(); $this->generate_compose_file(); // Save build-time .env file BEFORE the build $this->save_buildtime_environment_variables(); $this->build_static_image(); // Save runtime environment variables AFTER the build // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } private function write_deployment_configurations() { if ($this->preserveRepository) { if ($this->use_build_server) { $this->server = $this->mainServer; } if (str($this->configuration_dir)->isNotEmpty()) { $this->execute_remote_command( [ "mkdir -p $this->configuration_dir", ], [ "docker cp {$this->deployment_uuid}:{$this->workdir}/. {$this->configuration_dir}", ], ); } foreach ($this->application->fileStorages as $fileStorage) { if (! $fileStorage->is_host_file && ! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) { $fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer); } } if ($this->use_build_server) { $this->server = $this->build_server; } } if (isset($this->generated_docker_compose)) { if ($this->use_build_server) { $this->server = $this->mainServer; } $readme = generate_readme_file($this->application->name, $this->application_deployment_queue->updated_at); $mainDir = $this->configuration_dir; if ($this->application->settings->is_raw_compose_deployment_enabled) { $mainDir = $this->application->workdir(); } if ($this->pull_request_id === 0) { $composeFileName = "$mainDir/docker-compose.yaml"; } else { $composeFileName = "$mainDir/".addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml'; $this->docker_compose_location = '/'.addPreviewDeploymentSuffix('docker-compose', $this->pull_request_id).'.yaml'; } // `docker cp` above can copy symlinks from the repository to these names. Remove them first, so that // Coolify writes regular files. $this->execute_remote_command( [ "mkdir -p $mainDir", ], [ "rm -f $composeFileName $mainDir/README.md", 'hidden' => true, ], [ "tee $composeFileName > /dev/null", 'input' => $this->generated_docker_compose, ], [ "echo '{$readme}' | tee $mainDir/README.md > /dev/null", ] ); if ($this->use_build_server) { $this->server = $this->build_server; } } } private function push_to_docker_registry() { if (str($this->application->docker_registry_image_name)->isEmpty()) { return; } if ($this->restart_only) { return; } if ($this->application->build_pack === 'dockerimage') { return; } if ($this->is_this_additional_server) { return; } try { instant_remote_process(["docker images --format '{{json .}}' {$this->production_image_name}"], $this->server); $this->application_deployment_queue->addLogEntry('----------------------------------------'); $this->application_deployment_queue->addLogEntry("Pushing image to docker registry ({$this->production_image_name})."); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, 'docker push '.escapeshellarg($this->production_image_name)), 'hidden' => true, ], ); if ($this->shouldPushDockerRegistryImageTag()) { // Tag image with docker_registry_image_tag $this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag."); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, 'docker tag '.escapeshellarg($this->production_image_name).' '.escapeshellarg("{$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}")), 'ignore_errors' => true, 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'docker push '.escapeshellarg("{$this->application->docker_registry_image_name}:{$this->application->docker_registry_image_tag}")), 'ignore_errors' => true, 'hidden' => true, ], ); } } catch (Exception $e) { $this->application_deployment_queue->addLogEntry('Failed to push image to docker registry. Please check debug logs for more information.'); throw new DeploymentException(get_class($e).': '.$e->getMessage(), $e->getCode(), $e); } } private function shouldPushDockerRegistryImageTag(): bool { if (blank($this->application->docker_registry_image_tag)) { return false; } return $this->pull_request_id === 0; } private function validateDockerRegistryImageConfiguration(): void { if (! ValidationPatterns::isValidDockerImageName($this->application->docker_registry_image_name)) { throw new DeploymentException('Docker registry image name contains invalid characters.'); } if (! ValidationPatterns::isValidDockerImageTag($this->application->docker_registry_image_tag)) { throw new DeploymentException('Docker registry image tag contains invalid characters.'); } if (! ValidationPatterns::isValidDockerImageTag($this->dockerImagePreviewTag)) { throw new DeploymentException('Docker registry preview image tag contains invalid characters.'); } } private function generate_image_names() { if ($this->application->dockerfile) { if ($this->application->docker_registry_image_name) { $this->build_image_name = "{$this->application->docker_registry_image_name}:build"; $this->production_image_name = "{$this->application->docker_registry_image_name}:latest"; } else { $this->build_image_name = "{$this->application->uuid}:build"; $this->production_image_name = "{$this->application->uuid}:latest"; } } elseif ($this->application->build_pack === 'dockerimage') { // Check if this is an image hash deployment if (str($this->dockerImageTag)->startsWith('sha256-')) { $hash = str($this->dockerImageTag)->after('sha256-'); $this->production_image_name = "{$this->dockerImage}@sha256:{$hash}"; } else { $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; } } elseif ($this->pull_request_id !== 0) { $previewImageTag = $this->previewImageTag(); $previewBuildImageTag = $this->previewImageTag(build: true); if ($this->application->docker_registry_image_name) { $this->build_image_name = "{$this->application->docker_registry_image_name}:{$previewBuildImageTag}"; $this->production_image_name = "{$this->application->docker_registry_image_name}:{$previewImageTag}"; } else { $this->build_image_name = "{$this->application->uuid}:{$previewBuildImageTag}"; $this->production_image_name = "{$this->application->uuid}:{$previewImageTag}"; } } else { $this->dockerImageTag = str($this->commit)->substr(0, 128); // if ($this->application->docker_registry_image_tag) { // $this->dockerImageTag = $this->application->docker_registry_image_tag; // } if ($this->application->docker_registry_image_name) { $this->build_image_name = "{$this->application->docker_registry_image_name}:{$this->dockerImageTag}-build"; $this->production_image_name = "{$this->application->docker_registry_image_name}:{$this->dockerImageTag}"; } else { $this->build_image_name = "{$this->application->uuid}:{$this->dockerImageTag}-build"; $this->production_image_name = "{$this->application->uuid}:{$this->dockerImageTag}"; } } } private function previewImageTag(bool $build = false): string { $prefix = "pr-{$this->pull_request_id}-"; $suffix = $build ? '-build' : ''; $maxCommitLength = max(1, 128 - strlen($prefix) - strlen($suffix)); $commitSource = ($this->commit === 'HEAD' || blank($this->commit)) ? $this->deployment_uuid : $this->commit; $commit = Str::of($commitSource) ->replaceMatches('/[^A-Za-z0-9_.-]/', '-') ->substr(0, $maxCommitLength) ->toString(); if ($commit === '') { $commit = 'HEAD'; } return "{$prefix}{$commit}{$suffix}"; } private function just_restart() { $this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}."); // Restart doesn't need the build server — disable it so the helper container // is created on the deployment server with the correct network/flags. $originalUseBuildServer = $this->use_build_server; $this->use_build_server = false; $this->prepare_builder_image(); $this->check_git_if_build_needed(); $this->generate_image_names(); $this->check_image_locally_or_remotely(); // Restore before should_skip_build() — it may re-enter decide_what_to_do() // for a full rebuild which needs the build server. $this->use_build_server = $originalUseBuildServer; $this->should_skip_build(); $this->completeDeployment(); } private function should_skip_build() { if (str($this->saved_outputs->get('local_image_found'))->isNotEmpty()) { if ($this->is_this_additional_server) { $this->skip_build = true; $this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); $this->generate_compose_file(); // Save runtime environment variables even when skipping build $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); return true; } if ($this->has_remote_buildtime_secret_references()) { $this->application_deployment_queue->addLogEntry('Remote build-time secrets are configured. Running the build to check for updated values.'); return false; } $configurationDiff = $this->application->pendingDeploymentConfigurationDiff(); if (! $configurationDiff->requiresBuild()) { $this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); $this->skip_build = true; $this->generate_compose_file(); // Save runtime environment variables even when skipping build $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); return true; } elseif ($configurationDiff->isLegacyFallback()) { $this->application_deployment_queue->addLogEntry('The last deployment stored no configuration snapshot. Rebuilding image.'); } else { $this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.'); } } else { if ($this->is_this_additional_server && $this->application->build_pack !== 'dockercompose') { $message = "Image ({$this->production_image_name}) not found in the registry; the main server must push it first."; $message .= $this->server->canBuildApplications() ? ' Additional servers do not build, so they run the same image as the main server.' : " The server ({$this->server->name}) is set to deployments only and cannot build it."; throw new DeploymentException($message); } $this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image."); } if ($this->restart_only) { $this->restart_only = false; if (! $this->use_build_server) { // The restart skipped build server selection. $this->selectBuildServer(); if ($this->use_build_server) { $this->detectBuildKitCapabilities(); } } $this->decide_what_to_do(); } return false; } /** * Hash of every input that changes a Compose build: the build settings, the resolved build-time * variables (with domains, shared variables, and secrets), and the variables of the build command. * The hash goes on the built images as a label, so a later deployment can see what an image was built from. */ private function docker_compose_build_fingerprint(Collection $buildEnvironment): string { $buildSettings = collect((new ApplicationConfigurationSnapshot($this->application))->toArray()['sections']) ->flatMap(fn (array $section) => $section['items']) ->where('impact', 'build') ->mapWithKeys(fn (array $item) => [$item['key'] => $item['compare_value']]); return hash('sha256', json_encode([$buildSettings, $buildEnvironment->values(), $this->coolify_variables])); } private function add_build_fingerprint_to_compose(array $composeFile): array { foreach ($composeFile['services'] ?? [] as $serviceName => $service) { if (! isset($service['build'])) { continue; } if (is_string($service['build'])) { $service['build'] = ['context' => $service['build']]; } $labels = $service['build']['labels'] ?? []; if (array_is_list($labels)) { $labels[] = self::BUILD_FINGERPRINT_LABEL.'='.$this->docker_compose_build_fingerprint; } else { $labels[self::BUILD_FINGERPRINT_LABEL] = $this->docker_compose_build_fingerprint; } $service['build']['labels'] = $labels; $composeFile['services'][$serviceName] = $service; } return $composeFile; } /** * Whether every Compose service with a build section has an image on the server for this commit * that was built from the same inputs, so the deployment can start it without a build. * Only images tagged by the parser with the commit qualify: an image name set in the Compose * file can hold an older build. */ private function can_reuse_docker_compose_images(mixed $composeFile): bool { if ($this->force_rebuild || $this->use_build_server || $this->pull_request_id !== 0 || $this->application->settings->is_raw_compose_deployment_enabled || $this->docker_compose_custom_build_command || blank($this->docker_compose_build_fingerprint) || blank($this->commit) || $this->commit === 'HEAD') { return false; } $images = collect(data_get($composeFile, 'services', [])) ->filter(fn ($service) => data_get($service, 'build') !== null) ->map(fn ($service, $name) => [data_get($service, 'image'), "{$this->application->uuid}_{$name}:{$this->commit}"]); if ($images->isEmpty() || $images->contains(fn (array $image) => $image[0] !== $image[1])) { return false; } $imageNames = $images->map(fn (array $image) => escapeshellarg($image[1]))->implode(' '); $format = escapeshellarg('{{ index .Config.Labels "'.self::BUILD_FINGERPRINT_LABEL.'" }}'); $this->execute_remote_command([ "docker image inspect --format {$format} {$imageNames} 2>/dev/null", 'hidden' => true, 'ignore_errors' => true, 'save' => 'compose_image_fingerprints', ]); $fingerprints = $this->trimmedSavedOutput('compose_image_fingerprints')->explode("\n")->map(fn (string $line) => trim($line)); if ($fingerprints->count() !== $images->count() || $fingerprints->contains(fn (string $fingerprint) => $fingerprint !== $this->docker_compose_build_fingerprint)) { return false; } $this->application_deployment_queue->addLogEntry('Images found with the same Git Commit SHA and build inputs. Build step skipped.'); return true; } private function has_remote_buildtime_secret_references(): bool { $environmentVariables = $this->pull_request_id === 0 ? $this->application->environment_variables() : $this->application->environment_variables_preview(); return $environmentVariables ->where('is_buildtime', true) ->get(['value', 'is_literal']) ->contains(fn (EnvironmentVariable $environmentVariable) => $this->uses_remote_secret_references($environmentVariable)); } private function check_image_locally_or_remotely() { $this->execute_remote_command([ "docker images -q {$this->production_image_name} 2>/dev/null", 'hidden' => true, 'save' => 'local_image_found', ]); if (str($this->saved_outputs->get('local_image_found'))->isEmpty() && $this->application->docker_registry_image_name) { $this->application_deployment_queue->addLogEntry("Image ({$this->production_image_name}) not found on the server. Pulling it from the Docker registry."); $this->execute_remote_command([ serverDockerConfigPrefix($this->server).'docker pull '.escapeshellarg($this->production_image_name).' 2>/dev/null', 'ignore_errors' => true, 'hidden' => true, ]); $this->execute_remote_command([ "docker images -q {$this->production_image_name} 2>/dev/null", 'hidden' => true, 'save' => 'local_image_found', ]); } } /** * Fetch the secrets from the application's secret manager source. Values * live only in memory during the deployment and in the generated .env on * the server — they are never persisted in the Coolify database. Fetched * lazily (only when a variable references a secret), once per deployment. * A fetch failure fails the deployment. * * @return array */ private function remote_secrets(): array { if ($this->remote_secrets_cache !== null) { return $this->remote_secrets_cache; } $link = $this->application->secretManagerLink()->with('integrationToken')->first(); if (! $link) { throw new DeploymentException('Environment variables reference remote secrets ({{vault.KEY}}), but no secret manager source is configured for this application.'); } $provider = $link->integrationToken->providerName(); $tokenName = $link->integrationToken->name; try { $secrets = $link->fetchSecrets(); } catch (Throwable $e) { $this->application_deployment_queue->addLogEntry("Failed to fetch secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}): {$e->getMessage()}", 'stderr'); throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets."); } $this->application_deployment_queue->redactRemoteSecrets($secrets); $this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()})."); return $this->remote_secrets_cache = $secrets; } /** * Reads the Compose file from the repository again (also for pull request previews). If the file is not * safe to use, the deployment stops here with a visible log line, before a command uses the file. */ private function loadComposeFileForDeployment(): void { try { $this->application->loadComposeFile(isInit: false); } catch (DeploymentException $e) { // Deployment logs escape HTML themselves, so undo the escaping that error toasts need. $message = 'Deployment stopped: '.lcfirst(html_entity_decode($e->getMessage(), ENT_QUOTES | ENT_HTML5)); $this->application_deployment_queue->addLogEntry($message, 'stderr'); throw DeploymentException::alreadyLogged($message, previous: $e); } } /** * Replace {{vault.KEY}} references with values from the configured secret * manager source. Missing keys fail the deployment with a * list — changing the source never re-checks references, so this is the * moment problems surface. Values without references are returned as-is * and never fetch secrets. */ private function substitute_remote_secrets(string $value, string $envKey): string { if (! RemoteSecretReferences::containsReference($value)) { return $value; } $secrets = $this->remote_secrets(); $missing = RemoteSecretReferences::missingKeys($value, $secrets); if ($missing !== []) { $message = 'Missing secret keys: '.implode(', ', $missing)." (referenced by {$envKey}). Check the secret manager source of this application."; $this->application_deployment_queue->addLogEntry($message, 'stderr'); throw DeploymentException::alreadyLogged($message); } return RemoteSecretReferences::substitute($value, $secrets); } /** * Resolve shared variables, then secret references, in a raw variable value. */ private function resolve_environment_variable_raw(EnvironmentVariable $env): string { $value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer); if ($env->is_literal && ! $this->has_secret_manager_source()) { return $value ?? ''; } return $this->substitute_remote_secrets($value ?? '', $env->key); } /** * Without a source, a literal keeps "{{vault.KEY}}" as text; other references fail closed. */ private function uses_remote_secret_references(EnvironmentVariable $env): bool { if (! RemoteSecretReferences::containsReference($env->value)) { return false; } return ! $env->is_literal || $this->has_secret_manager_source(); } private function has_secret_manager_source(): bool { return $this->has_secret_manager_source ??= $this->remote_secrets_cache !== null || $this->application->secretManagerLink()->exists(); } /** * Resolve a runtime variable to its dotenv representation. Values with * secret references are substituted and written as literals. */ private function resolve_environment_variable(EnvironmentVariable $env): ?string { if (! $this->uses_remote_secret_references($env)) { return $env->getResolvedValueWithServer($this->mainServer); } return $this->format_remote_secret_value($this->resolve_environment_variable_raw($env)); } /** * Format a remote secret value for the runtime .env file (dotenv syntax read * by docker compose). Values are treated as literals — no interpolation. */ private function format_remote_secret_value(string $value): string { return RemoteSecretValueFormatter::dotenv($value); } private function generate_runtime_environment_variables() { $envs = collect([]); $sort = $this->application->settings->is_env_sorting_enabled; if ($sort) { $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('key'); $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->get()->sortBy('key'); } else { $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('id'); $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->get()->sortBy('id'); } if ($this->build_pack === 'dockercompose') { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); $sorted_environment_variables_preview = $sorted_environment_variables_preview->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); } $ports = $this->application->main_port(); $coolify_envs = $this->generate_coolify_env_variables(); $coolify_envs->each(function ($item, $key) use ($envs) { $envs->push($key.'='.escapeComposeEnvFileValue($item)); }); if ($this->pull_request_id === 0) { // Generate SERVICE_ variables first for dockercompose if ($this->build_pack === 'dockercompose') { foreach ($this->composeServiceUrlVariables($this->application->docker_compose_domains) as $key => $value) { $envs->push($key.'='.$value); } // Generate SERVICE_NAME for dockercompose services from processed compose if ($this->application->settings->is_raw_compose_deployment_enabled) { $dockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw); } else { $dockerCompose = parseDockerComposeYaml($this->application->docker_compose); } $services = data_get($dockerCompose, 'services', []); foreach ($services as $serviceName => $_) { $envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName); } } // Filter runtime variables (only include variables that are available at runtime) $runtime_environment_variables = $sorted_environment_variables->filter(function ($env) { return $env->is_runtime; }); // Sort runtime environment variables: those referencing SERVICE_ variables come after others $runtime_environment_variables = $runtime_environment_variables->sortBy(function ($env) { if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) { return 2; } return 1; }); foreach ($runtime_environment_variables as $env) { if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { continue; } $envs->push($this->runtimeEnvironmentAssignment($env)); } // Check for PORT environment variable mismatch with ports_exposes. // With readonly labels disabled, custom labels control proxy routing, so skip the check. if ($this->build_pack !== 'dockercompose' && $this->application->settings->is_container_label_readonly_enabled) { $detectedPort = $this->application->detectPortFromEnvironment(false); if ($detectedPort && ! empty($ports) && ! in_array($detectedPort, $ports)) { $this->application_deployment_queue->addLogEntry( "Warning: PORT environment variable ({$detectedPort}) does not match configured ports_exposes: ".implode(',', $ports).'. It could case "bad gateway" or "no server" errors. Check the "General" page to fix it.', 'stderr' ); } } // 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() && ! empty($ports)) { $envs->push("PORT={$ports[0]}"); } } // Add HOST if not exists if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) { $envs->push('HOST=0.0.0.0'); } } else { // Generate SERVICE_ variables first for dockercompose preview if ($this->build_pack === 'dockercompose') { foreach ($this->composeServiceUrlVariables(data_get($this->preview, 'docker_compose_domains')) as $key => $value) { $envs->push($key.'='.$value); } // Generate SERVICE_NAME for dockercompose services $rawDockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw); $rawServices = data_get($rawDockerCompose, 'services', []); foreach ($rawServices as $rawServiceName => $_) { $envs->push('SERVICE_NAME_'.str($rawServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)); } } // Filter runtime variables for preview (only include variables that are available at runtime) $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) { return $env->is_runtime; }); // Sort runtime environment variables: those referencing SERVICE_ variables come after others $runtime_environment_variables_preview = $runtime_environment_variables_preview->sortBy(function ($env) { if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->contains('${SERVICE_')) { return 2; } return 1; }); foreach ($runtime_environment_variables_preview as $env) { if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { continue; } $envs->push($this->runtimeEnvironmentAssignment($env)); } // A preview from before v4.5.1 also gets the production variables that have no preview copy. if ($this->preview?->keeps_production_values && $runtime_environment_variables_preview->isNotEmpty()) { $previewKeys = $runtime_environment_variables_preview->pluck('key')->all(); $sorted_environment_variables ->filter(fn (EnvironmentVariable $env) => $env->is_runtime && ! in_array($env->key, $previewKeys, true) && ! $this->shouldOmitBlankComposeEnvironmentVariable($env)) ->each(fn (EnvironmentVariable $env) => $envs->push($this->runtimeEnvironmentAssignment($env))); } // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { if ($this->deployedPreviewVariables()->where('key', 'PORT')->isEmpty()) { $envs->push("PORT={$ports[0]}"); } } // Add HOST if not exists if ($this->deployedPreviewVariables()->where('key', 'HOST')->isEmpty()) { $envs->push('HOST=0.0.0.0'); } } // Return the generated environment variables instead of storing them globally return $envs; } /** * A KEY="value" line for the runtime .env file, which Docker Compose reads. Variables saved * before exact escaping keep the old line, so their containers get the same value as before. */ private function runtimeEnvironmentAssignment(EnvironmentVariable $environmentVariable): string { if (! $this->application->useExactEscaping($environmentVariable)) { return $environmentVariable->key.'='.$this->resolve_environment_variable($environmentVariable); } $resolvedValue = (string) $environmentVariable->get_real_environment_variables_with_server( $environmentVariable->value, $this->application, $this->mainServer, ); if (RemoteSecretReferences::containsReference($environmentVariable->value) || RemoteSecretReferences::containsReference($resolvedValue)) { return $environmentVariable->key.'='.escapeComposeEnvFileValue($this->resolve_environment_variable_raw($environmentVariable)); } return $environmentVariable->key.'='.escapeComposeEnvFileValue( $resolvedValue, allowInterpolation: $this->application->environmentVariableAllowsInterpolation($environmentVariable, $resolvedValue), ); } /** * SERVICE_URL_* and SERVICE_FQDN_* values from the compose domains. Port-suffixed variables * that the parser stored (SERVICE_URL_APP_3000) get the same value with the port appended. * * @return array */ private function composeServiceUrlVariables(?string $domainsJson): array { $storedKeys = $this->application->environment_variables()->pluck('key'); $variables = []; foreach (json_decode($domainsJson ?: '[]', true) ?: [] as $forServiceName => $domain) { $parsedDomain = composeDomainEntryString($domain); if (blank($parsedDomain)) { continue; } $url = Url::fromString(str($parsedDomain)->explode(',')->first())->withPort(null); $values = ['URL' => $url->__toString(), 'FQDN' => $url->getHost()]; $serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper()->value(); foreach ($values as $type => $value) { $variables["SERVICE_{$type}_{$serviceEnvKey}"] = $value; } $pattern = '/^SERVICE_(URL|FQDN)_'.preg_quote($serviceEnvKey, '/').'_(\d+)$/'; foreach ($storedKeys as $key) { if (preg_match($pattern, $key, $matches)) { $variables[$key] = "{$values[$matches[1]]}:{$matches[2]}"; } } } return $variables; } private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool { $key = str($environmentVariable->key); return $key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_') || $key->startsWith('SERVICE_NAME_'); } private function shouldOmitBlankComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool { return $this->build_pack === 'dockercompose' && ! $environmentVariable->preservesLegacyComposeNull() && dockerComposeEnvironmentVariableShouldBeUnset( $this->application->docker_compose_raw ?? $this->application->docker_compose, $environmentVariable->key, $environmentVariable->value ); } private function save_runtime_environment_variables() { // This method saves the .env file with ALL runtime variables // For builds, it should be called AFTER the build to include runtime-only variables // Generate runtime environment variables locally $environment_variables = $this->generate_runtime_environment_variables(); // Each .env write first removes the file: with "Preserve repository", `docker cp` can copy a symlink // from the repository to this name. Coolify writes a regular file instead of the link target. // Handle empty environment variables if ($environment_variables->isEmpty()) { // For Docker Compose and Docker Image, we need to create an empty .env file // because we always reference it in the compose file if ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerimage') { $this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).'); // Create empty .env file $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "rm -f $this->workdir/.env && touch $this->workdir/.env"), ] ); // Also create in configuration directory if ($this->use_build_server) { $this->server = $this->mainServer; $this->execute_remote_command( [ "rm -f $this->configuration_dir/.env", 'hidden' => true, ], [ "touch $this->configuration_dir/.env", ] ); $this->server = $this->build_server; } else { $this->execute_remote_command( [ "rm -f $this->configuration_dir/.env", 'hidden' => true, ], [ "touch $this->configuration_dir/.env", ] ); } } else { // For non-Docker Compose deployments, clean up any existing .env files if ($this->use_build_server) { $this->server = $this->mainServer; $this->execute_remote_command( [ 'command' => "rm -f $this->configuration_dir/.env", 'hidden' => true, 'ignore_errors' => true, ] ); $this->server = $this->build_server; $this->execute_remote_command( [ 'command' => "rm -f $this->configuration_dir/.env", 'hidden' => true, 'ignore_errors' => true, ] ); } else { $this->execute_remote_command( [ 'command' => "rm -f $this->configuration_dir/.env", 'hidden' => true, 'ignore_errors' => true, ] ); } } return; } // Write the environment variables to file $envs_base64 = base64_encode($environment_variables->implode("\n")); // Write .env file to workdir (for container runtime) $this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for container.', hidden: true); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "rm -f $this->workdir/.env && echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"), 'skip_command_log' => true, ] ); if (isDev()) { $this->logEnvironmentFileKeys("{$this->workdir}/.env", $environment_variables); } // Write .env file to configuration directory if ($this->use_build_server) { $this->server = $this->mainServer; $this->execute_remote_command( [ "rm -f $this->configuration_dir/.env", 'hidden' => true, ], [ "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", 'skip_command_log' => true, ] ); $this->server = $this->build_server; } else { $this->execute_remote_command( [ "rm -f $this->configuration_dir/.env", 'hidden' => true, ], [ "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", 'skip_command_log' => true, ] ); } } /** * Development aid: log which variables an env file contains, without their values. * * @param Collection $environmentVariables Lines in KEY=VALUE format. */ private function logEnvironmentFileKeys(string $path, Collection $environmentVariables): void { $keys = $environmentVariables ->map(fn (string $line): string => explode('=', $line, 2)[0]) ->implode(', '); $this->application_deployment_queue->addLogEntry("[DEBUG] Variable names in {$path}: {$keys}", hidden: true); } private function generate_buildtime_environment_variables() { if (isDev()) { $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); $this->application_deployment_queue->addLogEntry('[DEBUG] Generating build-time environment variables'); $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); } // Use associative array for automatic deduplication $envs_dict = []; // 1. Add nixpacks plan variables FIRST (lowest priority - can be overridden) if ($this->build_pack === 'nixpacks' && isset($this->nixpacks_plan_json) && $this->nixpacks_plan_json->isNotEmpty()) { $planVariables = data_get($this->nixpacks_plan_json, 'variables', []); if (! empty($planVariables)) { if (isDev()) { $this->application_deployment_queue->addLogEntry('[DEBUG] Adding '.count($planVariables).' nixpacks plan variables to buildtime.env'); } foreach ($planVariables as $key => $value) { $key = (string) $key; // Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) { continue; } $key = $this->validatedBuildtimeEnvironmentVariableKey($key, 'the Nixpacks plan'); $escapedValue = escapeBashEnvValue($value); $envs_dict[$key] = $escapedValue; if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Nixpacks var: {$key}"); } } } } // 2. Add COOLIFY variables (can override nixpacks, but shouldn't happen in practice) $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); foreach ($coolify_envs as $key => $item) { $envs_dict[$key] = escapeBashEnvValue($item); } // 3. Add SERVICE_NAME, SERVICE_FQDN, SERVICE_URL variables for Docker Compose builds if ($this->build_pack === 'dockercompose') { if ($this->pull_request_id === 0) { // Generate SERVICE_NAME for dockercompose services from processed compose if ($this->application->settings->is_raw_compose_deployment_enabled) { $dockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw); } else { $dockerCompose = parseDockerComposeYaml($this->application->docker_compose); } $services = data_get($dockerCompose, 'services', []); foreach ($services as $serviceName => $_) { $envs_dict['SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue($serviceName); } // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments foreach ($this->composeServiceUrlVariables($this->application->docker_compose_domains) as $key => $value) { $envs_dict[$key] = escapeBashEnvValue($value); } } else { // Generate SERVICE_NAME for preview deployments $rawDockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw); $rawServices = data_get($rawDockerCompose, 'services', []); foreach ($rawServices as $rawServiceName => $_) { $envs_dict['SERVICE_NAME_'.str($rawServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)); } // Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains foreach ($this->composeServiceUrlVariables(data_get($this->preview, 'docker_compose_domains')) as $key => $value) { $envs_dict[$key] = escapeBashEnvValue($value); } } } // 4. Add user-defined build-time variables LAST (highest priority - can override everything) if ($this->pull_request_id === 0) { $sorted_environment_variables = $this->application->environment_variables() ->withoutBuildpackControlVariables() ->where('is_buildtime', true) // ONLY build-time variables ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); $sorted_environment_variables = $this->withoutLegacyEnvironmentVariableKeys($sorted_environment_variables); // For Docker Compose, filter out generated SERVICE_* variables as we generate these if ($this->build_pack === 'dockercompose') { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); } foreach ($sorted_environment_variables as $env) { if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { continue; } if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) { continue; } if ($this->uses_remote_secret_references($env)) { $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); continue; } $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { // Strip outer quotes from real_value and apply proper bash escaping $value = trim($resolvedValue, "'"); $escapedValue = escapeBashEnvValue($value); if (isDev() && isset($envs_dict[$env->key])) { $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (value replaced)"); } $envs_dict[$env->key] = $escapedValue; if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline'); $this->application_deployment_queue->addLogEntry('[DEBUG] Value length: '.strlen($escapedValue)); } } else { // For normal vars, use double quotes to allow $VAR expansion $escapedValue = escapeBashDoubleQuoted($resolvedValue); if (isDev() && isset($envs_dict[$env->key])) { $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (value replaced)"); } $envs_dict[$env->key] = $escapedValue; if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)'); $this->application_deployment_queue->addLogEntry('[DEBUG] Value length: '.strlen($escapedValue)); } } } } else { $sorted_environment_variables = $this->application->environment_variables_preview() ->whereNotIn('id', $this->previewVariableIdsWithProductionValues()) ->withoutBuildpackControlVariables() ->where('is_buildtime', true) // ONLY build-time variables ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); $sorted_environment_variables = $this->withoutLegacyEnvironmentVariableKeys($sorted_environment_variables); // For Docker Compose, filter out generated SERVICE_* variables as we generate these with PR-specific values if ($this->build_pack === 'dockercompose') { $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); } foreach ($sorted_environment_variables as $env) { if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { continue; } if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) { continue; } if ($this->uses_remote_secret_references($env)) { $envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env)); continue; } $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); // For literal/multiline vars, real_value includes quotes that we need to remove if ($env->is_literal || $env->is_multiline) { // Strip outer quotes from real_value and apply proper bash escaping $value = trim($resolvedValue, "'"); $escapedValue = escapeBashEnvValue($value); if (isDev() && isset($envs_dict[$env->key])) { $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (value replaced)"); } $envs_dict[$env->key] = $escapedValue; if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry('[DEBUG] Type: literal/multiline'); $this->application_deployment_queue->addLogEntry('[DEBUG] Value length: '.strlen($escapedValue)); } } else { // For normal vars, use double quotes to allow $VAR expansion $escapedValue = escapeBashDoubleQuoted($resolvedValue); if (isDev() && isset($envs_dict[$env->key])) { $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (value replaced)"); } $envs_dict[$env->key] = $escapedValue; if (isDev()) { $this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}"); $this->application_deployment_queue->addLogEntry('[DEBUG] Type: normal (allows expansion)'); $this->application_deployment_queue->addLogEntry('[DEBUG] Value length: '.strlen($escapedValue)); } } } } // Convert dictionary back to collection in KEY=VALUE format $envs = collect([]); foreach ($envs_dict as $key => $value) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build-time environment'); $envs->push($key.'='.$value); } // Return the generated environment variables if (isDev()) { $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); $this->application_deployment_queue->addLogEntry("[DEBUG] Total build-time env variables: {$envs->count()}"); $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); } return $envs; } private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string { try { if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) { throw new \InvalidArgumentException('Invalid build-time environment variable key.'); } return $key; } catch (\InvalidArgumentException $exception) { $this->logInvalidBuildtimeEnvironmentVariableKey($key, $origin); // The log already explains the problem and how to fix it. throw DeploymentException::alreadyLogged( "Invalid environment variable name from {$origin}: ".ValidationPatterns::displayShellEnvironmentVariableKey($key).'. Names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', previous: $exception, ); } } /** * Coolify blocks new volumes on multi-server applications, but older applications can have both. * Some applications accept separate data on each server (for example a cache), so the * deployment continues and only shows a warning. */ private function warnAboutVolumesOnMultipleServers(): void { if ($this->pull_request_id !== 0) { return; } if ($this->application->additional_servers()->doesntExist() || $this->application->persistentStorages()->doesntExist()) { return; } $this->application_deployment_queue->addLogEntry('Warning: This application uses multiple servers and has persistent storage. Volumes are not shared between servers, so each server has its own data.', 'stderr'); } /** * Servers that were added before the proxy check can still use another proxy than the primary server. */ private function warnAboutMixedProxiesOnMultipleServers(): void { if ($this->pull_request_id !== 0) { return; } foreach ($this->application->additional_servers as $server) { if ($reason = $this->application->proxyMismatchReason($server)) { $this->application_deployment_queue->addLogEntry("Warning: {$reason} The domains of this application will not work through the proxy on {$server->name}.", 'stderr'); } } } /** * Additional servers pull the image that the main server pushes. Without a registry image, each * server builds its own image, so the servers can run different code. */ private function ensureRegistryImageForMultipleServers(): void { if ($this->pull_request_id !== 0 || $this->application->build_pack === 'dockercompose') { return; } if ($this->application->additional_servers()->doesntExist()) { return; } if (str($this->application->docker_registry_image_name)->isNotEmpty()) { return; } throw new DeploymentException('Before deploying to multiple servers, you must set a Docker image in the General tab. More information: https://coolify.io/docs/knowledge-base/server/multiple-servers'); } /** * Two applications that build the same Docker image name overwrite each other's image tags. */ private function ensureRegistryImageIsNotShared(): void { $otherApplication = $this->application->otherApplicationBuildingSameImage(); if ($otherApplication) { throw new DeploymentException($this->application->sharedImageNameMessage($otherApplication)); } } /** * Build-time names go into shell and Docker build commands, so they must be valid when the * deployment builds an image ($buildsImage, checked right before the build steps). Restarts and * deployments that reuse an existing image do not pass them to a build, so handle() only warns. * Runtime-only variables only go into the .env file: existing ones with names that new variables * can no longer use (such as my-var) keep working, unless the name would break a .env line. * Deployments without a build step (Docker image) never validate build-time names. */ private function validateDeploymentEnvironmentVariableKeys(bool $buildsImage = false): void { $environmentVariables = $this->pull_request_id === 0 ? $this->application->environment_variables()->get(['key', 'is_buildtime', 'is_runtime']) : $this->application->environment_variables_preview()->get(['key', 'is_buildtime', 'is_runtime']); $passesBuildtimeVariables = $this->deploymentPassesBuildtimeVariables(); foreach ($environmentVariables as $environmentVariable) { $key = (string) $environmentVariable->key; if (ValidationPatterns::isValidEnvironmentVariableKey($key)) { continue; } $isEnvFileSafe = $key !== '' && strpbrk($key, "=\n\r\0") === false; $isLegacyBuildtimeKey = preg_match('/\A[A-Za-z0-9_.-]+\z/', $key) === 1; $isPassedToBuild = $environmentVariable->is_buildtime && $passesBuildtimeVariables && $buildsImage; if (! $isEnvFileSafe || ($isPassedToBuild && ! $isLegacyBuildtimeKey)) { $this->validatedBuildtimeEnvironmentVariableKey($key, 'the deployment environment'); } $this->logLegacyEnvironmentVariableKey($key, (bool) $environmentVariable->is_buildtime, (bool) $environmentVariable->is_runtime, $passesBuildtimeVariables); } } /** * Only Docker image deployments never build an image. Other deployments (also restarts, * rollbacks, and previews) can build and pass build-time variables to the build. */ private function deploymentPassesBuildtimeVariables(): bool { return $this->application->build_pack !== 'dockerimage'; } private function logLegacyEnvironmentVariableKey(string $key, bool $isBuildtime, bool $isRuntime, bool $passesBuildtimeVariables): void { $suggestedKey = (string) preg_replace('/[^A-Za-z0-9_]/', '_', $key); if (preg_match('/\A[0-9]/', $suggestedKey) === 1) { $suggestedKey = '_'.$suggestedKey; } $displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key); if ($isBuildtime && $passesBuildtimeVariables) { $this->application_deployment_queue->addLogEntry("⚠️ Build-time variable {$displayKey} uses a name that new variables cannot use. It is skipped during the build".($isRuntime ? ', but is still passed to the container.' : '.'), 'stderr'); } else { if ($isBuildtime) { $this->application_deployment_queue->addLogEntry("⚠️ Build-time variable {$displayKey} uses a name that new variables cannot use. This deployment does not build an image, so it is not used as a build-time variable. Rename it before you use it in a build.", 'stderr'); } if ($isRuntime) { $this->application_deployment_queue->addLogEntry("⚠️ Runtime variable {$displayKey} uses a name that new variables cannot use. It is still passed to the container, but shell scripts cannot read it.", 'stderr'); } if (! $isBuildtime && ! $isRuntime) { $this->application_deployment_queue->addLogEntry("⚠️ Variable {$displayKey} uses a name that new variables cannot use. This deployment does not use it.", 'stderr'); } } $this->application_deployment_queue->addLogEntry(' Suggested name: '.ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey), type: 'info'); } /** * IDs of preview variables that have the same value as the production variable with the same key. * Coolify copies each new production variable to the previews. With public PR deployments, a * preview can run code from a fork, so it gets only the preview values that the user changed. * * @return array */ private function previewVariableIdsWithProductionValues(): array { if ($this->previewVariableIdsWithProductionValues !== null) { return $this->previewVariableIdsWithProductionValues; } if ($this->pull_request_id === 0 || ! $this->application->settings->is_pr_deployments_public_enabled || $this->preview?->keeps_production_values) { return $this->previewVariableIdsWithProductionValues = []; } $productionValues = $this->application->environment_variables()->get()->pluck('value', 'key'); return $this->previewVariableIdsWithProductionValues = $this->application->environment_variables_preview()->get() ->filter(fn (EnvironmentVariable $env) => $productionValues->has($env->key) && $productionValues->get($env->key) === $env->value) ->pluck('id') ->all(); } /** * The preview variables that this preview deployment uses. */ private function deployedPreviewVariables(): Collection { return $this->application->environment_variables_preview->whereNotIn('id', $this->previewVariableIdsWithProductionValues()); } /** * Skip build-time variables with names that new variables can no longer use (such as my-var). * validateDeploymentEnvironmentVariableKeys() logs a warning for them before the deployment starts. */ private function withoutLegacyEnvironmentVariableKeys(Collection $environmentVariables): Collection { return $environmentVariables->filter( fn (EnvironmentVariable $environmentVariable): bool => ValidationPatterns::isValidEnvironmentVariableKey((string) $environmentVariable->key) ); } private function logInvalidBuildtimeEnvironmentVariableKey(string $key, string $origin): void { $displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key); $this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr'); $this->application_deployment_queue->addLogEntry("⚠️ Invalid environment variable name from {$origin}: {$displayKey}", 'stderr'); $this->application_deployment_queue->addLogEntry('Build-time variable names must start with a letter or underscore and contain only letters, numbers, underscores, and dots.', 'stderr'); $this->application_deployment_queue->addLogEntry('💡 How to fix:', type: 'info'); if ($origin === 'the Nixpacks plan') { $this->application_deployment_queue->addLogEntry(' 1. Open nixpacks.toml and check the [variables] section. Quoted keys can contain characters that are not valid environment variable names.', type: 'info'); $this->application_deployment_queue->addLogEntry(' 2. Rename the key to a plain name like MY_VARIABLE (no spaces, shell syntax, or command substitutions).', type: 'info'); $this->logSuggestedShellEnvironmentVariableKey($key); $this->application_deployment_queue->addLogEntry(' 3. Commit, push, and redeploy.', type: 'info'); $this->application_deployment_queue->addLogEntry('Docs: https://nixpacks.com/docs/configuration/file', type: 'info'); } else { $this->application_deployment_queue->addLogEntry(' Rename the environment variable to use only letters, numbers, and underscores, then redeploy.', type: 'info'); $this->logSuggestedShellEnvironmentVariableKey($key); } $this->application_deployment_queue->addLogEntry('----------------------------------------', 'stderr'); } private function logSuggestedShellEnvironmentVariableKey(string $key): void { $suggestedKey = str_replace('.', '_', $key); if ($suggestedKey === $key || preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $suggestedKey) !== 1) { return; } $displaySuggestedKey = ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey); $this->application_deployment_queue->addLogEntry(" Suggested name: {$displaySuggestedKey}", type: 'info'); } /** * Remove variables with dotted names from a Nixpacks or Railpack build. * * Nixpacks and Railpack cut these names at the first dot, and their Debian-based * images run build commands through dash, which drops dotted names from the * environment. The variables are still available at runtime. */ private function withoutDottedBuildVariables(Collection $variables, string $builder): Collection { $dottedKeys = $variables->keys() ->map(fn ($key): string => (string) $key) ->filter(fn (string $key): bool => str_contains($key, '.')) ->values(); if ($dottedKeys->isEmpty()) { return $variables; } foreach ($dottedKeys as $key) { $this->application_deployment_queue->addLogEntry('⚠️ Build-time variable '.ValidationPatterns::displayShellEnvironmentVariableKey($key)." has a dot in its name, which {$builder} cannot pass to the build. It is skipped during the build, but is still passed to the container.", 'stderr'); $this->logSuggestedShellEnvironmentVariableKey($key); } $this->application_deployment_queue->addLogEntry(' If the build needs the value, rename the variable. Otherwise, disable "Available at Buildtime" for it.', type: 'info'); return $variables->reject(fn ($value, $key): bool => str_contains((string) $key, '.')); } private function save_buildtime_environment_variables(?Collection $environment_variables = null) { $environment_variables ??= $this->generate_buildtime_environment_variables(); [$shell_environment_variables, $dotted_environment_variables] = $environment_variables->partition(function (string $environmentVariable): bool { [$key] = explode('=', $environmentVariable, 2); return preg_match(ValidationPatterns::SHELL_ENVIRONMENT_VARIABLE_KEY_PATTERN, $key) === 1; }); if ($dotted_environment_variables->isEmpty()) { $this->useBuildtimeEnvironmentLauncher = false; if ($environment_variables->isNotEmpty()) { $envs_base64 = base64_encode($environment_variables->implode("\n")); $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true); $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'), 'skip_command_log' => true, ]); if (isDev()) { $this->logEnvironmentFileKeys(self::BUILD_TIME_ENV_PATH, $environment_variables); } } elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) { $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true); $this->execute_remote_command([ executeInDocker($this->deployment_uuid, 'touch '.self::BUILD_TIME_ENV_PATH), ]); } return; } $this->useBuildtimeEnvironmentLauncher = true; $launcher = [ '#!/bin/bash', 'set -a', 'source '.self::BUILD_TIME_SHELL_ENV_PATH, 'set +a', ]; $launcher[] = 'exec env \\'; foreach ($dotted_environment_variables as $environmentVariable) { $launcher[] = " {$environmentVariable} \\"; } $launcher[] = ' "$@"'; $files = [ self::BUILD_TIME_ENV_PATH => $environment_variables->implode("\n"), self::BUILD_TIME_SHELL_ENV_PATH => $shell_environment_variables->implode("\n"), self::BUILD_TIME_ENV_LAUNCHER_PATH => implode("\n", $launcher)."\n", ]; $this->application_deployment_queue->addLogEntry('Creating build-time environment files in /artifacts (outside Docker context).', hidden: true); foreach ($files as $path => $contents) { $contents_base64 = base64_encode($contents); $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "echo '$contents_base64' | base64 -d | tee {$path} > /dev/null"), 'skip_command_log' => true, ]); } $this->execute_remote_command([ executeInDocker($this->deployment_uuid, 'chmod 700 '.self::BUILD_TIME_ENV_LAUNCHER_PATH), ]); } private function elixir_finetunes() { if ($this->pull_request_id === 0) { $envType = 'environment_variables'; } else { $envType = 'environment_variables_preview'; } $mix_env = $this->application->{$envType}->where('key', 'MIX_ENV')->first(); if (! $mix_env) { $this->application_deployment_queue->addLogEntry('MIX_ENV environment variable not found.', type: 'error'); $this->application_deployment_queue->addLogEntry('Please add MIX_ENV environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error'); } $secret_key_base = $this->application->{$envType}->where('key', 'SECRET_KEY_BASE')->first(); if (! $secret_key_base) { $this->application_deployment_queue->addLogEntry('SECRET_KEY_BASE environment variable not found.', type: 'error'); $this->application_deployment_queue->addLogEntry('Please add SECRET_KEY_BASE environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error'); } $database_url = $this->application->{$envType}->where('key', 'DATABASE_URL')->first(); if (! $database_url) { $this->application_deployment_queue->addLogEntry('DATABASE_URL environment variable not found.', type: 'error'); $this->application_deployment_queue->addLogEntry('Please add DATABASE_URL environment variable and set it to be build time variable if you facing any issues with the deployment.', type: 'error'); } } private function laravel_finetunes() { if ($this->pull_request_id === 0) { $envType = 'environment_variables'; } else { $envType = 'environment_variables_preview'; } $nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first(); $nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first(); if (! $nixpacks_php_fallback_path) { $nixpacks_php_fallback_path = new EnvironmentVariable; $nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH'; $nixpacks_php_fallback_path->value = '/index.php'; $nixpacks_php_fallback_path->resourceable_id = $this->application->id; $nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application'; $nixpacks_php_fallback_path->is_preview = $this->pull_request_id !== 0; $nixpacks_php_fallback_path->save(); } if (! $nixpacks_php_root_dir) { $nixpacks_php_root_dir = new EnvironmentVariable; $nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR'; $nixpacks_php_root_dir->value = '/app/public'; $nixpacks_php_root_dir->resourceable_id = $this->application->id; $nixpacks_php_root_dir->resourceable_type = 'App\Models\Application'; $nixpacks_php_root_dir->is_preview = $this->pull_request_id !== 0; $nixpacks_php_root_dir->save(); } return [$nixpacks_php_fallback_path, $nixpacks_php_root_dir]; } private function rolling_update() { try { $this->startDeployPhase(); $this->write_compose_file_to_workdir(); if ($this->server->isSwarm()) { $this->application_deployment_queue->addLogEntry('Rolling update started.'); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "docker stack deploy --detach=true --with-registry-auth -c {$this->workdir}{$this->docker_compose_location} {$this->application->uuid}"), ], ); $this->application_deployment_queue->addLogEntry('Rolling update completed.'); } else { if ($this->use_build_server) { $this->write_deployment_configurations(); $this->server = $this->mainServer; } if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) { $this->application_deployment_queue->addLogEntry('----------------------------------------'); if (count($this->application->ports_mappings_array) > 0) { $this->application_deployment_queue->addLogEntry('Application has ports mapped to the host system, rolling update is not supported.'); } if ((bool) $this->application->settings->is_consistent_container_name_enabled) { $this->application_deployment_queue->addLogEntry('Consistent container name feature enabled, rolling update is not supported.'); } if ((bool) $this->application->settings->is_consistent_container_name_enabled && str($this->application->settings->custom_internal_name)->isNotEmpty()) { $this->application_deployment_queue->addLogEntry('Custom internal name is set, rolling update is not supported.'); } if ($this->pull_request_id !== 0) { $this->application->settings->is_consistent_container_name_enabled = true; $this->application_deployment_queue->addLogEntry('Pull request deployment, rolling update is not supported.'); } if (str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) { $this->application_deployment_queue->addLogEntry('Custom IP address is set, rolling update is not supported.'); } $this->stop_running_container(force: true); $this->start_by_compose_file(); } else { $this->application_deployment_queue->addLogEntry('----------------------------------------'); $this->application_deployment_queue->addLogEntry('Rolling update started.'); $this->containerNamesBeforeRollingUpdate = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id) ->pluck('Names') ->all(); $this->start_by_compose_file(); $this->health_check(); $this->wait_for_proxy_to_route_new_container(); $this->stop_running_container(); $this->application_deployment_queue->addLogEntry('Rolling update completed.'); } } } catch (Exception $e) { throw new DeploymentException('Rolling update failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e); } } /** * The proxy adds the new container some seconds after it is healthy. Stopping the old * container before that sends requests to a closed port (502), see #8627. */ private function wait_for_proxy_to_route_new_container(): void { $seconds = $this->application->settings->rollingUpdateDrainSeconds(); if ($seconds === 0 || ! $this->newVersionIsHealthy()) { return; } $oldContainerNames = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id) ->filter(fn ($container) => data_get($container, 'State') === 'running' && data_get($container, 'Names') !== $this->container_name) ->pluck('Names'); if ($oldContainerNames->isEmpty()) { return; } if ($this->proxyLabelsChanged($oldContainerNames)) { // The proxy drops a route that two containers define differently until the old one stops. $this->application_deployment_queue->addLogEntry('The proxy labels changed, so the old containers stop without waiting.'); return; } $this->application_deployment_queue->addLogEntry("Waiting {$seconds} seconds for the proxy to send traffic to the new container."); Sleep::for($seconds)->seconds(); } private function proxyLabelsChanged(Collection $oldContainerNames): bool { $containerNames = $oldContainerNames->prepend($this->container_name)->map(fn (string $name) => escapeshellarg($name))->implode(' '); $output = instant_remote_process(["docker inspect --format '{{json .Config.Labels}}' {$containerNames}"], $this->server, false); return collect(preg_split('/\R/', trim((string) $output))) ->map(fn (string $labels) => collect(json_decode($labels, true) ?: []) ->filter(fn ($value, string $key) => str_starts_with($key, 'traefik.') || str_starts_with($key, 'caddy')) ->sortKeys() ->all()) ->unique() ->count() > 1; } private function health_check() { try { if ($this->server->isSwarm()) { // Implement healthcheck for swarm } else { if ($this->application->isHealthcheckDisabled() && $this->application->custom_healthcheck_found === false) { $this->markNewVersionHealthy(true); return; } if ($this->application->isHealthcheckDisabled()) { $this->application_deployment_queue->addLogEntry('Using the custom healthcheck from the Dockerfile.'); } if ($this->container_name) { $escapedContainerName = escapeshellarg($this->container_name); $counter = 1; $this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.'); if ($this->full_healthcheck_url) { $healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL'; $this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}"); } $this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck."); $sleeptime = 0; while ($sleeptime < $this->application->health_check_start_period) { Sleep::for(1)->seconds(); $sleeptime++; } while ($counter <= $this->application->health_check_retries) { $this->execute_remote_command( [ "docker inspect --format='{{json .State.Health.Status}}' {$escapedContainerName}", 'hidden' => true, 'save' => 'health_check', 'append' => false, ], [ "docker inspect --format='{{json .State.Health.Log}}' {$escapedContainerName}", 'hidden' => true, 'save' => 'health_check_logs', 'append' => false, ], ); $this->application_deployment_queue->addLogEntry("Attempt {$counter} of {$this->application->health_check_retries} | Healthcheck status: {$this->saved_outputs->get('health_check')}"); $health_check_logs = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'Output', '(no logs)'); if (empty($health_check_logs)) { $health_check_logs = '(no logs)'; } $health_check_return_code = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'ExitCode', '(no return code)'); if ($health_check_logs !== '(no logs)' || $health_check_return_code !== '(no return code)') { $this->application_deployment_queue->addLogEntry("Healthcheck logs: {$health_check_logs} | Return code: {$health_check_return_code}"); } $healthCheckStatus = $this->trimmedSavedOutput('health_check')->replace('"', '')->value(); if ($healthCheckStatus === 'healthy') { $this->markNewVersionHealthy(true); $this->application->update(['status' => 'running']); $this->application_deployment_queue->addLogEntry('New container is healthy.'); break; } elseif ($healthCheckStatus === 'unhealthy') { $this->markNewVersionHealthy(false); $this->application_deployment_queue->addLogEntry('New container is unhealthy.', type: 'error'); $this->query_logs(); break; } $counter++; $sleeptime = 0; while ($sleeptime < $this->application->health_check_interval) { Sleep::for(1)->seconds(); $sleeptime++; } } if ($this->trimmedSavedOutput('health_check')->replace('"', '')->value() === 'starting') { $this->query_logs(); } } } } catch (Exception $e) { throw new DeploymentException('Health check failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e); } } /** * Laravel runs failed() on a fresh copy of the job, so the health of the new version is kept outside the job instance. */ private function markNewVersionHealthy(bool $healthy): void { $key = "deployment-new-version-healthy:{$this->deployment_uuid}"; if ($healthy) { Cache::put($key, true, now()->addDay()); } else { Cache::forget($key); } } private function newVersionIsHealthy(): bool { return Cache::has("deployment-new-version-healthy:{$this->deployment_uuid}"); } private function query_logs() { $escapedContainerName = escapeshellarg($this->container_name); $this->application_deployment_queue->addLogEntry('----------------------------------------'); $this->application_deployment_queue->addLogEntry('Container logs:'); $this->execute_remote_command( [ 'command' => "docker logs -n 100 {$escapedContainerName}", 'type' => 'stderr', 'ignore_errors' => true, ], ); $this->application_deployment_queue->addLogEntry('----------------------------------------'); } private function deploy_pull_request() { if ($this->application->build_pack === 'dockerimage') { $this->deploy_dockerimage_buildpack(); return; } if ($this->application->build_pack === 'dockercompose') { $this->deploy_docker_compose_buildpack(); return; } if ($this->use_build_server) { $this->server = $this->build_server; } $this->markNewVersionHealthy(true); $this->generate_image_names(); $this->application_deployment_queue->addLogEntry("Starting pull request (#{$this->pull_request_id}) deployment of {$this->customRepository}:{$this->application->git_branch}."); $this->prepare_builder_image(); $this->check_git_if_build_needed(); $this->clone_repository(); $this->cleanup_git(); $this->validateDeploymentEnvironmentVariableKeys(buildsImage: true); if ($this->application->build_pack === 'nixpacks') { $this->generate_nixpacks_confs(); } $this->generate_compose_file(); // Save build-time .env file BEFORE the build $this->save_buildtime_environment_variables(); $this->generate_build_env_variables(); if ($this->application->build_pack === 'dockerfile') { $this->add_build_env_variables_to_dockerfile(); } $this->build_pull_request_image(); // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } private function build_pull_request_image(): void { match ($this->application->build_pack) { 'railpack' => $this->build_railpack_image(), 'static' => $this->build_static_image(), default => $this->build_image(), }; } private function create_workdir() { if ($this->use_build_server) { $this->server = $this->mainServer; $this->execute_remote_command( [ 'command' => "mkdir -p {$this->configuration_dir}", ], ); $this->server = $this->build_server; $this->execute_remote_command( [ 'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}"), ], [ 'command' => "mkdir -p {$this->configuration_dir}", ], ); } else { $this->execute_remote_command( [ 'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}"), ], [ 'command' => "mkdir -p {$this->configuration_dir}", ], ); } } private function prepare_builder_image(bool $firstTry = true) { $this->checkForCancellation(); $helperImage = coolifyHelperImage(); $helperImage = "{$helperImage}:".getHelperVersion(); // Get user home directory $this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server); instant_remote_process(["mkdir -p {$this->serverUserHomeDir}/.docker/buildx"], $this->server); $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); $env_flags = $this->generate_docker_env_flags_for_secrets(); $buildxMetadataVolume = isDev() && $this->server->isLocalhost() ? '-v coolify-buildx:/root/.docker/buildx' : "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx"; if ($this->use_build_server) { if ($this->dockerConfigFileExists === 'NOK') { throw new DeploymentException("Docker config file (~/.docker/config.json) of the SSH user ({$this->server->user}) not found on the build server ({$this->server->name}). Log in to your registry under Server → Docker Registries on the build server. On a non-root server, a login made with \"sudo docker login\" is saved for root and is not used for builds."); } $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { // docker compose runs in this helper and reads seccomp profile files from the custom Docker options $seccompMounts = $this->pull_request_id === 0 ? seccompProfileMountFlags($this->application->custom_docker_run_options) : ''; if ($this->dockerConfigFileExists === 'OK') { $safeNetwork = escapeshellarg($this->destination->network); $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$seccompMounts} {$helperImage}"; } else { $safeNetwork = escapeshellarg($this->destination->network); $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$seccompMounts} {$helperImage}"; } } if ($firstTry) { $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage"); } else { $this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.'); } $this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true); $this->execute_remote_command( [ $runCommand, 'hidden' => true, 'skip_command_log' => filled($env_flags), ], [ 'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->basedir}"), ], ); $this->run_pre_deployment_command(); } private function restart_builder_container_with_actual_commit() { // Stop the current helper container (no need for rm -f as it was started with --rm) $this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true); // Clear cached env_args to force regeneration with actual SOURCE_COMMIT value $this->env_args = null; // Restart the helper container with updated environment variables (including actual SOURCE_COMMIT) $this->prepare_builder_image(firstTry: false); } /** * Proxy labels are generated for the primary server. On an additional server whose * network has another name, point Caddy to the network the container joins there. */ private function useDestinationNetworkInCaddyLabels(Collection $labels): Collection { $primaryNetwork = $this->application->destination->network; $network = $this->destination->network; if ($network === $primaryNetwork) { return $labels; } return $labels->map(fn ($label) => $label === "caddy_ingress_network={$primaryNetwork}" ? "caddy_ingress_network={$network}" : $label); } /** * @return int The number of deployments that Coolify created on additional servers, including ones that failed to dispatch */ private function deploy_to_additional_destinations(): int { if ($this->application->additional_networks->count() === 0) { return 0; } if ($this->pull_request_id !== 0) { return 0; } $destination_ids = $this->application->additional_networks->pluck('id'); if ($this->server->isSwarm()) { $this->application_deployment_queue->addLogEntry('Additional destinations are not supported in swarm mode.'); return 0; } if ($destination_ids->contains($this->destination->id)) { return 0; } $queued = 0; foreach ($destination_ids as $destination_id) { $destination = StandaloneDocker::find($destination_id); if (! $destination) { continue; } $server = $destination->server; if ($server->team_id !== $this->mainServer->team_id) { $this->application_deployment_queue->addLogEntry("Skipping deployment to {$server->name}. Not in the same team?!"); continue; } $deployment_uuid = new_public_id(); try { // Deploy the exact commit and image tag of the main server, also for a rollback. $result = queue_application_deployment( deployment_uuid: $deployment_uuid, application: $this->application, commit: $this->commit, is_webhook: (bool) $this->application_deployment_queue->is_webhook, is_api: (bool) $this->application_deployment_queue->is_api, no_questions_asked: true, server: $server, destination: $destination, rollback: $this->rollback, docker_registry_image_tag: $this->application_deployment_queue->docker_registry_image_tag, parent_deployment_uuid: $this->deployment_uuid, is_mcp: (bool) $this->application_deployment_queue->is_mcp, ); } catch (Throwable $e) { \Log::warning("Deployment {$this->deployment_uuid} could not queue the deployment to {$server->name}: {$e->getMessage()}"); $this->application_deployment_queue->addLogEntry("Deployment to {$server->name} could not be started: {$e->getMessage()}", 'stderr'); // A deployment that failed to dispatch is already failed; it counts for the combined notification. if (ApplicationDeploymentQueue::query()->where('deployment_uuid', $deployment_uuid)->exists()) { $queued++; } continue; } if ($result['status'] !== 'queued') { $this->application_deployment_queue->addLogEntry("Deployment to {$server->name} was not queued: {$result['message']}", 'stderr'); continue; } $queued++; $deployment_url = base_url().'/project/'.data_get($this->application, 'environment.project.uuid').'/environment/'.data_get($this->application, 'environment.uuid').'/application/'.data_get($this->application, 'uuid')."/deployment/{$deployment_uuid}"; $this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: {$deployment_url}"); } return $queued; } private function set_coolify_variables() { $this->coolify_variables = ''; // Only include SOURCE_COMMIT in build context if enabled in settings if ($this->application->settings->include_source_commit_in_build) { $this->coolify_variables .= 'SOURCE_COMMIT='.escapeShellValue($this->commit).' '; } if ($this->pull_request_id === 0) { $fqdn = $this->application->fqdn; } else { $fqdn = $this->preview->fqdn; } if (isset($fqdn)) { $domains = $this->validDomainsFromFqdn($fqdn); if ($domains->isNotEmpty()) { $url = $domains->map(fn (string $domain) => Url::fromString($domain)->withPort(null)->__toString())->implode(','); $fqdn = $this->hostsFromFqdn($fqdn); if ((int) $this->application->compose_parsing_version >= 3) { $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' '; $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' '; } else { $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' '; $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' '; } } } if (isset($this->application->git_branch)) { $this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' '; } $this->coolify_variables .= 'COOLIFY_RESOURCE_UUID='.escapeShellValue($this->application->uuid).' '; } private function validDomainsFromFqdn(?string $fqdn): Collection { return str($fqdn ?? '')->explode(',') ->map(fn (string $domain) => trim($domain)) ->filter() ->filter(fn (string $domain) => isValidDomainUrl($domain)); } /** * Comma-separated hosts without scheme, port, or path (the COOLIFY_FQDN value). */ private function hostsFromFqdn(?string $fqdn): string { return $this->validDomainsFromFqdn($fqdn) ->map(fn (string $domain) => Url::fromString($domain)->getHost()) ->implode(','); } private function shellAssignmentForDockerfileArg(string $assignment): string { [$key, $value] = array_pad(explode('=', $assignment, 2), 2, null); if ($value === null) { return $assignment; } if (str_starts_with($value, "'") && str_ends_with($value, "'")) { $value = substr($value, 1, -1); $value = str_replace("'\\''", "'", $value); } return "{$key}={$value}"; } private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string { $sshCommand = gitSshCommand($this->customPort, $identityFile); $gitConfigOption = $this->application->gitHttpsCredentialConfigOption(); $gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git'; return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef); } private function update_repository_project_id(): void { $repositorySlug = gitRepositorySlug($this->customRepository); $repository = githubApi($this->source, "repos/{$repositorySlug}"); $repository_project_id = data_get($repository, 'data.id'); if (isset($repository_project_id) && $this->application->repository_project_id !== $repository_project_id) { $this->application->repository_project_id = $repository_project_id; $this->application->save(); } } private function check_git_if_build_needed() { if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) { $this->update_repository_project_id(); } $this->resolve_bitbucket_pull_request_commit(); $this->generate_git_import_commands(); $local_branch = $this->branch; if ($this->pull_request_id !== 0) { $local_branch = "pull/{$this->pull_request_id}/head"; } // Build an exact refspec for ls-remote so we don't match similarly named branches (e.g., changeset-release/main) if ($this->pull_request_id === 0) { $lsRemoteRef = "refs/heads/{$local_branch}"; } else { if ($this->git_type === 'github' || $this->git_type === 'gitea') { $lsRemoteRef = "refs/pull/{$this->pull_request_id}/head"; } elseif ($this->git_type === 'gitlab') { $lsRemoteRef = "refs/merge-requests/{$this->pull_request_id}/head"; } else { // Fallback to the original value if provider-specific ref is unknown $lsRemoteRef = $local_branch; } } $private_key = data_get($this->application, 'private_key.private_key'); if ($private_key) { $private_key = base64_encode($private_key); $customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$this->deployment_uuid}"; $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, 'mkdir -p /root/.ssh'), ], [ executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"), 'hidden' => true, 'skip_command_log' => true, ], [ executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"), ], [ executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, $customSshKeyLocation)), 'hidden' => true, 'save' => 'git_commit_sha', ] ); } else { $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef)), 'hidden' => true, 'save' => 'git_commit_sha', ], ); } if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback && $this->shouldResolveBranchHeadCommit()) { // Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings) // Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines // Note: Git warnings can be on the same line as the result (no newline) $lsRemoteOutput = $this->saved_outputs->get('git_commit_sha'); // Find the part containing a tab (the actual ls-remote result) // Handle cases where warning is on the same line as the result if ($lsRemoteOutput->contains("\t")) { // Get everything from the last occurrence of a valid commit SHA pattern before the tab // A valid commit SHA is 40 hex characters $output = $lsRemoteOutput->value(); // Extract the line with the tab (actual ls-remote result) preg_match('/\b([0-9a-fA-F]{40})(?=\s*\t)/', $output, $matches); if (isset($matches[1])) { $this->commit = $matches[1]; $this->application_deployment_queue->commit = $this->commit; $this->application_deployment_queue->save(); } } } $this->reuse_commit_message(); $this->set_coolify_variables(); // Restart helper container with actual SOURCE_COMMIT value if ($this->application->settings->use_build_secrets && $this->commit !== 'HEAD') { $this->application_deployment_queue->addLogEntry('Restarting helper container with actual SOURCE_COMMIT value.'); $this->restart_builder_container_with_actual_commit(); } } /** * Bitbucket Cloud has no pull request refs over git, so a preview deployment without a webhook * commit (a manual redeploy) builds the commit of the latest earlier deployment of the pull request. * Without such a commit the clone checks out the base branch, so the deployment fails instead. */ private function resolve_bitbucket_pull_request_commit(): void { if ($this->pull_request_id === 0 || $this->git_type !== 'bitbucket' || ! $this->shouldResolveBranchHeadCommit()) { return; } $commit = ApplicationDeploymentQueue::query() ->where('application_id', $this->application->id) ->where('pull_request_id', $this->pull_request_id) ->whereKeyNot($this->application_deployment_queue->id) ->whereNotIn('commit', ['HEAD', '']) ->latest('id') ->value('commit'); if (blank($commit)) { throw new DeploymentException("Coolify cannot find the commit of Bitbucket pull request #{$this->pull_request_id}, because Bitbucket Cloud has no pull request refs over git. Push a commit to the pull request to deploy it with the webhook, or deploy the preview with a commit SHA through the API."); } $this->commit = $commit; $this->application_deployment_queue->commit = $commit; $this->application_deployment_queue->save(); $this->application_deployment_queue->addLogEntry("Using commit {$commit} of the latest deployment of Bitbucket pull request #{$this->pull_request_id}."); } /** * The commit message is read from the cloned repository. Deployments that skip the build or fail * before the clone reuse the message stored by an earlier deployment of the same commit. */ private function reuse_commit_message(): void { if (filled($this->application_deployment_queue->commit_message) || $this->shouldResolveBranchHeadCommit()) { return; } $commitMessage = ApplicationDeploymentQueue::query() ->where('application_id', $this->application->id) ->where('commit', $this->commit) ->whereNotNull('commit_message') ->where('commit_message', '!=', '') ->latest('id') ->value('commit_message'); if (filled($commitMessage)) { $this->application_deployment_queue->update(['commit_message' => $commitMessage]); } } private function shouldResolveBranchHeadCommit(): bool { $commit = trim($this->commit); return $commit === '' || $commit === 'HEAD'; } private function clone_repository() { $this->application_deployment_queue->addLogEntry("\n----------------------------------------"); $this->application_deployment_queue->addLogEntry("Importing {$this->customRepository}:{$this->application->git_branch} (commit sha {$this->commit}) to {$this->basedir}."); if ($this->pull_request_id !== 0) { $this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head."); } $this->import_repository(); $this->create_workdir(); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "cd {$this->workdir} && git log -1 ".escapeshellarg($this->commit).' --pretty=%B'), 'hidden' => true, 'save' => 'commit_message', ] ); if ($this->saved_outputs->get('commit_message')) { $commit_message = $this->trimmedSavedOutput('commit_message'); $this->application_deployment_queue->commit_message = $commit_message->value(); ApplicationDeploymentQueue::whereCommit($this->commit)->whereApplicationId($this->application->id)->update( ['commit_message' => $commit_message->value()] ); } } /** * SSH exits with 255 when the connection drops, which can happen during a long clone. Git exits with * 128 for its own errors, so only 255 is retried. The interrupted clone can still run in the helper * container, so stop it and remove the partial checkout before cloning again. */ private function import_repository(): void { $maxAttempts = config('constants.ssh.max_retries'); for ($attempt = 1; ; $attempt++) { try { $this->execute_remote_command(...$this->gitCommandDefinitions($this->generate_git_import_commands())); return; } catch (DeploymentException $e) { if ($e->getCode() !== 255 || $attempt >= $maxAttempts) { throw $e; } $delay = $this->calculateRetryDelay($attempt - 1); $this->application_deployment_queue->addLogEntry('Lost the SSH connection while importing the repository. Retrying in '.$delay.'s (attempt '.($attempt + 1)."/{$maxAttempts}).", 'stderr'); Sleep::for($delay)->seconds(); $this->execute_remote_command([ executeInDocker($this->deployment_uuid, 'pkill git; rm -rf '.escapeshellarg($this->basedir)), 'hidden' => true, 'ignore_errors' => true, ]); } } } private function generate_git_import_commands() { ['commands' => $commands, 'branch' => $this->branch, 'fullRepoUrl' => $this->fullRepoUrl] = $this->application->generateGitImportCommands( deployment_uuid: $this->deployment_uuid, pull_request_id: $this->pull_request_id, git_type: $this->git_type, commit: $this->commit ); return $commands; } private function gitCommandDefinitions(Collection|array|string $commands): array { if (is_string($commands)) { return [ [ $commands, 'hidden' => true, ], ]; } return collect($commands) ->map(function ($command): array { if (is_string($command)) { return [ $command, 'hidden' => true, ]; } if (is_array($command)) { return $command + ['hidden' => true]; } return [ 'command' => $command, 'hidden' => true, ]; }) ->values() ->all(); } private function cleanup_git() { $this->execute_remote_command( [executeInDocker($this->deployment_uuid, "rm -fr {$this->basedir}/.git")], ); } private function generate_nixpacks_confs() { $nixpacks_command = $this->nixpacks_build_cmd(); $this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command"); $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true], [executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true], ); if ($this->saved_outputs->get('nixpacks_type')) { $this->nixpacks_type = $this->trimmedSavedOutput('nixpacks_type')->value(); if (str($this->nixpacks_type)->isEmpty()) { throw new DeploymentException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers'); } } if ($this->saved_outputs->get('nixpacks_plan')) { $this->nixpacks_plan = $this->saved_outputs->get('nixpacks_plan'); if ($this->nixpacks_plan) { $this->application_deployment_queue->addLogEntry("Found application type: {$this->nixpacks_type}."); $this->application_deployment_queue->addLogEntry("If you need further customization, please check the documentation of Nixpacks: https://nixpacks.com/docs/providers/{$this->nixpacks_type}"); $parsed = json_decode($this->nixpacks_plan, true); // Do any modifications here // We need to generate envs here because nixpacks need to know to generate a proper Dockerfile $this->generate_env_variables(); $merged_envs = $this->withoutDottedBuildVariables( collect(data_get($parsed, 'variables', []))->merge($this->env_args), 'Nixpacks', ); $aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []); if (count($aptPkgs) === 0) { $aptPkgs = ['curl', 'wget']; data_set($parsed, 'phases.setup.aptPkgs', ['curl', 'wget']); } else { if (! in_array('curl', $aptPkgs)) { $aptPkgs[] = 'curl'; } if (! in_array('wget', $aptPkgs)) { $aptPkgs[] = 'wget'; } data_set($parsed, 'phases.setup.aptPkgs', $aptPkgs); } data_set($parsed, 'variables', $merged_envs->toArray()); $is_laravel = data_get($parsed, 'variables.IS_LARAVEL', false); if ($is_laravel) { $variables = $this->laravel_finetunes(); data_set($parsed, 'variables.NIXPACKS_PHP_FALLBACK_PATH', $variables[0]->value); data_set($parsed, 'variables.NIXPACKS_PHP_ROOT_DIR', $variables[1]->value); } if ($this->nixpacks_type === 'elixir') { $this->elixir_finetunes(); } if ($this->nixpacks_type === 'node') { // Check if NIXPACKS_NODE_VERSION is set $variables = data_get($parsed, 'variables', []); if (! isset($variables['NIXPACKS_NODE_VERSION'])) { $this->application_deployment_queue->addLogEntry('----------------------------------------'); $this->application_deployment_queue->addLogEntry('⚠️ NIXPACKS_NODE_VERSION not set. Nixpacks will use Node.js 18 by default, which is EOL.'); $this->application_deployment_queue->addLogEntry('You can override this by setting NIXPACKS_NODE_VERSION=22 in your environment variables.'); } } $this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT); $this->nixpacks_plan_json = collect($parsed); if (isDev()) { $this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true); } else { $parsedForLog = $parsed; unset($parsedForLog['variables']); // remove variables section to avoid exposing ENVs in production logs $this->application_deployment_queue->addLogEntry('Final Nixpacks plan: '.json_encode($parsedForLog, JSON_PRETTY_PRINT), hidden: true); } if ($this->nixpacks_type === 'rust') { // temporary: disable healthcheck for rust because the start phase does not have curl/wget $this->application->health_check_enabled = false; $this->application->save(); } } } } private function nixpacks_build_cmd() { $this->generate_nixpacks_env_variables(); $nixpacks_command = "nixpacks plan -f json {$this->env_nixpacks_args}"; if ($this->application->build_command) { $nixpacks_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); } if ($this->application->start_command) { $nixpacks_command .= ' --start-cmd '.escapeShellValue($this->application->start_command); } if ($this->application->install_command) { $nixpacks_command .= ' --install-cmd '.escapeShellValue($this->application->install_command); } $nixpacks_command .= " {$this->workdir}"; return $nixpacks_command; } private function generate_nixpacks_env_variables() { $this->env_nixpacks_args = collect([]); if ($this->pull_request_id === 0) { foreach ($this->application->nixpacks_environment_variables as $env) { $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue) && $resolvedValue !== '') { $value = ($env->is_literal || $env->is_multiline) ? trim($resolvedValue, "'") : $resolvedValue; $this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}")); } } } else { foreach ($this->application->nixpacks_environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->get() as $env) { $resolvedValue = $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue) && $resolvedValue !== '') { $value = ($env->is_literal || $env->is_multiline) ? trim($resolvedValue, "'") : $resolvedValue; $this->env_nixpacks_args->push('--env '.escapeShellValue("{$env->key}={$value}")); } } } // Add COOLIFY_* environment variables to Nixpacks build context $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { // Only add environment variables with non-null and non-empty values if (! is_null($value) && $value !== '') { $this->env_nixpacks_args->push('--env '.escapeShellValue("{$key}={$value}")); } }); $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } private function is_reserved_docker_client_env_key(?string $key): bool { if (blank($key)) { return false; } return in_array(strtoupper($key), self::DOCKER_CLIENT_ENV_KEYS, true); } private function without_reserved_docker_client_variables(Collection $variables): Collection { return $variables->reject(fn ($value, $key) => $this->is_reserved_docker_client_env_key((string) $key)); } private function generate_railpack_env_variables(): Collection { $variables = $this->railpack_build_variables(); $this->env_railpack_args = $variables ->map(function ($value, $key) { return '--env '.escapeShellValue("{$key}={$value}"); }) ->implode(' '); return $variables; } private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string { if ($this->uses_remote_secret_references($environmentVariable)) { $resolved = $this->resolve_environment_variable_raw($environmentVariable); return $resolved === '' ? null : $resolved; } $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer); if (is_null($resolvedValue) || $resolvedValue === '') { return null; } if ($environmentVariable->is_literal || $environmentVariable->is_multiline) { return trim($resolvedValue, "'"); } return $resolvedValue; } /** * All buildtime variables that must reach the Railpack build. * * Railpack's BuildKit frontend treats every `--env` passed to `railpack prepare` * as a build secret entry in the generated plan, then pairs it with `--secret id=,env=` * on `docker buildx build`. Because Railpack's schema disallows top-level `variables` * (unlike Nixpacks, which bakes variables into the plan), this `--env` → `--secret` * channel is the only way user-defined buildtime variables become available to * commands declared with `useSecrets: true`. */ private function railpack_build_variables(): Collection { $genericBuildVariables = $this->pull_request_id === 0 ? $this->application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get() : $this->application->environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->withoutBuildpackControlVariables()->where('is_buildtime', true)->get(); $railpackVariables = $this->pull_request_id === 0 ? $this->application->railpack_environment_variables()->get() : $this->application->railpack_environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues())->get(); $variables = $this->withoutLegacyEnvironmentVariableKeys($genericBuildVariables->merge($railpackVariables)) ->mapWithKeys(function (EnvironmentVariable $environmentVariable) { $value = $this->normalize_resolved_build_variable_value($environmentVariable); if (is_null($value) || $value === '') { return []; } return [$environmentVariable->key => $value]; }); $variables = $this->withoutDottedBuildVariables($variables, 'Railpack'); if ($this->application->install_command) { $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); } $variables = $this->merge_railpack_deploy_apt_packages($variables); // Mirror Nixpacks behavior: expose COOLIFY_* and SOURCE_COMMIT to the build so apps // (e.g. SPAs baking the public URL) can read them via /run/secrets/. foreach ($this->generate_coolify_env_variables(forBuildTime: true) as $key => $value) { if (! is_null($value) && $value !== '') { $variables->put($key, $value); } } return $variables; } /** * Without the `...` entry, the list replaces the packages that Railpack generates * (for example libatomic1, which Node.js 25+ needs at runtime). */ private function merge_railpack_deploy_apt_packages(Collection $variables): Collection { $packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: []) ->filter() ->values(); if (! $packages->contains('...')) { $packages->prepend('...'); } foreach (['curl', 'wget'] as $package) { if (! $packages->contains($package)) { $packages->push($package); } } $variables->put('RAILPACK_DEPLOY_APT_PACKAGES', $packages->implode(' ')); return $variables; } private function railpack_build_environment_prefix(Collection $variables): string { if ($variables->isEmpty()) { return ''; } return 'env '.$variables ->map(function ($value, $key) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Railpack environment'); return escapeShellValue("{$key}={$value}"); }) ->implode(' ').' '; } private function railpack_build_secret_flags(Collection $variables): string { if ($variables->isEmpty()) { return ''; } return ' '.$variables ->map(function ($value, $key) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Railpack environment'); return '--secret '.escapeShellValue("id={$key},env={$key}"); }) ->implode(' '); } private function railpack_build_command(string $imageName, Collection $variables): string { $variables = $this->without_reserved_docker_client_variables($variables); $cacheArgs = ''; if ($this->force_rebuild) { $cacheArgs = '--no-cache'; } else { $cacheArgs = "--build-arg cache-key='{$this->application->uuid}'"; } if ($variables->isNotEmpty()) { $cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables); } // Build-time variables reach the build through the sourced build-time .env file // (written by save_buildtime_environment_variables), which interpolates shell-style // references such as BETTER_AUTH_URL=$COOLIFY_URL. Passing them inline via `env` // would forward the literal `$COOLIFY_URL` because each value is single-quoted and // `env` does not interpolate its own assignments. Only buildpack control variables // (NIXPACKS_/RAILPACK_) — which are excluded from the build-time .env file and never // need interpolation — are still passed inline. $controlVariables = $variables->filter( fn ($value, $key) => str($key)->startsWith(EnvironmentVariable::BUILDPACK_CONTROL_VARIABLE_PREFIXES) ); $environmentPrefix = $this->railpack_build_environment_prefix($controlVariables); $secretFlags = $this->railpack_build_secret_flags($variables); // The helper image sets RAILPACK_VERSION, so the frontend always matches the Railpack CLI in the helper. // Helper images without it fall back to the version of the Railpack CLI itself. $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v${RAILPACK_VERSION:-$(railpack --version | cut -d " " -f 3)}'; $buildxBuildCommand = "{$environmentPrefix}DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack" ." {$this->addHosts} --network host" ." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\"" ." {$cacheArgs}" ."{$secretFlags}" .' -f /artifacts/railpack-plan.json' .' --progress plain' .' --load' ." -t {$imageName}" ." {$this->workdir}"; return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' ."\n".railpackBuilderLockedScript(railpackBuilderBootstrapScript().'; ('.$this->wrap_build_command_with_env_export($buildxBuildCommand).')'); } private function decode_railpack_config(string $config, string $source): array { try { $decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $exception) { throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception); } if (! is_array($decoded)) { throw new DeploymentException("Invalid {$source}: expected a JSON object."); } return $decoded; } private function is_assoc_array(array $value): bool { if ($value === []) { return false; } return array_keys($value) !== range(0, count($value) - 1); } private function merge_railpack_config(array $base, array $overrides): array { foreach ($overrides as $key => $value) { if ( array_key_exists($key, $base) && is_array($base[$key]) && is_array($value) && $this->is_assoc_array($base[$key]) && $this->is_assoc_array($value) ) { $base[$key] = $this->merge_railpack_config($base[$key], $value); } else { $base[$key] = $value; } } return $base; } private function railpack_config_overrides(): array { return []; } private function generated_railpack_config_relative_path(): string { return self::RAILPACK_GENERATED_CONFIG_PATH; } private function generated_railpack_config_absolute_path(): string { return "{$this->workdir}/".self::RAILPACK_GENERATED_CONFIG_PATH; } private function generate_railpack_config_file(): ?string { $repositoryConfig = []; $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH." && echo 'exists' || echo 'missing'"), 'hidden' => true, 'save' => 'railpack_config_exists', ]); if (str($this->saved_outputs->get('railpack_config_exists'))->trim()->toString() === 'exists') { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH), 'hidden' => true, 'save' => 'railpack_repository_config', ]); $repositoryConfig = $this->decode_railpack_config( $this->saved_outputs->get('railpack_repository_config', ''), 'repository railpack.json' ); } $overrides = $this->railpack_config_overrides(); if ($repositoryConfig === [] && $overrides === []) { return null; } $mergedConfig = $this->merge_railpack_config($repositoryConfig, $overrides); if (! array_key_exists('$schema', $mergedConfig)) { $mergedConfig['$schema'] = 'https://schema.railpack.com'; } try { $encodedConfig = json_encode($mergedConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } catch (JsonException $exception) { throw new DeploymentException("Failed to encode generated Railpack config: {$exception->getMessage()}", $exception->getCode(), $exception); } $configPath = $this->generated_railpack_config_absolute_path(); $encodedConfig = base64_encode($encodedConfig); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}/.coolify"), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, "echo '{$encodedConfig}' | base64 -d | tee {$configPath} > /dev/null"), 'hidden' => true, ] ); if (isDev()) { $this->application_deployment_queue->addLogEntry('Generated Railpack config: '.json_encode($mergedConfig, JSON_PRETTY_PRINT), hidden: true); } return $this->generated_railpack_config_relative_path(); } private function railpack_prepare_command(?string $configFilePath = null): string { $prepare_command = 'railpack prepare'; if ($this->application->build_command) { $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); } if ($this->application->start_command) { $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command); } if ($this->env_railpack_args) { $prepare_command .= " {$this->env_railpack_args}"; } if ($configFilePath) { $prepare_command .= ' --config-file '.escapeShellValue($configFilePath); } $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}"; return $prepare_command; } private function ensure_docker_buildx_available_for_railpack(): void { if ($this->dockerBuildxAvailable) { return; } throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.'); } private function ensure_helper_docker_buildx_available_for_railpack(): void { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, 'DOCKER_CONFIG=/root/.docker docker buildx version >/dev/null 2>&1 && echo available || echo not-available'), 'hidden' => true, 'save' => 'railpack_helper_buildx_available', ]); if (trim((string) $this->saved_outputs->get('railpack_helper_buildx_available')) === 'available') { return; } throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin inside the Coolify helper container. The helper could not find buildx at /root/.docker/cli-plugins/docker-buildx. Pull the latest helper image and retry the deployment.'); } private function build_railpack_image(): void { $this->ensure_docker_buildx_available_for_railpack(); $this->ensure_helper_docker_buildx_available_for_railpack(); $railpackVariables = $this->generate_railpack_env_variables(); $railpackConfigPath = $this->generate_railpack_config_file(); // Step 1: Generate build plan with railpack prepare $prepare_command = $this->railpack_prepare_command($railpackConfigPath); $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.'); $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true, 'skip_command_log' => true], [ executeInDocker($this->deployment_uuid, 'cat /artifacts/railpack-plan.json'), 'hidden' => true, 'save' => 'railpack_plan', ], ); $railpackPlanRaw = $this->saved_outputs->get('railpack_plan'); if (! empty($railpackPlanRaw)) { if (isDev()) { $this->application_deployment_queue->addLogEntry("Final Railpack plan: {$railpackPlanRaw}", hidden: true); } else { $parsedPlan = json_decode($railpackPlanRaw, true); if (is_array($parsedPlan)) { // Strip secrets array to avoid logging variable names in production. unset($parsedPlan['secrets']); $this->application_deployment_queue->addLogEntry('Final Railpack plan: '.json_encode($parsedPlan, JSON_PRETTY_PRINT), hidden: true); } } } // Step 2: Build image using docker buildx with railpack frontend. // Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder. $this->application_deployment_queue->addLogEntry('Building docker image with Railpack.'); $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.'); $image_name = $this->application->settings->is_static ? $this->build_image_name : $this->production_image_name; if ($this->application->settings->is_static && $this->application->static_image) { $this->pull_latest_image($this->staticImage()); } $build_command = $this->railpack_build_command($image_name, $railpackVariables); $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ] ); // Step 3: If static, copy built assets into nginx image if ($this->application->settings->is_static) { $this->build_railpack_static_image(); } } private function build_railpack_static_image(): void { $publishDir = trim($this->application->publish_directory, '/'); $publishDir = $publishDir ? "/{$publishDir}" : ''; $dockerfile = base64_encode("FROM {$this->staticImage()} WORKDIR /usr/share/nginx/html/ LABEL coolify.deploymentId={$this->deployment_uuid} COPY --from={$this->build_image_name} /app{$publishDir} . COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { $nginx_config = base64_encode($this->application->custom_nginx_configuration); } else { $nginx_config = $this->application->settings->is_spa ? base64_encode(defaultNginxConfiguration('spa')) : base64_encode(defaultNginxConfiguration()); } $static_build = $this->dockerBuildkitSupported ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}" : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; $base64_static_build = base64_encode($static_build); $this->execute_remote_command( [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")], [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")], [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true], [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true], [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true], ); } protected function generate_coolify_env_variables(bool $forBuildTime = false): Collection { $coolify_envs = collect([]); $local_branch = $this->branch; if ($this->pull_request_id !== 0) { // Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time // SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) { if ($this->deployedPreviewVariables()->where('key', 'SOURCE_COMMIT')->isEmpty()) { if (! is_null($this->commit)) { $coolify_envs->put('SOURCE_COMMIT', $this->commit); } else { $coolify_envs->put('SOURCE_COMMIT', 'unknown'); } } } if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_FQDN')->isEmpty()) { if ((int) $this->application->compose_parsing_version >= 3) { $coolify_envs->put('COOLIFY_URL', $this->preview->fqdn); } else { $coolify_envs->put('COOLIFY_FQDN', $this->preview->fqdn); } } if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_URL')->isEmpty()) { $url = $this->hostsFromFqdn($this->preview->fqdn); if ((int) $this->application->compose_parsing_version >= 3) { $coolify_envs->put('COOLIFY_FQDN', $url); } else { $coolify_envs->put('COOLIFY_URL', $url); } } if ($this->application->build_pack !== 'dockercompose' || $this->application->compose_parsing_version === '1' || $this->application->compose_parsing_version === '2') { if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_BRANCH')->isEmpty()) { $coolify_envs->put('COOLIFY_BRANCH', $local_branch); } if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) { $coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid); } // Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache if (! $forBuildTime) { if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { $coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name); } } } // Runtime only, so a project or server change does not break the Docker build cache if (! $forBuildTime) { $coolify_envs = $coolify_envs->merge(coolifyDefaultEnvironmentVariables($this->application, $this->deployedPreviewVariables()->pluck('key'), $this->mainServer)); } } else { // Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time // SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) { if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) { if (! is_null($this->commit)) { $coolify_envs->put('SOURCE_COMMIT', $this->commit); } else { $coolify_envs->put('SOURCE_COMMIT', 'unknown'); } } } if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) { if ((int) $this->application->compose_parsing_version >= 3) { $coolify_envs->put('COOLIFY_URL', $this->application->fqdn); } else { $coolify_envs->put('COOLIFY_FQDN', $this->application->fqdn); } } if ($this->application->environment_variables->where('key', 'COOLIFY_URL')->isEmpty()) { $url = $this->hostsFromFqdn($this->application->fqdn); if ((int) $this->application->compose_parsing_version >= 3) { $coolify_envs->put('COOLIFY_FQDN', $url); } else { $coolify_envs->put('COOLIFY_URL', $url); } } if ($this->application->build_pack !== 'dockercompose' || $this->application->compose_parsing_version === '1' || $this->application->compose_parsing_version === '2') { if ($this->application->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) { $coolify_envs->put('COOLIFY_BRANCH', $local_branch); } if ($this->application->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) { $coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid); } // Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache if (! $forBuildTime) { if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) { $coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name); } } } // Runtime only, so a project or server change does not break the Docker build cache if (! $forBuildTime) { $coolify_envs = $coolify_envs->merge(coolifyDefaultEnvironmentVariables($this->application, $this->application->environment_variables->pluck('key'), $this->mainServer)); } } return $coolify_envs; } private function generate_env_variables() { $this->env_args = collect([]); // Only include SOURCE_COMMIT in build args if enabled in settings if ($this->application->settings->include_source_commit_in_build) { $this->env_args->put('SOURCE_COMMIT', $this->commit); } $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { if (! is_null($value) && $value !== '') { $this->env_args->put($key, $value); } }); // For build process, include only environment variables where is_buildtime = true if ($this->pull_request_id === 0) { $envs = $this->application->environment_variables() ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); $envs = $this->withoutLegacyEnvironmentVariableKeys($envs); if ($this->build_pack === 'dockercompose') { $envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); } foreach ($envs as $env) { if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { continue; } $resolvedValue = $this->uses_remote_secret_references($env) ? $this->resolve_environment_variable_raw($env) : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { $this->env_args->put($env->key, $resolvedValue); } } } else { $envs = $this->application->environment_variables_preview() ->whereNotIn('id', $this->previewVariableIdsWithProductionValues()) ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); $envs = $this->withoutLegacyEnvironmentVariableKeys($envs); if ($this->build_pack === 'dockercompose') { $envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env)); } foreach ($envs as $env) { if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { continue; } $resolvedValue = $this->uses_remote_secret_references($env) ? $this->resolve_environment_variable_raw($env) : $env->getResolvedValueWithServer($this->mainServer); if (! is_null($resolvedValue)) { $this->env_args->put($env->key, $resolvedValue); } } } } private function generate_compose_file() { $this->checkForCancellation(); $this->create_workdir(); $ports = $this->application->main_port(); $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->application->fileStorages()->get(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); if (data_get($this->application, 'custom_labels')) { $this->application->parseContainerLabels(); $labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels))); $labels = $labels->filter(function ($value, $key) { return ! Str::startsWith($value, 'coolify.'); }); $this->application->custom_labels = base64_encode($labels->implode("\n")); $this->application->save(); } else { if ($this->application->settings->is_container_label_readonly_enabled) { $labels = collect(generateLabelsApplication($this->application, $this->preview)); } } if ($this->pull_request_id !== 0) { $labels = collect(generateLabelsApplication($this->application, $this->preview)); } $labels = $this->useDestinationNetworkInCaddyLabels($labels); $labels = traefikHostRulesWithoutIpv6Brackets($labels); if ($this->application->settings->is_container_label_escape_enabled) { $labels = $labels->map(function ($value, $key) { return escapeDollarSign($value); }); } $labels = $labels->merge(defaultLabels($this->application->uuid, $this->application->uuid, $this->application->project()->name, $this->application->name, $this->application->environment->name, $this->pull_request_id))->toArray(); // Check for custom HEALTHCHECK if ($this->application->build_pack === 'dockerfile' || $this->application->dockerfile) { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), 'hidden' => true, 'save' => 'dockerfile_from_repo', 'ignore_errors' => true, ]); $this->application->parseHealthcheckFromDockerfile($this->saved_outputs->get('dockerfile_from_repo')); } $custom_network_aliases = []; if (! empty($this->application->custom_network_aliases_array)) { $custom_network_aliases = $this->application->custom_network_aliases_array; } $docker_compose = [ 'services' => [ $this->container_name => [ 'image' => $this->production_image_name, 'container_name' => $this->container_name, 'restart' => RESTART_MODE, ...(! empty($ports) ? ['expose' => $ports] : []), 'networks' => [ $this->destination->network => [ 'aliases' => array_merge( [$this->container_name], $custom_network_aliases ), ], ], 'mem_limit' => $this->application->limits_memory, 'memswap_limit' => $this->application->limits_memory_swap, 'mem_swappiness' => $this->application->limits_memory_swappiness, 'mem_reservation' => $this->application->limits_memory_reservation, 'cpus' => (float) $this->application->limits_cpus, 'cpu_shares' => $this->application->limits_cpu_shares, ], ], 'networks' => [ $this->destination->network => [ 'external' => true, 'name' => $this->destination->network, 'attachable' => true, ], ], ]; // Always use .env file $docker_compose['services'][$this->container_name]['env_file'] = ['.env']; if ($this->application->settings->stop_grace_period !== null) { $docker_compose['services'][$this->container_name]['stop_grace_period'] = $this->application->settings->stopGracePeriodSeconds().'s'; } // An enabled Coolify healthcheck replaces any HEALTHCHECK from the image or Dockerfile. // When it is disabled, the container keeps the HEALTHCHECK of the image (if any). if (! $this->application->isHealthcheckDisabled()) { $healthcheck_command = $this->generate_healthcheck_commands(); if ($healthcheck_command !== null) { $docker_compose['services'][$this->container_name]['healthcheck'] = [ 'test' => [ 'CMD-SHELL', $healthcheck_command, ], 'interval' => $this->application->health_check_interval.'s', 'timeout' => $this->application->health_check_timeout.'s', 'retries' => $this->application->health_check_retries, 'start_period' => $this->application->health_check_start_period.'s', ]; } } if (! is_null($this->application->limits_cpuset)) { data_set($docker_compose, 'services.'.$this->container_name.'.cpuset', $this->application->limits_cpuset); } if ($this->mainServer->isSwarm()) { data_forget($docker_compose, 'services.'.$this->container_name.'.container_name'); data_forget($docker_compose, 'services.'.$this->container_name.'.expose'); data_forget($docker_compose, 'services.'.$this->container_name.'.restart'); data_forget($docker_compose, 'services.'.$this->container_name.'.mem_limit'); data_forget($docker_compose, 'services.'.$this->container_name.'.memswap_limit'); data_forget($docker_compose, 'services.'.$this->container_name.'.mem_swappiness'); data_forget($docker_compose, 'services.'.$this->container_name.'.mem_reservation'); data_forget($docker_compose, 'services.'.$this->container_name.'.cpus'); data_forget($docker_compose, 'services.'.$this->container_name.'.cpuset'); data_forget($docker_compose, 'services.'.$this->container_name.'.cpu_shares'); $docker_compose['services'][$this->container_name]['deploy'] = [ 'mode' => 'replicated', 'replicas' => data_get($this->application, 'swarm_replicas', 1), 'update_config' => [ 'order' => 'start-first', ], 'rollback_config' => [ 'order' => 'start-first', ], 'labels' => $labels, 'resources' => [ 'limits' => [ 'cpus' => $this->application->limits_cpus, 'memory' => $this->application->limits_memory, ], 'reservations' => [ 'cpus' => $this->application->limits_cpus, 'memory' => $this->application->limits_memory, ], ], ]; if (data_get($this->application, 'swarm_placement_constraints')) { $swarm_placement_constraints = Yaml::parse(base64_decode(data_get($this->application, 'swarm_placement_constraints'))); $docker_compose['services'][$this->container_name]['deploy'] = array_merge( $docker_compose['services'][$this->container_name]['deploy'], $swarm_placement_constraints ); } if (data_get($this->application, 'settings.is_swarm_only_worker_nodes')) { $docker_compose['services'][$this->container_name]['deploy']['placement']['constraints'][] = 'node.role == worker'; } if ($this->pull_request_id !== 0) { $docker_compose['services'][$this->container_name]['deploy']['replicas'] = 1; } } else { $docker_compose['services'][$this->container_name]['labels'] = $labels; } if ($this->mainServer->isLogDrainEnabled() && $this->application->isLogDrainEnabled()) { $docker_compose['services'][$this->container_name]['logging'] = generate_log_drain_configuration($this->mainServer); } if ($this->application->settings->is_gpu_enabled) { $docker_compose['services'][$this->container_name]['deploy']['resources']['reservations']['devices'] = [ $this->application->settings->gpuDeviceReservation(), ]; if (ApplicationSetting::parseGpuOptions($this->application->settings->gpu_options) === null) { $this->application_deployment_queue->addLogEntry('GPU options are not a valid key=value list or YAML mapping. They are ignored.'); } } if ($this->application->isHealthcheckDisabled()) { data_forget($docker_compose, 'services.'.$this->container_name.'.healthcheck'); } if (count($this->application->ports_mappings_array) > 0 && $this->pull_request_id === 0) { $docker_compose['services'][$this->container_name]['ports'] = $this->application->ports_mappings_array; } if (count($persistent_storages) > 0) { if (! data_get($docker_compose, 'services.'.$this->container_name.'.volumes')) { $docker_compose['services'][$this->container_name]['volumes'] = []; } $docker_compose['services'][$this->container_name]['volumes'] = array_merge($docker_compose['services'][$this->container_name]['volumes'], $persistent_storages); } if (count($persistent_file_volumes) > 0) { if (! data_get($docker_compose, 'services.'.$this->container_name.'.volumes')) { $docker_compose['services'][$this->container_name]['volumes'] = []; } $docker_compose['services'][$this->container_name]['volumes'] = array_merge($docker_compose['services'][$this->container_name]['volumes'], $persistent_file_volumes->map(function ($item) { return "$item->fs_path:$item->mount_path"; })->toArray()); } if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } if ($this->pull_request_id === 0) { $custom_compose = convertDockerRunToCompose($this->application->custom_docker_run_options); if ($this->application->settings->is_gpu_enabled && data_get($custom_compose, 'deploy.resources.reservations.devices')) { data_forget($custom_compose, 'deploy.resources.reservations.devices'); $this->application_deployment_queue->addLogEntry('GPU access is enabled in the GPU settings, so --gpus in Custom Docker Options is ignored.'); } $unsupported_options = unsupportedDockerRunOptions($this->application->custom_docker_run_options); if (count($unsupported_options) > 0) { $this->application_deployment_queue->addLogEntry('Warning: These custom Docker options are not supported and were not applied: '.implode(', ', $unsupported_options).'. See https://coolify.io/docs/applications/builds/custom-docker-options for the supported options.', 'stderr'); } if ((bool) $this->application->settings->is_consistent_container_name_enabled) { $docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name]; if ($this->container_name !== $this->application->uuid) { unset($docker_compose['services'][$this->container_name]); } if (count($custom_compose) > 0) { $ipv4 = data_get($custom_compose, 'ip.0'); $ipv6 = data_get($custom_compose, 'ip6.0'); data_forget($custom_compose, 'ip'); data_forget($custom_compose, 'ip6'); if ($ipv4) { $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv4_address'] = $ipv4; } if ($ipv6) { $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv6_address'] = $ipv6; } $docker_compose['services'][$this->application->uuid] = array_merge_recursive($docker_compose['services'][$this->application->uuid], $custom_compose); } } else { if (count($custom_compose) > 0) { $ipv4 = data_get($custom_compose, 'ip.0'); $ipv6 = data_get($custom_compose, 'ip6.0'); data_forget($custom_compose, 'ip'); data_forget($custom_compose, 'ip6'); if ($ipv4 || $ipv6) { data_forget($docker_compose['services'][$this->container_name], 'networks'); } if ($ipv4) { $docker_compose['services'][$this->container_name]['networks'][$this->destination->network]['ipv4_address'] = $ipv4; } if ($ipv6) { $docker_compose['services'][$this->container_name]['networks'][$this->destination->network]['ipv6_address'] = $ipv6; } $docker_compose['services'][$this->container_name] = array_merge_recursive($docker_compose['services'][$this->container_name], $custom_compose); } } } $this->docker_compose = Yaml::dump($docker_compose, 10); $this->generated_docker_compose = $this->docker_compose; } /** * The workdir is the build context, so the generated compose file is written only after the image * build. Otherwise `COPY . .`, Nixpacks, and Railpack put it (with the proxy labels) into the image. */ private function write_compose_file_to_workdir(): void { $this->execute_remote_command([ "docker exec {$this->deployment_uuid} rm -f {$this->workdir}/docker-compose.yaml", 'hidden' => true, ], [ "docker exec -i {$this->deployment_uuid} tee {$this->workdir}/docker-compose.yaml > /dev/null", 'input' => $this->docker_compose, 'hidden' => true, ]); } private function generate_local_persistent_volumes() { $local_persistent_volumes = []; foreach ($this->application->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { $volume_name = $persistentStorage->host_path; } else { $volume_name = $persistentStorage->name; } $isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true); if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) { $volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id); } $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } return $local_persistent_volumes; } private function generate_local_persistent_volumes_only_volume_names() { $local_persistent_volumes_names = []; foreach ($this->application->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path) { continue; } $name = $persistentStorage->name; $isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true); if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) { $name = addPreviewDeploymentSuffix($name, $this->pull_request_id); } $local_persistent_volumes_names[$name] = [ 'name' => $name, 'external' => false, ]; } return $local_persistent_volumes_names; } private function generate_healthcheck_commands() { // Handle CMD type healthcheck if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) { $command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command); // Defense in depth: validate command at runtime (matches input validation regex) if (! preg_match('/^[a-zA-Z0-9 \-_.\/:=@,+]+$/', $command) || strlen($command) > 1000) { $this->application_deployment_queue->addLogEntry('Warning: Health check command contains invalid characters or exceeds max length. Falling back to HTTP healthcheck.'); } else { $this->full_healthcheck_url = $command; return $command; } } // HTTP type healthcheck (default) if (! $this->application->health_check_port) { if (! empty($this->application->ports_exposes_array)) { $health_check_port = (int) $this->application->ports_exposes_array[0]; } else { return null; } } else { $health_check_port = (int) $this->application->health_check_port; } if ($this->application->settings->is_static || $this->application->build_pack === 'static') { $health_check_port = 80; } $method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET'); $scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http'); $host = ValidationPatterns::isValidHealthCheckHost($this->application->health_check_host) ? formatHostForUrl($this->application->health_check_host) : 'localhost'; $path = $this->application->health_check_path ? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/') : null; $url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/')); $escapedMethod = escapeshellarg($method); if ($path) { $this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}{$path}"; } else { $this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}/"; } $generated_healthchecks_commands = [ "curl -s -X {$escapedMethod} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1", ]; return implode(' ', $generated_healthchecks_commands); } private function sanitizeHealthCheckValue(string $value, string $pattern, string $default): string { if (preg_match($pattern, $value)) { return $value; } return $default; } /** * Older versions accepted any static image through the API, so a stored value can be one that * Coolify no longer supports. Such a value stops the deployment with a clear error instead of * silently building with another web server image. */ private function staticImage(): string { $image = StaticImageTypes::tryFrom((string) $this->application->static_image); if ($image === null) { $supported = implode(', ', array_column(StaticImageTypes::cases(), 'value')); throw new DeploymentException("The static image '{$this->application->static_image}' is not supported. Select a supported web server ({$supported}) in the application settings and deploy again."); } return $image->value; } private function pull_latest_image(string $image): void { $image = StaticImageTypes::from($image)->value; $this->application_deployment_queue->addLogEntry("Pulling latest image ($image) from the registry."); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, 'docker pull '.escapeshellarg($image)), 'hidden' => true, ] ); } private function build_static_image() { $this->application_deployment_queue->addLogEntry('----------------------------------------'); $this->application_deployment_queue->addLogEntry('Static deployment. Copying static assets to the image.'); if ($this->application->static_image) { $this->pull_latest_image($this->staticImage()); } $dockerfile = base64_encode("FROM {$this->staticImage()} WORKDIR /usr/share/nginx/html/ LABEL coolify.deploymentId={$this->deployment_uuid} COPY . . RUN rm -f /usr/share/nginx/html/nginx.conf RUN rm -f /usr/share/nginx/html/Dockerfile RUN rm -f /usr/share/nginx/html/docker-compose.yaml RUN rm -f /usr/share/nginx/html/.env COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { $nginx_config = base64_encode($this->application->custom_nginx_configuration); } else { if ($this->application->settings->is_spa) { $nginx_config = base64_encode(defaultNginxConfiguration('spa')); } else { $nginx_config = base64_encode(defaultNginxConfiguration()); } } if ($this->dockerBuildkitSupported) { $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"; } else { $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"), ], [ executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"), ], [ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ] ); $this->application_deployment_queue->addLogEntry('Building docker image completed.'); } /** * Wrap a docker build command with environment export from build-time .env file * This enables shell interpolation of variables (e.g., APP_URL=$COOLIFY_URL) * * @param string $build_command The docker build command to wrap * @return string The wrapped command with export statement */ private function wrap_build_command_with_env_export(string $build_command): string { if (! $this->useBuildtimeEnvironmentLauncher) { return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}"; } $escapedBuildCommand = escapeBashEnvValue($build_command); return "cd {$this->workdir} && bash ".self::BUILD_TIME_ENV_LAUNCHER_PATH." /bin/bash -c {$escapedBuildCommand}"; } private function build_image() { // Add Coolify related variables to the build args/secrets if (! $this->dockerSecretsSupported) { // Traditional build args approach - generate COOLIFY_ variables locally $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Coolify build environment'); $this->build_args->push('--build-arg '.escapeshellarg($key)); }); } // Always convert build_args Collection to string for command interpolation $this->build_args = $this->build_args instanceof Collection ? $this->build_args->implode(' ') : (string) $this->build_args; $this->application_deployment_queue->addLogEntry('----------------------------------------'); if ($this->disableBuildCache) { $this->application_deployment_queue->addLogEntry('Docker build cache is disabled. It will not be used during the build process.'); } if ($this->application->build_pack === 'static') { $this->application_deployment_queue->addLogEntry('Static deployment. Copying static assets to the image.'); } else { $this->application_deployment_queue->addLogEntry('Building docker image started.'); $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.'); } if ($this->application->settings->is_static) { if ($this->application->static_image) { $this->pull_latest_image($this->staticImage()); $this->application_deployment_queue->addLogEntry('Continuing with the building process.'); } if ($this->application->build_pack === 'nixpacks') { $this->nixpacks_plan = base64_encode($this->nixpacks_plan); $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true, 'skip_command_log' => true]); if ($this->force_rebuild) { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); if ($this->dockerSecretsSupported) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"); } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } } else { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); if ($this->dockerSecretsSupported) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"); } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ] ); $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]); } else { // Dockerfile buildpack if ($this->dockerSecretsSupported) { // Modify the Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"); } } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets if ($this->force_rebuild) { $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ] ); } $publishDir = trim($this->application->publish_directory, '/'); $publishDir = $publishDir ? "/{$publishDir}" : ''; $dockerfile = base64_encode("FROM {$this->staticImage()} WORKDIR /usr/share/nginx/html/ LABEL coolify.deploymentId={$this->deployment_uuid} COPY --from=$this->build_image_name /app{$publishDir} . COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { $nginx_config = base64_encode($this->application->custom_nginx_configuration); } else { if ($this->application->settings->is_spa) { $nginx_config = base64_encode(defaultNginxConfiguration('spa')); } else { $nginx_config = base64_encode(defaultNginxConfiguration()); } } if ($this->dockerBuildkitSupported) { $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"), ], [ executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"), ], [ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ] ); } else { // Pure Dockerfile based deployment if ($this->application->dockerfile) { if ($this->dockerSecretsSupported) { // Modify the Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; } else { $build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; } } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets if ($this->force_rebuild) { $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args (no --progress for legacy builder compatibility) if ($this->force_rebuild) { $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ] ); } else { if ($this->application->build_pack === 'nixpacks') { $this->nixpacks_plan = base64_encode($this->nixpacks_plan); $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true, 'skip_command_log' => true]); if ($this->force_rebuild) { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); if ($this->dockerSecretsSupported) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } else { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); if ($this->dockerSecretsSupported) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ] ); $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]); } else { // Dockerfile buildpack if ($this->dockerSecretsSupported) { // Modify the Dockerfile to use build secrets $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); // Use BuildKit with secrets $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"); } } elseif ($this->dockerBuildkitSupported) { // BuildKit without secrets if ($this->force_rebuild) { $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"); } } else { // Traditional build with args if ($this->force_rebuild) { $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); } else { $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}"); } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ], [ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true, ] ); } } } $this->application_deployment_queue->addLogEntry('Building docker image completed.'); } private function graceful_shutdown_container(string $containerName, bool $skipRemove = false) { try { $timeout = $this->application->settings->deploymentStopGracePeriodSeconds(); if ($skipRemove) { // The helper container (started with --rm) must also stop when the deployment was cancelled. $this->execute_remote_command( [dockerStopCommand($timeout, escapeshellarg($containerName), $this->server), 'hidden' => true, 'ignore_errors' => true, 'skip_cancellation_check' => true] ); } else { $this->execute_remote_command( [dockerStopCommand($timeout, escapeshellarg($containerName), $this->server), 'hidden' => true, 'ignore_errors' => true] ); $this->removeContainerWithTimeout($containerName); } } catch (Exception $error) { $this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr'); } } private function removeContainerWithTimeout(string $containerName): void { $outputKey = 'container_remove_'.md5($containerName); $this->execute_remote_command([ dockerRemoveCommandWithTimeout($containerName), 'hidden' => true, 'ignore_errors' => true, 'save' => $outputKey, 'append' => false, ]); if (! isset($this->saved_outputs)) { return; } $output = (string) $this->saved_outputs->get($outputKey, ''); if (! str_contains($output, self::CONTAINER_REMOVE_TIMEOUT_MARKER)) { return; } $this->application_deployment_queue->addLogEntry( "Warning: Removing container {$containerName} timed out after 60 seconds. The deployment will continue and cleanup will be retried in 5 minutes.", 'stderr' ); RemoveContainerJob::dispatch($this->server->id, $containerName) ->delay(now()->addMinutes(5)); } private function stop_running_container(bool $force = false) { try { $this->application_deployment_queue->addLogEntry('Removing old containers.'); if ($this->newVersionIsHealthy() || $force) { if ($this->application->settings->is_consistent_container_name_enabled) { $containers = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id); $this->containerNamesToRemove($containers)->each(function (string $containerName) { $this->graceful_shutdown_container($containerName); }); } else { $containers = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id); $this->oldGeneratedContainerNames($containers)->each(function (string $containerName) { $this->graceful_shutdown_container($containerName); }); } } else { if ($this->application->dockerfile || $this->application->build_pack === 'dockerfile' || $this->application->build_pack === 'dockerimage') { $this->application_deployment_queue->addLogEntry('----------------------------------------'); $this->application_deployment_queue->addLogEntry("WARNING: Dockerfile or Docker Image based deployment detected. The healthcheck needs a curl or wget command to check the health of the application. Please make sure that it is available in the image or turn off healthcheck on Coolify's UI."); $this->application_deployment_queue->addLogEntry('----------------------------------------'); } $this->application_deployment_queue->addLogEntry('New container is not healthy, rolling back to the old container.'); $this->failDeployment(); $this->graceful_shutdown_container($this->container_name); } } catch (Exception $e) { // If new version is healthy, this is just cleanup - don't fail the deployment if ($this->newVersionIsHealthy() || $force) { $this->application_deployment_queue->addLogEntry( "Warning: Could not remove old container: {$e->getMessage()}", 'stderr', hidden: true ); return; // Don't re-throw - cleanup failures shouldn't fail successful deployments } // Only re-throw if deployment hasn't succeeded yet throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e); } } /** * The containers to remove when container names are generated per deployment: all except the * new one, and after a rolling update only the ones that ran before its new container started. */ private function oldGeneratedContainerNames(Collection $containers): Collection { $names = $containers->pluck('Names')->filter(); if ($this->pull_request_id === 0) { $names = $names->reject(fn (string $name) => $name === $this->container_name || $name === addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id)); } if ($this->containerNamesBeforeRollingUpdate !== null) { $names = $names->filter(fn (string $name) => in_array($name, $this->containerNamesBeforeRollingUpdate, true)); } return $names->values(); } private function containerNamesToRemove(Collection $containers): Collection { return $containers ->pluck('Names') ->push($this->container_name) ->filter() ->unique() ->values(); } /** * `docker compose up` creates a missing bind source as an empty directory, so content files are * written first. This also gives a server that was added later the files it does not have. */ private function start_by_compose_file() { $this->write_missing_content_files(); try { // Ensure .env file exists before docker compose tries to load it (defensive programming) $this->execute_remote_command( ["touch {$this->configuration_dir}/.env", 'hidden' => true], [$this->destination->proxyConnectCommand(), 'hidden' => true, 'ignore_errors' => true], ); if ($this->application->build_pack === 'dockerimage') { $this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.'); $this->execute_remote_command( [executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} pull"), 'hidden' => true], [executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} up --build -d"), 'hidden' => true], ); } else { if ($this->use_build_server) { $this->execute_remote_command( [serverDockerConfigPrefix($this->server)."{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true], ); } else { $this->execute_remote_command( [executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up --build -d"), 'hidden' => true], ); } } $this->application_deployment_queue->addLogEntry('New container started.'); } catch (Exception $e) { throw new DeploymentException("Failed to start container: {$e->getMessage()}", $e->getCode(), $e); } } private function analyzeBuildTimeVariables($variables) { $userDefinedVariables = collect([]); $dbVariables = $this->pull_request_id === 0 ? $this->application->environment_variables() ->where('is_buildtime', true) ->pluck('key') : $this->application->environment_variables_preview() ->where('is_buildtime', true) ->pluck('key'); foreach ($variables as $key => $value) { if ($dbVariables->contains($key)) { $userDefinedVariables->put($key, $value); } } if ($userDefinedVariables->isEmpty()) { return; } $variablesArray = $userDefinedVariables->toArray(); $warnings = self::analyzeBuildVariables($variablesArray); if (empty($warnings)) { return; } $this->application_deployment_queue->addLogEntry('----------------------------------------'); foreach ($warnings as $warning) { $messages = self::formatBuildWarning($warning); foreach ($messages as $message) { $this->application_deployment_queue->addLogEntry($message, type: 'warning'); } $this->application_deployment_queue->addLogEntry(''); } // Add general advice $this->application_deployment_queue->addLogEntry('💡 Tips to resolve build issues:', type: 'info'); $this->application_deployment_queue->addLogEntry(' 1. Set these variables as "Runtime only" in the environment variables settings', type: 'info'); $this->application_deployment_queue->addLogEntry(' 2. Use different values for build-time (e.g., NODE_ENV=development for build)', type: 'info'); $this->application_deployment_queue->addLogEntry(' 3. Consider using multi-stage Docker builds to separate build and runtime environments', type: 'info'); } private function generate_build_env_variables() { if ($this->application->build_pack === 'nixpacks') { $variables = collect($this->nixpacks_plan_json->get('variables')); } else { $this->generate_env_variables(); $variables = collect([])->merge($this->env_args); } // Analyze build variables for potential issues if ($variables->isNotEmpty()) { $this->analyzeBuildTimeVariables($variables); } if ($this->dockerSecretsSupported) { $this->generate_build_secrets($variables); $this->build_args = ''; } else { $secrets_hash = ''; if ($variables->isNotEmpty()) { $secrets_hash = $this->generate_secrets_hash($variables); } $env_vars = $this->pull_request_id === 0 ? $this->application->environment_variables()->where('is_buildtime', true)->get() : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); // Map variables to include is_multiline flag $vars_with_metadata = $variables->map(function ($value, $key) use ($env_vars) { $env = $env_vars->firstWhere('key', $key); return [ 'key' => $key, 'value' => $value, 'is_multiline' => $env ? $env->is_multiline : false, ]; }); $this->build_args = generateDockerBuildArgs($vars_with_metadata); if ($secrets_hash) { $this->build_args->push('--build-arg '.escapeshellarg("COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}")); } } } private function generate_docker_env_flags_for_secrets() { $this->helperBuildSecretEnvironmentKeys = []; // Only generate env flags if build secrets are enabled and the deployment builds an image if (! $this->application->settings->use_build_secrets || ! $this->deploymentPassesBuildtimeVariables()) { return ''; } // Generate env variables if not already done // This populates $this->env_args with both user-defined and COOLIFY_* variables if (! $this->env_args || $this->env_args->isEmpty()) { $this->generate_env_variables(); } // Invalid names stay out of the helper container: a build fails on them before it starts, // and restarts or deployments that reuse an image do not need them. $variables = $this->env_args->filter(fn ($value, $key): bool => ValidationPatterns::isValidEnvironmentVariableKey((string) $key)); if ($this->build_pack === 'railpack') { $variables = $this->without_reserved_docker_client_variables($variables); } if ($variables->isEmpty()) { return ''; } $vars_array = $this->withRawBuildSecretValues($variables)->map(fn ($value, $key): array => [ 'key' => $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build secret environment'), 'value' => $value, ]); $this->helperBuildSecretEnvironmentKeys = $vars_array->keys()->map(fn ($key): string => (string) $key)->values()->all(); return generateDockerEnvFlags($vars_array); } /** * The build-time values are escaped or quoted for .env files and shell build arguments, where the * shell removes the escapes again. A build secret gets its value from the helper container as it * is, so it gets the raw value of each variable instead. * * @param Collection $variables * @return Collection */ private function withRawBuildSecretValues(Collection $variables): Collection { $environmentVariables = $this->pull_request_id === 0 ? $this->application->environment_variables() : $this->application->environment_variables_preview()->whereNotIn('id', $this->previewVariableIdsWithProductionValues()); $rawValues = $environmentVariables ->where('is_buildtime', true) ->whereIn('key', $variables->keys()->all()) ->get() ->mapWithKeys(fn (EnvironmentVariable $env): array => [$env->key => $this->resolve_environment_variable_raw($env)]); return $variables->map(fn ($value, $key) => $rawValues->get($key, $value)); } /** * Docker Compose prefers the process environment over `--env-file`. A command in the helper container * that pulls or starts services must not see the build-time values that the container holds for build * secrets, so the services get the values of the runtime .env file. */ private function withoutHelperBuildSecretEnvironment(string $command): string { if ($this->helperBuildSecretEnvironmentKeys === []) { return $command; } return 'unset '.implode(' ', $this->helperBuildSecretEnvironmentKeys)."; {$command}"; } private function generate_build_secrets(Collection $variables) { if ($variables->isEmpty()) { $this->build_secrets = ''; return; } $this->build_secrets = $variables ->map(function ($value, $key) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build secret environment'); return '--secret '.escapeshellarg("id={$key},env={$key}"); }) ->implode(' '); // Docker ignores secret values for the build cache, so the hash goes in as a build arg $this->build_secrets .= ' --build-arg '.escapeshellarg('COOLIFY_BUILD_SECRETS_HASH='.$this->generate_secrets_hash($this->withRawBuildSecretValues($variables))); } private function generate_secrets_hash($variables) { if (! $this->secrets_hash_key) { // Use APP_KEY as deterministic hash key to preserve Docker build cache // Random keys would change every deployment, breaking cache even when secrets haven't changed $this->secrets_hash_key = config('app.key'); } if ($variables instanceof Collection) { $secrets_string = $variables ->mapWithKeys(function ($value, $key) { return [$key => $value]; }) ->sortKeys() ->map(function ($value, $key) { return "{$key}={$value}"; }) ->implode('|'); } else { $secrets_string = $variables ->map(function ($env) { return "{$env->key}={$this->resolve_environment_variable($env)}"; }) ->sort() ->implode('|'); } return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key); } /** * Returns the last line of each FROM instruction, so stage ARGs can go directly after it. */ protected function findFromInstructionLines($dockerfile): array { return collect($this->dockerfileInstructions($dockerfile)) ->where('keyword', 'FROM') ->pluck('end') ->all(); } /** * Splits a Dockerfile into instructions with their first and last line. Comments, continuation * lines, and heredoc bodies are part of an instruction, so text such as "from app import x" * in a heredoc is not read as an instruction. * * @return list */ protected function dockerfileInstructions(iterable $dockerfile): array { $escape = '\\'; $instructions = []; $continues = false; $heredocTerminators = []; foreach ($dockerfile as $index => $line) { $trimmed = trim($line); if ($heredocTerminators !== []) { $instructions[array_key_last($instructions)]['end'] = $index; if ($trimmed === $heredocTerminators[0]) { array_shift($heredocTerminators); } continue; } if ($trimmed === '' || str_starts_with($trimmed, '#')) { if ($instructions === [] && preg_match('/^#\s*escape\s*=\s*([\\\\`])\s*$/i', $trimmed, $matches) === 1) { $escape = $matches[1]; } continue; } if ($continues) { $instructions[array_key_last($instructions)]['end'] = $index; } else { $instructions[] = ['keyword' => strtoupper(strtok($trimmed, " \t")), 'start' => $index, 'end' => $index]; } $continues = str_ends_with($trimmed, $escape); $keyword = $instructions[array_key_last($instructions)]['keyword']; if (in_array($keyword, ['RUN', 'COPY', 'ADD'], true) && preg_match_all('/(? 0) { $heredocTerminators = $matches[2]; } } return $instructions; } private function add_build_env_variables_to_dockerfile() { if ($this->dockerSecretsSupported) { // We dont need to add ARG declarations when using Docker build secrets, as variables are passed with --secret flag return; } // Skip ARG injection if disabled by user - preserves Docker build cache if ($this->application->settings->inject_build_args_to_dockerfile === false) { $this->application_deployment_queue->addLogEntry('Skipping Dockerfile ARG injection (disabled in settings).', hidden: true); return; } $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), 'hidden' => true, 'save' => 'dockerfile', 'ignore_errors' => true, ]); $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); // Find all FROM instruction positions $fromLines = $this->findFromInstructionLines($dockerfile); // If no FROM instructions found, skip ARG insertion if (empty($fromLines)) { return; } // Collect all ARG statements to insert $argsToInsert = collect(); if ($this->pull_request_id === 0) { // Only add environment variables that are available during build $envs = $this->application->environment_variables() ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); $envs = $this->withoutLegacyEnvironmentVariableKeys($envs); foreach ($envs as $env) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $env->key, 'the generated Dockerfile'); $argsToInsert->push("ARG {$key}"); } // Add Coolify variables as ARGs if ($this->coolify_variables) { $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) ->filter() ->map(function ($var) { return 'ARG '.$this->shellAssignmentForDockerfileArg($var); }); $argsToInsert = $argsToInsert->merge($coolify_vars); } } else { // Only add preview environment variables that are available during build $envs = $this->application->environment_variables_preview() ->whereNotIn('id', $this->previewVariableIdsWithProductionValues()) ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); $envs = $this->withoutLegacyEnvironmentVariableKeys($envs); foreach ($envs as $env) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $env->key, 'the generated Dockerfile'); $argsToInsert->push("ARG {$key}"); } // Add Coolify variables as ARGs if ($this->coolify_variables) { $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) ->filter() ->map(function ($var) { return 'ARG '.$this->shellAssignmentForDockerfileArg($var); }); $argsToInsert = $argsToInsert->merge($coolify_vars); } } if ($argsToInsert->isNotEmpty()) { $environmentVariables = $envs->mapWithKeys(function ($environmentVariable) { return [$environmentVariable->key => escapeBashEnvValue($this->resolve_environment_variable_raw($environmentVariable))]; }); $secretsHash = $this->generate_secrets_hash($environmentVariables); $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secretsHash}"); } // Development logging to show what ARGs are being injected if (isDev()) { $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); $this->application_deployment_queue->addLogEntry('[DEBUG] Dockerfile ARG Injection'); $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); $this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToInsert->count()); foreach ($argsToInsert as $arg) { // Only show ARG key, not the value (for security) $argKey = str($arg)->after('ARG ')->before('=')->toString(); $this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}"); } } // Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers) if ($argsToInsert->isNotEmpty()) { foreach (array_reverse($fromLines) as $fromLineIndex) { // Insert all ARGs after this FROM instruction foreach ($argsToInsert->reverse() as $arg) { $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); } } } $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), 'hidden' => true, 'skip_command_log' => true, ]); } private function modify_dockerfile_for_secrets($dockerfile_path) { // Only process if build secrets are enabled and we have secrets to mount if (empty($this->build_secrets)) { return; } // Read the Dockerfile $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "cat {$dockerfile_path}"), 'hidden' => true, 'save' => 'dockerfile_content', ]); $dockerfile = str($this->saved_outputs->get('dockerfile_content'))->trim()->explode("\n"); // Add BuildKit syntax directive if not present if (! str_starts_with($dockerfile->first(), '# syntax=')) { $dockerfile->prepend('# syntax=docker/dockerfile:1'); } // Generate env variables if not already done // This populates $this->env_args with both user-defined and COOLIFY_* variables if (! $this->env_args || $this->env_args->isEmpty()) { $this->generate_env_variables(); } $variables = $this->application->build_pack === 'nixpacks' ? collect($this->nixpacks_plan_json->get('variables')) : $this->env_args; if ($variables->isEmpty()) { return; } // Generate mount strings for all secrets $mountStrings = $variables->map(function ($value, $key) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the generated Dockerfile'); return "--mount=type=secret,id={$key},env={$key}"; })->implode(' '); $instructions = collect($this->dockerfileInstructions($dockerfile)); $runLines = $instructions->where('keyword', 'RUN')->pluck('start')->all(); $fromEndLines = $instructions->where('keyword', 'FROM')->pluck('end')->all(); $modified = false; $dockerfile = $dockerfile->flatMap(function ($line, $index) use ($mountStrings, $runLines, $fromEndLines, &$modified) { // Add mount strings to RUN commands that do not have secret mounts yet if (in_array($index, $runLines, true) && ! str_contains($line, '--mount=type=secret')) { $originalCommand = trim(substr(ltrim($line), 3)); $line = "RUN {$mountStrings} {$originalCommand}"; $modified = true; } // A RUN step reads every ARG of its stage, so a changed hash rebuilds it if (in_array($index, $fromEndLines, true)) { $modified = true; return [$line, 'ARG COOLIFY_BUILD_SECRETS_HASH']; } return [$line]; }); if ($modified) { // Write the modified Dockerfile back $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"), 'hidden' => true, ]); } } private function modify_dockerfiles_for_compose($composeFile) { if ($this->application->build_pack !== 'dockercompose') { return; } // Skip ARG injection if disabled by user - preserves Docker build cache if ($this->application->settings->inject_build_args_to_dockerfile === false) { $this->application_deployment_queue->addLogEntry('Skipping Docker Compose Dockerfile ARG injection (disabled in settings).', hidden: true); return; } // Generate env variables if not already done // This populates $this->env_args with both user-defined and COOLIFY_* variables if (! $this->env_args || $this->env_args->isEmpty()) { $this->generate_env_variables(); } $variables = $this->env_args; if ($variables->isEmpty()) { $this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.'); return; } $services = data_get($composeFile, 'services', []); foreach ($services as $serviceName => $service) { if (! isset($service['build'])) { continue; } $dockerfilePath = $this->resolveComposeDockerfilePath($service['build']); if ($dockerfilePath === null) { $this->application_deployment_queue->addLogEntry($this->composeArgInjectionSkippedMessage((string) $serviceName, $service['build'], $variables)); continue; } // Compose resolves relative paths from the project directory (the workdir). $fullDockerfilePath = escapeshellarg(str_starts_with($dockerfilePath, '/') ? $dockerfilePath : "{$this->workdir}/{$dockerfilePath}"); // BusyBox realpath in the helper image accepts no options; a missing file prints nothing. $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "resolved_path=$(realpath {$fullDockerfilePath} 2>/dev/null) && test -f \"\$resolved_path\" && printf '%s' \"\$resolved_path\" || true"), 'hidden' => true, 'save' => 'dockerfile_check_'.$serviceName, ]); // A monorepo context may leave the base directory, but never the cloned repository. $resolvedDockerfilePath = str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString(); if (! str_starts_with($resolvedDockerfilePath, "{$this->basedir}/")) { $this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection."); continue; } $fullDockerfilePath = escapeshellarg($resolvedDockerfilePath); $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "cat {$fullDockerfilePath}"), 'hidden' => true, 'save' => 'dockerfile_content_'.$serviceName, ]); $dockerfileContent = $this->saved_outputs->get('dockerfile_content_'.$serviceName); if (! $dockerfileContent) { continue; } $dockerfile_lines = collect(str($dockerfileContent)->trim()->explode("\n")); $fromIndices = $this->findFromInstructionLines($dockerfile_lines); if (empty($fromIndices)) { $this->application_deployment_queue->addLogEntry("No FROM instruction found in Dockerfile for service {$serviceName}, skipping."); continue; } $isMultiStage = count($fromIndices) > 1; $argsToAdd = collect([]); foreach ($variables as $key => $value) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the generated Dockerfile'); $argsToAdd->push("ARG {$key}"); } if ($argsToAdd->isEmpty()) { $this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add."); continue; } // Development logging to show what ARGs are being injected for Docker Compose if (isDev()) { $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); $this->application_deployment_queue->addLogEntry("[DEBUG] Docker Compose ARG Injection - Service: {$serviceName}"); $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); $this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToAdd->count()); foreach ($argsToAdd as $arg) { $argKey = str($arg)->after('ARG ')->toString(); $this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}"); } } $totalAdded = 0; $offset = 0; foreach ($fromIndices as $stageIndex => $fromIndex) { $adjustedIndex = $fromIndex + $offset; $stageStart = $adjustedIndex + 1; $stageEnd = isset($fromIndices[$stageIndex + 1]) ? $fromIndices[$stageIndex + 1] + $offset : $dockerfile_lines->count(); $existingStageArgs = collect([]); for ($i = $stageStart; $i < $stageEnd; $i++) { $line = $dockerfile_lines->get($i); if (! $line || ! str($line)->trim()->startsWith('ARG')) { break; } $parts = explode(' ', trim($line), 2); if (count($parts) >= 2) { $argPart = $parts[1]; $keyValue = explode('=', $argPart, 2); $existingStageArgs->push($keyValue[0]); } } $stageArgsToAdd = $argsToAdd->filter(function ($arg) use ($existingStageArgs) { $key = str($arg)->after('ARG ')->trim()->toString(); return ! $existingStageArgs->contains($key); }); if ($stageArgsToAdd->isNotEmpty()) { $dockerfile_lines->splice($adjustedIndex + 1, 0, $stageArgsToAdd->toArray()); $totalAdded += $stageArgsToAdd->count(); $offset += $stageArgsToAdd->count(); } } if ($totalAdded > 0) { $dockerfile_base64 = base64_encode($dockerfile_lines->implode("\n")); $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$fullDockerfilePath} > /dev/null"), 'hidden' => true, ]); $stageInfo = $isMultiStage ? ' (multi-stage build, added to '.count($fromIndices).' stages)' : ''; $this->application_deployment_queue->addLogEntry("Added {$totalAdded} ARG declarations to Dockerfile for service {$serviceName}{$stageInfo}."); } else { $this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist."); } if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) { $this->modify_dockerfile_for_secrets($fullDockerfilePath); $this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets."); } } } /** * Returns the Dockerfile path of a Compose build as written (relative to the project directory, * or absolute), or null when Coolify cannot inspect it locally: a remote git context, a path that * Compose fills in from variables, or an inline Dockerfile. The path is untrusted: the caller * quotes it and only uses the resolved file when it is inside the cloned repository. */ private function resolveComposeDockerfilePath(mixed $build): ?string { if (! is_string($build) && ! is_array($build)) { return null; } $context = is_string($build) ? $build : data_get($build, 'context', '.'); $dockerfile = is_array($build) ? data_get($build, 'dockerfile', 'Dockerfile') : 'Dockerfile'; if (! is_string($context) || ! is_string($dockerfile) || $context === '' || (is_array($build) && array_key_exists('dockerfile_inline', $build))) { return null; } if (str_contains($context.$dockerfile, '$') || preg_match('~^[a-z][a-z0-9+.-]*://|^git@~i', $context) === 1) { return null; } return str_starts_with($dockerfile, '/') ? $dockerfile : rtrim($context, '/').'/'.$dockerfile; } /** * Tells the user why Coolify cannot add ARG lines to a Compose service Dockerfile, and which * build-time variables to declare there. Only valid variable names are shown, never values. * * @param Collection $variables */ private function composeArgInjectionSkippedMessage(string $serviceName, mixed $build, Collection $variables): string { $context = is_string($build) ? $build : data_get($build, 'context'); $dockerfile = is_array($build) ? data_get($build, 'dockerfile') : null; $reason = match (true) { is_array($build) && array_key_exists('dockerfile_inline', $build) => 'the service uses an inline Dockerfile (dockerfile_inline)', is_string($context) && preg_match('~^[a-z][a-z0-9+.-]*://|^git@~i', $context) === 1 => 'the build context is a remote Git URL', (is_string($context) && str_contains($context, '$')) || (is_string($dockerfile) && str_contains($dockerfile, '$')) => 'the build context or Dockerfile path uses variables', default => 'Coolify cannot read the build definition', }; $names = $variables->keys() ->map(fn ($key) => (string) $key) ->filter(fn (string $key) => ValidationPatterns::isValidEnvironmentVariableKey($key)) ->sortBy(fn (string $key) => str_starts_with($key, 'COOLIFY_') ? 1 : 0) ->values(); $message = "Skipping ARG injection for service {$serviceName}: {$reason}. Docker ignores build-time variables that the Dockerfile does not declare, so add 'ARG ' lines to the Dockerfile for the ones you need"; if ($names->isEmpty()) { return "{$message}."; } $listedNames = $names->take(10)->implode(', '); $moreCount = $names->count() - 10; return $moreCount > 0 ? "{$message}: {$listedNames} and {$moreCount} more." : "{$message}: {$listedNames}."; } private function add_build_secrets_to_compose($composeFile) { // Generate env variables if not already done // This populates $this->env_args with both user-defined and COOLIFY_* variables if (! $this->env_args || $this->env_args->isEmpty()) { $this->generate_env_variables(); } $variables = $this->env_args; if ($variables->isEmpty()) { return $composeFile; } $secrets = []; foreach ($variables as $key => $value) { $key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Compose build secret environment'); $secrets[$key] = [ 'environment' => $key, ]; } $secretsHash = $this->generate_secrets_hash($this->withRawBuildSecretValues($variables)); $services = data_get($composeFile, 'services', []); foreach ($services as $serviceName => &$service) { if (isset($service['build'])) { if (is_string($service['build'])) { $service['build'] = [ 'context' => $service['build'], ]; } if (! isset($service['build']['secrets'])) { $service['build']['secrets'] = []; } foreach (array_keys($secrets) as $key) { if (! in_array($key, $service['build']['secrets'])) { $service['build']['secrets'][] = $key; } } // Docker ignores secret values for the build cache, so the hash goes in as a build arg $args = $service['build']['args'] ?? []; if ($args instanceof Collection) { $args = $args->toArray(); } if ($args !== [] && array_is_list($args)) { $args[] = "COOLIFY_BUILD_SECRETS_HASH={$secretsHash}"; } else { $args['COOLIFY_BUILD_SECRETS_HASH'] = $secretsHash; } $service['build']['args'] = $args; } } $composeFile['services'] = $services; $existingSecrets = data_get($composeFile, 'secrets', []); if ($existingSecrets instanceof Collection) { $existingSecrets = $existingSecrets->toArray(); } $composeFile['secrets'] = array_replace($existingSecrets, $secrets); $this->application_deployment_queue->addLogEntry('Added build secrets configuration to docker-compose file (using environment variables).'); return $composeFile; } private function validatePathField(string $value, string $fieldName): string { if (! preg_match(ValidationPatterns::FILE_PATH_PATTERN, $value)) { throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters."); } if (str_contains($value, '..')) { throw new \RuntimeException("Invalid {$fieldName}: path traversal detected."); } return $value; } private function validateShellSafeCommand(string $value, string $fieldName): string { if (! preg_match(ValidationPatterns::SHELL_SAFE_COMMAND_PATTERN, $value)) { throw new \RuntimeException("Invalid {$fieldName}: contains forbidden shell characters."); } return $value; } private function validateContainerName(string $value): string { if (! preg_match(ValidationPatterns::CONTAINER_NAME_PATTERN, $value)) { throw new \RuntimeException('Invalid container name: contains forbidden characters.'); } return $value; } /** * Resolve which container to execute a deployment command in. * * For single-container apps, returns the sole container. * For multi-container apps, matches by the user-specified container name. * If no container name is specified for multi-container apps, logs available containers and returns null. */ private function resolveCommandContainer(Collection $containers, ?string $specifiedContainerName, string $commandType): ?array { if ($containers->count() === 0) { return null; } if ($containers->count() === 1) { return $containers->first(); } // Multi-container: require a container name to be specified if (empty($specifiedContainerName)) { $available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', '); $this->application_deployment_queue->addLogEntry( "{$commandType} command: Multiple containers found but no container name specified. Available: {$available}" ); return null; } // Multi-container: match by specified name prefix $prefix = $specifiedContainerName.'-'.$this->application->uuid; foreach ($containers as $container) { $containerName = data_get($container, 'Names'); if (str_starts_with($containerName, $prefix)) { return $container; } } // No match found — log available containers to help the user debug $available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', '); $this->application_deployment_queue->addLogEntry( "{$commandType} command: Container '{$specifiedContainerName}' not found. Available: {$available}" ); return null; } private function run_pre_deployment_command() { if (empty($this->application->pre_deployment_command)) { return; } $containers = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id); if ($containers->count() == 0) { $this->application_deployment_queue->addLogEntry('Pre-deployment command: No running containers found. Skipping.'); return; } $this->application_deployment_queue->addLogEntry('Executing pre-deployment command (see debug log for output/errors).'); $container = $this->resolveCommandContainer($containers, $this->application->pre_deployment_command_container, 'Pre-deployment'); if ($container === null) { throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?'); } $containerName = data_get($container, 'Names'); if ($containerName) { $this->validateContainerName($containerName); } // Security: pre_deployment_command is intentionally treated as arbitrary shell input. // Users (team members with deployment access) need full shell flexibility to run commands // like "php artisan migrate", "npm run build", etc. inside their own application containers. // The trust boundary is at the application/team ownership level — only authenticated team // members can set these commands, and execution is scoped to the application's own container. // The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not // restrict the command itself. Container names are validated separately via validateContainerName(). // Newlines are normalized to spaces to prevent injection via SSH heredoc transport // (matches the pattern used for health_check_command at line ~2824). $preCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->pre_deployment_command); $cmd = "sh -c '".str_replace("'", "'\''", $preCommand)."'"; $exec = "docker exec {$containerName} {$cmd}"; $this->execute_remote_command( [ 'command' => $exec, 'hidden' => true, ], ); } private function run_post_deployment_command() { if (empty($this->application->post_deployment_command)) { return; } $this->application_deployment_queue->addLogEntry('----------------------------------------'); $this->application_deployment_queue->addLogEntry('Executing post-deployment command (see debug log for output).'); $containers = getCurrentApplicationContainerStatus($this->server, $this->application, $this->pull_request_id); if ($containers->count() == 0) { $this->application_deployment_queue->addLogEntry('Post-deployment command: No running containers found. Skipping.'); return; } $container = $this->resolveCommandContainer($containers, $this->application->post_deployment_command_container, 'Post-deployment'); if ($container === null) { throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?'); } $containerName = data_get($container, 'Names'); if ($containerName) { $this->validateContainerName($containerName); } // Security: post_deployment_command is intentionally treated as arbitrary shell input. // See the equivalent comment in run_pre_deployment_command() for the full security rationale. // Newlines are normalized to spaces to prevent injection via SSH heredoc transport. $postCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->post_deployment_command); $cmd = "sh -c '".str_replace("'", "'\''", $postCommand)."'"; $exec = "docker exec {$containerName} {$cmd}"; try { $this->execute_remote_command( [ 'command' => $exec, 'hidden' => true, 'save' => 'post-deployment-command-output', ], ); } catch (Exception $e) { $post_deployment_command_output = $this->saved_outputs->get('post-deployment-command-output'); if ($post_deployment_command_output) { $this->application_deployment_queue->addLogEntry('Post-deployment command failed.'); $this->application_deployment_queue->addLogEntry($post_deployment_command_output, 'stderr'); } } } /** * Check if the deployment was cancelled and abort if it was */ private function checkForCancellation(): void { $this->application_deployment_queue->refresh(); if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); throw new DeploymentException('Deployment cancelled by user', 69420); } } /** * 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. */ private function transitionToStatus(ApplicationDeploymentStatus $status): void { if ($this->isInTerminalState()) { return; } $this->updateDeploymentStatus($status); // The status is stored. Side effect failures are logged so they cannot flip a finished // deployment to failed or stop the next queued deployment from starting. try { $this->handleStatusTransition($status); } catch (Throwable $e) { $this->logStatusTransitionSideEffectFailure("Post-{$status->value} actions failed", $e); } try { queue_next_deployment($this->application, $this->application_deployment_queue->server_id); } catch (Throwable $e) { $this->logStatusTransitionSideEffectFailure('Starting the next queued deployment failed', $e); } } private function logStatusTransitionSideEffectFailure(string $context, Throwable $e): void { \Log::warning("{$context} for deployment {$this->deployment_uuid}: {$e->getMessage()}"); try { $this->application_deployment_queue->addLogEntry("Warning: {$context}: {$e->getMessage()}", 'stderr'); } catch (Throwable) { // The warning is already in the application log. } } /** * Check if deployment is in a terminal state (FINISHED, FAILED or CANCELLED). * Terminal states cannot be changed. */ private function isInTerminalState(): bool { $this->application_deployment_queue->refresh(); if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FINISHED->value) { return true; } if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) { return true; } if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); $this->handleCancelledDeployment(); throw new DeploymentException('Deployment cancelled by user', 69420); } return false; } /** * A cancelled deployment on an additional server can be the last one of a multi-server * deployment, so it must also trigger the combined notification. */ private function handleCancelledDeployment(): void { if (filled($this->application_deployment_queue->parent_deployment_uuid)) { $this->sendMultiServerDeploymentNotification($this->application_deployment_queue->parent_deployment_uuid); } } /** * Update the deployment status in the database. */ private function updateDeploymentStatus(ApplicationDeploymentStatus $status): void { $this->application_deployment_queue->update([ 'status' => $status->value, ]); } /** * Execute status-specific side effects (events, notifications, additional deployments). */ private function handleStatusTransition(ApplicationDeploymentStatus $status): void { match ($status) { ApplicationDeploymentStatus::FINISHED => $this->handleSuccessfulDeployment(), ApplicationDeploymentStatus::FAILED => $this->handleFailedDeployment(), default => null, }; } /** * Handle side effects when deployment succeeds. */ private function handleSuccessfulDeployment(): void { // Reset restart count after successful deployment // This is done here (not in Livewire) to avoid race conditions // with GetContainersStatus reading old container restart counts $restartState = [ 'restart_count' => 0, 'last_restart_at' => null, 'last_restart_type' => null, ]; if ($this->pull_request_id === 0) { $restartState['restart_limit_reached'] = false; } if ($this->pull_request_id === 0) { $this->application->update($restartState); } else { $this->preview?->resetRestartLimit(); } try { $this->application->markDeploymentConfigurationApplied($this->application_deployment_queue); } catch (Exception $e) { \Log::warning('Failed to mark configuration as applied for deployment '.$this->deployment_uuid.': '.$e->getMessage()); } event(new ApplicationConfigurationChanged($this->application->team()->id)); if (filled($this->application_deployment_queue->parent_deployment_uuid)) { $this->sendMultiServerDeploymentNotification($this->application_deployment_queue->parent_deployment_uuid); return; } $queuedAdditionalDeployments = $this->only_this_server ? 0 : $this->deploy_to_additional_destinations(); if ($queuedAdditionalDeployments === 0) { $this->sendDeploymentNotification(DeploymentSuccess::class); return; } Cache::put("multi-server-deployment-queued:{$this->deployment_uuid}", true, now()->addDay()); $this->sendMultiServerDeploymentNotification($this->deployment_uuid); } /** * Handle side effects when deployment fails. */ private function handleFailedDeployment(): void { if (filled($this->application_deployment_queue->parent_deployment_uuid)) { $this->sendMultiServerDeploymentNotification($this->application_deployment_queue->parent_deployment_uuid); return; } $this->sendDeploymentNotification(DeploymentFailed::class); } /** * The main server queues one deployment for each additional server. When all are queued and the * last one ends, Coolify sends one notification: success if all servers succeeded, else failed * with a link to the first deployment that did not succeed. */ private function sendMultiServerDeploymentNotification(string $parentDeploymentUuid): void { if (! Cache::has("multi-server-deployment-queued:{$parentDeploymentUuid}")) { return; } $deployments = ApplicationDeploymentQueue::query() ->where('parent_deployment_uuid', $parentDeploymentUuid) ->get(['deployment_uuid', 'status']); $endStatuses = [ ApplicationDeploymentStatus::FINISHED->value, ApplicationDeploymentStatus::FAILED->value, ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]; if ($deployments->contains(fn (ApplicationDeploymentQueue $deployment) => ! in_array($deployment->status, $endStatuses, true))) { return; } if (! Cache::add("multi-server-deployment-notified:{$parentDeploymentUuid}", true, now()->addDay())) { return; } $unsuccessful = $deployments->first(fn (ApplicationDeploymentQueue $deployment) => $deployment->status !== ApplicationDeploymentStatus::FINISHED->value); // Additional servers pull the image and do not clone, so only the main deployment has the commit message. $parentDeployment = ApplicationDeploymentQueue::query()->where('deployment_uuid', $parentDeploymentUuid)->first(); $this->application->environment->project->team?->notify($unsuccessful ? new DeploymentFailed($this->application, $unsuccessful->deployment_uuid, $this->preview, $parentDeployment) : new DeploymentSuccess($this->application, $parentDeploymentUuid, $this->preview, $parentDeployment)); } /** * Send deployment status notification to the team. */ private function sendDeploymentNotification(string $notificationClass): void { $this->application->environment->project->team?->notify( new $notificationClass($this->application, $this->deployment_uuid, $this->preview, $this->application_deployment_queue) ); } /** * Complete deployment successfully. * Sends success notification and triggers additional deployments if needed. */ private function completeDeployment(): void { $this->transitionToStatus(ApplicationDeploymentStatus::FINISHED); } /** * Fail the deployment. * Sends failure notification and queues next deployment. */ protected function failDeployment(): void { $this->transitionToStatus(ApplicationDeploymentStatus::FAILED); } public function failed(Throwable $exception): void { $this->failDeployment(); // failed() runs on a fresh job instance, so remote secrets fetched during the deployment are not loaded here. // Every entry still passes through ApplicationDeploymentQueue::redactLogText(); raw stack traces are not written // because their truncated arguments can hold partial secrets that redaction cannot match. $errorMessage = $exception->getMessage() ?: 'Unknown error occurred'; $errorCode = $exception->getCode(); $errorClass = get_class($exception); $this->application_deployment_queue->addLogEntry('========================================', 'stderr'); if ($exception instanceof TimeoutExceededException) { $this->application_deployment_queue->addLogEntry("Deployment failed: Timeout of {$this->timeout} seconds exceeded. Increase the deployment timeout in the Server settings (Advanced > Builds).", 'stderr'); } elseif ($exception instanceof DeploymentException && $exception->isMessageAlreadyLogged()) { $this->application_deployment_queue->addLogEntry('Deployment failed.', 'stderr'); } else { $this->application_deployment_queue->addLogEntry("Deployment failed: {$errorMessage}", 'stderr'); } $this->application_deployment_queue->addLogEntry("Error type: {$errorClass}", 'stderr', hidden: true); $this->application_deployment_queue->addLogEntry("Error code: {$errorCode}", 'stderr', hidden: true); // Log the exception file and line for debugging $this->application_deployment_queue->addLogEntry("Location: {$exception->getFile()}:{$exception->getLine()}", 'stderr', hidden: true); // Log previous exceptions if they exist (for chained exceptions) $previous = $exception->getPrevious(); if ($previous) { $this->application_deployment_queue->addLogEntry('Caused by:', 'stderr', hidden: true); $previousMessage = $previous->getMessage() ?: 'No message'; $previousClass = get_class($previous); $this->application_deployment_queue->addLogEntry(" {$previousClass}: {$previousMessage}", 'stderr', hidden: true); $this->application_deployment_queue->addLogEntry(" at {$previous->getFile()}:{$previous->getLine()}", 'stderr', hidden: true); } $this->application_deployment_queue->addLogEntry('========================================', 'stderr'); if ($this->application->build_pack !== 'dockercompose') { $code = $exception->getCode(); if ($code !== 69420) { // 69420 means failed to push the image to the registry, so we don't need to remove the new version as it is the currently running one if ($this->application->settings->is_consistent_container_name_enabled || $this->pull_request_id !== 0) { // do not remove already running container for PR deployments } elseif ($this->newVersionIsHealthy()) { // The new container passed its health check, so the old container may already be removed. // A later failure (additional destinations, queue advancement, ...) must not remove the only running version. $this->application_deployment_queue->addLogEntry('Deployment failed after the new version became healthy. Keeping the new version of your application running.', 'stderr'); } else { $this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr'); $this->removeContainerWithTimeout($this->container_name); } } } } }