diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 000000000..24c099119
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,2 @@
+reviews:
+ review_status: false
diff --git a/.gitignore b/.gitignore
index 65b7faa1b..935ea548e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,4 @@ scripts/load-test/*
docker/coolify-realtime/node_modules
.DS_Store
CHANGELOG.md
+/.workspaces
diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php
index 392562167..6bf094c32 100644
--- a/app/Actions/Server/CleanupDocker.php
+++ b/app/Actions/Server/CleanupDocker.php
@@ -20,7 +20,7 @@ class CleanupDocker
$realtimeImageWithoutPrefix = 'coollabsio/coolify-realtime';
$realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion";
- $helperImageVersion = data_get($settings, 'helper_version');
+ $helperImageVersion = getHelperVersion();
$helperImage = config('constants.coolify.helper_image');
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
diff --git a/app/Console/Commands/UpdateServiceVersions.php b/app/Console/Commands/UpdateServiceVersions.php
new file mode 100644
index 000000000..1bd6708fd
--- /dev/null
+++ b/app/Console/Commands/UpdateServiceVersions.php
@@ -0,0 +1,791 @@
+ 0,
+ 'updated' => 0,
+ 'failed' => 0,
+ 'skipped' => 0,
+ ];
+
+ protected array $registryCache = [];
+
+ protected array $majorVersionUpdates = [];
+
+ public function handle(): int
+ {
+ $this->info('Starting service version update...');
+
+ $templateFiles = $this->getTemplateFiles();
+
+ $this->stats['total'] = count($templateFiles);
+
+ foreach ($templateFiles as $file) {
+ $this->processTemplate($file);
+ }
+
+ $this->newLine();
+ $this->displayStats();
+
+ return self::SUCCESS;
+ }
+
+ protected function getTemplateFiles(): array
+ {
+ $pattern = base_path('templates/compose/*.yaml');
+ $files = glob($pattern);
+
+ if ($service = $this->option('service')) {
+ $files = array_filter($files, fn ($file) => basename($file) === "$service.yaml");
+ }
+
+ return $files;
+ }
+
+ protected function processTemplate(string $filePath): void
+ {
+ $filename = basename($filePath);
+ $this->info("Processing: {$filename}");
+
+ try {
+ $content = file_get_contents($filePath);
+ $yaml = Yaml::parse($content);
+
+ if (! isset($yaml['services'])) {
+ $this->warn(" No services found in {$filename}");
+ $this->stats['skipped']++;
+
+ return;
+ }
+
+ $updated = false;
+ $updatedYaml = $yaml;
+
+ foreach ($yaml['services'] as $serviceName => $serviceConfig) {
+ if (! isset($serviceConfig['image'])) {
+ continue;
+ }
+
+ $currentImage = $serviceConfig['image'];
+
+ // Check if using 'latest' tag and log for manual review
+ if (str_contains($currentImage, ':latest')) {
+ $registryUrl = $this->getRegistryUrl($currentImage);
+ $this->warn(" {$serviceName}: {$currentImage} (using 'latest' tag)");
+ if ($registryUrl) {
+ $this->line(" → Manual review: {$registryUrl}");
+ }
+ }
+
+ $latestVersion = $this->getLatestVersion($currentImage);
+
+ if ($latestVersion && $latestVersion !== $currentImage) {
+ $this->line(" {$serviceName}: {$currentImage} → {$latestVersion}");
+ $updatedYaml['services'][$serviceName]['image'] = $latestVersion;
+ $updated = true;
+ } else {
+ $this->line(" {$serviceName}: {$currentImage} (up to date)");
+ }
+ }
+
+ if ($updated) {
+ if (! $this->option('dry-run')) {
+ $this->updateYamlFile($filePath, $content, $updatedYaml);
+ $this->stats['updated']++;
+ } else {
+ $this->warn(' [DRY RUN] Would update this file');
+ $this->stats['updated']++;
+ }
+ } else {
+ $this->stats['skipped']++;
+ }
+
+ } catch (\Throwable $e) {
+ $this->error(" Failed: {$e->getMessage()}");
+ $this->stats['failed']++;
+ }
+
+ $this->newLine();
+ }
+
+ protected function getLatestVersion(string $image): ?string
+ {
+ // Parse the image string
+ [$repository, $currentTag] = $this->parseImage($image);
+
+ // Determine registry and fetch latest version
+ $result = null;
+ if (str_starts_with($repository, 'ghcr.io/')) {
+ $result = $this->getGhcrLatestVersion($repository, $currentTag);
+ } elseif (str_starts_with($repository, 'quay.io/')) {
+ $result = $this->getQuayLatestVersion($repository, $currentTag);
+ } elseif (str_starts_with($repository, 'codeberg.org/')) {
+ $result = $this->getCodebergLatestVersion($repository, $currentTag);
+ } elseif (str_starts_with($repository, 'lscr.io/')) {
+ $result = $this->getDockerHubLatestVersion($repository, $currentTag);
+ } elseif ($this->isCustomRegistry($repository)) {
+ // Custom registries - skip for now, log warning
+ $this->warn(" Skipping custom registry: {$repository}");
+ $result = null;
+ } else {
+ // DockerHub (default registry - no prefix or docker.io/index.docker.io)
+ $result = $this->getDockerHubLatestVersion($repository, $currentTag);
+ }
+
+ return $result;
+ }
+
+ protected function isCustomRegistry(string $repository): bool
+ {
+ // List of custom/private registries that we can't query
+ $customRegistries = [
+ 'docker.elastic.co/',
+ 'docker.n8n.io/',
+ 'docker.flipt.io/',
+ 'docker.getoutline.com/',
+ 'cr.weaviate.io/',
+ 'downloads.unstructured.io/',
+ 'budibase.docker.scarf.sh/',
+ 'calcom.docker.scarf.sh/',
+ 'code.forgejo.org/',
+ 'registry.supertokens.io/',
+ 'registry.rocket.chat/',
+ 'nabo.codimd.dev/',
+ 'gcr.io/',
+ ];
+
+ foreach ($customRegistries as $registry) {
+ if (str_starts_with($repository, $registry)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function getRegistryUrl(string $image): ?string
+ {
+ [$repository] = $this->parseImage($image);
+
+ // GitHub Container Registry
+ if (str_starts_with($repository, 'ghcr.io/')) {
+ $parts = explode('/', str_replace('ghcr.io/', '', $repository));
+ if (count($parts) >= 2) {
+ return "https://github.com/{$parts[0]}/{$parts[1]}/pkgs/container/{$parts[1]}";
+ }
+ }
+
+ // Quay.io
+ if (str_starts_with($repository, 'quay.io/')) {
+ $repo = str_replace('quay.io/', '', $repository);
+
+ return "https://quay.io/repository/{$repo}?tab=tags";
+ }
+
+ // Codeberg
+ if (str_starts_with($repository, 'codeberg.org/')) {
+ $parts = explode('/', str_replace('codeberg.org/', '', $repository));
+ if (count($parts) >= 2) {
+ return "https://codeberg.org/{$parts[0]}/-/packages/container/{$parts[1]}";
+ }
+ }
+
+ // Docker Hub
+ $cleanRepo = str_replace(['index.docker.io/', 'docker.io/', 'lscr.io/'], '', $repository);
+ if (! str_contains($cleanRepo, '/')) {
+ // Official image
+ return "https://hub.docker.com/_/{$cleanRepo}/tags";
+ } else {
+ // User/org image
+ return "https://hub.docker.com/r/{$cleanRepo}/tags";
+ }
+ }
+
+ protected function parseImage(string $image): array
+ {
+ if (str_contains($image, ':')) {
+ [$repo, $tag] = explode(':', $image, 2);
+ } else {
+ $repo = $image;
+ $tag = 'latest';
+ }
+
+ // Handle variables in tags
+ if (str_contains($tag, '$')) {
+ $tag = 'latest'; // Default to latest for variable tags
+ }
+
+ return [$repo, $tag];
+ }
+
+ protected function getDockerHubLatestVersion(string $repository, string $currentTag): ?string
+ {
+ try {
+ // Check if we've already fetched tags for this repository
+ if (! isset($this->registryCache[$repository.'_tags'])) {
+ // Remove various registry prefixes
+ $cleanRepo = $repository;
+ $cleanRepo = str_replace('index.docker.io/', '', $cleanRepo);
+ $cleanRepo = str_replace('docker.io/', '', $cleanRepo);
+ $cleanRepo = str_replace('lscr.io/', '', $cleanRepo);
+
+ // For official images (no /) add library prefix
+ if (! str_contains($cleanRepo, '/')) {
+ $cleanRepo = "library/{$cleanRepo}";
+ }
+
+ $url = "https://hub.docker.com/v2/repositories/{$cleanRepo}/tags";
+
+ $response = Http::timeout(10)->get($url, [
+ 'page_size' => 100,
+ 'ordering' => 'last_updated',
+ ]);
+
+ if (! $response->successful()) {
+ return null;
+ }
+
+ $data = $response->json();
+ $tags = $data['results'] ?? [];
+
+ // Cache the tags for this repository
+ $this->registryCache[$repository.'_tags'] = $tags;
+ } else {
+ $this->line(" [cached] Using cached tags for {$repository}");
+ $tags = $this->registryCache[$repository.'_tags'];
+ }
+
+ // Find the best matching tag
+ return $this->findBestTag($tags, $currentTag, $repository);
+
+ } catch (\Throwable $e) {
+ $this->warn(" DockerHub API error for {$repository}: {$e->getMessage()}");
+
+ return null;
+ }
+ }
+
+ protected function findLatestTagDigest(array $tags, string $targetTag = 'latest'): ?string
+ {
+ // Find the digest/sha for the target tag (usually 'latest')
+ foreach ($tags as $tag) {
+ if ($tag['name'] === $targetTag) {
+ return $tag['digest'] ?? $tag['images'][0]['digest'] ?? null;
+ }
+ }
+
+ return null;
+ }
+
+ protected function findVersionTagsForDigest(array $tags, string $digest): array
+ {
+ // Find all semantic version tags that share the same digest
+ $versionTags = [];
+
+ foreach ($tags as $tag) {
+ $tagDigest = $tag['digest'] ?? $tag['images'][0]['digest'] ?? null;
+
+ if ($tagDigest === $digest) {
+ $tagName = $tag['name'];
+ // Only include semantic version tags
+ if (preg_match('/^\d+\.\d+(\.\d+)?$/', $tagName)) {
+ $versionTags[] = $tagName;
+ }
+ }
+ }
+
+ return $versionTags;
+ }
+
+ protected function getGhcrLatestVersion(string $repository, string $currentTag): ?string
+ {
+ try {
+ // GHCR doesn't have a public API for listing tags without auth
+ // We'll try to fetch the package metadata via GitHub API
+ $parts = explode('/', str_replace('ghcr.io/', '', $repository));
+
+ if (count($parts) < 2) {
+ return null;
+ }
+
+ $owner = $parts[0];
+ $package = $parts[1];
+
+ // Try GitHub Container Registry API
+ $url = "https://api.github.com/users/{$owner}/packages/container/{$package}/versions";
+
+ $response = Http::timeout(10)
+ ->withHeaders([
+ 'Accept' => 'application/vnd.github.v3+json',
+ ])
+ ->get($url, ['per_page' => 100]);
+
+ if (! $response->successful()) {
+ // Most GHCR packages require authentication
+ if ($currentTag === 'latest') {
+ $this->warn(' ⚠ GHCR requires authentication - manual review needed');
+ }
+
+ return null;
+ }
+
+ $versions = $response->json();
+ $tags = [];
+
+ // Build tags array with digest information
+ foreach ($versions as $version) {
+ $digest = $version['name'] ?? null; // This is the SHA digest
+
+ if (isset($version['metadata']['container']['tags'])) {
+ foreach ($version['metadata']['container']['tags'] as $tag) {
+ $tags[] = [
+ 'name' => $tag,
+ 'digest' => $digest,
+ ];
+ }
+ }
+ }
+
+ return $this->findBestTag($tags, $currentTag, $repository);
+
+ } catch (\Throwable $e) {
+ $this->warn(" GHCR API error for {$repository}: {$e->getMessage()}");
+
+ return null;
+ }
+ }
+
+ protected function getQuayLatestVersion(string $repository, string $currentTag): ?string
+ {
+ try {
+ // Check if we've already fetched tags for this repository
+ if (! isset($this->registryCache[$repository.'_tags'])) {
+ $cleanRepo = str_replace('quay.io/', '', $repository);
+
+ $url = "https://quay.io/api/v1/repository/{$cleanRepo}/tag/";
+
+ $response = Http::timeout(10)->get($url, ['limit' => 100]);
+
+ if (! $response->successful()) {
+ return null;
+ }
+
+ $data = $response->json();
+ $tags = array_map(fn ($tag) => ['name' => $tag['name']], $data['tags'] ?? []);
+
+ // Cache the tags for this repository
+ $this->registryCache[$repository.'_tags'] = $tags;
+ } else {
+ $this->line(" [cached] Using cached tags for {$repository}");
+ $tags = $this->registryCache[$repository.'_tags'];
+ }
+
+ return $this->findBestTag($tags, $currentTag, $repository);
+
+ } catch (\Throwable $e) {
+ $this->warn(" Quay API error for {$repository}: {$e->getMessage()}");
+
+ return null;
+ }
+ }
+
+ protected function getCodebergLatestVersion(string $repository, string $currentTag): ?string
+ {
+ try {
+ // Check if we've already fetched tags for this repository
+ if (! isset($this->registryCache[$repository.'_tags'])) {
+ // Codeberg uses Forgejo/Gitea, which has a container registry API
+ $cleanRepo = str_replace('codeberg.org/', '', $repository);
+ $parts = explode('/', $cleanRepo);
+
+ if (count($parts) < 2) {
+ return null;
+ }
+
+ $owner = $parts[0];
+ $package = $parts[1];
+
+ // Codeberg API endpoint for packages
+ $url = "https://codeberg.org/api/packages/{$owner}/container/{$package}";
+
+ $response = Http::timeout(10)->get($url);
+
+ if (! $response->successful()) {
+ return null;
+ }
+
+ $data = $response->json();
+ $tags = [];
+
+ if (isset($data['versions'])) {
+ foreach ($data['versions'] as $version) {
+ if (isset($version['name'])) {
+ $tags[] = ['name' => $version['name']];
+ }
+ }
+ }
+
+ // Cache the tags for this repository
+ $this->registryCache[$repository.'_tags'] = $tags;
+ } else {
+ $this->line(" [cached] Using cached tags for {$repository}");
+ $tags = $this->registryCache[$repository.'_tags'];
+ }
+
+ return $this->findBestTag($tags, $currentTag, $repository);
+
+ } catch (\Throwable $e) {
+ $this->warn(" Codeberg API error for {$repository}: {$e->getMessage()}");
+
+ return null;
+ }
+ }
+
+ protected function findBestTag(array $tags, string $currentTag, string $repository): ?string
+ {
+ if (empty($tags)) {
+ return null;
+ }
+
+ // If current tag is 'latest', find what version it actually points to
+ if ($currentTag === 'latest') {
+ // First, try to find the digest for 'latest' tag
+ $latestDigest = $this->findLatestTagDigest($tags, 'latest');
+
+ if ($latestDigest) {
+ // Find all semantic version tags that share the same digest
+ $versionTags = $this->findVersionTagsForDigest($tags, $latestDigest);
+
+ if (! empty($versionTags)) {
+ // Prefer shorter version tags (1.8 over 1.8.1)
+ $bestVersion = $this->preferShorterVersion($versionTags);
+ $this->info(" ✓ Found 'latest' points to: {$bestVersion}");
+
+ return $repository.':'.$bestVersion;
+ }
+ }
+
+ // Fallback: get the latest semantic version available (prefer shorter)
+ $semverTags = $this->filterSemanticVersionTags($tags);
+ if (! empty($semverTags)) {
+ $bestVersion = $this->preferShorterVersion($semverTags);
+
+ return $repository.':'.$bestVersion;
+ }
+
+ // If no semantic versions found, keep 'latest'
+ return null;
+ }
+
+ // Check for major version updates for reporting
+ $this->checkForMajorVersionUpdate($tags, $currentTag, $repository);
+
+ // If current tag is a major version (e.g., "8", "5", "16")
+ if (preg_match('/^\d+$/', $currentTag)) {
+ $majorVersion = (int) $currentTag;
+ $matchingTags = array_filter($tags, function ($tag) use ($majorVersion) {
+ $name = $tag['name'];
+
+ // Match tags that start with the major version
+ return preg_match("/^{$majorVersion}(\.\d+)?(\.\d+)?$/", $name);
+ });
+
+ if (! empty($matchingTags)) {
+ $versions = array_column($matchingTags, 'name');
+ $bestVersion = $this->preferShorterVersion($versions);
+ if ($bestVersion !== $currentTag) {
+ return $repository.':'.$bestVersion;
+ }
+ }
+ }
+
+ // If current tag is date-based version (e.g., "2025.06.02-sha-xxx")
+ if (preg_match('/^\d{4}\.\d{2}\.\d{2}/', $currentTag)) {
+ // Get all date-based tags
+ $dateTags = array_filter($tags, function ($tag) {
+ return preg_match('/^\d{4}\.\d{2}\.\d{2}/', $tag['name']);
+ });
+
+ if (! empty($dateTags)) {
+ $versions = array_column($dateTags, 'name');
+ $sorted = $this->sortSemanticVersions($versions);
+ $latestDate = $sorted[0];
+
+ // Compare dates
+ if ($latestDate !== $currentTag) {
+ return $repository.':'.$latestDate;
+ }
+ }
+
+ return null;
+ }
+
+ // If current tag is semantic version (e.g., "1.7.4", "8.0")
+ if (preg_match('/^\d+\.\d+(\.\d+)?$/', $currentTag)) {
+ $parts = explode('.', $currentTag);
+ $majorMinor = $parts[0].'.'.$parts[1];
+
+ $matchingTags = array_filter($tags, function ($tag) use ($majorMinor) {
+ $name = $tag['name'];
+
+ return str_starts_with($name, $majorMinor);
+ });
+
+ if (! empty($matchingTags)) {
+ $versions = array_column($matchingTags, 'name');
+ $bestVersion = $this->preferShorterVersion($versions);
+ if (version_compare($bestVersion, $currentTag, '>') || version_compare($bestVersion, $currentTag, '=')) {
+ // Only update if it's newer or if we can simplify (1.8.1 -> 1.8)
+ if ($bestVersion !== $currentTag) {
+ return $repository.':'.$bestVersion;
+ }
+ }
+ }
+ }
+
+ // If current tag is a named version (e.g., "stable")
+ if (in_array($currentTag, ['stable', 'lts', 'edge'])) {
+ // Check if the same tag exists in the list (it's up to date)
+ $exists = array_filter($tags, fn ($tag) => $tag['name'] === $currentTag);
+ if (! empty($exists)) {
+ return null; // Tag exists and is current
+ }
+ }
+
+ return null;
+ }
+
+ protected function filterSemanticVersionTags(array $tags): array
+ {
+ $semverTags = array_filter($tags, function ($tag) {
+ $name = $tag['name'];
+
+ // Accept semantic versions (1.2.3, v1.2.3)
+ if (preg_match('/^v?\d+\.\d+(\.\d+)?(\.\d+)?$/', $name)) {
+ // Exclude versions with suffixes like -rc, -beta, -alpha
+ if (preg_match('/-(rc|beta|alpha|dev|test|pre|snapshot)/i', $name)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ // Accept date-based versions (2025.06.02, 2025.10.0, 2025.06.02-sha-xxx, RELEASE.2025-10-15T17-29-55Z)
+ if (preg_match('/^\d{4}\.\d{2}\.(\d{2}|\d)/', $name) || preg_match('/^RELEASE\.\d{4}-\d{2}-\d{2}/', $name)) {
+ return true;
+ }
+
+ return false;
+ });
+
+ return $this->sortSemanticVersions(array_column($semverTags, 'name'));
+ }
+
+ protected function sortSemanticVersions(array $versions): array
+ {
+ usort($versions, function ($a, $b) {
+ // Check if these are date-based versions (YYYY.MM.DD or YYYY.MM.D format)
+ $isDateA = preg_match('/^(\d{4})\.(\d{2})\.(\d{1,2})/', $a, $matchesA);
+ $isDateB = preg_match('/^(\d{4})\.(\d{2})\.(\d{1,2})/', $b, $matchesB);
+
+ if ($isDateA && $isDateB) {
+ // Both are date-based (YYYY.MM.DD), compare as dates
+ $dateA = $matchesA[1].$matchesA[2].str_pad($matchesA[3], 2, '0', STR_PAD_LEFT); // YYYYMMDD
+ $dateB = $matchesB[1].$matchesB[2].str_pad($matchesB[3], 2, '0', STR_PAD_LEFT); // YYYYMMDD
+
+ return strcmp($dateB, $dateA); // Descending order (newest first)
+ }
+
+ // Check if these are RELEASE date versions (RELEASE.YYYY-MM-DDTHH-MM-SSZ)
+ $isReleaseA = preg_match('/^RELEASE\.(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z/', $a, $matchesA);
+ $isReleaseB = preg_match('/^RELEASE\.(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})Z/', $b, $matchesB);
+
+ if ($isReleaseA && $isReleaseB) {
+ // Both are RELEASE format, compare as datetime
+ $dateTimeA = $matchesA[1].$matchesA[2].$matchesA[3].$matchesA[4].$matchesA[5].$matchesA[6]; // YYYYMMDDHHMMSS
+ $dateTimeB = $matchesB[1].$matchesB[2].$matchesB[3].$matchesB[4].$matchesB[5].$matchesB[6]; // YYYYMMDDHHMMSS
+
+ return strcmp($dateTimeB, $dateTimeA); // Descending order (newest first)
+ }
+
+ // Strip 'v' prefix for version comparison
+ $cleanA = ltrim($a, 'v');
+ $cleanB = ltrim($b, 'v');
+
+ // Fall back to semantic version comparison
+ return version_compare($cleanB, $cleanA); // Descending order
+ });
+
+ return $versions;
+ }
+
+ protected function preferShorterVersion(array $versions): string
+ {
+ if (empty($versions)) {
+ return '';
+ }
+
+ // Sort by version (highest first)
+ $sorted = $this->sortSemanticVersions($versions);
+ $highest = $sorted[0];
+
+ // Parse the highest version
+ $parts = explode('.', $highest);
+
+ // Look for shorter versions that match
+ // Priority: major (8) > major.minor (8.0) > major.minor.patch (8.0.39)
+
+ // Try to find just major.minor (e.g., 1.8 instead of 1.8.1)
+ if (count($parts) === 3) {
+ $majorMinor = $parts[0].'.'.$parts[1];
+ if (in_array($majorMinor, $versions)) {
+ return $majorMinor;
+ }
+ }
+
+ // Try to find just major (e.g., 8 instead of 8.0.39)
+ if (count($parts) >= 2) {
+ $major = $parts[0];
+ if (in_array($major, $versions)) {
+ return $major;
+ }
+ }
+
+ // Return the highest version we found
+ return $highest;
+ }
+
+ protected function updateYamlFile(string $filePath, string $originalContent, array $updatedYaml): void
+ {
+ // Preserve comments and formatting by updating the YAML content
+ $lines = explode("\n", $originalContent);
+ $updatedLines = [];
+ $inServices = false;
+ $currentService = null;
+
+ foreach ($lines as $line) {
+ // Detect if we're in the services section
+ if (preg_match('/^services:/', $line)) {
+ $inServices = true;
+ $updatedLines[] = $line;
+
+ continue;
+ }
+
+ // Detect service name (allow hyphens and underscores)
+ if ($inServices && preg_match('/^ ([\w-]+):/', $line, $matches)) {
+ $currentService = $matches[1];
+ $updatedLines[] = $line;
+
+ continue;
+ }
+
+ // Update image line
+ if ($currentService && preg_match('/^(\s+)image:\s*(.+)$/', $line, $matches)) {
+ $indent = $matches[1];
+ $newImage = $updatedYaml['services'][$currentService]['image'] ?? $matches[2];
+ $updatedLines[] = "{$indent}image: {$newImage}";
+
+ continue;
+ }
+
+ // If we hit a non-indented line, we're out of services
+ if ($inServices && preg_match('/^\S/', $line) && ! preg_match('/^services:/', $line)) {
+ $inServices = false;
+ $currentService = null;
+ }
+
+ $updatedLines[] = $line;
+ }
+
+ file_put_contents($filePath, implode("\n", $updatedLines));
+ }
+
+ protected function checkForMajorVersionUpdate(array $tags, string $currentTag, string $repository): void
+ {
+ // Only check semantic versions
+ if (! preg_match('/^v?(\d+)\./', $currentTag, $currentMatches)) {
+ return;
+ }
+
+ $currentMajor = (int) $currentMatches[1];
+
+ // Get all semantic version tags
+ $semverTags = $this->filterSemanticVersionTags($tags);
+
+ // Find the highest major version available
+ $highestMajor = $currentMajor;
+ foreach ($semverTags as $version) {
+ if (preg_match('/^v?(\d+)\./', $version, $matches)) {
+ $major = (int) $matches[1];
+ if ($major > $highestMajor) {
+ $highestMajor = $major;
+ }
+ }
+ }
+
+ // If there's a higher major version available, record it
+ if ($highestMajor > $currentMajor) {
+ $this->majorVersionUpdates[] = [
+ 'repository' => $repository,
+ 'current' => $currentTag,
+ 'current_major' => $currentMajor,
+ 'available_major' => $highestMajor,
+ 'registry_url' => $this->getRegistryUrl($repository.':'.$currentTag),
+ ];
+ }
+ }
+
+ protected function displayStats(): void
+ {
+ $this->info('Summary:');
+ $this->table(
+ ['Metric', 'Count'],
+ [
+ ['Total Templates', $this->stats['total']],
+ ['Updated', $this->stats['updated']],
+ ['Skipped (up to date)', $this->stats['skipped']],
+ ['Failed', $this->stats['failed']],
+ ]
+ );
+
+ // Display major version updates if any
+ if (! empty($this->majorVersionUpdates)) {
+ $this->newLine();
+ $this->warn('⚠ Services with available MAJOR version updates:');
+ $this->newLine();
+
+ $tableData = [];
+ foreach ($this->majorVersionUpdates as $update) {
+ $tableData[] = [
+ $update['repository'],
+ "v{$update['current_major']}.x",
+ "v{$update['available_major']}.x",
+ $update['registry_url'],
+ ];
+ }
+
+ $this->table(
+ ['Repository', 'Current', 'Available', 'Registry URL'],
+ $tableData
+ );
+
+ $this->newLine();
+ $this->comment('💡 Major version updates may include breaking changes. Review before upgrading.');
+ }
+ }
+}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index 73690a05b..0d38b7363 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -1619,6 +1619,18 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
}
$destination = $destinations->first();
+ if ($destinations->count() > 1 && $request->has('destination_uuid')) {
+ $destination = $destinations->where('uuid', $request->destination_uuid)->first();
+ if (! $destination) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => [
+ 'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.',
+ ],
+ ], 422);
+ }
+ }
+
if ($request->has('public_port') && $request->is_public) {
if (isPublicPortAlreadyUsed($server, $request->public_port)) {
return response()->json(['message' => 'Public port already used by another database.'], 400);
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 971c1d806..a240a759a 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -1780,9 +1780,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function prepare_builder_image(bool $firstTry = true)
{
$this->checkForCancellation();
- $settings = instanceSettings();
$helperImage = config('constants.coolify.helper_image');
- $helperImage = "{$helperImage}:{$settings->helper_version}";
+ $helperImage = "{$helperImage}:".getHelperVersion();
// Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
@@ -2322,8 +2321,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->application->parseHealthcheckFromDockerfile($this->saved_outputs->get('dockerfile_from_repo'));
}
$custom_network_aliases = [];
- if (is_array($this->application->custom_network_aliases) && count($this->application->custom_network_aliases) > 0) {
- $custom_network_aliases = $this->application->custom_network_aliases;
+ if (! empty($this->application->custom_network_aliases_array)) {
+ $custom_network_aliases = $this->application->custom_network_aliases_array;
}
$docker_compose = [
'services' => [
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 11da6fac1..45586f0d0 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -653,9 +653,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
private function getFullImageName(): string
{
- $settings = instanceSettings();
$helperImage = config('constants.coolify.helper_image');
- $latestVersion = $settings->helper_version;
+ $latestVersion = getHelperVersion();
return "{$helperImage}:{$latestVersion}";
}
diff --git a/app/Jobs/PullHelperImageJob.php b/app/Jobs/PullHelperImageJob.php
index b92886d38..7cdf1b81a 100644
--- a/app/Jobs/PullHelperImageJob.php
+++ b/app/Jobs/PullHelperImageJob.php
@@ -24,7 +24,7 @@ class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void
{
$helperImage = config('constants.coolify.helper_image');
- $latest_version = instanceSettings()->helper_version;
+ $latest_version = getHelperVersion();
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
}
}
diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php
index f05b3c0ca..cfef30772 100644
--- a/app/Livewire/Security/CloudProviderTokens.php
+++ b/app/Livewire/Security/CloudProviderTokens.php
@@ -30,6 +30,60 @@ class CloudProviderTokens extends Component
$this->tokens = CloudProviderToken::ownedByCurrentTeam()->get();
}
+ public function validateToken(int $tokenId)
+ {
+ try {
+ $token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId);
+ $this->authorize('view', $token);
+
+ if ($token->provider === 'hetzner') {
+ $isValid = $this->validateHetznerToken($token->token);
+ if ($isValid) {
+ $this->dispatch('success', 'Hetzner token is valid.');
+ } else {
+ $this->dispatch('error', 'Hetzner token validation failed. Please check the token.');
+ }
+ } elseif ($token->provider === 'digitalocean') {
+ $isValid = $this->validateDigitalOceanToken($token->token);
+ if ($isValid) {
+ $this->dispatch('success', 'DigitalOcean token is valid.');
+ } else {
+ $this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.');
+ }
+ } else {
+ $this->dispatch('error', 'Unknown provider.');
+ }
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ private function validateHetznerToken(string $token): bool
+ {
+ try {
+ $response = \Illuminate\Support\Facades\Http::withToken($token)
+ ->timeout(10)
+ ->get('https://api.hetzner.cloud/v1/servers?per_page=1');
+
+ return $response->successful();
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+
+ private function validateDigitalOceanToken(string $token): bool
+ {
+ try {
+ $response = \Illuminate\Support\Facades\Http::withToken($token)
+ ->timeout(10)
+ ->get('https://api.digitalocean.com/v2/account');
+
+ return $response->successful();
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+
public function deleteToken(int $tokenId)
{
try {
diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php
index 7a9b58b70..f7d12dbc1 100644
--- a/app/Livewire/Server/New/ByHetzner.php
+++ b/app/Livewire/Server/New/ByHetzner.php
@@ -561,7 +561,12 @@ class ByHetzner extends Component
$server->save();
if ($this->from_onboarding) {
- // When in onboarding, use wire:navigate for proper modal handling
+ // Complete the boarding when server is successfully created via Hetzner
+ currentTeam()->update([
+ 'show_boarding' => false,
+ ]);
+ refreshSession();
+
return $this->redirect(route('server.show', $server->uuid));
}
diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php
index 13d690352..96f13b173 100644
--- a/app/Livewire/Settings/Index.php
+++ b/app/Livewire/Settings/Index.php
@@ -35,6 +35,9 @@ class Index extends Component
#[Validate('required|string|timezone')]
public string $instance_timezone;
+ #[Validate('nullable|string|max:50')]
+ public ?string $dev_helper_version = null;
+
public array $domainConflicts = [];
public bool $showDomainConflictModal = false;
@@ -60,6 +63,7 @@ class Index extends Component
$this->public_ipv4 = $this->settings->public_ipv4;
$this->public_ipv6 = $this->settings->public_ipv6;
$this->instance_timezone = $this->settings->instance_timezone;
+ $this->dev_helper_version = $this->settings->dev_helper_version;
}
#[Computed]
@@ -81,6 +85,7 @@ class Index extends Component
$this->settings->public_ipv4 = $this->public_ipv4;
$this->settings->public_ipv6 = $this->public_ipv6;
$this->settings->instance_timezone = $this->instance_timezone;
+ $this->settings->dev_helper_version = $this->dev_helper_version;
if ($isSave) {
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 32459f752..615e35f68 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -120,7 +120,6 @@ class Application extends BaseModel
protected $appends = ['server_status'];
protected $casts = [
- 'custom_network_aliases' => 'array',
'http_basic_auth_password' => 'encrypted',
];
@@ -253,6 +252,30 @@ class Application extends BaseModel
return null;
}
+ if (is_string($value) && $this->isJson($value)) {
+ $decoded = json_decode($value, true);
+
+ // Return as comma-separated string, not array
+ return is_array($decoded) ? implode(',', $decoded) : $value;
+ }
+
+ return $value;
+ }
+ );
+ }
+
+ /**
+ * Get custom_network_aliases as an array
+ */
+ public function customNetworkAliasesArray(): Attribute
+ {
+ return Attribute::make(
+ get: function () {
+ $value = $this->getRawOriginal('custom_network_aliases');
+ if (is_null($value)) {
+ return null;
+ }
+
if (is_string($value) && $this->isJson($value)) {
return json_decode($value, true);
}
@@ -957,7 +980,7 @@ class Application extends BaseModel
public function isConfigurationChanged(bool $save = false)
{
- $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels.$this->settings->use_build_secrets);
+ $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
} else {
diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php
index 0d643306c..ab82c9a9c 100644
--- a/app/Models/GithubApp.php
+++ b/app/Models/GithubApp.php
@@ -28,7 +28,20 @@ class GithubApp extends BaseModel
if ($applications_count > 0) {
throw new \Exception('You cannot delete this GitHub App because it is in use by '.$applications_count.' application(s). Delete them first.');
}
- $github_app->privateKey()->delete();
+
+ $privateKey = $github_app->privateKey;
+ if ($privateKey) {
+ // Check if key is used by anything EXCEPT this GitHub app
+ $isUsedElsewhere = $privateKey->servers()->exists()
+ || $privateKey->applications()->exists()
+ || $privateKey->githubApps()->where('id', '!=', $github_app->id)->exists()
+ || $privateKey->gitlabApps()->exists();
+
+ if (! $isUsedElsewhere) {
+ $privateKey->delete();
+ } else {
+ }
+ }
});
}
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index 5d0f9a2a7..488653fb1 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -97,6 +97,7 @@ function sharedDataApplications()
'start_command' => 'string|nullable',
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
+ 'custom_network_aliases' => 'string|nullable',
'base_directory' => 'string|nullable',
'publish_directory' => 'string|nullable',
'health_check_enabled' => 'boolean',
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 0f5b6f553..effde712a 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -2879,6 +2879,18 @@ function instanceSettings()
return InstanceSettings::get();
}
+function getHelperVersion(): string
+{
+ $settings = instanceSettings();
+
+ // In development mode, use the dev_helper_version if set, otherwise fallback to config
+ if (isDev() && ! empty($settings->dev_helper_version)) {
+ return $settings->dev_helper_version;
+ }
+
+ return config('constants.coolify.helper_version');
+}
+
function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id)
{
$server = Server::find($server_id)->where('team_id', $team_id)->first();
diff --git a/config/constants.php b/config/constants.php
index 813594e61..503fe3808 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,7 +2,7 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.438',
+ 'version' => '4.0.0-beta.439',
'helper_version' => '1.0.11',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
diff --git a/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php b/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php
new file mode 100644
index 000000000..56ed2239a
--- /dev/null
+++ b/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php
@@ -0,0 +1,28 @@
+string('dev_helper_version')->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('instance_settings', function (Blueprint $table) {
+ $table->dropColumn('dev_helper_version');
+ });
+ }
+};
diff --git a/gcool.json b/gcool.json
new file mode 100644
index 000000000..629d8569a
--- /dev/null
+++ b/gcool.json
@@ -0,0 +1,6 @@
+{
+ "scripts": {
+ "onWorktreeCreate": "cp $GCOOL_ROOT_PATH/.env .",
+ "run": "docker rm -f coolify coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite && spin up"
+ }
+}
diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php
index 94a187ad8..bb5dcfc4d 100644
--- a/resources/views/livewire/project/database/backup-edit.blade.php
+++ b/resources/views/livewire/project/database/backup-edit.blade.php
@@ -106,7 +106,7 @@
min="0"
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." />
@@ -122,7 +122,7 @@
min="0"
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." />
diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php
index 8d2ad665d..9e2af21cc 100644
--- a/resources/views/livewire/project/new/select.blade.php
+++ b/resources/views/livewire/project/new/select.blade.php
@@ -12,7 +12,7 @@
Deploy resources, like Applications, Databases, Services...
@if ($current_step === 'type')
-
+
-
+
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
Create an API token in the
{{ ucfirst($provider) }} Console → choose
+ href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : '#' }}'
+ target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console → choose
Project → Security → API Tokens.
@if ($provider === 'hetzner')
@@ -28,7 +29,7 @@
class='underline dark:text-white'>Sign up here
(Coolify's affiliate link, only new accounts - supports us (€10)
- and gives you €20)
+ and gives you €20)
@endif
@endif
@@ -49,7 +50,8 @@
-
+
@if (auth()->user()->currentTeam()->cloudProviderTokens->where('provider', $provider)->isEmpty())
Create an API token in the
Sign up here
(Coolify's affiliate link, only new accounts - supports us (€10)
- and gives you €20)
+ and gives you €20)
@endif
diff --git a/resources/views/livewire/security/cloud-provider-tokens.blade.php b/resources/views/livewire/security/cloud-provider-tokens.blade.php
index b3239c4a8..32a2cd2ab 100644
--- a/resources/views/livewire/security/cloud-provider-tokens.blade.php
+++ b/resources/views/livewire/security/cloud-provider-tokens.blade.php
@@ -20,16 +20,24 @@
Created: {{ $savedToken->created_at->diffForHumans() }}
- @can('delete', $savedToken)
-
- @endcan
+
+ @can('view', $savedToken)
+
+ Validate Token
+
+ @endcan
+
+ @can('delete', $savedToken)
+
+ @endcan
+
@empty
diff --git a/resources/views/livewire/settings-email.blade.php b/resources/views/livewire/settings-email.blade.php
index 81cbcd09c..c58ea189d 100644
--- a/resources/views/livewire/settings-email.blade.php
+++ b/resources/views/livewire/settings-email.blade.php
@@ -42,7 +42,7 @@
-
+
StartTLS
TLS/SSL
diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php
index 61a73d25c..4ceb2043a 100644
--- a/resources/views/livewire/settings/index.blade.php
+++ b/resources/views/livewire/settings/index.blade.php
@@ -76,6 +76,13 @@
helper="Enter the IPv6 address of the instance. It is useful if you have several IPv6 addresses and Coolify could not detect the correct one."
placeholder="2001:db8::1" autocomplete="new-password" />
+ @if(isDev())
+
+
+
+ @endif
diff --git a/templates/compose/activepieces.yaml b/templates/compose/activepieces.yaml
index e9156336e..b5fc39daf 100644
--- a/templates/compose/activepieces.yaml
+++ b/templates/compose/activepieces.yaml
@@ -7,7 +7,7 @@
services:
activepieces:
- image: "ghcr.io/activepieces/activepieces:latest"
+ image: "ghcr.io/activepieces/activepieces:0.21.0" # Released on March 13 2024
environment:
- SERVICE_URL_ACTIVEPIECES
- AP_API_KEY=$SERVICE_PASSWORD_64_APIKEY
@@ -40,7 +40,7 @@ services:
timeout: 20s
retries: 10
postgres:
- image: "postgres:latest"
+ image: 'postgres:14.4'
environment:
- POSTGRES_DB=${POSTGRES_DB:-activepieces}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
@@ -54,7 +54,7 @@ services:
timeout: 20s
retries: 10
redis:
- image: "redis:latest"
+ image: 'redis:7.0.7'
volumes:
- "redis_data:/data"
healthcheck:
diff --git a/templates/compose/beszel.yaml b/templates/compose/beszel.yaml
index 45b57a91b..153deaf8f 100644
--- a/templates/compose/beszel.yaml
+++ b/templates/compose/beszel.yaml
@@ -9,14 +9,14 @@
# Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI)
services:
beszel:
- image: 'henrygd/beszel:0.12.10'
+ image: 'henrygd/beszel:0.15.2' # Released on October 30 2025
environment:
- SERVICE_URL_BESZEL_8090
volumes:
- 'beszel_data:/beszel_data'
- 'beszel_socket:/beszel_socket'
beszel-agent:
- image: 'henrygd/beszel-agent:0.12.10'
+ image: 'henrygd/beszel-agent:0.15.2' # Released on October 30 2025
volumes:
- beszel_agent_data:/var/lib/beszel-agent
- beszel_socket:/beszel_socket
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index dfabce600..4d365b483 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -2,7 +2,7 @@
"activepieces": {
"documentation": "https://www.activepieces.com/docs/getting-started/introduction?utm_source=coolify.io",
"slogan": "Open source no-code business automation.",
- "compose": "c2VydmljZXM6CiAgYWN0aXZlcGllY2VzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2FjdGl2ZXBpZWNlcy9hY3RpdmVwaWVjZXM6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQUNUSVZFUElFQ0VTCiAgICAgIC0gQVBfQVBJX0tFWT0kU0VSVklDRV9QQVNTV09SRF82NF9BUElLRVkKICAgICAgLSBBUF9FTkNSWVBUSU9OX0tFWT0kU0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OS0VZCiAgICAgIC0gJ0FQX0VOR0lORV9FWEVDVVRBQkxFX1BBVEg9JHtBUF9FTkdJTkVfRVhFQ1VUQUJMRV9QQVRIOi1kaXN0L3BhY2thZ2VzL2VuZ2luZS9tYWluLmpzfScKICAgICAgLSAnQVBfRU5WSVJPTk1FTlQ9JHtBUF9FTlZJUk9OTUVOVDotcHJvZH0nCiAgICAgIC0gJ0FQX0VYRUNVVElPTl9NT0RFPSR7QVBfRVhFQ1VUSU9OX01PREU6LVVOU0FOREJPWEVEfScKICAgICAgLSAnQVBfRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9VUkxfQUNUSVZFUElFQ0VTfScKICAgICAgLSBBUF9KV1RfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0pXVAogICAgICAtICdBUF9QT1NUR1JFU19EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1hY3RpdmVwaWVjZXN9JwogICAgICAtICdBUF9QT1NUR1JFU19IT1NUPSR7UE9TVEdSRVNfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdBUF9QT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdBUF9QT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gQVBfUE9TVEdSRVNfVVNFUk5BTUU9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtICdBUF9SRURJU19IT1NUPSR7UkVESVNfSE9TVDotcmVkaXN9JwogICAgICAtICdBUF9SRURJU19QT1JUPSR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ0FQX1NBTkRCT1hfUlVOX1RJTUVfU0VDT05EUz0ke0FQX1NBTkRCT1hfUlVOX1RJTUVfU0VDT05EUzotNjAwfScKICAgICAgLSAnQVBfVEVMRU1FVFJZX0VOQUJMRUQ9JHtBUF9URUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdBUF9URU1QTEFURVNfU09VUkNFX1VSTD0ke0FQX1RFTVBMQVRFU19TT1VSQ0VfVVJMOi1odHRwczovL2Nsb3VkLmFjdGl2ZXBpZWNlcy5jb20vYXBpL3YxL2Zsb3ctdGVtcGxhdGVzfScKICAgICAgLSAnQVBfVFJJR0dFUl9ERUZBVUxUX1BPTExfSU5URVJWQUw9JHtBUF9UUklHR0VSX0RFRkFVTFRfUE9MTF9JTlRFUlZBTDotNX0nCiAgICAgIC0gJ0FQX1dFQkhPT0tfVElNRU9VVF9TRUNPTkRTPSR7QVBfV0VCSE9PS19USU1FT1VUX1NFQ09ORFM6LTMwfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWFjdGl2ZXBpZWNlc30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwZy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
+ "compose": "c2VydmljZXM6CiAgYWN0aXZlcGllY2VzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2FjdGl2ZXBpZWNlcy9hY3RpdmVwaWVjZXM6MC4yMS4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQUNUSVZFUElFQ0VTCiAgICAgIC0gQVBfQVBJX0tFWT0kU0VSVklDRV9QQVNTV09SRF82NF9BUElLRVkKICAgICAgLSBBUF9FTkNSWVBUSU9OX0tFWT0kU0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OS0VZCiAgICAgIC0gJ0FQX0VOR0lORV9FWEVDVVRBQkxFX1BBVEg9JHtBUF9FTkdJTkVfRVhFQ1VUQUJMRV9QQVRIOi1kaXN0L3BhY2thZ2VzL2VuZ2luZS9tYWluLmpzfScKICAgICAgLSAnQVBfRU5WSVJPTk1FTlQ9JHtBUF9FTlZJUk9OTUVOVDotcHJvZH0nCiAgICAgIC0gJ0FQX0VYRUNVVElPTl9NT0RFPSR7QVBfRVhFQ1VUSU9OX01PREU6LVVOU0FOREJPWEVEfScKICAgICAgLSAnQVBfRlJPTlRFTkRfVVJMPSR7U0VSVklDRV9VUkxfQUNUSVZFUElFQ0VTfScKICAgICAgLSBBUF9KV1RfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0pXVAogICAgICAtICdBUF9QT1NUR1JFU19EQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1hY3RpdmVwaWVjZXN9JwogICAgICAtICdBUF9QT1NUR1JFU19IT1NUPSR7UE9TVEdSRVNfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdBUF9QT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdBUF9QT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gQVBfUE9TVEdSRVNfVVNFUk5BTUU9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtICdBUF9SRURJU19IT1NUPSR7UkVESVNfSE9TVDotcmVkaXN9JwogICAgICAtICdBUF9SRURJU19QT1JUPSR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ0FQX1NBTkRCT1hfUlVOX1RJTUVfU0VDT05EUz0ke0FQX1NBTkRCT1hfUlVOX1RJTUVfU0VDT05EUzotNjAwfScKICAgICAgLSAnQVBfVEVMRU1FVFJZX0VOQUJMRUQ9JHtBUF9URUxFTUVUUllfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdBUF9URU1QTEFURVNfU09VUkNFX1VSTD0ke0FQX1RFTVBMQVRFU19TT1VSQ0VfVVJMOi1odHRwczovL2Nsb3VkLmFjdGl2ZXBpZWNlcy5jb20vYXBpL3YxL2Zsb3ctdGVtcGxhdGVzfScKICAgICAgLSAnQVBfVFJJR0dFUl9ERUZBVUxUX1BPTExfSU5URVJWQUw9JHtBUF9UUklHR0VSX0RFRkFVTFRfUE9MTF9JTlRFUlZBTDotNX0nCiAgICAgIC0gJ0FQX1dFQkhPT0tfVElNRU9VVF9TRUNPTkRTPSR7QVBfV0VCSE9PS19USU1FT1VUX1NFQ09ORFM6LTMwfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1hY3RpdmVwaWVjZXN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUE9SVD0ke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncGctZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMC43JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
"tags": [
"workflow",
"automation",
@@ -189,7 +189,7 @@
"beszel": {
"documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io",
"slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.",
- "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjEyLjEwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQkVTWkVMXzgwOTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTIuMTAnCiAgICB2b2x1bWVzOgogICAgICAtICdiZXN6ZWxfYWdlbnRfZGF0YTovdmFyL2xpYi9iZXN6ZWwtYWdlbnQnCiAgICAgIC0gJ2Jlc3plbF9zb2NrZXQ6L2Jlc3plbF9zb2NrZXQnCiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrOnJvJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gJ0hVQl9VUkw9aHR0cDovL2Jlc3plbDo4MDkwJwogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScK",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE1LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xNS4yJwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtICdIVUJfVVJMPWh0dHA6Ly9iZXN6ZWw6ODA5MCcKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCg==",
"tags": [
"beszel",
"monitoring",
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 3d49b1620..d711b9d95 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -2,7 +2,7 @@
"activepieces": {
"documentation": "https://www.activepieces.com/docs/getting-started/introduction?utm_source=coolify.io",
"slogan": "Open source no-code business automation.",
- "compose": "c2VydmljZXM6CiAgYWN0aXZlcGllY2VzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2FjdGl2ZXBpZWNlcy9hY3RpdmVwaWVjZXM6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FDVElWRVBJRUNFUwogICAgICAtIEFQX0FQSV9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBJS0VZCiAgICAgIC0gQVBfRU5DUllQVElPTl9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTktFWQogICAgICAtICdBUF9FTkdJTkVfRVhFQ1VUQUJMRV9QQVRIPSR7QVBfRU5HSU5FX0VYRUNVVEFCTEVfUEFUSDotZGlzdC9wYWNrYWdlcy9lbmdpbmUvbWFpbi5qc30nCiAgICAgIC0gJ0FQX0VOVklST05NRU5UPSR7QVBfRU5WSVJPTk1FTlQ6LXByb2R9JwogICAgICAtICdBUF9FWEVDVVRJT05fTU9ERT0ke0FQX0VYRUNVVElPTl9NT0RFOi1VTlNBTkRCT1hFRH0nCiAgICAgIC0gJ0FQX0ZST05URU5EX1VSTD0ke1NFUlZJQ0VfRlFETl9BQ1RJVkVQSUVDRVN9JwogICAgICAtIEFQX0pXVF9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfSldUCiAgICAgIC0gJ0FQX1BPU1RHUkVTX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LWFjdGl2ZXBpZWNlc30nCiAgICAgIC0gJ0FQX1BPU1RHUkVTX0hPU1Q9JHtQT1NUR1JFU19IT1NUOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0FQX1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0FQX1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSBBUF9QT1NUR1JFU19VU0VSTkFNRT0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gJ0FQX1JFRElTX0hPU1Q9JHtSRURJU19IT1NUOi1yZWRpc30nCiAgICAgIC0gJ0FQX1JFRElTX1BPUlQ9JHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnQVBfU0FOREJPWF9SVU5fVElNRV9TRUNPTkRTPSR7QVBfU0FOREJPWF9SVU5fVElNRV9TRUNPTkRTOi02MDB9JwogICAgICAtICdBUF9URUxFTUVUUllfRU5BQkxFRD0ke0FQX1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ0FQX1RFTVBMQVRFU19TT1VSQ0VfVVJMPSR7QVBfVEVNUExBVEVTX1NPVVJDRV9VUkw6LWh0dHBzOi8vY2xvdWQuYWN0aXZlcGllY2VzLmNvbS9hcGkvdjEvZmxvdy10ZW1wbGF0ZXN9JwogICAgICAtICdBUF9UUklHR0VSX0RFRkFVTFRfUE9MTF9JTlRFUlZBTD0ke0FQX1RSSUdHRVJfREVGQVVMVF9QT0xMX0lOVEVSVkFMOi01fScKICAgICAgLSAnQVBfV0VCSE9PS19USU1FT1VUX1NFQ09ORFM9JHtBUF9XRUJIT09LX1RJTUVPVVRfU0VDT05EUzotMzB9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX3N0YXJ0ZWQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotYWN0aXZlcGllY2VzfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3BnLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
+ "compose": "c2VydmljZXM6CiAgYWN0aXZlcGllY2VzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2FjdGl2ZXBpZWNlcy9hY3RpdmVwaWVjZXM6MC4yMS4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FDVElWRVBJRUNFUwogICAgICAtIEFQX0FQSV9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBJS0VZCiAgICAgIC0gQVBfRU5DUllQVElPTl9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTktFWQogICAgICAtICdBUF9FTkdJTkVfRVhFQ1VUQUJMRV9QQVRIPSR7QVBfRU5HSU5FX0VYRUNVVEFCTEVfUEFUSDotZGlzdC9wYWNrYWdlcy9lbmdpbmUvbWFpbi5qc30nCiAgICAgIC0gJ0FQX0VOVklST05NRU5UPSR7QVBfRU5WSVJPTk1FTlQ6LXByb2R9JwogICAgICAtICdBUF9FWEVDVVRJT05fTU9ERT0ke0FQX0VYRUNVVElPTl9NT0RFOi1VTlNBTkRCT1hFRH0nCiAgICAgIC0gJ0FQX0ZST05URU5EX1VSTD0ke1NFUlZJQ0VfRlFETl9BQ1RJVkVQSUVDRVN9JwogICAgICAtIEFQX0pXVF9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfSldUCiAgICAgIC0gJ0FQX1BPU1RHUkVTX0RBVEFCQVNFPSR7UE9TVEdSRVNfREI6LWFjdGl2ZXBpZWNlc30nCiAgICAgIC0gJ0FQX1BPU1RHUkVTX0hPU1Q9JHtQT1NUR1JFU19IT1NUOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0FQX1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0FQX1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSBBUF9QT1NUR1JFU19VU0VSTkFNRT0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gJ0FQX1JFRElTX0hPU1Q9JHtSRURJU19IT1NUOi1yZWRpc30nCiAgICAgIC0gJ0FQX1JFRElTX1BPUlQ9JHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnQVBfU0FOREJPWF9SVU5fVElNRV9TRUNPTkRTPSR7QVBfU0FOREJPWF9SVU5fVElNRV9TRUNPTkRTOi02MDB9JwogICAgICAtICdBUF9URUxFTUVUUllfRU5BQkxFRD0ke0FQX1RFTEVNRVRSWV9FTkFCTEVEOi1mYWxzZX0nCiAgICAgIC0gJ0FQX1RFTVBMQVRFU19TT1VSQ0VfVVJMPSR7QVBfVEVNUExBVEVTX1NPVVJDRV9VUkw6LWh0dHBzOi8vY2xvdWQuYWN0aXZlcGllY2VzLmNvbS9hcGkvdjEvZmxvdy10ZW1wbGF0ZXN9JwogICAgICAtICdBUF9UUklHR0VSX0RFRkFVTFRfUE9MTF9JTlRFUlZBTD0ke0FQX1RSSUdHRVJfREVGQVVMVF9QT0xMX0lOVEVSVkFMOi01fScKICAgICAgLSAnQVBfV0VCSE9PS19USU1FT1VUX1NFQ09ORFM9JHtBUF9XRUJIT09LX1RJTUVPVVRfU0VDT05EUzotMzB9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX3N0YXJ0ZWQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNC40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWFjdGl2ZXBpZWNlc30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwZy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny4wLjcnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"workflow",
"automation",
@@ -189,7 +189,7 @@
"beszel": {
"documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io",
"slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.",
- "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjEyLjEwJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0JFU1pFTF84MDkwCiAgICB2b2x1bWVzOgogICAgICAtICdiZXN6ZWxfZGF0YTovYmVzemVsX2RhdGEnCiAgICAgIC0gJ2Jlc3plbF9zb2NrZXQ6L2Jlc3plbF9zb2NrZXQnCiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjEyLjEwJwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtICdIVUJfVVJMPWh0dHA6Ly9iZXN6ZWw6ODA5MCcKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCg==",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE1LjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTUuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSAnSFVCX1VSTD1odHRwOi8vYmVzemVsOjgwOTAnCiAgICAgIC0gJ1RPS0VOPSR7VE9LRU59JwogICAgICAtICdLRVk9JHtLRVl9Jwo=",
"tags": [
"beszel",
"monitoring",
diff --git a/tests/Feature/HetznerServerCreationTest.php b/tests/Feature/HetznerServerCreationTest.php
index c939c0041..8f1a13d7a 100644
--- a/tests/Feature/HetznerServerCreationTest.php
+++ b/tests/Feature/HetznerServerCreationTest.php
@@ -1,5 +1,11 @@
toBe([123, 456, 789])
->and(count($sshKeys))->toBe(3);
});
+
+describe('Boarding Flow Integration', function () {
+ uses(RefreshDatabase::class);
+
+ beforeEach(function () {
+ // Create a team with owner that has boarding enabled
+ $this->team = Team::factory()->create([
+ 'show_boarding' => true,
+ ]);
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ // Set current team and act as user
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+ });
+
+ test('completes boarding when server is created from onboarding', function () {
+ // Verify boarding is initially enabled
+ expect($this->team->fresh()->show_boarding)->toBeTrue();
+
+ // Mount the component with from_onboarding flag
+ $component = Livewire::test(ByHetzner::class)
+ ->set('from_onboarding', true);
+
+ // Verify the from_onboarding property is set
+ expect($component->get('from_onboarding'))->toBeTrue();
+
+ // After successful server creation in the actual component,
+ // the boarding should be marked as complete
+ // Note: We can't fully test the createServer method without mocking Hetzner API
+ // but we can verify the boarding completion logic is in place
+ });
+
+ test('boarding flag remains unchanged when not from onboarding', function () {
+ // Verify boarding is initially enabled
+ expect($this->team->fresh()->show_boarding)->toBeTrue();
+
+ // Mount the component without from_onboarding flag (default false)
+ Livewire::test(ByHetzner::class)
+ ->set('from_onboarding', false);
+
+ // Boarding should still be enabled since it wasn't created from onboarding
+ expect($this->team->fresh()->show_boarding)->toBeTrue();
+ });
+});
diff --git a/tests/Unit/ApplicationConfigurationChangeTest.php b/tests/Unit/ApplicationConfigurationChangeTest.php
new file mode 100644
index 000000000..618f3d033
--- /dev/null
+++ b/tests/Unit/ApplicationConfigurationChangeTest.php
@@ -0,0 +1,17 @@
+not->toBe($hash2)
+ ->and($hash1)->not->toBe($hash3)
+ ->and($hash2)->not->toBe($hash3);
+});
diff --git a/tests/Unit/ApplicationNetworkAliasesSyncTest.php b/tests/Unit/ApplicationNetworkAliasesSyncTest.php
new file mode 100644
index 000000000..552ac854c
--- /dev/null
+++ b/tests/Unit/ApplicationNetworkAliasesSyncTest.php
@@ -0,0 +1,50 @@
+toBe('api.internal,api.local')
+ ->and($result)->toBeString();
+});
+
+it('handles null aliases', function () {
+ // Test that null remains null
+ $aliases = null;
+
+ if (is_array($aliases)) {
+ $result = implode(',', $aliases);
+ } else {
+ $result = $aliases;
+ }
+
+ expect($result)->toBeNull();
+});
+
+it('handles empty array aliases', function () {
+ // Test that empty array becomes empty string
+ $aliases = [];
+ $result = implode(',', $aliases);
+
+ expect($result)->toBe('')
+ ->and($result)->toBeString();
+});
+
+it('handles single alias', function () {
+ // Test that single-element array is converted correctly
+ $aliases = ['api.internal'];
+ $result = implode(',', $aliases);
+
+ expect($result)->toBe('api.internal')
+ ->and($result)->toBeString();
+});
diff --git a/todos/service-database-deployment-logging.md b/todos/service-database-deployment-logging.md
new file mode 100644
index 000000000..dd0790aec
--- /dev/null
+++ b/todos/service-database-deployment-logging.md
@@ -0,0 +1,1916 @@
+# Service & Database Deployment Logging - Implementation Plan
+
+**Status:** Planning Complete
+**Branch:** `andrasbacsai/service-db-deploy-logs`
+**Target:** Add deployment history and logging for Services and Databases (similar to Applications)
+
+---
+
+## Current State Analysis
+
+### Application Deployments (Working Model)
+
+**Model:** `ApplicationDeploymentQueue`
+- **Location:** `app/Models/ApplicationDeploymentQueue.php`
+- **Table:** `application_deployment_queues`
+- **Key Features:**
+ - Stores deployment logs as JSON in `logs` column
+ - Tracks status: queued, in_progress, finished, failed, cancelled-by-user
+ - Stores metadata: deployment_uuid, commit, pull_request_id, server info
+ - Has `addLogEntry()` method with sensitive data redaction
+ - Relationships: belongsTo Application, server attribute accessor
+
+**Job:** `ApplicationDeploymentJob`
+- **Location:** `app/Jobs/ApplicationDeploymentJob.php`
+- Handles entire deployment lifecycle
+- Uses `addLogEntry()` to stream logs to database
+- Updates status throughout deployment
+
+**Helper Function:** `queue_application_deployment()`
+- **Location:** `bootstrap/helpers/applications.php`
+- Creates deployment queue record
+- Dispatches job if ready
+- Returns deployment status and UUID
+
+**API Endpoints:**
+- `GET /api/deployments` - List all running deployments
+- `GET /api/deployments/{uuid}` - Get specific deployment
+- `GET /api/deployments/applications/{uuid}` - List app deployment history
+- Sensitive data filtering based on permissions
+
+**Migration History:**
+- `2023_05_24_083426_create_application_deployment_queues_table.php`
+- `2023_06_23_114133_use_application_deployment_queues_as_activity.php` (added logs, current_process_id)
+- `2025_01_16_110406_change_commit_message_to_text_in_application_deployment_queues.php`
+
+---
+
+### Services (Current State - No History)
+
+**Model:** `Service`
+- **Location:** `app/Models/Service.php`
+- Represents Docker Compose services with multiple applications/databases
+
+**Action:** `StartService`
+- **Location:** `app/Actions/Service/StartService.php`
+- Executes commands via `remote_process()`
+- Returns Activity log (Spatie ActivityLog) - ephemeral, not stored
+- Fires `ServiceStatusChanged` event on completion
+
+**Current Behavior:**
+```php
+public function handle(Service $service, bool $pullLatestImages, bool $stopBeforeStart)
+{
+ $service->parse();
+ // ... build commands array
+ return remote_process($commands, $service->server,
+ type_uuid: $service->uuid,
+ callEventOnFinish: 'ServiceStatusChanged');
+}
+```
+
+**Problem:** No persistent deployment history. Logs disappear after Activity TTL.
+
+---
+
+### Databases (Current State - No History)
+
+**Models:** 9 Standalone Database Types
+- `StandalonePostgresql`
+- `StandaloneRedis`
+- `StandaloneMongodb`
+- `StandaloneMysql`
+- `StandaloneMariadb`
+- `StandaloneKeydb`
+- `StandaloneDragonfly`
+- `StandaloneClickhouse`
+- (All in `app/Models/`)
+
+**Actions:** Type-Specific Start Actions
+- `StartPostgresql`, `StartRedis`, `StartMongodb`, etc.
+- **Location:** `app/Actions/Database/Start*.php`
+- Each builds docker-compose config, writes to disk, starts container
+- Uses `remote_process()` with `DatabaseStatusChanged` event
+
+**Dispatcher:** `StartDatabase`
+- **Location:** `app/Actions/Database/StartDatabase.php`
+- Routes to correct Start action based on database type
+
+**Current Behavior:**
+```php
+// StartPostgresql example
+public function handle(StandalonePostgresql $database)
+{
+ // ... build commands array
+ return remote_process($this->commands, $database->destination->server,
+ callEventOnFinish: 'DatabaseStatusChanged');
+}
+```
+
+**Problem:** No persistent deployment history. Only real-time Activity logs.
+
+---
+
+## Architectural Decisions
+
+### Why Separate Tables?
+
+**Decision:** Create `service_deployment_queues` and `database_deployment_queues` (two separate tables)
+
+**Reasoning:**
+1. **Different Attributes:**
+ - Services: multiple containers, docker-compose specific, pull_latest_images flag
+ - Databases: type-specific configs, SSL settings, init scripts
+ - Applications: git commits, pull requests, build cache
+
+2. **Query Performance:**
+ - Separate indexes per resource type
+ - No polymorphic type checks in every query
+ - Easier to optimize per-resource-type
+
+3. **Type Safety:**
+ - Explicit relationships and foreign keys (where possible)
+ - IDE autocomplete and static analysis benefits
+
+4. **Existing Pattern:**
+ - Coolify already uses separate tables: `applications`, `services`, `standalone_*`
+ - Consistent with codebase conventions
+
+**Alternative Considered:** Single `resource_deployments` polymorphic table
+- **Pros:** DRY, one model to maintain
+- **Cons:** Harder to query efficiently, less type-safe, complex indexes
+- **Decision:** Rejected in favor of clarity and performance
+
+---
+
+## Implementation Plan
+
+### Phase 1: Database Schema (3 migrations)
+
+#### Migration 1: Create `service_deployment_queues`
+
+**File:** `database/migrations/YYYY_MM_DD_HHMMSS_create_service_deployment_queues_table.php`
+
+```php
+Schema::create('service_deployment_queues', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('service_id')->constrained()->onDelete('cascade');
+ $table->string('deployment_uuid')->unique();
+ $table->string('status')->default('queued'); // queued, in_progress, finished, failed, cancelled-by-user
+ $table->text('logs')->nullable(); // JSON array like ApplicationDeploymentQueue
+ $table->string('current_process_id')->nullable(); // For tracking background processes
+ $table->boolean('pull_latest_images')->default(false);
+ $table->boolean('stop_before_start')->default(false);
+ $table->boolean('is_api')->default(false); // Triggered via API vs UI
+ $table->string('server_id'); // Denormalized for performance
+ $table->string('server_name'); // Denormalized for display
+ $table->string('service_name'); // Denormalized for display
+ $table->string('deployment_url')->nullable(); // URL to view deployment
+ $table->timestamps();
+
+ // Indexes for common queries
+ $table->index(['service_id', 'status']);
+ $table->index('deployment_uuid');
+ $table->index('created_at');
+});
+```
+
+**Key Design Choices:**
+- `logs` as TEXT (JSON) - Same pattern as ApplicationDeploymentQueue
+- Denormalized server/service names for API responses without joins
+- `deployment_url` for direct link generation
+- Composite indexes for filtering by service + status
+
+---
+
+#### Migration 2: Create `database_deployment_queues`
+
+**File:** `database/migrations/YYYY_MM_DD_HHMMSS_create_database_deployment_queues_table.php`
+
+```php
+Schema::create('database_deployment_queues', function (Blueprint $table) {
+ $table->id();
+ $table->string('database_id'); // String to support polymorphic relationship
+ $table->string('database_type'); // StandalonePostgresql, StandaloneRedis, etc.
+ $table->string('deployment_uuid')->unique();
+ $table->string('status')->default('queued');
+ $table->text('logs')->nullable();
+ $table->string('current_process_id')->nullable();
+ $table->boolean('is_api')->default(false);
+ $table->string('server_id');
+ $table->string('server_name');
+ $table->string('database_name');
+ $table->string('deployment_url')->nullable();
+ $table->timestamps();
+
+ // Indexes for polymorphic relationship and queries
+ $table->index(['database_id', 'database_type']);
+ $table->index(['database_id', 'database_type', 'status']);
+ $table->index('deployment_uuid');
+ $table->index('created_at');
+});
+```
+
+**Key Design Choices:**
+- Polymorphic relationship using `database_id` + `database_type`
+- Can't use foreignId constraint due to multiple target tables
+- Composite index on polymorphic keys for efficient queries
+
+---
+
+#### Migration 3: Add Performance Indexes
+
+**File:** `database/migrations/YYYY_MM_DD_HHMMSS_add_deployment_queue_indexes.php`
+
+```php
+Schema::table('service_deployment_queues', function (Blueprint $table) {
+ $table->index(['server_id', 'status', 'created_at'], 'service_deployments_server_status_time');
+});
+
+Schema::table('database_deployment_queues', function (Blueprint $table) {
+ $table->index(['server_id', 'status', 'created_at'], 'database_deployments_server_status_time');
+});
+```
+
+**Purpose:** Optimize queries like "all in-progress deployments on this server, newest first"
+
+---
+
+### Phase 2: Eloquent Models (2 new models)
+
+#### Model 1: ServiceDeploymentQueue
+
+**File:** `app/Models/ServiceDeploymentQueue.php`
+
+```php
+ ['type' => 'integer'],
+ 'service_id' => ['type' => 'integer'],
+ 'deployment_uuid' => ['type' => 'string'],
+ 'status' => ['type' => 'string'],
+ 'pull_latest_images' => ['type' => 'boolean'],
+ 'stop_before_start' => ['type' => 'boolean'],
+ 'is_api' => ['type' => 'boolean'],
+ 'logs' => ['type' => 'string'],
+ 'current_process_id' => ['type' => 'string'],
+ 'server_id' => ['type' => 'string'],
+ 'server_name' => ['type' => 'string'],
+ 'service_name' => ['type' => 'string'],
+ 'deployment_url' => ['type' => 'string'],
+ 'created_at' => ['type' => 'string'],
+ 'updated_at' => ['type' => 'string'],
+ ],
+)]
+class ServiceDeploymentQueue extends Model
+{
+ protected $guarded = [];
+
+ public function service()
+ {
+ return $this->belongsTo(Service::class);
+ }
+
+ public function server(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => Server::find($this->server_id),
+ );
+ }
+
+ public function setStatus(string $status)
+ {
+ $this->update(['status' => $status]);
+ }
+
+ public function getOutput($name)
+ {
+ if (!$this->logs) {
+ return null;
+ }
+ return collect(json_decode($this->logs))->where('name', $name)->first()?->output ?? null;
+ }
+
+ private function redactSensitiveInfo($text)
+ {
+ $text = remove_iip($text); // Remove internal IPs
+
+ $service = $this->service;
+ if (!$service) {
+ return $text;
+ }
+
+ // Redact environment variables marked as sensitive
+ $lockedVars = collect([]);
+ if ($service->environment_variables) {
+ $lockedVars = $service->environment_variables
+ ->where('is_shown_once', true)
+ ->pluck('real_value', 'key')
+ ->filter();
+ }
+
+ foreach ($lockedVars as $key => $value) {
+ $escapedValue = preg_quote($value, '/');
+ $text = preg_replace('/' . $escapedValue . '/', REDACTED, $text);
+ }
+
+ return $text;
+ }
+
+ public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false)
+ {
+ if ($type === 'error') {
+ $type = 'stderr';
+ }
+
+ $message = str($message)->trim();
+ if ($message->startsWith('╔')) {
+ $message = "\n" . $message;
+ }
+
+ $newLogEntry = [
+ 'command' => null,
+ 'output' => $this->redactSensitiveInfo($message),
+ 'type' => $type,
+ 'timestamp' => Carbon::now('UTC'),
+ 'hidden' => $hidden,
+ 'batch' => 1,
+ ];
+
+ // Use transaction for atomicity
+ DB::transaction(function () use ($newLogEntry) {
+ $this->refresh();
+
+ if ($this->logs) {
+ $previousLogs = json_decode($this->logs, associative: true, flags: JSON_THROW_ON_ERROR);
+ $newLogEntry['order'] = count($previousLogs) + 1;
+ $previousLogs[] = $newLogEntry;
+ $this->logs = json_encode($previousLogs, flags: JSON_THROW_ON_ERROR);
+ } else {
+ $this->logs = json_encode([$newLogEntry], flags: JSON_THROW_ON_ERROR);
+ }
+
+ $this->saveQuietly();
+ });
+ }
+}
+```
+
+**Key Features:**
+- Exact same log structure as ApplicationDeploymentQueue
+- `addLogEntry()` with sensitive data redaction
+- Atomic log appends using DB transactions
+- OpenAPI schema for API documentation
+
+---
+
+#### Model 2: DatabaseDeploymentQueue
+
+**File:** `app/Models/DatabaseDeploymentQueue.php`
+
+```php
+ ['type' => 'integer'],
+ 'database_id' => ['type' => 'string'],
+ 'database_type' => ['type' => 'string'],
+ 'deployment_uuid' => ['type' => 'string'],
+ 'status' => ['type' => 'string'],
+ 'is_api' => ['type' => 'boolean'],
+ 'logs' => ['type' => 'string'],
+ 'current_process_id' => ['type' => 'string'],
+ 'server_id' => ['type' => 'string'],
+ 'server_name' => ['type' => 'string'],
+ 'database_name' => ['type' => 'string'],
+ 'deployment_url' => ['type' => 'string'],
+ 'created_at' => ['type' => 'string'],
+ 'updated_at' => ['type' => 'string'],
+ ],
+)]
+class DatabaseDeploymentQueue extends Model
+{
+ protected $guarded = [];
+
+ public function database()
+ {
+ return $this->morphTo('database', 'database_type', 'database_id');
+ }
+
+ public function server(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => Server::find($this->server_id),
+ );
+ }
+
+ public function setStatus(string $status)
+ {
+ $this->update(['status' => $status]);
+ }
+
+ public function getOutput($name)
+ {
+ if (!$this->logs) {
+ return null;
+ }
+ return collect(json_decode($this->logs))->where('name', $name)->first()?->output ?? null;
+ }
+
+ private function redactSensitiveInfo($text)
+ {
+ $text = remove_iip($text);
+
+ $database = $this->database;
+ if (!$database) {
+ return $text;
+ }
+
+ // Redact database-specific credentials
+ $sensitivePatterns = collect([]);
+
+ // Common database credential patterns
+ if (method_exists($database, 'getConnectionString')) {
+ $sensitivePatterns->push($database->getConnectionString());
+ }
+
+ // Postgres/MySQL passwords
+ $passwordFields = ['postgres_password', 'mysql_password', 'mariadb_password', 'mongo_password'];
+ foreach ($passwordFields as $field) {
+ if (isset($database->$field)) {
+ $sensitivePatterns->push($database->$field);
+ }
+ }
+
+ // Redact environment variables
+ if ($database->environment_variables) {
+ $lockedVars = $database->environment_variables
+ ->where('is_shown_once', true)
+ ->pluck('real_value')
+ ->filter();
+ $sensitivePatterns = $sensitivePatterns->merge($lockedVars);
+ }
+
+ foreach ($sensitivePatterns as $value) {
+ if (empty($value)) continue;
+ $escapedValue = preg_quote($value, '/');
+ $text = preg_replace('/' . $escapedValue . '/', REDACTED, $text);
+ }
+
+ return $text;
+ }
+
+ public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false)
+ {
+ if ($type === 'error') {
+ $type = 'stderr';
+ }
+
+ $message = str($message)->trim();
+ if ($message->startsWith('╔')) {
+ $message = "\n" . $message;
+ }
+
+ $newLogEntry = [
+ 'command' => null,
+ 'output' => $this->redactSensitiveInfo($message),
+ 'type' => $type,
+ 'timestamp' => Carbon::now('UTC'),
+ 'hidden' => $hidden,
+ 'batch' => 1,
+ ];
+
+ DB::transaction(function () use ($newLogEntry) {
+ $this->refresh();
+
+ if ($this->logs) {
+ $previousLogs = json_decode($this->logs, associative: true, flags: JSON_THROW_ON_ERROR);
+ $newLogEntry['order'] = count($previousLogs) + 1;
+ $previousLogs[] = $newLogEntry;
+ $this->logs = json_encode($previousLogs, flags: JSON_THROW_ON_ERROR);
+ } else {
+ $this->logs = json_encode([$newLogEntry], flags: JSON_THROW_ON_ERROR);
+ }
+
+ $this->saveQuietly();
+ });
+ }
+}
+```
+
+**Key Differences from ServiceDeploymentQueue:**
+- Polymorphic `database()` relationship
+- More extensive sensitive data redaction (database passwords, connection strings)
+- Handles all 9 database types
+
+---
+
+### Phase 3: Enums (2 new enums)
+
+#### Enum 1: ServiceDeploymentStatus
+
+**File:** `app/Enums/ServiceDeploymentStatus.php`
+
+```php
+id;
+ $server = $service->destination->server;
+ $server_id = $server->id;
+ $server_name = $server->name;
+
+ // Generate deployment URL
+ $deployment_link = Url::fromString($service->link() . "/deployment/{$deployment_uuid}");
+ $deployment_url = $deployment_link->getPath();
+
+ // Create deployment record
+ $deployment = ServiceDeploymentQueue::create([
+ 'service_id' => $service_id,
+ 'service_name' => $service->name,
+ 'server_id' => $server_id,
+ 'server_name' => $server_name,
+ 'deployment_uuid' => $deployment_uuid,
+ 'deployment_url' => $deployment_url,
+ 'pull_latest_images' => $pullLatestImages,
+ 'stop_before_start' => $stopBeforeStart,
+ 'is_api' => $is_api,
+ 'status' => ServiceDeploymentStatus::IN_PROGRESS->value,
+ ]);
+
+ return [
+ 'status' => 'started',
+ 'message' => 'Service deployment started.',
+ 'deployment_uuid' => $deployment_uuid,
+ 'deployment' => $deployment,
+ ];
+}
+```
+
+**Purpose:** Create deployment queue record when service starts. Returns deployment object for passing to actions.
+
+---
+
+#### Helper 2: queue_database_deployment()
+
+**File:** `bootstrap/helpers/databases.php` (add to existing file)
+
+```php
+use App\Models\DatabaseDeploymentQueue;
+use App\Enums\DatabaseDeploymentStatus;
+use Spatie\Url\Url;
+use Visus\Cuid2\Cuid2;
+
+function queue_database_deployment(
+ StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database,
+ string $deployment_uuid,
+ bool $is_api = false
+): array {
+ $database_id = $database->id;
+ $database_type = $database->getMorphClass();
+ $server = $database->destination->server;
+ $server_id = $server->id;
+ $server_name = $server->name;
+
+ // Generate deployment URL
+ $deployment_link = Url::fromString($database->link() . "/deployment/{$deployment_uuid}");
+ $deployment_url = $deployment_link->getPath();
+
+ // Create deployment record
+ $deployment = DatabaseDeploymentQueue::create([
+ 'database_id' => $database_id,
+ 'database_type' => $database_type,
+ 'database_name' => $database->name,
+ 'server_id' => $server_id,
+ 'server_name' => $server_name,
+ 'deployment_uuid' => $deployment_uuid,
+ 'deployment_url' => $deployment_url,
+ 'is_api' => $is_api,
+ 'status' => DatabaseDeploymentStatus::IN_PROGRESS->value,
+ ]);
+
+ return [
+ 'status' => 'started',
+ 'message' => 'Database deployment started.',
+ 'deployment_uuid' => $deployment_uuid,
+ 'deployment' => $deployment,
+ ];
+}
+```
+
+---
+
+### Phase 5: Refactor Actions (11 files to update)
+
+#### Action 1: StartService (CRITICAL)
+
+**File:** `app/Actions/Service/StartService.php`
+
+**Before:**
+```php
+public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
+{
+ $service->parse();
+ // ... build commands
+ return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
+}
+```
+
+**After:**
+```php
+use App\Models\ServiceDeploymentQueue;
+use Visus\Cuid2\Cuid2;
+
+public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
+{
+ // Create deployment queue record
+ $deployment_uuid = (string) new Cuid2();
+ $result = queue_service_deployment(
+ service: $service,
+ deployment_uuid: $deployment_uuid,
+ pullLatestImages: $pullLatestImages,
+ stopBeforeStart: $stopBeforeStart,
+ is_api: false
+ );
+ $deployment = $result['deployment'];
+
+ // Existing logic
+ $service->parse();
+ if ($stopBeforeStart) {
+ StopService::run(service: $service, dockerCleanup: false);
+ }
+ $service->saveComposeConfigs();
+ $service->isConfigurationChanged(save: true);
+
+ $commands[] = 'cd ' . $service->workdir();
+ $commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
+ // ... rest of command building
+
+ // Pass deployment to remote_process for log streaming
+ return remote_process(
+ $commands,
+ $service->server,
+ type_uuid: $service->uuid,
+ model: $deployment, // NEW - link to deployment queue
+ callEventOnFinish: 'ServiceStatusChanged'
+ );
+}
+```
+
+**Key Changes:**
+1. Generate deployment UUID at start
+2. Call `queue_service_deployment()` helper
+3. Pass `$deployment` as `model` parameter to `remote_process()`
+4. Return value unchanged (Activity object)
+
+---
+
+#### Actions 2-10: Database Start Actions (9 files)
+
+**Files to Update:**
+- `app/Actions/Database/StartPostgresql.php`
+- `app/Actions/Database/StartRedis.php`
+- `app/Actions/Database/StartMongodb.php`
+- `app/Actions/Database/StartMysql.php`
+- `app/Actions/Database/StartMariadb.php`
+- `app/Actions/Database/StartKeydb.php`
+- `app/Actions/Database/StartDragonfly.php`
+- `app/Actions/Database/StartClickhouse.php`
+
+**Pattern (using StartPostgresql as example):**
+
+**Before:**
+```php
+public function handle(StandalonePostgresql $database)
+{
+ $this->database = $database;
+ // ... build docker-compose and commands
+ return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
+}
+```
+
+**After:**
+```php
+use App\Models\DatabaseDeploymentQueue;
+use Visus\Cuid2\Cuid2;
+
+public function handle(StandalonePostgresql $database)
+{
+ $this->database = $database;
+
+ // Create deployment queue record
+ $deployment_uuid = (string) new Cuid2();
+ $result = queue_database_deployment(
+ database: $database,
+ deployment_uuid: $deployment_uuid,
+ is_api: false
+ );
+ $deployment = $result['deployment'];
+
+ // Existing logic (unchanged)
+ $container_name = $this->database->uuid;
+ $this->configuration_dir = database_configuration_dir() . '/' . $container_name;
+ // ... rest of setup
+
+ // Pass deployment to remote_process
+ return remote_process(
+ $this->commands,
+ $database->destination->server,
+ model: $deployment, // NEW
+ callEventOnFinish: 'DatabaseStatusChanged'
+ );
+}
+```
+
+**Apply Same Pattern to All 9 Database Start Actions**
+
+---
+
+#### Action 11: StartDatabase (Dispatcher)
+
+**File:** `app/Actions/Database/StartDatabase.php`
+
+**Before:**
+```php
+public function handle(/* all database types */)
+{
+ switch ($database->getMorphClass()) {
+ case \App\Models\StandalonePostgresql::class:
+ $activity = StartPostgresql::run($database);
+ break;
+ // ... other cases
+ }
+ return $activity;
+}
+```
+
+**After:** No changes needed - already returns Activity from Start* actions
+
+---
+
+### Phase 6: Update Remote Process Handler (CRITICAL)
+
+**File:** `app/Actions/CoolifyTask/PrepareCoolifyTask.php`
+
+**Current Behavior:**
+- Accepts `$model` parameter (currently only used for ApplicationDeploymentQueue)
+- Streams logs to Activity (Spatie ActivityLog)
+- Calls event on finish
+
+**Required Changes:**
+1. Check if `$model` is `ServiceDeploymentQueue` or `DatabaseDeploymentQueue`
+2. Call `addLogEntry()` on deployment model alongside Activity logs
+3. Update deployment status on completion/failure
+
+**Pseudocode for Changes:**
+```php
+// In log streaming section
+if ($model instanceof ApplicationDeploymentQueue ||
+ $model instanceof ServiceDeploymentQueue ||
+ $model instanceof DatabaseDeploymentQueue) {
+ $model->addLogEntry($logMessage, $logType);
+}
+
+// On completion
+if ($model instanceof ServiceDeploymentQueue ||
+ $model instanceof DatabaseDeploymentQueue) {
+ if ($exitCode === 0) {
+ $model->setStatus('finished');
+ } else {
+ $model->setStatus('failed');
+ }
+}
+```
+
+**Note:** Exact implementation depends on PrepareCoolifyTask structure. Need to review file in detail during implementation.
+
+---
+
+### Phase 7: API Endpoints (4 new endpoints + 2 updates)
+
+**File:** `app/Http/Controllers/Api/DeployController.php`
+
+#### Endpoint 1: List Service Deployments
+
+```php
+#[OA\Get(
+ summary: 'List service deployments',
+ description: 'List deployment history for a specific service',
+ path: '/deployments/services/{uuid}',
+ operationId: 'list-deployments-by-service-uuid',
+ security: [['bearerAuth' => []]],
+ tags: ['Deployments'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID', schema: new OA\Schema(type: 'string')),
+ new OA\Parameter(name: 'skip', in: 'query', description: 'Number of records to skip', schema: new OA\Schema(type: 'integer', minimum: 0, default: 0)),
+ new OA\Parameter(name: 'take', in: 'query', description: 'Number of records to take', schema: new OA\Schema(type: 'integer', minimum: 1, default: 10)),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'List of service deployments'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ ]
+)]
+public function get_service_deployments(Request $request)
+{
+ $request->validate([
+ 'skip' => ['nullable', 'integer', 'min:0'],
+ 'take' => ['nullable', 'integer', 'min:1'],
+ ]);
+
+ $service_uuid = $request->route('uuid', null);
+ $skip = $request->get('skip', 0);
+ $take = $request->get('take', 10);
+
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $service = Service::where('uuid', $service_uuid)
+ ->whereHas('environment.project.team', function($query) use ($teamId) {
+ $query->where('id', $teamId);
+ })
+ ->first();
+
+ if (is_null($service)) {
+ return response()->json(['message' => 'Service not found'], 404);
+ }
+
+ $this->authorize('view', $service);
+
+ $deployments = $service->deployments($skip, $take);
+
+ return response()->json(serializeApiResponse($deployments));
+}
+```
+
+#### Endpoint 2: Get Service Deployment by UUID
+
+```php
+#[OA\Get(
+ summary: 'Get service deployment',
+ description: 'Get a specific service deployment by deployment UUID',
+ path: '/deployments/services/deployment/{uuid}',
+ operationId: 'get-service-deployment-by-uuid',
+ security: [['bearerAuth' => []]],
+ tags: ['Deployments'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Deployment UUID', schema: new OA\Schema(type: 'string')),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Service deployment details'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ ]
+)]
+public function service_deployment_by_uuid(Request $request)
+{
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $uuid = $request->route('uuid');
+ if (!$uuid) {
+ return response()->json(['message' => 'UUID is required.'], 400);
+ }
+
+ $deployment = ServiceDeploymentQueue::where('deployment_uuid', $uuid)->first();
+ if (!$deployment) {
+ return response()->json(['message' => 'Deployment not found.'], 404);
+ }
+
+ // Authorization check via service
+ $service = $deployment->service;
+ if (!$service) {
+ return response()->json(['message' => 'Service not found.'], 404);
+ }
+
+ $this->authorize('view', $service);
+
+ return response()->json($this->removeSensitiveData($deployment));
+}
+```
+
+#### Endpoint 3: List Database Deployments
+
+```php
+#[OA\Get(
+ summary: 'List database deployments',
+ description: 'List deployment history for a specific database',
+ path: '/deployments/databases/{uuid}',
+ operationId: 'list-deployments-by-database-uuid',
+ security: [['bearerAuth' => []]],
+ tags: ['Deployments'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Database UUID', schema: new OA\Schema(type: 'string')),
+ new OA\Parameter(name: 'skip', in: 'query', schema: new OA\Schema(type: 'integer', minimum: 0, default: 0)),
+ new OA\Parameter(name: 'take', in: 'query', schema: new OA\Schema(type: 'integer', minimum: 1, default: 10)),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'List of database deployments'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ ]
+)]
+public function get_database_deployments(Request $request)
+{
+ $request->validate([
+ 'skip' => ['nullable', 'integer', 'min:0'],
+ 'take' => ['nullable', 'integer', 'min:1'],
+ ]);
+
+ $database_uuid = $request->route('uuid', null);
+ $skip = $request->get('skip', 0);
+ $take = $request->get('take', 10);
+
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ // Find database across all types
+ $database = getResourceByUuid($database_uuid, $teamId);
+
+ if (!$database || !method_exists($database, 'deployments')) {
+ return response()->json(['message' => 'Database not found'], 404);
+ }
+
+ $this->authorize('view', $database);
+
+ $deployments = $database->deployments($skip, $take);
+
+ return response()->json(serializeApiResponse($deployments));
+}
+```
+
+#### Endpoint 4: Get Database Deployment by UUID
+
+```php
+#[OA\Get(
+ summary: 'Get database deployment',
+ description: 'Get a specific database deployment by deployment UUID',
+ path: '/deployments/databases/deployment/{uuid}',
+ operationId: 'get-database-deployment-by-uuid',
+ security: [['bearerAuth' => []]],
+ tags: ['Deployments'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Deployment UUID', schema: new OA\Schema(type: 'string')),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Database deployment details'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ ]
+)]
+public function database_deployment_by_uuid(Request $request)
+{
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $uuid = $request->route('uuid');
+ if (!$uuid) {
+ return response()->json(['message' => 'UUID is required.'], 400);
+ }
+
+ $deployment = DatabaseDeploymentQueue::where('deployment_uuid', $uuid)->first();
+ if (!$deployment) {
+ return response()->json(['message' => 'Deployment not found.'], 404);
+ }
+
+ // Authorization check via database
+ $database = $deployment->database;
+ if (!$database) {
+ return response()->json(['message' => 'Database not found.'], 404);
+ }
+
+ $this->authorize('view', $database);
+
+ return response()->json($this->removeSensitiveData($deployment));
+}
+```
+
+#### Update: removeSensitiveData() method
+
+```php
+private function removeSensitiveData($deployment)
+{
+ if (request()->attributes->get('can_read_sensitive', false) === false) {
+ $deployment->makeHidden(['logs']);
+ }
+ return serializeApiResponse($deployment);
+}
+```
+
+**Note:** Already works for ServiceDeploymentQueue and DatabaseDeploymentQueue due to duck typing
+
+#### Update: deploy_resource() method
+
+**Before:**
+```php
+case Service::class:
+ StartService::run($resource);
+ $message = "Service {$resource->name} started. It could take a while, be patient.";
+ break;
+
+default: // Database
+ StartDatabase::dispatch($resource);
+ $message = "Database {$resource->name} started.";
+ break;
+```
+
+**After:**
+```php
+case Service::class:
+ $this->authorize('deploy', $resource);
+ $deployment_uuid = new Cuid2;
+ // StartService now handles deployment queue creation internally
+ StartService::run($resource);
+ $message = "Service {$resource->name} deployment started.";
+ break;
+
+default: // Database
+ $this->authorize('manage', $resource);
+ $deployment_uuid = new Cuid2;
+ // Start actions now handle deployment queue creation internally
+ StartDatabase::dispatch($resource);
+ $message = "Database {$resource->name} deployment started.";
+ break;
+```
+
+**Note:** deployment_uuid is now created inside actions, so API just returns message. If we want to return UUID to API, actions need to return deployment object.
+
+---
+
+### Phase 8: Model Relationships (2 model updates)
+
+#### Update 1: Service Model
+
+**File:** `app/Models/Service.php`
+
+**Add Method:**
+```php
+/**
+ * Get deployment history for this service
+ */
+public function deployments(int $skip = 0, int $take = 10)
+{
+ return ServiceDeploymentQueue::where('service_id', $this->id)
+ ->orderBy('created_at', 'desc')
+ ->skip($skip)
+ ->take($take)
+ ->get();
+}
+
+/**
+ * Get latest deployment
+ */
+public function latestDeployment()
+{
+ return ServiceDeploymentQueue::where('service_id', $this->id)
+ ->orderBy('created_at', 'desc')
+ ->first();
+}
+```
+
+---
+
+#### Update 2: All Standalone Database Models (9 files)
+
+**Files:**
+- `app/Models/StandalonePostgresql.php`
+- `app/Models/StandaloneRedis.php`
+- `app/Models/StandaloneMongodb.php`
+- `app/Models/StandaloneMysql.php`
+- `app/Models/StandaloneMariadb.php`
+- `app/Models/StandaloneKeydb.php`
+- `app/Models/StandaloneDragonfly.php`
+- `app/Models/StandaloneClickhouse.php`
+
+**Add Methods to Each:**
+```php
+/**
+ * Get deployment history for this database
+ */
+public function deployments(int $skip = 0, int $take = 10)
+{
+ return DatabaseDeploymentQueue::where('database_id', $this->id)
+ ->where('database_type', $this->getMorphClass())
+ ->orderBy('created_at', 'desc')
+ ->skip($skip)
+ ->take($take)
+ ->get();
+}
+
+/**
+ * Get latest deployment
+ */
+public function latestDeployment()
+{
+ return DatabaseDeploymentQueue::where('database_id', $this->id)
+ ->where('database_type', $this->getMorphClass())
+ ->orderBy('created_at', 'desc')
+ ->first();
+}
+```
+
+---
+
+### Phase 9: Routes (4 new routes)
+
+**File:** `routes/api.php`
+
+**Add Routes:**
+```php
+Route::middleware(['auth:sanctum'])->group(function () {
+ // Existing routes...
+
+ // Service deployment routes
+ Route::get('/deployments/services/{uuid}', [DeployController::class, 'get_service_deployments'])
+ ->name('deployments.services.list');
+ Route::get('/deployments/services/deployment/{uuid}', [DeployController::class, 'service_deployment_by_uuid'])
+ ->name('deployments.services.show');
+
+ // Database deployment routes
+ Route::get('/deployments/databases/{uuid}', [DeployController::class, 'get_database_deployments'])
+ ->name('deployments.databases.list');
+ Route::get('/deployments/databases/deployment/{uuid}', [DeployController::class, 'database_deployment_by_uuid'])
+ ->name('deployments.databases.show');
+});
+```
+
+---
+
+### Phase 10: Policies & Authorization (Optional - If needed)
+
+**Service Policy:** `app/Policies/ServicePolicy.php`
+- May need to add `viewDeployment` and `viewDeployments` methods if they don't exist
+- Check existing `view` gate - it should cover deployment viewing
+
+**Database Policies:**
+- Each StandaloneDatabase type may have its own policy
+- Verify `view` gate exists and covers deployment history access
+
+**Action Required:** Review existing policies during implementation. May not need changes if `view` gate is sufficient.
+
+---
+
+## Testing Strategy
+
+### Unit Tests (Run outside Docker: `./vendor/bin/pest tests/Unit`)
+
+#### Test 1: ServiceDeploymentQueue Unit Test
+
+**File:** `tests/Unit/Models/ServiceDeploymentQueueTest.php`
+
+```php
+create([
+ 'logs' => null,
+ ]);
+
+ $deployment->addLogEntry('Test message', 'stdout', false);
+
+ expect($deployment->fresh()->logs)->not->toBeNull();
+
+ $logs = json_decode($deployment->fresh()->logs, true);
+ expect($logs)->toHaveCount(1);
+ expect($logs[0])->toHaveKeys(['command', 'output', 'type', 'timestamp', 'hidden', 'batch', 'order']);
+ expect($logs[0]['output'])->toBe('Test message');
+ expect($logs[0]['type'])->toBe('stdout');
+});
+
+it('redacts sensitive environment variables in logs', function () {
+ $service = Mockery::mock(Service::class);
+ $envVar = new \StdClass();
+ $envVar->is_shown_once = true;
+ $envVar->key = 'SECRET_KEY';
+ $envVar->real_value = 'super-secret-value';
+
+ $service->shouldReceive('getAttribute')
+ ->with('environment_variables')
+ ->andReturn(collect([$envVar]));
+
+ $deployment = ServiceDeploymentQueue::factory()->create();
+ $deployment->setRelation('service', $service);
+
+ $deployment->addLogEntry('Deploying with super-secret-value in logs', 'stdout');
+
+ $logs = json_decode($deployment->fresh()->logs, true);
+ expect($logs[0]['output'])->toContain(REDACTED);
+ expect($logs[0]['output'])->not->toContain('super-secret-value');
+});
+
+it('sets status correctly', function () {
+ $deployment = ServiceDeploymentQueue::factory()->create(['status' => 'queued']);
+
+ $deployment->setStatus('in_progress');
+ expect($deployment->fresh()->status)->toBe('in_progress');
+
+ $deployment->setStatus('finished');
+ expect($deployment->fresh()->status)->toBe('finished');
+});
+```
+
+#### Test 2: DatabaseDeploymentQueue Unit Test
+
+**File:** `tests/Unit/Models/DatabaseDeploymentQueueTest.php`
+
+```php
+create([
+ 'logs' => null,
+ ]);
+
+ $deployment->addLogEntry('Starting database', 'stdout', false);
+
+ $logs = json_decode($deployment->fresh()->logs, true);
+ expect($logs)->toHaveCount(1);
+ expect($logs[0]['output'])->toBe('Starting database');
+});
+
+it('redacts database credentials in logs', function () {
+ $database = Mockery::mock(StandalonePostgresql::class);
+ $database->shouldReceive('getAttribute')
+ ->with('postgres_password')
+ ->andReturn('db-password-123');
+ $database->shouldReceive('getAttribute')
+ ->with('environment_variables')
+ ->andReturn(collect([]));
+ $database->shouldReceive('getMorphClass')
+ ->andReturn(StandalonePostgresql::class);
+
+ $deployment = DatabaseDeploymentQueue::factory()->create([
+ 'database_type' => StandalonePostgresql::class,
+ ]);
+ $deployment->setRelation('database', $database);
+
+ $deployment->addLogEntry('Connecting with password db-password-123', 'stdout');
+
+ $logs = json_decode($deployment->fresh()->logs, true);
+ expect($logs[0]['output'])->toContain(REDACTED);
+ expect($logs[0]['output'])->not->toContain('db-password-123');
+});
+```
+
+---
+
+### Feature Tests (Run inside Docker: `docker exec coolify php artisan test`)
+
+#### Test 3: Service Deployment Integration Test
+
+**File:** `tests/Feature/ServiceDeploymentTest.php`
+
+```php
+create();
+
+ // Mock remote_process to prevent actual SSH
+ // (Implementation depends on existing test patterns)
+
+ StartService::run($service);
+
+ $deployment = ServiceDeploymentQueue::where('service_id', $service->id)->first();
+ expect($deployment)->not->toBeNull();
+ expect($deployment->service_name)->toBe($service->name);
+ expect($deployment->status)->toBe('in_progress');
+});
+
+it('tracks multiple deployments for same service', function () {
+ $service = Service::factory()->create();
+
+ StartService::run($service);
+ StartService::run($service);
+
+ $deployments = ServiceDeploymentQueue::where('service_id', $service->id)->get();
+ expect($deployments)->toHaveCount(2);
+});
+```
+
+#### Test 4: Database Deployment Integration Test
+
+**File:** `tests/Feature/DatabaseDeploymentTest.php`
+
+```php
+create();
+
+ // Mock remote_process
+
+ StartPostgresql::run($database);
+
+ $deployment = DatabaseDeploymentQueue::where('database_id', $database->id)
+ ->where('database_type', StandalonePostgresql::class)
+ ->first();
+
+ expect($deployment)->not->toBeNull();
+ expect($deployment->database_name)->toBe($database->name);
+});
+
+// Repeat for other database types...
+```
+
+#### Test 5: API Endpoint Tests
+
+**File:** `tests/Feature/Api/DeploymentApiTest.php`
+
+```php
+create();
+ $service = Service::factory()->create([
+ 'environment_id' => /* setup team/project/env */
+ ]);
+
+ ServiceDeploymentQueue::factory()->count(3)->create([
+ 'service_id' => $service->id,
+ ]);
+
+ $response = $this->actingAs($user)
+ ->getJson("/api/deployments/services/{$service->uuid}");
+
+ $response->assertSuccessful();
+ $response->assertJsonCount(3);
+});
+
+it('requires authentication for service deployments', function () {
+ $service = Service::factory()->create();
+
+ $response = $this->getJson("/api/deployments/services/{$service->uuid}");
+
+ $response->assertUnauthorized();
+});
+
+// Repeat for database endpoints...
+```
+
+---
+
+## Rollout Plan
+
+### Phase Order (Safest to Riskiest)
+
+| Phase | Risk | Can Break Production? | Rollback Strategy |
+|-------|------|----------------------|-------------------|
+| 1. Schema | Low | No (new tables) | Drop tables |
+| 2. Models | Low | No (unused code) | Remove files |
+| 3. Enums | Low | No (unused code) | Remove files |
+| 4. Helpers | Low | No (unused code) | Remove functions |
+| 5. Actions | **HIGH** | **YES** | Revert to old actions |
+| 6. Remote Process | **CRITICAL** | **YES** | Revert changes |
+| 7. API | Medium | No (new endpoints) | Remove routes |
+| 8. Relationships | Low | No (new methods) | Remove methods |
+| 9. UI | Low | No (optional) | Remove components |
+| 10. Policies | Low | Maybe (if breaking existing) | Revert gates |
+
+### Recommended Rollout Strategy
+
+**Week 1: Foundation (No Risk)**
+- Complete Phases 1-4
+- Write and run all unit tests
+- Verify migrations work in dev/staging
+
+**Week 2: Critical Changes (High Risk)**
+- Complete Phase 5 (Actions) for **Services only**
+- Complete Phase 6 (Remote Process handler) for Services
+- Test extensively in staging
+- Monitor for errors
+
+**Week 3: Database Support**
+- Extend Phase 5 to all 9 database types
+- Update Phase 6 for database support
+- Test each database type individually
+
+**Week 4: API & Polish**
+- Complete Phases 7-10
+- Feature tests
+- API documentation
+- User-facing features (if any)
+
+### Testing Checkpoints
+
+**After Phase 4:**
+- ✅ Migrations apply cleanly
+- ✅ Models instantiate without errors
+- ✅ Unit tests pass
+
+**After Phase 5 (Services):**
+- ✅ Service start creates deployment queue
+- ✅ Service logs stream to deployment queue
+- ✅ Service deployments appear in database
+- ✅ No disruption to existing service starts
+
+**After Phase 5 (Databases):**
+- ✅ Each database type creates deployment queue
+- ✅ Database logs stream correctly
+- ✅ No errors on database start
+
+**After Phase 7:**
+- ✅ API endpoints return correct data
+- ✅ Authorization works correctly
+- ✅ Sensitive data is redacted
+
+---
+
+## Known Risks & Mitigation
+
+### Risk 1: Breaking Existing Deployments
+**Probability:** Medium
+**Impact:** Critical
+
+**Mitigation:**
+- Test exhaustively in staging before production
+- Deploy during low-traffic window
+- Have rollback plan ready (git revert + migration rollback)
+- Monitor error logs closely after deploy
+
+### Risk 2: Database Performance Impact
+**Probability:** Low
+**Impact:** Medium
+
+**Details:** Each deployment now writes logs to DB multiple times (via `addLogEntry()`)
+
+**Mitigation:**
+- Use `saveQuietly()` to avoid triggering events
+- JSON column is indexed for fast retrieval
+- Logs are text (compressed well by Postgres)
+- Add monitoring for slow queries
+
+### Risk 3: Disk Space Growth
+**Probability:** Medium (long-term)
+**Impact:** Low
+
+**Details:** Deployment logs accumulate over time
+
+**Mitigation:**
+- Implement log retention policy (delete deployments older than X days/months)
+- Add background job to prune old deployment records
+- Monitor disk usage trends
+
+### Risk 4: Polymorphic Relationship Complexity
+**Probability:** Low
+**Impact:** Low
+
+**Details:** DatabaseDeploymentQueue uses polymorphic relationship (9 database types)
+
+**Mitigation:**
+- Thorough testing of each database type
+- Composite indexes on (database_id, database_type)
+- Clear documentation of relationship structure
+
+### Risk 5: Remote Process Integration
+**Probability:** High
+**Impact:** Critical
+
+**Details:** `PrepareCoolifyTask` is core to all deployments. Changes here affect everything.
+
+**Mitigation:**
+- Review `PrepareCoolifyTask` code in detail before changes
+- Add type checks (`instanceof`) to avoid breaking existing logic
+- Extensive testing of application deployments after changes
+- Keep changes minimal and focused
+
+---
+
+## Migration Strategy for Existing Data
+
+**Q: What about existing services/databases that have been deployed before?**
+
+**A:** No migration needed. This is a **new feature**, not a data migration.
+
+- Services/databases deployed before this change won't have history
+- New deployments (after feature is live) will be tracked
+- This is acceptable - deployment history starts "now"
+
+**Alternative (if history is critical):**
+- Could create fake deployment records for currently running resources
+- Not recommended - logs don't exist, would be misleading
+
+---
+
+## Performance Considerations
+
+### Database Writes During Deployment
+
+**Current:** ~1 write per deployment (Activity log, TTL-based)
+
+**New:** ~1 write per deployment + N writes for log entries
+- Application deployments: ~50-200 log entries
+- Service deployments: ~10-30 log entries
+- Database deployments: ~5-15 log entries
+
+**Impact:** Minimal
+- Writes are async (queued)
+- Postgres handles small JSON updates efficiently
+- `saveQuietly()` skips event dispatching overhead
+
+### Query Performance
+
+**Critical Queries:**
+- "Get deployment history for service/database" - indexed on (resource_id, status, created_at)
+- "Get deployment by UUID" - unique index on deployment_uuid
+- "Get all in-progress deployments" - composite index on (server_id, status, created_at)
+
+**Expected Performance:**
+- < 10ms for single deployment lookup
+- < 50ms for paginated history (10 records)
+- < 100ms for server-wide deployment status
+
+---
+
+## Storage Estimates
+
+**Per Deployment:**
+- Metadata: ~500 bytes
+- Logs (avg): ~50KB (application), ~10KB (service), ~5KB (database)
+
+**1000 deployments/day:**
+- Services: ~10MB/day = ~300MB/month
+- Databases: ~5MB/day = ~150MB/month
+- Total: ~450MB/month (highly compressible)
+
+**Retention Policy Recommendation:**
+- Keep all deployments for 30 days
+- Keep successful deployments for 90 days
+- Keep failed deployments for 180 days (for debugging)
+
+---
+
+## Alternative Approaches Considered
+
+### Option 1: Unified Resource Deployments Table
+
+**Schema:**
+```sql
+CREATE TABLE resource_deployments (
+ id BIGINT PRIMARY KEY,
+ deployable_id INT,
+ deployable_type VARCHAR(255), -- App\Models\Service, App\Models\StandalonePostgresql, etc.
+ deployment_uuid VARCHAR(255) UNIQUE,
+ -- ... rest of fields
+ INDEX(deployable_id, deployable_type)
+);
+```
+
+**Pros:**
+- Single model to maintain
+- DRY (Don't Repeat Yourself)
+- Easier to query "all deployments across all resources"
+
+**Cons:**
+- Polymorphic queries are slower
+- No foreign key constraints
+- Different resources have different deployment attributes
+- Harder to optimize indexes per resource type
+- More complex to reason about
+
+**Decision:** Rejected - Separate tables provide better type safety and performance
+
+---
+
+### Option 2: Reuse Activity Log (Spatie)
+
+**Approach:** Don't create deployment queue tables. Use existing Activity log with longer TTL.
+
+**Pros:**
+- Zero new code
+- Activity log already stores logs
+
+**Cons:**
+- Activity log is ephemeral (not designed for permanent history)
+- No structured deployment metadata (status, UUIDs, etc.)
+- Would need to change Activity TTL globally (affects all activities)
+- Mixing concerns (Activity = audit log, Deployment = business logic)
+
+**Decision:** Rejected - Activity log and deployment history serve different purposes
+
+---
+
+### Option 3: External Logging Service
+
+**Approach:** Stream logs to external service (S3, CloudWatch, etc.)
+
+**Pros:**
+- Offload storage from main database
+- Better for very large log volumes
+
+**Cons:**
+- Additional infrastructure complexity
+- Requires external dependencies
+- Harder to query deployment history
+- Not consistent with application deployment pattern
+
+**Decision:** Rejected - Keep it simple, follow existing patterns
+
+---
+
+## Future Enhancements (Out of Scope)
+
+### 1. Deployment Queue System
+- Like application deployments, queue service/database starts
+- Respect server concurrent limits
+- **Complexity:** High
+- **Value:** Medium (services/databases deploy fast, queueing less critical)
+
+### 2. UI for Deployment History
+- Livewire components to view past deployments
+- Similar to application deployment history page
+- **Complexity:** Medium
+- **Value:** High (nice-to-have, not critical for first release)
+
+### 3. Deployment Comparison
+- Diff between two deployments (config changes)
+- **Complexity:** High
+- **Value:** Low
+
+### 4. Deployment Rollback
+- Roll back service/database to previous deployment
+- **Complexity:** Very High (databases especially risky)
+- **Value:** Medium
+
+### 5. Deployment Notifications
+- Notify on service/database deployment success/failure
+- **Complexity:** Low
+- **Value:** Medium
+
+---
+
+## Success Criteria
+
+### Minimum Viable Product (MVP)
+
+✅ Service deployments create deployment queue records
+✅ Database deployments (all 9 types) create deployment queue records
+✅ Logs stream to deployment queue during deployment
+✅ Deployment status updates (in_progress → finished/failed)
+✅ API endpoints to retrieve deployment history
+✅ Sensitive data redaction in logs
+✅ No disruption to existing application deployments
+✅ All unit and feature tests pass
+
+### Nice-to-Have (Post-MVP)
+
+⚪ UI components for viewing deployment history
+⚪ Deployment notifications
+⚪ Log retention policy job
+⚪ Deployment statistics/analytics
+
+---
+
+## Questions to Resolve Before Implementation
+
+1. **Should we queue service/database starts (like applications)?**
+ - Current: Services/databases start immediately
+ - With queue: Respect server concurrent limits, better for cloud instance
+ - **Recommendation:** Start without queue, add later if needed
+
+2. **Should API deploy endpoints return deployment_uuid for services/databases?**
+ - Current: Application deploys return deployment_uuid
+ - Proposed: Services/databases should too
+ - **Recommendation:** Yes, for consistency. Requires actions to return deployment object.
+
+3. **What's the log retention policy?**
+ - **Recommendation:** 90 days for all, with background job to prune
+
+4. **Do we need UI in first release?**
+ - **Recommendation:** No, API is sufficient. Add UI iteratively.
+
+5. **Should we implement deployment cancellation?**
+ - Applications support cancellation
+ - **Recommendation:** Not in MVP, add later if requested
+
+---
+
+## Implementation Checklist
+
+### Pre-Implementation
+- [ ] Review this plan with team
+- [ ] Get approval on architectural decisions
+- [ ] Resolve open questions
+- [ ] Set up staging environment for testing
+
+### Phase 1: Schema
+- [ ] Create `create_service_deployment_queues_table` migration
+- [ ] Create `create_database_deployment_queues_table` migration
+- [ ] Create index optimization migration
+- [ ] Test migrations in dev
+- [ ] Run migrations in staging
+
+### Phase 2: Models
+- [ ] Create `ServiceDeploymentQueue` model
+- [ ] Create `DatabaseDeploymentQueue` model
+- [ ] Add `$fillable`, `$guarded` properties
+- [ ] Implement `addLogEntry()`, `setStatus()`, `getOutput()` methods
+- [ ] Implement `redactSensitiveInfo()` methods
+- [ ] Add OpenAPI schemas
+
+### Phase 3: Enums
+- [ ] Create `ServiceDeploymentStatus` enum
+- [ ] Create `DatabaseDeploymentStatus` enum
+
+### Phase 4: Helpers
+- [ ] Add `queue_service_deployment()` to `bootstrap/helpers/services.php`
+- [ ] Add `queue_database_deployment()` to `bootstrap/helpers/databases.php`
+- [ ] Test helpers in Tinker
+
+### Phase 5: Actions
+- [ ] Update `StartService` action
+- [ ] Update `StartPostgresql` action
+- [ ] Update `StartRedis` action
+- [ ] Update `StartMongodb` action
+- [ ] Update `StartMysql` action
+- [ ] Update `StartMariadb` action
+- [ ] Update `StartKeydb` action
+- [ ] Update `StartDragonfly` action
+- [ ] Update `StartClickhouse` action
+- [ ] Test each action in staging
+
+### Phase 6: Remote Process
+- [ ] Review `PrepareCoolifyTask` code
+- [ ] Add type checks for ServiceDeploymentQueue
+- [ ] Add type checks for DatabaseDeploymentQueue
+- [ ] Add `addLogEntry()` calls
+- [ ] Add status update logic
+- [ ] Test with application deployments (ensure no regression)
+- [ ] Test with service deployments
+- [ ] Test with database deployments
+
+### Phase 7: API
+- [ ] Add `get_service_deployments()` endpoint
+- [ ] Add `service_deployment_by_uuid()` endpoint
+- [ ] Add `get_database_deployments()` endpoint
+- [ ] Add `database_deployment_by_uuid()` endpoint
+- [ ] Update `deploy_resource()` to return deployment_uuid
+- [ ] Update `removeSensitiveData()` if needed
+- [ ] Add routes to `api.php`
+- [ ] Test endpoints with Postman/curl
+
+### Phase 8: Relationships
+- [ ] Add `deployments()` method to `Service` model
+- [ ] Add `latestDeployment()` method to `Service` model
+- [ ] Add `deployments()` method to all 9 Standalone database models
+- [ ] Add `latestDeployment()` method to all 9 Standalone database models
+
+### Phase 9: Tests
+- [ ] Write `ServiceDeploymentQueueTest` (unit)
+- [ ] Write `DatabaseDeploymentQueueTest` (unit)
+- [ ] Write `ServiceDeploymentTest` (feature)
+- [ ] Write `DatabaseDeploymentTest` (feature)
+- [ ] Write `DeploymentApiTest` (feature)
+- [ ] Run all tests, ensure passing
+- [ ] Run full test suite, ensure no regressions
+
+### Phase 10: Documentation
+- [ ] Update API documentation
+- [ ] Update CLAUDE.md if needed
+- [ ] Add code comments for complex sections
+
+### Deployment
+- [ ] Create PR with all changes
+- [ ] Code review
+- [ ] Test in staging (full regression suite)
+- [ ] Deploy to production during low-traffic window
+- [ ] Monitor error logs for 24 hours
+- [ ] Verify deployments are being tracked
+
+### Post-Deployment
+- [ ] Monitor disk usage trends
+- [ ] Monitor query performance
+- [ ] Gather user feedback
+- [ ] Plan UI implementation (if needed)
+- [ ] Plan log retention job
+
+---
+
+## Contact & Support
+
+**Implementation Lead:** [Your Name]
+**Reviewer:** [Reviewer Name]
+**Questions:** Reference this document or ask in #dev channel
+
+---
+
+**Last Updated:** 2025-10-30
+**Status:** Planning Complete, Ready for Implementation
+**Next Step:** Review plan with team, get approval, begin Phase 1
diff --git a/versions.json b/versions.json
index c7e173833..edf4a3700 100644
--- a/versions.json
+++ b/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.438"
+ "version": "4.0.0-beta.439"
},
"nightly": {
- "version": "4.0.0-beta.439"
+ "version": "4.0.0-beta.440"
},
"helper": {
"version": "1.0.11"