diff --git a/.ai/README.md b/.ai/README.md
index da24b09dc..ea7812496 100644
--- a/.ai/README.md
+++ b/.ai/README.md
@@ -16,7 +16,7 @@ ### 📚 Core Documentation
- **[Technology Stack](core/technology-stack.md)** - All versions, packages, and dependencies (Laravel 12.4.1, PHP 8.4.7, etc.)
- **[Project Overview](core/project-overview.md)** - What Coolify is and how it works
- **[Application Architecture](core/application-architecture.md)** - System design and component relationships
-- **[Deployment Architecture](core/deployment-architecture.md)** - How deployments work end-to-end
+- **[Deployment Architecture](core/deployment-architecture.md)** - How deployments work end-to-end, including Coolify Docker Compose extensions (custom fields)
### 💻 Development
Day-to-day development practices:
@@ -85,6 +85,13 @@ ### Laravel-Specific Questions
- Pest testing patterns
- Laravel conventions
+### Docker Compose Extensions
+→ [core/deployment-architecture.md](core/deployment-architecture.md#coolify-docker-compose-extensions)
+- Custom fields: `exclude_from_hc`, `content`, `isDirectory`
+- How to use inline file content
+- Health check exclusion patterns
+- Volume creation control
+
### Version Numbers
→ [core/technology-stack.md](core/technology-stack.md)
- **Single source of truth** for all version numbers
diff --git a/.ai/core/deployment-architecture.md b/.ai/core/deployment-architecture.md
index ec19cd0cd..272f00e4c 100644
--- a/.ai/core/deployment-architecture.md
+++ b/.ai/core/deployment-architecture.md
@@ -303,3 +303,286 @@ ### External Services
- **External database** connections
- **Third-party monitoring** tools
- **Custom notification** channels
+
+---
+
+## Coolify Docker Compose Extensions
+
+Coolify extends standard Docker Compose with custom fields (often called "magic fields") that provide Coolify-specific functionality. These extensions are processed during deployment and stripped before sending the final compose file to Docker, maintaining full compatibility with Docker's compose specification.
+
+### Overview
+
+**Why Custom Fields?**
+- Enable Coolify-specific features without breaking Docker Compose compatibility
+- Simplify configuration by embedding content directly in compose files
+- Allow fine-grained control over health check monitoring
+- Reduce external file dependencies
+
+**Processing Flow:**
+1. User defines compose file with custom fields
+2. Coolify parses and processes custom fields (creates files, stores settings)
+3. Custom fields are stripped from final compose sent to Docker
+4. Docker receives standard, valid compose file
+
+### Service-Level Extensions
+
+#### `exclude_from_hc`
+
+**Type:** Boolean
+**Default:** `false`
+**Purpose:** Exclude specific services from health check monitoring while still showing their status
+
+**Example Usage:**
+```yaml
+services:
+ watchtower:
+ image: containrrr/watchtower
+ exclude_from_hc: true # Don't monitor this service's health
+
+ backup:
+ image: postgres:16
+ exclude_from_hc: true # Backup containers don't need monitoring
+ restart: always
+```
+
+**Behavior:**
+- Container status is still calculated from Docker state (running, exited, etc.)
+- Status displays with `:excluded` suffix (e.g., `running:healthy:excluded`)
+- UI shows "Monitoring Disabled" indicator
+- Functionally equivalent to `restart: no` for health check purposes
+- See [Container Status with All Excluded](application-architecture.md#container-status-when-all-containers-excluded) for detailed status handling
+
+**Use Cases:**
+- Sidecar containers (watchtower, log collectors)
+- Backup/maintenance containers
+- One-time initialization containers
+- Containers that intentionally restart frequently
+
+**Implementation:**
+- Parsed: `bootstrap/helpers/parsers.php`
+- Status logic: `app/Traits/CalculatesExcludedStatus.php`
+- Validation: `tests/Unit/ExcludeFromHealthCheckTest.php`
+
+### Volume-Level Extensions
+
+Volume extensions only work with **long syntax** (array/object format), not short syntax (string format).
+
+#### `content`
+
+**Type:** String (supports multiline with `|` or `>`)
+**Purpose:** Embed file content directly in compose file for automatic creation during deployment
+
+**Example Usage:**
+```yaml
+services:
+ app:
+ image: node:20
+ volumes:
+ # Inline entrypoint script
+ - type: bind
+ source: ./entrypoint.sh
+ target: /app/entrypoint.sh
+ content: |
+ #!/bin/sh
+ set -e
+ echo "Starting application..."
+ npm run migrate
+ exec "$@"
+
+ # Configuration file with environment variables
+ - type: bind
+ source: ./config.xml
+ target: /etc/app/config.xml
+ content: |
+
+
+
+ ${DB_HOST}
+ ${DB_PORT}
+
+
+```
+
+**Behavior:**
+- Content is written to the host at `source` path before container starts
+- File is created with mode `644` (readable by all, writable by owner)
+- Environment variables in content are interpolated at deployment time
+- Content is stored in `LocalFileVolume` model (encrypted at rest)
+- Original `docker_compose_raw` retains content for editing
+
+**Use Cases:**
+- Entrypoint scripts
+- Configuration files
+- Environment-specific settings
+- Small initialization scripts
+- Templates that require dynamic content
+
+**Limitations:**
+- Not suitable for large files (use git repo or external storage instead)
+- Binary files not supported
+- Changes require redeployment
+
+**Real-World Examples:**
+- `templates/compose/traccar.yaml` - XML configuration file
+- `templates/compose/supabase.yaml` - Multiple config files
+- `templates/compose/chaskiq.yaml` - Entrypoint script
+
+**Implementation:**
+- Parsed: `bootstrap/helpers/parsers.php` (line 717)
+- Storage: `app/Models/LocalFileVolume.php`
+- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
+
+#### `is_directory` / `isDirectory`
+
+**Type:** Boolean
+**Default:** `true` (if neither `content` nor explicit flag provided)
+**Purpose:** Indicate whether bind mount source should be created as directory or file
+
+**Example Usage:**
+```yaml
+services:
+ app:
+ volumes:
+ # Explicit file
+ - type: bind
+ source: ./config.json
+ target: /app/config.json
+ is_directory: false # Create as file
+
+ # Explicit directory
+ - type: bind
+ source: ./logs
+ target: /var/log/app
+ is_directory: true # Create as directory
+
+ # Auto-detected as file (has content)
+ - type: bind
+ source: ./script.sh
+ target: /entrypoint.sh
+ content: |
+ #!/bin/sh
+ echo "Hello"
+ # is_directory: false implied by content presence
+```
+
+**Behavior:**
+- If `is_directory: true` → Creates directory with `mkdir -p`
+- If `is_directory: false` → Creates empty file with `touch`
+- If `content` provided → Implies `is_directory: false`
+- If neither specified → Defaults to `true` (directory)
+
+**Naming Conventions:**
+- `is_directory` (snake_case) - **Preferred**, consistent with PHP/Laravel conventions
+- `isDirectory` (camelCase) - **Legacy support**, both work identically
+
+**Use Cases:**
+- Disambiguating files vs directories when no content provided
+- Ensuring correct bind mount type for Docker
+- Pre-creating mount points before container starts
+
+**Implementation:**
+- Parsed: `bootstrap/helpers/parsers.php` (line 718)
+- Storage: `app/Models/LocalFileVolume.php` (`is_directory` column)
+- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
+
+### Custom Field Stripping
+
+**Function:** `stripCoolifyCustomFields()` in `bootstrap/helpers/docker.php`
+
+All custom fields are removed before the compose file is sent to Docker. This happens in two contexts:
+
+**1. Validation (User-Triggered)**
+```php
+// In validateComposeFile() - Edit Docker Compose modal
+$yaml_compose = Yaml::parse($compose);
+$yaml_compose = stripCoolifyCustomFields($yaml_compose); // Strip custom fields
+// Send to docker compose config for validation
+```
+
+**2. Deployment (Automatic)**
+```php
+// In Service::parse() - During deployment
+$docker_compose = parseCompose($docker_compose_raw);
+// Custom fields are processed and then stripped
+// Final compose sent to Docker has no custom fields
+```
+
+**What Gets Stripped:**
+- Service-level: `exclude_from_hc`
+- Volume-level: `content`, `isDirectory`, `is_directory`
+
+**What's Preserved:**
+- All standard Docker Compose fields
+- Environment variables
+- Standard volume definitions (after custom fields removed)
+
+### Important Notes
+
+#### Long vs Short Volume Syntax
+
+**✅ Long Syntax (Works with Custom Fields):**
+```yaml
+volumes:
+ - type: bind
+ source: ./data
+ target: /app/data
+ content: "Hello" # ✅ Custom fields work here
+```
+
+**❌ Short Syntax (Custom Fields Ignored):**
+```yaml
+volumes:
+ - "./data:/app/data" # ❌ Cannot add custom fields to strings
+```
+
+#### Docker Compose Compatibility
+
+Custom fields are **Coolify-specific** and won't work with standalone `docker compose` CLI:
+
+```bash
+# ❌ Won't work - Docker doesn't recognize custom fields
+docker compose -f compose.yaml up
+
+# ✅ Works - Use Coolify's deployment (strips custom fields first)
+# Deploy through Coolify UI or API
+```
+
+#### Editing Custom Fields
+
+When editing in "Edit Docker Compose" modal:
+- Custom fields are preserved in the editor
+- "Validate" button strips them temporarily for Docker validation
+- "Save" button preserves them in `docker_compose_raw`
+- They're processed again on next deployment
+
+### Template Examples
+
+See these templates for real-world usage:
+
+**Service Exclusions:**
+- `templates/compose/budibase.yaml` - Excludes watchtower from monitoring
+- `templates/compose/pgbackweb.yaml` - Excludes backup service
+- `templates/compose/elasticsearch-with-kibana.yaml` - Excludes elasticsearch
+
+**Inline Content:**
+- `templates/compose/traccar.yaml` - XML configuration (multiline)
+- `templates/compose/supabase.yaml` - Multiple config files
+- `templates/compose/searxng.yaml` - Settings file
+- `templates/compose/invoice-ninja.yaml` - Nginx config
+
+**Directory Flags:**
+- `templates/compose/paperless.yaml` - Explicit directory creation
+
+### Testing
+
+**Unit Tests:**
+- `tests/Unit/StripCoolifyCustomFieldsTest.php` - Custom field stripping logic
+- `tests/Unit/ExcludeFromHealthCheckTest.php` - Health check exclusion behavior
+- `tests/Unit/ContainerStatusAggregatorTest.php` - Status aggregation with exclusions
+
+**Test Coverage:**
+- ✅ All custom fields (exclude_from_hc, content, isDirectory, is_directory)
+- ✅ Multiline content (YAML `|` syntax)
+- ✅ Short vs long volume syntax
+- ✅ Field stripping without data loss
+- ✅ Standard Docker Compose field preservation
diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php
index e1b51aa62..402a1f202 100644
--- a/app/Services/ContainerStatusAggregator.php
+++ b/app/Services/ContainerStatusAggregator.php
@@ -3,6 +3,7 @@
namespace App\Services;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Log;
/**
* Container Status Aggregator Service
@@ -35,6 +36,21 @@ class ContainerStatusAggregator
*/
public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0): string
{
+ // Validate maxRestartCount parameter
+ if ($maxRestartCount < 0) {
+ Log::warning('Negative maxRestartCount corrected to 0', [
+ 'original_value' => $maxRestartCount,
+ ]);
+ $maxRestartCount = 0;
+ }
+
+ if ($maxRestartCount > 1000) {
+ Log::warning('High maxRestartCount detected', [
+ 'maxRestartCount' => $maxRestartCount,
+ 'containers' => $containerStatuses->count(),
+ ]);
+ }
+
if ($containerStatuses->isEmpty()) {
return 'exited:unhealthy';
}
@@ -96,6 +112,21 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest
*/
public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0): string
{
+ // Validate maxRestartCount parameter
+ if ($maxRestartCount < 0) {
+ Log::warning('Negative maxRestartCount corrected to 0', [
+ 'original_value' => $maxRestartCount,
+ ]);
+ $maxRestartCount = 0;
+ }
+
+ if ($maxRestartCount > 1000) {
+ Log::warning('High maxRestartCount detected', [
+ 'maxRestartCount' => $maxRestartCount,
+ 'containers' => $containers->count(),
+ ]);
+ }
+
if ($containers->isEmpty()) {
return 'exited:unhealthy';
}
diff --git a/app/Traits/CalculatesExcludedStatus.php b/app/Traits/CalculatesExcludedStatus.php
index 323b6474c..9cbc6a86b 100644
--- a/app/Traits/CalculatesExcludedStatus.php
+++ b/app/Traits/CalculatesExcludedStatus.php
@@ -4,6 +4,8 @@
use App\Services\ContainerStatusAggregator;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Log;
+use Symfony\Component\Yaml\Exception\ParseException;
trait CalculatesExcludedStatus
{
@@ -111,8 +113,27 @@ protected function getExcludedContainersFromDockerCompose(?string $dockerCompose
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
+
+ // Validate structure
+ if (! is_array($dockerCompose)) {
+ Log::warning('Docker Compose YAML did not parse to array', [
+ 'yaml_length' => strlen($dockerComposeRaw),
+ 'parsed_type' => gettype($dockerCompose),
+ ]);
+
+ return $excludedContainers;
+ }
+
$services = data_get($dockerCompose, 'services', []);
+ if (! is_array($services)) {
+ Log::warning('Docker Compose services is not an array', [
+ 'services_type' => gettype($services),
+ ]);
+
+ return $excludedContainers;
+ }
+
foreach ($services as $serviceName => $serviceConfig) {
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
@@ -121,8 +142,23 @@ protected function getExcludedContainersFromDockerCompose(?string $dockerCompose
$excludedContainers->push($serviceName);
}
}
+ } catch (ParseException $e) {
+ // Specific YAML parsing errors
+ Log::warning('Failed to parse Docker Compose YAML for health check exclusions', [
+ 'error' => $e->getMessage(),
+ 'line' => $e->getParsedLine(),
+ 'snippet' => $e->getSnippet(),
+ ]);
+
+ return $excludedContainers;
} catch (\Exception $e) {
- // If we can't parse, treat all containers as included
+ // Unexpected errors
+ Log::error('Unexpected error parsing Docker Compose YAML', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+
+ return $excludedContainers;
}
return $excludedContainers;
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 256a2cb66..c4d77979f 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -1083,6 +1083,44 @@ function generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker
return $docker_compose;
}
+/**
+ * Remove Coolify's custom Docker Compose fields from parsed YAML array
+ *
+ * Coolify extends Docker Compose with custom fields that are processed during
+ * parsing and deployment but must be removed before sending to Docker.
+ *
+ * Custom fields:
+ * - exclude_from_hc (service-level): Exclude service from health check monitoring
+ * - content (volume-level): Auto-create file with specified content during init
+ * - isDirectory / is_directory (volume-level): Mark bind mount as directory
+ *
+ * @param array $yamlCompose Parsed Docker Compose array
+ * @return array Cleaned Docker Compose array with custom fields removed
+ */
+function stripCoolifyCustomFields(array $yamlCompose): array
+{
+ foreach ($yamlCompose['services'] ?? [] as $serviceName => $service) {
+ // Remove service-level custom fields
+ unset($yamlCompose['services'][$serviceName]['exclude_from_hc']);
+
+ // Remove volume-level custom fields (only for long syntax - arrays)
+ if (isset($service['volumes'])) {
+ foreach ($service['volumes'] as $volumeName => $volume) {
+ // Skip if volume is string (short syntax like 'db-data:/var/lib/postgresql/data')
+ if (! is_array($volume)) {
+ continue;
+ }
+
+ unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['content']);
+ unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['isDirectory']);
+ unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['is_directory']);
+ }
+ }
+ }
+
+ return $yamlCompose;
+}
+
function validateComposeFile(string $compose, int $server_id): string|Throwable
{
$uuid = Str::random(18);
@@ -1092,16 +1130,10 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
throw new \Exception('Server not found');
}
$yaml_compose = Yaml::parse($compose);
- foreach ($yaml_compose['services'] as $service_name => $service) {
- if (! isset($service['volumes'])) {
- continue;
- }
- foreach ($service['volumes'] as $volume_name => $volume) {
- if (data_get($volume, 'type') === 'bind' && data_get($volume, 'content')) {
- unset($yaml_compose['services'][$service_name]['volumes'][$volume_name]['content']);
- }
- }
- }
+
+ // Remove Coolify's custom fields before Docker validation
+ $yaml_compose = stripCoolifyCustomFields($yaml_compose);
+
$base64_compose = base64_encode(Yaml::dump($yaml_compose));
instant_remote_process([
"echo {$base64_compose} | base64 -d | tee /tmp/{$uuid}.yml > /dev/null",
diff --git a/tests/Unit/ContainerStatusAggregatorTest.php b/tests/Unit/ContainerStatusAggregatorTest.php
index 04b9b0cfd..39fd82b8e 100644
--- a/tests/Unit/ContainerStatusAggregatorTest.php
+++ b/tests/Unit/ContainerStatusAggregatorTest.php
@@ -1,6 +1,7 @@
aggregator = new ContainerStatusAggregator;
@@ -461,3 +462,79 @@
expect($result)->toBe('running:unknown');
});
});
+
+describe('maxRestartCount validation', function () {
+ test('negative maxRestartCount is corrected to 0 in aggregateFromStrings', function () {
+ // Mock the Log facade to avoid "facade root not set" error in unit tests
+ Log::shouldReceive('warning')->once();
+
+ $statuses = collect(['exited']);
+
+ // With negative value, should be treated as 0 (no restarts)
+ $result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: -5);
+
+ // Should return exited:unhealthy (not degraded) since corrected to 0
+ expect($result)->toBe('exited:unhealthy');
+ });
+
+ test('negative maxRestartCount is corrected to 0 in aggregateFromContainers', function () {
+ // Mock the Log facade to avoid "facade root not set" error in unit tests
+ Log::shouldReceive('warning')->once();
+
+ $containers = collect([
+ [
+ 'State' => [
+ 'Status' => 'exited',
+ 'ExitCode' => 1,
+ ],
+ ],
+ ]);
+
+ // With negative value, should be treated as 0 (no restarts)
+ $result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: -10);
+
+ // Should return exited:unhealthy (not degraded) since corrected to 0
+ expect($result)->toBe('exited:unhealthy');
+ });
+
+ test('zero maxRestartCount works correctly', function () {
+ $statuses = collect(['exited']);
+
+ $result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
+
+ // Zero is valid default - no crash loop detection
+ expect($result)->toBe('exited:unhealthy');
+ });
+
+ test('positive maxRestartCount works correctly', function () {
+ $statuses = collect(['exited']);
+
+ $result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
+
+ // Positive value enables crash loop detection
+ expect($result)->toBe('degraded:unhealthy');
+ });
+
+ test('crash loop detection still functions after validation', function () {
+ $statuses = collect(['exited']);
+
+ // Test with various positive restart counts
+ expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 1))
+ ->toBe('degraded:unhealthy');
+
+ expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 100))
+ ->toBe('degraded:unhealthy');
+
+ expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 999))
+ ->toBe('degraded:unhealthy');
+ });
+
+ test('default maxRestartCount parameter works', function () {
+ $statuses = collect(['exited']);
+
+ // Call without specifying maxRestartCount (should default to 0)
+ $result = $this->aggregator->aggregateFromStrings($statuses);
+
+ expect($result)->toBe('exited:unhealthy');
+ });
+});
diff --git a/tests/Unit/ExcludeFromHealthCheckTest.php b/tests/Unit/ExcludeFromHealthCheckTest.php
index be7fbf59f..8046d77e3 100644
--- a/tests/Unit/ExcludeFromHealthCheckTest.php
+++ b/tests/Unit/ExcludeFromHealthCheckTest.php
@@ -104,3 +104,51 @@
->toContain('str($service->status)->contains(\'degraded\')')
->toContain('str($service->status)->contains(\'exited\')');
});
+
+/**
+ * Unit tests for YAML validation in CalculatesExcludedStatus trait
+ */
+it('ensures YAML validation has proper exception handling for parse errors', function () {
+ $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
+
+ // Verify that ParseException is imported and caught separately from generic Exception
+ expect($traitFile)
+ ->toContain('use Symfony\Component\Yaml\Exception\ParseException')
+ ->toContain('use Illuminate\Support\Facades\Log')
+ ->toContain('} catch (ParseException $e) {')
+ ->toContain('} catch (\Exception $e) {');
+});
+
+it('ensures YAML validation logs parse errors with context', function () {
+ $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
+
+ // Verify that parse errors are logged with useful context (error message, line, snippet)
+ expect($traitFile)
+ ->toContain('Log::warning(\'Failed to parse Docker Compose YAML for health check exclusions\'')
+ ->toContain('\'error\' => $e->getMessage()')
+ ->toContain('\'line\' => $e->getParsedLine()')
+ ->toContain('\'snippet\' => $e->getSnippet()');
+});
+
+it('ensures YAML validation logs unexpected errors', function () {
+ $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
+
+ // Verify that unexpected errors are logged with error level
+ expect($traitFile)
+ ->toContain('Log::error(\'Unexpected error parsing Docker Compose YAML\'')
+ ->toContain('\'trace\' => $e->getTraceAsString()');
+});
+
+it('ensures YAML validation checks structure after parsing', function () {
+ $traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
+
+ // Verify that parsed result is validated to be an array
+ expect($traitFile)
+ ->toContain('if (! is_array($dockerCompose)) {')
+ ->toContain('Log::warning(\'Docker Compose YAML did not parse to array\'');
+
+ // Verify that services is validated to be an array
+ expect($traitFile)
+ ->toContain('if (! is_array($services)) {')
+ ->toContain('Log::warning(\'Docker Compose services is not an array\'');
+});
diff --git a/tests/Unit/StripCoolifyCustomFieldsTest.php b/tests/Unit/StripCoolifyCustomFieldsTest.php
new file mode 100644
index 000000000..de9a299a8
--- /dev/null
+++ b/tests/Unit/StripCoolifyCustomFieldsTest.php
@@ -0,0 +1,229 @@
+ [
+ 'web' => [
+ 'image' => 'nginx:latest',
+ 'exclude_from_hc' => true,
+ 'ports' => ['80:80'],
+ ],
+ ],
+ ];
+
+ $result = stripCoolifyCustomFields($yaml);
+
+ assertEquals('nginx:latest', $result['services']['web']['image']);
+ assertEquals(['80:80'], $result['services']['web']['ports']);
+ expect($result['services']['web'])->not->toHaveKey('exclude_from_hc');
+});
+
+test('removes content from volume level', function () {
+ $yaml = [
+ 'services' => [
+ 'app' => [
+ 'image' => 'php:8.4',
+ 'volumes' => [
+ [
+ 'type' => 'bind',
+ 'source' => './config.xml',
+ 'target' => '/app/config.xml',
+ 'content' => '',
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ $result = stripCoolifyCustomFields($yaml);
+
+ expect($result['services']['app']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
+ expect($result['services']['app']['volumes'][0])->not->toHaveKey('content');
+});
+
+test('removes isDirectory from volume level', function () {
+ $yaml = [
+ 'services' => [
+ 'app' => [
+ 'image' => 'node:20',
+ 'volumes' => [
+ [
+ 'type' => 'bind',
+ 'source' => './data',
+ 'target' => '/app/data',
+ 'isDirectory' => true,
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ $result = stripCoolifyCustomFields($yaml);
+
+ expect($result['services']['app']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
+ expect($result['services']['app']['volumes'][0])->not->toHaveKey('isDirectory');
+});
+
+test('removes is_directory from volume level', function () {
+ $yaml = [
+ 'services' => [
+ 'app' => [
+ 'image' => 'python:3.12',
+ 'volumes' => [
+ [
+ 'type' => 'bind',
+ 'source' => './logs',
+ 'target' => '/var/log/app',
+ 'is_directory' => true,
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ $result = stripCoolifyCustomFields($yaml);
+
+ expect($result['services']['app']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
+ expect($result['services']['app']['volumes'][0])->not->toHaveKey('is_directory');
+});
+
+test('removes all custom fields together', function () {
+ $yaml = [
+ 'services' => [
+ 'web' => [
+ 'image' => 'nginx:latest',
+ 'exclude_from_hc' => true,
+ 'volumes' => [
+ [
+ 'type' => 'bind',
+ 'source' => './config.xml',
+ 'target' => '/etc/nginx/config.xml',
+ 'content' => '',
+ 'isDirectory' => false,
+ ],
+ [
+ 'type' => 'bind',
+ 'source' => './data',
+ 'target' => '/var/www/data',
+ 'is_directory' => true,
+ ],
+ ],
+ ],
+ 'worker' => [
+ 'image' => 'worker:latest',
+ 'exclude_from_hc' => true,
+ ],
+ ],
+ ];
+
+ $result = stripCoolifyCustomFields($yaml);
+
+ // Verify service-level custom fields removed
+ expect($result['services']['web'])->not->toHaveKey('exclude_from_hc');
+ expect($result['services']['worker'])->not->toHaveKey('exclude_from_hc');
+
+ // Verify volume-level custom fields removed
+ expect($result['services']['web']['volumes'][0])->not->toHaveKey('content');
+ expect($result['services']['web']['volumes'][0])->not->toHaveKey('isDirectory');
+ expect($result['services']['web']['volumes'][1])->not->toHaveKey('is_directory');
+
+ // Verify standard fields preserved
+ assertEquals('nginx:latest', $result['services']['web']['image']);
+ assertEquals('worker:latest', $result['services']['worker']['image']);
+});
+
+test('preserves standard Docker Compose fields', function () {
+ $yaml = [
+ 'services' => [
+ 'db' => [
+ 'image' => 'postgres:16',
+ 'environment' => [
+ 'POSTGRES_DB' => 'mydb',
+ 'POSTGRES_USER' => 'user',
+ ],
+ 'ports' => ['5432:5432'],
+ 'volumes' => [
+ 'db-data:/var/lib/postgresql/data',
+ ],
+ 'healthcheck' => [
+ 'test' => ['CMD', 'pg_isready'],
+ 'interval' => '5s',
+ ],
+ 'restart' => 'unless-stopped',
+ 'networks' => ['backend'],
+ ],
+ ],
+ 'networks' => [
+ 'backend' => [
+ 'driver' => 'bridge',
+ ],
+ ],
+ 'volumes' => [
+ 'db-data' => null,
+ ],
+ ];
+
+ $result = stripCoolifyCustomFields($yaml);
+
+ // All standard fields should be preserved
+ expect($result)->toHaveKeys(['services', 'networks', 'volumes']);
+ expect($result['services']['db'])->toHaveKeys([
+ 'image', 'environment', 'ports', 'volumes',
+ 'healthcheck', 'restart', 'networks',
+ ]);
+ assertEquals('postgres:16', $result['services']['db']['image']);
+ assertEquals(['5432:5432'], $result['services']['db']['ports']);
+});
+
+test('handles missing services gracefully', function () {
+ $yaml = [
+ 'version' => '3.8',
+ ];
+
+ $result = stripCoolifyCustomFields($yaml);
+
+ expect($result)->toBe($yaml);
+});
+
+test('handles missing volumes in service gracefully', function () {
+ $yaml = [
+ 'services' => [
+ 'app' => [
+ 'image' => 'nginx:latest',
+ 'exclude_from_hc' => true,
+ ],
+ ],
+ ];
+
+ $result = stripCoolifyCustomFields($yaml);
+
+ expect($result['services']['app'])->not->toHaveKey('exclude_from_hc');
+ expect($result['services']['app'])->not->toHaveKey('volumes');
+ assertEquals('nginx:latest', $result['services']['app']['image']);
+});
+
+test('handles traccar.yaml example with multiline content', function () {
+ $yaml = [
+ 'services' => [
+ 'traccar' => [
+ 'image' => 'traccar/traccar:latest',
+ 'volumes' => [
+ [
+ 'type' => 'bind',
+ 'source' => './srv/traccar/conf/traccar.xml',
+ 'target' => '/opt/traccar/conf/traccar.xml',
+ 'content' => "\n\n\n ./conf/default.xml\n",
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ $result = stripCoolifyCustomFields($yaml);
+
+ expect($result['services']['traccar']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
+ expect($result['services']['traccar']['volumes'][0])->not->toHaveKey('content');
+ assertEquals('./srv/traccar/conf/traccar.xml', $result['services']['traccar']['volumes'][0]['source']);
+});