Files
coolify/app/Jobs/ApplicationDeploymentJob.php
T

6737 lines
324 KiB
PHP

<?php
namespace App\Jobs;
use App\Actions\Docker\GetContainersStatus;
use App\Actions\Shared\EnsureContentFilesOnServer;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\ProcessStatus;
use App\Enums\StaticImageTypes;
use App\Events\ApplicationConfigurationChanged;
use App\Events\ServiceStatusChanged;
use App\Exceptions\DeploymentException;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\ApplicationSetting;
use App\Models\EnvironmentVariable;
use App\Models\GiteaApp;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Notifications\Application\DeploymentFailed;
use App\Notifications\Application\DeploymentSuccess;
use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
use App\Support\RemoteSecretReferences;
use App\Support\RemoteSecretValueFormatter;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\ExecuteRemoteCommand;
use Carbon\Carbon;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
use JsonException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Throwable;
class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, EnvironmentVariableAnalyzer, ExecuteRemoteCommand, InteractsWithQueue, Queueable, SerializesModels;
public const BUILD_TIME_ENV_PATH = '/artifacts/build-time.env';
private const BUILD_FINGERPRINT_LABEL = 'coolify.build-fingerprint';
public const BUILD_TIME_SHELL_ENV_PATH = '/artifacts/build-time-shell.env';
public const BUILD_TIME_ENV_LAUNCHER_PATH = '/artifacts/run-with-build-time-env';
private const BUILD_SCRIPT_PATH = '/artifacts/build.sh';
/**
* The Docker CLI config directory of the helper image, which holds the buildx and compose plugins.
* Set explicitly, because a build-time HOME variable would otherwise move it.
*/
private const HELPER_DOCKER_CONFIG = '/root/.docker';
/**
* System CA bundles of the supported server distributions, in the order that Go checks them.
*
* @var list<string>
*/
private const HOST_CA_BUNDLE_PATHS = [
'/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
'/etc/ssl/ca-bundle.pem',
'/etc/pki/tls/cacert.pem',
'/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem',
'/etc/ssl/cert.pem',
];
/**
* Go programs in the helper (the Docker CLI, which fetches registry tokens for BuildKit) read every file
* in /etc/ssl/certs, so this file adds the server CAs to the helper CAs. OpenSSL (git, curl) ignores it.
*/
private const HELPER_HOST_CA_BUNDLE_PATH = '/etc/ssl/certs/coolify-host-ca-certificates.crt';
private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json';
private const RAILPACK_REPOSITORY_CONFIG_PATH = 'railpack.json';
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
private const CONTAINER_REMOVE_TIMEOUT_MARKER = '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__';
private const DOCKER_CLIENT_ENV_KEYS = [
'BUILDKIT_HOST',
'BUILDX_BUILDER',
'BUILDX_CONFIG',
'DOCKER_API_VERSION',
'DOCKER_BUILDKIT',
'DOCKER_CERT_PATH',
'DOCKER_CLI_EXPERIMENTAL',
'DOCKER_CONFIG',
'DOCKER_CONTEXT',
'DOCKER_HOST',
'DOCKER_TLS',
'DOCKER_TLS_VERIFY',
];
public $tries = 1;
public $timeout = 3600;
public static int $batch_counter = 0;
private ApplicationDeploymentQueue $application_deployment_queue;
private Application $application;
private string $deployment_uuid;
private int $pull_request_id;
private string $commit;
private bool $rollback;
private bool $force_rebuild;
private bool $restart_only;
private ?string $docker_compose_build_fingerprint = null;
private ?string $dockerImage = null;
private ?string $dockerImageTag = null;
private ?string $dockerImagePreviewTag = null;
private GithubApp|GitlabApp|GiteaApp|string $source = 'other';
private StandaloneDocker|SwarmDocker $destination;
// Deploy to Server
private Server $server;
// Build Server
private Server $build_server;
private bool $use_build_server = false;
private Server $mainServer;
private bool $is_this_additional_server = false;
private ?ApplicationPreview $preview = null;
private ?string $git_type = null;
private bool $only_this_server = false;
private string $container_name;
private ?string $currently_running_container_name = null;
/**
* Containers that ran before the rolling update started its new container. Only these are
* removed after it, so the new container of another deployment of the application stays.
*
* @var list<string>|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<string, string>|null */
private ?array $remote_secrets_cache = null;
/** @var array<int, int>|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<int, string>
*/
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->application->mustBuildOnBuildServer($this->server);
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 = $this->application->buildServerCandidates($this->server);
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;
}
// Admission reserved a build server with a free build slot. A forced start reserved none, and
// the reserved server can become unusable before the job runs.
$reservedId = $this->application_deployment_queue->build_server_id;
$this->build_server = ($reservedId === null ? null : $buildServers->firstWhere('id', $reservedId))
?? least_busy_build_server($buildServers)
?? least_busy_build_server($buildServers, onlyWithFreeSlot: false);
// 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->composeProjectName()} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache";
} else {
$command .= " --project-name {$this->composeProjectName()} --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<int, string>
*/
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<array-key, mixed>
*
* @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->composeProjectName()} --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->composeProjectName()} --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<int, string>
*/
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();
}
/**
* The Docker Compose project of this deployment. Each preview has its own project, so Docker
* Compose does not treat the containers of the application and of the other previews as orphans
* of the deployed preview, and networks that the Compose file declares are not shared between them.
*/
private function composeProjectName(): string
{
return addPreviewDeploymentSuffix($this->application->uuid, $this->pull_request_id);
}
/**
* Removes the containers that `docker compose up` would give a volume that the user deleted
* in Coolify (see composeContainersWithRemovedVolumes()).
*/
private function remove_containers_with_removed_volumes(): void
{
$this->execute_remote_command([
composeProjectMountsCommand($this->application->uuid),
'hidden' => true,
'ignore_errors' => true,
'save' => 'compose_project_mounts',
'append' => false,
]);
$services = data_get(parseDockerComposeYaml((string) $this->generated_docker_compose), 'services');
$containers = composeContainersWithRemovedVolumes(
(string) $this->saved_outputs->get('compose_project_mounts', ''),
is_array($services) ? $services : [],
$this->application->uuid,
$this->application->persistentStorages()->pluck('name')->all(),
);
foreach ($containers as $containerName => $volumeName) {
$this->application_deployment_queue->addLogEntry("Removing container {$containerName}, so that it does not keep the deleted volume {$volumeName}.");
// Without --time, Docker waits the stop_grace_period of the Compose file, as Compose does.
$this->execute_remote_command(['docker stop '.escapeshellarg($containerName), 'hidden' => true, 'ignore_errors' => true]);
$this->removeContainerWithTimeout($containerName);
}
}
/**
* 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();
$this->remove_containers_with_removed_volumes();
$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->composeProjectName()} --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->composeProjectName()} --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-<id>` 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<string, string>
*/
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<string, string>
*/
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<int, string> $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<int, int>
*/
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}",
],
);
}
}
/**
* Mount the server CA bundle, so that the helper trusts the same registries as the Docker daemon,
* for example behind a TLS-inspecting proxy with a CA that is only installed on the server.
*/
private function hostCaBundleMount(): string
{
$paths = implode(' ', self::HOST_CA_BUNDLE_PATHS);
$bundle = instant_remote_process(
["for f in {$paths}; do if [ -f \"\$f\" ]; then echo \"\$f\"; break; fi; done"],
$this->server,
throwError: false,
no_sudo: true,
);
if (! in_array($bundle, self::HOST_CA_BUNDLE_PATHS, true)) {
return '';
}
// Unlike -v, --mount does not create a missing source as a directory on the server.
return " --mount type=bind,source={$bundle},target=".self::HELPER_HOST_CA_BUNDLE_PATH.',readonly';
}
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);
// Build secrets put user variables such as HOME or DOCKER_CONFIG into the helper environment.
// DOCKER_CONFIG comes after them, so the Docker CLI keeps its plugins. Without build secrets the
// helper environment stays empty, so Docker Compose interpolates the user DOCKER_CONFIG from the env file.
$secretEnvFlags = $this->generate_docker_env_flags_for_secrets();
$env_flags = filled($secretEnvFlags) ? "{$secretEnvFlags} -e DOCKER_CONFIG=".self::HELPER_DOCKER_CONFIG : '';
$buildxMetadataVolume = isDev() && $this->server->isLocalhost()
? '-v coolify-buildx:/root/.docker/buildx'
: "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
$hostCaBundleVolume = $this->hostCaBundleMount();
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}{$hostCaBundleVolume} -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}{$hostCaBundleVolume} -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}{$hostCaBundleVolume} -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.');
}
// "docker stop" returns before the --rm auto-removal frees the name, so the "docker run" below can conflict with it.
$this->removeContainerWithTimeout($this->deployment_uuid);
$this->execute_remote_command(
[
$runCommand,
'hidden' => true,
'skip_command_log' => filled($secretEnvFlags),
],
[
'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->basedir}"),
],
);
$this->run_pre_deployment_command();
}
private function restart_builder_container_with_actual_commit()
{
// Clear cached env_args to force regeneration with actual SOURCE_COMMIT value
$this->env_args = null;
// prepare_builder_image() removes the current helper before it starts the new one
$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).' ';
}
}
}
$branch = $this->application->coolifyBranch($this->pull_request_id);
if (isset($branch)) {
$this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($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/<KEY>.
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->application->coolifyBranch($this->pull_request_id);
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
{
// A build-time DOCKER_CONFIG must not move the Docker CLI away from its plugins (buildx, compose).
$build_command = 'export DOCKER_CONFIG='.self::HELPER_DOCKER_CONFIG." && {$build_command}";
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->composeProjectName()} --project-directory {$this->workdir} pull"), 'hidden' => true],
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->composeProjectName()} --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->composeProjectName()} --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->composeProjectName()} --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<string, mixed> $variables
* @return Collection<string, mixed>
*/
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<array{keyword: string, start: int, end: int}>
*/
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('/(?<!<)<<-?(["\']?)([A-Za-z_][A-Za-z0-9_]*)\1/', $line, $matches) > 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<string, mixed> $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 <NAME>' 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);
}
}
}
}
}