diff --git a/app/Livewire/Security/CloudInitScriptForm.php b/app/Livewire/Security/CloudInitScriptForm.php
index ff670cd4f..33beff334 100644
--- a/app/Livewire/Security/CloudInitScriptForm.php
+++ b/app/Livewire/Security/CloudInitScriptForm.php
@@ -36,7 +36,7 @@ protected function rules(): array
{
return [
'name' => 'required|string|max:255',
- 'script' => 'required|string',
+ 'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml],
];
}
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index 7d828b12e..abbe4c379 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -157,7 +157,7 @@ protected function rules(): array
'selectedHetznerSshKeyIds.*' => 'integer',
'enable_ipv4' => 'required|boolean',
'enable_ipv6' => 'required|boolean',
- 'cloud_init_script' => 'nullable|string',
+ 'cloud_init_script' => ['nullable', 'string', new \App\Rules\ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
diff --git a/app/Rules/ValidCloudInitYaml.php b/app/Rules/ValidCloudInitYaml.php
new file mode 100644
index 000000000..8116e1161
--- /dev/null
+++ b/app/Rules/ValidCloudInitYaml.php
@@ -0,0 +1,55 @@
+getMessage());
+ }
+
+ return;
+ }
+
+ // If it doesn't start with #! or #cloud-config, try to parse as YAML
+ // (some users might omit the #cloud-config header)
+ try {
+ Yaml::parse($script);
+ } catch (ParseException $e) {
+ $fail('The :attribute must be either a valid bash script (starting with #!) or valid cloud-config YAML. YAML parse error: '.$e->getMessage());
+ }
+ }
+}
diff --git a/resources/views/livewire/security/cloud-init-script-form.blade.php b/resources/views/livewire/security/cloud-init-script-form.blade.php
index 1632b48d3..83bedffab 100644
--- a/resources/views/livewire/security/cloud-init-script-form.blade.php
+++ b/resources/views/livewire/security/cloud-init-script-form.blade.php
@@ -2,7 +2,7 @@
+ helper="Enter your cloud-init script. Supports cloud-config YAML format." required />
@if ($modal_mode)
diff --git a/tests/Unit/Rules/ValidCloudInitYamlTest.php b/tests/Unit/Rules/ValidCloudInitYamlTest.php
new file mode 100644
index 000000000..f3ea906af
--- /dev/null
+++ b/tests/Unit/Rules/ValidCloudInitYamlTest.php
@@ -0,0 +1,174 @@
+validate('script', $script, function ($message) use (&$valid) {
+ $valid = false;
+ });
+
+ expect($valid)->toBeTrue();
+});
+
+it('accepts valid cloud-config YAML without header', function () {
+ $rule = new ValidCloudInitYaml;
+ $valid = true;
+
+ $script = <<<'YAML'
+users:
+ - name: demo
+ groups: sudo
+packages:
+ - nginx
+YAML;
+
+ $rule->validate('script', $script, function ($message) use (&$valid) {
+ $valid = false;
+ });
+
+ expect($valid)->toBeTrue();
+});
+
+it('accepts valid bash script with shebang', function () {
+ $rule = new ValidCloudInitYaml;
+ $valid = true;
+
+ $script = <<<'BASH'
+#!/bin/bash
+apt update
+apt install -y nginx
+systemctl start nginx
+BASH;
+
+ $rule->validate('script', $script, function ($message) use (&$valid) {
+ $valid = false;
+ });
+
+ expect($valid)->toBeTrue();
+});
+
+it('accepts empty or null script', function () {
+ $rule = new ValidCloudInitYaml;
+ $valid = true;
+
+ $rule->validate('script', '', function ($message) use (&$valid) {
+ $valid = false;
+ });
+
+ expect($valid)->toBeTrue();
+
+ $rule->validate('script', null, function ($message) use (&$valid) {
+ $valid = false;
+ });
+
+ expect($valid)->toBeTrue();
+});
+
+it('rejects invalid YAML format', function () {
+ $rule = new ValidCloudInitYaml;
+ $valid = true;
+ $errorMessage = '';
+
+ $script = <<<'YAML'
+#cloud-config
+users:
+ - name: demo
+ groups: sudo
+ invalid_indentation
+packages:
+ - nginx
+YAML;
+
+ $rule->validate('script', $script, function ($message) use (&$valid, &$errorMessage) {
+ $valid = false;
+ $errorMessage = $message;
+ });
+
+ expect($valid)->toBeFalse();
+ expect($errorMessage)->toContain('YAML');
+});
+
+it('rejects script that is neither bash nor valid YAML', function () {
+ $rule = new ValidCloudInitYaml;
+ $valid = true;
+ $errorMessage = '';
+
+ $script = <<<'INVALID'
+this is not valid YAML
+ and has invalid indentation:
+ - item
+ without proper structure {
+INVALID;
+
+ $rule->validate('script', $script, function ($message) use (&$valid, &$errorMessage) {
+ $valid = false;
+ $errorMessage = $message;
+ });
+
+ expect($valid)->toBeFalse();
+ expect($errorMessage)->toContain('bash script');
+});
+
+it('accepts complex cloud-config with multiple sections', function () {
+ $rule = new ValidCloudInitYaml;
+ $valid = true;
+
+ $script = <<<'YAML'
+#cloud-config
+users:
+ - name: coolify
+ groups: sudo, docker
+ shell: /bin/bash
+ sudo: ['ALL=(ALL) NOPASSWD:ALL']
+ ssh_authorized_keys:
+ - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ...
+
+packages:
+ - docker.io
+ - docker-compose
+ - git
+ - curl
+
+package_update: true
+package_upgrade: true
+
+runcmd:
+ - systemctl enable docker
+ - systemctl start docker
+ - usermod -aG docker coolify
+ - echo "Server setup complete"
+
+write_files:
+ - path: /etc/docker/daemon.json
+ content: |
+ {
+ "log-driver": "json-file",
+ "log-opts": {
+ "max-size": "10m",
+ "max-file": "3"
+ }
+ }
+YAML;
+
+ $rule->validate('script', $script, function ($message) use (&$valid) {
+ $valid = false;
+ });
+
+ expect($valid)->toBeTrue();
+});