diff --git a/app/Enums/ServerRole.php b/app/Enums/ServerRole.php
new file mode 100644
index 0000000000..d07c353aee
--- /dev/null
+++ b/app/Enums/ServerRole.php
@@ -0,0 +1,20 @@
+ ['type' => 'integer', 'example' => 22, 'description' => 'The port of the server.'],
'user' => ['type' => 'string', 'example' => 'root', 'description' => 'The user of the server.'],
'private_key_uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the private key.'],
- 'is_build_server' => ['type' => 'boolean', 'example' => false, 'description' => 'Is build server.'],
+ 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'example' => 'both', 'description' => 'Server role.'],
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Instant validate.'],
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'example' => 'traefik', 'description' => 'The proxy type.'],
],
@@ -481,7 +482,7 @@ class ServersController extends Controller
)]
public function create_server(Request $request)
{
- $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type'];
+ $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'instant_validate', 'proxy_type'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -500,7 +501,7 @@ class ServersController extends Controller
'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|required',
'user' => ValidationPatterns::serverUsernameRules(required: false),
- 'is_build_server' => 'boolean|nullable',
+ 'server_role' => 'string|nullable|in:deployment,build,both',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
], [
@@ -530,8 +531,15 @@ class ServersController extends Controller
if (is_null($request->port)) {
$request->offsetSet('port', 22);
}
- if (is_null($request->is_build_server)) {
- $request->offsetSet('is_build_server', false);
+ $serverRole = $request->filled('server_role')
+ ? ServerRole::from($request->string('server_role')->toString())
+ : ServerRole::BOTH;
+
+ if ($serverRole === ServerRole::DEPLOYMENT && ! ModelsServer::buildServers($teamId)->exists()) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['server_role' => ['Add another build-capable server before you set this server to deployments only.']],
+ ], 422);
}
if (is_null($request->instant_validate)) {
$request->offsetSet('instant_validate', false);
@@ -569,7 +577,7 @@ class ServersController extends Controller
$server->save();
$server->settings()->update([
- 'is_build_server' => $request->is_build_server,
+ 'server_role' => $serverRole,
]);
if ($request->instant_validate) {
ValidateServer::dispatch($server);
@@ -580,7 +588,7 @@ class ServersController extends Controller
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'ip' => $server->ip,
- 'is_build_server' => (bool) $request->is_build_server,
+ 'server_role' => $serverRole->value,
]);
return response()->json([
@@ -614,7 +622,7 @@ class ServersController extends Controller
'port' => ['type' => 'integer', 'description' => 'The port of the server.'],
'user' => ['type' => 'string', 'description' => 'The user of the server.'],
'private_key_uuid' => ['type' => 'string', 'description' => 'The UUID of the private key.'],
- 'is_build_server' => ['type' => 'boolean', 'description' => 'Is build server.'],
+ 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'description' => 'Server role.'],
'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'],
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'],
'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'],
@@ -659,7 +667,7 @@ class ServersController extends Controller
)]
public function update_server(Request $request)
{
- $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout', 'is_terminal_enabled'];
+ $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout', 'is_terminal_enabled'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -677,7 +685,7 @@ class ServersController extends Controller
'port' => 'integer|nullable|between:1,65535',
'private_key_uuid' => 'string|nullable',
'user' => ValidationPatterns::serverUsernameRules(required: false),
- 'is_build_server' => 'boolean|nullable',
+ 'server_role' => 'string|nullable|in:deployment,build,both',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
'concurrent_builds' => 'integer|min:1',
@@ -734,17 +742,29 @@ class ServersController extends Controller
], 422);
}
- if ($request->boolean('is_build_server') && ! $server->isBuildServer() && ! $server->isEmpty()) {
+ $serverRole = null;
+ if ($request->filled('server_role')) {
+ $serverRole = ServerRole::from($request->string('server_role')->toString());
+ }
+
+ if ($serverRole === ServerRole::BUILD && ! $server->isBuildServer() && ! $server->isEmpty()) {
return response()->json([
'message' => 'Validation failed.',
- 'errors' => ['is_build_server' => ['A server with existing resources cannot be configured as a build server.']],
+ 'errors' => ['server_role' => ['A server with existing resources cannot be configured as build only.']],
+ ], 422);
+ }
+
+ if ($serverRole === ServerRole::DEPLOYMENT && ! ModelsServer::buildServers($teamId)->whereKeyNot($server->id)->exists()) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['server_role' => ['Add another build-capable server before you set this server to deployments only.']],
], 422);
}
$server->update($updateFields);
- if ($request->has('is_build_server')) {
+ if ($serverRole !== null) {
$server->settings()->update([
- 'is_build_server' => $request->boolean('is_build_server'),
+ 'server_role' => $serverRole,
]);
}
diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php
index b9f8572673..ed8d34d226 100644
--- a/app/Http/Controllers/Api/TeamController.php
+++ b/app/Http/Controllers/Api/TeamController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -224,6 +225,58 @@ class TeamController extends Controller
);
}
+ #[OA\Patch(
+ summary: 'Update authenticated team',
+ description: 'Update settings for the team bound to the API token.',
+ path: '/team',
+ operationId: 'update-token-team',
+ security: [['bearerAuth' => []]],
+ tags: ['Teams'],
+ requestBody: new OA\RequestBody(
+ required: true,
+ content: new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['is_build_server_fallback_enabled'],
+ properties: [
+ 'is_build_server_fallback_enabled' => [
+ 'type' => 'boolean',
+ 'description' => 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.',
+ ],
+ ],
+ ),
+ ),
+ ),
+ responses: [
+ new OA\Response(response: 200, description: 'Updated team.', content: new OA\JsonContent(ref: '#/components/schemas/Team')),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 403, description: 'Forbidden.'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function update_current_team(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ $team = auth()->user()->teams->where('id', $teamId)->first();
+ if (is_null($team)) {
+ return response()->json(['message' => 'Team not found.'], 404);
+ }
+
+ $this->authorize('update', $team);
+ $validated = $request->validate([
+ 'is_build_server_fallback_enabled' => ['required', 'boolean'],
+ ]);
+
+ $team->update($validated);
+
+ return response()->json($this->removeSensitiveData($team));
+ }
+
#[OA\Get(
summary: 'Authenticated Team Members',
description: 'Get members of the team bound to the API token.',
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 7ef4774f8c..ca4f17ecd4 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -433,6 +433,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
$this->build_server = $buildServers->random();
+ if ($this->build_server->is($this->server)) {
+ $this->application_deployment_queue->addLogEntry("Using deployment server ({$this->server->name}) for the build.");
+
+ return;
+ }
+
$this->application_deployment_queue->build_server_id = $this->build_server->id;
$this->application_deployment_queue->addLogEntry("Found a suitable build server ({$this->build_server->name}).");
$this->use_build_server = true;
diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php
index ba03041e2b..307fbb8936 100644
--- a/app/Livewire/Project/New/Select.php
+++ b/app/Livewire/Project/New/Select.php
@@ -274,7 +274,7 @@ class Select extends Component
$this->servers = $this->allServers;
} else {
if ($this->allServers instanceof Collection) {
- $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->where('settings.is_build_server', false);
+ $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->filter(fn (Server $server) => $server->canHostResources());
} else {
$this->servers = $this->allServers;
}
@@ -372,7 +372,7 @@ class Select extends Component
$this->isDatabase = true;
$this->includeSwarm = false;
if ($this->allServers instanceof Collection) {
- $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->where('settings.is_build_server', false);
+ $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->filter(fn (Server $server) => $server->canHostResources());
} else {
$this->servers = $this->allServers;
}
@@ -382,7 +382,7 @@ class Select extends Component
$this->isDatabase = true;
$this->includeSwarm = false;
if ($this->allServers instanceof Collection) {
- $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->where('settings.is_build_server', false);
+ $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->filter(fn (Server $server) => $server->canHostResources());
} else {
$this->servers = $this->allServers;
}
diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php
index a85306f6bf..1c92078f27 100644
--- a/app/Livewire/Server/New/ByIp.php
+++ b/app/Livewire/Server/New/ByIp.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
+use App\Enums\ServerRole;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
@@ -40,7 +41,7 @@ class ByIp extends Component
public int $port = 22;
- public bool $is_build_server = false;
+ public string $server_role = ServerRole::BOTH->value;
public function mount()
{
@@ -60,7 +61,7 @@ class ByIp extends Component
'ip' => ['required', 'string', new ValidServerIp],
'user' => ValidationPatterns::serverUsernameRules(),
'port' => 'required|integer|between:1,65535',
- 'is_build_server' => 'required|boolean',
+ 'server_role' => ['required', 'in:deployment,build,both'],
];
}
@@ -80,8 +81,8 @@ class ByIp extends Component
'port.required' => 'The Port field is required.',
'port.integer' => 'The Port field must be an integer.',
'port.between' => 'The Port field must be between 1 and 65535.',
- 'is_build_server.required' => 'The Build Server field is required.',
- 'is_build_server.boolean' => 'The Build Server field must be true or false.',
+ 'server_role.required' => 'The Server Role field is required.',
+ 'server_role.in' => 'The selected Server Role is invalid.',
]);
}
@@ -164,14 +165,15 @@ class ByIp extends Component
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
];
- if ($this->is_build_server) {
+ if ($this->server_role === ServerRole::BUILD->value) {
data_forget($payload, 'proxy');
}
$server = Server::create($payload);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
- $server->settings->is_build_server = $this->is_build_server;
+ $server->settings->server_role = ServerRole::from($this->server_role);
+ $server->settings->is_build_server = $this->server_role === ServerRole::BUILD->value;
$server->settings->save();
return redirectRoute($this, 'server.show', [$server->uuid]);
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index b58050cef8..e6e8ec0d69 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Server;
use App\Actions\Server\StopSentinel;
+use App\Enums\ServerRole;
use App\Events\ServerReachabilityChanged;
use App\Models\CloudProviderToken;
use App\Models\Server;
@@ -47,10 +48,10 @@ class Show extends Component
public bool $isSwarmWorker;
- public bool $isBuildServer;
+ public string $serverRole;
#[Locked]
- public bool $isBuildServerLocked = false;
+ public ?string $pendingServerRole = null;
public bool $isMetricsEnabled;
@@ -150,7 +151,7 @@ class Show extends Component
'isUsable' => 'required',
'isSwarmManager' => 'required',
'isSwarmWorker' => 'required',
- 'isBuildServer' => 'required',
+ 'serverRole' => ['required', 'in:deployment,build,both'],
'isMetricsEnabled' => 'required',
'sentinelToken' => 'required',
'sentinelUpdatedAt' => 'nullable',
@@ -198,9 +199,6 @@ class Show extends Component
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->syncData();
- if (! $this->server->isBuildServer() && ! $this->server->isEmpty()) {
- $this->isBuildServerLocked = true;
- }
// Load saved Hetzner status and validation state
$this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->vultrInstanceStatus = $this->server->vultr_instance_status;
@@ -254,7 +252,9 @@ class Show extends Component
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
$this->server->settings->wildcard_domain = $this->wildcardDomain;
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
- $this->server->settings->is_build_server = $this->isBuildServer;
+ $role = ServerRole::from($this->serverRole);
+ $this->server->settings->server_role = $role;
+ $this->server->settings->is_build_server = $role === ServerRole::BUILD;
$this->server->settings->is_metrics_enabled = $this->isMetricsEnabled;
$this->server->settings->sentinel_token = $this->sentinelToken;
$this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds;
@@ -284,7 +284,7 @@ class Show extends Component
$this->isUsable = $this->server->settings->is_usable;
$this->isSwarmManager = $this->server->settings->is_swarm_manager;
$this->isSwarmWorker = $this->server->settings->is_swarm_worker;
- $this->isBuildServer = $this->server->settings->is_build_server;
+ $this->serverRole = $this->server->settings->effectiveServerRole()->value;
$this->isMetricsEnabled = $this->server->settings->is_metrics_enabled;
$this->sentinelToken = $this->server->settings->sentinel_token;
$this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds;
@@ -409,31 +409,67 @@ class Show extends Component
}
}
- public function updatedIsBuildServer($value)
+ public function requestServerRoleChange(): void
{
try {
$this->authorize('update', $this->server);
- if ($value === true && ! $this->server->isEmpty()) {
- $this->isBuildServer = false;
- $this->dispatch('error', 'A server with existing resources cannot be configured as a build server.');
+ $newRole = ServerRole::from($this->serverRole);
+ $currentRole = $this->server->settings()->firstOrFail()->effectiveServerRole();
+
+ if ($newRole === ServerRole::BUILD && ! $this->server->isEmpty()) {
+ $this->serverRole = $currentRole->value;
+ $this->dispatch('error', 'Move or remove the existing resources before you set this server to build only.');
return;
}
- if ($value === true && $this->server->isSentinelEnabled()) {
- $this->isMetricsEnabled = false;
- $this->isSentinelDebugEnabled = false;
- $this->server->settings->is_sentinel_enabled = false;
- StopSentinel::dispatch($this->server);
- $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.');
+
+ if ($newRole === ServerRole::DEPLOYMENT && ! Server::buildServers($this->server->team_id)->whereKeyNot($this->server->id)->exists()) {
+ $this->serverRole = $currentRole->value;
+ $this->dispatch('error', 'Add another build-capable server before you set this server to deployments only.');
+
+ return;
}
- $this->submit();
- // Dispatch event to refresh the navbar
- $this->dispatch('refreshServerShow');
+
+ if ($newRole === ServerRole::BOTH && $currentRole !== ServerRole::BOTH) {
+ $this->pendingServerRole = $newRole->value;
+ $this->serverRole = $currentRole->value;
+ $this->dispatch('open-server-role-confirmation');
+
+ return;
+ }
+
+ $this->saveServerRole($newRole);
} catch (\Throwable $e) {
- return handleError($e, $this);
+ handleError($e, $this);
}
}
+ public function confirmServerRoleChange(): void
+ {
+ try {
+ $this->authorize('update', $this->server);
+ $role = ServerRole::from($this->pendingServerRole ?? '');
+ $this->pendingServerRole = null;
+ $this->saveServerRole($role);
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ private function saveServerRole(ServerRole $role): void
+ {
+ $this->serverRole = $role->value;
+ if ($role === ServerRole::BUILD && $this->server->isSentinelEnabled()) {
+ $this->isMetricsEnabled = false;
+ $this->isSentinelDebugEnabled = false;
+ $this->server->settings->is_sentinel_enabled = false;
+ StopSentinel::dispatch($this->server);
+ $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.');
+ }
+ $this->submit();
+ $this->dispatch('refreshServerShow');
+ }
+
public function regenerateSentinelToken()
{
try {
diff --git a/app/Models/Server.php b/app/Models/Server.php
index dfca0593e4..1da5c8e91d 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -8,6 +8,7 @@ use App\Actions\Server\InstallPrerequisites;
use App\Actions\Server\StartSentinel;
use App\Actions\Server\ValidatePrerequisites;
use App\Enums\ProxyTypes;
+use App\Enums\ServerRole;
use App\Events\ServerReachabilityChanged;
use App\Helpers\SslHelper;
use App\Jobs\CheckAndStartSentinelJob;
@@ -262,7 +263,6 @@ class Server extends BaseModel
'delete_unused_volumes' => 'boolean',
'delete_unused_networks' => 'boolean',
'unreachable_notification_sent' => 'boolean',
- 'is_build_server' => 'boolean',
'force_disabled' => 'boolean',
'sentinel_waiting_since' => 'datetime',
];
@@ -522,17 +522,24 @@ class Server extends BaseModel
private static function usableByBuildServerStatus(bool $isBuildServer): Builder
{
- return Server::ownedByCurrentTeam()
+ $query = Server::ownedByCurrentTeam()
->whereRelation('settings', 'is_reachable', true)
->whereRelation('settings', 'is_usable', true)
->whereRelation('settings', 'is_swarm_worker', false)
- ->whereRelation('settings', 'is_build_server', $isBuildServer)
->whereRelation('settings', 'force_disabled', false);
+
+ return $isBuildServer
+ ? $query->whereHas('settings', fn (Builder $settings) => $settings
+ ->where('server_role', '!=', ServerRole::DEPLOYMENT->value)
+ ->orWhereNull('server_role'))
+ : $query->whereHas('settings', fn (Builder $settings) => $settings
+ ->where('server_role', '!=', ServerRole::BUILD->value)
+ ->orWhereNull('server_role'));
}
public function canHostResources(): bool
{
- return ! $this->isBuildServer();
+ return $this->settings->effectiveServerRole()->canDeploy();
}
public function settings()
@@ -547,7 +554,7 @@ class Server extends BaseModel
public function proxySet()
{
- return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && ! $this->settings->is_build_server;
+ return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && $this->canHostResources();
}
public function setupDefaultRedirect()
@@ -907,7 +914,9 @@ $siteAddress {
->whereRelation('settings', 'is_reachable', true)
->whereRelation('settings', 'is_usable', true)
->whereRelation('settings', 'is_swarm_worker', false)
- ->whereRelation('settings', 'is_build_server', true)
+ ->whereHas('settings', fn (Builder $settings) => $settings
+ ->where('server_role', '!=', ServerRole::DEPLOYMENT->value)
+ ->orWhereNull('server_role'))
->whereRelation('settings', 'force_disabled', false);
}
@@ -1655,7 +1664,7 @@ $siteAddress {
}
$this->settings->is_usable = true;
$this->settings->save();
- $this->validateCoolifyNetwork(isSwarm: false, isBuildServer: $this->settings->is_build_server);
+ $this->validateCoolifyNetwork(isSwarm: false, isBuildServer: $this->isBuildServer());
return true;
}
@@ -1766,7 +1775,12 @@ $siteAddress {
public function isBuildServer()
{
- return $this->settings->is_build_server;
+ return $this->settings->effectiveServerRole() === ServerRole::BUILD;
+ }
+
+ public function canBuildApplications(): bool
+ {
+ return $this->settings->effectiveServerRole()->canBuild();
}
public static function createWithPrivateKey(array $data, PrivateKey $privateKey)
diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php
index e3500b732d..02dc0e8afb 100644
--- a/app/Models/ServerSetting.php
+++ b/app/Models/ServerSetting.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Enums\ServerRole;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
@@ -19,7 +20,7 @@ use OpenApi\Attributes as OA;
'dynamic_timeout' => ['type' => 'integer'],
'force_disabled' => ['type' => 'boolean'],
'force_server_cleanup' => ['type' => 'boolean'],
- 'is_build_server' => ['type' => 'boolean'],
+ 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both']],
'is_cloudflare_tunnel' => ['type' => 'boolean'],
'is_jump_server' => ['type' => 'boolean'],
'is_logdrain_axiom_enabled' => ['type' => 'boolean'],
@@ -71,6 +72,7 @@ class ServerSetting extends Model
'is_swarm_manager',
'is_jump_server',
'is_build_server',
+ 'server_role',
'is_reachable',
'is_usable',
'wildcard_domain',
@@ -126,6 +128,7 @@ class ServerSetting extends Model
'is_reachable' => 'boolean',
'is_usable' => 'boolean',
'is_build_server' => 'boolean',
+ 'server_role' => ServerRole::class,
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
'connection_timeout' => 'integer',
@@ -140,6 +143,7 @@ class ServerSetting extends Model
* `read:sensitive` or `root` token ability.
*/
protected $hidden = [
+ 'is_build_server',
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
@@ -175,6 +179,11 @@ class ServerSetting extends Model
});
}
+ public function effectiveServerRole(): ServerRole
+ {
+ return $this->server_role ?? ($this->is_build_server ? ServerRole::BUILD : ServerRole::BOTH);
+ }
+
/**
* Validate that a sentinel token contains only safe characters.
* Prevents OS command injection when the token is interpolated into shell commands.
diff --git a/app/Models/Team.php b/app/Models/Team.php
index 2478028798..5c748b4ae0 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -28,6 +28,7 @@ use OpenApi\Attributes as OA;
'updated_at' => ['type' => 'string', 'description' => 'The date and time the team was last updated.'],
'show_boarding' => ['type' => 'boolean', 'description' => 'Whether to show the boarding screen or not.'],
'custom_server_limit' => ['type' => 'string', 'description' => 'The custom server limit.'],
+ 'is_build_server_fallback_enabled' => ['type' => 'boolean', 'description' => 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.'],
'members' => new OA\Property(
property: 'members',
type: 'array',
diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php
index bd67f8e39a..4a07e813ea 100644
--- a/app/Services/ServerTransfer/ServerTransferExporter.php
+++ b/app/Services/ServerTransfer/ServerTransferExporter.php
@@ -384,7 +384,8 @@ class ServerTransferExporter
'port' => (int) $server->port,
'user' => (string) $server->user,
'proxy' => $server->proxy?->toArray() ?? [],
- 'is_build_server' => (bool) $server->is_build_server,
+ 'server_role' => $server->settings->effectiveServerRole()->value,
+ 'is_build_server' => $server->isBuildServer(),
'cloud_provider_token_uuid' => $server->cloudProviderToken?->uuid,
'settings' => $settingsPayload,
];
diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php
index b341725f9e..371844bcf9 100644
--- a/app/Services/ServerTransfer/ServerTransferImporter.php
+++ b/app/Services/ServerTransfer/ServerTransferImporter.php
@@ -2,6 +2,7 @@
namespace App\Services\ServerTransfer;
+use App\Enums\ServerRole;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\CloudProviderToken;
@@ -451,8 +452,13 @@ class ServerTransferImporter
$server->uuid = $uuid;
$server->save();
- if ($server->settings && data_get($payload, 'is_build_server')) {
- $server->settings->is_build_server = true;
+ if ($server->settings) {
+ $serverRole = data_get($payload, 'server_role');
+ if (! in_array($serverRole, array_column(ServerRole::cases(), 'value'), true)) {
+ $serverRole = data_get($payload, 'is_build_server') ? ServerRole::BUILD->value : ServerRole::BOTH->value;
+ }
+ $server->settings->server_role = $serverRole;
+ $server->settings->is_build_server = $serverRole === ServerRole::BUILD->value;
$server->settings->save();
}
diff --git a/database/migrations/2026_09_19_121840_add_server_role_to_server_settings_table.php b/database/migrations/2026_09_19_121840_add_server_role_to_server_settings_table.php
new file mode 100644
index 0000000000..4c982a6a49
--- /dev/null
+++ b/database/migrations/2026_09_19_121840_add_server_role_to_server_settings_table.php
@@ -0,0 +1,28 @@
+string('server_role')->nullable()->default(ServerRole::BOTH->value)->after('is_build_server');
+ });
+
+ DB::table('server_settings')
+ ->where('is_build_server', true)
+ ->update(['server_role' => ServerRole::BUILD->value]);
+ }
+
+ public function down(): void
+ {
+ Schema::table('server_settings', function (Blueprint $table) {
+ $table->dropColumn('server_role');
+ });
+ }
+};
diff --git a/openapi.json b/openapi.json
index 500ffdd58b..f8173b7aa8 100644
--- a/openapi.json
+++ b/openapi.json
@@ -20782,6 +20782,62 @@
"bearerAuth": []
}
]
+ },
+ "patch": {
+ "tags": [
+ "Teams"
+ ],
+ "summary": "Update authenticated team",
+ "description": "Update settings for the team bound to the API token.",
+ "operationId": "update-token-team",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "is_build_server_fallback_enabled"
+ ],
+ "properties": {
+ "is_build_server_fallback_enabled": {
+ "type": "boolean",
+ "description": "Whether deployments can fall back to the deployment server when no usable dedicated build server is available."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Updated team.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/Team"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "403": {
+ "description": "Forbidden."
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
}
},
"\/team\/members": {
@@ -22874,6 +22930,10 @@
"type": "string",
"description": "The custom server limit."
},
+ "is_build_server_fallback_enabled": {
+ "type": "boolean",
+ "description": "Whether deployments can fall back to the deployment server when no usable dedicated build server is available."
+ },
"members": {
"description": "The members of the team.",
"type": "array",
diff --git a/openapi.yaml b/openapi.yaml
index 393aa2e60e..3f4993eae9 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -13186,6 +13186,42 @@ paths:
security:
-
bearerAuth: []
+ patch:
+ tags:
+ - Teams
+ summary: 'Update authenticated team'
+ description: 'Update settings for the team bound to the API token.'
+ operationId: update-token-team
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - is_build_server_fallback_enabled
+ properties:
+ is_build_server_fallback_enabled:
+ type: boolean
+ description: 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.'
+ type: object
+ responses:
+ '200':
+ description: 'Updated team.'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Team'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '403':
+ description: Forbidden.
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
/team/members:
get:
tags:
@@ -14673,6 +14709,9 @@ components:
custom_server_limit:
type: string
description: 'The custom server limit.'
+ is_build_server_fallback_enabled:
+ type: boolean
+ description: 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.'
members:
description: 'The members of the team.'
type: array
diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php
index 4b652bd51b..14fc7751d4 100644
--- a/resources/views/components/server/sidebar.blade.php
+++ b/resources/views/components/server/sidebar.blade.php
@@ -63,7 +63,7 @@
'active' => $activeMenu === 'proxy',
'icon' => 'network',
'group' => 'Platform',
- 'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
+ 'visible' => ! $server->isSwarmWorker() && $server->canHostResources(),
'warning' => $server->hasCurrentTraefikOutdatedInfo(),
'tracks_proxy_configuration' => true,
'children' => [
@@ -78,7 +78,7 @@
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
'icon' => 'shield-star',
'group' => 'Platform',
- 'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server),
+ 'visible' => $server->isFunctional() && ! $server->isSwarm() && $server->canHostResources() && auth()->user()?->can('viewSentinel', $server),
'warning' => $server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync',
'tracks_sentinel_status' => true,
'children' => [
diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php
index d9ec926f96..30a2e0ecdd 100644
--- a/resources/views/livewire/server/navbar.blade.php
+++ b/resources/views/livewire/server/navbar.blade.php
@@ -42,7 +42,7 @@
'label' => 'Proxy',
'route' => 'server.proxy',
'active' => request()->routeIs('server.proxy', 'server.proxy.*'),
- 'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
+ 'visible' => ! $server->isSwarmWorker() && $server->canHostResources(),
'warning' => $this->hasTraefikOutdated || $this->hasPendingProxyConfiguration,
],
[
@@ -51,7 +51,7 @@
'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'),
'visible' => $server->isFunctional()
&& ! $server->isSwarm()
- && ! $server->settings->is_build_server
+ && $server->canHostResources()
&& auth()->user()?->can('viewSentinel', $server),
'warning' => $sentinelWarningOverride ?? ($server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync'),
],
diff --git a/resources/views/livewire/server/new/by-ip.blade.php b/resources/views/livewire/server/new/by-ip.blade.php
index 189c059242..d3110316d3 100644
--- a/resources/views/livewire/server/new/by-ip.blade.php
+++ b/resources/views/livewire/server/new/by-ip.blade.php
@@ -83,11 +83,12 @@
helper="Non-root SSH users are experimental." />