feat: Add support for coolify.json configuration import and schema validation

- Introduced a new schema for coolify.json to validate application configurations.
- Implemented loading and parsing logic for coolify.json in the application.
- Added UI components for importing coolify.json configurations in various project creation flows.
- Enhanced logging for coolify.json processing to aid in debugging.
- Created unit tests to validate coolify.json parsing and magic variable resolution.
- Updated existing forms to include options for importing coolify.json settings.
This commit is contained in:
Andras Bacsai 2025-12-03 15:45:48 +01:00
parent 083d745d70
commit d5a5d1c32a
25 changed files with 2352 additions and 42 deletions

View File

@ -193,6 +193,7 @@ class ApplicationsController extends Controller
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'use_coolify_json' => ['type' => 'boolean', 'description' => 'Check repository for coolify.json and apply configuration if found. Default is true.'],
],
)
),
@ -344,6 +345,7 @@ class ApplicationsController extends Controller
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'use_coolify_json' => ['type' => 'boolean', 'description' => 'Check repository for coolify.json and apply configuration if found. Default is true.'],
],
)
),
@ -495,6 +497,7 @@ class ApplicationsController extends Controller
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
'use_coolify_json' => ['type' => 'boolean', 'description' => 'Check repository for coolify.json and apply configuration if found. Default is true.'],
],
)
),
@ -933,6 +936,7 @@ class ApplicationsController extends Controller
return $return;
}
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain'];
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'use_coolify_json'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@ -977,6 +981,7 @@ class ApplicationsController extends Controller
$isStatic = $request->is_static;
$connectToDockerNetwork = $request->connect_to_docker_network;
$customNginxConfiguration = $request->custom_nginx_configuration;
$useCoolifyJson = $request->use_coolify_json ?? true;
if (! is_null($customNginxConfiguration)) {
if (! isBase64Encoded($customNginxConfiguration)) {
@ -1105,6 +1110,28 @@ class ApplicationsController extends Controller
}
$application->isConfigurationChanged(true);
// Check for coolify.json configuration if enabled (default: true)
if ($useCoolifyJson) {
try {
$gitUrl = $request->git_repository;
if (! str_ends_with($gitUrl, '.git')) {
$gitUrl = $gitUrl.'.git';
}
$config = loadConfigFromGit(
$gitUrl,
$application->git_branch,
$application->base_directory ?? '/',
$destination->server->id,
$teamId
);
if ($config) {
$application->setConfig($config, fromRepository: true);
}
} catch (\Exception $e) {
\Log::warning('coolify.json: Failed to apply config via API - '.$e->getMessage());
}
}
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
@ -1265,6 +1292,25 @@ class ApplicationsController extends Controller
}
$application->isConfigurationChanged(true);
// Check for coolify.json configuration if enabled (default: true)
if ($useCoolifyJson) {
try {
$gitUrl = 'https://github.com/'.$application->git_repository.'.git';
$config = loadConfigFromGit(
$gitUrl,
$application->git_branch,
$application->base_directory ?? '/',
$destination->server->id,
$teamId
);
if ($config) {
$application->setConfig($config, fromRepository: true);
}
} catch (\Exception $e) {
\Log::warning('coolify.json: Failed to apply config via API - '.$e->getMessage());
}
}
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
@ -1399,6 +1445,28 @@ class ApplicationsController extends Controller
}
$application->isConfigurationChanged(true);
// Check for coolify.json configuration if enabled (default: true)
if ($useCoolifyJson) {
try {
$gitUrl = $application->git_repository;
if (! str_ends_with($gitUrl, '.git')) {
$gitUrl = $gitUrl.'.git';
}
$config = loadConfigFromGit(
$gitUrl,
$application->git_branch,
$application->base_directory ?? '/',
$destination->server->id,
$teamId
);
if ($config) {
$application->setConfig($config, fromRepository: true);
}
} catch (\Exception $e) {
\Log::warning('coolify.json: Failed to apply config via API - '.$e->getMessage());
}
}
if ($instantDeploy) {
$deployment_uuid = new Cuid2;

View File

@ -190,6 +190,9 @@ class GlobalSearch extends Component
'new compose' => 'docker-compose-empty',
'new docker image' => 'docker-image',
'new image' => 'docker-image',
'new coolify json' => 'coolify-json',
'new json' => 'coolify-json',
'new import' => 'coolify-json',
// Databases
'new postgresql' => 'postgresql',
@ -234,7 +237,7 @@ class GlobalSearch extends Component
'project', 'source',
// Applications
'public', 'private-gh-app', 'private-deploy-key',
'dockerfile', 'docker-compose-empty', 'docker-image',
'dockerfile', 'docker-compose-empty', 'docker-image', 'coolify-json',
// Databases
'postgresql', 'mysql', 'mariadb', 'redis', 'keydb',
'dragonfly', 'mongodb', 'clickhouse',
@ -1028,6 +1031,15 @@ class GlobalSearch extends Component
'category' => 'Applications',
'resourceType' => 'application',
]);
$items->push([
'name' => 'Import from coolify.json',
'description' => 'Paste a coolify.json configuration to quickly create an application',
'quickcommand' => '(type: new json)',
'type' => 'coolify-json',
'category' => 'Applications',
'resourceType' => 'application',
]);
}
// === Databases Category ===

View File

@ -58,6 +58,17 @@ class Configuration extends Component
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
}
// Check for flash messages from redirect and dispatch as Livewire events
if (session('success')) {
$this->dispatch('success', session('success'));
}
if (session('warning')) {
$this->dispatch('warning', session('warning'));
}
if (session('error')) {
$this->dispatch('error', session('error'));
}
}
public function render()

View File

@ -936,6 +936,21 @@ class General extends Component
]);
}
public function downloadRepositoryConfig()
{
$this->authorize('view', $this->application);
$config = $this->application->generateRepositoryConfig();
$json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
return response()->streamDownload(function () use ($json) {
echo $json;
}, 'coolify.json', [
'Content-Type' => 'application/json',
'Content-Disposition' => 'attachment; filename=coolify.json',
]);
}
private function updateServiceEnvironmentVariables()
{
$domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]);

