Files

2824 lines
100 KiB
PHP

<?php
namespace App\Models;
use App\Actions\Proxy\StartProxy;
use App\Actions\Server\InstallDocker;
use App\Actions\Server\InstallPrerequisites;
use App\Actions\Server\StartSentinel;
use App\Actions\Server\ValidatePrerequisites;
use App\Enums\ApplicationDeploymentStatus;
use App\Enums\ProxyTypes;
use App\Enums\ServerRole;
use App\Events\ServerReachabilityChanged;
use App\Helpers\SshMultiplexingHelper;
use App\Helpers\SslHelper;
use App\Jobs\CheckAndStartSentinelJob;
use App\Jobs\CheckTraefikVersionForServerJob;
use App\Jobs\RegenerateSslCertJob;
use App\Jobs\ServerConnectionCheckJob;
use App\Livewire\Server\Proxy;
use App\Notifications\Server\Reachable;
use App\Notifications\Server\Unreachable;
use App\Services\DigitalOceanService;
use App\Services\HetznerService;
use App\Services\HostingerService;
use App\Services\VultrService;
use App\Support\ValidationPatterns;
use App\Traits\Auditable;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
use OpenApi\Attributes as OA;
use Spatie\SchemalessAttributes\Casts\SchemalessAttributes;
use Spatie\SchemalessAttributes\SchemalessAttributesTrait;
use Spatie\Url\Url;
use Stevebauman\Purify\Facades\Purify;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
/**
* @property array{
* current: string,
* latest: string,
* type: 'patch_update'|'minor_upgrade',
* checked_at: string,
* newer_branch_target?: string,
* newer_branch_latest?: string,
* upgrade_target?: string
* }|null $traefik_outdated_info Traefik version tracking information.
*
* This JSON column stores information about outdated Traefik proxy versions on this server.
* The structure varies depending on the type of update available:
*
* **For patch updates** (e.g., 3.5.0 → 3.5.2):
* ```php
* [
* 'current' => '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'] = str($server->user)->trim();
}
if ($server->ip) {
$payload['ip'] = str($server->ip)->trim();
// 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<string, mixed> $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<int, Application|Service>
*/
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<int, Application|Service> $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<int, Application|Service>|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-reverb-api' => [
'entryPoints' => [
0 => 'http',
],
'service' => 'coolify-reverb',
'rule' => "Host(`{$host}`) && PathPrefix(`/apps`)",
],
'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-reverb-api-https'] = [
'entryPoints' => [
0 => 'https',
],
'service' => 'coolify-reverb',
'rule' => "Host(`{$host}`) && PathPrefix(`/apps`)",
'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 /apps* {
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<string, int> 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());
}
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 hasDefinedResources()
{
$applications = $this->applications()->count() > 0;
$databases = $this->databases()->count() > 0;
$services = $this->services()->count() > 0;
if ($applications || $databases || $services) {
return true;
}
return false;
}
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());
}
public function applications()
{
// 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
$applications = 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();
// 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;
}
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 () {
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, disableMux: false))->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<string> $lines
* @return list<array{index: string, uuid: string, memory_mb: int, name: string}>
*/
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<array{index: string, uuid: string, memory_mb: int, name: string}>|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<string>, found: array<string>}
*/
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 = '<a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>';
$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);
}
}
}