diff --git a/.AI_INSTRUCTIONS_SYNC.md b/.AI_INSTRUCTIONS_SYNC.md
new file mode 100644
index 000000000..bbe0a90e1
--- /dev/null
+++ b/.AI_INSTRUCTIONS_SYNC.md
@@ -0,0 +1,156 @@
+# AI Instructions Synchronization Guide
+
+This document explains how AI instructions are organized and synchronized across different AI tools used with Coolify.
+
+## Overview
+
+Coolify maintains AI instructions in two parallel systems:
+
+1. **CLAUDE.md** - For Claude Code (claude.ai/code)
+2. **.cursor/rules/** - For Cursor IDE and other AI assistants
+
+Both systems share core principles but are optimized for their respective workflows.
+
+## Structure
+
+### CLAUDE.md
+- **Purpose**: Condensed, workflow-focused guide for Claude Code
+- **Format**: Single markdown file
+- **Includes**:
+ - Quick-reference development commands
+ - High-level architecture overview
+ - Core patterns and guidelines
+ - Embedded Laravel Boost guidelines
+ - References to detailed .cursor/rules/ documentation
+
+### .cursor/rules/
+- **Purpose**: Detailed, topic-specific documentation
+- **Format**: Multiple .mdc files organized by topic
+- **Structure**:
+ - `README.mdc` - Main index and overview
+ - `cursor_rules.mdc` - Maintenance guidelines
+ - Topic-specific files (testing-patterns.mdc, security-patterns.mdc, etc.)
+- **Used by**: Cursor IDE, Claude Code (for detailed reference), other AI assistants
+
+## Cross-References
+
+Both systems reference each other:
+
+- **CLAUDE.md** → references `.cursor/rules/` for detailed documentation
+- **.cursor/rules/README.mdc** → references `CLAUDE.md` for Claude Code workflow
+- **.cursor/rules/cursor_rules.mdc** → notes that changes should sync with CLAUDE.md
+
+## Maintaining Consistency
+
+When updating AI instructions, follow these guidelines:
+
+### 1. Core Principles (MUST be consistent)
+- Laravel version (currently Laravel 12)
+- PHP version (8.4)
+- Testing execution rules (Docker for Feature tests, mocking for Unit tests)
+- Security patterns and authorization requirements
+- Code style requirements (Pint, PSR-12)
+
+### 2. Where to Make Changes
+
+**For workflow changes** (how to run commands, development setup):
+- Primary: `CLAUDE.md`
+- Secondary: `.cursor/rules/development-workflow.mdc`
+
+**For architectural patterns** (how code should be structured):
+- Primary: `.cursor/rules/` topic files
+- Secondary: Reference in `CLAUDE.md` "Additional Documentation" section
+
+**For testing patterns**:
+- Both: Must be synchronized
+- `CLAUDE.md` - Contains condensed testing execution rules
+- `.cursor/rules/testing-patterns.mdc` - Contains detailed examples and patterns
+
+### 3. Update Checklist
+
+When making significant changes:
+
+- [ ] Identify if change affects core principles (version numbers, critical patterns)
+- [ ] Update primary location (CLAUDE.md or .cursor/rules/)
+- [ ] Check if update affects cross-referenced content
+- [ ] Update secondary location if needed
+- [ ] Verify cross-references are still accurate
+- [ ] Run: `./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc` (if applicable)
+
+### 4. Common Inconsistencies to Watch
+
+- **Version numbers**: Laravel, PHP, package versions
+- **Testing instructions**: Docker execution requirements
+- **File paths**: Ensure relative paths work from root
+- **Command syntax**: Docker commands, artisan commands
+- **Architecture decisions**: Laravel 10 structure vs Laravel 12+ structure
+
+## File Organization
+
+```
+/
+├── CLAUDE.md # Claude Code instructions (condensed)
+├── .AI_INSTRUCTIONS_SYNC.md # This file
+└── .cursor/
+ └── rules/
+ ├── README.mdc # Index and overview
+ ├── cursor_rules.mdc # Maintenance guide
+ ├── testing-patterns.mdc # Testing details
+ ├── development-workflow.mdc # Dev setup details
+ ├── security-patterns.mdc # Security details
+ ├── application-architecture.mdc
+ ├── deployment-architecture.mdc
+ ├── database-patterns.mdc
+ ├── frontend-patterns.mdc
+ ├── api-and-routing.mdc
+ ├── form-components.mdc
+ ├── technology-stack.mdc
+ ├── project-overview.mdc
+ └── laravel-boost.mdc # Laravel-specific patterns
+```
+
+## Recent Updates
+
+### 2025-10-07
+- ✅ Added cross-references between CLAUDE.md and .cursor/rules/
+- ✅ Synchronized Laravel version (12) across all files
+- ✅ Added comprehensive testing execution rules (Docker for Feature tests)
+- ✅ Added test design philosophy (prefer mocking over database)
+- ✅ Fixed inconsistencies in testing documentation
+- ✅ Created this synchronization guide
+
+## Maintenance Commands
+
+```bash
+# Check for version inconsistencies
+grep -r "Laravel [0-9]" CLAUDE.md .cursor/rules/*.mdc
+
+# Check for PHP version consistency
+grep -r "PHP [0-9]" CLAUDE.md .cursor/rules/*.mdc
+
+# Format all documentation
+./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc
+
+# Search for specific patterns across all docs
+grep -r "pattern_to_check" CLAUDE.md .cursor/rules/
+```
+
+## Contributing
+
+When contributing documentation:
+
+1. Check both CLAUDE.md and .cursor/rules/ for existing documentation
+2. Add to appropriate location(s) based on guidelines above
+3. Add cross-references if creating new patterns
+4. Update this file if changing organizational structure
+5. Verify consistency before submitting PR
+
+## Questions?
+
+If unsure about where to document something:
+
+- **Quick reference / workflow** → CLAUDE.md
+- **Detailed patterns / examples** → .cursor/rules/[topic].mdc
+- **Both?** → Start with .cursor/rules/, then reference in CLAUDE.md
+
+When in doubt, prefer detailed documentation in .cursor/rules/ and concise references in CLAUDE.md.
diff --git a/.cursor/rules/README.mdc b/.cursor/rules/README.mdc
index 07f19a816..d0597bb72 100644
--- a/.cursor/rules/README.mdc
+++ b/.cursor/rules/README.mdc
@@ -9,6 +9,10 @@ alwaysApply: false
This comprehensive set of Cursor Rules provides deep insights into **Coolify**, an open-source self-hostable alternative to Heroku/Netlify/Vercel. These rules will help you understand, navigate, and contribute to this complex Laravel-based deployment platform.
+> **Cross-Reference**: This directory is for **detailed, topic-specific rules** used by Cursor IDE and other AI assistants. For Claude Code specifically, also see **[CLAUDE.md](mdc:CLAUDE.md)** which provides a condensed, workflow-focused guide. Both systems share core principles but are optimized for their respective tools.
+>
+> **Maintaining Rules**: When updating these rules, see **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for synchronization guidelines to keep CLAUDE.md and .cursor/rules/ consistent.
+
## Rule Categories
### 🏗️ Architecture & Foundation
@@ -71,7 +75,7 @@ Coolify uses a **team-based multi-tenancy** model where:
- **Multi-server** support with SSH connections
### 3. Technology Stack
-- **Backend**: Laravel 11 + PHP 8.4
+- **Backend**: Laravel 12 + PHP 8.4
- **Frontend**: Livewire 3.5 + Alpine.js + Tailwind CSS 4.1
- **Database**: PostgreSQL 15 + Redis 7
- **Containerization**: Docker + Docker Compose
diff --git a/.cursor/rules/cursor_rules.mdc b/.cursor/rules/cursor_rules.mdc
index 7dfae3de0..9edccd496 100644
--- a/.cursor/rules/cursor_rules.mdc
+++ b/.cursor/rules/cursor_rules.mdc
@@ -4,6 +4,12 @@ globs: .cursor/rules/*.mdc
alwaysApply: true
---
+# Cursor Rules Maintenance Guide
+
+> **Important**: These rules in `.cursor/rules/` are shared between Cursor IDE and other AI assistants. Changes here should be reflected in **[CLAUDE.md](mdc:CLAUDE.md)** when they affect core workflows or patterns.
+>
+> **Synchronization Guide**: See **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for detailed guidelines on maintaining consistency between CLAUDE.md and .cursor/rules/.
+
- **Required Rule Structure:**
```markdown
---
diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc
index 005ede849..c409a4647 100644
--- a/.cursor/rules/laravel-boost.mdc
+++ b/.cursor/rules/laravel-boost.mdc
@@ -185,7 +185,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
-- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
+- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
diff --git a/.cursor/rules/testing-patterns.mdc b/.cursor/rules/testing-patterns.mdc
index a0e64dbae..8d250b56a 100644
--- a/.cursor/rules/testing-patterns.mdc
+++ b/.cursor/rules/testing-patterns.mdc
@@ -5,11 +5,56 @@ alwaysApply: false
---
# Coolify Testing Architecture & Patterns
+> **Cross-Reference**: These detailed testing patterns align with the testing guidelines in **[CLAUDE.md](mdc:CLAUDE.md)**. Both documents share the same core principles about Docker execution and mocking preferences.
+
## Testing Philosophy
Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions.
-!Important: Always run tests inside `coolify` container.
+### Test Execution Rules
+
+**CRITICAL**: Tests are categorized by database dependency:
+
+#### Unit Tests (`tests/Unit/`)
+- **MUST NOT** use database connections
+- **MUST** use mocking for models and external dependencies
+- **CAN** run outside Docker: `./vendor/bin/pest tests/Unit`
+- Purpose: Test isolated logic, helper functions, and business rules
+
+#### Feature Tests (`tests/Feature/`)
+- **MAY** use database connections (factories, migrations, models)
+- **MUST** run inside Docker container: `docker exec coolify php artisan test`
+- **MUST** use `RefreshDatabase` trait if touching database
+- Purpose: Test API endpoints, workflows, and integration scenarios
+
+**Rule of thumb**: If your test needs `Server::factory()->create()` or any database operation, it's a Feature test and MUST run in Docker.
+
+### Prefer Mocking Over Database
+
+When writing tests, always prefer mocking over real database operations:
+
+```php
+// ❌ BAD: Unit test using database
+it('extracts custom commands', function () {
+ $server = Server::factory()->create(['ip' => '1.2.3.4']);
+ $commands = extract_custom_proxy_commands($server, $yaml);
+ expect($commands)->toBeArray();
+});
+
+// ✅ GOOD: Unit test using mocking
+it('extracts custom commands', function () {
+ $server = Mockery::mock('App\Models\Server');
+ $server->shouldReceive('proxyType')->andReturn('traefik');
+ $commands = extract_custom_proxy_commands($server, $yaml);
+ expect($commands)->toBeArray();
+});
+```
+
+**Design principles for testable code:**
+- Use dependency injection instead of global state
+- Create interfaces for external dependencies (SSH, Docker, etc.)
+- Separate business logic from data persistence
+- Make functions accept interfaces instead of concrete models when possible
## Testing Framework Stack
diff --git a/CLAUDE.md b/CLAUDE.md
index 22e762182..6c594955c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,10 @@
# CLAUDE.md
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+This file provides guidance to **Claude Code** (claude.ai/code) when working with code in this repository.
+
+> **Note for AI Assistants**: This file is specifically for Claude Code. If you're using Cursor IDE, refer to the `.cursor/rules/` directory for detailed rule files. Both systems share core principles but are optimized for their respective workflows.
+>
+> **Maintaining Instructions**: When updating AI instructions, see [.AI_INSTRUCTIONS_SYNC.md](.AI_INSTRUCTIONS_SYNC.md) for synchronization guidelines between CLAUDE.md and .cursor/rules/.
## Project Overview
@@ -23,7 +27,14 @@ ### Backend Development
### Code Quality
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
-- `./vendor/bin/pest` - Run Pest tests
+- `./vendor/bin/pest` - Run Pest tests (unit tests only, without database)
+
+### Running Tests
+**IMPORTANT**: Tests that require database connections MUST be run inside the Docker container:
+- **Inside Docker**: `docker exec coolify php artisan test` (for feature tests requiring database)
+- **Outside Docker**: `./vendor/bin/pest tests/Unit` (for pure unit tests without database dependencies)
+- Unit tests should use mocking and avoid database connections
+- Feature tests that require database must be run in the `coolify` container
## Architecture Overview
@@ -173,6 +184,21 @@ ### Testing Strategy
- **Mocking**: Use Laravel's built-in mocking for external services
- **Database**: Use RefreshDatabase trait for test isolation
+#### Test Execution Environment
+**CRITICAL**: Database-dependent tests MUST run inside Docker container:
+- **Unit Tests** (`tests/Unit/`): Should NOT use database. Use mocking. Run with `./vendor/bin/pest tests/Unit`
+- **Feature Tests** (`tests/Feature/`): May use database. MUST run inside Docker with `docker exec coolify php artisan test`
+- If a test needs database (factories, migrations, etc.), it belongs in `tests/Feature/`
+- Always mock external services and SSH connections in tests
+
+#### Test Design Philosophy
+**PREFER MOCKING**: When designing features and writing tests:
+- **Design for testability**: Structure code so it can be tested without database (use dependency injection, interfaces)
+- **Mock by default**: Unit tests should mock models and external dependencies using Mockery
+- **Avoid database when possible**: If you can test the logic without database, write it as a Unit test
+- **Only use database when necessary**: Feature tests should test integration points, not isolated logic
+- **Example**: Instead of `Server::factory()->create()`, use `Mockery::mock('App\Models\Server')` in unit tests
+
### Routing Conventions
- Group routes by middleware and prefix
- Use route model binding for cleaner controllers
@@ -228,7 +254,9 @@ ## Important Reminders
## Additional Documentation
-For more detailed guidelines and patterns, refer to the `.cursor/rules/` directory:
+This file contains high-level guidelines for Claude Code. For **more detailed, topic-specific documentation**, refer to the `.cursor/rules/` directory (also accessible by Cursor IDE and other AI assistants):
+
+> **Cross-Reference**: The `.cursor/rules/` directory contains comprehensive, detailed documentation organized by topic. Start with [.cursor/rules/README.mdc](.cursor/rules/README.mdc) for an overview, then explore specific topics below.
### Architecture & Patterns
- [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure
@@ -434,7 +462,7 @@ ### Laravel 10 Structure
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
-- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
+- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
@@ -543,6 +571,10 @@ ### Pest Tests
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
+- **Unit tests** MUST use mocking and avoid database. They can run outside Docker.
+- **Feature tests** can use database but MUST run inside Docker container.
+- **Design for testability**: Structure code to be testable without database when possible. Use dependency injection and interfaces.
+- **Mock by default**: Prefer `Mockery::mock()` over `Model::factory()->create()` in unit tests.
- Pest tests look and behave like this:
it('is true', function () {
@@ -551,11 +583,23 @@ ### Pest Tests
### Running Tests
-- Run the minimal number of tests using an appropriate filter before finalizing code edits.
-- To run all tests: `php artisan test`.
-- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
-- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
-- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
+**IMPORTANT**: Always run tests in the correct environment based on database dependencies:
+
+**Unit Tests (no database):**
+- Run outside Docker: `./vendor/bin/pest tests/Unit`
+- Run specific file: `./vendor/bin/pest tests/Unit/ProxyCustomCommandsTest.php`
+- These tests use mocking and don't require PostgreSQL
+
+**Feature Tests (with database):**
+- Run inside Docker: `docker exec coolify php artisan test`
+- Run specific file: `docker exec coolify php artisan test tests/Feature/ExampleTest.php`
+- Filter by name: `docker exec coolify php artisan test --filter=testName`
+- These tests require PostgreSQL and use factories/migrations
+
+**General Guidelines:**
+- Run the minimal number of tests using an appropriate filter before finalizing code edits
+- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite
+- If you get database connection errors, you're running a Feature test outside Docker - move it inside
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
@@ -650,7 +694,12 @@ ### Replaced Utilities
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
-- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
+- Run the minimum number of tests needed to ensure code quality and speed.
+- **For Unit tests**: Use `./vendor/bin/pest tests/Unit/YourTest.php` (runs outside Docker)
+- **For Feature tests**: Use `docker exec coolify php artisan test --filter=YourTest` (runs inside Docker)
+- Choose the correct test type based on database dependency:
+ - No database needed? → Unit test with mocking
+ - Database needed? → Feature test in Docker
diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php
index 3bf91c281..3aa1d8d34 100644
--- a/app/Actions/Proxy/GetProxyConfiguration.php
+++ b/app/Actions/Proxy/GetProxyConfiguration.php
@@ -33,7 +33,13 @@ public function handle(Server $server, bool $forceRegenerate = false): string
// 1. Force regenerate is requested
// 2. Configuration file doesn't exist or is empty
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
- $proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value();
+ // Extract custom commands from existing config before regenerating
+ $custom_commands = [];
+ if (! empty(trim($proxy_configuration ?? ''))) {
+ $custom_commands = extractCustomProxyCommands($server, $proxy_configuration);
+ }
+
+ $proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value();
}
if (empty($proxy_configuration)) {
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index fafcdb618..8ffaabde5 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -1005,6 +1005,10 @@ private function should_skip_build()
$this->skip_build = true;
$this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
$this->generate_compose_file();
+
+ // Save runtime environment variables even when skipping build
+ $this->save_runtime_environment_variables();
+
$this->push_to_docker_registry();
$this->rolling_update();
@@ -1014,6 +1018,10 @@ private function should_skip_build()
$this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
$this->skip_build = true;
$this->generate_compose_file();
+
+ // Save runtime environment variables even when skipping build
+ $this->save_runtime_environment_variables();
+
$this->push_to_docker_registry();
$this->rolling_update();
@@ -1694,7 +1702,7 @@ private function create_workdir()
}
}
- private function prepare_builder_image()
+ private function prepare_builder_image(bool $firstTry = true)
{
$this->checkForCancellation();
$settings = instanceSettings();
@@ -1717,7 +1725,12 @@ private function prepare_builder_image()
$runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
}
}
- $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage.");
+ if ($firstTry) {
+ $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage");
+ } else {
+ $this->application_deployment_queue->addLogEntry('Preparing container with helper image with updated envs.');
+ }
+
$this->graceful_shutdown_container($this->deployment_uuid);
$this->execute_remote_command(
[
@@ -1740,7 +1753,7 @@ private function restart_builder_container_with_actual_commit()
$this->env_args = null;
// Restart the helper container with updated environment variables (including actual SOURCE_COMMIT)
- $this->prepare_builder_image();
+ $this->prepare_builder_image(firstTry: false);
}
private function deploy_to_additional_destinations()
@@ -1988,6 +2001,15 @@ private function generate_nixpacks_confs()
if ($this->nixpacks_type === 'elixir') {
$this->elixir_finetunes();
}
+ if ($this->nixpacks_type === 'node') {
+ // Check if NIXPACKS_NODE_VERSION is set
+ $variables = data_get($parsed, 'variables', []);
+ if (! isset($variables['NIXPACKS_NODE_VERSION'])) {
+ $this->application_deployment_queue->addLogEntry('----------------------------------------');
+ $this->application_deployment_queue->addLogEntry('⚠️ NIXPACKS_NODE_VERSION not set. Nixpacks will use Node.js 18 by default, which is EOL.');
+ $this->application_deployment_queue->addLogEntry('You can override this by setting NIXPACKS_NODE_VERSION=22 in your environment variables.');
+ }
+ }
$this->nixpacks_plan = json_encode($parsed, JSON_PRETTY_PRINT);
$this->nixpacks_plan_json = collect($parsed);
$this->application_deployment_queue->addLogEntry("Final Nixpacks plan: {$this->nixpacks_plan}", hidden: true);
diff --git a/app/Jobs/ApplicationPullRequestUpdateJob.php b/app/Jobs/ApplicationPullRequestUpdateJob.php
index ef8e6efb6..471edb4c6 100755
--- a/app/Jobs/ApplicationPullRequestUpdateJob.php
+++ b/app/Jobs/ApplicationPullRequestUpdateJob.php
@@ -49,7 +49,7 @@ public function handle()
} elseif ($this->status === ProcessStatus::ERROR) {
$this->body = "The preview deployment failed. 🔴\n\n";
}
- $this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/{$this->application->environment->name}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
+ $this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
$this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n";
$this->body .= 'Last updated at: '.now()->toDateTimeString().' CET';
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 92db14a61..3cc372fd1 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -15,6 +15,7 @@
use App\Models\Team;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess;
+use App\Notifications\Database\BackupSuccessWithS3Warning;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -74,6 +75,7 @@ public function __construct(public ScheduledDatabaseBackup $backup)
{
$this->onQueue('high');
$this->timeout = $backup->timeout;
+ $this->backup_log_uuid = (string) new Cuid2;
}
public function handle(): void
@@ -298,6 +300,10 @@ public function handle(): void
} while ($exists);
$size = 0;
+ $localBackupSucceeded = false;
+ $s3UploadError = null;
+
+ // Step 1: Create local backup
try {
if (str($databaseType)->contains('postgres')) {
$this->backup_file = "/pg-dump-$database-".Carbon::now()->timestamp.'.dmp';
@@ -310,6 +316,7 @@ public function handle(): void
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
+ 'local_storage_deleted' => false,
]);
$this->backup_standalone_postgresql($database);
} elseif (str($databaseType)->contains('mongo')) {
@@ -330,6 +337,7 @@ public function handle(): void
'database_name' => $databaseName,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
+ 'local_storage_deleted' => false,
]);
$this->backup_standalone_mongodb($database);
} elseif (str($databaseType)->contains('mysql')) {
@@ -343,6 +351,7 @@ public function handle(): void
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
+ 'local_storage_deleted' => false,
]);
$this->backup_standalone_mysql($database);
} elseif (str($databaseType)->contains('mariadb')) {
@@ -356,56 +365,77 @@ public function handle(): void
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
+ 'local_storage_deleted' => false,
]);
$this->backup_standalone_mariadb($database);
} else {
throw new \Exception('Unsupported database type');
}
+
$size = $this->calculate_size();
- if ($this->backup->save_s3) {
+
+ // Verify local backup succeeded
+ if ($size > 0) {
+ $localBackupSucceeded = true;
+ } else {
+ throw new \Exception('Local backup file is empty or was not created');
+ }
+ } catch (\Throwable $e) {
+ // Local backup failed
+ if ($this->backup_log) {
+ $this->backup_log->update([
+ 'status' => 'failed',
+ 'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
+ 'size' => $size,
+ 'filename' => null,
+ 's3_uploaded' => null,
+ ]);
+ }
+ $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
+
+ continue;
+ }
+
+ // Step 2: Upload to S3 if enabled (independent of local backup)
+ $localStorageDeleted = false;
+ if ($this->backup->save_s3 && $localBackupSucceeded) {
+ try {
$this->upload_to_s3();
// If local backup is disabled, delete the local file immediately after S3 upload
if ($this->backup->disable_local_backup) {
deleteBackupsLocally($this->backup_location, $this->server);
+ $localStorageDeleted = true;
}
+ } catch (\Throwable $e) {
+ // S3 upload failed but local backup succeeded
+ $s3UploadError = $e->getMessage();
}
+ }
- $this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
+ // Step 3: Update status and send notifications based on results
+ if ($localBackupSucceeded) {
+ $message = $this->backup_output;
+
+ if ($s3UploadError) {
+ $message = $message
+ ? $message."\n\nWarning: S3 upload failed: ".$s3UploadError
+ : 'Warning: S3 upload failed: '.$s3UploadError;
+ }
$this->backup_log->update([
'status' => 'success',
- 'message' => $this->backup_output,
+ 'message' => $message,
'size' => $size,
+ 's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null,
+ 'local_storage_deleted' => $localStorageDeleted,
]);
- } catch (\Throwable $e) {
- // Check if backup actually failed or if it's just a post-backup issue
- $actualBackupFailed = ! $this->s3_uploaded && $this->backup->save_s3;
- if ($actualBackupFailed || $size === 0) {
- // Real backup failure
- if ($this->backup_log) {
- $this->backup_log->update([
- 'status' => 'failed',
- 'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
- 'size' => $size,
- 'filename' => null,
- ]);
- }
- $this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
+ // Send appropriate notification
+ if ($s3UploadError) {
+ $this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
} else {
- // Backup succeeded but post-processing failed (cleanup, notification, etc.)
- if ($this->backup_log) {
- $this->backup_log->update([
- 'status' => 'success',
- 'message' => $this->backup_output ? $this->backup_output."\nWarning: Post-backup cleanup encountered an issue: ".$e->getMessage() : 'Warning: '.$e->getMessage(),
- 'size' => $size,
- ]);
- }
- // Send success notification since the backup itself succeeded
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
- // Log the post-backup issue
- ray('Post-backup operation failed but backup was successful: '.$e->getMessage());
}
}
}
@@ -591,24 +621,24 @@ private function upload_to_s3(): void
$fullImageName = $this->getFullImageName();
- $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup->uuid}"], $this->server, false);
+ $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false);
if (filled($containerExists)) {
- instant_remote_process(["docker rm -f backup-of-{$this->backup->uuid}"], $this->server, false);
+ instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false);
}
if (isDev()) {
if ($this->database->name === 'coolify-db') {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file;
- $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
+ $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
} else {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file;
- $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
+ $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
}
} else {
- $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
+ $commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
}
- $commands[] = "docker exec backup-of-{$this->backup->uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
- $commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
+ $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
+ $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
instant_remote_process($commands, $this->server);
$this->s3_uploaded = true;
@@ -617,7 +647,7 @@ private function upload_to_s3(): void
$this->add_to_error_output($e->getMessage());
throw $e;
} finally {
- $command = "docker rm -f backup-of-{$this->backup->uuid}";
+ $command = "docker rm -f backup-of-{$this->backup_log_uuid}";
instant_remote_process([$command], $this->server);
}
}
diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php
index 15de5d838..679926738 100644
--- a/app/Livewire/GlobalSearch.php
+++ b/app/Livewire/GlobalSearch.php
@@ -28,12 +28,21 @@ class GlobalSearch extends Component
public $allSearchableItems = [];
+ public $isCreateMode = false;
+
+ public $creatableItems = [];
+
+ public $autoOpenResource = null;
+
public function mount()
{
$this->searchQuery = '';
$this->isModalOpen = false;
$this->searchResults = [];
$this->allSearchableItems = [];
+ $this->isCreateMode = false;
+ $this->creatableItems = [];
+ $this->autoOpenResource = null;
}
public function openSearchModal()
@@ -62,7 +71,63 @@ public static function clearTeamCache($teamId)
public function updatedSearchQuery()
{
- $this->search();
+ $query = strtolower(trim($this->searchQuery));
+
+ if (str_starts_with($query, 'new')) {
+ $this->isCreateMode = true;
+ $this->loadCreatableItems();
+ $this->searchResults = [];
+
+ // Check for sub-commands like "new project", "new server", etc.
+ // Use original query (not trimmed) to ensure exact match without trailing spaces
+ $this->autoOpenResource = $this->detectSpecificResource(strtolower($this->searchQuery));
+ } else {
+ $this->isCreateMode = false;
+ $this->creatableItems = [];
+ $this->autoOpenResource = null;
+ $this->search();
+ }
+ }
+
+ private function detectSpecificResource(string $query): ?string
+ {
+ // Map of keywords to resource types - order matters for multi-word matches
+ $resourceMap = [
+ 'new project' => 'project',
+ 'new server' => 'server',
+ 'new team' => 'team',
+ 'new storage' => 'storage',
+ 'new s3' => 'storage',
+ 'new private key' => 'private-key',
+ 'new privatekey' => 'private-key',
+ 'new key' => 'private-key',
+ 'new github' => 'source',
+ 'new source' => 'source',
+ 'new git' => 'source',
+ ];
+
+ foreach ($resourceMap as $command => $type) {
+ if ($query === $command) {
+ // Check if user has permission for this resource type
+ if ($this->canCreateResource($type)) {
+ return $type;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private function canCreateResource(string $type): bool
+ {
+ $user = auth()->user();
+
+ return match ($type) {
+ 'project', 'source' => $user->can('createAnyResource'),
+ 'server', 'storage', 'private-key' => $user->isAdmin() || $user->isOwner(),
+ 'team' => true,
+ default => false,
+ };
}
private function loadSearchableItems()
@@ -437,6 +502,72 @@ private function search()
->toArray();
}
+ private function loadCreatableItems()
+ {
+ $items = collect();
+ $user = auth()->user();
+
+ // Project - can be created if user has createAnyResource permission
+ if ($user->can('createAnyResource')) {
+ $items->push([
+ 'name' => 'Project',
+ 'description' => 'Create a new project to organize your resources',
+ 'type' => 'project',
+ 'component' => 'project.add-empty',
+ ]);
+ }
+
+ // Server - can be created if user is admin or owner
+ if ($user->isAdmin() || $user->isOwner()) {
+ $items->push([
+ 'name' => 'Server',
+ 'description' => 'Add a new server to deploy your applications',
+ 'type' => 'server',
+ 'component' => 'server.create',
+ ]);
+ }
+
+ // Team - can be created by anyone (they become owner of new team)
+ $items->push([
+ 'name' => 'Team',
+ 'description' => 'Create a new team to collaborate with others',
+ 'type' => 'team',
+ 'component' => 'team.create',
+ ]);
+
+ // Storage - can be created if user is admin or owner
+ if ($user->isAdmin() || $user->isOwner()) {
+ $items->push([
+ 'name' => 'S3 Storage',
+ 'description' => 'Add S3 storage for backups and file uploads',
+ 'type' => 'storage',
+ 'component' => 'storage.create',
+ ]);
+ }
+
+ // Private Key - can be created if user is admin or owner
+ if ($user->isAdmin() || $user->isOwner()) {
+ $items->push([
+ 'name' => 'Private Key',
+ 'description' => 'Add an SSH private key for server access',
+ 'type' => 'private-key',
+ 'component' => 'security.private-key.create',
+ ]);
+ }
+
+ // GitHub Source - can be created if user has createAnyResource permission
+ if ($user->can('createAnyResource')) {
+ $items->push([
+ 'name' => 'GitHub App',
+ 'description' => 'Connect a GitHub app for source control',
+ 'type' => 'source',
+ 'component' => 'source.github.create',
+ ]);
+ }
+
+ $this->creatableItems = $items->toArray();
+ }
+
public function render()
{
return view('livewire.global-search');
diff --git a/app/Livewire/Project/AddEmpty.php b/app/Livewire/Project/AddEmpty.php
index 751b4945b..974f0608a 100644
--- a/app/Livewire/Project/AddEmpty.php
+++ b/app/Livewire/Project/AddEmpty.php
@@ -37,7 +37,12 @@ public function submit()
'uuid' => (string) new Cuid2,
]);
- return redirect()->route('project.show', $project->uuid);
+ $productionEnvironment = $project->environments()->where('name', 'production')->first();
+
+ return redirect()->route('project.resource.index', [
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $productionEnvironment->uuid,
+ ]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php
index 98d076ac0..b3df79008 100644
--- a/app/Livewire/Project/Database/BackupEdit.php
+++ b/app/Livewire/Project/Database/BackupEdit.php
@@ -208,7 +208,7 @@ private function customValidate()
// Validate that disable_local_backup can only be true when S3 backup is enabled
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
- throw new \Exception('Local backup can only be disabled when S3 backup is enabled.');
+ $this->backup->disable_local_backup = $this->disableLocalBackup = false;
}
$isValid = validate_cron_expression($this->backup->frequency);
diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php
index 2f3aae8cf..0b6d8338b 100644
--- a/app/Livewire/Project/Database/BackupExecutions.php
+++ b/app/Livewire/Project/Database/BackupExecutions.php
@@ -202,11 +202,6 @@ public function server()
public function render()
{
- return view('livewire.project.database.backup-executions', [
- 'checkboxes' => [
- ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'],
- // ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently from SFTP Storage'],
- ],
- ]);
+ return view('livewire.project.database.backup-executions');
}
}
diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php
index 5381fa78d..a27a3652f 100644
--- a/app/Livewire/Project/Index.php
+++ b/app/Livewire/Project/Index.php
@@ -21,6 +21,14 @@ public function mount()
$this->projects = Project::ownedByCurrentTeam()->get()->map(function ($project) {
$project->settingsRoute = route('project.edit', ['project_uuid' => $project->uuid]);
$project->canUpdate = auth()->user()->can('update', $project);
+ $project->canCreateResource = auth()->user()->can('createAnyResource');
+ $firstEnvironment = $project->environments->first();
+ $project->addResourceRoute = $firstEnvironment
+ ? route('project.resource.create', [
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $firstEnvironment->uuid,
+ ])
+ : null;
return $project;
});
diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php
index a2071931e..27ecacb99 100644
--- a/app/Livewire/Project/New/GithubPrivateRepository.php
+++ b/app/Livewire/Project/New/GithubPrivateRepository.php
@@ -55,7 +55,7 @@ class GithubPrivateRepository extends Component
public ?string $publish_directory = null;
// In case of docker compose
- public ?string $base_directory = null;
+ public ?string $base_directory = '/';
public ?string $docker_compose_location = '/docker-compose.yaml';
// End of docker compose
@@ -198,6 +198,7 @@ public function submit()
'build_pack' => $this->build_pack,
'ports_exposes' => $this->port,
'publish_directory' => $this->publish_directory,
+ 'base_directory' => $this->base_directory,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination_class,
@@ -212,7 +213,6 @@ public function submit()
}
if ($this->build_pack === 'dockercompose') {
$application['docker_compose_location'] = $this->docker_compose_location;
- $application['base_directory'] = $this->base_directory;
}
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->fqdn = $fqdn;
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
index 639c025c7..07938d9d0 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
@@ -212,6 +212,12 @@ private function handleSingleSubmit($data)
$environment = $this->createEnvironmentVariable($data);
$environment->order = $maxOrder + 1;
$environment->save();
+
+ // Clear computed property cache to force refresh
+ unset($this->environmentVariables);
+ unset($this->environmentVariablesPreview);
+
+ $this->dispatch('success', 'Environment variable added.');
}
private function createEnvironmentVariable($data)
@@ -300,6 +306,9 @@ private function updateOrCreateVariables($isPreview, $variables)
public function refreshEnvs()
{
$this->resource->refresh();
+ // Clear computed property cache to force refresh
+ unset($this->environmentVariables);
+ unset($this->environmentVariablesPreview);
$this->getDevView();
}
}
diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php
index 41541f6b9..9438b7727 100644
--- a/app/Livewire/Storage/Form.php
+++ b/app/Livewire/Storage/Form.php
@@ -5,6 +5,7 @@
use App\Models\S3Storage;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Form extends Component
@@ -91,9 +92,24 @@ public function submit()
try {
$this->authorize('update', $this->storage);
- $this->validate();
- $this->testConnection();
+ DB::transaction(function () {
+ $this->validate();
+ $this->storage->save();
+
+ // Test connection with new values - if this fails, transaction will rollback
+ $this->storage->testConnection(shouldSave: false);
+
+ // If we get here, the connection test succeeded
+ $this->storage->is_usable = true;
+ $this->storage->unusable_email_sent = false;
+ $this->storage->save();
+ });
+
+ $this->dispatch('success', 'Storage settings updated and connection verified.');
} catch (\Throwable $e) {
+ // Refresh the model to revert UI to database values after rollback
+ $this->storage->refresh();
+
return handleError($e, $this);
}
}
diff --git a/app/Livewire/Team/Create.php b/app/Livewire/Team/Create.php
index d3d27556c..cd15be67d 100644
--- a/app/Livewire/Team/Create.php
+++ b/app/Livewire/Team/Create.php
@@ -35,7 +35,7 @@ public function submit()
'personal_team' => false,
]);
auth()->user()->teams()->attach($team, ['role' => 'admin']);
- refreshSession();
+ refreshSession($team);
return redirect()->route('team.index');
} catch (\Throwable $e) {
diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php
index b06dd5b45..c0298ecc8 100644
--- a/app/Models/ScheduledDatabaseBackupExecution.php
+++ b/app/Models/ScheduledDatabaseBackupExecution.php
@@ -8,6 +8,15 @@ class ScheduledDatabaseBackupExecution extends BaseModel
{
protected $guarded = [];
+ protected function casts(): array
+ {
+ return [
+ 's3_uploaded' => 'boolean',
+ 'local_storage_deleted' => 'boolean',
+ 's3_storage_deleted' => 'boolean',
+ ];
+ }
+
public function scheduledDatabaseBackup(): BelongsTo
{
return $this->belongsTo(ScheduledDatabaseBackup::class);
diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php
new file mode 100644
index 000000000..75ae2824c
--- /dev/null
+++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php
@@ -0,0 +1,116 @@
+onQueue('high');
+
+ $this->name = $database->name;
+ $this->frequency = $backup->frequency;
+
+ if ($backup->s3) {
+ $this->s3_storage_url = base_url().'/storages/'.$backup->s3->uuid;
+ }
+ }
+
+ public function via(object $notifiable): array
+ {
+ return $notifiable->getEnabledChannels('backup_failure');
+ }
+
+ public function toMail(): MailMessage
+ {
+ $mail = new MailMessage;
+ $mail->subject("Coolify: Backup succeeded locally but S3 upload failed for {$this->database->name}");
+ $mail->view('emails.backup-success-with-s3-warning', [
+ 'name' => $this->name,
+ 'database_name' => $this->database_name,
+ 'frequency' => $this->frequency,
+ 's3_error' => $this->s3_error,
+ 's3_storage_url' => $this->s3_storage_url,
+ ]);
+
+ return $mail;
+ }
+
+ public function toDiscord(): DiscordMessage
+ {
+ $message = new DiscordMessage(
+ title: ':warning: Database backup succeeded locally, S3 upload failed',
+ description: "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.",
+ color: DiscordMessage::warningColor(),
+ );
+
+ $message->addField('Frequency', $this->frequency, true);
+ $message->addField('S3 Error', $this->s3_error);
+
+ if ($this->s3_storage_url) {
+ $message->addField('S3 Storage', '[Check Configuration]('.$this->s3_storage_url.')');
+ }
+
+ return $message;
+ }
+
+ public function toTelegram(): array
+ {
+ $message = "Coolify: Database backup for {$this->name} (db:{$this->database_name}) with frequency of {$this->frequency} succeeded locally but failed to upload to S3.\n\nS3 Error:\n{$this->s3_error}";
+
+ if ($this->s3_storage_url) {
+ $message .= "\n\nCheck S3 Configuration: {$this->s3_storage_url}";
+ }
+
+ return [
+ 'message' => $message,
+ ];
+ }
+
+ public function toPushover(): PushoverMessage
+ {
+ $message = "Database backup for {$this->name} (db:{$this->database_name}) was created successfully on local storage, but failed to upload to S3.