View File

@ -0,0 +1,154 @@
<?php
namespace App\Livewire\Project\New;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\Project;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
class CoolifyJsonImport extends Component
{
public string $coolifyJson = '';
public array $parameters;
public array $query;
public ?array $parsedConfig = null;
public ?string $parseError = null;
public function mount()
{
$this->parameters = get_route_parameters();
$this->query = request()->query();
if (isDev()) {
$this->coolifyJson = json_encode([
'version' => '1.0',
'name' => 'My App',
'source' => [
'repository' => 'https://github.com/coollabsio/coolify-examples',
'branch' => 'main',
],
'build' => [
'type' => 'nixpacks',
],
], JSON_PRETTY_PRINT);
}
}
public function updatedCoolifyJson()
{
$this->parseError = null;
$this->parsedConfig = null;
if (empty(trim($this->coolifyJson))) {
return;
}
try {
$config = json_decode($this->coolifyJson, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->parseError = 'Invalid JSON: '.json_last_error_msg();
return;
}
$this->parsedConfig = $config;
} catch (\Exception $e) {
$this->parseError = 'Error parsing JSON: '.$e->getMessage();
}
}
public function submit()
{
$this->validate([
'coolifyJson' => 'required',
]);
// Parse the JSON
$config = json_decode($this->coolifyJson, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->dispatch('error', 'Invalid JSON format: '.json_last_error_msg());
return;
}
// Validate required fields
$source = data_get($config, 'source', []);
$repository = data_get($source, 'repository');
$branch = data_get($source, 'branch', 'main');
if (empty($repository)) {
$this->dispatch('error', 'Git repository URL is required in the source section');
return;
}
// Get destination
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
if (! $destination) {
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
}
if (! $destination) {
throw new \Exception('Destination not found. What?!');
}
$destination_class = $destination->getMorphClass();
// Get project and environment
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
// Determine build pack and port
$buildPack = data_get($config, 'build.type', 'nixpacks');
$portsExposes = data_get($config, 'domains.ports_exposes');
// If no explicit port in config, use sensible defaults based on build pack
// Same logic as other application creation flows (PublicGitRepository, etc.)
if ($portsExposes === null) {
$portsExposes = ($buildPack === 'static') ? '80' : '3000';
}
// Create the application with basic settings
$application = Application::create([
'name' => data_get($config, 'name', 'app-'.new Cuid2),
'description' => data_get($config, 'description'),
'repository_project_id' => 0,
'git_repository' => $repository,
'git_branch' => $branch,
'git_commit_sha' => data_get($source, 'commit_sha', 'HEAD'),
'build_pack' => $buildPack,
'ports_exposes' => $portsExposes,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination_class,
'health_check_enabled' => false,
'source_id' => 0,
'source_type' => GithubApp::class,
]);
// Generate FQDN
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->update([
'fqdn' => $fqdn,
]);
// Apply the full configuration using setConfig
$application->setConfig($config);
return redirect()->route('project.application.configuration', [
'application_uuid' => $application->uuid,
'environment_uuid' => $environment->uuid,
'project_uuid' => $project->uuid,
]);
}
public function render()
{
return view('livewire.project.new.coolify-json-import');
}
}

View File

