diff --git a/.ai/core/deployment-architecture.md b/.ai/core/deployment-architecture.md index 272f00e4c..adc07cf20 100644 --- a/.ai/core/deployment-architecture.md +++ b/.ai/core/deployment-architecture.md @@ -270,6 +270,84 @@ ### Build Optimization - **Build artifact** reuse - **Parallel build** processing +### Docker Build Cache Preservation + +Coolify provides settings to preserve Docker build cache across deployments, addressing cache invalidation issues. + +#### The Problem + +By default, Coolify injects `ARG` statements into user Dockerfiles for build-time variables. This breaks Docker's cache mechanism because: +1. **ARG declarations invalidate cache** - Any change in ARG values after the `ARG` instruction invalidates all subsequent layers +2. **SOURCE_COMMIT changes every commit** - Causes full rebuilds even when code changes are minimal + +#### Application Settings + +Two toggles in **Advanced Settings** control this behavior: + +| Setting | Default | Description | +|---------|---------|-------------| +| `inject_build_args_to_dockerfile` | `true` | Controls whether Coolify adds `ARG` statements to Dockerfile | +| `include_source_commit_in_build` | `false` | Controls whether `SOURCE_COMMIT` is included in build context | + +**Database columns:** `application_settings.inject_build_args_to_dockerfile`, `application_settings.include_source_commit_in_build` + +#### Buildpack Coverage + +| Build Pack | ARG Injection | Method | +|------------|---------------|--------| +| **Dockerfile** | ✅ Yes | `add_build_env_variables_to_dockerfile()` | +| **Docker Compose** (with `build:`) | ✅ Yes | `modify_dockerfiles_for_compose()` | +| **PR Deployments** (Dockerfile only) | ✅ Yes | `add_build_env_variables_to_dockerfile()` | +| **Nixpacks** | ❌ No | Generates its own Dockerfile internally | +| **Static** | ❌ No | Uses internal Dockerfile | +| **Docker Image** | ❌ No | No build phase | + +#### How It Works + +**When `inject_build_args_to_dockerfile` is enabled (default):** +```dockerfile +# Coolify modifies your Dockerfile to add: +FROM node:20 +ARG MY_VAR=value +ARG COOLIFY_URL=... +ARG SOURCE_COMMIT=abc123 # (if include_source_commit_in_build is true) +# ... rest of your Dockerfile +``` + +**When `inject_build_args_to_dockerfile` is disabled:** +- Coolify does NOT modify the Dockerfile +- `--build-arg` flags are still passed (harmless without matching `ARG` in Dockerfile) +- User must manually add `ARG` statements for any build-time variables they need + +**When `include_source_commit_in_build` is disabled (default):** +- `SOURCE_COMMIT` is NOT included in build-time variables +- `SOURCE_COMMIT` is still available at **runtime** (in container environment) +- Docker cache preserved across different commits + +#### Recommended Configuration + +| Use Case | inject_build_args | include_source_commit | Cache Behavior | +|----------|-------------------|----------------------|----------------| +| Maximum cache preservation | `false` | `false` | Best cache retention | +| Need build-time vars, no commit | `true` | `false` | Cache breaks on var changes | +| Need commit at build-time | `true` | `true` | Cache breaks every commit | +| Manual ARG management | `false` | `true` | Cache preserved (no ARG in Dockerfile) | + +#### Implementation Details + +**Files:** +- `app/Jobs/ApplicationDeploymentJob.php`: + - `set_coolify_variables()` - SOURCE_COMMIT conditional (~line 1960) + - `generate_coolify_env_variables()` - SOURCE_COMMIT conditional for forBuildTime + - `generate_env_variables()` - SOURCE_COMMIT conditional (~line 2340) + - `add_build_env_variables_to_dockerfile()` - ARG injection toggle (~line 3358) + - `modify_dockerfiles_for_compose()` - Docker Compose ARG injection (~line 3530) +- `app/Models/ApplicationSetting.php` - Boolean casts +- `app/Livewire/Project/Application/Advanced.php` - UI binding +- `resources/views/livewire/project/application/advanced.blade.php` - Checkboxes + +**Note:** Docker Compose services without a `build:` section (image-only) are automatically skipped. + ### Runtime Optimization - **Container resource** limits - **Auto-scaling** based on metrics diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 8c1769181..13938675a 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1957,7 +1957,12 @@ private function deploy_to_additional_destinations() private function set_coolify_variables() { - $this->coolify_variables = "SOURCE_COMMIT={$this->commit} "; + $this->coolify_variables = ''; + + // Only include SOURCE_COMMIT in build context if enabled in settings + if ($this->application->settings->include_source_commit_in_build) { + $this->coolify_variables .= "SOURCE_COMMIT={$this->commit} "; + } if ($this->pull_request_id === 0) { $fqdn = $this->application->fqdn; } else { @@ -2242,12 +2247,15 @@ private function generate_coolify_env_variables(bool $forBuildTime = false): Col $coolify_envs = collect([]); $local_branch = $this->branch; if ($this->pull_request_id !== 0) { - // Add SOURCE_COMMIT if not exists - if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) { - if (! is_null($this->commit)) { - $coolify_envs->put('SOURCE_COMMIT', $this->commit); - } else { - $coolify_envs->put('SOURCE_COMMIT', 'unknown'); + // Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time + // SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build + if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) { + if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) { + if (! is_null($this->commit)) { + $coolify_envs->put('SOURCE_COMMIT', $this->commit); + } else { + $coolify_envs->put('SOURCE_COMMIT', 'unknown'); + } } } if ($this->application->environment_variables_preview->where('key', 'COOLIFY_FQDN')->isEmpty()) { @@ -2283,12 +2291,15 @@ private function generate_coolify_env_variables(bool $forBuildTime = false): Col add_coolify_default_environment_variables($this->application, $coolify_envs, $this->application->environment_variables_preview); } else { - // Add SOURCE_COMMIT if not exists - if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) { - if (! is_null($this->commit)) { - $coolify_envs->put('SOURCE_COMMIT', $this->commit); - } else { - $coolify_envs->put('SOURCE_COMMIT', 'unknown'); + // Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time + // SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build + if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) { + if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) { + if (! is_null($this->commit)) { + $coolify_envs->put('SOURCE_COMMIT', $this->commit); + } else { + $coolify_envs->put('SOURCE_COMMIT', 'unknown'); + } } } if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) { @@ -2331,7 +2342,11 @@ private function generate_coolify_env_variables(bool $forBuildTime = false): Col private function generate_env_variables() { $this->env_args = collect([]); - $this->env_args->put('SOURCE_COMMIT', $this->commit); + + // Only include SOURCE_COMMIT in build args if enabled in settings + if ($this->application->settings->include_source_commit_in_build) { + $this->env_args->put('SOURCE_COMMIT', $this->commit); + } $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { @@ -3344,100 +3359,121 @@ private function add_build_env_variables_to_dockerfile() { if ($this->dockerBuildkitSupported) { // We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets + return; + } + + // Skip ARG injection if disabled by user - preserves Docker build cache + if ($this->application->settings->inject_build_args_to_dockerfile === false) { + $this->application_deployment_queue->addLogEntry('Skipping Dockerfile ARG injection (disabled in settings).', hidden: true); + + return; + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), + 'hidden' => true, + 'save' => 'dockerfile', + 'ignore_errors' => true, + ]); + $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); + + // Find all FROM instruction positions + $fromLines = $this->findFromInstructionLines($dockerfile); + + // If no FROM instructions found, skip ARG insertion + if (empty($fromLines)) { + return; + } + + // Collect all ARG statements to insert + $argsToInsert = collect(); + + if ($this->pull_request_id === 0) { + // Only add environment variables that are available during build + $envs = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + foreach ($envs as $env) { + if (data_get($env, 'is_multiline') === true) { + $argsToInsert->push("ARG {$env->key}"); + } else { + $argsToInsert->push("ARG {$env->key}={$env->real_value}"); + } + } + // Add Coolify variables as ARGs + if ($this->coolify_variables) { + $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) + ->filter() + ->map(function ($var) { + return "ARG {$var}"; + }); + $argsToInsert = $argsToInsert->merge($coolify_vars); + } } else { - $this->execute_remote_command([ + // Only add preview environment variables that are available during build + $envs = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + foreach ($envs as $env) { + if (data_get($env, 'is_multiline') === true) { + $argsToInsert->push("ARG {$env->key}"); + } else { + $argsToInsert->push("ARG {$env->key}={$env->real_value}"); + } + } + // Add Coolify variables as ARGs + if ($this->coolify_variables) { + $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) + ->filter() + ->map(function ($var) { + return "ARG {$var}"; + }); + $argsToInsert = $argsToInsert->merge($coolify_vars); + } + } + + // Development logging to show what ARGs are being injected + if (isDev()) { + $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); + $this->application_deployment_queue->addLogEntry('[DEBUG] Dockerfile ARG Injection'); + $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); + $this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToInsert->count()); + foreach ($argsToInsert as $arg) { + // Only show ARG key, not the value (for security) + $argKey = str($arg)->after('ARG ')->before('=')->toString(); + $this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}"); + } + } + + // Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers) + if ($argsToInsert->isNotEmpty()) { + foreach (array_reverse($fromLines) as $fromLineIndex) { + // Insert all ARGs after this FROM instruction + foreach ($argsToInsert->reverse() as $arg) { + $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); + } + } + $envs_mapped = $envs->mapWithKeys(function ($env) { + return [$env->key => $env->real_value]; + }); + $secrets_hash = $this->generate_secrets_hash($envs_mapped); + $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); + } + + $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); + $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), + 'hidden' => true, + ], + [ executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), 'hidden' => true, - 'save' => 'dockerfile', 'ignore_errors' => true, ]); - $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); - - // Find all FROM instruction positions - $fromLines = $this->findFromInstructionLines($dockerfile); - - // If no FROM instructions found, skip ARG insertion - if (empty($fromLines)) { - return; - } - - // Collect all ARG statements to insert - $argsToInsert = collect(); - - if ($this->pull_request_id === 0) { - // Only add environment variables that are available during build - $envs = $this->application->environment_variables() - ->where('key', 'not like', 'NIXPACKS_%') - ->where('is_buildtime', true) - ->get(); - foreach ($envs as $env) { - if (data_get($env, 'is_multiline') === true) { - $argsToInsert->push("ARG {$env->key}"); - } else { - $argsToInsert->push("ARG {$env->key}={$env->real_value}"); - } - } - // Add Coolify variables as ARGs - if ($this->coolify_variables) { - $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) - ->filter() - ->map(function ($var) { - return "ARG {$var}"; - }); - $argsToInsert = $argsToInsert->merge($coolify_vars); - } - } else { - // Only add preview environment variables that are available during build - $envs = $this->application->environment_variables_preview() - ->where('key', 'not like', 'NIXPACKS_%') - ->where('is_buildtime', true) - ->get(); - foreach ($envs as $env) { - if (data_get($env, 'is_multiline') === true) { - $argsToInsert->push("ARG {$env->key}"); - } else { - $argsToInsert->push("ARG {$env->key}={$env->real_value}"); - } - } - // Add Coolify variables as ARGs - if ($this->coolify_variables) { - $coolify_vars = collect(explode(' ', trim($this->coolify_variables))) - ->filter() - ->map(function ($var) { - return "ARG {$var}"; - }); - $argsToInsert = $argsToInsert->merge($coolify_vars); - } - } - - // Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers) - if ($argsToInsert->isNotEmpty()) { - foreach (array_reverse($fromLines) as $fromLineIndex) { - // Insert all ARGs after this FROM instruction - foreach ($argsToInsert->reverse() as $arg) { - $dockerfile->splice($fromLineIndex + 1, 0, [$arg]); - } - } - $envs_mapped = $envs->mapWithKeys(function ($env) { - return [$env->key => $env->real_value]; - }); - $secrets_hash = $this->generate_secrets_hash($envs_mapped); - $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); - } - - $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); - $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), - 'hidden' => true, - 'ignore_errors' => true, - ]); - } } private function modify_dockerfile_for_secrets($dockerfile_path) @@ -3510,6 +3546,13 @@ private function modify_dockerfiles_for_compose($composeFile) return; } + // Skip ARG injection if disabled by user - preserves Docker build cache + if ($this->application->settings->inject_build_args_to_dockerfile === false) { + $this->application_deployment_queue->addLogEntry('Skipping Docker Compose Dockerfile ARG injection (disabled in settings).', hidden: true); + + return; + } + // Generate env variables if not already done // This populates $this->env_args with both user-defined and COOLIFY_* variables if (! $this->env_args || $this->env_args->isEmpty()) { @@ -3600,6 +3643,18 @@ private function modify_dockerfiles_for_compose($composeFile) continue; } + // Development logging to show what ARGs are being injected for Docker Compose + if (isDev()) { + $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); + $this->application_deployment_queue->addLogEntry("[DEBUG] Docker Compose ARG Injection - Service: {$serviceName}"); + $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================'); + $this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToAdd->count()); + foreach ($argsToAdd as $arg) { + $argKey = str($arg)->after('ARG ')->toString(); + $this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}"); + } + } + $totalAdded = 0; $offset = 0; diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index ed15ab258..cf7ef3e0b 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -37,6 +37,12 @@ class Advanced extends Component #[Validate(['boolean'])] public bool $disableBuildCache = false; + #[Validate(['boolean'])] + public bool $injectBuildArgsToDockerfile = true; + + #[Validate(['boolean'])] + public bool $includeSourceCommitInBuild = false; + #[Validate(['boolean'])] public bool $isLogDrainEnabled = false; @@ -110,6 +116,8 @@ public function syncData(bool $toModel = false) $this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled; $this->application->settings->connect_to_docker_network = $this->isConnectToDockerNetworkEnabled; $this->application->settings->disable_build_cache = $this->disableBuildCache; + $this->application->settings->inject_build_args_to_dockerfile = $this->injectBuildArgsToDockerfile; + $this->application->settings->include_source_commit_in_build = $this->includeSourceCommitInBuild; $this->application->settings->save(); } else { $this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled(); @@ -134,6 +142,8 @@ public function syncData(bool $toModel = false) $this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled; $this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network; $this->disableBuildCache = $this->application->settings->disable_build_cache; + $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; + $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; } } diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 26cb937b3..de545e9bb 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -15,6 +15,8 @@ class ApplicationSetting extends Model 'is_container_label_escape_enabled' => 'boolean', 'is_container_label_readonly_enabled' => 'boolean', 'use_build_secrets' => 'boolean', + 'inject_build_args_to_dockerfile' => 'boolean', + 'include_source_commit_in_build' => 'boolean', 'is_auto_deploy_enabled' => 'boolean', 'is_force_https_enabled' => 'boolean', 'is_debug_enabled' => 'boolean', diff --git a/database/migrations/2025_11_26_000000_add_build_cache_settings_to_application_settings.php b/database/migrations/2025_11_26_000000_add_build_cache_settings_to_application_settings.php new file mode 100644 index 000000000..5f41816f6 --- /dev/null +++ b/database/migrations/2025_11_26_000000_add_build_cache_settings_to_application_settings.php @@ -0,0 +1,30 @@ +boolean('inject_build_args_to_dockerfile')->default(true)->after('use_build_secrets'); + $table->boolean('include_source_commit_in_build')->default(false)->after('inject_build_args_to_dockerfile'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('inject_build_args_to_dockerfile'); + $table->dropColumn('include_source_commit_in_build'); + }); + } +}; diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 62d4380e9..bd5f5b1e8 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -22,6 +22,14 @@ @endif + + @if ($application->settings->is_container_label_readonly_enabled)