diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 9d927d10c..2af380a45 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1333,6 +1333,22 @@ private function generate_runtime_environment_variables() foreach ($runtime_environment_variables_preview as $env) { $envs->push($env->key.'='.$env->real_value); } + + // Fall back to production env vars for keys not overridden by preview vars, + // but only when preview vars are configured. This ensures variables like + // DB_PASSWORD that are only set for production will be available in the + // preview .env file (fixing ${VAR} interpolation in docker-compose YAML), + // while avoiding leaking production values when previews aren't configured. + if ($runtime_environment_variables_preview->isNotEmpty()) { + $previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray(); + $fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) { + return $env->is_runtime && ! in_array($env->key, $previewKeys); + }); + foreach ($fallback_production_vars as $env) { + $envs->push($env->key.'='.$env->real_value); + } + } + // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) { diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index cf60d5ab5..5acd4c1e4 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -32,6 +32,11 @@ )] class EnvironmentVariable extends BaseModel { + protected $attributes = [ + 'is_runtime' => true, + 'is_buildtime' => true, + ]; + protected $fillable = [ // Core identification 'key', diff --git a/resources/views/components/applications/links.blade.php b/resources/views/components/applications/links.blade.php index 26b1cedf5..85e8f7431 100644 --- a/resources/views/components/applications/links.blade.php +++ b/resources/views/components/applications/links.blade.php @@ -4,7 +4,7 @@ @if ( (data_get($application, 'fqdn') || - collect(json_decode($this->application->docker_compose_domains))->count() > 0 || + collect(json_decode($this->application->docker_compose_domains))->contains(fn($fqdn) => !empty(data_get($fqdn, 'domain'))) || data_get($application, 'previews', collect([]))->count() > 0 || data_get($application, 'ports_mappings_array')) && data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true) diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index e27eda8b6..d743e346e 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -49,7 +49,13 @@ !is_null($parsedServices) && count($parsedServices) > 0 && !$application->settings->is_raw_compose_deployment_enabled) -

Domains

+ @php + $hasNonDatabaseService = collect(data_get($parsedServices, 'services', [])) + ->contains(fn($service) => !isDatabaseImage(data_get($service, 'image'))); + @endphp + @if ($hasNonDatabaseService) +

Domains

+ @endif @foreach (data_get($parsedServices, 'services') as $serviceName => $service) @if (!isDatabaseImage(data_get($service, 'image')))
@@ -86,18 +92,20 @@ ]" /> @endcan @endif -
- @if ($application->could_set_build_commands()) - - @endif - @if ($isStatic && $buildPack !== 'static') - - @endif -
+ @if ($application->could_set_build_commands() || ($isStatic && $buildPack !== 'static')) +
+ @if ($application->could_set_build_commands()) + + @endif + @if ($isStatic && $buildPack !== 'static') + + @endif +
+ @endif @if ($buildPack !== 'dockercompose')
@if ($application->settings->is_container_label_readonly_enabled == false) @@ -209,7 +217,7 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @endif
@endif -
+

Build

@if ($application->build_pack === 'dockerimage')

Logs

- @if (str($status)->contains('exited')) -
The resource is not running.
- @else -
- Loading containers... -
-
- @forelse ($servers as $server) -
-

Server: {{ $server->name }}

- @if ($server->isFunctional()) - @if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0) - @php - $totalContainers = collect($serverContainers)->flatten(1)->count(); - @endphp - @foreach ($serverContainers[$server->id] as $container) - - @endforeach - @else -
No containers are running on server: {{ $server->name }}
- @endif +
+ Loading containers... +
+
+ @forelse ($servers as $server) +
+

Server: {{ $server->name }}

+ @if ($server->isFunctional()) + @if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0) + @php + $totalContainers = collect($serverContainers)->flatten(1)->count(); + @endphp + @foreach ($serverContainers[$server->id] as $container) + + @endforeach @else -
Server {{ $server->name }} is not functional.
+
No containers are running on server: {{ $server->name }}
@endif -
- @empty -
No functional server found for the application.
- @endforelse -
- @endif + @else +
Server {{ $server->name }} is not functional.
+ @endif +
+ @empty +
No functional server found for the application.
+ @endforelse +
@elseif ($type === 'database')

Logs