@ -66,6 +66,8 @@ class GithubPrivateRepository extends Component
public bool $show_is_static = true;
public bool $checkCoolifyConfig = true;
public function mount()
{
$this->currentRoute = Route::currentRouteName();
@ -211,6 +213,25 @@ class GithubPrivateRepository extends Component
$application->name = generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name, $application->uuid);
$application->save();
// Check for coolify.json configuration
try {
$gitRepository = 'https://github.com/'.$this->selected_repository_owner.'/'.$this->selected_repository_repo.'.git';
$config = loadConfigFromGit(
$gitRepository,
$this->selected_branch_name,
$this->base_directory ?? '/',
$destination->server->id,
auth()->user()->currentTeam()->id
);
if ($config) {
$application->setConfig($config, fromRepository: true);
session()->flash('success', 'coolify.json configuration detected and applied.');
}
} catch (\Exception $e) {
\Log::warning('coolify.json: Failed to apply config - '.$e->getMessage());
session()->flash('warning', 'coolify.json found but failed to apply: '.$e->getMessage());
}
return redirect()->route('project.application.configuration', [
'application_uuid' => $application->uuid,
'environment_uuid' => $environment->uuid,

View File

@ -49,6 +49,8 @@ class GithubPrivateRepositoryDeployKey extends Component
public bool $show_is_static = true;
public bool $checkCoolifyConfig = true;
private object $repository_url_parsed;
private GithubApp|GitlabApp|string $git_source = 'other';
@ -199,6 +201,24 @@ class GithubPrivateRepositoryDeployKey extends Component
$application->name = generate_random_name($application->uuid);
$application->save();
// Check for coolify.json configuration
try {
$config = loadConfigFromGit(
$this->repository_url,
$this->branch,
$this->base_directory ?? '/',
$destination->server->id,
auth()->user()->currentTeam()->id
);
if ($config) {
$application->setConfig($config, fromRepository: true);
session()->flash('success', 'coolify.json configuration detected and applied.');
}
} catch (\Exception $e) {
\Log::warning('coolify.json: Failed to apply config - '.$e->getMessage());
session()->flash('warning', 'coolify.json found but failed to apply: '.$e->getMessage());
}
return redirect()->route('project.application.configuration', [
'application_uuid' => $application->uuid,
'environment_uuid' => $environment->uuid,

View File

@ -363,10 +363,24 @@ class PublicGitRepository extends Component
$application->fqdn = $fqdn;
$application->save();
if ($this->checkCoolifyConfig) {
// $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id);
// if ($config) {
// $application->setConfig($config);
// }
try {
// Construct clean git URL from host and repository
$gitUrl = "https://{$this->git_host}/{$this->git_repository}.git";
$config = loadConfigFromGit(
$gitUrl,
$this->git_branch,
$this->base_directory,
$destination->server->id,
auth()->user()->currentTeam()->id
);
if ($config) {
$application->setConfig($config, fromRepository: true);
session()->flash('success', 'coolify.json configuration detected and applied.');
}
} catch (\Exception $e) {
\Log::warning('coolify.json: Failed to apply config - '.$e->getMessage());
session()->flash('warning', 'coolify.json found but failed to apply: '.$e->getMessage());
}
}
return redirect()->route('project.application.configuration', [

View File

@ -188,6 +188,12 @@ class Select extends Component
'logo' => asset('svgs/docker.svg'),
],
];
$coolifyJsonImport = [
'id' => 'coolify-json',
'name' => 'Import from coolify.json',
'description' => 'Paste a coolify.json configuration to quickly create and configure an application.',
'logo' => asset('svgs/coolify-logo.svg'),
];
$databases = [
[
'id' => 'postgresql',
@ -251,6 +257,7 @@ class Select extends Component
'categories' => $categories,
'gitBasedApplications' => $gitBasedApplications,
'dockerBasedApplications' => $dockerBasedApplications,
'coolifyJsonImport' => $coolifyJsonImport,
'databases' => $databases,
];
}

File diff suppressed because it is too large Load Diff

View File

@ -135,6 +135,7 @@ function sharedDataApplications()
'docker_compose_domains' => 'array|nullable',
'docker_compose_custom_start_command' => 'string|nullable',
'docker_compose_custom_build_command' => 'string|nullable',
'use_coolify_json' => 'boolean',
];
}
@ -179,4 +180,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('is_static');
$request->offsetUnset('force_domain_override');
$request->offsetUnset('autogenerate_domain');
$request->offsetUnset('use_coolify_json');
}

View File

@ -35,6 +35,7 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Request;
@ -2963,31 +2964,91 @@ function getHelperVersion(): string
return config('constants.coolify.helper_version');
}
function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id)
function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id): ?array
{
$server = Server::find($server_id)->where('team_id', $team_id)->first();
Log::info("coolify.json: Starting detection for {$repository} branch {$branch} base_dir {$base_directory}");
$server = Server::where('id', $server_id)->where('team_id', $team_id)->first();
if (! $server) {
return;
Log::warning("coolify.json: Server not found - server_id: {$server_id}, team_id: {$team_id}");
return null;
}
$uuid = new Cuid2;
$cloneCommand = "git clone --no-checkout -b $branch $repository .";
$cloneCommand = "git clone --no-checkout -b {$branch} {$repository} .";
$workdir = rtrim($base_directory, '/');
$fileList = collect([".$workdir/coolify.json"]);
// Build paths to check: base_directory/coolify.json first, then repo root
$pathsToCheck = [];
if ($workdir !== '' && $workdir !== '/') {
$pathsToCheck[] = ltrim($workdir, '/').'/coolify.json';
}
$pathsToCheck[] = 'coolify.json';
Log::info('coolify.json: Checking paths - '.implode(', ', $pathsToCheck));
// Build sparse-checkout file list
$fileList = collect($pathsToCheck)->map(fn ($path) => "./{$path}")->implode(' ');
$commands = collect([
"rm -rf /tmp/{$uuid}",
"mkdir -p /tmp/{$uuid}",
"cd /tmp/{$uuid}",
$cloneCommand,
'git sparse-checkout init --cone',
"git sparse-checkout set {$fileList->implode(' ')}",
"git sparse-checkout set {$fileList}",
'git read-tree -mu HEAD',
"cat .$workdir/coolify.json",
'rm -rf /tmp/{$uuid}',
]);
// Add cat commands for each path, trying base_directory first
// Use subshell to capture output before cleanup
$catCommands = collect($pathsToCheck)->map(fn ($path) => "cat ./{$path} 2>/dev/null")->implode(' || ');
$commands->push("({$catCommands}) || true");
$commands->push("cd / && rm -rf /tmp/{$uuid}");
try {
return instant_remote_process($commands, $server);
} catch (\Exception) {
// continue
$output = instant_remote_process($commands, $server);
Log::info('coolify.json: Raw output length - '.strlen($output ?? ''));
if (empty($output)) {
Log::info('coolify.json: No output from remote process');
return null;
}
$config = json_decode($output, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::warning('coolify.json: Invalid JSON format - '.json_last_error_msg().' - output was: '.substr($output, 0, 200));
return null;
}
// Validate schema version
$version = data_get($config, 'version', '1.0');
$supportedVersions = ['1.0'];
if (! in_array($version, $supportedVersions)) {
Log::warning('coolify.json: Unsupported schema version - '.$version);
return null;
}
// Warn about unknown top-level fields
$knownFields = ['version', 'name', 'description', 'build', 'domains', 'environment_variables', 'health_check', 'limits', 'settings'];
$unknownFields = array_diff(array_keys($config), $knownFields);
if (! empty($unknownFields)) {
Log::info('coolify.json: Unknown fields detected (will be ignored) - '.implode(', ', $unknownFields));
}
Log::info('coolify.json: Successfully loaded config with keys - '.implode(', ', array_keys($config)));
return $config;
} catch (\Exception $e) {
Log::warning('coolify.json: Exception during detection - '.$e->getMessage());
return null;
}
}

View File

@ -366,6 +366,10 @@
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
},
"use_coolify_json": {
"type": "boolean",
"description": "Check repository for coolify.json and apply configuration if found. Default is true."
}
},
"type": "object"
@ -781,6 +785,10 @@
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
},
"use_coolify_json": {
"type": "boolean",
"description": "Check repository for coolify.json and apply configuration if found. Default is true."
}
},
"type": "object"
@ -1196,6 +1204,10 @@
"type": "boolean",
"default": true,
"description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
},
"use_coolify_json": {
"type": "boolean",
"description": "Check repository for coolify.json and apply configuration if found. Default is true."
}
},
"type": "object"

