diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php
index c6f0320133..428d696fdc 100644
--- a/app/Actions/Server/DeleteServer.php
+++ b/app/Actions/Server/DeleteServer.php
@@ -8,6 +8,7 @@ use App\Models\Team;
use App\Notifications\Server\HetznerDeletionFailed;
use App\Services\DigitalOceanService;
use App\Services\HetznerService;
+use App\Services\HostingerService;
use App\Services\VultrService;
use Lorisleiva\Actions\Concerns\AsAction;
@@ -15,7 +16,7 @@ class DeleteServer
{
use AsAction;
- public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null)
+ public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null, bool $deleteFromHostinger = false, ?int $hostingerVirtualMachineId = null)
{
$server = Server::withTrashed()->find($serverId);
@@ -44,6 +45,14 @@ class DeleteServer
);
}
+ if ($deleteFromHostinger && ($hostingerVirtualMachineId || ($server && $server->hostinger_virtual_machine_id))) {
+ $this->deleteFromHostingerById(
+ $hostingerVirtualMachineId ?? $server->hostinger_virtual_machine_id,
+ $cloudProviderTokenId ?? $server->cloud_provider_token_id,
+ $teamId ?? $server->team_id
+ );
+ }
+
logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
// If server is already deleted from Coolify, skip this part
@@ -182,4 +191,50 @@ class DeleteServer
throw $e;
}
}
+
+ private function deleteFromHostingerById(int $hostingerVirtualMachineId, ?int $cloudProviderTokenId, int $teamId): void
+ {
+ try {
+ $token = null;
+
+ if ($cloudProviderTokenId) {
+ $token = CloudProviderToken::where('id', $cloudProviderTokenId)
+ ->where('team_id', $teamId)
+ ->where('provider', 'hostinger')
+ ->first();
+ }
+
+ if (! $token) {
+ $token = CloudProviderToken::where('team_id', $teamId)
+ ->where('provider', 'hostinger')
+ ->first();
+ }
+
+ if (! $token) {
+ throw new \RuntimeException('No Hostinger token found for the server team.');
+ }
+
+ $hostingerService = new HostingerService($token->token);
+ $subscriptionId = $hostingerService->getVirtualMachine($hostingerVirtualMachineId)['subscription_id'] ?? null;
+
+ if (! $subscriptionId) {
+ throw new \RuntimeException('The Hostinger VPS has no subscription.');
+ }
+
+ $hostingerService->disableAutoRenewal($subscriptionId);
+
+ logger()->debug('Disabled Hostinger auto-renewal, so Hostinger deletes the VPS at the end of the paid period', [
+ 'hostinger_virtual_machine_id' => $hostingerVirtualMachineId,
+ 'team_id' => $teamId,
+ ]);
+ } catch (\Throwable $e) {
+ logger()->error('Failed to delete VPS from Hostinger', [
+ 'error' => $e->getMessage(),
+ 'hostinger_virtual_machine_id' => $hostingerVirtualMachineId,
+ 'team_id' => $teamId,
+ ]);
+
+ throw $e;
+ }
+ }
}
diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php
index 9bb15c2053..7c973130c4 100644
--- a/app/Actions/Server/ValidateServer.php
+++ b/app/Actions/Server/ValidateServer.php
@@ -63,6 +63,15 @@ class ValidateServer
}
}
+ if ($server->hostinger_virtual_machine_id) {
+ $status = $server->refreshHostingerState();
+ if (in_array($status, ['stopping', 'stopped', 'suspending', 'suspended', 'destroying', 'destroyed', 'error'], true)) {
+ $this->error = 'Hostinger VPS is '.($status ?? 'not running').'. Power it on before validating.';
+ $server->update(['validation_logs' => $this->error]);
+ throw new \Exception($this->error);
+ }
+ }
+
['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection();
if (! $this->uptime) {
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php
index ad699e8f12..eca3397493 100644
--- a/app/Http/Controllers/Api/CloudProviderTokensController.php
+++ b/app/Http/Controllers/Api/CloudProviderTokensController.php
@@ -45,6 +45,10 @@ class CloudProviderTokensController extends Controller
'vultr' => Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.vultr.com/v2/account'),
+ 'hostinger' => Http::withToken($token)
+ ->acceptJson()
+ ->timeout(10)
+ ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines'),
default => null,
};
@@ -90,7 +94,7 @@ class CloudProviderTokensController extends Controller
properties: [
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
- 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']],
+ 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr', 'hostinger']],
'team_id' => ['type' => 'integer'],
'servers_count' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
@@ -208,7 +212,7 @@ class CloudProviderTokensController extends Controller
type: 'object',
required: ['provider', 'token', 'name'],
properties: [
- 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
+ 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr', 'hostinger'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'],
'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'],
],
@@ -263,7 +267,7 @@ class CloudProviderTokensController extends Controller
$body = $request->json()->all();
$validator = customApiValidator($body, [
- 'provider' => 'required|string|in:hetzner,digitalocean,vultr',
+ 'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger',
'token' => 'required|string',
'name' => 'required|string|max:255',
]);
diff --git a/app/Http/Controllers/Api/HostingerController.php b/app/Http/Controllers/Api/HostingerController.php
new file mode 100644
index 0000000000..84017e7ded
--- /dev/null
+++ b/app/Http/Controllers/Api/HostingerController.php
@@ -0,0 +1,365 @@
+ []]],
+ tags: ['Hostinger'],
+ responses: [
+ new OA\Response(response: 200, description: 'List of Hostinger VPS data centers.'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
+ new OA\Response(response: 422, description: 'Validation failed.'),
+ new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
+ ]
+ )]
+ public function dataCenters(Request $request): JsonResponse
+ {
+ return $this->providerData($request, 'getDataCenters', 'data centers');
+ }
+
+ #[OA\Get(
+ path: '/hostinger/catalog',
+ operationId: 'get-hostinger-catalog',
+ summary: 'Get Hostinger VPS plans and prices',
+ security: [['bearerAuth' => []]],
+ tags: ['Hostinger'],
+ responses: [
+ new OA\Response(response: 200, description: 'List of Hostinger VPS plans and prices.'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
+ new OA\Response(response: 422, description: 'Validation failed.'),
+ new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
+ ]
+ )]
+ public function catalog(Request $request): JsonResponse
+ {
+ return $this->providerData($request, 'getCatalogItems', 'catalog');
+ }
+
+ #[OA\Get(
+ path: '/hostinger/templates',
+ operationId: 'get-hostinger-templates',
+ summary: 'Get Hostinger VPS operating system templates',
+ security: [['bearerAuth' => []]],
+ tags: ['Hostinger'],
+ responses: [
+ new OA\Response(response: 200, description: 'List of Hostinger VPS templates.'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
+ new OA\Response(response: 422, description: 'Validation failed.'),
+ new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
+ ]
+ )]
+ public function templates(Request $request): JsonResponse
+ {
+ return $this->providerData($request, 'getTemplates', 'templates');
+ }
+
+ #[OA\Get(
+ path: '/hostinger/ssh-keys',
+ operationId: 'get-hostinger-ssh-keys',
+ summary: 'Get Hostinger account SSH keys',
+ security: [['bearerAuth' => []]],
+ tags: ['Hostinger'],
+ responses: [
+ new OA\Response(response: 200, description: 'List of Hostinger account SSH keys.'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
+ new OA\Response(response: 422, description: 'Validation failed.'),
+ new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
+ ]
+ )]
+ public function sshKeys(Request $request): JsonResponse
+ {
+ return $this->providerData($request, 'getPublicKeys', 'SSH keys');
+ }
+
+ #[OA\Get(
+ path: '/hostinger/post-install-scripts',
+ operationId: 'get-hostinger-post-install-scripts',
+ summary: 'Get Hostinger post-install scripts',
+ security: [['bearerAuth' => []]],
+ tags: ['Hostinger'],
+ responses: [
+ new OA\Response(response: 200, description: 'List of Hostinger post-install scripts.'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 403, description: 'Hostinger denied access to this resource for the cloud provider token.'),
+ new OA\Response(response: 422, description: 'Validation failed.'),
+ new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
+ ]
+ )]
+ public function postInstallScripts(Request $request): JsonResponse
+ {
+ return $this->providerData($request, 'getPostInstallScripts', 'post-install scripts');
+ }
+
+ #[OA\Post(
+ path: '/servers/hostinger',
+ operationId: 'create-hostinger-server',
+ summary: 'Purchase a Hostinger VPS and create a linked server',
+ security: [['bearerAuth' => []]],
+ tags: ['Hostinger'],
+ responses: [
+ new OA\Response(response: 201, description: 'Hostinger VPS purchased and linked to a Coolify server.'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 422, description: 'Validation failed.'),
+ new OA\Response(response: 429, description: 'Hostinger rate limit exceeded.'),
+ ]
+ )]
+ public function createServer(Request $request): JsonResponse
+ {
+ $allowedFields = [
+ 'cloud_provider_token_uuid',
+ 'cloud_provider_token_id',
+ 'item_id',
+ 'data_center_id',
+ 'template_id',
+ 'name',
+ 'private_key_uuid',
+ 'enable_backups',
+ 'public_key_ids',
+ 'post_install_script_id',
+ 'instant_validate',
+ ];
+ $teamId = getTeamIdFromToken();
+
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $this->authorize('create', [Server::class]);
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
+ $validator = customApiValidator($request->all(), [
+ 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
+ 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
+ 'item_id' => 'required|string',
+ 'data_center_id' => 'required|integer',
+ 'template_id' => 'required|integer',
+ 'name' => ['nullable', 'string', 'max:253', new ValidHostname],
+ 'private_key_uuid' => 'required|string',
+ 'enable_backups' => 'nullable|boolean',
+ 'public_key_ids' => 'nullable|array',
+ 'public_key_ids.*' => 'integer',
+ 'post_install_script_id' => 'nullable|integer',
+ 'instant_validate' => 'nullable|boolean',
+ ]);
+ $extraFields = array_diff(array_keys($request->all()), $allowedFields);
+
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ $team = Team::find($teamId);
+ if (Team::serverLimitReached($team)) {
+ return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
+ }
+
+ $token = $this->hostingerToken($request, $teamId);
+ if ($token instanceof JsonResponse) {
+ return $token;
+ }
+
+ $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
+ if (! $privateKey) {
+ return response()->json(['message' => 'Private key not found.'], 404);
+ }
+
+ $virtualMachineId = null;
+
+ try {
+ $hostingerService = new HostingerService($token->token);
+ $normalizedServerName = strtolower(trim($request->name ?: generate_random_name()));
+ $setup = [
+ 'data_center_id' => $request->integer('data_center_id'),
+ 'template_id' => $request->integer('template_id'),
+ 'hostname' => $normalizedServerName,
+ 'enable_backups' => $request->boolean('enable_backups'),
+ 'public_key' => [
+ 'name' => $privateKey->name,
+ 'key' => $privateKey->getPublicKey(),
+ ],
+ ];
+ if ($request->filled('post_install_script_id')) {
+ $setup['post_install_script_id'] = $request->integer('post_install_script_id');
+ }
+
+ $virtualMachine = $hostingerService->purchaseVirtualMachine([
+ 'item_id' => $request->item_id,
+ 'setup' => $setup,
+ ]);
+ $virtualMachineId = (int) $virtualMachine['id'];
+
+ $server = Server::create([
+ 'name' => $normalizedServerName,
+ 'ip' => Server::PLACEHOLDER_IP,
+ 'user' => 'root',
+ 'port' => 22,
+ 'team_id' => $teamId,
+ 'private_key_id' => $privateKey->id,
+ 'cloud_provider_token_id' => $token->id,
+ 'hostinger_virtual_machine_id' => $virtualMachine['id'],
+ 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null,
+ ]);
+
+ $server->proxy->set('status', 'exited');
+ $server->proxy->set('type', ProxyTypes::TRAEFIK->value);
+ $server->save();
+
+ try {
+ if ($request->array('public_key_ids')) {
+ $hostingerService->attachPublicKeys((int) $virtualMachine['id'], $request->array('public_key_ids'));
+ }
+ $virtualMachine = $hostingerService->waitForPublicIp($virtualMachine);
+ $ipAddress = $hostingerService->getPublicIpAddress($virtualMachine);
+ if ($ipAddress) {
+ $server->update([
+ 'ip' => $ipAddress,
+ 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? $server->hostinger_virtual_machine_status,
+ ]);
+ }
+ } catch (\Throwable $e) {
+ report($e);
+ }
+
+ $ipAddress = $server->fresh()->ip;
+
+ if ($request->boolean('instant_validate') && ! $server->hasPlaceholderIp()) {
+ ValidateServer::dispatch($server);
+ }
+
+ auditLog('api.hostinger_virtual_machine.created', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'hostinger_virtual_machine_id' => $virtualMachine['id'],
+ 'ip' => $ipAddress,
+ ]);
+
+ return response()->json([
+ 'uuid' => $server->uuid,
+ 'hostinger_virtual_machine_id' => $virtualMachine['id'],
+ 'ip' => $ipAddress,
+ 'provisioning' => $ipAddress === Server::PLACEHOLDER_IP,
+ ])->setStatusCode(201);
+ } catch (RateLimitException $e) {
+ return $this->rateLimitResponse($e);
+ } catch (\Throwable $e) {
+ logger()->error('Failed to create Hostinger server', [
+ 'error' => $e->getMessage(),
+ 'team_id' => $teamId,
+ 'hostinger_virtual_machine_id' => $virtualMachineId,
+ ]);
+
+ if ($e->getCode() === 202) {
+ return response()->json(['message' => $e->getMessage()], 202);
+ }
+
+ return response()->json(array_filter([
+ 'message' => $virtualMachineId
+ ? 'The Hostinger VPS was purchased but could not be saved in Coolify. Manage it in hPanel.'
+ : 'Failed to create Hostinger server.',
+ 'hostinger_virtual_machine_id' => $virtualMachineId,
+ ]), 500);
+ }
+ }
+
+ private function providerData(Request $request, string $method, string $resource): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $token = $this->hostingerToken($request, $teamId);
+ if ($token instanceof JsonResponse) {
+ return $token;
+ }
+
+ try {
+ return response()->json((new HostingerService($token->token))->{$method}());
+ } catch (RateLimitException $e) {
+ return $this->rateLimitResponse($e);
+ } catch (\Throwable $e) {
+ $hostingerErrorPrefix = 'Hostinger API error: ';
+ if (in_array($e->getCode(), [401, 403], true) && str_starts_with($e->getMessage(), $hostingerErrorPrefix)) {
+ $hostingerMessage = substr($e->getMessage(), strlen($hostingerErrorPrefix));
+
+ return response()->json(['message' => "Hostinger denied access to {$resource}: {$hostingerMessage}"], 403);
+ }
+
+ return response()->json(['message' => "Failed to fetch Hostinger {$resource}."], 500);
+ }
+ }
+
+ private function rateLimitResponse(RateLimitException $e): JsonResponse
+ {
+ $response = response()->json(['message' => $e->getMessage()], 429);
+ if ($e->retryAfter !== null) {
+ $response->header('Retry-After', $e->retryAfter);
+ }
+
+ return $response;
+ }
+
+ private function hostingerToken(Request $request, int $teamId): CloudProviderToken|JsonResponse
+ {
+ $validator = customApiValidator($request->all(), [
+ 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
+ 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $validator->errors(),
+ ], 422);
+ }
+
+ $tokenUuid = $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
+ $token = CloudProviderToken::whereTeamId($teamId)
+ ->whereUuid($tokenUuid)
+ ->where('provider', 'hostinger')
+ ->first();
+
+ if (! $token) {
+ return response()->json(['message' => 'Hostinger cloud provider token not found.'], 404);
+ }
+
+ $this->authorize('view', $token);
+
+ return $token;
+ }
+}
diff --git a/app/Jobs/ServerCloudProviderStatusCheckJob.php b/app/Jobs/ServerCloudProviderStatusCheckJob.php
index cd2e51df7d..46d0898c93 100644
--- a/app/Jobs/ServerCloudProviderStatusCheckJob.php
+++ b/app/Jobs/ServerCloudProviderStatusCheckJob.php
@@ -47,6 +47,9 @@ class ServerCloudProviderStatusCheckJob implements ShouldBeEncrypted, ShouldQueu
'digitalocean' => $this->server->digitalocean_droplet_id
? $this->server->refreshDigitalOceanState()
: null,
+ 'hostinger' => $this->server->hostinger_virtual_machine_id
+ ? $this->server->refreshHostingerState()
+ : null,
default => null,
};
} catch (\Throwable $e) {
diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php
index a8f2a50a31..ac1c551982 100644
--- a/app/Jobs/ServerManagerJob.php
+++ b/app/Jobs/ServerManagerJob.php
@@ -99,7 +99,8 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
$servers->each(function (Server $server) {
$hasCloudResource = $server->hetzner_server_id
|| $server->vultr_instance_id
- || $server->digitalocean_droplet_id;
+ || $server->digitalocean_droplet_id
+ || $server->hostinger_virtual_machine_id;
if ($hasCloudResource && $server->cloudProviderToken) {
ServerCloudProviderStatusCheckJob::dispatch($server);
diff --git a/app/Livewire/Security/CloudProviderToken/Show.php b/app/Livewire/Security/CloudProviderToken/Show.php
index d8006f7740..f2389628bd 100644
--- a/app/Livewire/Security/CloudProviderToken/Show.php
+++ b/app/Livewire/Security/CloudProviderToken/Show.php
@@ -85,6 +85,7 @@ class Show extends Component
'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token),
'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token),
'vultr' => $this->validateVultrToken($this->cloudProviderToken->token),
+ 'hostinger' => $this->validateHostingerToken($this->cloudProviderToken->token),
default => false,
};
@@ -141,6 +142,7 @@ class Show extends Component
return match ($this->cloudProviderToken->provider) {
'digitalocean' => 'DigitalOcean',
'vultr' => 'Vultr',
+ 'hostinger' => 'Hostinger',
default => 'Hetzner',
};
}
@@ -181,6 +183,19 @@ class Show extends Component
}
}
+ private function validateHostingerToken(string $token): bool
+ {
+ try {
+ return Http::withToken($token)
+ ->acceptJson()
+ ->timeout(10)
+ ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines')
+ ->successful();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
public function render()
{
return view('livewire.security.cloud-provider-token.show');
diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php
index 2c31d22035..4bdf3f5f3a 100644
--- a/app/Livewire/Security/CloudProviderTokenForm.php
+++ b/app/Livewire/Security/CloudProviderTokenForm.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Security;
use App\Livewire\Server\CloudProviderToken\Show as ServerCloudProviderTokenShow;
use App\Livewire\Server\New\ByDigitalOcean;
use App\Livewire\Server\New\ByHetzner;
+use App\Livewire\Server\New\ByHostinger;
use App\Models\CloudProviderToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
@@ -41,7 +42,7 @@ class CloudProviderTokenForm extends Component
protected function rules(): array
{
return [
- 'provider' => 'required|string|in:hetzner,digitalocean,vultr',
+ 'provider' => 'required|string|in:hetzner,digitalocean,vultr,hostinger',
'token' => 'required|string',
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
@@ -86,6 +87,15 @@ class CloudProviderTokenForm extends Component
return $response->successful();
}
+ if ($provider === 'hostinger') {
+ $response = Http::withToken($token)
+ ->acceptJson()
+ ->timeout(10)
+ ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines');
+
+ return $response->successful();
+ }
+
return false;
} catch (\Throwable $e) {
return false;
@@ -128,6 +138,7 @@ class CloudProviderTokenForm extends Component
if ($savedToken->provider === 'digitalocean') {
$this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ByDigitalOcean::class);
+ $this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class);
}
if ($savedToken->provider === 'hetzner') {
@@ -135,6 +146,11 @@ class CloudProviderTokenForm extends Component
$this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class);
}
+ if ($savedToken->provider === 'hostinger') {
+ $this->dispatch('tokenAdded.hostinger', tokenId: $savedToken->id)->to(ByHostinger::class);
+ $this->dispatch('tokenAdded.hostinger', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class);
+ }
+
if ($this->modal_mode) {
$this->dispatch('close-modal');
}
diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php
index d16c729fca..549576ff6f 100644
--- a/app/Livewire/Security/CloudProviderTokens.php
+++ b/app/Livewire/Security/CloudProviderTokens.php
@@ -59,6 +59,13 @@ class CloudProviderTokens extends Component
} else {
$this->dispatch('error', 'Vultr token validation failed. Please check the token.');
}
+ } elseif ($token->provider === 'hostinger') {
+ $isValid = $this->validateHostingerToken($token->token);
+ if ($isValid) {
+ $this->dispatch('success', 'Hostinger token is valid.');
+ } else {
+ $this->dispatch('error', 'Hostinger token validation failed. Please check the token.');
+ }
} else {
$this->dispatch('error', 'Unknown provider.');
}
@@ -114,6 +121,19 @@ class CloudProviderTokens extends Component
}
}
+ private function validateHostingerToken(string $token): bool
+ {
+ try {
+ return Http::withToken($token)
+ ->acceptJson()
+ ->timeout(10)
+ ->get('https://developers.hostinger.com/api/vps/v1/virtual-machines')
+ ->successful();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
public function deleteToken(int $tokenId)
{
try {
diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php
index e8216d0549..9d27340013 100644
--- a/app/Livewire/Server/CloudProviderToken/Show.php
+++ b/app/Livewire/Server/CloudProviderToken/Show.php
@@ -36,13 +36,25 @@ class Show extends Component
{
return [
'tokenAdded.hetzner' => 'handleTokenAdded',
+ 'tokenAdded.digitalocean' => 'handleTokenAdded',
+ 'tokenAdded.hostinger' => 'handleTokenAdded',
];
}
public function loadTokens()
{
- $this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner';
- $this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner';
+ $this->provider = match (true) {
+ filled($this->server->vultr_instance_id) => 'vultr',
+ filled($this->server->digitalocean_droplet_id) => 'digitalocean',
+ filled($this->server->hostinger_virtual_machine_id) => 'hostinger',
+ default => 'hetzner',
+ };
+ $this->providerName = match ($this->provider) {
+ 'vultr' => 'Vultr',
+ 'digitalocean' => 'DigitalOcean',
+ 'hostinger' => 'Hostinger',
+ default => 'Hetzner',
+ };
$this->cloudProviderTokens = CloudProviderToken::where('team_id', $this->server->team_id)
->where('provider', $this->provider)
@@ -96,9 +108,7 @@ class Show extends Component
private function validateTokenForServer(CloudProviderToken $token): array
{
try {
- $endpoint = $token->provider === 'vultr'
- ? 'https://api.vultr.com/v2/account'
- : 'https://api.hetzner.cloud/v1/servers';
+ $endpoint = $this->validationEndpoint($token->provider);
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
@@ -137,6 +147,33 @@ class Show extends Component
}
}
+ if ($this->server->digitalocean_droplet_id) {
+ $serverResponse = Http::withToken($token->token)
+ ->timeout(10)
+ ->get("https://api.digitalocean.com/v2/droplets/{$this->server->digitalocean_droplet_id}");
+
+ if (! $serverResponse->successful()) {
+ return [
+ 'valid' => false,
+ 'error' => 'This token cannot access this droplet. It may belong to a different DigitalOcean account.',
+ ];
+ }
+ }
+
+ if ($this->server->hostinger_virtual_machine_id) {
+ $serverResponse = Http::withToken($token->token)
+ ->acceptJson()
+ ->timeout(10)
+ ->get("https://developers.hostinger.com/api/vps/v1/virtual-machines/{$this->server->hostinger_virtual_machine_id}");
+
+ if (! $serverResponse->successful()) {
+ return [
+ 'valid' => false,
+ 'error' => 'This token cannot access this VPS. It may belong to a different Hostinger account.',
+ ];
+ }
+ }
+
return ['valid' => true];
} catch (\Throwable $e) {
return [
@@ -156,9 +193,7 @@ class Show extends Component
return;
}
- $endpoint = $token->provider === 'vultr'
- ? 'https://api.vultr.com/v2/account'
- : 'https://api.hetzner.cloud/v1/servers';
+ $endpoint = $this->validationEndpoint($token->provider);
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
@@ -184,6 +219,16 @@ class Show extends Component
}
}
+ private function validationEndpoint(string $provider): string
+ {
+ return match ($provider) {
+ 'vultr' => 'https://api.vultr.com/v2/account',
+ 'digitalocean' => 'https://api.digitalocean.com/v2/account',
+ 'hostinger' => 'https://developers.hostinger.com/api/vps/v1/virtual-machines',
+ default => 'https://api.hetzner.cloud/v1/servers',
+ };
+ }
+
public function render()
{
return view('livewire.server.cloud-provider-token.show');
diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php
index 7edfcaf72b..1f1aae51ea 100644
--- a/app/Livewire/Server/Create.php
+++ b/app/Livewire/Server/Create.php
@@ -21,7 +21,7 @@ class Create extends Component
public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void
{
- $this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true)
+ $this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual'], true)
? $selectedType
: null;
$this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null;
diff --git a/app/Livewire/Server/CreatePage.php b/app/Livewire/Server/CreatePage.php
index 158f28351b..be901b9db6 100644
--- a/app/Livewire/Server/CreatePage.php
+++ b/app/Livewire/Server/CreatePage.php
@@ -28,12 +28,14 @@ class CreatePage extends Component
'hetzner' => 'hetzner',
'vultr' => 'vultr',
'digital-ocean' => 'digitalocean',
+ 'hostinger' => 'hostinger',
default => null,
};
$this->tokenProviderName = match ($type) {
'hetzner' => 'Hetzner',
'vultr' => 'Vultr',
'digital-ocean' => 'DigitalOcean',
+ 'hostinger' => 'Hostinger',
default => null,
};
$this->hasProviderTokens = $this->tokenProvider
@@ -43,6 +45,7 @@ class CreatePage extends Component
'hetzner' => 'Hetzner',
'vultr' => 'Vultr',
'digital-ocean' => 'DigitalOcean',
+ 'hostinger' => 'Hostinger',
'manual' => 'Manual',
default => 'New Server',
};
diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php
index 359f94e0fb..c3539aa282 100644
--- a/app/Livewire/Server/Delete.php
+++ b/app/Livewire/Server/Delete.php
@@ -20,6 +20,8 @@ class Delete extends Component
public bool $delete_from_digitalocean = false;
+ public bool $delete_from_hostinger = false;
+
public bool $force_delete_resources = false;
public function mount(string $server_uuid)
@@ -41,6 +43,7 @@ class Delete extends Component
$this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions);
$this->delete_from_vultr = in_array('delete_from_vultr', $selectedActions);
$this->delete_from_digitalocean = in_array('delete_from_digitalocean', $selectedActions);
+ $this->delete_from_hostinger = in_array('delete_from_hostinger', $selectedActions);
$this->force_delete_resources = in_array('force_delete_resources', $selectedActions);
}
try {
@@ -72,7 +75,9 @@ class Delete extends Component
$this->delete_from_vultr,
$this->server->vultr_instance_id,
$this->delete_from_digitalocean,
- $this->server->digitalocean_droplet_id
+ $this->server->digitalocean_droplet_id,
+ $this->delete_from_hostinger,
+ $this->server->hostinger_virtual_machine_id
);
return redirectRoute($this, 'server.index');
@@ -118,6 +123,14 @@ class Delete extends Component
];
}
+ if ($this->server->hostinger_virtual_machine_id) {
+ $checkboxes[] = [
+ 'id' => 'delete_from_hostinger',
+ 'label' => 'Also delete VPS from Hostinger (at the end of the paid period)',
+ 'default_warning' => 'The VPS on Hostinger will NOT be deleted, and Hostinger will keep billing for it.',
+ ];
+ }
+
return view('livewire.server.delete', [
'checkboxes' => $checkboxes,
]);
diff --git a/app/Livewire/Server/New/ByHostinger.php b/app/Livewire/Server/New/ByHostinger.php
new file mode 100644
index 0000000000..c05fcf7d5d
--- /dev/null
+++ b/app/Livewire/Server/New/ByHostinger.php
@@ -0,0 +1,514 @@
+authorize('viewAny', CloudProviderToken::class);
+ $this->loadTokens();
+ $this->selectTokenFromUrl($selectedTokenUuid);
+ $this->server_name = generate_random_name();
+ $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
+
+ if ($this->private_keys->isNotEmpty()) {
+ $this->private_key_id = $this->private_keys->first()->id;
+ }
+
+ if ($this->selectedTokenUuid) {
+ $this->current_step = 2;
+ $this->loading_data = true;
+ }
+
+ return null;
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function getListeners(): array
+ {
+ return [
+ 'tokenAdded.hostinger' => 'handleTokenAdded',
+ 'privateKeyCreated' => 'handlePrivateKeyCreated',
+ 'modalClosed' => 'resetSelection',
+ ];
+ }
+
+ protected function rules(): array
+ {
+ $rules = [
+ 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
+ ];
+
+ if ($this->current_step === 2) {
+ $rules = array_merge($rules, [
+ 'server_name' => ['required', 'string', 'max:253', new ValidHostname],
+ 'selected_data_center_id' => 'required|integer',
+ 'selected_template_id' => 'required|integer',
+ 'selected_price_id' => 'required|string',
+ 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
+ 'enable_backups' => 'required|boolean',
+ 'selected_public_key_ids' => 'array',
+ 'selected_public_key_ids.*' => 'integer',
+ 'selected_post_install_script_id' => 'nullable|integer',
+ ]);
+ }
+
+ return $rules;
+ }
+
+ protected function messages(): array
+ {
+ return [
+ 'selected_token_id.required' => 'Please select a Hostinger token.',
+ 'selected_token_id.exists' => 'Selected token not found.',
+ ];
+ }
+
+ public function loadTokens(): void
+ {
+ $this->available_tokens = CloudProviderToken::ownedByCurrentTeam()
+ ->where('provider', 'hostinger')
+ ->get();
+ }
+
+ public function handleTokenAdded(int $tokenId): mixed
+ {
+ $this->loadTokens();
+ $this->selected_token_id = $tokenId;
+
+ return $this->nextStep();
+ }
+
+ public function handlePrivateKeyCreated(int $keyId): void
+ {
+ $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
+ $this->private_key_id = $keyId;
+ $this->resetErrorBag('private_key_id');
+ }
+
+ public function resetSelection(): void
+ {
+ $this->selected_token_id = null;
+ $this->current_step = 1;
+ }
+
+ public function nextStep(): mixed
+ {
+ $this->validate([
+ 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
+ ]);
+
+ try {
+ if (! $this->selectedTokenUuid) {
+ $token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
+
+ if ($token) {
+ return $this->redirectRoute('server.create.token', [
+ 'type' => 'hostinger',
+ 'token_uuid' => $token->uuid,
+ ], navigate: true);
+ }
+ }
+
+ $this->current_step = 2;
+ $this->loading_data = true;
+
+ return null;
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function loadHostingerData(): void
+ {
+ $token = $this->getHostingerToken();
+
+ if (! $token) {
+ $this->loading_data = false;
+ $this->dispatch('error', 'Please select a valid Hostinger token.');
+
+ return;
+ }
+
+ $this->loading_data = true;
+ $this->provider_data_error = null;
+
+ try {
+ $hostingerService = new HostingerService($token);
+ $this->data_centers = $hostingerService->getDataCenters();
+ $this->templates = collect($hostingerService->getTemplates())
+ ->sortBy('name')
+ ->values()
+ ->toArray();
+ $this->catalog_items = $hostingerService->getCatalogItems();
+ $this->hostinger_public_keys = $this->optionalProviderData(fn () => $hostingerService->getPublicKeys());
+ $this->post_install_scripts = $this->optionalProviderData(fn () => $hostingerService->getPostInstallScripts());
+ $this->selectDefaultTemplate();
+ } catch (\Throwable $e) {
+ $this->provider_data_error = $e->getMessage();
+ $this->dispatch('error', $this->provider_data_error);
+ } finally {
+ $this->loading_data = false;
+ }
+ }
+
+ public function updatedSelectedOsTemplateId(): void
+ {
+ if ($this->selected_os_template_id !== null) {
+ $this->selected_app_template_id = null;
+ }
+
+ $this->selected_template_id = $this->selected_os_template_id ?? $this->selected_app_template_id;
+ }
+
+ public function updatedSelectedAppTemplateId(): void
+ {
+ if ($this->selected_app_template_id !== null) {
+ $this->selected_os_template_id = null;
+ }
+
+ $this->selected_template_id = $this->selected_app_template_id ?? $this->selected_os_template_id;
+ }
+
+ /**
+ * Plain operating system templates.
+ */
+ public function getOsTemplatesProperty(): array
+ {
+ return collect($this->templates)
+ ->reject(fn (array $template): bool => $this->templateIncludesApplication($template))
+ ->values()
+ ->all();
+ }
+
+ /**
+ * Operating system templates that also install an application or control panel.
+ */
+ public function getAppTemplatesProperty(): array
+ {
+ return collect($this->templates)
+ ->filter(fn (array $template): bool => $this->templateIncludesApplication($template))
+ ->values()
+ ->all();
+ }
+
+ public function getPriceOptionsProperty(): array
+ {
+ return collect($this->catalog_items)
+ ->flatMap(function (array $item): array {
+ $planSize = $this->planSize($item);
+
+ return collect($item['prices'] ?? [])
+ ->map(fn (array $price): array => array_merge($price, [
+ 'plan_name' => $item['name'] ?? $item['id'] ?? 'VPS',
+ 'plan_size' => $planSize,
+ ]))
+ ->all();
+ })
+ ->sort(fn (array $a, array $b): int => [$a['plan_size'], $this->periodInMonths($a)] <=> [$b['plan_size'], $this->periodInMonths($b)])
+ ->values()
+ ->toArray();
+ }
+
+ public function getSelectedPriceProperty(): ?array
+ {
+ return collect($this->priceOptions)->firstWhere('id', $this->selected_price_id);
+ }
+
+ public function priceLabel(array $price): string
+ {
+ $currency = strtoupper($price['currency'] ?? 'USD');
+ $firstPeriodPrice = (int) ($price['first_period_price'] ?? $price['price'] ?? 0);
+ $renewalPrice = (int) ($price['price'] ?? $firstPeriodPrice);
+ $period = (int) ($price['period'] ?? 1);
+ $periodUnit = $price['period_unit'] ?? 'month';
+ $periodLabel = $period.' '.str($periodUnit)->plural($period);
+
+ return sprintf(
+ '%s — %s %.2f for %s, then %s %.2f',
+ $price['plan_name'] ?? 'VPS',
+ $currency,
+ $firstPeriodPrice / 100,
+ $periodLabel,
+ $currency,
+ $renewalPrice / 100
+ );
+ }
+
+ public function submit(): mixed
+ {
+ $this->validate();
+
+ $virtualMachineId = null;
+
+ try {
+ $this->authorize('create', Server::class);
+
+ if (Team::serverLimitReached()) {
+ return $this->dispatch('error', 'You have reached the server limit for your subscription.');
+ }
+
+ $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
+ $hostingerService = new HostingerService($this->getHostingerToken());
+
+ if (! $this->providerSelectionsAreValid($hostingerService)) {
+ return null;
+ }
+
+ $normalizedServerName = strtolower(trim($this->server_name));
+ $setup = [
+ 'data_center_id' => $this->selected_data_center_id,
+ 'template_id' => $this->selected_template_id,
+ 'hostname' => $normalizedServerName,
+ 'enable_backups' => $this->enable_backups,
+ 'public_key' => [
+ 'name' => $privateKey->name,
+ 'key' => $privateKey->getPublicKey(),
+ ],
+ ];
+ if ($this->selected_post_install_script_id) {
+ $setup['post_install_script_id'] = $this->selected_post_install_script_id;
+ }
+
+ $virtualMachine = $hostingerService->purchaseVirtualMachine([
+ 'item_id' => $this->selected_price_id,
+ 'setup' => $setup,
+ ]);
+ $virtualMachineId = (int) $virtualMachine['id'];
+
+ $server = Server::create([
+ 'name' => $normalizedServerName,
+ 'ip' => Server::PLACEHOLDER_IP,
+ 'user' => 'root',
+ 'port' => 22,
+ 'team_id' => currentTeam()->id,
+ 'private_key_id' => $privateKey->id,
+ 'cloud_provider_token_id' => $this->selected_token_id,
+ 'hostinger_virtual_machine_id' => $virtualMachine['id'],
+ 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null,
+ ]);
+
+ $server->proxy->set('status', 'exited');
+ $server->proxy->set('type', ProxyTypes::TRAEFIK->value);
+ $server->save();
+
+ try {
+ if ($this->selected_public_key_ids) {
+ $hostingerService->attachPublicKeys((int) $virtualMachine['id'], $this->selected_public_key_ids);
+ }
+ $virtualMachine = $hostingerService->waitForPublicIp($virtualMachine);
+ $ipAddress = $hostingerService->getPublicIpAddress($virtualMachine);
+ if ($ipAddress) {
+ $server->update([
+ 'ip' => $ipAddress,
+ 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? $server->hostinger_virtual_machine_status,
+ ]);
+ }
+ } catch (\Throwable $e) {
+ report($e);
+ }
+
+ if ($this->from_onboarding) {
+ currentTeam()->update(['show_boarding' => false]);
+ refreshSession();
+ }
+
+ return redirectRoute($this, 'server.show', [$server->uuid]);
+ } catch (\Throwable $e) {
+ if ($virtualMachineId) {
+ logger()->warning('Hostinger VPS was purchased but could not be saved in Coolify', [
+ 'team_id' => currentTeam()->id,
+ 'hostinger_virtual_machine_id' => $virtualMachineId,
+ ]);
+ $this->dispatch('error', "Hostinger VPS {$virtualMachineId} was purchased but could not be saved in Coolify. Manage it in hPanel.");
+ }
+
+ return handleError($e, $this);
+ }
+ }
+
+ private function selectTokenFromUrl(?string $selectedTokenUuid): void
+ {
+ if (! $selectedTokenUuid) {
+ return;
+ }
+
+ $token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid);
+
+ if ($token) {
+ $this->selectedTokenUuid = $selectedTokenUuid;
+ $this->selected_token_id = $token->id;
+ }
+ }
+
+ private function getHostingerToken(): string
+ {
+ $token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
+
+ return $token?->token ?? '';
+ }
+
+ private function selectDefaultTemplate(): void
+ {
+ if ($this->selected_template_id !== null) {
+ return;
+ }
+
+ $defaultTemplate = collect($this->osTemplates)->firstWhere('name', 'Ubuntu 24.04 LTS');
+
+ if ($defaultTemplate) {
+ $this->selected_os_template_id = (int) $defaultTemplate['id'];
+ $this->selected_template_id = $this->selected_os_template_id;
+ }
+ }
+
+ private function templateIncludesApplication(array $template): bool
+ {
+ return str_contains((string) ($template['name'] ?? ''), ' with ');
+ }
+
+ /**
+ * The plan size from the KVM plan id (hostingercom-vps-kvm4 is 4), falling back to its CPU count.
+ */
+ private function planSize(array $item): int
+ {
+ if (preg_match('/-vps-kvm(\d+)$/', (string) ($item['id'] ?? ''), $matches) === 1) {
+ return (int) $matches[1];
+ }
+
+ return (int) ($item['metadata']['cpus'] ?? 0);
+ }
+
+ private function periodInMonths(array $price): float
+ {
+ $period = (int) ($price['period'] ?? 0);
+
+ return match ($price['period_unit'] ?? 'month') {
+ 'day' => $period / 30,
+ 'week' => $period * 7 / 30,
+ 'year' => $period * 12,
+ 'none' => PHP_INT_MAX,
+ default => $period,
+ };
+ }
+
+ /**
+ * Load an optional Hostinger list. Some tokens cannot read these lists, so a failure must not block the form.
+ */
+ private function optionalProviderData(callable $callback): array
+ {
+ try {
+ return $callback();
+ } catch (\Throwable $e) {
+ report($e);
+
+ return [];
+ }
+ }
+
+ private function providerSelectionsAreValid(HostingerService $hostingerService): bool
+ {
+ $priceIds = collect($hostingerService->getCatalogItems())
+ ->flatMap(fn (array $item) => collect($item['prices'] ?? [])->pluck('id'));
+ $dataCenterIds = collect($hostingerService->getDataCenters())->pluck('id')->map(fn ($id) => (int) $id);
+ $templateIds = collect($hostingerService->getTemplates())->pluck('id')->map(fn ($id) => (int) $id);
+ $publicKeyIds = collect($this->selected_public_key_ids ? $hostingerService->getPublicKeys() : [])->pluck('id')->map(fn ($id) => (int) $id);
+ $postInstallScriptIds = collect($this->selected_post_install_script_id ? $hostingerService->getPostInstallScripts() : [])->pluck('id')->map(fn ($id) => (int) $id);
+
+ if (! $priceIds->contains($this->selected_price_id)) {
+ $this->addError('selected_price_id', 'The selected Hostinger plan or billing period is no longer available.');
+ }
+
+ if (! $dataCenterIds->contains($this->selected_data_center_id)) {
+ $this->addError('selected_data_center_id', 'The selected Hostinger data center is no longer available.');
+ }
+
+ if (! $templateIds->contains($this->selected_template_id)) {
+ $this->addError('selected_template_id', 'The selected Hostinger operating system is no longer available.');
+ }
+
+ if (collect($this->selected_public_key_ids)->contains(fn ($id) => ! $publicKeyIds->contains((int) $id))) {
+ $this->addError('selected_public_key_ids', 'One or more selected Hostinger SSH keys are no longer available.');
+ }
+
+ if ($this->selected_post_install_script_id && ! $postInstallScriptIds->contains($this->selected_post_install_script_id)) {
+ $this->addError('selected_post_install_script_id', 'The selected Hostinger post-install script is no longer available.');
+ }
+
+ return ! $this->getErrorBag()->isNotEmpty();
+ }
+
+ public function render()
+ {
+ return view('livewire.server.new.by-hostinger');
+ }
+}
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 8357e89257..839f545ae2 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -10,6 +10,7 @@ use App\Models\Server;
use App\Rules\ValidServerIp;
use App\Services\DigitalOceanService;
use App\Services\HetznerService;
+use App\Services\HostingerService;
use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Services\VultrService;
use App\Support\ValidationPatterns;
@@ -82,12 +83,16 @@ class Show extends Component
public ?string $digitalOceanDropletStatus = null;
+ public ?string $hostingerVirtualMachineStatus = null;
+
public bool $hetznerServerManuallyStarted = false;
public bool $vultrInstanceManuallyStarted = false;
public bool $digitalOceanDropletManuallyStarted = false;
+ public bool $hostingerVirtualMachineManuallyStarted = false;
+
public bool $isValidating = false;
// Hetzner linking properties
@@ -127,6 +132,18 @@ class Show extends Component
public bool $digitalOceanNoMatchFound = false;
+ public Collection $availableHostingerTokens;
+
+ public ?int $selectedHostingerTokenId = null;
+
+ public ?string $manualHostingerVirtualMachineId = null;
+
+ public ?array $matchedHostingerVirtualMachine = null;
+
+ public ?string $hostingerSearchError = null;
+
+ public bool $hostingerNoMatchFound = false;
+
public function getListeners()
{
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
@@ -206,12 +223,14 @@ class Show extends Component
$this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->vultrInstanceStatus = $this->server->vultr_instance_status;
$this->digitalOceanDropletStatus = $this->server->digitalocean_droplet_status;
+ $this->hostingerVirtualMachineStatus = $this->server->hostinger_virtual_machine_status;
$this->isValidating = $this->server->is_validating ?? false;
// Load cloud provider tokens for linking
$this->loadHetznerTokens();
$this->loadVultrTokens();
$this->loadDigitalOceanTokens();
+ $this->loadHostingerTokens();
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -658,6 +677,30 @@ class Show extends Component
}
}
+ public function checkHostingerVirtualMachineStatus(bool $manual = false): mixed
+ {
+ try {
+ $this->authorize('view', $this->server);
+ if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) {
+ $this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.');
+
+ return null;
+ }
+
+ $this->hostingerVirtualMachineStatus = $this->server->refreshHostingerState();
+ $this->server->refresh();
+ $this->ip = $this->server->ip;
+
+ if ($manual) {
+ $this->dispatch('success', 'VPS status refreshed: '.ucfirst($this->hostingerVirtualMachineStatus ?? 'unknown'));
+ }
+
+ return null;
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
public function handleServerValidated($event = null)
{
// Check if event is for this server
@@ -679,6 +722,7 @@ class Show extends Component
$this->loadHetznerTokens();
$this->loadVultrTokens();
$this->loadDigitalOceanTokens();
+ $this->loadHostingerTokens();
$this->dispatch('refreshServerShow');
$this->dispatch('refreshServer');
@@ -750,6 +794,30 @@ class Show extends Component
}
}
+ public function startHostingerVirtualMachine(): mixed
+ {
+ try {
+ $this->authorize('update', $this->server);
+ if (! $this->server->hostinger_virtual_machine_id || ! $this->server->cloudProviderToken) {
+ $this->dispatch('error', 'This server is not associated with a Hostinger VPS or token.');
+
+ return null;
+ }
+
+ $hostingerService = new HostingerService($this->server->cloudProviderToken->token);
+ $hostingerService->startVirtualMachine((int) $this->server->hostinger_virtual_machine_id);
+
+ $this->hostingerVirtualMachineStatus = 'starting';
+ $this->server->update(['hostinger_virtual_machine_status' => 'starting']);
+ $this->hostingerVirtualMachineManuallyStarted = true;
+ $this->dispatch('success', 'Hostinger VPS is starting...');
+
+ return null;
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
public function refreshServerMetadata(): void
{
try {
@@ -804,6 +872,13 @@ class Show extends Component
->get();
}
+ public function loadHostingerTokens(): void
+ {
+ $this->availableHostingerTokens = CloudProviderToken::ownedByCurrentTeam()
+ ->where('provider', 'hostinger')
+ ->get();
+ }
+
#[Computed]
public function limaStartCommand(): ?string
{
@@ -1077,6 +1152,136 @@ class Show extends Component
}
}
+ public function searchHostingerVirtualMachine(): void
+ {
+ $this->hostingerSearchError = null;
+ $this->hostingerNoMatchFound = false;
+ $this->matchedHostingerVirtualMachine = null;
+
+ if (! $this->selectedHostingerTokenId) {
+ $this->hostingerSearchError = 'Please select a Hostinger token.';
+
+ return;
+ }
+
+ try {
+ $this->authorize('update', $this->server);
+ $token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId);
+
+ if (! $token) {
+ $this->hostingerSearchError = 'Invalid token selected.';
+
+ return;
+ }
+
+ $matched = (new HostingerService($token->token))->findVirtualMachineByIp($this->server->ip);
+
+ if ($matched) {
+ $this->matchedHostingerVirtualMachine = $matched;
+ } else {
+ $this->hostingerNoMatchFound = true;
+ }
+ } catch (\Throwable $e) {
+ $this->hostingerSearchError = 'Failed to search Hostinger virtual machines: '.$e->getMessage();
+ }
+ }
+
+ public function searchHostingerVirtualMachineById(): void
+ {
+ $this->hostingerSearchError = null;
+ $this->hostingerNoMatchFound = false;
+ $this->matchedHostingerVirtualMachine = null;
+
+ if (! $this->selectedHostingerTokenId) {
+ $this->hostingerSearchError = 'Please select a Hostinger token first.';
+
+ return;
+ }
+
+ if (! $this->manualHostingerVirtualMachineId) {
+ $this->hostingerSearchError = 'Please enter a Hostinger Virtual Machine ID.';
+
+ return;
+ }
+
+ try {
+ $this->authorize('update', $this->server);
+ $token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId);
+
+ if (! $token) {
+ $this->hostingerSearchError = 'Invalid token selected.';
+
+ return;
+ }
+
+ $virtualMachine = (new HostingerService($token->token))
+ ->getVirtualMachine((int) $this->manualHostingerVirtualMachineId);
+
+ if ($virtualMachine) {
+ $this->matchedHostingerVirtualMachine = $virtualMachine;
+ } else {
+ $this->hostingerNoMatchFound = true;
+ }
+ } catch (\Throwable $e) {
+ $this->hostingerSearchError = 'Failed to fetch Hostinger virtual machine: '.$e->getMessage();
+ }
+ }
+
+ public function linkToHostinger(): mixed
+ {
+ if (! $this->matchedHostingerVirtualMachine) {
+ $this->dispatch('error', 'No Hostinger virtual machine selected.');
+
+ return null;
+ }
+
+ try {
+ $this->authorize('update', $this->server);
+ $token = $this->availableHostingerTokens->firstWhere('id', $this->selectedHostingerTokenId);
+
+ if (! $token) {
+ $this->dispatch('error', 'Invalid token selected.');
+
+ return null;
+ }
+
+ $hostingerService = new HostingerService($token->token);
+ $virtualMachine = $hostingerService->getVirtualMachine((int) $this->matchedHostingerVirtualMachine['id']);
+
+ if (! $virtualMachine) {
+ $this->dispatch('error', 'Could not find Hostinger virtual machine with ID: '.$this->matchedHostingerVirtualMachine['id']);
+
+ return null;
+ }
+
+ $updates = [
+ 'cloud_provider_token_id' => $this->selectedHostingerTokenId,
+ 'hostinger_virtual_machine_id' => $virtualMachine['id'],
+ 'hostinger_virtual_machine_status' => $virtualMachine['state'] ?? null,
+ ];
+ $ip = $hostingerService->getPublicIpAddress($virtualMachine);
+ if ($ip) {
+ $updates['ip'] = $ip;
+ }
+
+ $this->server->update($updates);
+ $this->hostingerVirtualMachineStatus = $virtualMachine['state'] ?? null;
+ $this->matchedHostingerVirtualMachine = null;
+ $this->selectedHostingerTokenId = null;
+ $this->manualHostingerVirtualMachineId = null;
+ $this->hostingerNoMatchFound = false;
+ $this->hostingerSearchError = null;
+
+ $this->dispatch('success', 'Server successfully linked to Hostinger!');
+ $this->dispatch('close-modal');
+ $this->dispatch('refreshServerShow');
+
+ return null;
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
public function searchVultrInstance(): void
{
$this->vultrSearchError = null;
diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php
index 33b77418d6..407f5f31d2 100644
--- a/app/Livewire/Server/ValidateAndInstall.php
+++ b/app/Livewire/Server/ValidateAndInstall.php
@@ -145,6 +145,18 @@ class ValidateAndInstall extends Component
}
}
+ if ($this->server->hostinger_virtual_machine_id) {
+ $status = $this->server->refreshHostingerState();
+ $this->server->refresh();
+
+ if (in_array($status, ['stopping', 'stopped', 'suspending', 'suspended', 'destroying', 'destroyed', 'error'], true)) {
+ $this->error = 'Hostinger VPS is '.($status ?? 'not running').'. Power it on before validating.';
+ $this->server->update(['validation_logs' => $this->error]);
+
+ return;
+ }
+ }
+
['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
if (! $this->uptime) {
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');
diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php
index 280e26b9e7..f6311f22bb 100644
--- a/app/Mcp/Concerns/BuildsResponse.php
+++ b/app/Mcp/Concerns/BuildsResponse.php
@@ -24,7 +24,7 @@ trait BuildsResponse
// raw IDs / morph types (uuid is the public identifier)
'id', 'team_id', 'tokenable_id', 'tokenable_type',
'server_id', 'private_key_id', 'cloud_provider_token_id',
- 'hetzner_server_id', 'digitalocean_droplet_id', 'environment_id', 'destination_id',
+ 'hetzner_server_id', 'digitalocean_droplet_id', 'hostinger_virtual_machine_id', 'environment_id', 'destination_id',
'source_id', 'repository_project_id', 'application_id',
'service_id', 'project_id', 'parent_id',
'resourceable', 'resourceable_id', 'resourceable_type',
diff --git a/app/Models/Server.php b/app/Models/Server.php
index b7e4e9ba53..bc20bc07b8 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -21,6 +21,7 @@ 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;
@@ -345,6 +346,8 @@ class Server extends BaseModel
'vultr_instance_status',
'digitalocean_droplet_id',
'digitalocean_droplet_status',
+ 'hostinger_virtual_machine_id',
+ 'hostinger_virtual_machine_status',
'is_validating',
'validation_logs',
'detected_traefik_version',
@@ -523,6 +526,27 @@ class Server extends BaseModel
return $status;
}
+ public function refreshHostingerState(): ?string
+ {
+ 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(
diff --git a/app/Services/HostingerService.php b/app/Services/HostingerService.php
new file mode 100644
index 0000000000..d90f28ef27
--- /dev/null
+++ b/app/Services/HostingerService.php
@@ -0,0 +1,224 @@
+token)
+ ->acceptJson()
+ ->asJson()
+ ->timeout(30)
+ ->connectTimeout(10)
+ ->retry(
+ $method === 'get' ? 3 : 1,
+ fn (int $attempt) => $attempt * 100,
+ fn (\Exception $exception) => ! $exception instanceof RequestException || $exception->response->serverError(),
+ throw: false
+ )
+ ->{$method}($this->baseUrl.$endpoint, $data);
+
+ if (! $response->successful()) {
+ if ($response->status() === 429) {
+ throw new RateLimitException(
+ 'Rate limit exceeded. Please try again later.',
+ $response->header('Retry-After') !== null ? (int) $response->header('Retry-After') : null
+ );
+ }
+
+ $message = $response->json('message')
+ ?? $response->json('error')
+ ?? 'Unknown error';
+
+ throw new \Exception('Hostinger API error: '.$message, $response->status());
+ }
+
+ return $response->json() ?? [];
+ }
+
+ public function getDataCenters(): array
+ {
+ return $this->request('get', '/api/vps/v1/data-centers');
+ }
+
+ public function getTemplates(): array
+ {
+ return $this->request('get', '/api/vps/v1/templates');
+ }
+
+ /**
+ * Get the KVM VPS plans. Game Panel plans share the VPS category but are not offered for Coolify servers.
+ */
+ public function getCatalogItems(): array
+ {
+ return collect($this->request('get', '/api/billing/v1/catalog', ['category' => 'VPS']))
+ ->filter(fn ($item) => is_array($item) && preg_match('/-vps-kvm\d+$/', (string) ($item['id'] ?? '')) === 1)
+ ->values()
+ ->all();
+ }
+
+ public function getPublicKeys(): array
+ {
+ return $this->request('get', '/api/vps/v1/public-keys')['data'] ?? [];
+ }
+
+ public function getPostInstallScripts(): array
+ {
+ return $this->request('get', '/api/vps/v1/post-install-scripts')['data'] ?? [];
+ }
+
+ public function attachPublicKeys(int $virtualMachineId, array $publicKeyIds): array
+ {
+ return $this->request('post', "/api/vps/v1/public-keys/attach/{$virtualMachineId}", [
+ 'ids' => array_map('intval', array_values($publicKeyIds)),
+ ]);
+ }
+
+ public function purchaseVirtualMachine(array $params): array
+ {
+ // Hostinger accepts only a fully qualified hostname and uses its own default otherwise.
+ if (! str_contains($params['setup']['hostname'] ?? '', '.')) {
+ unset($params['setup']['hostname']);
+ }
+
+ $existingVirtualMachineIds = collect($this->getVirtualMachines())->pluck('id')->all();
+
+ try {
+ $response = $this->request('post', '/api/vps/v1/virtual-machines', $params);
+ } catch (\Throwable $e) {
+ // Hostinger can charge for the VPS before its setup fails, so keep the VPS if one was created.
+ $virtualMachine = $this->findPurchasedVirtualMachine($existingVirtualMachineIds);
+ if (! $virtualMachine) {
+ throw $e;
+ }
+
+ logger()->warning('Hostinger charged for a VPS but its setup failed', [
+ 'hostinger_virtual_machine_id' => $virtualMachine['id'],
+ 'error' => $e->getMessage(),
+ ]);
+
+ return $this->setupPurchasedVirtualMachine($virtualMachine, $params['setup']);
+ }
+
+ if (empty($response['virtual_machine']['id'])) {
+ logger()->warning('Hostinger VPS order did not return a virtual machine', Arr::only($response, ['id', 'subscription_id', 'status', 'message']));
+
+ throw new \Exception('Hostinger order '.($response['id'] ?? 'unknown').' is waiting for payment. Finish the VPS setup in hPanel.', 202);
+ }
+
+ return $response['virtual_machine'];
+ }
+
+ private function findPurchasedVirtualMachine(array $existingVirtualMachineIds): ?array
+ {
+ try {
+ return collect($this->getVirtualMachines())
+ ->first(fn (array $virtualMachine) => ! in_array($virtualMachine['id'] ?? null, $existingVirtualMachineIds, true));
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ private function setupPurchasedVirtualMachine(array $virtualMachine, array $setup): array
+ {
+ if (($virtualMachine['state'] ?? null) !== 'initial') {
+ return $virtualMachine;
+ }
+
+ try {
+ return $this->request('post', $this->virtualMachineEndpoint((int) $virtualMachine['id']).'/setup', $setup);
+ } catch (\Throwable $e) {
+ logger()->warning('Hostinger VPS setup retry failed', [
+ 'hostinger_virtual_machine_id' => $virtualMachine['id'],
+ 'error' => $e->getMessage(),
+ ]);
+
+ return $virtualMachine;
+ }
+ }
+
+ public function getVirtualMachine(int $virtualMachineId): array
+ {
+ return $this->request('get', $this->virtualMachineEndpoint($virtualMachineId));
+ }
+
+ public function getVirtualMachines(): array
+ {
+ return $this->request('get', '/api/vps/v1/virtual-machines');
+ }
+
+ /**
+ * Briefly wait for a public IP. VPS setup usually takes longer, so the server status check fills in the IP later.
+ */
+ public function waitForPublicIp(array $virtualMachine, int $attempts = 10, int $sleepMilliseconds = 1000): array
+ {
+ if ($this->getPublicIpAddress($virtualMachine) || empty($virtualMachine['id'])) {
+ return $virtualMachine;
+ }
+
+ for ($attempt = 0; $attempt < $attempts; $attempt++) {
+ usleep($sleepMilliseconds * 1000);
+
+ $virtualMachine = $this->getVirtualMachine((int) $virtualMachine['id']);
+
+ if ($this->getPublicIpAddress($virtualMachine)) {
+ return $virtualMachine;
+ }
+ }
+
+ return $virtualMachine;
+ }
+
+ public function getPublicIpAddress(array $virtualMachine): ?string
+ {
+ foreach (['ipv4', 'ipv6'] as $version) {
+ foreach ($virtualMachine[$version] ?? [] as $ipAddress) {
+ if (filled($ipAddress['address'] ?? null)) {
+ return $ipAddress['address'];
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public function disableAutoRenewal(string $subscriptionId): array
+ {
+ return $this->request('delete', "/api/billing/v1/subscriptions/{$subscriptionId}/auto-renewal/disable");
+ }
+
+ public function startVirtualMachine(int $virtualMachineId): array
+ {
+ return $this->request('post', $this->virtualMachineEndpoint($virtualMachineId).'/start');
+ }
+
+ public function findVirtualMachineByIp(string $ip): ?array
+ {
+ foreach ($this->getVirtualMachines() as $virtualMachine) {
+ foreach (['ipv4', 'ipv6'] as $version) {
+ foreach ($virtualMachine[$version] ?? [] as $ipAddress) {
+ if (($ipAddress['address'] ?? null) === $ip) {
+ return $virtualMachine;
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private function virtualMachineEndpoint(int $virtualMachineId): string
+ {
+ return '/api/vps/v1/virtual-machines/'.$virtualMachineId;
+ }
+}
diff --git a/database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php b/database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php
new file mode 100644
index 0000000000..496b2a3bce
--- /dev/null
+++ b/database/migrations/2026_07_10_210239_add_hostinger_virtual_machine_fields_to_servers_table.php
@@ -0,0 +1,32 @@
+bigInteger('hostinger_virtual_machine_id')->nullable()->after('digitalocean_droplet_status');
+ $table->string('hostinger_virtual_machine_status')->nullable()->after('hostinger_virtual_machine_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->dropColumn([
+ 'hostinger_virtual_machine_status',
+ 'hostinger_virtual_machine_id',
+ ]);
+ });
+ }
+};
diff --git a/openapi.json b/openapi.json
index 572091bba7..192d6f49b9 100644
--- a/openapi.json
+++ b/openapi.json
@@ -6318,7 +6318,8 @@
"enum": [
"hetzner",
"digitalocean",
- "vultr"
+ "vultr",
+ "hostinger"
]
},
"team_id": {
@@ -6377,7 +6378,8 @@
"enum": [
"hetzner",
"digitalocean",
- "vultr"
+ "vultr",
+ "hostinger"
],
"example": "hetzner",
"description": "The cloud provider."
@@ -12873,6 +12875,189 @@
]
}
},
+ "\/hostinger\/data-centers": {
+ "get": {
+ "tags": [
+ "Hostinger"
+ ],
+ "summary": "Get Hostinger VPS data centers",
+ "operationId": "get-hostinger-data-centers",
+ "responses": {
+ "200": {
+ "description": "List of Hostinger VPS data centers."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "403": {
+ "description": "Hostinger denied access to this resource for the cloud provider token."
+ },
+ "422": {
+ "description": "Validation failed."
+ },
+ "429": {
+ "description": "Hostinger rate limit exceeded."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/hostinger\/catalog": {
+ "get": {
+ "tags": [
+ "Hostinger"
+ ],
+ "summary": "Get Hostinger VPS plans and prices",
+ "operationId": "get-hostinger-catalog",
+ "responses": {
+ "200": {
+ "description": "List of Hostinger VPS plans and prices."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "403": {
+ "description": "Hostinger denied access to this resource for the cloud provider token."
+ },
+ "422": {
+ "description": "Validation failed."
+ },
+ "429": {
+ "description": "Hostinger rate limit exceeded."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/hostinger\/templates": {
+ "get": {
+ "tags": [
+ "Hostinger"
+ ],
+ "summary": "Get Hostinger VPS operating system templates",
+ "operationId": "get-hostinger-templates",
+ "responses": {
+ "200": {
+ "description": "List of Hostinger VPS templates."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "403": {
+ "description": "Hostinger denied access to this resource for the cloud provider token."
+ },
+ "422": {
+ "description": "Validation failed."
+ },
+ "429": {
+ "description": "Hostinger rate limit exceeded."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/hostinger\/ssh-keys": {
+ "get": {
+ "tags": [
+ "Hostinger"
+ ],
+ "summary": "Get Hostinger account SSH keys",
+ "operationId": "get-hostinger-ssh-keys",
+ "responses": {
+ "200": {
+ "description": "List of Hostinger account SSH keys."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "403": {
+ "description": "Hostinger denied access to this resource for the cloud provider token."
+ },
+ "422": {
+ "description": "Validation failed."
+ },
+ "429": {
+ "description": "Hostinger rate limit exceeded."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/hostinger\/post-install-scripts": {
+ "get": {
+ "tags": [
+ "Hostinger"
+ ],
+ "summary": "Get Hostinger post-install scripts",
+ "operationId": "get-hostinger-post-install-scripts",
+ "responses": {
+ "200": {
+ "description": "List of Hostinger post-install scripts."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "403": {
+ "description": "Hostinger denied access to this resource for the cloud provider token."
+ },
+ "422": {
+ "description": "Validation failed."
+ },
+ "429": {
+ "description": "Hostinger rate limit exceeded."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/servers\/hostinger": {
+ "post": {
+ "tags": [
+ "Hostinger"
+ ],
+ "summary": "Purchase a Hostinger VPS and create a linked server",
+ "operationId": "create-hostinger-server",
+ "responses": {
+ "201": {
+ "description": "Hostinger VPS purchased and linked to a Coolify server."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "422": {
+ "description": "Validation failed."
+ },
+ "429": {
+ "description": "Hostinger rate limit exceeded."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/settings\/email": {
"get": {
"tags": [
@@ -25077,6 +25262,10 @@
"name": "Hetzner",
"description": "Hetzner"
},
+ {
+ "name": "Hostinger",
+ "description": "Hostinger"
+ },
{
"name": "Settings",
"description": "Settings"
diff --git a/openapi.yaml b/openapi.yaml
index 0884149b38..5983cbe38d 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -4075,7 +4075,7 @@ paths:
schema:
type: array
items:
- properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } }
+ properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean, vultr, hostinger] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } }
type: object
'401':
$ref: '#/components/responses/401'
@@ -4103,7 +4103,7 @@ paths:
properties:
provider:
type: string
- enum: [hetzner, digitalocean, vultr]
+ enum: [hetzner, digitalocean, vultr, hostinger]
example: hetzner
description: 'The cloud provider.'
token:
@@ -8247,6 +8247,124 @@ paths:
security:
-
bearerAuth: []
+ /hostinger/data-centers:
+ get:
+ tags:
+ - Hostinger
+ summary: 'Get Hostinger VPS data centers'
+ operationId: get-hostinger-data-centers
+ responses:
+ '200':
+ description: 'List of Hostinger VPS data centers.'
+ '401':
+ $ref: '#/components/responses/401'
+ '403':
+ description: 'Hostinger denied access to this resource for the cloud provider token.'
+ '422':
+ description: 'Validation failed.'
+ '429':
+ description: 'Hostinger rate limit exceeded.'
+ security:
+ -
+ bearerAuth: []
+ /hostinger/catalog:
+ get:
+ tags:
+ - Hostinger
+ summary: 'Get Hostinger VPS plans and prices'
+ operationId: get-hostinger-catalog
+ responses:
+ '200':
+ description: 'List of Hostinger VPS plans and prices.'
+ '401':
+ $ref: '#/components/responses/401'
+ '403':
+ description: 'Hostinger denied access to this resource for the cloud provider token.'
+ '422':
+ description: 'Validation failed.'
+ '429':
+ description: 'Hostinger rate limit exceeded.'
+ security:
+ -
+ bearerAuth: []
+ /hostinger/templates:
+ get:
+ tags:
+ - Hostinger
+ summary: 'Get Hostinger VPS operating system templates'
+ operationId: get-hostinger-templates
+ responses:
+ '200':
+ description: 'List of Hostinger VPS templates.'
+ '401':
+ $ref: '#/components/responses/401'
+ '403':
+ description: 'Hostinger denied access to this resource for the cloud provider token.'
+ '422':
+ description: 'Validation failed.'
+ '429':
+ description: 'Hostinger rate limit exceeded.'
+ security:
+ -
+ bearerAuth: []
+ /hostinger/ssh-keys:
+ get:
+ tags:
+ - Hostinger
+ summary: 'Get Hostinger account SSH keys'
+ operationId: get-hostinger-ssh-keys
+ responses:
+ '200':
+ description: 'List of Hostinger account SSH keys.'
+ '401':
+ $ref: '#/components/responses/401'
+ '403':
+ description: 'Hostinger denied access to this resource for the cloud provider token.'
+ '422':
+ description: 'Validation failed.'
+ '429':
+ description: 'Hostinger rate limit exceeded.'
+ security:
+ -
+ bearerAuth: []
+ /hostinger/post-install-scripts:
+ get:
+ tags:
+ - Hostinger
+ summary: 'Get Hostinger post-install scripts'
+ operationId: get-hostinger-post-install-scripts
+ responses:
+ '200':
+ description: 'List of Hostinger post-install scripts.'
+ '401':
+ $ref: '#/components/responses/401'
+ '403':
+ description: 'Hostinger denied access to this resource for the cloud provider token.'
+ '422':
+ description: 'Validation failed.'
+ '429':
+ description: 'Hostinger rate limit exceeded.'
+ security:
+ -
+ bearerAuth: []
+ /servers/hostinger:
+ post:
+ tags:
+ - Hostinger
+ summary: 'Purchase a Hostinger VPS and create a linked server'
+ operationId: create-hostinger-server
+ responses:
+ '201':
+ description: 'Hostinger VPS purchased and linked to a Coolify server.'
+ '401':
+ $ref: '#/components/responses/401'
+ '422':
+ description: 'Validation failed.'
+ '429':
+ description: 'Hostinger rate limit exceeded.'
+ security:
+ -
+ bearerAuth: []
/settings/email:
get:
tags:
@@ -16078,6 +16196,9 @@ tags:
-
name: Hetzner
description: Hetzner
+ -
+ name: Hostinger
+ description: Hostinger
-
name: Settings
description: Settings
diff --git a/resources/views/components/hostinger-icon.blade.php b/resources/views/components/hostinger-icon.blade.php
new file mode 100644
index 0000000000..9616c8d965
--- /dev/null
+++ b/resources/views/components/hostinger-icon.blade.php
@@ -0,0 +1,7 @@
+
diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php
index f444210e00..ed3a19435c 100644
--- a/resources/views/components/server/sidebar.blade.php
+++ b/resources/views/components/server/sidebar.blade.php
@@ -41,7 +41,7 @@
'active' => $activeMenu === 'cloud-provider-token',
'icon' => 'subscription',
'group' => 'Settings',
- 'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id),
+ 'visible' => (bool) ($server->hetzner_server_id || $server->vultr_instance_id || $server->digitalocean_droplet_id || $server->hostinger_virtual_machine_id),
],
[
'label' => 'CA Certificate',
diff --git a/resources/views/livewire/boarding/index.blade.php b/resources/views/livewire/boarding/index.blade.php
index bc22715931..71c065dac3 100644
--- a/resources/views/livewire/boarding/index.blade.php
+++ b/resources/views/livewire/boarding/index.blade.php
@@ -207,6 +207,23 @@
+ Deploy servers directly from your Hostinger account.
+ Hostinger
+
Create a server with a cloud provider.
Type the server name in the confirmation dialog to continue.
+ New to Hostinger? + Create an account + through Coolify's affiliate link. +
+{{ $provider_data_error }}
+