'3.5.0', // Current version (without 'v' prefix) * 'latest' => '3.5.2', // Latest patch version available * 'type' => 'patch_update', // Update type identifier * 'checked_at' => '2025-11-14T10:00:00Z', // ISO8601 timestamp * 'newer_branch_target' => 'v3.6', // (Optional) Available major/minor version * 'newer_branch_latest' => '3.6.2' // (Optional) Latest version in that branch * ] * ``` * * **For minor/major upgrades** (e.g., 3.5.6 → 3.6.2): * ```php * [ * 'current' => '3.5.6', // Current version * 'latest' => '3.6.2', // Latest version in target branch * 'type' => 'minor_upgrade', // Update type identifier * 'upgrade_target' => 'v3.6', // Target branch (with 'v' prefix) * 'checked_at' => '2025-11-14T10:00:00Z' // ISO8601 timestamp * ] * ``` * * **Null value**: Set to null when: * - Server is fully up-to-date with the latest version * - Traefik image uses the 'latest' tag (no fixed version tracking) * - No Traefik version detected on the server * * @see CheckTraefikVersionForServerJob Where this data is populated * @see Proxy Where this data is read and displayed */ #[OA\Schema( description: 'Server model', type: 'object', properties: [ 'id' => ['type' => 'integer', 'description' => 'The server ID.'], 'uuid' => ['type' => 'string', 'description' => 'The server UUID.'], 'name' => ['type' => 'string', 'description' => 'The server name.'], 'description' => ['type' => 'string', 'description' => 'The server description.'], 'ip' => ['type' => 'string', 'description' => 'The IP address.'], 'user' => ['type' => 'string', 'description' => 'The user.'], 'port' => ['type' => 'integer', 'description' => 'The port number.'], 'proxy' => ['type' => 'object', 'description' => 'The proxy configuration.'], 'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'], 'high_disk_usage_notification_sent' => ['type' => 'boolean', 'description' => 'The flag to indicate if the high disk usage notification has been sent.'], 'unreachable_notification_sent' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unreachable notification has been sent.'], 'unreachable_count' => ['type' => 'integer', 'description' => 'The unreachable count for your server.'], 'validation_logs' => ['type' => 'string', 'description' => 'The validation logs.'], 'log_drain_notification_sent' => ['type' => 'boolean', 'description' => 'The flag to indicate if the log drain notification has been sent.'], 'swarm_cluster' => ['type' => 'string', 'description' => 'The swarm cluster configuration.'], 'settings' => ['$ref' => '#/components/schemas/ServerSetting'], ] )] class Server extends BaseModel { use Auditable, ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; /** * Sentinel IP for servers that do not have a real address yet * (cloud provisioning in progress or parked as unreachable). * Scheduled jobs skip these servers via skipServer(). */ public const PLACEHOLDER_IP = '1.2.3.4'; public const PLACEHOLDER_IPS = [self::PLACEHOLDER_IP, '0.0.0.0', '::']; protected array $auditExclude = ['canvas_position']; /** * Serves the proxy error pages (503.html) for the Traefik and Caddy catch-all. */ public const PROXY_ERROR_PAGE_CONTAINER = 'coolify-proxy-error-pages'; public const PROXY_ERROR_PAGE_IMAGE = 'busybox:musl'; /** * Maximum size of a custom error or maintenance page. The page goes to the server with scp * (see uploadProxyFile()), so this limit only keeps the Livewire payload reasonable. */ public const PROXY_ERROR_PAGE_MAX_BYTES = 1048576; /** * Default Caddy proxy image. caddy-docker-proxy 2.13 ships Caddy 2.11. */ public const RECOMMENDED_CADDY_PROXY_IMAGE = 'lucaslorentz/caddy-docker-proxy:2.13-alpine'; /** * First caddy-docker-proxy version that ships Caddy 2.8+ (`log_append`, `basic_auth`). * Version 2.8 of the image still runs Caddy 2.7.6. */ public const MINIMUM_CURRENT_CADDY_PROXY_VERSION = [2, 9]; /** * Address of the development `testing-host` server (docker-compose.dev*.yml, ServerSeeder). */ public const DEV_TESTING_HOST_IP = 'coolify-testing-host'; public static $batch_counter = 0; /** * Identity map cache for request-scoped Server lookups. * Prevents N+1 queries when the same Server is accessed multiple times. */ private static ?array $identityMapCache = null; protected $appends = ['is_coolify_host']; protected static function booted() { static::saving(function ($server) { $payload = []; if ($server->user) { $payload['user'] = trim($server->user); } if ($server->ip) { $payload['ip'] = trim($server->ip); // Update ip_previous when ip is being changed if ($server->isDirty('ip') && $server->getOriginal('ip')) { $payload['ip_previous'] = $server->getOriginal('ip'); } } $server->fill($payload); }); static::saved(function ($server) { if ($server->wasChanged('private_key_id') || $server->privateKey?->isDirty()) { refresh_server_connection($server->privateKey); } elseif ($server->wasChanged(['user', 'ip', 'port'])) { // An open multiplexed connection keeps the old user and host until it is closed. SshMultiplexingHelper::removeMuxFile($server); } }); static::created(function ($server) { ServerSetting::create([ 'server_id' => $server->id, ]); if ($server->id === 0) { if ($server->isSwarm()) { (new SwarmDocker)->forceFill([ 'id' => 0, 'name' => 'coolify', 'network' => 'coolify-overlay', 'server_id' => $server->id, ])->save(); } else { (new StandaloneDocker)->forceFill($server->defaultStandaloneDockerAttributes(id: 0))->saveQuietly(); } } else { if ($server->isSwarm()) { SwarmDocker::create([ 'name' => 'coolify-overlay', 'network' => 'coolify-overlay', 'server_id' => $server->id, ]); } else { $standaloneDocker = new StandaloneDocker; $standaloneDocker->forceFill($server->defaultStandaloneDockerAttributes()); $standaloneDocker->saveQuietly(); } } if (! isset($server->proxy->redirect_enabled)) { $server->proxy->redirect_enabled = true; } // Create predefined server shared variables SharedEnvironmentVariable::create([ 'key' => 'COOLIFY_SERVER_UUID', 'value' => $server->uuid, 'type' => 'server', 'server_id' => $server->id, 'team_id' => $server->team_id, 'is_literal' => true, ]); SharedEnvironmentVariable::create([ 'key' => 'COOLIFY_SERVER_NAME', 'value' => $server->name, 'type' => 'server', 'server_id' => $server->id, 'team_id' => $server->team_id, 'is_literal' => true, ]); }); static::retrieved(function ($server) { if (! isset($server->proxy->redirect_enabled)) { $server->proxy->redirect_enabled = true; } }); static::forceDeleting(function ($server) { $server->destinations()->each(function ($destination) { $destination->delete(); }); // Leftover active runner rows would block deleting the GitHub App. // The runners of a transferred server now belong to the other instance, so keep them registered at GitHub. $executions = GithubRunnerExecution::query()->where('server_id', $server->id); if ($server->isTransferredAway()) { $executions->delete(); } else { GithubRunnerExecution::deleteAndDeregister($executions); } $server->githubRunnerConfig()->delete(); $server->settings()->delete(); $server->sslCertificates()->delete(); $server->notificationThrottles()->delete(); }); static::deleted(function (Server $server) { $server->failQueuedDeployments(); }); static::updated(function () { static::flushIdentityMap(); }); } /** * Fail the queued deployments of a deleted server, because they can never start. */ public function failQueuedDeployments(): void { ApplicationDeploymentQueue::query() ->where('server_id', $this->id) ->where('status', ApplicationDeploymentStatus::QUEUED->value) ->eachById(function (ApplicationDeploymentQueue $deployment) { $updated = ApplicationDeploymentQueue::query() ->whereKey($deployment->id) ->where('status', ApplicationDeploymentStatus::QUEUED->value) ->update(['status' => ApplicationDeploymentStatus::FAILED->value]); if ($updated > 0) { $deployment->addLogEntry('The server was deleted.', 'stderr'); } }); } /** * Find a Server by ID using the identity map cache. * This prevents N+1 queries when the same Server is accessed multiple times. */ public static function findCached($id): ?static { if ($id === null) { return null; } if (static::$identityMapCache === null) { static::$identityMapCache = []; } if (! isset(static::$identityMapCache[$id])) { static::$identityMapCache[$id] = static::query()->find($id); } return static::$identityMapCache[$id]; } /** * Flush the identity map cache. * Called automatically on update, and should be called in tests. */ public static function flushIdentityMap(): void { static::$identityMapCache = null; } protected $casts = [ 'proxy' => SchemalessAttributes::class, 'traefik_outdated_info' => 'array', 'server_metadata' => 'array', 'logdrain_axiom_api_key' => 'encrypted', 'logdrain_newrelic_license_key' => 'encrypted', 'delete_unused_volumes' => 'boolean', 'delete_unused_networks' => 'boolean', 'force_disabled' => 'boolean', 'sentinel_waiting_since' => 'datetime', 'canvas_position' => 'array', ]; /** * Sensitive fields hidden by default in serialized output (toArray/toJson). * API controllers should call makeVisible([...]) for callers with the * `read:sensitive` or `root` token ability. */ protected $hidden = [ 'logdrain_axiom_api_key', 'logdrain_newrelic_license_key', 'proxy_error_page', ]; protected $schemalessAttributes = [ 'proxy', ]; protected $fillable = [ 'name', 'ip', 'port', 'user', 'description', 'private_key_id', 'cloud_provider_token_id', 'team_id', 'hetzner_server_id', 'hetzner_server_status', 'vultr_instance_id', 'vultr_instance_status', 'digitalocean_droplet_id', 'digitalocean_droplet_status', 'hostinger_virtual_machine_id', 'hostinger_virtual_machine_status', 'is_validating', 'validation_logs', 'detected_traefik_version', 'traefik_outdated_info', 'server_metadata', 'ip_previous', ]; use HasSafeStringAttribute; public function setValidationLogsAttribute($value): void { $this->attributes['validation_logs'] = $value !== null ? Purify::config('validation_logs')->clean(remove_iip($value)) : null; } public function type() { return 'server'; } public function hasPlaceholderIp(): bool { // Cast: the saving hook stores the ip as a Stringable in memory. return self::isPlaceholderIp((string) $this->ip); } public static function isPlaceholderIp(?string $ip): bool { return blank($ip) || in_array($ip, self::PLACEHOLDER_IPS, true); } /** * Replace a placeholder IP with the real address once the cloud * provider reports one. Returns true when the IP was updated. */ public function backfillPlaceholderIp(?string $ip): bool { if ($this->isTransferredAway()) { return false; } $ip = blank($ip) ? null : self::normalizeIp($ip); if (self::isPlaceholderIp($ip)) { return false; } $updated = static::query() ->whereKey($this->getKey()) ->where(function (Builder $query): void { $query->whereNull('ip') ->orWhere('ip', '') ->orWhereIn('ip', self::PLACEHOLDER_IPS); }) ->update(['ip' => $ip]); if ($updated === 0) { return false; } $this->forceFill(['ip' => $ip]); $this->syncOriginalAttribute('ip'); static::flushIdentityMap(); return true; } /** * Persist provider status without saving a stale in-memory IP value. * * @param array $updates */ private function persistProviderState(array $updates): void { if (empty($updates)) { return; } static::query()->whereKey($this->getKey())->update($updates); $this->forceFill($updates); $this->syncOriginalAttributes(array_keys($updates)); static::flushIdentityMap(); } /** * Refresh the status of the linked cloud server from its provider. */ public function refreshCloudProviderState(): ?string { return match ($this->cloudProviderToken?->provider) { 'hetzner' => $this->hetzner_server_id ? $this->refreshHetznerState() : null, 'vultr' => $this->vultr_instance_id ? $this->refreshVultrState() : null, 'digitalocean' => $this->digitalocean_droplet_id ? $this->refreshDigitalOceanState() : null, 'hostinger' => $this->hostinger_virtual_machine_id ? $this->refreshHostingerState() : null, default => null, }; } public function refreshHetznerState(): ?string { // Another instance manages the cloud server of an archive; keep the stored status. if ($this->isTransferredAway()) { return $this->hetzner_server_status; } if (! $this->hetzner_server_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hetzner') { return $this->hetzner_server_status; } $hetznerService = new HetznerService($this->cloudProviderToken->token); $server = $hetznerService->getServer($this->hetzner_server_id); $status = $server['status'] ?? null; $assignedIp = data_get($server, 'public_net.ipv4.ip') ?? hetznerServerIpv6(data_get($server, 'public_net.ipv6.ip')); $updates = []; if ($this->hetzner_server_status !== $status) { $updates['hetzner_server_status'] = $status; } $this->persistProviderState($updates); $this->backfillPlaceholderIp($assignedIp); return $status; } public function refreshVultrState(): ?string { // Another instance manages the cloud server of an archive; keep the stored status. if ($this->isTransferredAway()) { return $this->vultr_instance_status; } if (! $this->vultr_instance_id || ! $this->cloudProviderToken) { return null; } $vultrService = new VultrService($this->cloudProviderToken->token); try { $instance = $vultrService->getInstance($this->vultr_instance_id); } catch (\Throwable $e) { if ((int) $e->getCode() !== 404) { throw $e; } if ($this->vultr_instance_status !== 'deleted') { $this->persistProviderState(['vultr_instance_status' => 'deleted']); } return 'deleted'; } $status = ($instance['power_status'] ?? null) === 'stopped' ? 'stopped' : ($instance['status'] ?? null); $publicIp = $vultrService->getPublicIp($instance); $updates = []; if ($this->vultr_instance_status !== $status) { $updates['vultr_instance_status'] = $status; } $this->persistProviderState($updates); $this->backfillPlaceholderIp($publicIp); return $status; } public function refreshDigitalOceanState(): ?string { // Another instance manages the cloud server of an archive; keep the stored status. if ($this->isTransferredAway()) { return $this->digitalocean_droplet_status; } if (! $this->digitalocean_droplet_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'digitalocean') { return $this->digitalocean_droplet_status; } $digitalOceanService = new DigitalOceanService($this->cloudProviderToken->token); try { $droplet = $digitalOceanService->getDroplet((int) $this->digitalocean_droplet_id); } catch (RequestException $e) { if ($e->response?->status() === 404) { $this->persistProviderState(['digitalocean_droplet_status' => 'deleted']); return 'deleted'; } throw $e; } catch (\Throwable $e) { if ((int) $e->getCode() === 404) { $this->persistProviderState(['digitalocean_droplet_status' => 'deleted']); return 'deleted'; } throw $e; } if (empty($droplet)) { return $this->digitalocean_droplet_status; } $status = $droplet['status'] ?? null; $ip = $digitalOceanService->getPublicIpAddress($droplet); $this->persistProviderState(['digitalocean_droplet_status' => $status]); $this->backfillPlaceholderIp($ip); return $status; } public function refreshHostingerState(): ?string { // Another instance manages the cloud server of an archive; keep the stored status. if ($this->isTransferredAway()) { return $this->hostinger_virtual_machine_status; } if (! $this->hostinger_virtual_machine_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'hostinger') { return $this->hostinger_virtual_machine_status; } $hostingerService = new HostingerService($this->cloudProviderToken->token); $virtualMachine = $hostingerService->getVirtualMachine((int) $this->hostinger_virtual_machine_id); if (empty($virtualMachine)) { return $this->hostinger_virtual_machine_status; } $status = $virtualMachine['state'] ?? null; $ip = $hostingerService->getPublicIpAddress($virtualMachine); $this->persistProviderState(['hostinger_virtual_machine_status' => $status]); $this->backfillPlaceholderIp($ip); return $status; } protected function isCoolifyHost(): Attribute { return Attribute::make( get: function () { return $this->id === 0; } ); } public static function isReachable() { return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true); } /** * Get query builder for servers owned by current team. * If you need all servers without further query chaining, use ownedByCurrentTeamCached() instead. */ public static function ownedByCurrentTeam(array $select = ['*']) { $teamId = currentTeam()->id; $selectArray = collect($select)->concat(['id']); return Server::whereTeamId($teamId)->with('settings', 'swarmDockers', 'standaloneDockers')->select($selectArray->all())->orderBy('name'); } /** * Get all servers owned by current team (cached for request duration). */ public static function ownedByCurrentTeamCached() { return once(function () { return Server::ownedByCurrentTeam()->get(); }); } public static function isUsable(): Builder { return self::usableByBuildServerStatus(false); } public static function isUsableBuildServer(): Builder { return self::usableByBuildServerStatus(true); } private static function usableByBuildServerStatus(bool $isBuildServer): Builder { $query = Server::ownedByCurrentTeam() ->whereRelation('settings', 'is_reachable', true) ->whereRelation('settings', 'is_usable', true) ->whereRelation('settings', 'is_swarm_worker', false) ->whereRelation('settings', 'force_disabled', false); return $isBuildServer ? self::whereServerRole($query, ServerRole::BUILD) : self::whereServerRole($query, ServerRole::DEPLOYMENT, ServerRole::BOTH); } /** * Filters by the effective server role. A null role falls back to the legacy * is_build_server flag, like ServerSetting::effectiveServerRole(). */ private static function whereServerRole(Builder $query, ServerRole ...$roles): Builder { $legacyBuildServerFlags = collect($roles) ->reject(fn (ServerRole $role) => $role === ServerRole::DEPLOYMENT) ->map(fn (ServerRole $role) => $role === ServerRole::BUILD) ->values() ->all(); return $query->whereHas('settings', fn (Builder $settings) => $settings ->whereIn('server_role', array_map(fn (ServerRole $role) => $role->value, $roles)) ->orWhere(fn (Builder $legacy) => $legacy ->whereNull('server_role') ->whereIn('is_build_server', $legacyBuildServerFlags))); } public function canHostResources(): bool { return $this->settings->effectiveServerRole()->canDeploy(); } public function settings() { return $this->hasOne(ServerSetting::class); } public function dockerCleanupExecutions() { return $this->hasMany(DockerCleanupExecution::class); } public function proxySet() { return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && $this->canHostResources(); } /** * Writes the catch-all proxy configuration for requests that no resource handles. * Priority: redirect URL > custom error page > Coolify default error page. */ public function setupDefaultRedirect() { $banner = "# This file is generated by Coolify, do not edit it manually.\n". "# Disable the default redirect to customize (only if you know what are you doing).\n\n"; $dynamic_conf_path = $this->proxyPath().'/dynamic'; $proxy_type = $this->proxyType(); $redirect_enabled = $this->proxy->redirect_enabled ?? true; if ($proxy_type === ProxyTypes::TRAEFIK->value) { $default_redirect_file = "$dynamic_conf_path/default_redirect_503.yaml"; } elseif ($proxy_type === ProxyTypes::CADDY->value) { $default_redirect_file = "$dynamic_conf_path/default_redirect_503.caddy"; } instant_remote_process([ "mkdir -p $dynamic_conf_path", "rm -f $dynamic_conf_path/default_redirect_404.yaml", "rm -f $dynamic_conf_path/default_redirect_404.caddy", ], $this); // The error page container must run before the catch-all points to it. $this->setupProxyErrorPageContainer(); if ($redirect_enabled === false) { instant_remote_process(["rm -f $default_redirect_file"], $this); } else { $conf = $banner.$this->defaultRedirectConfiguration(); $base64 = base64_encode($conf); instant_remote_process([ "echo '$base64' | base64 -d | tee $default_redirect_file > /dev/null", ], $this); } if ($proxy_type === 'CADDY') { $this->reloadCaddy(); } } /** * Catch-all proxy configuration (without the banner) for an enabled default redirect. */ public function defaultRedirectConfiguration(): string { $redirect_url = $this->proxy->redirect_url; $usesErrorPage = $this->usesProxyErrorPage(); if ($this->proxyType() === ProxyTypes::CADDY->value) { if (filled($redirect_url)) { return ":80, :443 { redir $redirect_url }"; } if ($usesErrorPage) { // busybox httpd answers 200, so copy_response sets the 503 status. return ':80, :443 { reverse_proxy '.self::PROXY_ERROR_PAGE_CONTAINER.':80 { rewrite /503.html handle_response { copy_response 503 } } }'; } return ':80, :443 { respond 503 }'; } $dynamic_conf = [ 'http' => [ 'routers' => [ 'catchall' => [ 'entryPoints' => [ 0 => 'http', 1 => 'https', ], 'service' => 'noop', 'rule' => 'PathPrefix(`/`)', 'tls' => $this->traefikCertificateResolver() ? ['certResolver' => $this->traefikCertificateResolver()] : [], 'priority' => -1000, ], ], 'services' => [ 'noop' => [ 'loadBalancer' => [ 'servers' => [], ], ], ], ], ]; if (filled($redirect_url)) { $dynamic_conf['http']['routers']['catchall']['middlewares'] = [ 0 => 'redirect-regexp', ]; $dynamic_conf['http']['services']['noop']['loadBalancer']['servers'][0] = [ 'url' => '', ]; $dynamic_conf['http']['middlewares'] = [ 'redirect-regexp' => [ 'redirectRegex' => [ 'regex' => '(.*)', 'replacement' => $redirect_url, 'permanent' => false, ], ], ]; } elseif ($usesErrorPage) { // The noop service (no servers) keeps the 503; the errors middleware only replaces the body. $dynamic_conf['http']['routers']['catchall']['middlewares'] = [ 0 => 'error-pages', ]; $dynamic_conf['http']['middlewares'] = [ 'error-pages' => [ 'errors' => [ 'status' => ['503'], 'service' => 'error-pages', 'query' => '/503.html', ], ], ]; $dynamic_conf['http']['services']['error-pages'] = [ 'loadBalancer' => [ 'servers' => [ ['url' => 'http://'.self::PROXY_ERROR_PAGE_CONTAINER.':80'], ], ], ]; } // A router with `tls` matches only HTTPS, so plain HTTP needs its own catch-all router. if (! $this->isSwarm()) { $catchall = $dynamic_conf['http']['routers']['catchall']; $dynamic_conf['http']['routers']['catchall']['entryPoints'] = ['https']; unset($catchall['tls']); $dynamic_conf['http']['routers']['catchall-http'] = array_merge($catchall, ['entryPoints' => ['http']]); } return Yaml::dump($dynamic_conf, 12, 2); } /** * True when the catch-all shows the error page: a standalone Traefik or Caddy proxy with * the default redirect enabled and no redirect URL. Swarm servers keep the plain 503. */ public function usesProxyErrorPage(): bool { return ! $this->isSwarm() && in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true) && ($this->proxy->redirect_enabled ?? true) !== false && blank($this->proxy->redirect_url); } public function proxyErrorPagesPath(): string { return base_configuration_dir().'/proxy/error-pages'; } /** * The custom error page HTML, or the Coolify default page. */ public function proxyErrorPageHtml(): string { return filled($this->proxy_error_page) ? $this->proxy_error_page : view('proxy.error-page')->render(); } /** * Applications and services on this server that are in maintenance mode. * * @return Collection */ public function maintenanceResources(): Collection { if ($this->isSwarm() || ! in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)) { return collect(); } return $this->applications()->where('is_maintenance_enabled', true)->values() ->concat($this->services()->where('is_maintenance_enabled', true)->get()); } /** * Writes 503.html and starts the error page container, or removes the container when * neither the catch-all nor a maintenance page uses it. Swarm servers are not supported. */ public function setupProxyErrorPageContainer(): void { if ($this->isSwarm()) { return; } $container = self::PROXY_ERROR_PAGE_CONTAINER; if (! $this->usesProxyErrorPage() && $this->maintenanceResources()->isEmpty()) { instant_remote_process(["docker rm -f $container >/dev/null 2>&1 || true"], $this); return; } $path = $this->proxyErrorPagesPath(); $mount = devHostDockerPath($this, $path); $restart = RESTART_MODE; instant_remote_process(["mkdir -p $path"], $this); $this->uploadProxyFile("$path/503.html", $this->proxyErrorPageHtml()); instant_remote_process([ dockerNetworkEnsureCommand('coolify'), "docker start $container >/dev/null 2>&1 || docker run -d --name $container --network coolify --restart $restart --label coolify.managed=true -v $mount:/www:ro ".self::PROXY_ERROR_PAGE_IMAGE.' httpd -f -p 80 -h /www', // A proxy on custom networks only must still reach the error page container. 'docker network connect coolify coolify-proxy >/dev/null 2>&1 || true', ], $this); } /** * Writes $contents to $path on this server with scp. A shell command is limited to 128 KiB, * so large pages and proxy files cannot go inside one command. */ public function uploadProxyFile(string $path, string $contents): void { $uploadPath = '/tmp/coolify-upload-'.bin2hex(random_bytes(8)); $localPath = tempnam(sys_get_temp_dir(), 'coolify-proxy-file-'); try { file_put_contents($localPath, $contents); instant_scp($localPath, $uploadPath, $this); } finally { @unlink($localPath); } // The proxy and the page container read the file as root, other users do not need it. instant_remote_process(["mv -f $uploadPath $path", "chmod 644 $path"], $this); } /** * Writes the maintenance pages and the proxy configuration that shows them instead of the * applications and services in maintenance mode. Swarm servers are not supported. */ public function setupMaintenancePages(): void { $proxyType = $this->proxyType(); if ($this->isSwarm() || ! in_array($proxyType, [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true)) { return; } $resources = $this->maintenanceResources(); $path = $this->proxyErrorPagesPath(); $keep = $resources->map(fn ($resource) => "! -name '{$resource->maintenancePageFile()}' ")->implode(''); instant_remote_process([ "mkdir -p $path", "find $path -maxdepth 1 -name 'maintenance-*.html' {$keep}-delete", ], $this); $this->setupProxyErrorPageContainer(); foreach ($resources as $resource) { $this->uploadProxyFile("$path/{$resource->maintenancePageFile()}", $resource->maintenancePageHtml()); } $dynamicPath = $this->proxyPath().'/dynamic'; instant_remote_process(["mkdir -p $dynamicPath"], $this); if ($proxyType === ProxyTypes::TRAEFIK->value) { $file = "$dynamicPath/coolify-maintenance.yaml"; $configuration = $this->maintenanceTraefikConfiguration($resources); if ($configuration === null) { instant_remote_process(["rm -f $file"], $this); } else { $this->uploadProxyFile($file, $configuration); } } else { // caddy-docker-proxy reads the base Caddyfile again on its next poll. $this->uploadProxyFile("$dynamicPath/Caddyfile", $this->caddyBaseCaddyfile($resources)); } } /** * Traefik routers that use the same rule as the resource routers, with a priority one * higher. Traefik uses the rule length as the default priority, so a resource on a longer * path of the same host keeps its own router. * * @param Collection $resources */ public function maintenanceTraefikConfiguration(Collection $resources): ?string { $routers = []; $middlewares = []; foreach ($resources as $resource) { $routes = $resource->maintenanceRoutes(); if ($routes->isEmpty()) { continue; } $middleware = "coolify-maintenance-{$resource->uuid}"; $middlewares[$middleware] = [ 'errors' => [ 'status' => ['503'], 'service' => 'coolify-maintenance-pages', 'query' => '/'.$resource->maintenancePageFile(), ], ]; foreach ($routes as $index => $route) { $rule = "Host(`{$route['host']}`) && PathPrefix(`{$route['path']}`)"; $router = [ 'entryPoints' => ['http'], 'rule' => $rule, 'service' => 'coolify-maintenance-noop', 'middlewares' => ['coolify-maintenance-headers', $middleware], 'priority' => strlen($rule) + 1, ]; $routers["coolify-maintenance-{$resource->uuid}-{$index}-http"] = $router; if ($route['scheme'] === 'https') { $routers["coolify-maintenance-{$resource->uuid}-{$index}-https"] = array_merge($router, [ 'entryPoints' => ['https'], 'tls' => ['certResolver' => 'letsencrypt'], ]); } } } if ($routers === []) { return null; } $middlewares['coolify-maintenance-headers'] = [ 'headers' => ['customResponseHeaders' => ['Retry-After' => '300']], ]; return "# This file is generated by Coolify, do not edit it manually.\n\n".Yaml::dump([ 'http' => [ 'routers' => $routers, 'middlewares' => $middlewares, 'services' => [ // No servers: Traefik answers 503 and the errors middleware adds the page. 'coolify-maintenance-noop' => ['loadBalancer' => ['servers' => []]], 'coolify-maintenance-pages' => [ 'loadBalancer' => ['servers' => [['url' => 'http://'.self::PROXY_ERROR_PAGE_CONTAINER.':80']]], ], ], ], ], 12, 2); } /** * The base Caddyfile of caddy-docker-proxy. A maintenance block uses the same site address * as the resource labels, so caddy-docker-proxy merges both into one site block. Its named * matcher has the same path as the resource handle, so Caddy runs it first. * * @param Collection|null $resources */ public function caddyBaseCaddyfile(?Collection $resources = null): string { $resources ??= $this->maintenanceResources(); $sites = []; foreach ($resources as $resource) { foreach ($resource->maintenanceRoutes() as $index => $route) { $address = caddySiteAddress($route['scheme'], $route['host'], $route['force_https']); $matcher = "@coolify-maintenance-{$resource->uuid}-{$index}"; $sites[$address][] = implode("\n", [ "\t{$matcher} path {$route['path']}*", "\thandle {$matcher} {", "\t\treverse_proxy ".self::PROXY_ERROR_PAGE_CONTAINER.':80 {', "\t\t\trewrite /{$resource->maintenancePageFile()}", "\t\t\theader_down Retry-After 300", "\t\t\thandle_response {", "\t\t\t\tcopy_response 503", "\t\t\t}", "\t\t}", "\t}", ]); } } $caddyfile = 'import /dynamic/*.caddy'; foreach ($sites as $address => $blocks) { $caddyfile .= "\n\n{$address} {\n".implode("\n", $blocks)."\n}"; } return $caddyfile."\n"; } public function setupDynamicProxyConfiguration() { $settings = instanceSettings(); $dynamic_config_path = $this->proxyPath().'/dynamic'; if ($this->proxyType() === ProxyTypes::TRAEFIK->value) { $file = "$dynamic_config_path/coolify.yaml"; if (empty($settings->fqdn) || (isCloud() && $this->id !== 0) || ! $this->isLocalhost()) { instant_remote_process([ "rm -f $file", ], $this); } else { $url = Url::fromString($settings->fqdn); $host = $this->dashboardTraefikHost($url->getHost()); $schema = $url->getScheme(); $traefik_dynamic_conf = [ 'http' => [ 'middlewares' => [ 'redirect-to-https' => [ 'redirectscheme' => [ 'scheme' => 'https', ], ], 'gzip' => [ 'compress' => true, ], ], 'routers' => [ 'coolify-http' => [ 'middlewares' => [ 0 => 'gzip', ], 'entryPoints' => [ 0 => 'http', ], 'service' => 'coolify', 'rule' => "Host(`{$host}`)", ], 'coolify-reverb-ws' => [ 'entryPoints' => [ 0 => 'http', ], 'service' => 'coolify-reverb', 'rule' => "Host(`{$host}`) && PathPrefix(`/app/`)", ], 'coolify-terminal-ws' => [ 'entryPoints' => [ 0 => 'http', ], 'service' => 'coolify-terminal', 'rule' => "Host(`{$host}`) && PathPrefix(`/terminal/ws`)", ], ], 'services' => [ 'coolify' => [ 'loadBalancer' => [ 'servers' => [ 0 => [ 'url' => 'http://coolify:8080', ], ], ], ], 'coolify-reverb' => [ 'loadBalancer' => [ 'servers' => [ 0 => [ 'url' => 'http://coolify:6001', ], ], ], ], 'coolify-terminal' => [ 'loadBalancer' => [ 'servers' => [ 0 => [ 'url' => 'http://coolify:6002', ], ], ], ], ], ], ]; if ($schema === 'https') { $certificateResolver = $this->traefikCertificateResolver(); $tls = $certificateResolver ? ['certresolver' => $certificateResolver] : []; $traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = $this->dashboardHttpMiddlewares($settings); $traefik_dynamic_conf['http']['routers']['coolify-https'] = [ 'middlewares' => [ 0 => 'gzip', ], 'entryPoints' => [ 0 => 'https', ], 'service' => 'coolify', 'rule' => "Host(`{$host}`)", 'tls' => $tls, ]; $traefik_dynamic_conf['http']['routers']['coolify-reverb-wss'] = [ 'entryPoints' => [ 0 => 'https', ], 'service' => 'coolify-reverb', 'rule' => "Host(`{$host}`) && PathPrefix(`/app/`)", 'tls' => $tls, ]; $traefik_dynamic_conf['http']['routers']['coolify-terminal-wss'] = [ 'entryPoints' => [ 0 => 'https', ], 'service' => 'coolify-terminal', 'rule' => "Host(`{$host}`) && PathPrefix(`/terminal/ws`)", 'tls' => $tls, ]; } $yaml = Yaml::dump($traefik_dynamic_conf, 12, 2); $yaml = "# This file is automatically generated by Coolify.\n". "# Do not edit it manually (only if you know what are you doing).\n\n". $yaml; $base64 = base64_encode($yaml); instant_remote_process([ "mkdir -p $dynamic_config_path", "echo '$base64' | base64 -d | tee $file > /dev/null", ], $this); } } elseif ($this->proxyType() === 'CADDY') { $file = "$dynamic_config_path/coolify.caddy"; if (empty($settings->fqdn) || (isCloud() && $this->id !== 0) || ! $this->isLocalhost()) { instant_remote_process([ "rm -f $file", ], $this); $this->reloadCaddy(); } else { $url = Url::fromString($settings->fqdn); $host = $url->getHost(); $schema = $url->getScheme(); $siteAddress = $this->dashboardCaddySiteAddress($settings, $schema, $host); $caddy_file = " $siteAddress { encode zstd gzip handle /app/* { reverse_proxy coolify:6001 } handle /terminal/ws { reverse_proxy coolify:6002 } reverse_proxy coolify:8080 }"; $base64 = base64_encode($caddy_file); instant_remote_process([ "echo '$base64' | base64 -d | tee $file > /dev/null", ], $this); $this->reloadCaddy(); } } } public function reloadCaddy() { return instant_remote_process([ 'docker exec coolify-proxy caddy reload --config /config/caddy/Caddyfile.autosave', ], $this); } public function dashboardHttpMiddlewares(InstanceSettings $settings): array { if ($settings->is_dashboard_force_https_enabled) { return ['redirect-to-https']; } return ['gzip']; } /** * Traefik matches an IPv6 Host() rule only without brackets (Host(`2a01:4f8::1`)). */ public function dashboardTraefikHost(string $host): string { return trim($host, '[]'); } public function dashboardCaddySiteAddress(InstanceSettings $settings, string $schema, string $host): string { if ($schema === 'https' && ! $settings->is_dashboard_force_https_enabled) { return "http://{$host}, https://{$host}"; } return "{$schema}://{$host}"; } public function proxyPath() { $base_path = config('constants.coolify.base_config_path'); $proxyType = $this->proxyType(); $proxy_path = "$base_path/proxy"; if ($proxyType === ProxyTypes::TRAEFIK->value) { $proxy_path = $proxy_path.'/'; } elseif ($proxyType === ProxyTypes::CADDY->value) { $proxy_path = $proxy_path.'/caddy'; } elseif ($proxyType === ProxyTypes::NGINX->value) { $proxy_path = $proxy_path.'/nginx'; } return $proxy_path; } public function proxyType() { return data_get($this->proxy, 'type'); } public function hasPendingProxyConfiguration(): bool { if ($this->proxy->get('status') !== 'running') { return false; } if ($this->proxy->get('certificates_restart_required')) { return true; } $savedSettings = $this->proxy->get('last_saved_settings'); $appliedSettings = $this->proxy->get('last_applied_settings'); return filled($savedSettings) && filled($appliedSettings) && $savedSettings !== $appliedSettings; } /** * Record the configuration the proxy runs with after a start or restart. */ public function markProxyConfigurationApplied(string $configuration): void { $this->proxy->last_applied_settings = str(base64_encode($configuration))->pipe('md5')->value(); $this->proxy->certificates_restart_required = false; $this->save(); } public function hasCurrentTraefikOutdatedInfo(): bool { if ($this->proxyType() !== ProxyTypes::TRAEFIK->value) { return false; } $detectedVersion = ltrim((string) $this->detected_traefik_version, 'v'); $storedVersion = ltrim((string) data_get($this->traefik_outdated_info, 'current'), 'v'); $type = data_get($this->traefik_outdated_info, 'type'); return filled($detectedVersion) && $storedVersion === $detectedVersion && in_array($type, ['patch_update', 'minor_upgrade'], true); } public function scopeWithProxy(): Builder { return $this->proxy->modelScope(); } /** * Servers that this instance manages. A transferred server is only an archive and does not use a server slot. */ public function scopeNotTransferredAway(Builder $query): Builder { return $query->where(fn (Builder $query) => $query ->whereNull('server_metadata->transfer->status') ->orWhere('server_metadata->transfer->status', '!=', 'transferred')); } public function scopeWhereProxyType(Builder $query, string $proxyType): Builder { return $query->where('proxy->type', $proxyType); } public function isLocalhost() { return $this->ip === 'host.docker.internal' || $this->id === 0; } /** * True only in development for the `testing-host` server. That container runs containers on the * host Docker daemon (/var/run/docker.sock), but its /data/coolify is a Docker named volume. The host * daemon must therefore mount the volume's host path instead of /data/coolify (see devHostDockerPath()). * * Dev KVM VMs and all other servers have their own Docker daemon and their own /data/coolify. * A `host.docker.internal` server writes to the real host /data/coolify, so it also needs no change. */ public function sharesDevHostDocker(): bool { // The saving hook can leave a Stringable in `ip`, so compare the string value. return isDev() && (string) $this->ip === self::DEV_TESTING_HOST_IP; } /** * Usable dedicated (build-only) servers of a team. Servers with the combined role * host deployments, so they are never picked as build servers. Servers dedicated to * GitHub Actions runners are also left out. */ public static function buildServers($teamId): Builder { $query = Server::whereTeamId($teamId) ->whereRelation('settings', 'is_reachable', true) ->whereRelation('settings', 'is_usable', true) ->whereRelation('settings', 'is_swarm_worker', false) ->whereRelation('settings', 'force_disabled', false) ->whereDoesntHave('githubRunnerConfig', fn (Builder $config) => $config->where('is_enabled', true)->where('is_dedicated', true)); return self::whereServerRole($query, ServerRole::BUILD); } public function isForceDisabled() { return $this->settings->force_disabled; } /** * Server was migrated away from this Coolify instance (source side). * Must not be revalidated or re-enabled as a live managed host. */ public function isTransferredAway(): bool { return data_get($this->server_metadata, 'transfer.status') === 'transferred'; } /** * Stops an action that changes the host, its cloud server, its Cloudflare tunnel, or its DNS records. * Another Coolify instance manages a transferred server; this instance keeps only an archive of it. * * @throws \RuntimeException */ public function assertNotTransferredAway(): void { if ($this->isTransferredAway()) { throw new \RuntimeException('This server is managed by another Coolify instance.'); } } /** * Whether this server may be validated / installed against from this instance. */ public function canBeValidated(): bool { return ! $this->isTransferredAway(); } /** * On Coolify Cloud, limit how often SSH validations can run against one IP and from one team. * The window and the maximums are server_validation_* instance settings. * Without a limit, users can make Cloud try SSH logins on hosts that they do not own. * A successful connection gives its attempt back, so only failed logins use up the limit. * * @throws ThrottleRequestsException */ public static function throttleValidation(int $teamId, string $ip): void { if (! isCloud()) { return; } $limits = self::validationRateLimits($teamId, $ip); foreach ($limits as $key => $maxAttempts) { if (RateLimiter::tooManyAttempts($key, $maxAttempts)) { $seconds = RateLimiter::availableIn($key); throw new ThrottleRequestsException("Too many server validations. Please try again in {$seconds} seconds.", headers: ['Retry-After' => $seconds]); } } foreach (array_keys($limits) as $key) { RateLimiter::hit($key, self::validationRateLimitSeconds()); } } private static function validationRateLimitSeconds(): int { return instanceSettings()->server_validation_rate_limit_seconds; } /** * @return array rate limiter key => max attempts */ private static function validationRateLimits(int $teamId, string $ip): array { $settings = instanceSettings(); return [ 'server-validation:ip:'.strtolower(self::normalizeIp($ip)) => $settings->server_validation_max_failures_per_ip, 'server-validation:team:'.$teamId => $settings->server_validation_max_failures_per_team, ]; } private function refundValidationAttempt(): void { if (! isCloud()) { return; } foreach (array_keys(self::validationRateLimits($this->team_id, $this->ip)) as $key) { if (RateLimiter::attempts($key) > 0) { RateLimiter::decrement($key, self::validationRateLimitSeconds()); } } } public function forceEnableServer() { if ($this->isTransferredAway()) { return; } $this->settings->force_disabled = false; $this->settings->save(); } public function forceDisableServer() { $this->settings->force_disabled = true; $this->settings->save(); $sshKeyFileLocation = "id.root@{$this->uuid}"; Storage::disk('ssh-keys')->delete($sshKeyFileLocation); } public function sentinelHeartbeat(bool $isReset = false) { $this->sentinel_updated_at = $isReset ? now()->subMinutes(6000) : now(); $this->save(); } /** * Get the wait time for Sentinel to push before performing an SSH check. * * @return int The wait time in seconds. */ public function waitBeforeDoingSshCheck(): int { $wait = $this->settings->sentinel_push_interval_seconds * 3; if ($wait < 120) { $wait = 120; } return $wait; } public function firstSentinelReportTimeoutSeconds(): int { return max(30, $this->settings->sentinel_push_interval_seconds + 30); } public static function sentinelReportedVersionCacheKey(int $serverId): string { return "sentinel:reported-version:{$serverId}"; } /** * The last known reason why Sentinel pushes do not arrive. It is shown while Sentinel is out of sync. */ public function sentinelPushProblem(): ?string { return Cache::get("sentinel:push-problem:{$this->id}"); } public function rememberSentinelPushProblem(?string $problem): void { if (blank($problem)) { Cache::forget("sentinel:push-problem:{$this->id}"); return; } Cache::put("sentinel:push-problem:{$this->id}", Str::limit($problem, 500), now()->addDay()); } public function isSentinelLive() { return Carbon::parse($this->sentinel_updated_at)->isAfter(now()->subSeconds($this->waitBeforeDoingSshCheck())); } public function sentinelStatus(): string { if ($this->sentinel_waiting_since !== null) { return $this->sentinel_waiting_since->isAfter(now()->subSeconds($this->firstSentinelReportTimeoutSeconds())) ? 'waiting' : 'out_of_sync'; } return $this->isSentinelLive() ? 'in_sync' : 'out_of_sync'; } public function isSentinelEnabled(): bool { return ! $this->isBuildServer() && ! $this->isSwarm() && ! $this->isForceDisabled() && ! $this->isTransferredAway(); } public function isMetricsEnabled(): bool { return $this->settings->is_metrics_enabled; } /** * Traefik certificate resolver for generated HTTPS routers, or null when the proxy uses its own certificates. * A resource value overrides the server value; "none" turns the resolver off. */ public function traefikCertificateResolver(?string $resourceResolver = null): ?string { $resolver = filled($resourceResolver) ? $resourceResolver : data_get($this, 'settings.traefik_certificate_resolver'); return filled($resolver) && $resolver !== ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE ? $resolver : null; } public function isTrafficAnalyticsEnabled(): bool { return (bool) data_get($this, 'settings.is_traffic_analytics_enabled', false); } /** * Traffic analytics reads the access log of a Coolify-managed Traefik or Caddy proxy. */ public function hasTrafficAnalyticsProxy(): bool { return in_array($this->proxyType(), [ProxyTypes::TRAEFIK->value, ProxyTypes::CADDY->value], true); } /** * Why traffic analytics cannot be enabled on this server, or null when it can. */ public function trafficAnalyticsUnsupportedReason(): ?string { if ($this->isSwarm() || $this->isBuildServer()) { return 'Traffic analytics is not supported on Swarm/Build servers.'; } if (! $this->hasTrafficAnalyticsProxy()) { return 'Traffic analytics needs the Traefik or Caddy proxy.'; } return null; } public function supportsTrafficAnalytics(): bool { return $this->trafficAnalyticsUnsupportedReason() === null; } /** * Major and minor version from a caddy-docker-proxy image tag, for example [2, 8] for * `lucaslorentz/caddy-docker-proxy:2.8-alpine`. Other images, `latest`, and digests without a tag give null. * * @return array{0: int, 1: int}|null */ public static function caddyDockerProxyImageVersion(?string $image): ?array { if ($image === null || preg_match('#(?:^|/)caddy-docker-proxy:(\d+)\.(\d+)#', $image, $version) !== 1) { return null; } return [(int) $version[1], (int) $version[2]]; } /** * Caddy image in the saved proxy configuration. Null for other proxies or a configuration that cannot be read. */ public function configuredCaddyProxyImage(): ?string { if ($this->proxyType() !== ProxyTypes::CADDY->value) { return null; } try { $image = data_get(Yaml::parse((string) $this->proxy->get('last_saved_proxy_configuration')), 'services.caddy.image'); } catch (ParseException) { return null; } return is_string($image) && $image !== '' ? $image : null; } /** * The saved Caddy image when it is caddy-docker-proxy older than 2.9 (Caddy 2.7), else null. * Unknown versions (custom images, `latest`, digests) are not reported. */ public function outdatedCaddyProxyImage(): ?string { $image = $this->configuredCaddyProxyImage(); $version = self::caddyDockerProxyImageVersion($image); return $version !== null && $version < self::MINIMUM_CURRENT_CADDY_PROXY_VERSION ? $image : null; } /** * True when the Caddy proxy runs caddy-docker-proxy 2.9+ (Caddy 2.8+). The 2.8 image (the default before 2.13) * runs Caddy 2.7.6, which rejects the whole Caddyfile when it contains newer directives. A saved change that * is not applied yet may still run the old image, so it counts as unsupported. */ private function caddyRunsCurrentVersion(): bool { if ($this->hasPendingProxyConfiguration()) { return false; } $version = self::caddyDockerProxyImageVersion($this->configuredCaddyProxyImage()); return $version !== null && $version >= self::MINIMUM_CURRENT_CADDY_PROXY_VERSION; } /** * Caddy's `log_append` tags access-log lines with the app UUID for traffic analytics. It needs Caddy 2.8+. */ public function caddySupportsLogAppend(): bool { return $this->caddyRunsCurrentVersion(); } /** * Caddy 2.8 renamed `basicauth` to `basic_auth`. Caddy 2.7 knows only `basicauth`, and Caddy 2.8+ still * accepts it as a deprecated name, so `basicauth` is the safe fallback. */ public function caddySupportsBasicAuthDirective(): bool { return $this->caddyRunsCurrentVersion(); } public function caddySupportsArgon2idBasicAuth(): bool { return $this->caddyRunsCurrentVersion() && self::caddyDockerProxyImageVersion($this->configuredCaddyProxyImage()) >= [2, 11]; } public function isServerApiEnabled(): bool { return $this->settings->is_sentinel_enabled; } public function checkSentinel() { CheckAndStartSentinelJob::dispatch($this); } public function getDiskUsage(): ?string { return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false); // return instant_remote_process(["df /| tail -1 | awk '{ print $5}' | sed 's/%//g'"], $this, false); } public function definedResources() { $applications = $this->applications(); $databases = $this->databases(); $services = $this->services(); return $applications->concat($databases)->concat($services->get()); } /** * The resources that deleting this server with "Delete all resources" deletes. An application that has this server * only as an additional destination runs on another server, so it is detached instead. * * @return Collection */ public function primaryResources(): Collection { return $this->primaryApplications() ->concat($this->databases()) ->concat($this->services()->get()); } /** * Remove this server from the applications that use it only as an additional destination. * The applications keep running on their other servers. * * @param bool $stopContainers stop and remove the containers of the applications on this server (best effort) */ public function detachAdditionalApplications(bool $stopContainers = true): void { $applications = $this->additionalApplications(); if ($stopContainers) { foreach ($applications as $application) { try { StopApplicationOneServer::run($application, $this); } catch (\Throwable $e) { logger()->warning('Could not stop the containers of an application on a deleted server.', [ 'application_id' => $application->id, 'server_id' => $this->id, 'error' => $e->getMessage(), ]); } } } DB::table('additional_destinations') ->where('server_id', $this->id) ->whereIn('application_id', $applications->pluck('id')) ->delete(); } public function stopUnmanaged($id) { return instant_remote_process([dockerStopCommand(0, escapeshellarg($id), $this)], $this); } public function restartUnmanaged($id) { return instant_remote_process(['docker restart '.escapeshellarg($id)], $this); } public function startUnmanaged($id) { return instant_remote_process(['docker start '.escapeshellarg($id)], $this); } public function getContainers() { $containers = collect([]); $containerReplicates = collect([]); if ($this->isSwarm()) { $containers = instant_remote_process_with_timeout(["docker service inspect $(docker service ls -q) --format '{{json .}}'"], $this, false); $containers = format_docker_command_output_to_json($containers); $containerReplicates = instant_remote_process_with_timeout(["docker service ls --format '{{json .}}'"], $this, false); if ($containerReplicates) { $containerReplicates = format_docker_command_output_to_json($containerReplicates); foreach ($containerReplicates as $containerReplica) { $name = data_get($containerReplica, 'Name'); $containers = $containers->map(function ($container) use ($name, $containerReplica) { if (data_get($container, 'Spec.Name') === $name) { $replicas = data_get($containerReplica, 'Replicas'); $running = str($replicas)->explode('/')[0]; $total = str($replicas)->explode('/')[1]; if ($running === $total) { data_set($container, 'State.Status', 'running'); data_set($container, 'State.Health.Status', 'healthy'); } else { data_set($container, 'State.Status', 'starting'); data_set($container, 'State.Health.Status', 'unhealthy'); } } return $container; }); } } } else { $containers = instant_remote_process_with_timeout(["docker container inspect $(docker container ls -aq) --format '{{json .}}'"], $this, false); $containers = format_docker_command_output_to_json($containers); $containerReplicates = collect([]); } return [ 'containers' => collect($containers) ?? collect([]), 'containerReplicates' => collect($containerReplicates) ?? collect([]), ]; } public function loadAllContainers(): Collection { if ($this->isFunctional()) { $containers = instant_remote_process(["docker ps -a --format '{{json .}}'"], $this); $containers = format_docker_command_output_to_json($containers); return collect($containers); } return collect([]); } public function loadUnmanagedContainers(): Collection { if ($this->isFunctional()) { $containers = instant_remote_process(["docker ps -a --format '{{json .}}'"], $this); $containers = format_docker_command_output_to_json($containers); $containers = $containers->map(function ($container) { $labels = data_get($container, 'Labels'); if (! str($labels)->contains('coolify.managed')) { return $container; } return null; }); $containers = $containers->filter(); return collect($containers); } else { return collect([]); } } public function databases() { // Get destination IDs for this server in two efficient queries $standaloneDockerIds = StandaloneDocker::where('server_id', $this->id)->pluck('id'); $swarmDockerIds = SwarmDocker::where('server_id', $this->id)->pluck('id'); $destinationCondition = function ($query) use ($standaloneDockerIds, $swarmDockerIds) { $query->where(function ($q) use ($standaloneDockerIds) { $q->where('destination_type', StandaloneDocker::class) ->whereIn('destination_id', $standaloneDockerIds); })->orWhere(function ($q) use ($swarmDockerIds) { $q->where('destination_type', SwarmDocker::class) ->whereIn('destination_id', $swarmDockerIds); }); }; // Query each database type with the destination condition $postgresqls = StandalonePostgresql::where($destinationCondition)->get(); $redis = StandaloneRedis::where($destinationCondition)->get(); $mongodbs = StandaloneMongodb::where($destinationCondition)->get(); $mysqls = StandaloneMysql::where($destinationCondition)->get(); $mariadbs = StandaloneMariadb::where($destinationCondition)->get(); $keydbs = StandaloneKeydb::where($destinationCondition)->get(); $dragonflies = StandaloneDragonfly::where($destinationCondition)->get(); $clickhouses = StandaloneClickhouse::where($destinationCondition)->get(); $sqlites = StandaloneSqlite::where($destinationCondition)->get(); return $postgresqls ->concat($redis) ->concat($mongodbs) ->concat($mysqls) ->concat($mariadbs) ->concat($keydbs) ->concat($dragonflies) ->concat($clickhouses) ->concat($sqlites) ->reject(fn ($item) => $item instanceof StandalonePostgresql && $item->isInstanceDatabase()); } /** * The applications whose primary destination is on this server, plus the applications that use this server as an * additional destination. */ public function applications() { $applications = $this->primaryApplications(); // Get additional server applications $additionalApplicationIds = DB::table('additional_destinations') ->where('server_id', $this->id) ->pluck('application_id'); if ($additionalApplicationIds->isNotEmpty()) { $additionalApps = Application::whereIn('id', $additionalApplicationIds)->get(); $applications = $applications->concat($additionalApps); } return $applications; } /** * The applications whose primary destination is on this server. * * @return Collection */ public function primaryApplications(): Collection { // Get destination IDs for this server in two efficient queries $standaloneDockerIds = StandaloneDocker::where('server_id', $this->id)->pluck('id'); $swarmDockerIds = SwarmDocker::where('server_id', $this->id)->pluck('id'); // Query all applications in a single query using polymorphic conditions return Application::where(function ($query) use ($standaloneDockerIds, $swarmDockerIds) { $query->where(function ($q) use ($standaloneDockerIds) { $q->where('destination_type', StandaloneDocker::class) ->whereIn('destination_id', $standaloneDockerIds); })->orWhere(function ($q) use ($swarmDockerIds) { $q->where('destination_type', SwarmDocker::class) ->whereIn('destination_id', $swarmDockerIds); }); })->get(); } /** * The applications that use this server only as an additional destination. Their primary destination is on * another server. * * @return Collection */ public function additionalApplications(): Collection { $applicationIds = DB::table('additional_destinations') ->where('server_id', $this->id) ->whereNotIn('application_id', $this->primaryApplications()->pluck('id')) ->pluck('application_id'); return Application::query()->with('destination.server')->whereIn('id', $applicationIds)->get(); } public function dockerComposeBasedApplications() { return $this->applications()->filter(function ($application) { return data_get($application, 'build_pack') === 'dockercompose'; }); } public function dockerComposeBasedPreviewDeployments() { return $this->previews()->filter(function ($preview) { $applicationId = data_get($preview, 'application_id'); $application = Application::find($applicationId); if (! $application) { return false; } return data_get($application, 'build_pack') === 'dockercompose'; }); } public function services() { return $this->hasMany(Service::class); } public function port(): Attribute { return Attribute::make( get: function ($value) { return (int) preg_replace('/[^0-9]/', '', $value); }, set: function ($value) { return (int) preg_replace('/[^0-9]/', '', (string) $value); } ); } public function user(): Attribute { return Attribute::make( get: function ($value) { return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value); }, set: function ($value) { return preg_replace(ValidationPatterns::INVALID_SERVER_USERNAME_CHARACTERS_PATTERN, '', $value); } ); } public function ip(): Attribute { return Attribute::make( get: function ($value) { return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); }, set: fn ($value) => self::normalizeIp($value), ); } /** * The form a server IP is stored in: unsafe characters removed (including IPv6 brackets) and * IPv6 addresses in their short lower-case form (2A01:04F8::0001 -> 2a01:4f8::1). */ public static function normalizeIp(?string $value): string { return normalizeIpAddress(preg_replace('/[^0-9a-zA-Z.:%-]/', '', (string) $value)); } public function getIp(): Attribute { return Attribute::make( get: function () { /** * Whether a server of any team already uses this SSH endpoint. The port is part of the endpoint, * so servers behind one NAT address with different forwarded SSH ports are different servers. */ public static function sshEndpointInUse(?string $ip, int|string|null $port, ?int $exceptServerId = null): bool { return self::query() ->where('ip', self::normalizeIp($ip)) ->where('port', (int) ($port ?? 22)) ->when($exceptServerId !== null, fn ($query) => $query->whereKeyNot($exceptServerId)) ->exists(); } if (isDev()) { return '127.0.0.1'; } if ($this->isLocalhost()) { return base_ip(); } return $this->ip; } ); } public function previews() { return $this->destinations()->map(function ($standaloneDocker) { return $standaloneDocker->applications->map(function ($application) { return $application->previews; })->flatten(); })->flatten(); } public function destinations() { $standalone_docker = $this->hasMany(StandaloneDocker::class)->get(); $swarm_docker = $this->hasMany(SwarmDocker::class)->get(); return $standalone_docker->concat($swarm_docker); } public function githubRunnerConfig() { return $this->hasOne(GithubRunnerConfig::class); } public function githubRunnerExecutions() { return $this->hasMany(GithubRunnerExecution::class); } public function hasEnabledGithubRunners(): bool { return $this->githubRunnerConfig()->where('is_enabled', true)->exists(); } public function standaloneDockers() { return $this->hasMany(StandaloneDocker::class); } public function swarmDockers() { return $this->hasMany(SwarmDocker::class); } public function privateKey() { return $this->belongsTo(PrivateKey::class); } public function cloudProviderToken() { return $this->belongsTo(CloudProviderToken::class); } public function notificationThrottles(): MorphMany { return $this->morphMany(NotificationThrottle::class, 'notifiable'); } /** * True while an Unreachable notification was sent and no Reachable notification followed. */ protected function unreachableNotificationSent(): Attribute { return Attribute::get(fn (): bool => NotificationThrottle::wasSent($this, Unreachable::class)); } public function sslCertificates() { return $this->hasMany(SslCertificate::class); } public function muxFilename() { return 'mux_'.$this->uuid; } public function team() { return $this->belongsTo(Team::class); } /** * @return array{id?: int, name: string, uuid: string, network: string, server_id: int} */ public function defaultStandaloneDockerAttributes(?int $id = null): array { $attributes = [ 'name' => 'coolify', 'uuid' => new_public_id(), 'network' => 'coolify', 'server_id' => $this->id, ]; if (! is_null($id)) { $attributes['id'] = $id; } return $attributes; } public function environment_variables() { return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'server'); } public function isProxyShouldRun() { // TODO: Do we need "|| $this->proxy->force_stop" here? if ($this->proxyType() === ProxyTypes::NONE->value || $this->isBuildServer()) { return false; } return true; } /** * Marks a proxy start or restart as running, so status checks do not start a second proxy meanwhile. * ProxyStatusChangedNotification clears the mark when the start or restart finishes. */ public function markProxyTransitionStarted(): void { Cache::put($this->proxyTransitionCacheKey(), true, now()->addMinutes(5)); } public function markProxyTransitionFinished(): void { Cache::forget($this->proxyTransitionCacheKey()); } public function isProxyTransitionInProgress(): bool { return Cache::has($this->proxyTransitionCacheKey()); } private function proxyTransitionCacheKey(): string { return "proxy-transition:{$this->uuid}"; } public function skipServer() { if ($this->hasPlaceholderIp()) { return true; } if ($this->settings->force_disabled === true) { return true; } return false; } public function isFunctional() { $isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && ! $this->hasPlaceholderIp(); if ($isFunctional === false) { Storage::disk('ssh-mux')->delete($this->muxFilename()); } return $isFunctional; } /** * Like isFunctional(), but runs a live SSH and Docker check when the server is marked * unreachable. The flag can be stale after one failed scheduled check. */ public function isFunctionalAfterRecheck(): bool { if ($this->isFunctional()) { return true; } if ($this->settings->is_reachable || $this->settings->force_disabled || $this->hasPlaceholderIp()) { return false; } (new ServerConnectionCheckJob($this))->handle(); $this->settings->refresh(); return $this->isFunctional(); } public function isLogDrainEnabled() { return $this->isFluentBitLogDrainEnabled() || $this->settings->is_logdrain_cloudwatch_enabled; } /** * Whether the active log drain relies on the coolify-log-drain Fluent Bit container. */ public function isFluentBitLogDrainEnabled(): bool { return $this->settings->is_logdrain_newrelic_enabled || $this->settings->is_logdrain_axiom_enabled || $this->settings->is_logdrain_custom_enabled; } public function validateOS(): bool|Stringable { $os_release = instant_remote_process(['cat /etc/os-release'], $this); return self::supportedOsFromOsRelease($os_release); } /** * Match /etc/os-release against SUPPORTED_OS: ID first, then each ID_LIKE token (e.g. Manjaro: ID=manjaro, ID_LIKE=arch). */ public static function supportedOsFromOsRelease(?string $osRelease): bool|Stringable { $collectedData = collect(explode("\n", (string) $osRelease)) ->mapWithKeys(function ($line) { $item = str($line)->trim(); return [$item->before('=')->value() => $item->after('=')->lower()->replace(['"', "'"], '')->value()]; }); $candidates = collect([data_get($collectedData, 'ID')]) ->merge(explode(' ', (string) data_get($collectedData, 'ID_LIKE'))) ->filter(); foreach ($candidates as $candidate) { $supported = collect(SUPPORTED_OS)->filter(fn ($supportedOs) => str($supportedOs)->contains($candidate)); if ($supported->count() === 1) { return str($supported->first()); } } return false; } public function gatherServerMetadata(): ?array { if (! $this->isFunctional()) { return null; } try { $output = instant_remote_process([ 'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s && echo "---DOCKER---" && (docker version --format \'{{.Server.Version}}\' 2>/dev/null || true) && echo "---COMPOSE---" && (docker compose version --short 2>/dev/null || true) && echo "---GPUS---" && (nvidia-smi --query-gpu=index,uuid,memory.total,name --format=csv,noheader,nounits 2>/dev/null || true) && echo "---NVIDIA_TOOLKIT---" && (command -v nvidia-container-runtime-hook nvidia-ctk 2>/dev/null || true)', ], $this, false); if (! $output) { return null; } $sections = []; $sectionLines = []; $currentKey = null; foreach (explode("\n", trim($output)) as $line) { $line = trim($line); if (preg_match('/^---(\w+)---$/', $line, $m)) { $currentKey = $m[1]; } elseif ($currentKey) { $sections[$currentKey] = $line; if ($line !== '') { $sectionLines[$currentKey][] = $line; } } } $metadata = [ 'os' => $sections['PRETTY_NAME'] ?? 'Unknown', 'arch' => $sections['ARCH'] ?? 'Unknown', 'kernel' => $sections['KERNEL'] ?? 'Unknown', 'cpus' => (int) ($sections['CPUS'] ?? 0), 'memory_bytes' => (int) ($sections['MEMORY'] ?? 0), 'uptime_since' => $sections['UPTIME_SINCE'] ?? null, 'gpus' => self::parseNvidiaSmiGpus($sectionLines['GPUS'] ?? []), 'nvidia_container_toolkit' => ($sectionLines['NVIDIA_TOOLKIT'] ?? []) !== [], 'collected_at' => now()->toIso8601String(), ]; // Merge into the saved row: server_metadata also holds the transfer and migration state. DB::transaction(function () use ($metadata) { $locked = self::query()->lockForUpdate()->findOrFail($this->id); $locked->update(['server_metadata' => array_merge($locked->server_metadata ?? [], $metadata)]); $this->server_metadata = $locked->server_metadata; $this->syncOriginalAttribute('server_metadata'); }); try { $detectedDockerVersion = parseDockerEngineVersion($sections['DOCKER'] ?? null); if ($detectedDockerVersion !== null) { $this->rememberDockerVersion($detectedDockerVersion); } $detectedComposeVersion = parseDockerEngineVersion($sections['COMPOSE'] ?? null); if ($detectedComposeVersion !== null) { $this->rememberComposeVersion($detectedComposeVersion); } } catch (\Throwable $e) { Log::debug('Failed to store server runtime versions', [ 'server_id' => $this->id, 'error' => $e->getMessage(), ]); } return $metadata; } catch (\Throwable $e) { Log::debug('Failed to gather server metadata', [ 'server_id' => $this->id, 'error' => $e->getMessage(), ]); return null; } } /** * Parse `nvidia-smi --query-gpu=index,uuid,memory.total,name --format=csv,noheader,nounits` lines. * The name is the last column because it can contain commas. * * @param list $lines * @return list */ public static function parseNvidiaSmiGpus(array $lines): array { $gpus = []; foreach ($lines as $line) { $columns = array_map('trim', explode(',', $line, 4)); if (count($columns) !== 4 || ! ctype_digit($columns[0])) { continue; } [$index, $uuid, $memory, $name] = $columns; $gpus[] = ['index' => $index, 'uuid' => $uuid, 'memory_mb' => (int) $memory, 'name' => $name]; } return $gpus; } /** * GPUs found when the server details were last collected, or null when they were never checked. * * @return list|null */ public function detectedGpus(): ?array { return data_get($this->server_metadata, 'gpus'); } public function isTerminalEnabled() { return $this->settings->is_terminal_enabled ?? false; } public function isSwarm() { return data_get($this, 'settings.is_swarm_manager') || data_get($this, 'settings.is_swarm_worker'); } public function isSwarmManager() { return data_get($this, 'settings.is_swarm_manager'); } public function isSwarmWorker() { return data_get($this, 'settings.is_swarm_worker'); } public function serverStatus(): bool { if ($this->status() === false) { return false; } if ($this->isFunctional() === false) { return false; } return true; } public function status(): bool { ['uptime' => $uptime] = $this->validateConnection(); if ($uptime === false) { $this->markResourcesAsExited(); return false; } return true; } /** * Mark all resources on this server as exited, because their containers cannot be checked. */ public function markResourcesAsExited(): void { foreach ($this->applications() as $application) { $application->status = 'exited'; $application->save(); } foreach ($this->databases() as $database) { $database->status = 'exited'; $database->save(); } foreach ($this->services() as $service) { $apps = $service->applications()->get(); $dbs = $service->databases()->get(); foreach ($apps as $app) { $app->status = 'exited'; $app->save(); } foreach ($dbs as $db) { $db->status = 'exited'; $db->save(); } } } public function isReachableChanged() { $this->refresh(); $unreachableNotificationSent = (bool) $this->unreachable_notification_sent; $isReachable = (bool) $this->settings->is_reachable; if ($isReachable === true) { if ($unreachableNotificationSent === true) { $this->sendReachableNotification(); } return; } if ($this->unreachable_count >= ServerConnectionCheckJob::UNREACHABLE_THRESHOLD && ! $unreachableNotificationSent) { $this->sendUnreachableNotification(); } } public function sendReachableNotification() { if (NotificationThrottle::release($this, Unreachable::class)) { $this->team->notify(new Reachable($this)); } } public function sendUnreachableNotification() { NotificationThrottle::sendOnce($this, Unreachable::class, null, fn () => $this->team->notify(new Unreachable($this))); } public function validateConnection(bool $justCheckingNewKey = false) { if ($this->skipServer()) { return ['uptime' => false, 'error' => 'Server skipped.']; } try { // One attempt only: retries would repeat failed logins on a host that may not belong to the user. instant_remote_process(['ls /'], $this, disableMultiplexing: true, maxAttempts: 1); $this->refundValidationAttempt(); if ($this->settings->is_reachable === false) { $this->settings->is_reachable = true; $this->settings->save(); ServerReachabilityChanged::dispatch($this); } return ['uptime' => true, 'error' => null]; } catch (\Throwable $e) { if ($justCheckingNewKey) { return ['uptime' => false, 'error' => 'This key is not valid for this server.']; } if ($this->settings->is_reachable === true) { $this->settings->is_reachable = false; $this->settings->save(); ServerReachabilityChanged::dispatch($this); } return ['uptime' => false, 'error' => $e->getMessage()]; } } public function installDocker() { return InstallDocker::run($this); } /** * Validate that required commands are available on the server. * * @return array{success: bool, missing: array, found: array} */ public function validatePrerequisites(): array { return ValidatePrerequisites::run($this); } public function installPrerequisites() { return InstallPrerequisites::run($this); } public function validateDockerEngine($throwError = false) { $dockerBinary = instant_remote_process(['command -v docker'], $this, false, no_sudo: true); if (is_null($dockerBinary)) { $this->settings->is_usable = false; $this->settings->save(); if ($throwError) { throw new \Exception('Server is not usable. Docker Engine is not installed.'); } return false; } try { instant_remote_process(['docker version'], $this); } catch (\Throwable $e) { $this->settings->is_usable = false; $this->settings->save(); if ($throwError) { throw new \Exception('Server is not usable. Docker Engine is not running.'); } return false; } $this->settings->is_usable = true; $this->settings->save(); $this->validateCoolifyNetwork(isSwarm: false, isBuildServer: $this->isBuildServer()); return true; } public function validateDockerCompose($throwError = false) { $dockerCompose = instant_remote_process(['docker compose version'], $this, false); if (is_null($dockerCompose)) { $this->settings->is_usable = false; $this->settings->save(); if ($throwError) { throw new \Exception('Server is not usable. Docker Compose is not installed.'); } return false; } $this->settings->is_usable = true; $this->settings->save(); return true; } public function validateDockerSwarm() { $swarmStatus = instant_remote_process(['docker info|grep -i swarm'], $this, false); $swarmStatus = str($swarmStatus)->trim()->after(':')->trim(); if ($swarmStatus === 'inactive') { throw new \Exception('Docker Swarm is not initiated. Please join the server to a swarm before continuing.'); return false; } $this->settings->is_usable = true; $this->settings->save(); $this->validateCoolifyNetwork(isSwarm: true); return true; } public function dockerVersion(): ?string { return $this->settings?->docker_version; } public function rememberDockerVersion(?string $version): void { $this->settings->update([ 'docker_version' => parseDockerEngineVersion($version), 'docker_version_checked_at' => now(), ]); } public function composeVersion(): ?string { return $this->settings?->compose_version; } public function rememberComposeVersion(?string $version): void { $this->settings->update([ 'compose_version' => parseDockerEngineVersion($version), 'compose_version_checked_at' => now(), ]); } public function validateDockerEngineVersion() { $dockerVersionRaw = instant_remote_process(['docker version --format json'], $this, false); $dockerVersion = dockerEngineVersionFromJson($dockerVersionRaw); $this->rememberDockerVersion($dockerVersion); $composeVersionRaw = instant_remote_process(['docker compose version --short'], $this, false); $this->rememberComposeVersion(is_string($composeVersionRaw) ? $composeVersionRaw : null); if (is_null(checkMinimumDockerEngineVersion($dockerVersion))) { $this->settings->is_usable = false; $this->settings->save(); return false; } $this->settings->is_reachable = true; $this->settings->is_usable = true; $this->settings->save(); ServerReachabilityChanged::dispatch($this); return true; } /** * Explains why validateDockerEngineVersion() failed: the version could not be read, or it is too old. */ public function dockerEngineVersionError(): string { $requiredVersion = str(config('constants.docker.minimum_required_version'))->before('.'); $documentation = 'documentation'; $dockerVersion = $this->dockerVersion(); if (is_null($dockerVersion)) { $command = $this->isNonRoot() ? 'sudo docker version --format json' : 'docker version --format json'; $user = e($this->user); return "Coolify could not read the Docker Engine version. Run \"{$command}\" on the server as the {$user} user. It must exit without an error and print only JSON. Text from shell startup files (for example ~/.bashrc) breaks the output."; } return "Docker Engine {$dockerVersion} is installed, but Coolify needs version {$requiredVersion} or newer. Please upgrade Docker: {$documentation}."; } public function validateCoolifyNetwork($isSwarm = false, $isBuildServer = false) { if ($isBuildServer) { return; } if ($isSwarm) { return instant_remote_process(['docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true'], $this, false); } else { return instant_remote_process(['docker network create coolify --attachable >/dev/null 2>&1 || true'], $this, false); } } public function isNonRoot() { if ($this->user instanceof Stringable) { return $this->user->value() !== 'root'; } return $this->user !== 'root'; } public function isBuildServer() { return $this->settings->effectiveServerRole() === ServerRole::BUILD; } public function canBuildApplications(): bool { return $this->settings->effectiveServerRole()->canBuild(); } public static function createWithPrivateKey(array $data, PrivateKey $privateKey) { $server = new self($data); $server->privateKey()->associate($privateKey); $server->save(); return $server; } public function updateWithPrivateKey(array $data, ?PrivateKey $privateKey = null) { $this->update($data); if ($privateKey) { $this->privateKey()->associate($privateKey); $this->save(); } return $this; } public function storageCheck(): ?string { $commands = [ 'df / --output=pcent | tr -cd 0-9', ]; return instant_remote_process($commands, $this, false); } public function isIpv6(): bool { return str($this->ip)->contains(':'); } public function restartSentinel(?string $customImage = null, bool $async = true) { try { if ($async) { StartSentinel::dispatch($this, true, null, $customImage); } else { StartSentinel::run($this, true, null, $customImage); } } catch (\Throwable $e) { return handleError($e); } } public function url() { return base_url().'/server/'.$this->uuid; } public function restartContainer(string $containerName) { return instant_remote_process(['docker restart '.escapeshellarg($containerName)], $this, false); } public function changeProxy(string $proxyType, bool $async = true) { $validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) { return str($proxyType->value)->lower(); }); if ($validProxyTypes->contains(str($proxyType)->lower())) { $previousProxyType = $this->proxyType(); $this->proxy->set('type', str($proxyType)->upper()); $this->proxy->set('status', 'exited'); $this->proxy->set('last_saved_proxy_configuration', null); $this->proxy->set('last_saved_settings', null); $this->proxy->set('last_applied_settings', null); $this->proxy->set('certificates_restart_required', false); $this->detected_traefik_version = null; $this->traefik_outdated_info = null; $this->save(); if ($this->proxySet()) { if ($async) { StartProxy::dispatch($this); } else { StartProxy::run($this); } } if ($previousProxyType !== $this->proxyType() && $this->shouldRestartSentinelForTrafficAnalytics()) { // Sentinel keeps the traffic log mount, path and log format of the old proxy until it is recreated. $this->restartSentinel(); } } else { throw new \Exception('Invalid proxy type.'); } } /** * Sentinel reads the proxy access log only when it runs and traffic analytics is on. * The raw setting is used, because a switch to a proxy without analytics support must also drop the old log mount. */ private function shouldRestartSentinelForTrafficAnalytics(): bool { return (bool) $this->settings->is_sentinel_enabled && $this->isSentinelEnabled() && $this->isTrafficAnalyticsEnabled(); } public function isEmpty() { return $this->applications()->count() == 0 && $this->databases()->count() == 0 && $this->services()->count() == 0; } /** * Return the server's CA certificate, generating it first when it does not exist yet. */ public function ensureCaCertificate(): ?SslCertificate { $caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first(); if ($caCertificate) { return $caCertificate; } $this->generateCaCertificate(); return $this->sslCertificates()->where('is_ca_certificate', true)->first(); } public function generateCaCertificate() { try { SslHelper::generateSslCertificate( commonName: 'Coolify CA Certificate', serverId: $this->id, isCaCertificate: true, validityDays: 10 * 365 ); $caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first(); if ($caCertificate) { $certificateContent = $caCertificate->ssl_certificate; $commands = SslHelper::caCertificateFileCommands($certificateContent); instant_remote_process($commands, $this, false); dispatch(new RegenerateSslCertJob( server_id: $this->id, force_regeneration: true )); } } catch (\Throwable $e) { return handleError($e); } } }