View File

@ -269,6 +269,9 @@ paths:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
use_coolify_json:
type: boolean
description: 'Check repository for coolify.json and apply configuration if found. Default is true.'
type: object
responses:
'201':
@ -539,6 +542,9 @@ paths:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
use_coolify_json:
type: boolean
description: 'Check repository for coolify.json and apply configuration if found. Default is true.'
type: object
responses:
'201':
@ -809,6 +815,9 @@ paths:
type: boolean
default: true
description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
use_coolify_json:
type: boolean
description: 'Check repository for coolify.json and apply configuration if found. Default is true.'
type: object
responses:
'201':

View File

@ -0,0 +1,664 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://coolify.io/schemas/coolify.schema.json",
"title": "Coolify Application Configuration",
"description": "Schema for coolify.json configuration files used to configure Coolify applications",
"type": "object",
"properties": {
"$schema": {
"type": "string",
"description": "Reference to this JSON schema for IDE support"
},
"version": {
"type": "string",
"description": "Schema version for future compatibility",
"enum": ["1.0"],
"default": "1.0"
},
"name": {
"type": "string",
"description": "Application name displayed in Coolify"
},
"description": {
"type": "string",
"description": "Application description"
},
"source": {
"type": "object",
"description": "Git source configuration (only used when importing via copy-paste, ignored when coolify.json is in repository)",
"properties": {
"repository": {
"type": "string",
"description": "Git repository URL (e.g., https://github.com/user/repo)",
"examples": ["https://github.com/coollabsio/coolify-examples"]
},
"branch": {
"type": "string",
"description": "Git branch to deploy",
"default": "main"
},
"commit_sha": {
"type": "string",
"description": "Specific commit SHA to deploy",
"default": "HEAD"
}
}
},
"build": {
"type": "object",
"description": "Build configuration settings",
"properties": {
"type": {
"type": "string",
"description": "Build pack type",
"enum": ["nixpacks", "dockerfile", "dockercompose", "dockerimage", "static"],
"default": "nixpacks"
},
"base_directory": {
"type": "string",
"description": "Base directory for the build (relative to repository root)",
"default": "/",
"examples": ["/", "/app", "/frontend"]
},
"publish_directory": {
"type": "string",
"description": "Directory containing built files to publish (for static builds)",
"examples": ["/dist", "/build", "/public"]
},
"dockerfile_location": {
"type": "string",
"description": "Path to Dockerfile (relative to base_directory)",
"default": "/Dockerfile",
"examples": ["/Dockerfile", "/docker/Dockerfile.prod"]
},
"docker_compose_location": {
"type": "string",
"description": "Path to docker-compose file (relative to base_directory)",
"default": "/docker-compose.yml",
"examples": ["/docker-compose.yml", "/docker-compose.prod.yml"]
},
"install_command": {
"type": "string",
"description": "Custom install command (overrides auto-detected)",
"examples": ["npm ci", "yarn install --frozen-lockfile", "pnpm install"]
},
"build_command": {
"type": "string",
"description": "Custom build command (overrides auto-detected)",
"examples": ["npm run build", "yarn build", "cargo build --release"]
},
"start_command": {
"type": "string",
"description": "Custom start command (overrides auto-detected)",
"examples": ["npm start", "node server.js", "./target/release/app"]
},
"watch_paths": {
"type": "string",
"description": "Comma-separated paths to watch for changes (triggers rebuild)",
"examples": ["src,package.json", "**/*.rs,Cargo.toml"]
},
"static_image": {
"type": "string",
"description": "Docker image to use for static file serving",
"default": "nginx:alpine"
},
"dockerfile_target_build": {
"type": "string",
"description": "Docker build target stage (for multi-stage builds)",
"examples": ["production", "builder"]
},
"custom_docker_run_options": {
"type": "string",
"description": "Additional docker run options",
"examples": ["--cap-add=SYS_ADMIN", "--privileged"]
},
"docker_compose_custom_start_command": {
"type": "string",
"description": "Custom docker compose start command",
"examples": ["docker compose up -d --build"]
},
"docker_compose_custom_build_command": {
"type": "string",
"description": "Custom docker compose build command",
"examples": ["docker compose build --no-cache"]
}
}
},
"domains": {
"type": "object",
"description": "Domain and port configuration",
"properties": {
"ports_exposes": {
"type": "string",
"description": "Ports to expose (comma-separated for multiple)",
"default": "3000",
"examples": ["3000", "80", "3000,8080"]
},
"ports_mappings": {
"type": "string",
"description": "Port mappings in format host:container (comma-separated)",
"examples": ["8080:3000", "80:3000,443:3001"]
},
"redirect": {
"type": "string",
"description": "Redirect configuration",
"enum": ["www", "non-www", "both"]
},
"custom_nginx_configuration": {
"type": "string",
"description": "Custom Nginx configuration block"
}
}
},
"network_aliases": {
"type": "array",
"description": "Custom network aliases for the container",
"items": {
"type": "string"
},
"examples": [["api", "backend"]]
},
"http_basic_auth": {
"type": "object",
"description": "HTTP Basic Authentication settings",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable HTTP Basic Authentication",
"default": false
},
"username": {
"type": "string",
"description": "Basic auth username"
},
"password": {
"type": "string",
"description": "Basic auth password"
}
}
},
"health_check": {
"type": "object",
"description": "Container health check configuration",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable health checks",
"default": false
},
"path": {
"type": "string",
"description": "Health check endpoint path",
"default": "/",
"examples": ["/health", "/api/health", "/ping"]
},
"port": {
"type": ["integer", "string"],
"description": "Port for health check"
},
"host": {
"type": "string",
"description": "Host for health check",
"default": "localhost"
},
"method": {
"type": "string",
"description": "HTTP method for health check",
"enum": ["GET", "POST", "HEAD"],
"default": "GET"
},
"return_code": {
"type": "integer",
"description": "Expected HTTP return code",
"default": 200
},
"scheme": {
"type": "string",
"description": "Protocol scheme",
"enum": ["http", "https"],
"default": "http"
},
"response_text": {
"type": "string",
"description": "Expected response text to match"
},
"interval": {
"type": "integer",
"description": "Health check interval in seconds",
"default": 5,
"minimum": 1
},
"timeout": {
"type": "integer",
"description": "Health check timeout in seconds",
"default": 5,
"minimum": 1
},
"retries": {
"type": "integer",
"description": "Number of retries before marking unhealthy",
"default": 10,
"minimum": 1
},
"start_period": {
"type": "integer",
"description": "Grace period before health checks start (seconds)",
"default": 5,
"minimum": 0
}
}
},
"limits": {
"type": "object",
"description": "Container resource limits",
"properties": {
"memory": {
"type": "string",
"description": "Memory limit (e.g., 512m, 1g)",
"examples": ["256m", "512m", "1g", "2g"]
},
"memory_swap": {
"type": "string",
"description": "Memory + swap limit",
"examples": ["1g", "2g"]
},
"memory_swappiness": {
"type": "integer",
"description": "Swappiness value (0-100)",
"minimum": 0,
"maximum": 100
},
"memory_reservation": {
"type": "string",
"description": "Soft memory limit",
"examples": ["128m", "256m"]
},
"cpus": {
"type": "string",
"description": "CPU limit (e.g., 0.5, 1, 2)",
"examples": ["0.5", "1", "2"]
},
"cpuset": {
"type": "string",
"description": "CPUs to use (e.g., 0,1 or 0-3)",
"examples": ["0", "0,1", "0-3"]
},
"cpu_shares": {
"type": "integer",
"description": "CPU shares (relative weight)",
"minimum": 0
}
}
},
"settings": {
"type": "object",
"description": "Application behavior settings",
"properties": {
"is_static": {
"type": "boolean",
"description": "Treat as static site (serve files with Nginx)",
"default": false
},
"is_spa": {
"type": "boolean",
"description": "Enable SPA mode (redirect all routes to index.html)",
"default": false
},
"is_auto_deploy_enabled": {
"type": "boolean",
"description": "Auto-deploy on git push",
"default": true
},
"is_force_https_enabled": {
"type": "boolean",
"description": "Force HTTPS redirect",
"default": true
},
"is_preview_deployments_enabled": {
"type": "boolean",
"description": "Enable preview deployments for PRs",
"default": false
},
"is_pr_deployments_public_enabled": {
"type": "boolean",
"description": "Make PR deployments publicly accessible",
"default": false
},
"is_git_submodules_enabled": {
"type": "boolean",
"description": "Initialize git submodules",
"default": false
},
"is_git_lfs_enabled": {
"type": "boolean",
"description": "Enable Git LFS",
"default": false
},
"is_git_shallow_clone_enabled": {
"type": "boolean",
"description": "Use shallow clone (faster but limited history)",
"default": false
},
"is_build_server_enabled": {
"type": "boolean",
"description": "Use dedicated build server",
"default": false
},
"is_preserve_repository_enabled": {
"type": "boolean",
"description": "Preserve repository between builds",
"default": false
},
"is_container_label_escape_enabled": {
"type": "boolean",
"description": "Escape special characters in container labels",
"default": true
},
"is_container_label_readonly_enabled": {
"type": "boolean",
"description": "Make container labels read-only",
"default": false
},
"use_build_secrets": {
"type": "boolean",
"description": "Use Docker build secrets for environment variables",
"default": false
},
"inject_build_args_to_dockerfile": {
"type": "boolean",
"description": "Inject build args into Dockerfile",
"default": false
},
"include_source_commit_in_build": {
"type": "boolean",
"description": "Include source commit SHA in build metadata",
"default": false
},
"is_debug_enabled": {
"type": "boolean",
"description": "Enable debug mode",
"default": false
},
"is_consistent_container_name_enabled": {
"type": "boolean",
"description": "Use consistent container names across deployments",
"default": false
},
"connect_to_docker_network": {
"type": "string",
"description": "Additional Docker network to connect to"
},
"custom_internal_name": {
"type": "string",
"description": "Custom internal container name"
},
"is_env_sorting_enabled": {
"type": "boolean",
"description": "Sort environment variables alphabetically",
"default": false
}
}
},
"deployment_commands": {
"type": "object",
"description": "Pre and post deployment commands",
"properties": {
"pre_deployment_command": {
"type": "string",
"description": "Command to run before deployment"
},
"pre_deployment_command_container": {
"type": "string",
"description": "Container to run pre-deployment command in"
},
"post_deployment_command": {
"type": "string",
"description": "Command to run after deployment"
},
"post_deployment_command_container": {
"type": "string",
"description": "Container to run post-deployment command in"
}
}
},
"preview": {
"type": "object",
"description": "Preview deployment settings",
"properties": {
"preview_url_template": {
"type": "string",
"description": "Template for preview deployment URLs",
"examples": ["{{pr_id}}.preview.{{domain}}", "pr-{{pr_id}}.{{domain}}"]
}
}
},
"swarm": {
"type": "object",
"description": "Docker Swarm specific settings",
"properties": {
"swarm_replicas": {
"type": "integer",
"description": "Number of replicas in swarm mode",
"minimum": 1,
"default": 1
},
"swarm_placement_constraints": {
"type": "string",
"description": "Swarm placement constraints",
"examples": ["node.role==worker", "node.labels.type==compute"]
}
}
},
"docker_registry": {
"type": "object",
"description": "Docker registry settings for image builds",
"properties": {
"image": {
"type": "string",
"description": "Docker registry image name",
"examples": ["ghcr.io/myorg/myapp", "docker.io/myuser/myapp"]
},
"tag": {
"type": "string",
"description": "Docker image tag",
"default": "latest"
}
}
},
"persistent_storages": {
"type": "array",
"description": "Persistent volume mounts",
"items": {
"type": "object",
"required": ["name", "mount_path"],
"properties": {
"name": {
"type": "string",
"description": "Volume name"
},
"mount_path": {
"type": "string",
"description": "Path inside container to mount volume",
"examples": ["/data", "/app/uploads", "/var/lib/data"]
},
"host_path": {
"type": "string",
"description": "Optional host path for bind mount"
}
}
}
},
"file_mounts": {
"type": "array",
"description": "File mounts (inject files into container)",
"items": {
"type": "object",
"required": ["mount_path"],
"properties": {
"mount_path": {
"type": "string",
"description": "Path inside container to mount file",
"examples": ["/app/config.json", "/etc/nginx/nginx.conf"]
},
"fs_path": {
"type": "string",
"description": "Path on host filesystem (auto-generated if not specified)"
},
"content": {
"type": "string",
"description": "File content (for inline file creation)"
}
}
}
},
"directory_mounts": {
"type": "array",
"description": "Directory mounts (bind mount directories)",
"items": {
"type": "object",
"required": ["source_path", "mount_path"],
"properties": {
"source_path": {
"type": "string",
"description": "Source path on host",
"examples": ["/opt/data", "/home/user/config"]
},
"mount_path": {
"type": "string",
"description": "Path inside container",
"examples": ["/data", "/config"]
}
}
}
},
"scheduled_tasks": {
"type": "array",
"description": "Scheduled tasks (cron jobs)",
"items": {
"type": "object",
"required": ["name", "command", "frequency"],
"properties": {
"name": {
"type": "string",
"description": "Task name"
},
"command": {
"type": "string",
"description": "Command to execute",
"examples": ["php artisan schedule:run", "node cleanup.js", "/app/backup.sh"]
},
"frequency": {
"type": "string",
"description": "Cron expression for scheduling",
"examples": ["* * * * *", "0 * * * *", "0 0 * * *", "0 0 * * 0"]
},
"container": {
"type": "string",
"description": "Container to run command in (for compose apps)"
},
"enabled": {
"type": "boolean",
"description": "Whether the task is enabled",
"default": true
},
"timeout": {
"type": "integer",
"description": "Task timeout in seconds",
"default": 300
}
}
}
},
"environment_variables": {
"type": "object",
"description": "Environment variables configuration",
"properties": {
"production": {
"type": "array",
"description": "Production environment variables",
"items": {
"$ref": "#/definitions/environmentVariable"
}
},
"preview": {
"type": "array",
"description": "Preview deployment environment variables",
"items": {
"$ref": "#/definitions/environmentVariable"
}
}
}
}
},
"definitions": {
"environmentVariable": {
"type": "object",
"required": ["key"],
"properties": {
"key": {
"type": "string",
"description": "Environment variable name",
"examples": ["DATABASE_URL", "API_KEY", "NODE_ENV"]
},
"value": {
"type": "string",
"description": "Environment variable value. Supports magic variables like $SERVICE_PASSWORD_32, $SERVICE_FQDN_APP, $SERVICE_URL_API",
"examples": ["production", "$SERVICE_PASSWORD_32", "$SERVICE_FQDN_API"]
},
"is_multiline": {
"type": "boolean",
"description": "Whether the value contains multiple lines",
"default": false
},
"is_literal": {
"type": "boolean",
"description": "Whether to treat value as literal (no variable expansion)",
"default": false
},
"is_buildtime": {
"type": "boolean",
"description": "Make available during build",
"default": true
},
"is_runtime": {
"type": "boolean",
"description": "Make available at runtime",
"default": true
}
}
}
},
"examples": [
{
"$schema": "https://coolify.io/schemas/coolify.schema.json",
"version": "1.0",
"name": "My Node.js App",
"description": "A simple Node.js application",
"build": {
"type": "nixpacks",
"base_directory": "/",
"build_command": "npm run build",
"start_command": "npm start"
},
"domains": {
"ports_exposes": "3000"
},
"settings": {
"is_auto_deploy_enabled": true,
"is_force_https_enabled": true
},
"environment_variables": {
"production": [
{
"key": "NODE_ENV",
"value": "production"
},
{
"key": "DATABASE_PASSWORD",
"value": "$SERVICE_PASSWORD_32"
}
]
}
}
]
}

