mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
Add a recommendations page with dismiss and restore controls, and default new applications to Docker Build Secrets. Warn when Dockerfile syntax or variable usage is incompatible with build secrets.
6314 lines
304 KiB
PHP
6314 lines
304 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\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\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';
|
|
|
|
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';
|
|
|
|
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 $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;
|
|
|
|
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;
|
|
|
|
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;
|
|
$this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile';
|
|
$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->warnAboutVolumesOnMultipleServers();
|
|
$this->warnAboutMixedProxiesOnMultipleServers();
|
|
} catch (Exception $e) {
|
|
$this->fail($e);
|
|
throw $e;
|
|
}
|
|
|
|
$this->application_deployment_queue->update([
|
|
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
|
'horizon_job_worker' => gethostname(),
|
|
]);
|
|
if ($this->server->isFunctionalAfterRecheck() === false) {
|
|
$this->application_deployment_queue->addLogEntry('Server is not functional.');
|
|
$this->fail('Server is not functional.');
|
|
|
|
return;
|
|
}
|
|
try {
|
|
// Make sure the private key is stored in the filesystem
|
|
$this->server->privateKey->storeInFileSystem();
|
|
// Generate custom host<->ip mapping
|
|
$safeNetwork = escapeshellarg($this->destination->network);
|
|
$allContainers = instant_remote_process(["docker network inspect {$safeNetwork} -f '{{json .Containers}}' "], $this->server);
|
|
|
|
if (! is_null($allContainers)) {
|
|
$allContainers = format_docker_command_output_to_json($allContainers);
|
|
$this->addHosts = count($allContainers) > 0 ? $this->addHostFlags(collect($allContainers[0])) : '';
|
|
}
|
|
|
|
if ($this->application->dockerfile_target_build) {
|
|
$target = $this->application->dockerfile_target_build;
|
|
if (! preg_match(ValidationPatterns::DOCKER_TARGET_PATTERN, $target)) {
|
|
throw new \RuntimeException('Invalid dockerfile_target_build: contains forbidden characters.');
|
|
}
|
|
$this->buildTarget = " --target {$target} ";
|
|
}
|
|
|
|
// Check custom port
|
|
['repository' => $this->customRepository, 'port' => $this->customPort] = $this->application->customRepository();
|
|
|
|
$this->selectBuildServer();
|
|
$this->detectBuildKitCapabilities();
|
|
$this->decide_what_to_do();
|
|
} catch (Exception $e) {
|
|
if ($this->pull_request_id !== 0 && $this->application->isGithubAppSource()) {
|
|
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::ERROR);
|
|
}
|
|
$this->fail($e);
|
|
throw $e;
|
|
} finally {
|
|
// Wrap cleanup operations in try-catch to prevent exceptions from interfering
|
|
// with Laravel's job failure handling and status updates
|
|
try {
|
|
$this->application_deployment_queue->update([
|
|
'finished_at' => Carbon::now()->toImmutable(),
|
|
]);
|
|
} catch (Exception $e) {
|
|
// Log but don't fail - finished_at is not critical
|
|
\Log::warning('Failed to update finished_at for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
|
}
|
|
|
|
try {
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->build_server;
|
|
} else {
|
|
$this->write_deployment_configurations();
|
|
}
|
|
} catch (Exception $e) {
|
|
// Log but don't fail - configuration writing errors shouldn't prevent status updates
|
|
$this->application_deployment_queue->addLogEntry('Warning: Failed to write deployment configurations: '.$e->getMessage(), 'stderr');
|
|
}
|
|
|
|
try {
|
|
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
|
|
$this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true);
|
|
} catch (Exception $e) {
|
|
// Log but don't fail - container cleanup errors are expected when container is already gone
|
|
\Log::warning('Failed to shutdown container '.$this->deployment_uuid.': '.$e->getMessage());
|
|
}
|
|
|
|
try {
|
|
ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
|
|
} catch (Exception $e) {
|
|
// Log but don't fail - event dispatch errors shouldn't prevent status updates
|
|
\Log::warning('Failed to dispatch ServiceStatusChanged for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
private function selectBuildServer(): void
|
|
{
|
|
$this->build_server = $this->server;
|
|
|
|
// An additional server of a multi-server application does not build: it pulls the image the main server pushed.
|
|
if ($this->is_this_additional_server && str($this->application->docker_registry_image_name)->isNotEmpty()) {
|
|
// The main deployment already built the image (also for a forced rebuild or disabled build cache).
|
|
// A forced rebuild would skip the registry pull. Compose applications build on each server that can build.
|
|
if ($this->application->build_pack !== 'dockercompose' || ! $this->server->canBuildApplications()) {
|
|
$this->force_rebuild = false;
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('Additional server: pulls the image from the registry, no build server needed.');
|
|
|
|
return;
|
|
}
|
|
|
|
// A deployments-only server never builds. Docker image and Compose applications are exempt:
|
|
// the first builds nothing and the second does not support build servers.
|
|
$mustBuildElsewhere = ! $this->server->canBuildApplications()
|
|
&& ! in_array($this->application->build_pack, ['dockerimage', 'dockercompose'], true);
|
|
|
|
if (! $mustBuildElsewhere && ! data_get($this->application, 'settings.is_build_server_enabled')) {
|
|
return;
|
|
}
|
|
|
|
if ($mustBuildElsewhere && ! $this->restart_only && str($this->application->docker_registry_image_name)->isEmpty()) {
|
|
throw new DeploymentException("The deployment server ({$this->server->name}) is set to deployments only, so this application is built on a build server. Set a Docker image name in the application's General settings so the deployment server can pull the built image.");
|
|
}
|
|
|
|
$team = $this->application->environment->project->team;
|
|
$buildServers = Server::buildServers($team->id)->whereKeyNot($this->server->id)->get();
|
|
|
|
if ($buildServers->isEmpty()) {
|
|
// A restart only rebuilds when the image is missing, so it may still run on the deployment server.
|
|
if ($mustBuildElsewhere && ! $this->restart_only) {
|
|
throw new DeploymentException("The deployment server ({$this->server->name}) is set to deployments only, and no usable build server was found. Add a build server or change the server role.");
|
|
}
|
|
// A restart that has to rebuild selects again in should_skip_build().
|
|
if (! $team->is_build_server_fallback_enabled && ! $this->restart_only) {
|
|
throw new DeploymentException('No available dedicated build server was found. Enable a usable build server for this team or allow fallback to the deployment server in the team settings.');
|
|
}
|
|
|
|
$this->application_deployment_queue->addLogEntry('No suitable build server found. Using the deployment server.');
|
|
|
|
return;
|
|
}
|
|
|
|
$this->build_server = $buildServers->random();
|
|
$this->application_deployment_queue->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(
|
|
[
|
|
"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->deleteStorageOnServer($this->mainServer);
|
|
$fileStorage->saveStorageOnServer($this->mainServer);
|
|
} elseif ($fileStat->value() === 'regular file' && $fileStorage->is_directory) {
|
|
$fileStorage->is_directory = false;
|
|
$fileStorage->is_based_on_git = true;
|
|
$fileStorage->save();
|
|
$fileStorage->deleteStorageOnServer($this->mainServer);
|
|
$fileStorage->saveStorageOnServer($this->mainServer);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$this->generate_image_names();
|
|
$this->cleanup_git();
|
|
$this->validateDeploymentEnvironmentVariableKeys(buildsImage: true);
|
|
|
|
$this->generate_build_env_variables();
|
|
|
|
$this->loadComposeFileForDeployment();
|
|
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();
|
|
if (empty($composeFile)) {
|
|
$this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.');
|
|
$this->fail('Failed to parse docker-compose file.');
|
|
|
|
return;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
$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,
|
|
]);
|
|
|
|
// 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
|
|
$this->save_buildtime_environment_variables();
|
|
|
|
if ($this->docker_compose_custom_build_command) {
|
|
// Auto-inject -f (compose file) and --env-file flags using helper function
|
|
$build_command = injectDockerComposeFlags(
|
|
$this->docker_compose_custom_build_command,
|
|
"{$this->workdir}{$this->docker_compose_location}",
|
|
self::BUILD_TIME_ENV_PATH
|
|
);
|
|
|
|
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
|
|
if ($this->dockerBuildkitSupported) {
|
|
$build_command = "DOCKER_BUILDKIT=1 {$build_command}";
|
|
}
|
|
|
|
// Inject build arguments after build subcommand if not using build secrets
|
|
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof Collection && $this->build_args->isNotEmpty()) {
|
|
$build_args_string = $this->build_args->implode(' ');
|
|
|
|
// Inject build args right after 'build' subcommand (not at the end)
|
|
$original_command = $build_command;
|
|
$build_command = injectDockerComposeBuildArgs($build_command, $build_args_string);
|
|
|
|
// Only log if build args were actually injected (command was modified)
|
|
if ($build_command !== $original_command) {
|
|
$this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.');
|
|
}
|
|
}
|
|
|
|
try {
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
|
|
);
|
|
} catch (\RuntimeException $e) {
|
|
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
|
|
throw new DeploymentException("Custom build command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_build_command}");
|
|
}
|
|
|
|
throw $e;
|
|
}
|
|
} else {
|
|
$command = "{$this->coolify_variables} docker compose";
|
|
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
|
|
if ($this->dockerBuildkitSupported) {
|
|
$command = "DOCKER_BUILDKIT=1 {$command}";
|
|
}
|
|
// Use build-time .env file from /artifacts (outside Docker context to prevent it from being in the image)
|
|
$command .= ' --env-file '.self::BUILD_TIME_ENV_PATH;
|
|
if ($this->force_rebuild) {
|
|
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache";
|
|
} else {
|
|
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull";
|
|
}
|
|
|
|
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof Collection && $this->build_args->isNotEmpty()) {
|
|
$build_args_string = $this->build_args->implode(' ');
|
|
$command .= " {$build_args_string}";
|
|
$this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
|
|
}
|
|
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, $command), 'hidden' => true],
|
|
);
|
|
}
|
|
|
|
// 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->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.');
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @return Collection<array-key, mixed>
|
|
*/
|
|
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));
|
|
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 = "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}";
|
|
} else {
|
|
$workdir = $this->workdir;
|
|
$command = executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}");
|
|
}
|
|
|
|
$this->execute_remote_command([
|
|
$command,
|
|
'hidden' => true,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Returns the names of the Compose services that get their image from a registry. A service with a
|
|
* `build` section, or a service that uses the image of such a service, gets its image from the build.
|
|
*
|
|
* @return Collection<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();
|
|
}
|
|
|
|
/**
|
|
* Starts the Compose containers. Content files are written first, because `docker compose up`
|
|
* creates a missing bind source as an empty directory.
|
|
*/
|
|
private function start_docker_compose_services(): void
|
|
{
|
|
$this->write_missing_content_files();
|
|
|
|
$server_workdir = $this->application->workdir();
|
|
if ($this->application->settings->is_raw_compose_deployment_enabled) {
|
|
if ($this->docker_compose_custom_start_command) {
|
|
// Auto-inject -f (compose file) and --env-file flags using helper function
|
|
$start_command = injectDockerComposeFlags(
|
|
$this->docker_compose_custom_start_command,
|
|
"{$server_workdir}{$this->docker_compose_location}",
|
|
"{$server_workdir}/.env"
|
|
);
|
|
|
|
$this->write_deployment_configurations();
|
|
|
|
try {
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, "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, "cd {$this->basedir} && {$start_command}"), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
|
|
);
|
|
}
|
|
} else {
|
|
$command = "{$this->coolify_variables} docker compose";
|
|
if ($this->preserveRepository) {
|
|
// Always use .env file
|
|
$command .= " --env-file {$server_workdir}/.env";
|
|
$command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
|
|
$this->write_deployment_configurations();
|
|
|
|
$this->execute_remote_command(
|
|
['command' => $command, 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
|
|
);
|
|
} else {
|
|
// Always use .env file
|
|
$command .= " --env-file {$this->workdir}/.env";
|
|
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d";
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, $command), 'hidden' => false, 'type' => 'stdout', 'command_hidden' => true],
|
|
);
|
|
$this->write_deployment_configurations();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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(),
|
|
$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->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;
|
|
}
|
|
|
|
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([
|
|
'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') {
|
|
$domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []);
|
|
|
|
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
|
|
// Env keys always use underscore-normalized names so hyphen/dot storage keys stay valid.
|
|
foreach ($domains as $forServiceName => $domain) {
|
|
$parsedDomain = composeDomainEntryString($domain);
|
|
if (filled($parsedDomain)) {
|
|
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
|
$coolifyUrl = Url::fromString($parsedDomain);
|
|
$coolifyScheme = $coolifyUrl->getScheme();
|
|
$coolifyFqdn = $coolifyUrl->getHost();
|
|
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
|
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
|
|
$envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString());
|
|
$envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn);
|
|
}
|
|
}
|
|
|
|
// 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
|
|
if ($this->build_pack !== 'dockercompose') {
|
|
$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') {
|
|
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []);
|
|
|
|
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
|
|
foreach ($domains as $forServiceName => $domain) {
|
|
$parsedDomain = composeDomainEntryString($domain);
|
|
if (filled($parsedDomain)) {
|
|
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
|
$coolifyUrl = Url::fromString($parsedDomain);
|
|
$coolifyScheme = $coolifyUrl->getScheme();
|
|
$coolifyFqdn = $coolifyUrl->getHost();
|
|
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
|
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
|
|
$envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString());
|
|
$envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn);
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
|
|
// 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),
|
|
);
|
|
}
|
|
|
|
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
|
|
$domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []);
|
|
foreach ($domains as $forServiceName => $domain) {
|
|
$parsedDomain = composeDomainEntryString($domain);
|
|
if (filled($parsedDomain)) {
|
|
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
|
$coolifyUrl = Url::fromString($parsedDomain);
|
|
$coolifyScheme = $coolifyUrl->getScheme();
|
|
$coolifyFqdn = $coolifyUrl->getHost();
|
|
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
|
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
|
|
$envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString());
|
|
$envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn);
|
|
}
|
|
}
|
|
} 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
|
|
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []);
|
|
foreach ($domains as $forServiceName => $domain) {
|
|
$parsedDomain = composeDomainEntryString($domain);
|
|
if (filled($parsedDomain)) {
|
|
$parsedDomain = str($parsedDomain)->explode(',')->first();
|
|
$coolifyUrl = Url::fromString($parsedDomain);
|
|
$coolifyScheme = $coolifyUrl->getScheme();
|
|
$coolifyFqdn = $coolifyUrl->getHost();
|
|
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
|
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
|
|
$envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString());
|
|
$envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
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()
|
|
{
|
|
$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->checkForCancellation();
|
|
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->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->custom_healthcheck_found) {
|
|
$this->application_deployment_queue->addLogEntry('Custom healthcheck found in 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 && ! $this->application->custom_healthcheck_found) {
|
|
$healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL';
|
|
$this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}");
|
|
}
|
|
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
|
|
$sleeptime = 0;
|
|
while ($sleeptime < $this->application->health_check_start_period) {
|
|
Sleep::for(1)->seconds();
|
|
$sleeptime++;
|
|
}
|
|
while ($counter <= $this->application->health_check_retries) {
|
|
$this->execute_remote_command(
|
|
[
|
|
"docker inspect --format='{{json .State.Health.Status}}' {$escapedContainerName}",
|
|
'hidden' => true,
|
|
'save' => 'health_check',
|
|
'append' => false,
|
|
],
|
|
[
|
|
"docker inspect --format='{{json .State.Health.Log}}' {$escapedContainerName}",
|
|
'hidden' => true,
|
|
'save' => 'health_check_logs',
|
|
'append' => false,
|
|
],
|
|
);
|
|
$this->application_deployment_queue->addLogEntry("Attempt {$counter} of {$this->application->health_check_retries} | Healthcheck status: {$this->saved_outputs->get('health_check')}");
|
|
$health_check_logs = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'Output', '(no logs)');
|
|
if (empty($health_check_logs)) {
|
|
$health_check_logs = '(no logs)';
|
|
}
|
|
$health_check_return_code = data_get(collect(json_decode($this->saved_outputs->get('health_check_logs')))->last(), 'ExitCode', '(no return code)');
|
|
if ($health_check_logs !== '(no logs)' || $health_check_return_code !== '(no return code)') {
|
|
$this->application_deployment_queue->addLogEntry("Healthcheck logs: {$health_check_logs} | Return code: {$health_check_return_code}");
|
|
}
|
|
|
|
$healthCheckStatus = $this->trimmedSavedOutput('health_check')->replace('"', '')->value();
|
|
if ($healthCheckStatus === 'healthy') {
|
|
$this->markNewVersionHealthy(true);
|
|
$this->application->update(['status' => 'running']);
|
|
$this->application_deployment_queue->addLogEntry('New container is healthy.');
|
|
break;
|
|
} elseif ($healthCheckStatus === 'unhealthy') {
|
|
$this->markNewVersionHealthy(false);
|
|
$this->application_deployment_queue->addLogEntry('New container is unhealthy.', type: 'error');
|
|
$this->query_logs();
|
|
break;
|
|
}
|
|
$counter++;
|
|
$sleeptime = 0;
|
|
while ($sleeptime < $this->application->health_check_interval) {
|
|
Sleep::for(1)->seconds();
|
|
$sleeptime++;
|
|
}
|
|
}
|
|
if ($this->trimmedSavedOutput('health_check')->replace('"', '')->value() === 'starting') {
|
|
$this->query_logs();
|
|
}
|
|
}
|
|
}
|
|
} catch (Exception $e) {
|
|
throw new DeploymentException('Health check failed ('.get_class($e).'): '.$e->getMessage(), $e->getCode(), $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Laravel runs failed() on a fresh copy of the job, so the health of the new version is kept outside the job instance.
|
|
*/
|
|
private function markNewVersionHealthy(bool $healthy): void
|
|
{
|
|
$key = "deployment-new-version-healthy:{$this->deployment_uuid}";
|
|
if ($healthy) {
|
|
Cache::put($key, true, now()->addDay());
|
|
} else {
|
|
Cache::forget($key);
|
|
}
|
|
}
|
|
|
|
private function newVersionIsHealthy(): bool
|
|
{
|
|
return Cache::has("deployment-new-version-healthy:{$this->deployment_uuid}");
|
|
}
|
|
|
|
private function query_logs()
|
|
{
|
|
$escapedContainerName = escapeshellarg($this->container_name);
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
$this->application_deployment_queue->addLogEntry('Container logs:');
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => "docker logs -n 100 {$escapedContainerName}",
|
|
'type' => 'stderr',
|
|
'ignore_errors' => true,
|
|
],
|
|
);
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
}
|
|
|
|
private function deploy_pull_request()
|
|
{
|
|
if ($this->application->build_pack === 'dockerimage') {
|
|
$this->deploy_dockerimage_buildpack();
|
|
|
|
return;
|
|
}
|
|
if ($this->application->build_pack === 'dockercompose') {
|
|
$this->deploy_docker_compose_buildpack();
|
|
|
|
return;
|
|
}
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->build_server;
|
|
}
|
|
$this->markNewVersionHealthy(true);
|
|
$this->generate_image_names();
|
|
$this->application_deployment_queue->addLogEntry("Starting pull request (#{$this->pull_request_id}) deployment of {$this->customRepository}:{$this->application->git_branch}.");
|
|
$this->prepare_builder_image();
|
|
$this->check_git_if_build_needed();
|
|
$this->clone_repository();
|
|
$this->cleanup_git();
|
|
$this->validateDeploymentEnvironmentVariableKeys(buildsImage: true);
|
|
if ($this->application->build_pack === 'nixpacks') {
|
|
$this->generate_nixpacks_confs();
|
|
}
|
|
$this->generate_compose_file();
|
|
|
|
// Save build-time .env file BEFORE the build
|
|
$this->save_buildtime_environment_variables();
|
|
|
|
$this->generate_build_env_variables();
|
|
if ($this->application->build_pack === 'dockerfile') {
|
|
$this->add_build_env_variables_to_dockerfile();
|
|
}
|
|
$this->build_pull_request_image();
|
|
|
|
// This overwrites the build-time .env with ALL variables (build-time + runtime)
|
|
$this->save_runtime_environment_variables();
|
|
$this->push_to_docker_registry();
|
|
$this->rolling_update();
|
|
}
|
|
|
|
private function build_pull_request_image(): void
|
|
{
|
|
match ($this->application->build_pack) {
|
|
'railpack' => $this->build_railpack_image(),
|
|
'static' => $this->build_static_image(),
|
|
default => $this->build_image(),
|
|
};
|
|
}
|
|
|
|
private function create_workdir()
|
|
{
|
|
if ($this->use_build_server) {
|
|
$this->server = $this->mainServer;
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => "mkdir -p {$this->configuration_dir}",
|
|
],
|
|
);
|
|
$this->server = $this->build_server;
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}"),
|
|
],
|
|
[
|
|
'command' => "mkdir -p {$this->configuration_dir}",
|
|
],
|
|
);
|
|
} else {
|
|
$this->execute_remote_command(
|
|
[
|
|
'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}"),
|
|
],
|
|
[
|
|
'command' => "mkdir -p {$this->configuration_dir}",
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
private function prepare_builder_image(bool $firstTry = true)
|
|
{
|
|
$this->checkForCancellation();
|
|
$helperImage = coolifyHelperImage();
|
|
$helperImage = "{$helperImage}:".getHelperVersion();
|
|
// Get user home directory
|
|
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
|
|
instant_remote_process(["mkdir -p {$this->serverUserHomeDir}/.docker/buildx"], $this->server);
|
|
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
|
|
|
|
$env_flags = $this->generate_docker_env_flags_for_secrets();
|
|
$buildxMetadataVolume = isDev() && $this->server->isLocalhost()
|
|
? '-v coolify-buildx:/root/.docker/buildx'
|
|
: "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
|
|
if ($this->use_build_server) {
|
|
if ($this->dockerConfigFileExists === 'NOK') {
|
|
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
|
|
}
|
|
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
|
} else {
|
|
// docker compose runs in this helper and reads seccomp profile files from the custom Docker options
|
|
$seccompMounts = $this->pull_request_id === 0 ? seccompProfileMountFlags($this->application->custom_docker_run_options) : '';
|
|
if ($this->dockerConfigFileExists === 'OK') {
|
|
$safeNetwork = escapeshellarg($this->destination->network);
|
|
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$seccompMounts} {$helperImage}";
|
|
} else {
|
|
$safeNetwork = escapeshellarg($this->destination->network);
|
|
$runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$seccompMounts} {$helperImage}";
|
|
}
|
|
}
|
|
if ($firstTry) {
|
|
$this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage");
|
|
} else {
|
|
$this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.');
|
|
}
|
|
|
|
$this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true);
|
|
$this->execute_remote_command(
|
|
[
|
|
$runCommand,
|
|
'hidden' => true,
|
|
'skip_command_log' => filled($env_flags),
|
|
],
|
|
[
|
|
'command' => executeInDocker($this->deployment_uuid, "mkdir -p {$this->basedir}"),
|
|
],
|
|
);
|
|
$this->run_pre_deployment_command();
|
|
}
|
|
|
|
private function restart_builder_container_with_actual_commit()
|
|
{
|
|
// Stop the current helper container (no need for rm -f as it was started with --rm)
|
|
$this->graceful_shutdown_container($this->deployment_uuid, skipRemove: true);
|
|
|
|
// Clear cached env_args to force regeneration with actual SOURCE_COMMIT value
|
|
$this->env_args = null;
|
|
|
|
// Restart the helper container with updated environment variables (including actual SOURCE_COMMIT)
|
|
$this->prepare_builder_image(firstTry: false);
|
|
}
|
|
|
|
/**
|
|
* Proxy labels are generated for the primary server. On an additional server whose
|
|
* network has another name, point Caddy to the network the container joins there.
|
|
*/
|
|
private function useDestinationNetworkInCaddyLabels(Collection $labels): Collection
|
|
{
|
|
$primaryNetwork = $this->application->destination->network;
|
|
$network = $this->destination->network;
|
|
if ($network === $primaryNetwork) {
|
|
return $labels;
|
|
}
|
|
|
|
return $labels->map(fn ($label) => $label === "caddy_ingress_network={$primaryNetwork}" ? "caddy_ingress_network={$network}" : $label);
|
|
}
|
|
|
|
/**
|
|
* @return int The number of deployments that Coolify created on additional servers, including ones that failed to dispatch
|
|
*/
|
|
private function deploy_to_additional_destinations(): int
|
|
{
|
|
if ($this->application->additional_networks->count() === 0) {
|
|
return 0;
|
|
}
|
|
if ($this->pull_request_id !== 0) {
|
|
return 0;
|
|
}
|
|
$destination_ids = $this->application->additional_networks->pluck('id');
|
|
if ($this->server->isSwarm()) {
|
|
$this->application_deployment_queue->addLogEntry('Additional destinations are not supported in swarm mode.');
|
|
|
|
return 0;
|
|
}
|
|
if ($destination_ids->contains($this->destination->id)) {
|
|
return 0;
|
|
}
|
|
$queued = 0;
|
|
foreach ($destination_ids as $destination_id) {
|
|
$destination = StandaloneDocker::find($destination_id);
|
|
if (! $destination) {
|
|
continue;
|
|
}
|
|
$server = $destination->server;
|
|
if ($server->team_id !== $this->mainServer->team_id) {
|
|
$this->application_deployment_queue->addLogEntry("Skipping deployment to {$server->name}. Not in the same team?!");
|
|
|
|
continue;
|
|
}
|
|
$deployment_uuid = new_public_id();
|
|
try {
|
|
// Deploy the exact commit and image tag of the main server, also for a rollback.
|
|
$result = queue_application_deployment(
|
|
deployment_uuid: $deployment_uuid,
|
|
application: $this->application,
|
|
commit: $this->commit,
|
|
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,
|
|
);
|
|
} catch (Throwable $e) {
|
|
\Log::warning("Deployment {$this->deployment_uuid} could not queue the deployment to {$server->name}: {$e->getMessage()}");
|
|
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name} could not be started: {$e->getMessage()}", 'stderr');
|
|
// A deployment that failed to dispatch is already failed; it counts for the combined notification.
|
|
if (ApplicationDeploymentQueue::query()->where('deployment_uuid', $deployment_uuid)->exists()) {
|
|
$queued++;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
if ($result['status'] !== 'queued') {
|
|
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name} was not queued: {$result['message']}", 'stderr');
|
|
|
|
continue;
|
|
}
|
|
$queued++;
|
|
$deployment_url = base_url().'/project/'.data_get($this->application, 'environment.project.uuid').'/environment/'.data_get($this->application, 'environment.uuid').'/application/'.data_get($this->application, 'uuid')."/deployment/{$deployment_uuid}";
|
|
$this->application_deployment_queue->addLogEntry("Deployment to {$server->name}. Logs: {$deployment_url}");
|
|
}
|
|
|
|
return $queued;
|
|
}
|
|
|
|
private function set_coolify_variables()
|
|
{
|
|
$this->coolify_variables = '';
|
|
|
|
// Only include SOURCE_COMMIT in build context if enabled in settings
|
|
if ($this->application->settings->include_source_commit_in_build) {
|
|
$this->coolify_variables .= 'SOURCE_COMMIT='.escapeShellValue($this->commit).' ';
|
|
}
|
|
if ($this->pull_request_id === 0) {
|
|
$fqdn = $this->application->fqdn;
|
|
} else {
|
|
$fqdn = $this->preview->fqdn;
|
|
}
|
|
if (isset($fqdn)) {
|
|
$domains = $this->validDomainsFromFqdn($fqdn);
|
|
if ($domains->isNotEmpty()) {
|
|
$url = $domains->map(fn (string $domain) => Url::fromString($domain)->withPort(null)->__toString())->implode(',');
|
|
$fqdn = $this->hostsFromFqdn($fqdn);
|
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
|
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
|
|
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
|
|
} else {
|
|
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
|
|
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
|
|
}
|
|
}
|
|
}
|
|
if (isset($this->application->git_branch)) {
|
|
$this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
|
|
}
|
|
$this->coolify_variables .= 'COOLIFY_RESOURCE_UUID='.escapeShellValue($this->application->uuid).' ';
|
|
}
|
|
|
|
private function validDomainsFromFqdn(?string $fqdn): Collection
|
|
{
|
|
return str($fqdn ?? '')->explode(',')
|
|
->map(fn (string $domain) => trim($domain))
|
|
->filter()
|
|
->filter(fn (string $domain) => isValidDomainUrl($domain));
|
|
}
|
|
|
|
/**
|
|
* Comma-separated hosts without scheme, port, or path (the COOLIFY_FQDN value).
|
|
*/
|
|
private function hostsFromFqdn(?string $fqdn): string
|
|
{
|
|
return $this->validDomainsFromFqdn($fqdn)
|
|
->map(fn (string $domain) => Url::fromString($domain)->getHost())
|
|
->implode(',');
|
|
}
|
|
|
|
private function shellAssignmentForDockerfileArg(string $assignment): string
|
|
{
|
|
[$key, $value] = array_pad(explode('=', $assignment, 2), 2, null);
|
|
|
|
if ($value === null) {
|
|
return $assignment;
|
|
}
|
|
|
|
if (str_starts_with($value, "'") && str_ends_with($value, "'")) {
|
|
$value = substr($value, 1, -1);
|
|
$value = str_replace("'\\''", "'", $value);
|
|
}
|
|
|
|
return "{$key}={$value}";
|
|
}
|
|
|
|
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
|
|
{
|
|
$sshCommand = gitSshCommand($this->customPort, $identityFile);
|
|
|
|
$gitConfigOption = $this->application->gitHttpsCredentialConfigOption();
|
|
$gitCommand = $gitConfigOption ? "git {$gitConfigOption}" : 'git';
|
|
|
|
return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef);
|
|
}
|
|
|
|
private function check_git_if_build_needed()
|
|
{
|
|
if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) {
|
|
$repository = githubApi($this->source, "repos/{$this->customRepository}");
|
|
$data = data_get($repository, 'data');
|
|
$repository_project_id = data_get($data, 'id');
|
|
if (isset($repository_project_id)) {
|
|
if (blank($this->application->repository_project_id) || $this->application->repository_project_id !== $repository_project_id) {
|
|
$this->application->repository_project_id = $repository_project_id;
|
|
$this->application->save();
|
|
}
|
|
}
|
|
}
|
|
$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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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('('.$this->wrap_build_command_with_env_export($buildxBuildCommand).')');
|
|
}
|
|
|
|
private function decode_railpack_config(string $config, string $source): array
|
|
{
|
|
try {
|
|
$decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $exception) {
|
|
throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception);
|
|
}
|
|
|
|
if (! is_array($decoded)) {
|
|
throw new DeploymentException("Invalid {$source}: expected a JSON object.");
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
private function is_assoc_array(array $value): bool
|
|
{
|
|
if ($value === []) {
|
|
return false;
|
|
}
|
|
|
|
return array_keys($value) !== range(0, count($value) - 1);
|
|
}
|
|
|
|
private function merge_railpack_config(array $base, array $overrides): array
|
|
{
|
|
foreach ($overrides as $key => $value) {
|
|
if (
|
|
array_key_exists($key, $base)
|
|
&& is_array($base[$key])
|
|
&& is_array($value)
|
|
&& $this->is_assoc_array($base[$key])
|
|
&& $this->is_assoc_array($value)
|
|
) {
|
|
$base[$key] = $this->merge_railpack_config($base[$key], $value);
|
|
} else {
|
|
$base[$key] = $value;
|
|
}
|
|
}
|
|
|
|
return $base;
|
|
}
|
|
|
|
private function railpack_config_overrides(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
private function generated_railpack_config_relative_path(): string
|
|
{
|
|
return self::RAILPACK_GENERATED_CONFIG_PATH;
|
|
}
|
|
|
|
private function generated_railpack_config_absolute_path(): string
|
|
{
|
|
return "{$this->workdir}/".self::RAILPACK_GENERATED_CONFIG_PATH;
|
|
}
|
|
|
|
private function generate_railpack_config_file(): ?string
|
|
{
|
|
$repositoryConfig = [];
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH." && echo 'exists' || echo 'missing'"),
|
|
'hidden' => true,
|
|
'save' => 'railpack_config_exists',
|
|
]);
|
|
|
|
if (str($this->saved_outputs->get('railpack_config_exists'))->trim()->toString() === 'exists') {
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH),
|
|
'hidden' => true,
|
|
'save' => 'railpack_repository_config',
|
|
]);
|
|
|
|
$repositoryConfig = $this->decode_railpack_config(
|
|
$this->saved_outputs->get('railpack_repository_config', ''),
|
|
'repository railpack.json'
|
|
);
|
|
}
|
|
|
|
$overrides = $this->railpack_config_overrides();
|
|
if ($repositoryConfig === [] && $overrides === []) {
|
|
return null;
|
|
}
|
|
|
|
$mergedConfig = $this->merge_railpack_config($repositoryConfig, $overrides);
|
|
if (! array_key_exists('$schema', $mergedConfig)) {
|
|
$mergedConfig['$schema'] = 'https://schema.railpack.com';
|
|
}
|
|
|
|
try {
|
|
$encodedConfig = json_encode($mergedConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $exception) {
|
|
throw new DeploymentException("Failed to encode generated Railpack config: {$exception->getMessage()}", $exception->getCode(), $exception);
|
|
}
|
|
|
|
$configPath = $this->generated_railpack_config_absolute_path();
|
|
$encodedConfig = base64_encode($encodedConfig);
|
|
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}/.coolify"),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$encodedConfig}' | base64 -d | tee {$configPath} > /dev/null"),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry('Generated Railpack config: '.json_encode($mergedConfig, JSON_PRETTY_PRINT), hidden: true);
|
|
}
|
|
|
|
return $this->generated_railpack_config_relative_path();
|
|
}
|
|
|
|
private function railpack_prepare_command(?string $configFilePath = null): string
|
|
{
|
|
$prepare_command = 'railpack prepare';
|
|
|
|
if ($this->application->build_command) {
|
|
$prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
|
|
}
|
|
|
|
if ($this->application->start_command) {
|
|
$prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
|
|
}
|
|
|
|
if ($this->env_railpack_args) {
|
|
$prepare_command .= " {$this->env_railpack_args}";
|
|
}
|
|
|
|
if ($configFilePath) {
|
|
$prepare_command .= ' --config-file '.escapeShellValue($configFilePath);
|
|
}
|
|
|
|
$prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}";
|
|
|
|
return $prepare_command;
|
|
}
|
|
|
|
private function ensure_docker_buildx_available_for_railpack(): void
|
|
{
|
|
if ($this->dockerBuildxAvailable) {
|
|
return;
|
|
}
|
|
|
|
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.');
|
|
}
|
|
|
|
private function ensure_helper_docker_buildx_available_for_railpack(): void
|
|
{
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'DOCKER_CONFIG=/root/.docker docker buildx version >/dev/null 2>&1 && echo available || echo not-available'),
|
|
'hidden' => true,
|
|
'save' => 'railpack_helper_buildx_available',
|
|
]);
|
|
|
|
if (trim((string) $this->saved_outputs->get('railpack_helper_buildx_available')) === 'available') {
|
|
return;
|
|
}
|
|
|
|
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin inside the Coolify helper container. The helper could not find buildx at /root/.docker/cli-plugins/docker-buildx. Pull the latest helper image and retry the deployment.');
|
|
}
|
|
|
|
private function build_railpack_image(): void
|
|
{
|
|
$this->ensure_docker_buildx_available_for_railpack();
|
|
$this->ensure_helper_docker_buildx_available_for_railpack();
|
|
|
|
$railpackVariables = $this->generate_railpack_env_variables();
|
|
$railpackConfigPath = $this->generate_railpack_config_file();
|
|
|
|
// Step 1: Generate build plan with railpack prepare
|
|
$prepare_command = $this->railpack_prepare_command($railpackConfigPath);
|
|
|
|
$this->application_deployment_queue->addLogEntry('Generating Railpack build plan.');
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true, 'skip_command_log' => true],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat /artifacts/railpack-plan.json'),
|
|
'hidden' => true,
|
|
'save' => 'railpack_plan',
|
|
],
|
|
);
|
|
|
|
$railpackPlanRaw = $this->saved_outputs->get('railpack_plan');
|
|
if (! empty($railpackPlanRaw)) {
|
|
if (isDev()) {
|
|
$this->application_deployment_queue->addLogEntry("Final Railpack plan: {$railpackPlanRaw}", hidden: true);
|
|
} else {
|
|
$parsedPlan = json_decode($railpackPlanRaw, true);
|
|
if (is_array($parsedPlan)) {
|
|
// Strip secrets array to avoid logging variable names in production.
|
|
unset($parsedPlan['secrets']);
|
|
$this->application_deployment_queue->addLogEntry('Final Railpack plan: '.json_encode($parsedPlan, JSON_PRETTY_PRINT), hidden: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: Build image using docker buildx with railpack frontend.
|
|
// Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder.
|
|
$this->application_deployment_queue->addLogEntry('Building docker image with Railpack.');
|
|
$this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.');
|
|
|
|
$image_name = $this->application->settings->is_static
|
|
? $this->build_image_name
|
|
: $this->production_image_name;
|
|
|
|
if ($this->application->settings->is_static && $this->application->static_image) {
|
|
$this->pull_latest_image($this->staticImage());
|
|
}
|
|
|
|
$build_command = $this->railpack_build_command($image_name, $railpackVariables);
|
|
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
|
|
// Step 3: If static, copy built assets into nginx image
|
|
if ($this->application->settings->is_static) {
|
|
$this->build_railpack_static_image();
|
|
}
|
|
}
|
|
|
|
private function build_railpack_static_image(): void
|
|
{
|
|
$publishDir = trim($this->application->publish_directory, '/');
|
|
$publishDir = $publishDir ? "/{$publishDir}" : '';
|
|
$dockerfile = base64_encode("FROM {$this->staticImage()}
|
|
WORKDIR /usr/share/nginx/html/
|
|
LABEL coolify.deploymentId={$this->deployment_uuid}
|
|
COPY --from={$this->build_image_name} /app{$publishDir} .
|
|
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|
|
|
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
|
|
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
|
|
} else {
|
|
$nginx_config = $this->application->settings->is_spa
|
|
? base64_encode(defaultNginxConfiguration('spa'))
|
|
: base64_encode(defaultNginxConfiguration());
|
|
}
|
|
|
|
$static_build = $this->dockerBuildkitSupported
|
|
? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"
|
|
: "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
|
|
|
|
$base64_static_build = base64_encode($static_build);
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")],
|
|
[executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")],
|
|
[executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true],
|
|
[executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
|
|
[executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
|
|
);
|
|
}
|
|
|
|
protected function generate_coolify_env_variables(bool $forBuildTime = false): Collection
|
|
{
|
|
$coolify_envs = collect([]);
|
|
$local_branch = $this->branch;
|
|
if ($this->pull_request_id !== 0) {
|
|
// Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time
|
|
// SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build
|
|
if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) {
|
|
if ($this->deployedPreviewVariables()->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
|
if (! is_null($this->commit)) {
|
|
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
|
} else {
|
|
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
|
}
|
|
}
|
|
}
|
|
if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
|
$coolify_envs->put('COOLIFY_URL', $this->preview->fqdn);
|
|
} else {
|
|
$coolify_envs->put('COOLIFY_FQDN', $this->preview->fqdn);
|
|
}
|
|
}
|
|
if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_URL')->isEmpty()) {
|
|
$url = $this->hostsFromFqdn($this->preview->fqdn);
|
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
|
$coolify_envs->put('COOLIFY_FQDN', $url);
|
|
} else {
|
|
$coolify_envs->put('COOLIFY_URL', $url);
|
|
}
|
|
}
|
|
if ($this->application->build_pack !== 'dockercompose' || $this->application->compose_parsing_version === '1' || $this->application->compose_parsing_version === '2') {
|
|
if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
|
|
$coolify_envs->put('COOLIFY_BRANCH', $local_branch);
|
|
}
|
|
if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
|
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
|
|
}
|
|
// Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache
|
|
if (! $forBuildTime) {
|
|
if ($this->deployedPreviewVariables()->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
|
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Runtime only, so a project or server change does not break the Docker build cache
|
|
if (! $forBuildTime) {
|
|
$coolify_envs = $coolify_envs->merge(coolifyDefaultEnvironmentVariables($this->application, $this->deployedPreviewVariables()->pluck('key'), $this->mainServer));
|
|
}
|
|
|
|
} else {
|
|
// Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time
|
|
// SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build
|
|
if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) {
|
|
if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
|
if (! is_null($this->commit)) {
|
|
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
|
} else {
|
|
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
|
}
|
|
}
|
|
}
|
|
if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
|
$coolify_envs->put('COOLIFY_URL', $this->application->fqdn);
|
|
} else {
|
|
$coolify_envs->put('COOLIFY_FQDN', $this->application->fqdn);
|
|
}
|
|
}
|
|
if ($this->application->environment_variables->where('key', 'COOLIFY_URL')->isEmpty()) {
|
|
$url = $this->hostsFromFqdn($this->application->fqdn);
|
|
if ((int) $this->application->compose_parsing_version >= 3) {
|
|
$coolify_envs->put('COOLIFY_FQDN', $url);
|
|
} else {
|
|
$coolify_envs->put('COOLIFY_URL', $url);
|
|
}
|
|
}
|
|
if ($this->application->build_pack !== 'dockercompose' || $this->application->compose_parsing_version === '1' || $this->application->compose_parsing_version === '2') {
|
|
if ($this->application->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
|
|
$coolify_envs->put('COOLIFY_BRANCH', $local_branch);
|
|
}
|
|
if ($this->application->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
|
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
|
|
}
|
|
// Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache
|
|
if (! $forBuildTime) {
|
|
if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
|
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Runtime only, so a project or server change does not break the Docker build cache
|
|
if (! $forBuildTime) {
|
|
$coolify_envs = $coolify_envs->merge(coolifyDefaultEnvironmentVariables($this->application, $this->application->environment_variables->pluck('key'), $this->mainServer));
|
|
}
|
|
|
|
}
|
|
|
|
return $coolify_envs;
|
|
}
|
|
|
|
private function generate_env_variables()
|
|
{
|
|
$this->env_args = collect([]);
|
|
|
|
// Only include SOURCE_COMMIT in build args if enabled in settings
|
|
if ($this->application->settings->include_source_commit_in_build) {
|
|
$this->env_args->put('SOURCE_COMMIT', $this->commit);
|
|
}
|
|
|
|
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
|
$coolify_envs->each(function ($value, $key) {
|
|
if (! is_null($value) && $value !== '') {
|
|
$this->env_args->put($key, $value);
|
|
}
|
|
});
|
|
|
|
// For build process, include only environment variables where is_buildtime = true
|
|
if ($this->pull_request_id === 0) {
|
|
$envs = $this->application->environment_variables()
|
|
->withoutBuildpackControlVariables()
|
|
->where('is_buildtime', true)
|
|
->get();
|
|
$envs = $this->withoutLegacyEnvironmentVariableKeys($envs);
|
|
|
|
if ($this->build_pack === 'dockercompose') {
|
|
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
|
}
|
|
|
|
foreach ($envs as $env) {
|
|
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
|
|
continue;
|
|
}
|
|
|
|
$resolvedValue = $this->uses_remote_secret_references($env)
|
|
? $this->resolve_environment_variable_raw($env)
|
|
: $env->getResolvedValueWithServer($this->mainServer);
|
|
if (! is_null($resolvedValue)) {
|
|
$this->env_args->put($env->key, $resolvedValue);
|
|
}
|
|
}
|
|
} else {
|
|
$envs = $this->application->environment_variables_preview()
|
|
->whereNotIn('id', $this->previewVariableIdsWithProductionValues())
|
|
->withoutBuildpackControlVariables()
|
|
->where('is_buildtime', true)
|
|
->get();
|
|
$envs = $this->withoutLegacyEnvironmentVariableKeys($envs);
|
|
|
|
if ($this->build_pack === 'dockercompose') {
|
|
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
|
|
}
|
|
|
|
foreach ($envs as $env) {
|
|
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
|
|
continue;
|
|
}
|
|
|
|
$resolvedValue = $this->uses_remote_secret_references($env)
|
|
? $this->resolve_environment_variable_raw($env)
|
|
: $env->getResolvedValueWithServer($this->mainServer);
|
|
if (! is_null($resolvedValue)) {
|
|
$this->env_args->put($env->key, $resolvedValue);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function generate_compose_file()
|
|
{
|
|
$this->checkForCancellation();
|
|
$this->create_workdir();
|
|
$ports = $this->application->main_port();
|
|
$persistent_storages = $this->generate_local_persistent_volumes();
|
|
$persistent_file_volumes = $this->application->fileStorages()->get();
|
|
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
|
|
if (data_get($this->application, 'custom_labels')) {
|
|
$this->application->parseContainerLabels();
|
|
$labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels)));
|
|
$labels = $labels->filter(function ($value, $key) {
|
|
return ! Str::startsWith($value, 'coolify.');
|
|
});
|
|
$this->application->custom_labels = base64_encode($labels->implode("\n"));
|
|
$this->application->save();
|
|
} else {
|
|
if ($this->application->settings->is_container_label_readonly_enabled) {
|
|
$labels = collect(generateLabelsApplication($this->application, $this->preview));
|
|
}
|
|
}
|
|
if ($this->pull_request_id !== 0) {
|
|
$labels = collect(generateLabelsApplication($this->application, $this->preview));
|
|
}
|
|
$labels = $this->useDestinationNetworkInCaddyLabels($labels);
|
|
$labels = traefikHostRulesWithoutIpv6Brackets($labels);
|
|
if ($this->application->settings->is_container_label_escape_enabled) {
|
|
$labels = $labels->map(function ($value, $key) {
|
|
return escapeDollarSign($value);
|
|
});
|
|
}
|
|
$labels = $labels->merge(defaultLabels($this->application->uuid, $this->application->uuid, $this->application->project()->name, $this->application->name, $this->application->environment->name, $this->pull_request_id))->toArray();
|
|
|
|
// Check for custom HEALTHCHECK
|
|
if ($this->application->build_pack === 'dockerfile' || $this->application->dockerfile) {
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
|
|
'hidden' => true,
|
|
'save' => 'dockerfile_from_repo',
|
|
'ignore_errors' => true,
|
|
]);
|
|
$this->application->parseHealthcheckFromDockerfile($this->saved_outputs->get('dockerfile_from_repo'));
|
|
}
|
|
$custom_network_aliases = [];
|
|
if (! empty($this->application->custom_network_aliases_array)) {
|
|
$custom_network_aliases = $this->application->custom_network_aliases_array;
|
|
}
|
|
$docker_compose = [
|
|
'services' => [
|
|
$this->container_name => [
|
|
'image' => $this->production_image_name,
|
|
'container_name' => $this->container_name,
|
|
'restart' => RESTART_MODE,
|
|
...(! empty($ports) ? ['expose' => $ports] : []),
|
|
'networks' => [
|
|
$this->destination->network => [
|
|
'aliases' => array_merge(
|
|
[$this->container_name],
|
|
$custom_network_aliases
|
|
),
|
|
],
|
|
],
|
|
'mem_limit' => $this->application->limits_memory,
|
|
'memswap_limit' => $this->application->limits_memory_swap,
|
|
'mem_swappiness' => $this->application->limits_memory_swappiness,
|
|
'mem_reservation' => $this->application->limits_memory_reservation,
|
|
'cpus' => (float) $this->application->limits_cpus,
|
|
'cpu_shares' => $this->application->limits_cpu_shares,
|
|
],
|
|
],
|
|
'networks' => [
|
|
$this->destination->network => [
|
|
'external' => true,
|
|
'name' => $this->destination->network,
|
|
'attachable' => true,
|
|
],
|
|
],
|
|
];
|
|
// Always use .env file
|
|
$docker_compose['services'][$this->container_name]['env_file'] = ['.env'];
|
|
|
|
if ($this->application->settings->stop_grace_period !== null) {
|
|
$docker_compose['services'][$this->container_name]['stop_grace_period'] = $this->application->settings->stopGracePeriodSeconds().'s';
|
|
}
|
|
|
|
// Only add Coolify healthcheck if no custom HEALTHCHECK found in Dockerfile
|
|
// If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used
|
|
// If healthcheck is disabled, no healthcheck will be added
|
|
if (! $this->application->custom_healthcheck_found && ! $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;
|
|
$this->execute_remote_command([
|
|
"docker exec {$this->deployment_uuid} rm -f {$this->workdir}/docker-compose.yaml",
|
|
'hidden' => true,
|
|
], [
|
|
"docker exec -i {$this->deployment_uuid} tee {$this->workdir}/docker-compose.yaml > /dev/null",
|
|
'input' => $this->docker_compose,
|
|
'hidden' => true,
|
|
]);
|
|
}
|
|
|
|
private function generate_local_persistent_volumes()
|
|
{
|
|
$local_persistent_volumes = [];
|
|
foreach ($this->application->persistentStorages as $persistentStorage) {
|
|
if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) {
|
|
$volume_name = $persistentStorage->host_path;
|
|
} else {
|
|
$volume_name = $persistentStorage->name;
|
|
}
|
|
$isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
|
|
if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
|
|
$volume_name = addPreviewDeploymentSuffix($volume_name, $this->pull_request_id);
|
|
}
|
|
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
|
|
}
|
|
|
|
return $local_persistent_volumes;
|
|
}
|
|
|
|
private function generate_local_persistent_volumes_only_volume_names()
|
|
{
|
|
$local_persistent_volumes_names = [];
|
|
foreach ($this->application->persistentStorages as $persistentStorage) {
|
|
if ($persistentStorage->host_path) {
|
|
continue;
|
|
}
|
|
$name = $persistentStorage->name;
|
|
|
|
$isPreviewSuffixEnabled = (bool) data_get($persistentStorage, 'is_preview_suffix_enabled', true);
|
|
if ($this->pull_request_id !== 0 && $isPreviewSuffixEnabled) {
|
|
$name = addPreviewDeploymentSuffix($name, $this->pull_request_id);
|
|
}
|
|
|
|
$local_persistent_volumes_names[$name] = [
|
|
'name' => $name,
|
|
'external' => false,
|
|
];
|
|
}
|
|
|
|
return $local_persistent_volumes_names;
|
|
}
|
|
|
|
private function generate_healthcheck_commands()
|
|
{
|
|
// Handle CMD type healthcheck
|
|
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
|
|
$command = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->health_check_command);
|
|
|
|
// Defense in depth: validate command at runtime (matches input validation regex)
|
|
if (! preg_match('/^[a-zA-Z0-9 \-_.\/:=@,+]+$/', $command) || strlen($command) > 1000) {
|
|
$this->application_deployment_queue->addLogEntry('Warning: Health check command contains invalid characters or exceeds max length. Falling back to HTTP healthcheck.');
|
|
} else {
|
|
$this->full_healthcheck_url = $command;
|
|
|
|
return $command;
|
|
}
|
|
}
|
|
|
|
// HTTP type healthcheck (default)
|
|
if (! $this->application->health_check_port) {
|
|
if (! empty($this->application->ports_exposes_array)) {
|
|
$health_check_port = (int) $this->application->ports_exposes_array[0];
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
$health_check_port = (int) $this->application->health_check_port;
|
|
}
|
|
if ($this->application->settings->is_static || $this->application->build_pack === 'static') {
|
|
$health_check_port = 80;
|
|
}
|
|
|
|
$method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET');
|
|
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
|
|
$host = ValidationPatterns::isValidHealthCheckHost($this->application->health_check_host)
|
|
? formatHostForUrl($this->application->health_check_host)
|
|
: 'localhost';
|
|
$path = $this->application->health_check_path
|
|
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/')
|
|
: null;
|
|
|
|
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
|
|
$escapedMethod = escapeshellarg($method);
|
|
|
|
if ($path) {
|
|
$this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}{$path}";
|
|
} else {
|
|
$this->full_healthcheck_url = "{$method}: {$scheme}://{$host}:{$health_check_port}/";
|
|
}
|
|
|
|
$generated_healthchecks_commands = [
|
|
"curl -s -X {$escapedMethod} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
|
|
];
|
|
|
|
return implode(' ', $generated_healthchecks_commands);
|
|
}
|
|
|
|
private function sanitizeHealthCheckValue(string $value, string $pattern, string $default): string
|
|
{
|
|
if (preg_match($pattern, $value)) {
|
|
return $value;
|
|
}
|
|
|
|
return $default;
|
|
}
|
|
|
|
/**
|
|
* Older versions accepted any static image through the API, so a stored value can be one that
|
|
* Coolify no longer supports. Such a value stops the deployment with a clear error instead of
|
|
* silently building with another web server image.
|
|
*/
|
|
private function staticImage(): string
|
|
{
|
|
$image = StaticImageTypes::tryFrom((string) $this->application->static_image);
|
|
|
|
if ($image === null) {
|
|
$supported = implode(', ', array_column(StaticImageTypes::cases(), 'value'));
|
|
|
|
throw new DeploymentException("The static image '{$this->application->static_image}' is not supported. Select a supported web server ({$supported}) in the application settings and deploy again.");
|
|
}
|
|
|
|
return $image->value;
|
|
}
|
|
|
|
private function pull_latest_image(string $image): void
|
|
{
|
|
$image = StaticImageTypes::from($image)->value;
|
|
$this->application_deployment_queue->addLogEntry("Pulling latest image ($image) from the registry.");
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'docker pull '.escapeshellarg($image)),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
}
|
|
|
|
private function build_static_image()
|
|
{
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
$this->application_deployment_queue->addLogEntry('Static deployment. Copying static assets to the image.');
|
|
if ($this->application->static_image) {
|
|
$this->pull_latest_image($this->staticImage());
|
|
}
|
|
$dockerfile = base64_encode("FROM {$this->staticImage()}
|
|
WORKDIR /usr/share/nginx/html/
|
|
LABEL coolify.deploymentId={$this->deployment_uuid}
|
|
COPY . .
|
|
RUN rm -f /usr/share/nginx/html/nginx.conf
|
|
RUN rm -f /usr/share/nginx/html/Dockerfile
|
|
RUN rm -f /usr/share/nginx/html/docker-compose.yaml
|
|
RUN rm -f /usr/share/nginx/html/.env
|
|
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
|
|
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
|
|
} else {
|
|
if ($this->application->settings->is_spa) {
|
|
$nginx_config = base64_encode(defaultNginxConfiguration('spa'));
|
|
} else {
|
|
$nginx_config = base64_encode(defaultNginxConfiguration());
|
|
}
|
|
}
|
|
if ($this->dockerBuildkitSupported) {
|
|
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
|
|
} else {
|
|
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
|
|
}
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"),
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"),
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
$this->application_deployment_queue->addLogEntry('Building docker image completed.');
|
|
}
|
|
|
|
/**
|
|
* Wrap a docker build command with environment export from build-time .env file
|
|
* This enables shell interpolation of variables (e.g., APP_URL=$COOLIFY_URL)
|
|
*
|
|
* @param string $build_command The docker build command to wrap
|
|
* @return string The wrapped command with export statement
|
|
*/
|
|
private function wrap_build_command_with_env_export(string $build_command): string
|
|
{
|
|
if (! $this->useBuildtimeEnvironmentLauncher) {
|
|
return "cd {$this->workdir} && set -a && source ".self::BUILD_TIME_ENV_PATH." && set +a && {$build_command}";
|
|
}
|
|
|
|
$escapedBuildCommand = escapeBashEnvValue($build_command);
|
|
|
|
return "cd {$this->workdir} && bash ".self::BUILD_TIME_ENV_LAUNCHER_PATH." /bin/bash -c {$escapedBuildCommand}";
|
|
}
|
|
|
|
private function build_image()
|
|
{
|
|
// Add Coolify related variables to the build args/secrets
|
|
if (! $this->dockerSecretsSupported) {
|
|
// Traditional build args approach - generate COOLIFY_ variables locally
|
|
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
|
$coolify_envs->each(function ($value, $key) {
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the Coolify build environment');
|
|
$this->build_args->push('--build-arg '.escapeshellarg($key));
|
|
});
|
|
}
|
|
|
|
// Always convert build_args Collection to string for command interpolation
|
|
$this->build_args = $this->build_args instanceof Collection
|
|
? $this->build_args->implode(' ')
|
|
: (string) $this->build_args;
|
|
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
if ($this->disableBuildCache) {
|
|
$this->application_deployment_queue->addLogEntry('Docker build cache is disabled. It will not be used during the build process.');
|
|
}
|
|
if ($this->application->build_pack === 'static') {
|
|
$this->application_deployment_queue->addLogEntry('Static deployment. Copying static assets to the image.');
|
|
} else {
|
|
$this->application_deployment_queue->addLogEntry('Building docker image started.');
|
|
$this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.');
|
|
}
|
|
|
|
if ($this->application->settings->is_static) {
|
|
if ($this->application->static_image) {
|
|
$this->pull_latest_image($this->staticImage());
|
|
$this->application_deployment_queue->addLogEntry('Continuing with the building process.');
|
|
}
|
|
if ($this->application->build_pack === 'nixpacks') {
|
|
$this->nixpacks_plan = base64_encode($this->nixpacks_plan);
|
|
$this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true, 'skip_command_log' => true]);
|
|
if ($this->force_rebuild) {
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
|
|
'hidden' => true,
|
|
], [
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
|
'hidden' => true,
|
|
]);
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the nixpacks Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
|
}
|
|
} else {
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"),
|
|
'hidden' => true,
|
|
], [
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
|
'hidden' => true,
|
|
]);
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the nixpacks Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
|
}
|
|
}
|
|
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
|
|
} else {
|
|
// Dockerfile buildpack
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
|
|
}
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
|
|
}
|
|
} else {
|
|
// Traditional build with args
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
|
|
}
|
|
}
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
}
|
|
$publishDir = trim($this->application->publish_directory, '/');
|
|
$publishDir = $publishDir ? "/{$publishDir}" : '';
|
|
$dockerfile = base64_encode("FROM {$this->staticImage()}
|
|
WORKDIR /usr/share/nginx/html/
|
|
LABEL coolify.deploymentId={$this->deployment_uuid}
|
|
COPY --from=$this->build_image_name /app{$publishDir} .
|
|
COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|
if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
|
|
$nginx_config = base64_encode($this->application->custom_nginx_configuration);
|
|
} else {
|
|
if ($this->application->settings->is_spa) {
|
|
$nginx_config = base64_encode(defaultNginxConfiguration('spa'));
|
|
} else {
|
|
$nginx_config = base64_encode(defaultNginxConfiguration());
|
|
}
|
|
}
|
|
if ($this->dockerBuildkitSupported) {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
|
}
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"),
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"),
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
} else {
|
|
// Pure Dockerfile based deployment
|
|
if ($this->application->dockerfile) {
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
if ($this->force_rebuild) {
|
|
$build_command = "DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
|
|
} else {
|
|
$build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
|
|
}
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
}
|
|
} else {
|
|
// Traditional build with args (no --progress for legacy builder compatibility)
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
|
}
|
|
}
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
} else {
|
|
if ($this->application->build_pack === 'nixpacks') {
|
|
$this->nixpacks_plan = base64_encode($this->nixpacks_plan);
|
|
$this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee ".self::NIXPACKS_PLAN_PATH.' > /dev/null'), 'hidden' => true, 'skip_command_log' => true]);
|
|
if ($this->force_rebuild) {
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --no-cache --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
|
|
'hidden' => true,
|
|
], [
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
|
'hidden' => true,
|
|
]);
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the nixpacks Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
}
|
|
} else {
|
|
$this->execute_remote_command([
|
|
executeInDocker($this->deployment_uuid, 'nixpacks build -c '.self::NIXPACKS_PLAN_PATH." --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"),
|
|
'hidden' => true,
|
|
], [
|
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
|
'hidden' => true,
|
|
]);
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the nixpacks Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
}
|
|
}
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
|
|
} else {
|
|
// Dockerfile buildpack
|
|
if ($this->dockerSecretsSupported) {
|
|
// Modify the Dockerfile to use build secrets
|
|
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
|
|
// Use BuildKit with secrets
|
|
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
|
}
|
|
} elseif ($this->dockerBuildkitSupported) {
|
|
// BuildKit without secrets
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
|
}
|
|
} else {
|
|
// Traditional build with args
|
|
if ($this->force_rebuild) {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
|
} else {
|
|
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
|
}
|
|
}
|
|
$base64_build_command = base64_encode($build_command);
|
|
$this->execute_remote_command(
|
|
[
|
|
executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
],
|
|
[
|
|
executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
|
|
'hidden' => true,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('Building docker image completed.');
|
|
}
|
|
|
|
private function graceful_shutdown_container(string $containerName, bool $skipRemove = false)
|
|
{
|
|
try {
|
|
$timeout = $this->application->settings->deploymentStopGracePeriodSeconds();
|
|
|
|
if ($skipRemove) {
|
|
$this->execute_remote_command(
|
|
[dockerStopCommand($timeout, escapeshellarg($containerName), $this->server), 'hidden' => true, 'ignore_errors' => 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);
|
|
if ($this->pull_request_id === 0) {
|
|
$containers = $containers->filter(function ($container) {
|
|
return data_get($container, 'Names') !== $this->container_name && data_get($container, 'Names') !== addPreviewDeploymentSuffix($this->container_name, $this->pull_request_id);
|
|
});
|
|
}
|
|
$containers->each(function ($container) {
|
|
$this->graceful_shutdown_container(data_get($container, 'Names'));
|
|
});
|
|
}
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
private function containerNamesToRemove(Collection $containers): Collection
|
|
{
|
|
return $containers
|
|
->pluck('Names')
|
|
->push($this->container_name)
|
|
->filter()
|
|
->unique()
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* `docker compose up` creates a missing bind source as an empty directory, so content files are
|
|
* written first. This also gives a server that was added later the files it does not have.
|
|
*/
|
|
private function start_by_compose_file()
|
|
{
|
|
$this->write_missing_content_files();
|
|
|
|
try {
|
|
// Ensure .env file exists before docker compose tries to load it (defensive programming)
|
|
$this->execute_remote_command(
|
|
["touch {$this->configuration_dir}/.env", 'hidden' => true],
|
|
[$this->destination->proxyConnectCommand(), 'hidden' => true, 'ignore_errors' => true],
|
|
);
|
|
|
|
if ($this->application->build_pack === 'dockerimage') {
|
|
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, "docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} pull"), 'hidden' => true],
|
|
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} up --build -d"), 'hidden' => true],
|
|
);
|
|
} else {
|
|
if ($this->use_build_server) {
|
|
$this->execute_remote_command(
|
|
["{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true],
|
|
);
|
|
} else {
|
|
$this->execute_remote_command(
|
|
[executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up --build -d"), 'hidden' => true],
|
|
);
|
|
}
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('New container started.');
|
|
} catch (Exception $e) {
|
|
throw new DeploymentException("Failed to start container: {$e->getMessage()}", $e->getCode(), $e);
|
|
}
|
|
}
|
|
|
|
private function analyzeBuildTimeVariables($variables)
|
|
{
|
|
$userDefinedVariables = collect([]);
|
|
|
|
$dbVariables = $this->pull_request_id === 0
|
|
? $this->application->environment_variables()
|
|
->where('is_buildtime', true)
|
|
->pluck('key')
|
|
: $this->application->environment_variables_preview()
|
|
->where('is_buildtime', true)
|
|
->pluck('key');
|
|
|
|
foreach ($variables as $key => $value) {
|
|
if ($dbVariables->contains($key)) {
|
|
$userDefinedVariables->put($key, $value);
|
|
}
|
|
}
|
|
|
|
if ($userDefinedVariables->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
$variablesArray = $userDefinedVariables->toArray();
|
|
$warnings = self::analyzeBuildVariables($variablesArray);
|
|
|
|
if (empty($warnings)) {
|
|
return;
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
|
foreach ($warnings as $warning) {
|
|
$messages = self::formatBuildWarning($warning);
|
|
foreach ($messages as $message) {
|
|
$this->application_deployment_queue->addLogEntry($message, type: 'warning');
|
|
}
|
|
$this->application_deployment_queue->addLogEntry('');
|
|
}
|
|
|
|
// Add general advice
|
|
$this->application_deployment_queue->addLogEntry('💡 Tips to resolve build issues:', type: 'info');
|
|
$this->application_deployment_queue->addLogEntry(' 1. Set these variables as "Runtime only" in the environment variables settings', type: 'info');
|
|
$this->application_deployment_queue->addLogEntry(' 2. Use different values for build-time (e.g., NODE_ENV=development for build)', type: 'info');
|
|
$this->application_deployment_queue->addLogEntry(' 3. Consider using multi-stage Docker builds to separate build and runtime environments', type: 'info');
|
|
}
|
|
|
|
private function generate_build_env_variables()
|
|
{
|
|
if ($this->application->build_pack === 'nixpacks') {
|
|
$variables = collect($this->nixpacks_plan_json->get('variables'));
|
|
} else {
|
|
$this->generate_env_variables();
|
|
$variables = collect([])->merge($this->env_args);
|
|
}
|
|
// Analyze build variables for potential issues
|
|
if ($variables->isNotEmpty()) {
|
|
$this->analyzeBuildTimeVariables($variables);
|
|
}
|
|
|
|
if ($this->dockerSecretsSupported) {
|
|
$this->generate_build_secrets($variables);
|
|
$this->build_args = '';
|
|
} else {
|
|
$secrets_hash = '';
|
|
if ($variables->isNotEmpty()) {
|
|
$secrets_hash = $this->generate_secrets_hash($variables);
|
|
}
|
|
|
|
$env_vars = $this->pull_request_id === 0
|
|
? $this->application->environment_variables()->where('is_buildtime', true)->get()
|
|
: $this->application->environment_variables_preview()->where('is_buildtime', true)->get();
|
|
|
|
// Map variables to include is_multiline flag
|
|
$vars_with_metadata = $variables->map(function ($value, $key) use ($env_vars) {
|
|
$env = $env_vars->firstWhere('key', $key);
|
|
|
|
return [
|
|
'key' => $key,
|
|
'value' => $value,
|
|
'is_multiline' => $env ? $env->is_multiline : false,
|
|
];
|
|
});
|
|
|
|
$this->build_args = generateDockerBuildArgs($vars_with_metadata);
|
|
|
|
if ($secrets_hash) {
|
|
$this->build_args->push('--build-arg '.escapeshellarg("COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
private function generate_docker_env_flags_for_secrets()
|
|
{
|
|
// 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 '';
|
|
}
|
|
|
|
$secrets_hash = $this->generate_secrets_hash($variables);
|
|
|
|
// Get database env vars to check for multiline flag
|
|
$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 to simple array format for the helper function
|
|
$vars_array = $variables->map(function ($value, $key) use ($env_vars) {
|
|
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the build secret environment');
|
|
$env = $env_vars->firstWhere('key', $key);
|
|
|
|
return [
|
|
'key' => $key,
|
|
'value' => $value,
|
|
'is_multiline' => $env ? $env->is_multiline : false,
|
|
];
|
|
});
|
|
|
|
$env_flags = generateDockerEnvFlags($vars_array);
|
|
$env_flags .= ' -e '.escapeshellarg("COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
|
|
|
|
return $env_flags;
|
|
}
|
|
|
|
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(' ');
|
|
|
|
$this->build_secrets .= ' --secret id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
protected function findFromInstructionLines($dockerfile): array
|
|
{
|
|
$fromLines = [];
|
|
foreach ($dockerfile as $index => $line) {
|
|
$trimmedLine = trim($line);
|
|
// Check if line starts with FROM (case-insensitive)
|
|
if (preg_match('/^FROM\s+/i', $trimmedLine)) {
|
|
$fromLines[] = $index;
|
|
}
|
|
}
|
|
|
|
return $fromLines;
|
|
}
|
|
|
|
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,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Build secrets reach a build only as environment variables inside RUN, and the env= mount
|
|
* option needs Dockerfile frontend 1.10+. Warn about the two cases that then build without values.
|
|
*
|
|
* @param Collection<int, string> $dockerfile
|
|
* @param Collection<string, mixed> $variables
|
|
*/
|
|
private function warnAboutDockerfileBuildSecretsCompatibility(Collection $dockerfile, Collection $variables): void
|
|
{
|
|
if (preg_match('~^#\s*syntax\s*=\s*\S*docker/dockerfile:1\.(\d+)~i', (string) $dockerfile->first(), $matches) === 1 && (int) $matches[1] < 10) {
|
|
$this->application_deployment_queue->addLogEntry("⚠️ The Dockerfile pins docker/dockerfile:1.{$matches[1]}. Build secrets need docker/dockerfile:1.10 or later. Remove the syntax line or update it, or switch Build secrets off.", 'stderr');
|
|
}
|
|
|
|
$keysUsedOutsideRun = $variables->keys()
|
|
->map(fn ($key): string => (string) $key)
|
|
->filter(fn (string $key): bool => ValidationPatterns::isValidEnvironmentVariableKey($key))
|
|
->filter(fn (string $key): bool => $dockerfile->contains(function (string $line) use ($key): bool {
|
|
$trimmed = ltrim($line);
|
|
|
|
return preg_match('/^(RUN|ARG|ENV|#)/i', $trimmed) !== 1
|
|
&& preg_match('/\$\{?'.preg_quote($key, '/').'\b/', $trimmed) === 1;
|
|
}))
|
|
->values();
|
|
|
|
if ($keysUsedOutsideRun->isNotEmpty()) {
|
|
$this->application_deployment_queue->addLogEntry('⚠️ The Dockerfile uses build-time variables outside RUN instructions: '.$keysUsedOutsideRun->implode(', ').'. With build secrets, these values are only available inside RUN, so these instructions get empty values. Switch Build secrets off on the Environment Variables page if the build needs them.', 'stderr');
|
|
}
|
|
}
|
|
|
|
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(' ');
|
|
|
|
// Add mount for the secrets hash to ensure cache invalidation
|
|
$mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
|
|
|
|
$this->warnAboutDockerfileBuildSecretsCompatibility($dockerfile, $variables);
|
|
|
|
$modified = false;
|
|
$dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) {
|
|
$trimmed = ltrim($line);
|
|
|
|
// Skip lines that already have secret mounts or are not RUN commands
|
|
if (str_contains($line, '--mount=type=secret') || ! str_starts_with($trimmed, 'RUN')) {
|
|
return $line;
|
|
}
|
|
|
|
// Add mount strings to RUN command
|
|
$originalCommand = trim(substr($trimmed, 3));
|
|
$modified = true;
|
|
|
|
return "RUN {$mountStrings} {$originalCommand}";
|
|
});
|
|
|
|
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 = [];
|
|
$dockerfile_lines->each(function ($line, $index) use (&$fromIndices) {
|
|
if (str($line)->trim()->startsWith('FROM')) {
|
|
$fromIndices[] = $index;
|
|
}
|
|
});
|
|
|
|
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,
|
|
];
|
|
}
|
|
|
|
$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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|