feat(applications): add recommendations for safer build settings

Add a recommendations page with dismiss and restore controls, and default new
applications to Docker Build Secrets. Warn when Dockerfile syntax or variable
usage is incompatible with build secrets.
This commit is contained in:
Andras Bacsai committed 2026-10-26 13:03:55 +01:00
1 parent cbb6f7a970
commit 86fc6e8d07
23 files changed
+779 -21

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Enums;
use App\Models\Application;
use Illuminate\Support\Collection;
/**
* Settings that Coolify recommends for an application, shown on its Recommended settings page.
* The values are stored in application_settings.dismissed_recommendations, so do not rename them.
*/
enum ApplicationRecommendation: string
{
case UseBuildSecrets = 'use-build-secrets';
case UseRailpack = 'use-railpack';
/**
* Recommendations that apply to the application, are not done yet, and are not dismissed.
*
* @return Collection<int, self>
*/
public static function openFor(Application $application): Collection
{
return self::unsatisfiedFor($application)
->reject(fn (self $recommendation): bool => $recommendation->isDismissedFor($application))
->values();
}
/**
* @return Collection<int, self>
*/
public static function dismissedFor(Application $application): Collection
{
return self::unsatisfiedFor($application)
->filter(fn (self $recommendation): bool => $recommendation->isDismissedFor($application))
->values();
}
/**
* @return Collection<int, self>
*/
private static function unsatisfiedFor(Application $application): Collection
{
return collect(self::cases())
->filter(fn (self $recommendation): bool => $recommendation->appliesTo($application) && ! $recommendation->isSatisfied($application));
}
public function title(): string
{
return match ($this) {
self::UseBuildSecrets => 'Use build secrets',
self::UseRailpack => 'Switch from Nixpacks to Railpack',
};
}
public function why(Application $application): string
{
return match ($this) {
self::UseBuildSecrets => collect([
'Build-time variables are passed to the build as build arguments. Docker saves their values in the image history, so anyone who can read the image can read them.',
filled($application->docker_registry_image_name)
? "This application pushes its image to {$application->docker_registry_image_name}, so the values ship with the image."
: null,
'With build secrets, the values are available only inside RUN instructions, as environment variables. A Dockerfile that uses them in FROM, COPY, WORKDIR, or other instructions gets empty values there.',
])->filter()->implode(' '),
self::UseRailpack => config('deprecations.nixpacks').' Railpack reads RAILPACK_* variables and railpack.json, not NIXPACKS_* variables or nixpacks.toml. Check them before you redeploy.',
};
}
/**
* The callout type that shows the recommendation: danger or warning.
*/
public function severity(Application $application): string
{
return match ($this) {
self::UseBuildSecrets => filled($application->docker_registry_image_name) ? 'danger' : 'warning',
self::UseRailpack => 'warning',
};
}
public function appliesTo(Application $application): bool
{
return match ($this) {
self::UseBuildSecrets => in_array($application->build_pack, ['dockerfile', 'nixpacks', 'dockercompose'], true)
&& ! ($application->build_pack === 'dockercompose' && $application->settings?->is_raw_compose_deployment_enabled)
&& ($application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->exists()
|| $application->environment_variables_preview()->withoutBuildpackControlVariables()->where('is_buildtime', true)->exists()),
self::UseRailpack => $application->build_pack === 'nixpacks',
};
}
public function isSatisfied(Application $application): bool
{
return match ($this) {
self::UseBuildSecrets => (bool) $application->settings?->use_build_secrets,
self::UseRailpack => $application->build_pack === 'railpack',
};
}
public function isDismissedFor(Application $application): bool
{
return in_array($this->value, $application->settings?->dismissed_recommendations ?? [], true);
}
public function fixLabel(): string
{
return match ($this) {
self::UseBuildSecrets => 'Turn on build secrets',
self::UseRailpack => 'Switch to Railpack',
};
}
public function fixNeedsConfirmation(): bool
{
return match ($this) {
self::UseBuildSecrets => false,
self::UseRailpack => true,
};
}
/**
* Changes the application and its settings in memory. The caller authorizes and saves both.
*/
public function applyFix(Application $application): void
{
match ($this) {
self::UseBuildSecrets => $application->settings->use_build_secrets = true,
self::UseRailpack => $this->switchToRailpack($application),
};
}
private function switchToRailpack(Application $application): void
{
$application->build_pack = 'railpack';
// Same as a build pack change on the General page: read-only labels follow the build pack.
if ($application->settings->is_container_label_readonly_enabled) {
$application->custom_labels = base64_encode(implode("\n", generateLabelsApplication($application)));
}
}
}
@@ -464,7 +464,7 @@ class ApplicationsController extends Controller
],
'watch_paths' => ['type' => 'string', 'description' => 'Watch path patterns, one per line (newline-separated). Commas do not separate patterns.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => true, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
@@ -674,7 +674,7 @@ class ApplicationsController extends Controller
],
'watch_paths' => ['type' => 'string', 'description' => 'Watch path patterns, one per line (newline-separated). Commas do not separate patterns.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => true, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
@@ -884,7 +884,7 @@ class ApplicationsController extends Controller
],
'watch_paths' => ['type' => 'string', 'description' => 'Watch path patterns, one per line (newline-separated). Commas do not separate patterns.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => true, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
@@ -1064,7 +1064,7 @@ class ApplicationsController extends Controller
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => true, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
@@ -1240,7 +1240,7 @@ class ApplicationsController extends Controller
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'use_build_secrets' => ['type' => 'boolean', 'default' => true, 'description' => 'Use Docker Build Secrets for build-time environment variables.'],
'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'],
'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'],
'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'],
+31
View File
@@ -5478,6 +5478,35 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
]);
}
/**
* Build secrets reach a build only as environment variables inside RUN, and the env= mount
* option needs Dockerfile frontend 1.10+. Warn about the two cases that then build without values.
*
* @param Collection<int, string> $dockerfile
* @param Collection<string, mixed> $variables
*/
private function warnAboutDockerfileBuildSecretsCompatibility(Collection $dockerfile, Collection $variables): void
{
if (preg_match('~^#\s*syntax\s*=\s*\S*docker/dockerfile:1\.(\d+)~i', (string) $dockerfile->first(), $matches) === 1 && (int) $matches[1] < 10) {
$this->application_deployment_queue->addLogEntry("⚠️ The Dockerfile pins docker/dockerfile:1.{$matches[1]}. Build secrets need docker/dockerfile:1.10 or later. Remove the syntax line or update it, or switch Build secrets off.", 'stderr');
}
$keysUsedOutsideRun = $variables->keys()
->map(fn ($key): string => (string) $key)
->filter(fn (string $key): bool => ValidationPatterns::isValidEnvironmentVariableKey($key))
->filter(fn (string $key): bool => $dockerfile->contains(function (string $line) use ($key): bool {
$trimmed = ltrim($line);
return preg_match('/^(RUN|ARG|ENV|#)/i', $trimmed) !== 1
&& preg_match('/\$\{?'.preg_quote($key, '/').'\b/', $trimmed) === 1;
}))
->values();
if ($keysUsedOutsideRun->isNotEmpty()) {
$this->application_deployment_queue->addLogEntry('⚠️ The Dockerfile uses build-time variables outside RUN instructions: '.$keysUsedOutsideRun->implode(', ').'. With build secrets, these values are only available inside RUN, so these instructions get empty values. Switch Build secrets off on the Environment Variables page if the build needs them.', 'stderr');
}
}
private function modify_dockerfile_for_secrets($dockerfile_path)
{
// Only process if build secrets are enabled and we have secrets to mount
@@ -5522,6 +5551,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
// Add mount for the secrets hash to ensure cache invalidation
$mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
$this->warnAboutDockerfileBuildSecretsCompatibility($dockerfile, $variables);
$modified = false;
$dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) {
$trimmed = ltrim($line);
@@ -0,0 +1,76 @@
<?php
namespace App\Livewire\Project\Application;
use App\Enums\ApplicationRecommendation;
use App\Models\Application;
use App\Traits\AuditsApplicationSettings;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Recommendations extends Component
{
use AuditsApplicationSettings, AuthorizesRequests;
public Application $application;
public function applyFix(string $recommendation): void
{
try {
$this->authorize('update', $this->application);
$recommendation = ApplicationRecommendation::from($recommendation);
$recommendation->applyFix($this->application);
$this->application->save();
$this->saveApplicationSettingsWithAudit($this->application);
$this->dispatch('success', 'Settings saved. Redeploy the application to apply them.');
$this->refreshConfiguration();
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function dismiss(string $recommendation): void
{
$this->setDismissed(ApplicationRecommendation::from($recommendation), true);
}
public function restore(string $recommendation): void
{
$this->setDismissed(ApplicationRecommendation::from($recommendation), false);
}
public function render(): View
{
return view('livewire.project.application.recommendations', [
'openRecommendations' => ApplicationRecommendation::openFor($this->application),
'dismissedRecommendations' => ApplicationRecommendation::dismissedFor($this->application),
]);
}
private function setDismissed(ApplicationRecommendation $recommendation, bool $dismissed): void
{
try {
$this->authorize('update', $this->application);
$settings = $this->application->settings;
$dismissedValues = collect($settings->dismissed_recommendations ?? [])->reject(fn ($value): bool => $value === $recommendation->value);
$settings->dismissed_recommendations = ($dismissed ? $dismissedValues->push($recommendation->value) : $dismissedValues)->values()->all();
$this->saveApplicationSettingsWithAudit($this->application);
$this->refreshConfiguration();
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/**
* Only the parent page refreshes, so the sidebar count and the build pack dependent menu items update.
*/
private function refreshConfiguration(): void
{
$this->dispatch('refresh')->to(Configuration::class);
}
}
+3
View File
@@ -399,8 +399,11 @@ class Application extends BaseModel
}
});
static::created(function ($application) {
// New applications keep build-time values out of image layers. Clones and server transfer
// imports overwrite this row with the settings of their source application.
ApplicationSetting::create([
'application_id' => $application->id,
'use_build_secrets' => true,
]);
$application->compose_parsing_version = self::$parserVersion;
$application->save();
+2
View File
@@ -74,6 +74,7 @@ class ApplicationSetting extends Model
'is_container_label_escape_enabled' => 'boolean',
'is_container_label_readonly_enabled' => 'boolean',
'use_build_secrets' => 'boolean',
'dismissed_recommendations' => 'array',
'inject_build_args_to_dockerfile' => 'boolean',
'include_source_commit_in_build' => 'boolean',
'is_auto_deploy_enabled' => 'boolean',
@@ -140,6 +141,7 @@ class ApplicationSetting extends Model
'is_git_shallow_clone_enabled',
'is_pr_deployments_public_enabled',
'use_build_secrets',
'dismissed_recommendations',
'redact_all_env_values_in_logs',
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->json('dismissed_recommendations')->nullable();
});
}
public function down(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->dropColumn('dismissed_recommendations');
});
}
};
+1 -1
View File
@@ -96,7 +96,7 @@ When you add a model or a setting to the transfer, add it to this file too.
| ✅ | Limits | memory, swap, swappiness, reservation, CPUs, cpuset, CPU shares |
| ➕ | Advanced | custom labels, custom docker run options, pre/post deployment command + container, manual webhook secrets |
| ✅ | Maintenance | maintenance mode on + custom maintenance page |
| ✅ | All application settings that are not the default | git submodules, LFS, shallow clone, auto deploy off, force HTTPS, debug, preview deployments on, PR deployments public, log drain, GPU (driver, count, device IDs, options), include timestamps, swarm-only worker nodes, build server, consistent container name, gzip off, strip prefix off, connect to Docker network, custom internal name, container label escape off, env sorting, read-only labels, preserve repository, disable build cache, use build secrets, stop grace period 30, rolling update drain time 12, inject build args off, include source commit, Docker images to keep 5, custom container name prefix, redact env values in logs, Coolify default variables off, Traefik certificate resolver |
| ✅ | All application settings that are not the default | git submodules, LFS, shallow clone, auto deploy off, force HTTPS, debug, preview deployments on, PR deployments public, log drain, GPU (driver, count, device IDs, options), include timestamps, swarm-only worker nodes, build server, consistent container name, gzip off, strip prefix off, connect to Docker network, custom internal name, container label escape off, env sorting, read-only labels, preserve repository, disable build cache, build secrets off, a dismissed recommendation (Recommended settings), stop grace period 30, rolling update drain time 12, inject build args off, include source commit, Docker images to keep 5, custom container name prefix, redact env values in logs, Coolify default variables off, Traefik certificate resolver |
| ✅ | Environment variables | normal, multiline, literal, shown once, build time only, runtime only, required, with comment, preview-only, and references to `{{team.*}}`, `{{project.*}}`, `{{environment.*}}`, `{{server.*}}` |
| ✅ | Persistent volume | the seed has one; add a host-path bind mount and preview suffix on |
| ✅ | File mount + directory mount | content, chown, chmod |
+5 -5
View File
@@ -873,7 +873,7 @@
},
"use_build_secrets": {
"type": "boolean",
"default": false,
"default": true,
"description": "Use Docker Build Secrets for build-time environment variables."
},
"is_git_submodules_enabled": {
@@ -1531,7 +1531,7 @@
},
"use_build_secrets": {
"type": "boolean",
"default": false,
"default": true,
"description": "Use Docker Build Secrets for build-time environment variables."
},
"is_git_submodules_enabled": {
@@ -2189,7 +2189,7 @@
},
"use_build_secrets": {
"type": "boolean",
"default": false,
"default": true,
"description": "Use Docker Build Secrets for build-time environment variables."
},
"is_git_submodules_enabled": {
@@ -2737,7 +2737,7 @@
},
"use_build_secrets": {
"type": "boolean",
"default": false,
"default": true,
"description": "Use Docker Build Secrets for build-time environment variables."
},
"is_git_submodules_enabled": {
@@ -3265,7 +3265,7 @@
},
"use_build_secrets": {
"type": "boolean",
"default": false,
"default": true,
"description": "Use Docker Build Secrets for build-time environment variables."
},
"is_git_submodules_enabled": {
+5 -5
View File
@@ -563,7 +563,7 @@ paths:
description: 'Use build server.'
use_build_secrets:
type: boolean
default: false
default: true
description: 'Use Docker Build Secrets for build-time environment variables.'
is_git_submodules_enabled:
type: boolean
@@ -1001,7 +1001,7 @@ paths:
description: 'Use build server.'
use_build_secrets:
type: boolean
default: false
default: true
description: 'Use Docker Build Secrets for build-time environment variables.'
is_git_submodules_enabled:
type: boolean
@@ -1439,7 +1439,7 @@ paths:
description: 'Use build server.'
use_build_secrets:
type: boolean
default: false
default: true
description: 'Use Docker Build Secrets for build-time environment variables.'
is_git_submodules_enabled:
type: boolean
@@ -1814,7 +1814,7 @@ paths:
description: 'Use build server.'
use_build_secrets:
type: boolean
default: false
default: true
description: 'Use Docker Build Secrets for build-time environment variables.'
is_git_submodules_enabled:
type: boolean
@@ -2175,7 +2175,7 @@ paths:
description: 'Use build server.'
use_build_secrets:
type: boolean
default: false
default: true
description: 'Use Docker Build Secrets for build-time environment variables.'
is_git_submodules_enabled:
type: boolean
@@ -13,6 +13,12 @@
'route' => 'project.application.configuration',
'active' => $currentRoute === 'project.application.configuration',
],
[
'label' => 'Recommended settings',
'route' => 'project.application.recommendations',
'active' => $currentRoute === 'project.application.recommendations',
'count' => \App\Enums\ApplicationRecommendation::openFor($application)->count(),
],
[
'label' => 'Domains',
'route' => 'project.application.domains',
@@ -146,6 +152,7 @@
// Icons follow the main sidebar's reicon set
$menuIcons = [
'General' => 'settings',
'Recommended settings' => 'shield-star',
'Domains' => 'globe',
'Advanced' => 'grid',
'Swarm' => 'destinations',
@@ -173,7 +180,7 @@
// Discord-style groups for the settings sidebar
$menuGroups = [
'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'],
'Settings' => ['General', 'Recommended settings', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'],
'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Files', 'Metrics', 'Analytics'],
'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
@@ -266,6 +273,10 @@
@if ($menuItem['label'] === 'Files')
<x-beta-badge class="ml-auto shrink-0" />
@endif
@if (($menuItem['count'] ?? 0) > 0)
<span class="ml-auto shrink-0 rounded-full border border-coollabs/30 bg-coollabs/10 px-1.5 text-[11px] leading-4 font-semibold text-coollabs dark:border-warning/30 dark:bg-warning/15 dark:text-warning"
aria-label="{{ $menuItem['count'] }} open">{{ $menuItem['count'] }}</span>
@endif
@if ($menuItem['badge'] ?? false)
<span class="shrink-0">
<livewire:project.application.server-status-badge :application="$application"
@@ -17,6 +17,8 @@
<livewire:project.shared.maintenance :resource="$application" />
@elseif ($currentRoute === 'project.application.swarm' && $application->destination->server->isSwarm())
<livewire:project.application.swarm :application="$application" />
@elseif ($currentRoute === 'project.application.recommendations')
<livewire:project.application.recommendations :application="$application" />
@elseif ($currentRoute === 'project.application.advanced')
<livewire:project.application.advanced :application="$application" />
@elseif ($currentRoute === 'project.application.environment-variables')
@@ -0,0 +1,46 @@
<div class="flex flex-col gap-6">
<x-application.settings-section id="recommended-settings-section" title="Recommended settings"
helper="Settings that Coolify recommends for this application. A change takes effect with the next deployment.">
@forelse ($openRecommendations as $recommendation)
<x-callout wire:key="recommendation-{{ $recommendation->value }}" :type="$recommendation->severity($application)"
:title="$recommendation->title()" @class(['mt-3' => ! $loop->first])>
<p>{{ $recommendation->why($application) }}</p>
@can('update', $application)
<div class="mt-2 flex flex-wrap items-center gap-2">
@if ($recommendation->fixNeedsConfirmation())
<x-modal-confirmation :title="$recommendation->title().'?'" :buttonTitle="$recommendation->fixLabel()"
submitAction="applyFix('{{ $recommendation->value }}')" :actions="[
'The build pack of this application changes to Railpack.',
'The next deployment builds the application with Railpack.',
]" :confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Confirm" />
@else
<x-forms.button wire:click="applyFix('{{ $recommendation->value }}')">
{{ $recommendation->fixLabel() }}
</x-forms.button>
@endif
<x-forms.button wire:click="dismiss('{{ $recommendation->value }}')">Dismiss</x-forms.button>
</div>
@endcan
</x-callout>
@empty
<x-empty size="sm" icon-name="check-circle" title="No recommendations"
description="This application uses the settings that Coolify recommends." />
@endforelse
</x-application.settings-section>
@if ($dismissedRecommendations->isNotEmpty())
<x-application.settings-section id="dismissed-recommendations-section" title="Dismissed"
helper="Recommendations that you dismissed. They do not count in the menu." flush>
@foreach ($dismissedRecommendations as $recommendation)
<div wire:key="dismissed-recommendation-{{ $recommendation->value }}"
class="flex min-h-12 items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 dark:border-white/[0.07]">
<span class="min-w-0 flex-1 truncate text-sm">{{ $recommendation->title() }}</span>
@can('update', $application)
<x-forms.button wire:click="restore('{{ $recommendation->value }}')">Restore</x-forms.button>
@endcan
</div>
@endforeach
</x-application.settings-section>
@endif
</div>
+1
View File
@@ -396,6 +396,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/domains', ApplicationConfiguration::class)->name('project.application.domains');
Route::get('/swarm', ApplicationConfiguration::class)->name('project.application.swarm');
Route::get('/advanced', ApplicationConfiguration::class)->name('project.application.advanced');
Route::get('/recommendations', ApplicationConfiguration::class)->name('project.application.recommendations');
Route::get('/environment-variables', ApplicationConfiguration::class)->name('project.application.environment-variables');
Route::get('/persistent-storage', ApplicationConfiguration::class)->name('project.application.persistent-storage');
Route::get('/backups', ApplicationBackupIndex::class)->name('project.application.backup.index');
+1
View File
@@ -80,6 +80,7 @@ $app->settings->forceFill([
'include_coolify_default_variables' => false, 'traefik_certificate_resolver' => 'custom', 'connect_to_docker_network' => true,
'custom_internal_name' => 'whoami-two', 'is_permanent_redirect_enabled' => true, 'is_log_drain_enabled' => false,
'is_preview_deployments_enabled' => true, 'is_pr_deployments_public_enabled' => true, 'is_auto_deploy_enabled' => false,
'use_build_secrets' => false, 'dismissed_recommendations' => ['use-build-secrets'],
])->save();
// Variables of every kind, and references to every shared scope
@@ -59,7 +59,7 @@ function buildSecretsGithubPrivateKey(): string
describe('PATCH /api/v1/applications/{uuid} use_build_secrets', function () {
test('updates the application setting', function () {
expect($this->application->settings->use_build_secrets)->toBeFalse();
$this->application->settings->update(['use_build_secrets' => false]);
$this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
@@ -88,7 +88,7 @@ describe('PATCH /api/v1/applications/{uuid} use_build_secrets', function () {
});
test('does not change the setting when omitted', function () {
$this->application->settings->update(['use_build_secrets' => true]);
$this->application->settings->update(['use_build_secrets' => false]);
$this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
@@ -96,7 +96,7 @@ describe('PATCH /api/v1/applications/{uuid} use_build_secrets', function () {
])
->assertOk();
expect($this->application->fresh()->settings->use_build_secrets)->toBeTrue();
expect($this->application->fresh()->settings->use_build_secrets)->toBeFalse();
});
});
@@ -142,6 +142,33 @@ describe('POST /api/v1/applications/public use_build_secrets', function () {
});
});
describe('application creation endpoints without use_build_secrets', function () {
test('turn build secrets on for the new application', function (string $endpoint, array $payload) {
if ($endpoint === 'private-deploy-key') {
$payload['private_key_uuid'] = PrivateKey::factory()->create(['team_id' => $this->team->id])->uuid;
}
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
->postJson("/api/v1/applications/{$endpoint}", [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'server_uuid' => $this->server->uuid,
'autogenerate_domain' => false,
...$payload,
])
->assertCreated();
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
expect($application->settings->use_build_secrets)->toBeTrue();
})->with([
'public' => ['public', ['git_repository' => 'https://gitlab.com/coolify/build-secrets-test', 'git_branch' => 'main', 'build_pack' => 'nixpacks', 'ports_exposes' => '3000']],
'private deploy key' => ['private-deploy-key', ['git_repository' => 'git@gitlab.com:coolify/build-secrets-test.git', 'git_branch' => 'main', 'build_pack' => 'dockerfile', 'ports_exposes' => '3000']],
'dockerfile' => ['dockerfile', ['dockerfile' => base64_encode("FROM nginx:alpine\nEXPOSE 80")]],
'docker image' => ['dockerimage', ['docker_registry_image_name' => 'nginx', 'docker_registry_image_tag' => 'alpine', 'ports_exposes' => '80']],
]);
});
describe('other application creation endpoints use_build_secrets', function () {
test('creates a Dockerfile application with build secrets enabled', function () {
$response = $this->withHeaders(buildSecretsApiHeaders($this->bearerToken))
@@ -0,0 +1,68 @@
<?php
use App\Livewire\Project\New\SimpleDockerfile;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = $this->server->standaloneDockers()->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
test('a new application from the create form uses build secrets', function () {
$routeParams = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
];
request()->headers->set('referer', route('project.resource.create', $routeParams).'?destination='.$this->destination->uuid);
Livewire::withUrlParams(['destination' => $this->destination->uuid])
->test(SimpleDockerfile::class, $routeParams)
->set('parameters', $routeParams)
->set('dockerfile', "FROM nginx\nEXPOSE 80\n")
->call('submit');
$application = Application::query()->where('environment_id', $this->environment->id)->sole();
expect($application->settings->use_build_secrets)->toBeTrue();
});
test('a cloned application keeps the build secrets setting of its source', function (bool $sourceValue) {
$source = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$source->settings->update(['use_build_secrets' => $sourceValue]);
$clone = clone_application($source->fresh(), $this->destination, [
'environment_id' => $this->environment->id,
]);
expect($clone->fresh()->settings->use_build_secrets)->toBe($sourceValue);
})->with([
'off' => false,
'on' => true,
]);
@@ -634,6 +634,25 @@ it('mounts underscore nixpacks secrets on RUN lines when build secrets are enabl
->toContain('printenv SAFE_FROM_TOML');
});
it('warns when a Dockerfile in build secrets mode cannot read build variables', function () {
[$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerfile']);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'env_args' => collect(['NODE_VERSION' => '22']),
'build_secrets' => '--secret id=NODE_VERSION,env=NODE_VERSION',
'saved_outputs' => collect([
'dockerfile_content' => "# syntax=docker/dockerfile:1.4\nARG NODE_VERSION\nFROM node:\${NODE_VERSION}\nRUN node --version",
]),
]);
invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfile_for_secrets', '/artifacts/test-app/Dockerfile');
$logs = implode("\n", $job->recordedLogEntries);
expect($logs)
->toContain('docker/dockerfile:1.4')
->toContain('NODE_VERSION');
});
it('skips unsafe reserved Nixpacks plan variable keys before validation', function (string $key) {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'nixpacks',
@@ -0,0 +1,196 @@
<?php
use App\Enums\ApplicationRecommendation;
use App\Livewire\Project\Application\Recommendations;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = $this->server->standaloneDockers()->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->makeApplication = function (array $attributes = [], bool $useBuildSecrets = false, bool $withBuildVariable = true): Application {
$application = Application::factory()->create([
'environment_id' => $this->project->environments()->firstOrFail()->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'build_pack' => 'dockerfile',
...$attributes,
]);
$application->settings->update(['use_build_secrets' => $useBuildSecrets]);
if ($withBuildVariable) {
EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'secret',
'is_buildtime' => true,
'is_runtime' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
}
return $application->fresh();
};
});
function openRecommendationValues(Application $application): array
{
return ApplicationRecommendation::openFor($application->fresh())->map->value->all();
}
test('open recommendations depend on the build pack and the build secrets setting', function (array $attributes, bool $useBuildSecrets, array $expected) {
$application = ($this->makeApplication)($attributes, $useBuildSecrets);
expect(openRecommendationValues($application))->toBe($expected);
})->with([
'dockerfile with build arguments' => [['build_pack' => 'dockerfile'], false, ['use-build-secrets']],
'dockerfile with build secrets' => [['build_pack' => 'dockerfile'], true, []],
'nixpacks with build arguments' => [['build_pack' => 'nixpacks'], false, ['use-build-secrets', 'use-railpack']],
'compose with build arguments' => [['build_pack' => 'dockercompose'], false, ['use-build-secrets']],
'railpack always uses secrets' => [['build_pack' => 'railpack'], false, []],
'docker image does not build' => [['build_pack' => 'dockerimage'], false, []],
]);
test('build secrets are not recommended without user build-time variables', function () {
$application = ($this->makeApplication)(['build_pack' => 'nixpacks'], withBuildVariable: false);
// Nixpacks applications always get the NIXPACKS_NODE_VERSION control variable.
expect($application->environment_variables()->where('key', 'NIXPACKS_NODE_VERSION')->exists())->toBeTrue()
->and(openRecommendationValues($application))->toBe(['use-railpack']);
});
test('build secrets are not recommended for raw compose deployments', function () {
$application = ($this->makeApplication)(['build_pack' => 'dockercompose']);
$application->settings->update(['is_raw_compose_deployment_enabled' => true]);
expect(openRecommendationValues($application))->toBe([]);
});
test('the build secrets recommendation is stronger when the image is pushed to a registry', function () {
$local = ($this->makeApplication)();
$pushed = ($this->makeApplication)(['docker_registry_image_name' => 'ghcr.io/acme/app']);
expect(ApplicationRecommendation::UseBuildSecrets->severity($local))->toBe('warning')
->and(ApplicationRecommendation::UseBuildSecrets->severity($pushed))->toBe('danger')
->and(ApplicationRecommendation::UseBuildSecrets->why($pushed))->toContain('ghcr.io/acme/app')
->and(ApplicationRecommendation::UseBuildSecrets->why($local))->not->toContain('pushes its image');
});
test('an admin turns on build secrets with one click', function () {
$application = ($this->makeApplication)();
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(Recommendations::class, ['application' => $application])
->call('applyFix', 'use-build-secrets')
->assertDispatched('success')
->assertDispatched('refresh')
->assertViewHas('openRecommendations', fn ($open) => $open->isEmpty());
expect($application->fresh()->settings->use_build_secrets)->toBeTrue();
});
test('an admin switches a Nixpacks application to Railpack and keeps the read-only labels in sync', function () {
$application = ($this->makeApplication)(['build_pack' => 'nixpacks'], useBuildSecrets: true);
$application->settings->update(['is_container_label_readonly_enabled' => true]);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(Recommendations::class, ['application' => $application->fresh()])
->call('applyFix', 'use-railpack')
->assertDispatched('success');
$application = $application->fresh();
expect($application->build_pack)->toBe('railpack')
->and(base64_decode($application->custom_labels))->toBe(implode("\n", generateLabelsApplication($application)));
});
test('a dismissed recommendation leaves the open list and can be restored', function () {
$application = ($this->makeApplication)();
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(Recommendations::class, ['application' => $application])
->call('dismiss', 'use-build-secrets')
->assertDispatched('refresh')
->assertViewHas('openRecommendations', fn ($open) => $open->isEmpty())
->assertViewHas('dismissedRecommendations', fn ($dismissed) => $dismissed->map->value->all() === ['use-build-secrets']);
expect($application->fresh()->settings->dismissed_recommendations)->toBe(['use-build-secrets']);
$component->call('restore', 'use-build-secrets')
->assertViewHas('openRecommendations', fn ($open) => $open->map->value->all() === ['use-build-secrets']);
expect($application->fresh()->settings->dismissed_recommendations)->toBe([]);
});
test('unknown dismissed values do not hide recommendations', function () {
$application = ($this->makeApplication)();
$application->settings->update(['dismissed_recommendations' => ['removed-recommendation']]);
expect(openRecommendationValues($application))->toBe(['use-build-secrets']);
});
test('a member cannot apply, dismiss, or restore recommendations', function (string $method) {
$application = ($this->makeApplication)();
$application->settings->update(['dismissed_recommendations' => $method === 'restore' ? ['use-build-secrets'] : null]);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Recommendations::class, ['application' => $application->fresh()])
->call($method, 'use-build-secrets')
->assertDispatched('error')
->assertNotDispatched('refresh');
$settings = $application->fresh()->settings;
expect($settings->use_build_secrets)->toBeFalse()
->and($settings->dismissed_recommendations)->toBe($method === 'restore' ? ['use-build-secrets'] : null);
})->with(['applyFix', 'dismiss', 'restore']);
test('the role in the application team decides after the user switches to a team they own', function () {
$application = ($this->makeApplication)();
$ownTeam = Team::factory()->create();
$this->member->teams()->attach($ownTeam, ['role' => 'owner']);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(Recommendations::class, ['application' => $application]);
session(['currentTeam' => $ownTeam]);
$component->call('applyFix', 'use-build-secrets')->assertDispatched('error');
expect($application->fresh()->settings->use_build_secrets)->toBeFalse();
});
test('an admin of another team cannot change the recommendations of an application', function () {
$application = ($this->makeApplication)();
$otherTeam = Team::factory()->create();
$otherAdmin = User::factory()->create();
$otherAdmin->teams()->attach($otherTeam, ['role' => 'owner']);
$this->actingAs($otherAdmin);
session(['currentTeam' => $otherTeam]);
Livewire::test(Recommendations::class, ['application' => $application])
->call('applyFix', 'use-build-secrets')
->assertDispatched('error');
expect($application->fresh()->settings->use_build_secrets)->toBeFalse();
});
@@ -205,7 +205,7 @@ it('audits application setting changes from each settings control', function (Cl
}, 'docker_images_to_keep'],
'build secrets' => [function () {
Livewire::test(All::class, ['resource' => $this->application])
->set('use_build_secrets', true)
->set('use_build_secrets', false)
->call('instantSave')
->assertHasNoErrors();
}, 'use_build_secrets'],
+37
View File
@@ -1,5 +1,6 @@
<?php
use App\Enums\ApplicationRecommendation;
use App\Livewire\Project\Application\General;
use App\Livewire\Project\Application\Heading;
use App\Models\Application;
@@ -66,6 +67,42 @@ test('the general page does not repeat the nixpacks deprecation notice from the
->assertDontSee(config('deprecations.nixpacks'));
});
test('the Railpack recommendation shows the nixpacks deprecation notice only for nixpacks', function (string $buildPack, bool $showsNotice) {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => $buildPack,
]);
$open = ApplicationRecommendation::openFor($application);
expect($open->contains(ApplicationRecommendation::UseRailpack))->toBe($showsNotice)
->and(ApplicationRecommendation::UseRailpack->why($application))->toContain(config('deprecations.nixpacks'));
})->with([
'nixpacks' => ['nixpacks', true],
'railpack' => ['railpack', false],
]);
test('switching the build pack to railpack on the General page closes the Railpack recommendation', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
'static_image' => 'nginx:alpine',
'base_directory' => '/',
'ports_exposes' => '3000',
'is_http_basic_auth_enabled' => false,
'redirect' => 'no',
]);
Livewire::test(General::class, ['application' => $application])
->set('buildPack', 'railpack');
expect(ApplicationRecommendation::openFor($application->fresh())->contains(ApplicationRecommendation::UseRailpack))->toBeFalse();
});
test('switching the build pack to railpack hides the nixpacks deprecation notice in the heading', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
@@ -1330,6 +1330,21 @@ test('transfer keeps the Traefik certificate resolver of applications and servic
->and(ServiceApplication::where('uuid', 'svc-app-resolver')->firstOrFail()->traefik_certificate_resolver)->toBe('cloudflare');
});
test('transfer keeps the build secrets setting and the dismissed recommendations of an application', function () {
$this->application->settings->update([
'use_build_secrets' => false,
'dismissed_recommendations' => ['use-build-secrets'],
]);
$bundle = $this->exporter->export($this->server->fresh());
removeTransferSourceResources($this);
$this->importer->import($bundle, teamId: Team::factory()->create()->id);
$settings = Application::where('uuid', $this->application->uuid)->sole()->settings;
expect($settings->use_build_secrets)->toBeFalse()
->and($settings->dismissed_recommendations)->toBe(['use-build-secrets']);
});
test('transfer keeps the cloud provider instance id of the server', function () {
$this->server->forceFill(['hetzner_server_id' => 4242, 'vultr_instance_id' => 'vultr-7'])->save();
$bundle = $this->exporter->export($this->server->fresh());
@@ -0,0 +1,60 @@
<?php
use App\Models\Application;
use App\Models\EnvironmentVariable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('applies and dismisses recommended settings and updates the menu count', function () {
config()->set('app.maintenance.store', 'array');
$stack = seedBrowserResourceStack();
$application = createBrowserApplication($stack, ['docker_registry_image_name' => 'ghcr.io/acme/app']);
$application->settings->update(['use_build_secrets' => false]);
EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'secret',
'is_buildtime' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$page = visit('/login')
->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->assertSee('Dashboard')
->navigate(route('project.application.recommendations', [
'project_uuid' => $stack['project']->uuid,
'environment_uuid' => $stack['environment']->uuid,
'application_uuid' => $application->uuid,
]))
->assertSee('Use build secrets')
->assertSee('Switch from Nixpacks to Railpack')
->assertSee('ghcr.io/acme/app');
$menuCount = <<<'JS'
() => document.querySelector('[aria-label$=" open"]')?.textContent.trim() ?? null
JS;
$clickButton = fn (string $text) => $page->script(<<<JS
() => [...document.querySelectorAll('#recommended-settings-section button')]
.find(button => button.textContent.trim() === '{$text}')
.click()
JS);
expect($page->script($menuCount))->toBe('2');
$page->screenshot(filename: 'recommendations-open');
$clickButton('Turn on build secrets');
$page->wait(1.5);
expect($application->fresh()->settings->use_build_secrets)->toBeTrue()
->and($page->script($menuCount))->toBe('1');
$clickButton('Dismiss');
$page->wait(1.5);
$page->assertSee('No recommendations')->assertSee('Restore');
expect($page->script($menuCount))->toBeNull();
$page->assertNoJavaScriptErrors();
$page->screenshot(filename: 'recommendations-empty');
});