View File

@ -145,6 +145,7 @@
'new public', 'new public git', 'new public repo', 'new public repository',
'new private github', 'new private gh', 'new private deploy', 'new deploy key',
'new dockerfile', 'new docker compose', 'new compose', 'new docker image', 'new image',
'new coolify json', 'new json', 'new import',
'new postgresql', 'new postgres', 'new mysql', 'new mariadb',
'new redis', 'new keydb', 'new dragonfly', 'new mongodb', 'new mongo', 'new clickhouse'
];

View File

@ -18,6 +18,15 @@
{{ $application->docker_compose_raw ? 'Reload Compose File' : 'Load Compose File' }}
</x-forms.button>
@endif
@can('view', $application)
<x-forms.button wire:click="downloadRepositoryConfig"
title="Download coolify.json for your repository">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
coolify.json
</x-forms.button>
@endcan
</div>
<div>General configuration for your application.</div>
<div class="flex flex-col gap-2 py-4">

View File

@ -0,0 +1,57 @@
<div>
<h1>Import from coolify.json</h1>
<div class="pb-4">Paste a coolify.json configuration to quickly create and configure an application.</div>
<form wire:submit="submit">
<div class="flex gap-2 pb-1">
<h2>coolify.json</h2>
<x-forms.button type="submit">
<span wire:loading.remove wire:target="submit">Create Application</span>
<span wire:loading wire:target="submit">Creating...</span>
</x-forms.button>
</div>
@if ($parseError)
<div class="pb-2 text-red-500">{{ $parseError }}</div>
@endif
<x-forms.textarea useMonacoEditor monacoEditorLanguage="json" monacoJsonSchema="/schemas/coolify.schema.json"
rows="20" id="coolifyJson" autofocus wire:model.live.debounce.500ms="coolifyJson"
placeholder='{
"version": "1.0",
"name": "My Application",
"source": {
"repository": "https://github.com/user/repo",
"branch": "main"
},
"build": {
"type": "nixpacks"
}
}'></x-forms.textarea>
@if ($parsedConfig)
<div class="mt-4 p-4 bg-coolgray-100 rounded">
<h3 class="font-bold mb-2">Configuration Preview</h3>
<div class="grid gap-2 text-sm">
@if (data_get($parsedConfig, 'name'))
<div><span class="text-neutral-400">Name:</span> {{ data_get($parsedConfig, 'name') }}</div>
@endif
@if (data_get($parsedConfig, 'source.repository'))
<div><span class="text-neutral-400">Repository:</span> {{ data_get($parsedConfig, 'source.repository') }}</div>
@endif
@if (data_get($parsedConfig, 'source.branch'))
<div><span class="text-neutral-400">Branch:</span> {{ data_get($parsedConfig, 'source.branch') }}</div>
@endif
@if (data_get($parsedConfig, 'build.type'))
<div><span class="text-neutral-400">Build Type:</span> {{ data_get($parsedConfig, 'build.type') }}</div>
@endif
@if (data_get($parsedConfig, 'environment_variables'))
<div><span class="text-neutral-400">Environment Variables:</span>
{{ count(data_get($parsedConfig, 'environment_variables.production', [])) }} production,
{{ count(data_get($parsedConfig, 'environment_variables.preview', [])) }} preview
</div>
@endif
</div>
</div>
@endif
</form>
</div>

