fix(previews): keep production values for previews from before the upgrade

Public pull request previews no longer get production values, and no
preview falls back to production variables. Existing previews were
deployed with these values; for example, their databases were
initialized with the production password, so they broke on redeploy.

Previews that exist at the upgrade get keeps_production_values and
keep the earlier behavior. Previews created later do not. Server
transfer moves the flag.
This commit is contained in:
Andras Bacsai
2026-10-28 12:10:27 +01:00
parent 5dacaed9f2
commit 2dcf8eeca3
10 changed files with 98 additions and 4 deletions
+9 -1
View File
@@ -2018,6 +2018,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$envs->push($this->runtimeEnvironmentAssignment($env));
}
// A preview from before v4.5.1 also gets the production variables that have no preview copy.
if ($this->preview?->keeps_production_values && $runtime_environment_variables_preview->isNotEmpty()) {
$previewKeys = $runtime_environment_variables_preview->pluck('key')->all();
$sorted_environment_variables
->filter(fn (EnvironmentVariable $env) => $env->is_runtime && ! in_array($env->key, $previewKeys, true) && ! $this->shouldOmitBlankComposeEnvironmentVariable($env))
->each(fn (EnvironmentVariable $env) => $envs->push($this->runtimeEnvironmentAssignment($env)));
}
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
if ($this->deployedPreviewVariables()->where('key', 'PORT')->isEmpty()) {
@@ -2619,7 +2627,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $this->previewVariableIdsWithProductionValues;
}
if ($this->pull_request_id === 0 || ! $this->application->settings->is_pr_deployments_public_enabled) {
if ($this->pull_request_id === 0 || ! $this->application->settings->is_pr_deployments_public_enabled || $this->preview?->keeps_production_values) {
return $this->previewVariableIdsWithProductionValues = [];
}
+2
View File
@@ -34,6 +34,7 @@ class ApplicationPreview extends BaseModel
'last_online_at',
'domain_dns_statuses',
'domain_port_overrides',
'keeps_production_values',
];
protected $hidden = [
@@ -44,6 +45,7 @@ class ApplicationPreview extends BaseModel
'pull_request_id' => 'integer',
'domain_dns_statuses' => 'array',
'domain_port_overrides' => 'array',
'keeps_production_values' => 'boolean',
];
protected static function booted(): void
@@ -1644,6 +1644,7 @@ class ServerTransferExporter
'docker_registry_image_tag' => $preview->docker_registry_image_tag,
'domain_port_overrides' => $preview->domain_port_overrides,
'max_restart_count' => $preview->max_restart_count,
'keeps_production_values' => $preview->keeps_production_values,
'persistent_storages' => method_exists($preview, 'persistentStorages')
? $this->exportPersistentStorages($preview->persistentStorages)
: [],
@@ -2333,6 +2333,7 @@ class ServerTransferImporter
'docker_registry_image_tag' => data_get($previewPayload, 'docker_registry_image_tag'),
'domain_port_overrides' => data_get($previewPayload, 'domain_port_overrides'),
'max_restart_count' => (int) data_get($previewPayload, 'max_restart_count', 0),
'keeps_production_values' => (bool) data_get($previewPayload, 'keeps_production_values', false),
];
if ($preview) {
+5 -2
View File
@@ -1150,8 +1150,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$pullRequestId = $pull_request_id;
$isPullRequest = $pullRequestId == 0 ? false : true;
// A public preview can run the compose file of a fork pull request.
$isPublicPullRequest = $isPullRequest && (bool) data_get($resource, 'settings.is_pr_deployments_public_enabled');
$pullRequestPreview = $isPullRequest
? ($resource->previews()->find($preview_id) ?? ApplicationPreview::where('application_id', $resource->id)->where('pull_request_id', $pullRequestId)->first())
: null;
// A public preview can run the compose file of a fork pull request. A preview from before v4.5.1 keeps the production values.
$isPublicPullRequest = $isPullRequest && (bool) data_get($resource, 'settings.is_pr_deployments_public_enabled') && ! $pullRequestPreview?->keeps_production_values;
$server = data_get($resource, 'destination.server');
try {
$yaml = parseDockerComposeYaml($compose);
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Until v4.5.1, previews used production values: the production variables that had no preview copy,
* and on public pull requests also the preview copies with the production value. Existing previews
* keep them, so their databases and services keep working. Previews created later do not get them.
*/
public function up(): void
{
Schema::table('application_previews', function (Blueprint $table) {
$table->boolean('keeps_production_values')->default(false);
});
DB::table('application_previews')->update(['keeps_production_values' => true]);
}
public function down(): void
{
Schema::table('application_previews', function (Blueprint $table) {
$table->dropColumn('keeps_production_values');
});
}
};
+1 -1
View File
@@ -103,7 +103,7 @@ When you add a model or a setting to the transfer, add it to this file too.
| ✅ | Volume backup | on the persistent volume: S3 on, stop during backup, all retention values, timeout, missing-backup days |
| ✅ | Scheduled tasks | the seed has one; add a disabled one with container + timeout |
| ✅ | Tag | |
| ✅ | Preview deployment | PR preview with domain port overrides + max restart count (needs the GitHub App, or create it in the database) |
| ✅ | Preview deployment | PR preview with domain port overrides + max restart count + keeps production values (previews from before v4.5.1) (needs the GitHub App, or create it in the database) |
| ✅ | Secret manager link | the app loads variables from the secret manager token |
| ✅ | Managed DNS record | owned record in the Cloudflare zone for the app domain. Expected: the target takes ownership (provider comment changes) |
@@ -2050,3 +2050,29 @@ it('does not warn about a PORT mismatch when readonly labels are disabled', func
expect(collect($job->recordedLogEntries)->contains(fn (string $entry) => str_contains($entry, 'PORT environment variable')))->toBeFalse();
});
it('gives a preview from before v4.5.1 the production values it had', function () {
[$application, $server] = makeDeploymentControlVarFixture();
$application->settings()->update(['is_pr_deployments_public_enabled' => true]);
createApplicationEnvironmentVariable($application, ['key' => 'DB_PASSWORD', 'value' => 'production-secret', 'is_buildtime' => false]);
createApplicationEnvironmentVariable($application, ['key' => 'APP_NAME', 'value' => 'coolify', 'is_buildtime' => false]);
$application->environment_variables_preview()->where('key', 'APP_NAME')->delete();
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'pull_request_id' => 99,
'remote_secrets_cache' => [],
'preview' => ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 99,
'pull_request_html_url' => 'https://example.com/pr/99',
'keeps_production_values' => true,
]),
]);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('DB_PASSWORD=') && str($env)->contains('production-secret')))->toBeTrue()
->and($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=') && str($env)->contains('coolify')))->toBeTrue();
});
@@ -215,3 +215,24 @@ it('replaces an existing preview copy of a production magic value for a public p
->not->toBeEmpty()
->not->toBe($production['SERVICE_PASSWORD_DB']);
});
it('keeps the production magic value of a public pull request preview from before v4.5.1', function () {
composePreviewMagicParse($this->application);
$production = composePreviewMagicValues($this->application)['production'];
$this->application->environment_variables_preview()->create([
'key' => 'SERVICE_PASSWORD_DB',
'value' => $production['SERVICE_PASSWORD_DB'],
'is_preview' => true,
]);
ApplicationPreview::query()->create([
'application_id' => $this->application->id,
'pull_request_id' => 7,
'pull_request_html_url' => 'https://github.com/example/repository/pull/7',
'keeps_production_values' => true,
]);
$this->application->settings->update(['is_pr_deployments_public_enabled' => true]);
composePreviewMagicParse($this->application, 7);
expect(composePreviewMagicValues($this->application)['preview']['SERVICE_PASSWORD_DB'])->toBe($production['SERVICE_PASSWORD_DB']);
});
@@ -859,6 +859,7 @@ test('service nested apps dbs volumes backups and db file storages round-trip',
'fqdn' => 'https://pr-42.app.example.com',
'status' => 'running:healthy',
'git_type' => 'github',
'keeps_production_values' => true,
]);
$preview->uuid = 'preview-pr-42';
$preview->save();
@@ -969,6 +970,7 @@ test('service nested apps dbs volumes backups and db file storages round-trip',
expect($importedApp)->not->toBeNull()
->and($importedApp->previews)->toHaveCount(1)
->and($importedApp->previews->first()->uuid)->toBe('preview-pr-42')
->and($importedApp->previews->first()->keeps_production_values)->toBeTrue()
->and($importedApp->previews->first()->persistentStorages)->toHaveCount(1);
$importedVolBackup = ScheduledVolumeBackup::where('uuid', 'svc-app-vol-backup')->first();