diff --git a/tests/Feature/PreviewEnvVarFallbackTest.php b/tests/Feature/PreviewEnvVarFallbackTest.php new file mode 100644 index 000000000..e3fc3023f --- /dev/null +++ b/tests/Feature/PreviewEnvVarFallbackTest.php @@ -0,0 +1,247 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + ]); + + $this->actingAs($this->user); +}); + +/** + * Simulate the preview .env generation logic from + * ApplicationDeploymentJob::generate_runtime_environment_variables() + * including the production fallback fix. + */ +function simulatePreviewEnvGeneration(Application $application): \Illuminate\Support\Collection +{ + $sorted_environment_variables = $application->environment_variables->sortBy('id'); + $sorted_environment_variables_preview = $application->environment_variables_preview->sortBy('id'); + + $envs = collect([]); + + // Preview vars + $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(fn ($env) => $env->is_runtime); + foreach ($runtime_environment_variables_preview as $env) { + $envs->push($env->key.'='.$env->real_value); + } + + // Fallback: production vars not overridden by preview, + // only when preview vars are configured + if ($runtime_environment_variables_preview->isNotEmpty()) { + $previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray(); + $fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) { + return $env->is_runtime && ! in_array($env->key, $previewKeys); + }); + foreach ($fallback_production_vars as $env) { + $envs->push($env->key.'='.$env->real_value); + } + } + + return $envs; +} + +test('production vars fall back when preview vars exist but do not cover all keys', function () { + // Create two production vars (booted hook auto-creates preview copies) + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + EnvironmentVariable::create([ + 'key' => 'APP_KEY', + 'value' => 'app_key_value', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Delete only the DB_PASSWORD preview copy — APP_KEY preview copy remains + $this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->delete(); + $this->application->refresh(); + + // Preview has APP_KEY but not DB_PASSWORD + expect($this->application->environment_variables_preview()->where('key', 'APP_KEY')->count())->toBe(1); + expect($this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->count())->toBe(0); + + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + // DB_PASSWORD should fall back from production + expect($envString)->toContain('DB_PASSWORD='); + // APP_KEY should use the preview value + expect($envString)->toContain('APP_KEY='); +}); + +test('no fallback when no preview vars are configured at all', function () { + // Create a production-only var (booted hook auto-creates preview copy) + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Delete ALL preview copies — simulates no preview config + $this->application->environment_variables_preview()->delete(); + $this->application->refresh(); + + expect($this->application->environment_variables_preview()->count())->toBe(0); + + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + // Should NOT fall back to production when no preview vars exist + expect($envString)->not->toContain('DB_PASSWORD='); +}); + +test('preview var overrides production var when both exist', function () { + // Create production var (auto-creates preview copy) + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'prod_password', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Update the auto-created preview copy with a different value + $this->application->environment_variables_preview() + ->where('key', 'DB_PASSWORD') + ->update(['value' => encrypt('preview_password')]); + + $this->application->refresh(); + $envs = simulatePreviewEnvGeneration($this->application); + + // Should contain preview value only, not production + $envEntries = $envs->filter(fn ($e) => str_starts_with($e, 'DB_PASSWORD=')); + expect($envEntries)->toHaveCount(1); + expect($envEntries->first())->toContain('preview_password'); +}); + +test('preview-only var works without production counterpart', function () { + // Create a preview-only var directly (no production counterpart) + EnvironmentVariable::create([ + 'key' => 'PREVIEW_ONLY_VAR', + 'value' => 'preview_value', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $this->application->refresh(); + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + expect($envString)->toContain('PREVIEW_ONLY_VAR='); +}); + +test('buildtime-only production vars are not included in preview fallback', function () { + // Create a runtime preview var so fallback is active + EnvironmentVariable::create([ + 'key' => 'SOME_PREVIEW_VAR', + 'value' => 'preview_value', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Create a buildtime-only production var + EnvironmentVariable::create([ + 'key' => 'BUILD_SECRET', + 'value' => 'build_only', + 'is_preview' => false, + 'is_runtime' => false, + 'is_buildtime' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + // Delete the auto-created preview copy of BUILD_SECRET + $this->application->environment_variables_preview()->where('key', 'BUILD_SECRET')->delete(); + $this->application->refresh(); + + $envs = simulatePreviewEnvGeneration($this->application); + + $envString = $envs->implode("\n"); + expect($envString)->not->toContain('BUILD_SECRET'); + expect($envString)->toContain('SOME_PREVIEW_VAR='); +}); + +test('preview env var inherits is_runtime and is_buildtime from production var', function () { + // Create production var WITH explicit flags + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => true, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $preview = EnvironmentVariable::where('key', 'DB_PASSWORD') + ->where('is_preview', true) + ->where('resourceable_id', $this->application->id) + ->first(); + + expect($preview)->not->toBeNull(); + expect($preview->is_runtime)->toBeTrue(); + expect($preview->is_buildtime)->toBeTrue(); +}); + +test('preview env var gets correct defaults when production var created without explicit flags', function () { + // Simulate code paths (docker-compose parser, dev view bulk submit) that create + // env vars without explicitly setting is_runtime/is_buildtime + EnvironmentVariable::create([ + 'key' => 'DB_PASSWORD', + 'value' => 'secret123', + 'is_preview' => false, + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ]); + + $preview = EnvironmentVariable::where('key', 'DB_PASSWORD') + ->where('is_preview', true) + ->where('resourceable_id', $this->application->id) + ->first(); + + expect($preview)->not->toBeNull(); + expect($preview->is_runtime)->toBeTrue(); + expect($preview->is_buildtime)->toBeTrue(); +});