View File

@ -105,6 +105,10 @@
helper="If your application is a static site or the final build assets should be served as a static site, enable this." />
</div>
@endif
<div class="w-64">
<x-forms.checkbox id="checkCoolifyConfig" label="Import coolify.json"
helper="If a coolify.json file exists in the repository, import build settings and environment variables from it." />
</div>
<x-forms.button type="submit" class="mt-4">
Continue
</x-forms.button>

View File

@ -143,6 +143,10 @@
helper="If your application is a static site or the final build assets should be served as a static site, enable this." />
</div>
@endif
<div class="w-64">
<x-forms.checkbox id="checkCoolifyConfig" label="Import coolify.json"
helper="If a coolify.json file exists in the repository, import build settings and environment variables from it." />
</div>
</div>
<x-forms.button type="submit">
Continue

View File

@ -97,6 +97,10 @@
helper="If your application is a static site or the final build assets should be served as a static site, enable this." />
</div>
@endif
<div class="w-64">
<x-forms.checkbox id="checkCoolifyConfig" label="Import coolify.json"
helper="If a coolify.json file exists in the repository, import build settings and environment variables from it." />
</div>
</div>
<x-forms.button type="submit">
Continue

View File

@ -118,6 +118,21 @@
</div>
</div>
</div>
{{-- Coolify JSON Import - Full Width --}}
<div x-show="coolifyJsonImport && (search === '' || 'import coolify json configuration'.includes(search.toLowerCase()))"
class="mt-4">
<div x-on:click="setType(coolifyJsonImport.id)"
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
<x-resource-view>
<x-slot:title><span x-text="coolifyJsonImport.name"></span></x-slot>
<x-slot:description><span x-text="coolifyJsonImport.description"></span></x-slot>
<x-slot:logo>
<img class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/10 object-contain"
:src="coolifyJsonImport.logo">
</x-slot>
</x-resource-view>
</div>
</div>
<div x-show="filteredDatabases.length > 0" class="mt-8">
<h2 class="mb-4">Databases</h2>
<div class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-3">
@ -211,6 +226,7 @@
services: [],
gitBasedApplications: [],
dockerBasedApplications: [],
coolifyJsonImport: null,
databases: [],
docLinkCache: {}, // Cache resolved doc URLs: { serviceName: url | null }
docCheckInProgress: {}, // Track ongoing checks: { serviceName: boolean }
@ -226,12 +242,14 @@
categories,
gitBasedApplications,
dockerBasedApplications,
coolifyJsonImport,
databases
} = await this.$wire.loadServices();
this.services = services;
this.categories = categories || [];
this.gitBasedApplications = gitBasedApplications;
this.dockerBasedApplications = dockerBasedApplications;
this.coolifyJsonImport = coolifyJsonImport;
this.databases = databases;
this.loading = false;
this.$nextTick(() => {

View File

@ -14,6 +14,8 @@
<livewire:project.new.docker-compose :type="$type" />
@elseif ($type === 'docker-image')
<livewire:project.new.docker-image :type="$type" />
@elseif ($type === 'coolify-json')
<livewire:project.new.coolify-json-import :type="$type" />
@else
<livewire:project.new.select />
@endif

View File

@ -1,5 +1,6 @@
<form wire:submit="uploadConfig" class="flex flex-col gap-2 w-full">
<x-forms.textarea id="config" monacoEditorLanguage="json" useMonacoEditor />
<x-forms.textarea id="config" monacoEditorLanguage="json" monacoJsonSchema="/schemas/coolify.schema.json"
useMonacoEditor />
<x-forms.button type="submit">
Upload
</x-forms.button>

View File

@ -0,0 +1,206 @@
<?php
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
/**
* Unit tests for coolify.json configuration detection and application.
* Tests the magic environment variable resolution logic and schema validation.
*/
it('resolves SERVICE_PASSWORD magic variable with default length', function () {
$value = 'SERVICE_PASSWORD';
// Default is 64 characters
if ($value === 'SERVICE_PASSWORD') {
$result = Str::password(length: 64, symbols: false);
}
expect($result)->toHaveLength(64);
});
it('resolves SERVICE_PASSWORD_XX magic variable with custom length', function () {
$testCases = [
'SERVICE_PASSWORD_32' => 32,
'SERVICE_PASSWORD_128' => 128,
'SERVICE_PASSWORD_16' => 16,
];
foreach ($testCases as $value => $expectedLength) {
if (preg_match('/^SERVICE_PASSWORD_(\d+)$/', $value, $matches)) {
$length = (int) $matches[1];
$length = max(8, min(256, $length));
$result = Str::password(length: $length, symbols: false);
}
expect($result)->toHaveLength($expectedLength);
}
});
it('clamps SERVICE_PASSWORD length between 8 and 256', function () {
// Test too small
$value = 'SERVICE_PASSWORD_2';
if (preg_match('/^SERVICE_PASSWORD_(\d+)$/', $value, $matches)) {
$length = (int) $matches[1];
$length = max(8, min(256, $length));
}
expect($length)->toBe(8);
// Test too large
$value = 'SERVICE_PASSWORD_500';
if (preg_match('/^SERVICE_PASSWORD_(\d+)$/', $value, $matches)) {
$length = (int) $matches[1];
$length = max(8, min(256, $length));
}
expect($length)->toBe(256);
});
it('resolves SERVICE_USER magic variable', function () {
$value = 'SERVICE_USER';
if ($value === 'SERVICE_USER') {
$result = 'user_'.Str::random(8);
}
expect($result)->toStartWith('user_')
->and(strlen($result))->toBe(13); // 'user_' + 8 chars
});
it('resolves SERVICE_BASE64_XX magic variable', function () {
$value = 'SERVICE_BASE64_32';
if (preg_match('/^SERVICE_BASE64_(\d+)$/', $value, $matches)) {
$length = (int) $matches[1];
$length = max(8, min(256, $length));
$result = base64_encode(Str::random($length));
}
// Base64 encoded string should decode properly
$decoded = base64_decode($result, true);
expect($decoded)->not->toBeFalse()
->and($decoded)->toHaveLength(32);
});
it('resolves SERVICE_UUID magic variable', function () {
$value = 'SERVICE_UUID';
if ($value === 'SERVICE_UUID') {
$result = (string) new Cuid2;
}
expect($result)->toHaveLength(24); // Cuid2 default length
});
it('returns non-magic values unchanged', function () {
$regularValue = 'my-regular-value';
$result = $regularValue;
// The resolution logic only transforms magic values
$magicPatterns = [
'/^SERVICE_PASSWORD_(\d+)$/',
'/^SERVICE_PASSWORD$/',
'/^SERVICE_USER$/',
'/^SERVICE_BASE64_(\d+)$/',
'/^SERVICE_UUID$/',
];
$isMagic = false;
foreach ($magicPatterns as $pattern) {
if (preg_match($pattern, $regularValue)) {
$isMagic = true;
break;
}
}
expect($isMagic)->toBeFalse()
->and($result)->toBe($regularValue);
});
it('validates coolify.json schema version', function () {
// Test schema version validation in loadConfigFromGit by simulating the logic
$supportedVersions = ['1.0'];
expect(in_array('1.0', $supportedVersions))->toBeTrue()
->and(in_array('2.0', $supportedVersions))->toBeFalse()
->and(in_array('0.9', $supportedVersions))->toBeFalse();
});
it('detects unknown fields in coolify.json', function () {
$config = [
'version' => '1.0',
'name' => 'test-app',
'build' => ['type' => 'nixpacks'],
'unknown_field' => 'value',
'another_unknown' => 'value',
];
$knownFields = ['version', 'name', 'description', 'build', 'domains', 'environment_variables', 'health_check', 'limits', 'settings'];
$unknownFields = array_values(array_diff(array_keys($config), $knownFields));
expect($unknownFields)->toBe(['unknown_field', 'another_unknown']);
});
it('builds correct file paths for base_directory', function () {
// Test the path building logic used in loadConfigFromGit
// With base_directory
$baseDirectory = '/app/frontend';
$workdir = rtrim($baseDirectory, '/');
$pathsToCheck = [];
if ($workdir !== '' && $workdir !== '/') {
$pathsToCheck[] = ltrim($workdir, '/').'/coolify.json';
}
$pathsToCheck[] = 'coolify.json';
expect($pathsToCheck)->toBe(['app/frontend/coolify.json', 'coolify.json']);
// With root base_directory
$baseDirectory = '/';
$workdir = rtrim($baseDirectory, '/');
$pathsToCheck = [];
if ($workdir !== '' && $workdir !== '/') {
$pathsToCheck[] = ltrim($workdir, '/').'/coolify.json';
}
$pathsToCheck[] = 'coolify.json';
expect($pathsToCheck)->toBe(['coolify.json']);
});
it('parses valid coolify.json config', function () {
$json = json_encode([
'version' => '1.0',
'name' => 'my-app',
'build' => [
'type' => 'nixpacks',
'install_command' => 'npm install',
'build_command' => 'npm run build',
'start_command' => 'npm start',
],
'domains' => [
'ports_exposes' => '3000',
],
'environment_variables' => [
'production' => [
['key' => 'NODE_ENV', 'value' => 'production'],
['key' => 'DB_PASSWORD', 'value' => 'SERVICE_PASSWORD_64'],
],
],
]);
$config = json_decode($json, true);
expect($config)->toBeArray()
->and(json_last_error())->toBe(JSON_ERROR_NONE)
->and(data_get($config, 'version'))->toBe('1.0')
->and(data_get($config, 'build.type'))->toBe('nixpacks')
->and(data_get($config, 'environment_variables.production'))->toHaveCount(2);
});
it('handles invalid JSON gracefully', function () {
$invalidJson = '{invalid json}';
$config = json_decode($invalidJson, true);
expect(json_last_error())->not->toBe(JSON_ERROR_NONE)
->and($config)->toBeNull();
});