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/CHANGELOG.md b/CHANGELOG.md
index 3447b223b..aefabfd29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,78 @@ # Changelog
All notable changes to this project will be documented in this file.
-## [unreleased]
+## [4.0.0-beta.434] - 2025-10-03
+
+### 🚀 Features
+
+- *(deployments)* Enhance Docker build argument handling for multiline variables
+- *(deployments)* Add log copying functionality to clipboard in dev
+- *(deployments)* Generate SERVICE_NAME environment variables from Docker Compose services
+
+### 🐛 Bug Fixes
+
+- *(deployments)* Enhance builder container management and environment variable handling
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update version numbers for Coolify releases
+- *(versions)* Bump Coolify stable version to 4.0.0-beta.434
+
+## [4.0.0-beta.433] - 2025-10-01
+
+### 🚀 Features
+
+- *(user-deletion)* Implement file locking to prevent concurrent user deletions and enhance error handling
+- *(ui)* Enhance resource operations interface with dynamic selection for cloning and moving resources
+- *(global-search)* Integrate projects and environments into global search functionality
+- *(storage)* Consolidate storage management into a single component with enhanced UI
+- *(deployments)* Add support for Coolify variables in Dockerfile
+
+### 🐛 Bug Fixes
+
+- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow
+- *(ui)* Update docker registry image helper text for clarity
+- *(ui)* Correct HTML structure and improve clarity in Docker cleanup options
+- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow
+- *(api)* Correct OpenAPI schema annotations for array items
+- *(ui)* Improve queued deployment status readability in dark mode
+- *(git)* Handle additional repository URL cases for 'tangled' and improve branch assignment logic
+- *(git)* Enhance error handling for missing branch information during deployment
+- *(git)* Trim whitespace from repository, branch, and commit SHA fields
+- *(deployments)* Order deployments by ID for consistent retrieval
+
+### 💼 Other
+
+- *(storage)* Enhance file storage management with new properties and UI improvements
+- *(core)* Update projects property type and enhance UI styling
+- *(components)* Adjust SVG icon sizes for consistency across applications and services
+- *(components)* Auto-focus first input in modal on open
+- *(styles)* Enhance focus styles for buttons and links
+- *(components)* Enhance close button accessibility in modal
+
+### 🚜 Refactor
+
+- *(global-search)* Change event listener to window level for global search modal
+- *(dashboard)* Remove deployment loading logic and introduce DeploymentsIndicator component for better UI management
+- *(dashboard)* Replace project navigation method with direct link in UI
+- *(global-search)* Improve event handling and cleanup in global search component
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update coolify version to 4.0.0-beta.433 and nightly version to 4.0.0-beta.434 in configuration files
+
+## [4.0.0-beta.432] - 2025-09-29
### 🚀 Features
@@ -188,6 +259,7 @@ ## [4.0.0-beta.427] - 2025-09-15
### 🚀 Features
+- Add Ente Photos service template
- *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic
- *(ui)* Display current version in settings dropdown and update UI accordingly
- *(settings)* Add option to restrict PR deployments to repository members and contributors
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/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index ce9e723d4..065d7f767 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -1512,9 +1512,32 @@ private function create_application(Request $request, $type)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
- if (! $request->docker_registry_image_tag) {
- $request->offsetSet('docker_registry_image_tag', 'latest');
+ // Process docker image name and tag for SHA256 digests
+ $dockerImageName = $request->docker_registry_image_name;
+ $dockerImageTag = $request->docker_registry_image_tag;
+
+ // Strip 'sha256:' prefix if user provided it in the tag
+ if ($dockerImageTag) {
+ $dockerImageTag = preg_replace('/^sha256:/i', '', trim($dockerImageTag));
}
+
+ // Remove @sha256 from image name if user added it
+ if ($dockerImageName) {
+ $dockerImageName = preg_replace('/@sha256$/i', '', trim($dockerImageName));
+ }
+
+ // Check if tag is a valid SHA256 hash (64 hex characters)
+ $isSha256Hash = $dockerImageTag && preg_match('/^[a-f0-9]{64}$/i', $dockerImageTag);
+
+ // Append @sha256 to image name if using digest and not already present
+ if ($isSha256Hash && ! str_ends_with($dockerImageName, '@sha256')) {
+ $dockerImageName .= '@sha256';
+ }
+
+ // Set processed values back to request
+ $request->offsetSet('docker_registry_image_name', $dockerImageName);
+ $request->offsetSet('docker_registry_image_tag', $dockerImageTag ?: 'latest');
+
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index fcdb472ee..8ffaabde5 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -88,8 +88,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $is_this_additional_server = false;
- private bool $is_laravel_or_symfony = false;
-
private ?ApplicationPreview $preview = null;
private ?string $git_type = null;
@@ -118,16 +116,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_args;
- private $environment_variables;
-
private $env_nixpacks_args;
private $docker_compose;
private $docker_compose_base64;
- private ?string $env_filename = null;
-
private ?string $nixpacks_plan = null;
private Collection $nixpacks_plan_json;
@@ -505,7 +499,12 @@ private function deploy_dockerimage_buildpack()
} else {
$this->dockerImageTag = $this->application->docker_registry_image_tag;
}
- $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}.");
+
+ // Check if this is an image hash deployment
+ $isImageHash = str($this->dockerImageTag)->startsWith('sha256-');
+ $displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}";
+
+ $this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}.");
$this->generate_image_names();
$this->prepare_builder_image();
$this->generate_compose_file();
@@ -573,7 +572,6 @@ private function deploy_docker_compose_buildpack()
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$this->application->oldRawParser();
$yaml = $composeFile = $this->application->docker_compose_raw;
- $this->generate_runtime_environment_variables();
// For raw compose, we cannot automatically add secrets configuration
// User must define it manually in their docker-compose file
@@ -582,16 +580,14 @@ private function deploy_docker_compose_buildpack()
}
} else {
$composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'));
- $this->generate_runtime_environment_variables();
- if (filled($this->env_filename)) {
- $services = collect(data_get($composeFile, 'services', []));
- $services = $services->map(function ($service, $name) {
- $service['env_file'] = [$this->env_filename];
+ // Always add .env file to services
+ $services = collect(data_get($composeFile, 'services', []));
+ $services = $services->map(function ($service, $name) {
+ $service['env_file'] = ['.env'];
- return $service;
- });
- $composeFile['services'] = $services->toArray();
- }
+ return $service;
+ });
+ $composeFile['services'] = $services->toArray();
if (empty($composeFile)) {
$this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.');
$this->fail('Failed to parse docker-compose file.');
@@ -617,6 +613,9 @@ private function deploy_docker_compose_buildpack()
// Build new container to limit downtime.
$this->application_deployment_queue->addLogEntry('Pulling & building required images.');
+ // Save build-time .env file BEFORE the build
+ $this->save_buildtime_environment_variables();
+
if ($this->docker_compose_custom_build_command) {
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
$build_command = $this->docker_compose_custom_build_command;
@@ -632,9 +631,8 @@ private function deploy_docker_compose_buildpack()
if ($this->dockerBuildkitSupported) {
$command = "DOCKER_BUILDKIT=1 {$command}";
}
- if (filled($this->env_filename)) {
- $command .= " --env-file {$this->workdir}/{$this->env_filename}";
- }
+ // Use build-time .env file from /artifacts (outside Docker context to prevent it from being in the image)
+ $command .= ' --env-file /artifacts/build-time.env';
if ($this->force_rebuild) {
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache";
} else {
@@ -654,6 +652,10 @@ private function deploy_docker_compose_buildpack()
);
}
+ // Save runtime environment variables AFTER the build
+ // This overwrites the build-time .env with ALL variables (build-time + runtime)
+ $this->save_runtime_environment_variables();
+
$this->stop_running_container(force: true);
$this->application_deployment_queue->addLogEntry('Starting new application.');
$networkId = $this->application->uuid;
@@ -687,9 +689,8 @@ private function deploy_docker_compose_buildpack()
$this->docker_compose_location = '/docker-compose.yaml';
$command = "{$this->coolify_variables} docker compose";
- if (filled($this->env_filename)) {
- $command .= " --env-file {$server_workdir}/{$this->env_filename}";
- }
+ // Always use .env file
+ $command .= " --env-file {$server_workdir}/.env";
$command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command(
['command' => $command, 'hidden' => true],
@@ -704,9 +705,8 @@ private function deploy_docker_compose_buildpack()
} else {
$command = "{$this->coolify_variables} docker compose";
if ($this->preserveRepository) {
- if (filled($this->env_filename)) {
- $command .= " --env-file {$server_workdir}/{$this->env_filename}";
- }
+ // Always use .env file
+ $command .= " --env-file {$server_workdir}/.env";
$command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
$this->write_deployment_configurations();
@@ -714,9 +714,8 @@ private function deploy_docker_compose_buildpack()
['command' => $command, 'hidden' => true],
);
} else {
- if (filled($this->env_filename)) {
- $command .= " --env-file {$this->workdir}/{$this->env_filename}";
- }
+ // Always use .env file
+ $command .= " --env-file {$this->workdir}/.env";
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command(
[executeInDocker($this->deployment_uuid, $command), 'hidden' => true],
@@ -750,9 +749,18 @@ private function deploy_dockerfile_buildpack()
}
$this->cleanup_git();
$this->generate_compose_file();
+
+ // Save build-time .env file BEFORE the build
+ $this->save_buildtime_environment_variables();
+
$this->generate_build_env_variables();
$this->add_build_env_variables_to_dockerfile();
$this->build_image();
+
+ // Save runtime environment variables AFTER the build
+ // This overwrites the build-time .env with ALL variables (build-time + runtime)
+ $this->save_runtime_environment_variables();
+
$this->push_to_docker_registry();
$this->rolling_update();
}
@@ -773,15 +781,18 @@ private function deploy_nixpacks_buildpack()
}
}
$this->clone_repository();
- $this->detect_laravel_symfony();
$this->cleanup_git();
$this->generate_nixpacks_confs();
$this->generate_compose_file();
+
+ // Save build-time .env file BEFORE the build for Nixpacks
+ $this->save_buildtime_environment_variables();
+
$this->generate_build_env_variables();
$this->build_image();
// For Nixpacks, save runtime environment variables AFTER the build
- // to prevent them from being accessible during the build process
+ // This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
@@ -805,7 +816,16 @@ private function deploy_static_buildpack()
$this->clone_repository();
$this->cleanup_git();
$this->generate_compose_file();
+
+ // Save build-time .env file BEFORE the build
+ $this->save_buildtime_environment_variables();
+
$this->build_static_image();
+
+ // Save runtime environment variables AFTER the build
+ // This overwrites the build-time .env with ALL variables (build-time + runtime)
+ $this->save_runtime_environment_variables();
+
$this->push_to_docker_registry();
$this->rolling_update();
}
@@ -937,7 +957,13 @@ private function generate_image_names()
$this->production_image_name = "{$this->application->uuid}:latest";
}
} elseif ($this->application->build_pack === 'dockerimage') {
- $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
+ // Check if this is an image hash deployment
+ if (str($this->dockerImageTag)->startsWith('sha256-')) {
+ $hash = str($this->dockerImageTag)->after('sha256-');
+ $this->production_image_name = "{$this->dockerImage}@sha256:{$hash}";
+ } else {
+ $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
+ }
} elseif ($this->pull_request_id !== 0) {
if ($this->application->docker_registry_image_name) {
$this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build";
@@ -979,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();
@@ -988,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();
@@ -1052,8 +1086,6 @@ private function generate_runtime_environment_variables()
$envs->push($key.'='.$item);
});
if ($this->pull_request_id === 0) {
- $this->env_filename = '.env';
-
// Generate SERVICE_ variables first for dockercompose
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
@@ -1112,8 +1144,6 @@ private function generate_runtime_environment_variables()
$envs->push('HOST=0.0.0.0');
}
} else {
- $this->env_filename = '.env';
-
// Generate SERVICE_ variables first for dockercompose preview
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]);
@@ -1168,99 +1198,250 @@ private function generate_runtime_environment_variables()
$envs->push('HOST=0.0.0.0');
}
}
- if ($envs->isEmpty()) {
- if ($this->env_filename) {
- if ($this->use_build_server) {
- $this->server = $this->original_server;
- $this->execute_remote_command(
- [
- 'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
- 'hidden' => true,
- 'ignore_errors' => true,
- ]
- );
- $this->server = $this->build_server;
- $this->execute_remote_command(
- [
- 'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
- 'hidden' => true,
- 'ignore_errors' => true,
- ]
- );
- } else {
- $this->execute_remote_command(
- [
- 'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
- 'hidden' => true,
- 'ignore_errors' => true,
- ]
- );
- }
- }
- $this->env_filename = null;
- } else {
- // For Nixpacks builds, we save the .env file AFTER the build to prevent
- // runtime-only variables from being accessible during the build process
- if ($this->application->build_pack !== 'nixpacks' || $this->skip_build) {
- $envs_base64 = base64_encode($envs->implode("\n"));
- $this->execute_remote_command(
- [
- executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"),
- ],
- );
- if ($this->use_build_server) {
- $this->server = $this->original_server;
- $this->execute_remote_command(
- [
- "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
- ]
- );
- $this->server = $this->build_server;
- } else {
- $this->execute_remote_command(
- [
- "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
- ]
- );
- }
- }
- }
- $this->environment_variables = $envs;
+ // Return the generated environment variables instead of storing them globally
+ return $envs;
}
private function save_runtime_environment_variables()
{
- // This method saves the .env file with runtime variables
- // It should be called AFTER the build for Nixpacks to prevent runtime-only variables
- // from being accessible during the build process
+ // This method saves the .env file with ALL runtime variables
+ // For builds, it should be called AFTER the build to include runtime-only variables
- if ($this->environment_variables && $this->environment_variables->isNotEmpty() && $this->env_filename) {
- $envs_base64 = base64_encode($this->environment_variables->implode("\n"));
+ // Generate runtime environment variables locally
+ $environment_variables = $this->generate_runtime_environment_variables();
- // Write .env file to workdir (for container runtime)
+ // Handle empty environment variables
+ if ($environment_variables->isEmpty()) {
+ // For Docker Compose, we need to create an empty .env file
+ // because we always reference it in the compose file
+ if ($this->build_pack === 'dockercompose') {
+ $this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).');
+
+ // Create empty .env file
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "touch $this->workdir/.env"),
+ ]
+ );
+
+ // Also create in configuration directory
+ if ($this->use_build_server) {
+ $this->server = $this->original_server;
+ $this->execute_remote_command(
+ [
+ "touch $this->configuration_dir/.env",
+ ]
+ );
+ $this->server = $this->build_server;
+ } else {
+ $this->execute_remote_command(
+ [
+ "touch $this->configuration_dir/.env",
+ ]
+ );
+ }
+ } else {
+ // For non-Docker Compose deployments, clean up any existing .env files
+ if ($this->use_build_server) {
+ $this->server = $this->original_server;
+ $this->execute_remote_command(
+ [
+ 'command' => "rm -f $this->configuration_dir/.env",
+ 'hidden' => true,
+ 'ignore_errors' => true,
+ ]
+ );
+ $this->server = $this->build_server;
+ $this->execute_remote_command(
+ [
+ 'command' => "rm -f $this->configuration_dir/.env",
+ 'hidden' => true,
+ 'ignore_errors' => true,
+ ]
+ );
+ } else {
+ $this->execute_remote_command(
+ [
+ 'command' => "rm -f $this->configuration_dir/.env",
+ 'hidden' => true,
+ 'ignore_errors' => true,
+ ]
+ );
+ }
+ }
+
+ return;
+ }
+
+ // Write the environment variables to file
+ $envs_base64 = base64_encode($environment_variables->implode("\n"));
+
+ // Write .env file to workdir (for container runtime)
+ $this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for build phase.', hidden: true);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"),
+ 'hidden' => true,
+
+ ]
+ );
+
+ // Write .env file to configuration directory
+ if ($this->use_build_server) {
+ $this->server = $this->original_server;
$this->execute_remote_command(
[
- executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"),
+ "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
+ ]
+ );
+ $this->server = $this->build_server;
+ } else {
+ $this->execute_remote_command(
+ [
+ "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
+ ]
+ );
+ }
+ }
+
+ private function generate_buildtime_environment_variables()
+ {
+ $envs = collect([]);
+ $coolify_envs = $this->generate_coolify_env_variables();
+
+ // Add COOLIFY variables
+ $coolify_envs->each(function ($item, $key) use ($envs) {
+ $envs->push($key.'='.$item);
+ });
+
+ // Add SERVICE_NAME variables for Docker Compose builds
+ if ($this->build_pack === 'dockercompose') {
+ if ($this->pull_request_id === 0) {
+ // Generate SERVICE_NAME for dockercompose services from processed compose
+ if ($this->application->settings->is_raw_compose_deployment_enabled) {
+ $dockerCompose = Yaml::parse($this->application->docker_compose_raw);
+ } else {
+ $dockerCompose = Yaml::parse($this->application->docker_compose);
+ }
+ $services = data_get($dockerCompose, 'services', []);
+ foreach ($services as $serviceName => $_) {
+ $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName);
+ }
+
+ // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
+ $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
+ foreach ($domains as $forServiceName => $domain) {
+ $parsedDomain = data_get($domain, 'domain');
+ if (filled($parsedDomain)) {
+ $parsedDomain = str($parsedDomain)->explode(',')->first();
+ $coolifyUrl = Url::fromString($parsedDomain);
+ $coolifyScheme = $coolifyUrl->getScheme();
+ $coolifyFqdn = $coolifyUrl->getHost();
+ $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
+ $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
+ $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
+ }
+ }
+ } else {
+ // Generate SERVICE_NAME for preview deployments
+ $rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
+ $rawServices = data_get($rawDockerCompose, 'services', []);
+ foreach ($rawServices as $rawServiceName => $_) {
+ $envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
+ }
+
+ // Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
+ $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]);
+ foreach ($domains as $forServiceName => $domain) {
+ $parsedDomain = data_get($domain, 'domain');
+ if (filled($parsedDomain)) {
+ $parsedDomain = str($parsedDomain)->explode(',')->first();
+ $coolifyUrl = Url::fromString($parsedDomain);
+ $coolifyScheme = $coolifyUrl->getScheme();
+ $coolifyFqdn = $coolifyUrl->getHost();
+ $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
+ $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
+ $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
+ }
+ }
+ }
+ }
+
+ // Add build-time user variables only
+ if ($this->pull_request_id === 0) {
+ $sorted_environment_variables = $this->application->environment_variables()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true) // ONLY build-time variables
+ ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
+ ->get();
+
+ // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these
+ if ($this->build_pack === 'dockercompose') {
+ $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
+ return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
+ });
+ }
+
+ foreach ($sorted_environment_variables as $env) {
+ $envs->push($env->key.'='.$env->real_value);
+ }
+ } else {
+ $sorted_environment_variables = $this->application->environment_variables_preview()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true) // ONLY build-time variables
+ ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
+ ->get();
+
+ // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values
+ if ($this->build_pack === 'dockercompose') {
+ $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
+ return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
+ });
+ }
+
+ foreach ($sorted_environment_variables as $env) {
+ $envs->push($env->key.'='.$env->real_value);
+ }
+ }
+
+ // Return the generated environment variables
+ return $envs;
+ }
+
+ private function save_buildtime_environment_variables()
+ {
+ // Generate build-time environment variables locally
+ $environment_variables = $this->generate_buildtime_environment_variables();
+
+ // Save .env file for build phase in /artifacts to prevent it from being copied into Docker images
+ if ($environment_variables->isNotEmpty()) {
+ $envs_base64 = base64_encode($environment_variables->implode("\n"));
+
+ $this->application_deployment_queue->addLogEntry('Creating build-time .env file in /artifacts (outside Docker context).', hidden: true);
+
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee /artifacts/build-time.env > /dev/null"),
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'cat /artifacts/build-time.env'),
+ 'hidden' => true,
],
);
+ } elseif ($this->build_pack === 'dockercompose') {
+ // For Docker Compose, create an empty .env file even if there are no build-time variables
+ // This ensures the file exists when referenced in docker-compose commands
+ $this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
- // Write .env file to configuration directory
- if ($this->use_build_server) {
- $this->server = $this->original_server;
- $this->execute_remote_command(
- [
- "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
- ]
- );
- $this->server = $this->build_server;
- } else {
- $this->execute_remote_command(
- [
- "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
- ]
- );
- }
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, 'touch /artifacts/build-time.env'),
+ ]
+ );
}
}
@@ -1288,24 +1469,34 @@ private function elixir_finetunes()
}
}
- private function symfony_finetunes(&$parsed)
+ private function laravel_finetunes()
{
- $installCmds = data_get($parsed, 'phases.install.cmds', []);
- $variables = data_get($parsed, 'variables', []);
+ if ($this->pull_request_id === 0) {
+ $envType = 'environment_variables';
+ } else {
+ $envType = 'environment_variables_preview';
+ }
+ $nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first();
+ $nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first();
- $envCommands = [];
- foreach (array_keys($variables) as $key) {
- $envCommands[] = "printf '%s=%s\\n' ".escapeshellarg($key)." \"\${$key}\" >> /app/.env";
+ if (! $nixpacks_php_fallback_path) {
+ $nixpacks_php_fallback_path = new EnvironmentVariable;
+ $nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH';
+ $nixpacks_php_fallback_path->value = '/index.php';
+ $nixpacks_php_fallback_path->resourceable_id = $this->application->id;
+ $nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application';
+ $nixpacks_php_fallback_path->save();
+ }
+ if (! $nixpacks_php_root_dir) {
+ $nixpacks_php_root_dir = new EnvironmentVariable;
+ $nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR';
+ $nixpacks_php_root_dir->value = '/app/public';
+ $nixpacks_php_root_dir->resourceable_id = $this->application->id;
+ $nixpacks_php_root_dir->resourceable_type = 'App\Models\Application';
+ $nixpacks_php_root_dir->save();
}
- if (! empty($envCommands)) {
- $createEnvCmd = 'touch /app/.env';
-
- array_unshift($installCmds, $createEnvCmd);
- array_splice($installCmds, 1, 0, $envCommands);
-
- data_set($parsed, 'phases.install.cmds', $installCmds);
- }
+ return [$nixpacks_php_fallback_path, $nixpacks_php_root_dir];
}
private function rolling_update()
@@ -1460,21 +1651,23 @@ private function deploy_pull_request()
$this->prepare_builder_image();
$this->check_git_if_build_needed();
$this->clone_repository();
- $this->detect_laravel_symfony();
$this->cleanup_git();
if ($this->application->build_pack === 'nixpacks') {
$this->generate_nixpacks_confs();
}
$this->generate_compose_file();
+
+ // Save build-time .env file BEFORE the build
+ $this->save_buildtime_environment_variables();
+
$this->generate_build_env_variables();
if ($this->application->build_pack === 'dockerfile') {
$this->add_build_env_variables_to_dockerfile();
}
$this->build_image();
- // For Nixpacks, save runtime environment variables AFTER the build
- if ($this->application->build_pack === 'nixpacks') {
- $this->save_runtime_environment_variables();
- }
+
+ // This overwrites the build-time .env with ALL variables (build-time + runtime)
+ $this->save_runtime_environment_variables();
$this->push_to_docker_registry();
$this->rolling_update();
}
@@ -1509,7 +1702,7 @@ private function create_workdir()
}
}
- private function prepare_builder_image()
+ private function prepare_builder_image(bool $firstTry = true)
{
$this->checkForCancellation();
$settings = instanceSettings();
@@ -1520,7 +1713,6 @@ private function prepare_builder_image()
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
$env_flags = $this->generate_docker_env_flags_for_secrets();
-
if ($this->use_build_server) {
if ($this->dockerConfigFileExists === 'NOK') {
throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
@@ -1533,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(
[
@@ -1547,6 +1744,18 @@ private function prepare_builder_image()
$this->run_pre_deployment_command();
}
+ private function restart_builder_container_with_actual_commit()
+ {
+ // Stop and remove the current helper container
+ $this->graceful_shutdown_container($this->deployment_uuid);
+
+ // Clear cached env_args to force regeneration with actual SOURCE_COMMIT value
+ $this->env_args = null;
+
+ // Restart the helper container with updated environment variables (including actual SOURCE_COMMIT)
+ $this->prepare_builder_image(firstTry: false);
+ }
+
private function deploy_to_additional_destinations()
{
if ($this->application->additional_networks->count() === 0) {
@@ -1615,6 +1824,8 @@ private function set_coolify_variables()
if (isset($this->application->git_branch)) {
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
}
+ $this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
+ $this->coolify_variables .= "COOLIFY_CONTAINER_NAME={$this->container_name} ";
}
private function check_git_if_build_needed()
@@ -1677,27 +1888,17 @@ private function check_git_if_build_needed()
);
}
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) {
- $output = $this->saved_outputs->get('git_commit_sha');
-
- if ($output->isEmpty() || ! str($output)->contains("\t")) {
- $errorMessage = "Failed to find branch '{$local_branch}' in repository.\n\n";
- $errorMessage .= "Please verify:\n";
- $errorMessage .= "- The branch name is correct\n";
- $errorMessage .= "- The branch exists in the repository\n";
- $errorMessage .= "- You have access to the repository\n";
-
- if ($this->pull_request_id !== 0) {
- $errorMessage .= "- The pull request #{$this->pull_request_id} exists and is accessible\n";
- }
-
- throw new \RuntimeException($errorMessage);
- }
-
- $this->commit = $output->before("\t");
+ $this->commit = $this->saved_outputs->get('git_commit_sha')->before("\t");
$this->application_deployment_queue->commit = $this->commit;
$this->application_deployment_queue->save();
}
$this->set_coolify_variables();
+
+ // Restart helper container with actual SOURCE_COMMIT value
+ if ($this->application->settings->use_build_secrets && $this->commit !== 'HEAD') {
+ $this->application_deployment_queue->addLogEntry('Restarting helper container with actual SOURCE_COMMIT value.');
+ $this->restart_builder_container_with_actual_commit();
+ }
}
private function clone_repository()
@@ -1743,78 +1944,6 @@ private function generate_git_import_commands()
return $commands;
}
- private function detect_laravel_symfony()
- {
- if ($this->application->build_pack !== 'nixpacks') {
- return;
- }
-
- $this->execute_remote_command([
- executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/composer.json && echo 'exists' || echo 'not-exists'"),
- 'save' => 'composer_json_exists',
- 'hidden' => true,
- ]);
-
- if ($this->saved_outputs->get('composer_json_exists') == 'exists') {
- $this->execute_remote_command([
- executeInDocker($this->deployment_uuid, 'grep -E -q "laravel/framework|symfony/dotenv|symfony/framework-bundle|symfony/flex" '.$this->workdir.'/composer.json 2>/dev/null && echo "true" || echo "false"'),
- 'save' => 'is_laravel_or_symfony',
- 'hidden' => true,
- ]);
-
- $this->is_laravel_or_symfony = $this->saved_outputs->get('is_laravel_or_symfony') == 'true';
-
- if ($this->is_laravel_or_symfony) {
- $this->application_deployment_queue->addLogEntry('Laravel/Symfony framework detected. Setting NIXPACKS PHP variables.');
- $this->ensure_nixpacks_php_variables();
- }
- }
- }
-
- private function ensure_nixpacks_php_variables()
- {
- if ($this->pull_request_id === 0) {
- $envType = 'environment_variables';
- } else {
- $envType = 'environment_variables_preview';
- }
-
- $nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first();
- $nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first();
-
- $created_new = false;
- if (! $nixpacks_php_fallback_path) {
- $nixpacks_php_fallback_path = new EnvironmentVariable;
- $nixpacks_php_fallback_path->key = 'NIXPACKS_PHP_FALLBACK_PATH';
- $nixpacks_php_fallback_path->value = '/index.php';
- $nixpacks_php_fallback_path->is_buildtime = true;
- $nixpacks_php_fallback_path->is_preview = $this->pull_request_id !== 0;
- $nixpacks_php_fallback_path->resourceable_id = $this->application->id;
- $nixpacks_php_fallback_path->resourceable_type = 'App\Models\Application';
- $nixpacks_php_fallback_path->save();
- $this->application_deployment_queue->addLogEntry('Created NIXPACKS_PHP_FALLBACK_PATH environment variable.');
- $created_new = true;
- }
- if (! $nixpacks_php_root_dir) {
- $nixpacks_php_root_dir = new EnvironmentVariable;
- $nixpacks_php_root_dir->key = 'NIXPACKS_PHP_ROOT_DIR';
- $nixpacks_php_root_dir->value = '/app/public';
- $nixpacks_php_root_dir->is_buildtime = true;
- $nixpacks_php_root_dir->is_preview = $this->pull_request_id !== 0;
- $nixpacks_php_root_dir->resourceable_id = $this->application->id;
- $nixpacks_php_root_dir->resourceable_type = 'App\Models\Application';
- $nixpacks_php_root_dir->save();
- $this->application_deployment_queue->addLogEntry('Created NIXPACKS_PHP_ROOT_DIR environment variable.');
- $created_new = true;
- }
-
- if ($this->pull_request_id === 0) {
- $this->application->load(['nixpacks_environment_variables', 'environment_variables']);
- } else {
- $this->application->load(['nixpacks_environment_variables_preview', 'environment_variables_preview']);
- }
- }
-
private function cleanup_git()
{
$this->execute_remote_command(
@@ -1824,51 +1953,30 @@ private function cleanup_git()
private function generate_nixpacks_confs()
{
+ $nixpacks_command = $this->nixpacks_build_cmd();
+ $this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command");
+
$this->execute_remote_command(
+ [executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true],
[executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true],
);
-
if ($this->saved_outputs->get('nixpacks_type')) {
$this->nixpacks_type = $this->saved_outputs->get('nixpacks_type');
if (str($this->nixpacks_type)->isEmpty()) {
throw new RuntimeException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers');
}
}
- $nixpacks_command = $this->nixpacks_build_cmd();
- $this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command");
-
- $this->execute_remote_command(
- [executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true],
- );
if ($this->saved_outputs->get('nixpacks_plan')) {
$this->nixpacks_plan = $this->saved_outputs->get('nixpacks_plan');
if ($this->nixpacks_plan) {
$this->application_deployment_queue->addLogEntry("Found application type: {$this->nixpacks_type}.");
$this->application_deployment_queue->addLogEntry("If you need further customization, please check the documentation of Nixpacks: https://nixpacks.com/docs/providers/{$this->nixpacks_type}");
- $parsed = json_decode($this->nixpacks_plan);
+ $parsed = json_decode($this->nixpacks_plan, true);
// Do any modifications here
// We need to generate envs here because nixpacks need to know to generate a proper Dockerfile
$this->generate_env_variables();
-
- if ($this->is_laravel_or_symfony) {
- if ($this->pull_request_id === 0) {
- $envType = 'environment_variables';
- } else {
- $envType = 'environment_variables_preview';
- }
- $nixpacks_php_fallback_path = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_FALLBACK_PATH')->first();
- $nixpacks_php_root_dir = $this->application->{$envType}->where('key', 'NIXPACKS_PHP_ROOT_DIR')->first();
-
- if ($nixpacks_php_fallback_path) {
- data_set($parsed, 'variables.NIXPACKS_PHP_FALLBACK_PATH', $nixpacks_php_fallback_path->value);
- }
- if ($nixpacks_php_root_dir) {
- data_set($parsed, 'variables.NIXPACKS_PHP_ROOT_DIR', $nixpacks_php_root_dir->value);
- }
- }
-
$merged_envs = collect(data_get($parsed, 'variables', []))->merge($this->env_args);
$aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []);
if (count($aptPkgs) === 0) {
@@ -1884,23 +1992,32 @@ private function generate_nixpacks_confs()
data_set($parsed, 'phases.setup.aptPkgs', $aptPkgs);
}
data_set($parsed, 'variables', $merged_envs->toArray());
-
- if ($this->is_laravel_or_symfony) {
- $this->symfony_finetunes($parsed);
+ $is_laravel = data_get($parsed, 'variables.IS_LARAVEL', false);
+ if ($is_laravel) {
+ $variables = $this->laravel_finetunes();
+ data_set($parsed, 'variables.NIXPACKS_PHP_FALLBACK_PATH', $variables[0]->value);
+ data_set($parsed, 'variables.NIXPACKS_PHP_ROOT_DIR', $variables[1]->value);
}
-
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);
if ($this->nixpacks_type === 'rust') {
// temporary: disable healthcheck for rust because the start phase does not have curl/wget
$this->application->health_check_enabled = false;
$this->application->save();
}
- $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);
}
}
}
@@ -2038,11 +2155,14 @@ private function generate_env_variables()
{
$this->env_args = collect([]);
$this->env_args->put('SOURCE_COMMIT', $this->commit);
+
$coolify_envs = $this->generate_coolify_env_variables();
+ $coolify_envs->each(function ($value, $key) {
+ $this->env_args->put($key, $value);
+ });
// For build process, include only environment variables where is_buildtime = true
if ($this->pull_request_id === 0) {
- // Get environment variables that are marked as available during build
$envs = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true)
@@ -2051,24 +2171,9 @@ private function generate_env_variables()
foreach ($envs as $env) {
if (! is_null($env->real_value)) {
$this->env_args->put($env->key, $env->real_value);
- if (str($env->real_value)->startsWith('$')) {
- $variable_key = str($env->real_value)->after('$');
- if ($variable_key->startsWith('COOLIFY_')) {
- $variable = $coolify_envs->get($variable_key->value());
- if (filled($variable)) {
- $this->env_args->prepend($variable, $variable_key->value());
- }
- } else {
- $variable = $this->application->environment_variables()->where('key', $variable_key)->first();
- if ($variable) {
- $this->env_args->prepend($variable->real_value, $env->key);
- }
- }
- }
}
}
} else {
- // Get preview environment variables that are marked as available during build
$envs = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true)
@@ -2077,20 +2182,6 @@ private function generate_env_variables()
foreach ($envs as $env) {
if (! is_null($env->real_value)) {
$this->env_args->put($env->key, $env->real_value);
- if (str($env->real_value)->startsWith('$')) {
- $variable_key = str($env->real_value)->after('$');
- if ($variable_key->startsWith('COOLIFY_')) {
- $variable = $coolify_envs->get($variable_key->value());
- if (filled($variable)) {
- $this->env_args->prepend($variable, $variable_key->value());
- }
- } else {
- $variable = $this->application->environment_variables_preview()->where('key', $variable_key)->first();
- if ($variable) {
- $this->env_args->prepend($variable->real_value, $env->key);
- }
- }
- }
}
}
}
@@ -2104,7 +2195,6 @@ private function generate_compose_file()
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->application->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
- $this->generate_runtime_environment_variables();
if (data_get($this->application, 'custom_labels')) {
$this->application->parseContainerLabels();
$labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels)));
@@ -2173,9 +2263,8 @@ private function generate_compose_file()
],
],
];
- if (filled($this->env_filename)) {
- $docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename];
- }
+ // Always use .env file
+ $docker_compose['services'][$this->container_name]['env_file'] = ['.env'];
$docker_compose['services'][$this->container_name]['healthcheck'] = [
'test' => [
'CMD-SHELL',
@@ -2460,6 +2549,18 @@ private function build_static_image()
$this->application_deployment_queue->addLogEntry('Building docker image completed.');
}
+ /**
+ * Wrap a docker build command with environment export from /artifacts/build-time.env
+ * This enables shell interpolation of variables (e.g., APP_URL=$COOLIFY_URL)
+ *
+ * @param string $build_command The docker build command to wrap
+ * @return string The wrapped command with export statement
+ */
+ private function wrap_build_command_with_env_export(string $build_command): string
+ {
+ return "cd {$this->workdir} && set -a && source /artifacts/build-time.env && set +a && {$build_command}";
+ }
+
private function build_image()
{
// Add Coolify related variables to the build args/secrets
@@ -2467,13 +2568,12 @@ private function build_image()
// Coolify variables are already included in the secrets from generate_build_env_variables
// build_secrets is already a string at this point
} else {
- // Traditional build args approach
- $this->environment_variables->filter(function ($key, $value) {
- return str($key)->startsWith('COOLIFY_');
- })->each(function ($key, $value) {
+ // Traditional build args approach - generate COOLIFY_ variables locally
+ // Generate COOLIFY_ variables locally for build args
+ $coolify_envs = $this->generate_coolify_env_variables();
+ $coolify_envs->each(function ($value, $key) {
$this->build_args->push("--build-arg '{$key}'");
});
-
$this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
? $this->build_args->implode(' ')
: (string) $this->build_args;
@@ -2510,12 +2610,13 @@ private function build_image()
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
- $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
- $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
+ ray($build_command);
} else {
- $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@@ -2525,13 +2626,18 @@ private function build_image()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
- if ($this->dockerBuildkitSupported) {
+ if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
- $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
+ } elseif ($this->dockerBuildkitSupported) {
+ // BuildKit without secrets
+ $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
} else {
- $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
}
}
@@ -2558,16 +2664,25 @@ private function build_image()
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
- $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
- $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
+ }
+ } elseif ($this->dockerBuildkitSupported) {
+ // BuildKit without secrets
+ $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ if ($this->force_rebuild) {
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
+ } else {
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
- $build_command = "docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
- $build_command = "docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -2600,7 +2715,7 @@ private function build_image()
$nginx_config = base64_encode(defaultNginxConfiguration());
}
}
- $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
$base64_build_command = base64_encode($build_command);
$this->execute_remote_command(
[
@@ -2637,9 +2752,9 @@ private function build_image()
} else {
// Traditional build with args
if ($this->force_rebuild) {
- $build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
} else {
- $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -2669,13 +2784,18 @@ private function build_image()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
- if ($this->dockerBuildkitSupported) {
+ if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
- $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
+ } elseif ($this->dockerBuildkitSupported) {
+ // BuildKit without secrets
+ $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
- $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
$this->execute_remote_command([
@@ -2685,13 +2805,18 @@ private function build_image()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
'hidden' => true,
]);
- if ($this->dockerBuildkitSupported) {
+ if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
// Modify the nixpacks Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
- $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
+ } elseif ($this->dockerBuildkitSupported) {
+ // BuildKit without secrets
+ $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
} else {
- $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -2712,20 +2837,31 @@ private function build_image()
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]);
} else {
// Dockerfile buildpack
- if ($this->dockerBuildkitSupported) {
+ if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
+ // Modify the Dockerfile to use build secrets
+ $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
// Use BuildKit with secrets
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
- $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
} else {
- $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
+ }
+ } elseif ($this->dockerBuildkitSupported) {
+ // BuildKit without secrets
+ $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
+ $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
+ if ($this->force_rebuild) {
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
+ } else {
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
- $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
} else {
- $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}";
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -2867,7 +3003,6 @@ private function generate_build_env_variables()
$this->generate_env_variables();
$variables = collect([])->merge($this->env_args);
}
-
// Analyze build variables for potential issues
if ($variables->isNotEmpty()) {
$this->analyzeBuildTimeVariables($variables);
@@ -2882,12 +3017,23 @@ private function generate_build_env_variables()
$secrets_hash = $this->generate_secrets_hash($variables);
}
- $this->build_args = $variables->map(function ($value, $key) {
- $value = escapeshellarg($value);
+ $env_vars = $this->pull_request_id === 0
+ ? $this->application->environment_variables()->where('is_buildtime', true)->get()
+ : $this->application->environment_variables_preview()->where('is_buildtime', true)->get();
- return "--build-arg {$key}={$value}";
+ // Map variables to include is_multiline flag
+ $vars_with_metadata = $variables->map(function ($value, $key) use ($env_vars) {
+ $env = $env_vars->firstWhere('key', $key);
+
+ return [
+ 'key' => $key,
+ 'value' => $value,
+ 'is_multiline' => $env ? $env->is_multiline : false,
+ ];
});
+ $this->build_args = generateDockerBuildArgs($vars_with_metadata);
+
if ($secrets_hash) {
$this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
}
@@ -2901,23 +3047,37 @@ private function generate_docker_env_flags_for_secrets()
return '';
}
- $variables = $this->pull_request_id === 0
- ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get()
- : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get();
+ // 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()) {
+ $this->generate_env_variables();
+ }
+
+ $variables = $this->env_args;
if ($variables->isEmpty()) {
return '';
}
$secrets_hash = $this->generate_secrets_hash($variables);
- $env_flags = $variables
- ->map(function ($env) {
- $escaped_value = escapeshellarg($env->real_value);
- return "-e {$env->key}={$escaped_value}";
- })
- ->implode(' ');
+ // Get database env vars to check for multiline flag
+ $env_vars = $this->pull_request_id === 0
+ ? $this->application->environment_variables()->where('is_buildtime', true)->get()
+ : $this->application->environment_variables_preview()->where('is_buildtime', true)->get();
+ // Map to simple array format for the helper function
+ $vars_array = $variables->map(function ($value, $key) use ($env_vars) {
+ $env = $env_vars->firstWhere('key', $key);
+
+ return [
+ 'key' => $key,
+ 'value' => $value,
+ 'is_multiline' => $env ? $env->is_multiline : false,
+ ];
+ });
+
+ $env_flags = generateDockerEnvFlags($vars_array);
$env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}";
return $env_flags;
@@ -2977,9 +3137,9 @@ private function add_build_env_variables_to_dockerfile()
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"));
-
if ($this->pull_request_id === 0) {
// Only add environment variables that are available during build
$envs = $this->application->environment_variables()
@@ -2993,6 +3153,17 @@ private function add_build_env_variables_to_dockerfile()
$dockerfile->splice(1, 0, ["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}";
+ });
+ foreach ($coolify_vars as $arg) {
+ $dockerfile->splice(1, 0, [$arg]);
+ }
+ }
} else {
// Only add preview environment variables that are available during build
$envs = $this->application->environment_variables_preview()
@@ -3006,6 +3177,17 @@ private function add_build_env_variables_to_dockerfile()
$dockerfile->splice(1, 0, ["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}";
+ });
+ foreach ($coolify_vars as $arg) {
+ $dockerfile->splice(1, 0, [$arg]);
+ }
+ }
}
if ($envs->isNotEmpty()) {
@@ -3014,10 +3196,17 @@ private function add_build_env_variables_to_dockerfile()
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
- $this->execute_remote_command([
- executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
- 'hidden' => true,
- ]);
+ $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info');
+ $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,
+ ]);
}
}
@@ -3042,16 +3231,19 @@ private function modify_dockerfile_for_secrets($dockerfile_path)
$dockerfile->prepend('# syntax=docker/dockerfile:1');
}
- // Get environment variables for secrets
- $variables = $this->pull_request_id === 0
- ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get()
- : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get();
+ // 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()) {
+ $this->generate_env_variables();
+ }
+
+ $variables = $this->env_args;
if ($variables->isEmpty()) {
return;
}
// Generate mount strings for all secrets
- $mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' ');
+ $mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' ');
// Add mount for the secrets hash to ensure cache invalidation
$mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
@@ -3079,8 +3271,6 @@ private function modify_dockerfile_for_secrets($dockerfile_path)
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"),
'hidden' => true,
]);
-
- $this->application_deployment_queue->addLogEntry('Modified Dockerfile to use build secrets.');
}
}
@@ -3090,15 +3280,13 @@ private function modify_dockerfiles_for_compose($composeFile)
return;
}
- $variables = $this->pull_request_id === 0
- ? $this->application->environment_variables()
- ->where('key', 'not like', 'NIXPACKS_%')
- ->where('is_buildtime', true)
- ->get()
- : $this->application->environment_variables_preview()
- ->where('key', 'not like', 'NIXPACKS_%')
- ->where('is_buildtime', true)
- ->get();
+ // 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()) {
+ $this->generate_env_variables();
+ }
+
+ $variables = $this->env_args;
if ($variables->isEmpty()) {
$this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.');
@@ -3172,11 +3360,10 @@ private function modify_dockerfiles_for_compose($composeFile)
$isMultiStage = count($fromIndices) > 1;
$argsToAdd = collect([]);
- foreach ($variables as $env) {
- $argsToAdd->push("ARG {$env->key}");
+ foreach ($variables as $key => $value) {
+ $argsToAdd->push("ARG {$key}");
}
- ray($argsToAdd);
if ($argsToAdd->isEmpty()) {
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add.");
@@ -3244,19 +3431,22 @@ private function modify_dockerfiles_for_compose($composeFile)
private function add_build_secrets_to_compose($composeFile)
{
- // Get environment variables for secrets
- $variables = $this->pull_request_id === 0
- ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get()
- : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get();
+ // 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()) {
+ $this->generate_env_variables();
+ }
+
+ $variables = $this->env_args;
if ($variables->isEmpty()) {
return $composeFile;
}
$secrets = [];
- foreach ($variables as $env) {
- $secrets[$env->key] = [
- 'environment' => $env->key,
+ foreach ($variables as $key => $value) {
+ $secrets[$key] = [
+ 'environment' => $key,
];
}
@@ -3271,9 +3461,9 @@ private function add_build_secrets_to_compose($composeFile)
if (! isset($service['build']['secrets'])) {
$service['build']['secrets'] = [];
}
- foreach ($variables as $env) {
- if (! in_array($env->key, $service['build']['secrets'])) {
- $service['build']['secrets'][] = $env->key;
+ foreach ($variables as $key => $value) {
+ if (! in_array($key, $service['build']['secrets'])) {
+ $service['build']['secrets'][] = $key;
}
}
}
@@ -3392,7 +3582,6 @@ private function next(string $status)
queue_next_deployment($this->application);
if ($status === ApplicationDeploymentStatus::FINISHED->value) {
- ray($this->application->team()->id);
event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) {
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/Dashboard.php b/app/Livewire/Dashboard.php
index 45781af30..57ecaa8a2 100644
--- a/app/Livewire/Dashboard.php
+++ b/app/Livewire/Dashboard.php
@@ -10,7 +10,7 @@
class Dashboard extends Component
{
- public $projects = [];
+ public Collection $projects;
public Collection $servers;
@@ -23,11 +23,6 @@ public function mount()
$this->projects = Project::ownedByCurrentTeam()->get();
}
- public function navigateToProject($projectUuid)
- {
- return $this->redirect(collect($this->projects)->firstWhere('uuid', $projectUuid)->navigateTo(), navigate: false);
- }
-
public function render()
{
return view('livewire.dashboard');
diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php
index 34529a7e7..ac9cfd1c2 100644
--- a/app/Livewire/DeploymentsIndicator.php
+++ b/app/Livewire/DeploymentsIndicator.php
@@ -16,8 +16,10 @@ public function deployments()
{
$servers = Server::ownedByCurrentTeam()->get();
- return ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])
+ return ApplicationDeploymentQueue::with(['application.environment.project'])
+ ->whereIn('status', ['in_progress', 'queued'])
->whereIn('server_id', $servers->pluck('id'))
+ ->orderBy('id')
->get([
'id',
'application_id',
@@ -27,8 +29,7 @@ public function deployments()
'server_name',
'server_id',
'status',
- ])
- ->sortBy('id');
+ ]);
}
#[Computed]
diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php
index dacc0d4db..87008e45e 100644
--- a/app/Livewire/GlobalSearch.php
+++ b/app/Livewire/GlobalSearch.php
@@ -3,6 +3,8 @@
namespace App\Livewire;
use App\Models\Application;
+use App\Models\Environment;
+use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
@@ -20,24 +22,66 @@ class GlobalSearch extends Component
{
public $searchQuery = '';
+ private $previousTrimmedQuery = '';
+
public $isModalOpen = false;
public $searchResults = [];
public $allSearchableItems = [];
+ public $isCreateMode = false;
+
+ public $creatableItems = [];
+
+ public $autoOpenResource = null;
+
+ // Resource selection state
+ public $isSelectingResource = false;
+
+ public $selectedResourceType = null;
+
+ public $loadingServers = false;
+
+ public $loadingProjects = false;
+
+ public $loadingEnvironments = false;
+
+ public $availableServers = [];
+
+ public $availableProjects = [];
+
+ public $availableEnvironments = [];
+
+ public $selectedServerId = null;
+
+ public $selectedDestinationUuid = null;
+
+ public $selectedProjectUuid = null;
+
+ public $selectedEnvironmentUuid = null;
+
+ public $availableDestinations = [];
+
+ public $loadingDestinations = false;
+
public function mount()
{
$this->searchQuery = '';
$this->isModalOpen = false;
$this->searchResults = [];
$this->allSearchableItems = [];
+ $this->isCreateMode = false;
+ $this->creatableItems = [];
+ $this->autoOpenResource = null;
+ $this->isSelectingResource = false;
}
public function openSearchModal()
{
$this->isModalOpen = true;
$this->loadSearchableItems();
+ $this->loadCreatableItems();
$this->dispatch('search-modal-opened');
}
@@ -45,6 +89,7 @@ public function closeSearchModal()
{
$this->isModalOpen = false;
$this->searchQuery = '';
+ $this->previousTrimmedQuery = '';
$this->searchResults = [];
}
@@ -60,7 +105,144 @@ public static function clearTeamCache($teamId)
public function updatedSearchQuery()
{
- $this->search();
+ $trimmedQuery = trim($this->searchQuery);
+
+ // If only spaces were added/removed, don't trigger a search
+ if ($trimmedQuery === $this->previousTrimmedQuery) {
+ return;
+ }
+
+ $this->previousTrimmedQuery = $trimmedQuery;
+
+ // If search query is empty, just clear results without processing
+ if (empty($trimmedQuery)) {
+ $this->searchResults = [];
+ $this->isCreateMode = false;
+ $this->creatableItems = [];
+ $this->autoOpenResource = null;
+ $this->isSelectingResource = false;
+ $this->cancelResourceSelection();
+
+ return;
+ }
+
+ $query = strtolower($trimmedQuery);
+
+ // Reset keyboard navigation index
+ $this->dispatch('reset-selected-index');
+
+ // Only enter create mode if query is exactly "new" or starts with "new " (space after)
+ if ($query === 'new' || str_starts_with($query, 'new ')) {
+ $this->isCreateMode = true;
+ $this->loadCreatableItems();
+
+ // Check for sub-commands like "new project", "new server", etc.
+ $detectedType = $this->detectSpecificResource($query);
+ if ($detectedType) {
+ $this->navigateToResource($detectedType);
+ } else {
+ // If no specific resource detected, reset selection state
+ $this->cancelResourceSelection();
+ }
+
+ // Also search for existing resources that match the query
+ // This allows users to find resources with "new" in their name
+ $this->search();
+ } else {
+ $this->isCreateMode = false;
+ $this->creatableItems = [];
+ $this->autoOpenResource = null;
+ $this->isSelectingResource = false;
+ $this->search();
+ }
+ }
+
+ private function detectSpecificResource(string $query): ?string
+ {
+ // Map of keywords to resource types - order matters for multi-word matches
+ $resourceMap = [
+ // Quick Actions
+ '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 app' => 'source',
+ 'new github' => 'source',
+ 'new source' => 'source',
+
+ // Applications - Git-based
+ 'new public' => 'public',
+ 'new public git' => 'public',
+ 'new public repo' => 'public',
+ 'new public repository' => 'public',
+ 'new private github' => 'private-gh-app',
+ 'new private gh' => 'private-gh-app',
+ 'new private deploy' => 'private-deploy-key',
+ 'new deploy key' => 'private-deploy-key',
+
+ // Applications - Docker-based
+ 'new dockerfile' => 'dockerfile',
+ 'new docker compose' => 'docker-compose-empty',
+ 'new compose' => 'docker-compose-empty',
+ 'new docker image' => 'docker-image',
+ 'new image' => 'docker-image',
+
+ // Databases
+ 'new postgresql' => 'postgresql',
+ 'new postgres' => 'postgresql',
+ 'new mysql' => 'mysql',
+ 'new mariadb' => 'mariadb',
+ 'new redis' => 'redis',
+ 'new keydb' => 'keydb',
+ 'new dragonfly' => 'dragonfly',
+ 'new mongodb' => 'mongodb',
+ 'new mongo' => 'mongodb',
+ 'new clickhouse' => 'clickhouse',
+ ];
+
+ 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();
+
+ // Quick Actions
+ if (in_array($type, ['server', 'storage', 'private-key'])) {
+ return $user->isAdmin() || $user->isOwner();
+ }
+
+ if ($type === 'team') {
+ return true;
+ }
+
+ // Applications, Databases, Services, and other resources
+ if (in_array($type, [
+ 'project', 'source',
+ // Applications
+ 'public', 'private-gh-app', 'private-deploy-key',
+ 'dockerfile', 'docker-compose-empty', 'docker-image',
+ // Databases
+ 'postgresql', 'mysql', 'mariadb', 'redis', 'keydb',
+ 'dragonfly', 'mongodb', 'clickhouse',
+ ]) || str_starts_with($type, 'one-click-service-')) {
+ return $user->can('createAnyResource');
+ }
+
+ return false;
}
private function loadSearchableItems()
@@ -114,7 +296,7 @@ private function loadSearchableItems()
'project' => $app->environment->project->name ?? null,
'environment' => $app->environment->name ?? null,
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
- 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString),
+ 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString.' application applications app apps'),
];
});
@@ -143,7 +325,7 @@ private function loadSearchableItems()
'project' => $service->environment->project->name ?? null,
'environment' => $service->environment->name ?? null,
'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI
- 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString),
+ 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString.' service services'),
];
});
@@ -166,7 +348,7 @@ private function loadSearchableItems()
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
- 'search_text' => strtolower($db->name.' postgresql '.$db->description),
+ 'search_text' => strtolower($db->name.' postgresql '.$db->description.' database databases db'),
];
})
);
@@ -187,7 +369,7 @@ private function loadSearchableItems()
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
- 'search_text' => strtolower($db->name.' mysql '.$db->description),
+ 'search_text' => strtolower($db->name.' mysql '.$db->description.' database databases db'),
];
})
);
@@ -208,7 +390,7 @@ private function loadSearchableItems()
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
- 'search_text' => strtolower($db->name.' mariadb '.$db->description),
+ 'search_text' => strtolower($db->name.' mariadb '.$db->description.' database databases db'),
];
})
);
@@ -229,7 +411,7 @@ private function loadSearchableItems()
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
- 'search_text' => strtolower($db->name.' mongodb '.$db->description),
+ 'search_text' => strtolower($db->name.' mongodb '.$db->description.' database databases db'),
];
})
);
@@ -250,7 +432,7 @@ private function loadSearchableItems()
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
- 'search_text' => strtolower($db->name.' redis '.$db->description),
+ 'search_text' => strtolower($db->name.' redis '.$db->description.' database databases db'),
];
})
);
@@ -271,7 +453,7 @@ private function loadSearchableItems()
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
- 'search_text' => strtolower($db->name.' keydb '.$db->description),
+ 'search_text' => strtolower($db->name.' keydb '.$db->description.' database databases db'),
];
})
);
@@ -292,7 +474,7 @@ private function loadSearchableItems()
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
- 'search_text' => strtolower($db->name.' dragonfly '.$db->description),
+ 'search_text' => strtolower($db->name.' dragonfly '.$db->description.' database databases db'),
];
})
);
@@ -313,7 +495,7 @@ private function loadSearchableItems()
'link' => $db->link(),
'project' => $db->environment->project->name ?? null,
'environment' => $db->environment->name ?? null,
- 'search_text' => strtolower($db->name.' clickhouse '.$db->description),
+ 'search_text' => strtolower($db->name.' clickhouse '.$db->description.' database databases db'),
];
})
);
@@ -331,15 +513,210 @@ private function loadSearchableItems()
'link' => $server->url(),
'project' => null,
'environment' => null,
- 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description),
+ 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'),
+ ];
+ });
+ ray($servers);
+ // Get all projects
+ $projects = Project::ownedByCurrentTeam()
+ ->withCount(['environments', 'applications', 'services'])
+ ->get()
+ ->map(function ($project) {
+ $resourceCount = $project->applications_count + $project->services_count;
+ $resourceSummary = $resourceCount > 0
+ ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '')
+ : 'No resources';
+
+ return [
+ 'id' => $project->id,
+ 'name' => $project->name,
+ 'type' => 'project',
+ 'uuid' => $project->uuid,
+ 'description' => $project->description,
+ 'link' => $project->navigateTo(),
+ 'project' => null,
+ 'environment' => null,
+ 'resource_count' => $resourceSummary,
+ 'environment_count' => $project->environments_count,
+ 'search_text' => strtolower($project->name.' '.$project->description.' project projects'),
];
});
+ // Get all environments
+ $environments = Environment::ownedByCurrentTeam()
+ ->with('project')
+ ->withCount(['applications', 'services'])
+ ->get()
+ ->map(function ($environment) {
+ $resourceCount = $environment->applications_count + $environment->services_count;
+ $resourceSummary = $resourceCount > 0
+ ? "{$resourceCount} resource".($resourceCount !== 1 ? 's' : '')
+ : 'No resources';
+
+ // Build description with project context
+ $descriptionParts = [];
+ if ($environment->project) {
+ $descriptionParts[] = "Project: {$environment->project->name}";
+ }
+ if ($environment->description) {
+ $descriptionParts[] = $environment->description;
+ }
+ if (empty($descriptionParts)) {
+ $descriptionParts[] = $resourceSummary;
+ }
+
+ return [
+ 'id' => $environment->id,
+ 'name' => $environment->name,
+ 'type' => 'environment',
+ 'uuid' => $environment->uuid,
+ 'description' => implode(' • ', $descriptionParts),
+ 'link' => route('project.resource.index', [
+ 'project_uuid' => $environment->project->uuid,
+ 'environment_uuid' => $environment->uuid,
+ ]),
+ 'project' => $environment->project->name ?? null,
+ 'environment' => null,
+ 'resource_count' => $resourceSummary,
+ 'search_text' => strtolower($environment->name.' '.$environment->description.' '.$environment->project->name.' environment'),
+ ];
+ });
+
+ // Add navigation routes
+ $navigation = collect([
+ [
+ 'name' => 'Dashboard',
+ 'type' => 'navigation',
+ 'description' => 'Go to main dashboard',
+ 'link' => route('dashboard'),
+ 'search_text' => 'dashboard home main overview',
+ ],
+ [
+ 'name' => 'Servers',
+ 'type' => 'navigation',
+ 'description' => 'View all servers',
+ 'link' => route('server.index'),
+ 'search_text' => 'servers all list view',
+ ],
+ [
+ 'name' => 'Projects',
+ 'type' => 'navigation',
+ 'description' => 'View all projects',
+ 'link' => route('project.index'),
+ 'search_text' => 'projects all list view',
+ ],
+ [
+ 'name' => 'Destinations',
+ 'type' => 'navigation',
+ 'description' => 'View all destinations',
+ 'link' => route('destination.index'),
+ 'search_text' => 'destinations docker networks',
+ ],
+ [
+ 'name' => 'Security',
+ 'type' => 'navigation',
+ 'description' => 'Manage private keys and API tokens',
+ 'link' => route('security.private-key.index'),
+ 'search_text' => 'security private keys ssh api tokens',
+ ],
+ [
+ 'name' => 'Sources',
+ 'type' => 'navigation',
+ 'description' => 'Manage GitHub apps and Git sources',
+ 'link' => route('source.all'),
+ 'search_text' => 'sources github apps git repositories',
+ ],
+ [
+ 'name' => 'Storages',
+ 'type' => 'navigation',
+ 'description' => 'Manage S3 storage for backups',
+ 'link' => route('storage.index'),
+ 'search_text' => 'storages s3 backups',
+ ],
+ [
+ 'name' => 'Shared Variables',
+ 'type' => 'navigation',
+ 'description' => 'View all shared variables',
+ 'link' => route('shared-variables.index'),
+ 'search_text' => 'shared variables environment all',
+ ],
+ [
+ 'name' => 'Team Shared Variables',
+ 'type' => 'navigation',
+ 'description' => 'Manage team-wide shared variables',
+ 'link' => route('shared-variables.team.index'),
+ 'search_text' => 'shared variables team environment',
+ ],
+ [
+ 'name' => 'Project Shared Variables',
+ 'type' => 'navigation',
+ 'description' => 'Manage project shared variables',
+ 'link' => route('shared-variables.project.index'),
+ 'search_text' => 'shared variables project environment',
+ ],
+ [
+ 'name' => 'Environment Shared Variables',
+ 'type' => 'navigation',
+ 'description' => 'Manage environment shared variables',
+ 'link' => route('shared-variables.environment.index'),
+ 'search_text' => 'shared variables environment',
+ ],
+ [
+ 'name' => 'Tags',
+ 'type' => 'navigation',
+ 'description' => 'View resources by tags',
+ 'link' => route('tags.show'),
+ 'search_text' => 'tags labels organize',
+ ],
+ [
+ 'name' => 'Terminal',
+ 'type' => 'navigation',
+ 'description' => 'Access server terminal',
+ 'link' => route('terminal'),
+ 'search_text' => 'terminal ssh console shell command line',
+ ],
+ [
+ 'name' => 'Profile',
+ 'type' => 'navigation',
+ 'description' => 'Manage your profile and preferences',
+ 'link' => route('profile'),
+ 'search_text' => 'profile account user settings preferences',
+ ],
+ [
+ 'name' => 'Team',
+ 'type' => 'navigation',
+ 'description' => 'Manage team members and settings',
+ 'link' => route('team.index'),
+ 'search_text' => 'team settings members users invitations',
+ ],
+ [
+ 'name' => 'Notifications',
+ 'type' => 'navigation',
+ 'description' => 'Configure email, Discord, Telegram notifications',
+ 'link' => route('notifications.email'),
+ 'search_text' => 'notifications alerts email discord telegram slack pushover',
+ ],
+ ]);
+
+ // Add instance settings only for self-hosted and root team
+ if (! isCloud() && $team->id === 0) {
+ $navigation->push([
+ 'name' => 'Settings',
+ 'type' => 'navigation',
+ 'description' => 'Instance settings and configuration',
+ 'link' => route('settings.index'),
+ 'search_text' => 'settings configuration instance',
+ ]);
+ }
+
// Merge all collections
- $items = $items->merge($applications)
+ $items = $items->merge($navigation)
+ ->merge($applications)
->merge($services)
->merge($databases)
- ->merge($servers);
+ ->merge($servers)
+ ->merge($projects)
+ ->merge($environments);
return $items->toArray();
});
@@ -347,7 +724,7 @@ private function loadSearchableItems()
private function search()
{
- if (strlen($this->searchQuery) < 2) {
+ if (strlen($this->searchQuery) < 1) {
$this->searchResults = [];
return;
@@ -355,14 +732,720 @@ private function search()
$query = strtolower($this->searchQuery);
- // Case-insensitive search in the items
- $this->searchResults = collect($this->allSearchableItems)
+ // Detect resource category queries
+ $categoryMapping = [
+ 'server' => ['server', 'type' => 'server'],
+ 'servers' => ['server', 'type' => 'server'],
+ 'app' => ['application', 'type' => 'application'],
+ 'apps' => ['application', 'type' => 'application'],
+ 'application' => ['application', 'type' => 'application'],
+ 'applications' => ['application', 'type' => 'application'],
+ 'db' => ['database', 'type' => 'standalone-postgresql'],
+ 'database' => ['database', 'type' => 'standalone-postgresql'],
+ 'databases' => ['database', 'type' => 'standalone-postgresql'],
+ 'service' => ['service', 'category' => 'Services'],
+ 'services' => ['service', 'category' => 'Services'],
+ 'project' => ['project', 'type' => 'project'],
+ 'projects' => ['project', 'type' => 'project'],
+ ];
+
+ $priorityCreatableItem = null;
+
+ // Check if query matches a resource category
+ if (isset($categoryMapping[$query])) {
+ $this->loadCreatableItems();
+ $mapping = $categoryMapping[$query];
+
+ // Find the matching creatable item
+ $priorityCreatableItem = collect($this->creatableItems)
+ ->first(function ($item) use ($mapping) {
+ if (isset($mapping['type'])) {
+ return $item['type'] === $mapping['type'];
+ }
+ if (isset($mapping['category'])) {
+ return isset($item['category']) && $item['category'] === $mapping['category'];
+ }
+
+ return false;
+ });
+
+ if ($priorityCreatableItem) {
+ $priorityCreatableItem['is_creatable_suggestion'] = true;
+ }
+ }
+
+ // Search for matching creatable resources to show as suggestions (if no priority item)
+ if (! $priorityCreatableItem) {
+ $this->loadCreatableItems();
+
+ // Search in regular creatable items (apps, databases, quick actions)
+ $creatableSuggestions = collect($this->creatableItems)
+ ->filter(function ($item) use ($query) {
+ $searchText = strtolower($item['name'].' '.$item['description'].' '.($item['type'] ?? ''));
+
+ // Use word boundary matching to avoid substring matches (e.g., "wordpress" shouldn't match "classicpress")
+ return preg_match('/\b'.preg_quote($query, '/').'/i', $searchText);
+ })
+ ->map(function ($item) use ($query) {
+ // Calculate match priority: name > type > description
+ $name = strtolower($item['name']);
+ $type = strtolower($item['type'] ?? '');
+ $description = strtolower($item['description']);
+
+ if (preg_match('/\b'.preg_quote($query, '/').'/i', $name)) {
+ $item['match_priority'] = 1;
+ } elseif (preg_match('/\b'.preg_quote($query, '/').'/i', $type)) {
+ $item['match_priority'] = 2;
+ } else {
+ $item['match_priority'] = 3;
+ }
+
+ $item['is_creatable_suggestion'] = true;
+
+ return $item;
+ });
+
+ // Also search in services (loaded on-demand)
+ $serviceSuggestions = collect($this->services)
+ ->filter(function ($item) use ($query) {
+ $searchText = strtolower($item['name'].' '.$item['description'].' '.($item['type'] ?? ''));
+
+ return preg_match('/\b'.preg_quote($query, '/').'/i', $searchText);
+ })
+ ->map(function ($item) use ($query) {
+ // Calculate match priority: name > type > description
+ $name = strtolower($item['name']);
+ $type = strtolower($item['type'] ?? '');
+ $description = strtolower($item['description']);
+
+ if (preg_match('/\b'.preg_quote($query, '/').'/i', $name)) {
+ $item['match_priority'] = 1;
+ } elseif (preg_match('/\b'.preg_quote($query, '/').'/i', $type)) {
+ $item['match_priority'] = 2;
+ } else {
+ $item['match_priority'] = 3;
+ }
+
+ $item['is_creatable_suggestion'] = true;
+
+ return $item;
+ });
+
+ // Merge and sort all suggestions
+ $creatableSuggestions = $creatableSuggestions
+ ->merge($serviceSuggestions)
+ ->sortBy('match_priority')
+ ->take(10)
+ ->values()
+ ->toArray();
+ } else {
+ $creatableSuggestions = [];
+ }
+
+ // Case-insensitive search in existing resources
+ $existingResults = collect($this->allSearchableItems)
->filter(function ($item) use ($query) {
- return str_contains($item['search_text'], $query);
+ // Use word boundary matching to avoid substring matches (e.g., "wordpress" shouldn't match "classicpress")
+ return preg_match('/\b'.preg_quote($query, '/').'/i', $item['search_text']);
})
+ ->map(function ($item) use ($query) {
+ // Calculate match priority: name > type > description
+ $name = strtolower($item['name'] ?? '');
+ $type = strtolower($item['type'] ?? '');
+ $description = strtolower($item['description'] ?? '');
+
+ if (preg_match('/\b'.preg_quote($query, '/').'/i', $name)) {
+ $item['match_priority'] = 1;
+ } elseif (preg_match('/\b'.preg_quote($query, '/').'/i', $type)) {
+ $item['match_priority'] = 2;
+ } else {
+ $item['match_priority'] = 3;
+ }
+
+ return $item;
+ })
+ ->sortBy('match_priority')
->take(20)
->values()
->toArray();
+
+ // Merge results: existing resources first, then priority create item, then other creatable suggestions
+ $results = [];
+
+ // If we have existing results, show them first
+ $results = array_merge($results, $existingResults);
+
+ // Then show the priority "Create New" item (if exists)
+ if ($priorityCreatableItem) {
+ $results[] = $priorityCreatableItem;
+ }
+
+ // Finally show other creatable suggestions
+ $results = array_merge($results, $creatableSuggestions);
+
+ $this->searchResults = $results;
+ }
+
+ private function loadCreatableItems()
+ {
+ $items = collect();
+ $user = auth()->user();
+
+ // === Quick Actions Category ===
+
+ // 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',
+ 'quickcommand' => '(type: new project)',
+ 'type' => 'project',
+ 'category' => 'Quick Actions',
+ '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',
+ 'quickcommand' => '(type: new server)',
+ 'type' => 'server',
+ 'category' => 'Quick Actions',
+ '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',
+ 'quickcommand' => '(type: new team)',
+ 'type' => 'team',
+ 'category' => 'Quick Actions',
+ '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',
+ 'quickcommand' => '(type: new storage)',
+ 'type' => 'storage',
+ 'category' => 'Quick Actions',
+ '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',
+ 'quickcommand' => '(type: new private key)',
+ 'type' => 'private-key',
+ 'category' => 'Quick Actions',
+ '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',
+ 'quickcommand' => '(type: new github)',
+ 'type' => 'source',
+ 'category' => 'Quick Actions',
+ 'component' => 'source.github.create',
+ ]);
+ }
+
+ // === Applications Category ===
+
+ if ($user->can('createAnyResource')) {
+ // Git-based applications
+ $items->push([
+ 'name' => 'Public Git Repository',
+ 'description' => 'Deploy from any public Git repository',
+ 'quickcommand' => '(type: new public)',
+ 'type' => 'public',
+ 'category' => 'Applications',
+ 'resourceType' => 'application',
+ ]);
+
+ $items->push([
+ 'name' => 'Private Repository (GitHub App)',
+ 'description' => 'Deploy private repositories through GitHub Apps',
+ 'quickcommand' => '(type: new private github)',
+ 'type' => 'private-gh-app',
+ 'category' => 'Applications',
+ 'resourceType' => 'application',
+ ]);
+
+ $items->push([
+ 'name' => 'Private Repository (Deploy Key)',
+ 'description' => 'Deploy private repositories with a deploy key',
+ 'quickcommand' => '(type: new private deploy)',
+ 'type' => 'private-deploy-key',
+ 'category' => 'Applications',
+ 'resourceType' => 'application',
+ ]);
+
+ // Docker-based applications
+ $items->push([
+ 'name' => 'Dockerfile',
+ 'description' => 'Deploy a simple Dockerfile without Git',
+ 'quickcommand' => '(type: new dockerfile)',
+ 'type' => 'dockerfile',
+ 'category' => 'Applications',
+ 'resourceType' => 'application',
+ ]);
+
+ $items->push([
+ 'name' => 'Docker Compose',
+ 'description' => 'Deploy complex applications with Docker Compose',
+ 'quickcommand' => '(type: new compose)',
+ 'type' => 'docker-compose-empty',
+ 'category' => 'Applications',
+ 'resourceType' => 'application',
+ ]);
+
+ $items->push([
+ 'name' => 'Docker Image',
+ 'description' => 'Deploy an existing Docker image from any registry',
+ 'quickcommand' => '(type: new image)',
+ 'type' => 'docker-image',
+ 'category' => 'Applications',
+ 'resourceType' => 'application',
+ ]);
+ }
+
+ // === Databases Category ===
+
+ if ($user->can('createAnyResource')) {
+ $items->push([
+ 'name' => 'PostgreSQL',
+ 'description' => 'Robust, advanced open-source database',
+ 'quickcommand' => '(type: new postgresql)',
+ 'type' => 'postgresql',
+ 'category' => 'Databases',
+ 'resourceType' => 'database',
+ ]);
+
+ $items->push([
+ 'name' => 'MySQL',
+ 'description' => 'Popular open-source relational database',
+ 'quickcommand' => '(type: new mysql)',
+ 'type' => 'mysql',
+ 'category' => 'Databases',
+ 'resourceType' => 'database',
+ ]);
+
+ $items->push([
+ 'name' => 'MariaDB',
+ 'description' => 'Community-developed fork of MySQL',
+ 'quickcommand' => '(type: new mariadb)',
+ 'type' => 'mariadb',
+ 'category' => 'Databases',
+ 'resourceType' => 'database',
+ ]);
+
+ $items->push([
+ 'name' => 'Redis',
+ 'description' => 'In-memory data structure store',
+ 'quickcommand' => '(type: new redis)',
+ 'type' => 'redis',
+ 'category' => 'Databases',
+ 'resourceType' => 'database',
+ ]);
+
+ $items->push([
+ 'name' => 'KeyDB',
+ 'description' => 'High-performance Redis alternative',
+ 'quickcommand' => '(type: new keydb)',
+ 'type' => 'keydb',
+ 'category' => 'Databases',
+ 'resourceType' => 'database',
+ ]);
+
+ $items->push([
+ 'name' => 'Dragonfly',
+ 'description' => 'Modern in-memory datastore',
+ 'quickcommand' => '(type: new dragonfly)',
+ 'type' => 'dragonfly',
+ 'category' => 'Databases',
+ 'resourceType' => 'database',
+ ]);
+
+ $items->push([
+ 'name' => 'MongoDB',
+ 'description' => 'Document-oriented NoSQL database',
+ 'quickcommand' => '(type: new mongodb)',
+ 'type' => 'mongodb',
+ 'category' => 'Databases',
+ 'resourceType' => 'database',
+ ]);
+
+ $items->push([
+ 'name' => 'Clickhouse',
+ 'description' => 'Column-oriented database for analytics',
+ 'quickcommand' => '(type: new clickhouse)',
+ 'type' => 'clickhouse',
+ 'category' => 'Databases',
+ 'resourceType' => 'database',
+ ]);
+ }
+
+ // Merge with services
+ $items = $items->merge(collect($this->services));
+
+ $this->creatableItems = $items->toArray();
+ }
+
+ public function navigateToResource($type)
+ {
+ // Find the item by type - check regular items first, then services
+ $item = collect($this->creatableItems)->firstWhere('type', $type);
+
+ if (! $item) {
+ $item = collect($this->services)->firstWhere('type', $type);
+ }
+
+ if (! $item) {
+ return;
+ }
+
+ // If it has a component, it's a modal-based resource
+ // Close search modal and open the appropriate creation modal
+ if (isset($item['component'])) {
+ $this->dispatch('closeSearchModal');
+ $this->dispatch('open-create-modal-'.$type);
+
+ return;
+ }
+
+ // For applications, databases, and services, navigate to resource creation
+ // with smart defaults (auto-select if only 1 server/project/environment)
+ if (isset($item['resourceType'])) {
+ $this->navigateToResourceCreation($type);
+ }
+ }
+
+ private function navigateToResourceCreation($type)
+ {
+ // Start the selection flow
+ $this->selectedResourceType = $type;
+ $this->isSelectingResource = true;
+
+ // Reset selections
+ $this->selectedServerId = null;
+ $this->selectedDestinationUuid = null;
+ $this->selectedProjectUuid = null;
+ $this->selectedEnvironmentUuid = null;
+
+ // Start loading servers first (in order: servers -> destinations -> projects -> environments)
+ $this->loadServers();
+ }
+
+ public function loadServers()
+ {
+ $this->loadingServers = true;
+ $servers = Server::isUsable()->get()->sortBy('name');
+ $this->availableServers = $servers->map(fn ($s) => [
+ 'id' => $s->id,
+ 'name' => $s->name,
+ 'description' => $s->description,
+ ])->toArray();
+ $this->loadingServers = false;
+
+ // Auto-select if only one server
+ if (count($this->availableServers) === 1) {
+ $this->selectServer($this->availableServers[0]['id']);
+ }
+ }
+
+ public function selectServer($serverId, $shouldProgress = true)
+ {
+ $this->selectedServerId = $serverId;
+
+ if ($shouldProgress) {
+ $this->loadDestinations();
+ }
+ }
+
+ public function loadDestinations()
+ {
+ $this->loadingDestinations = true;
+ $server = Server::find($this->selectedServerId);
+
+ if (! $server) {
+ $this->loadingDestinations = false;
+
+ return $this->dispatch('error', message: 'Server not found');
+ }
+
+ $destinations = $server->destinations();
+
+ if ($destinations->isEmpty()) {
+ $this->loadingDestinations = false;
+
+ return $this->dispatch('error', message: 'No destinations found on this server');
+ }
+
+ $this->availableDestinations = $destinations->map(fn ($d) => [
+ 'uuid' => $d->uuid,
+ 'name' => $d->name,
+ 'network' => $d->network ?? 'default',
+ ])->toArray();
+
+ $this->loadingDestinations = false;
+
+ // Auto-select if only one destination
+ if (count($this->availableDestinations) === 1) {
+ $this->selectDestination($this->availableDestinations[0]['uuid']);
+ }
+ }
+
+ public function selectDestination($destinationUuid, $shouldProgress = true)
+ {
+ $this->selectedDestinationUuid = $destinationUuid;
+
+ if ($shouldProgress) {
+ $this->loadProjects();
+ }
+ }
+
+ public function loadProjects()
+ {
+ $this->loadingProjects = true;
+ $user = auth()->user();
+ $team = $user->currentTeam();
+ $projects = Project::where('team_id', $team->id)->get();
+
+ if ($projects->isEmpty()) {
+ $this->loadingProjects = false;
+
+ return $this->dispatch('error', message: 'Please create a project first');
+ }
+
+ $this->availableProjects = $projects->map(fn ($p) => [
+ 'uuid' => $p->uuid,
+ 'name' => $p->name,
+ 'description' => $p->description,
+ ])->toArray();
+ $this->loadingProjects = false;
+
+ // Auto-select if only one project
+ if (count($this->availableProjects) === 1) {
+ $this->selectProject($this->availableProjects[0]['uuid']);
+ }
+ }
+
+ public function selectProject($projectUuid, $shouldProgress = true)
+ {
+ $this->selectedProjectUuid = $projectUuid;
+
+ if ($shouldProgress) {
+ $this->loadEnvironments();
+ }
+ }
+
+ public function loadEnvironments()
+ {
+ $this->loadingEnvironments = true;
+ $project = Project::where('uuid', $this->selectedProjectUuid)->first();
+
+ if (! $project) {
+ $this->loadingEnvironments = false;
+
+ return;
+ }
+
+ $environments = $project->environments;
+
+ if ($environments->isEmpty()) {
+ $this->loadingEnvironments = false;
+
+ return $this->dispatch('error', message: 'No environments found in project');
+ }
+
+ $this->availableEnvironments = $environments->map(fn ($e) => [
+ 'uuid' => $e->uuid,
+ 'name' => $e->name,
+ 'description' => $e->description,
+ ])->toArray();
+ $this->loadingEnvironments = false;
+
+ // Auto-select if only one environment
+ if (count($this->availableEnvironments) === 1) {
+ $this->selectEnvironment($this->availableEnvironments[0]['uuid']);
+ }
+ }
+
+ public function selectEnvironment($environmentUuid, $shouldProgress = true)
+ {
+ $this->selectedEnvironmentUuid = $environmentUuid;
+
+ if ($shouldProgress) {
+ $this->completeResourceCreation();
+ }
+ }
+
+ private function completeResourceCreation()
+ {
+ // All selections made - navigate to resource creation
+ if ($this->selectedProjectUuid && $this->selectedEnvironmentUuid && $this->selectedResourceType && $this->selectedServerId !== null && $this->selectedDestinationUuid) {
+ $queryParams = [
+ 'type' => $this->selectedResourceType,
+ 'destination' => $this->selectedDestinationUuid,
+ 'server_id' => $this->selectedServerId,
+ ];
+
+ // PostgreSQL requires a database_image parameter
+ if ($this->selectedResourceType === 'postgresql') {
+ $queryParams['database_image'] = 'postgres:16-alpine';
+ }
+
+ return redirect()->route('project.resource.create', [
+ 'project_uuid' => $this->selectedProjectUuid,
+ 'environment_uuid' => $this->selectedEnvironmentUuid,
+ ] + $queryParams);
+ }
+ }
+
+ public function cancelResourceSelection()
+ {
+ $this->isSelectingResource = false;
+ $this->selectedResourceType = null;
+ $this->selectedServerId = null;
+ $this->selectedDestinationUuid = null;
+ $this->selectedProjectUuid = null;
+ $this->selectedEnvironmentUuid = null;
+ $this->availableServers = [];
+ $this->availableDestinations = [];
+ $this->availableProjects = [];
+ $this->availableEnvironments = [];
+ $this->autoOpenResource = null;
+ }
+
+ public function getFilteredCreatableItemsProperty()
+ {
+ $query = strtolower(trim($this->searchQuery));
+
+ // Check if query matches a category keyword
+ $categoryKeywords = ['server', 'servers', 'app', 'apps', 'application', 'applications', 'db', 'database', 'databases', 'service', 'services', 'project', 'projects'];
+ if (in_array($query, $categoryKeywords)) {
+ return $this->filterCreatableItemsByCategory($query);
+ }
+
+ // Extract search term - everything after "new "
+ if (str_starts_with($query, 'new ')) {
+ $searchTerm = trim(substr($query, strlen('new ')));
+
+ if (empty($searchTerm)) {
+ return $this->creatableItems;
+ }
+
+ // Filter items by name or description
+ return collect($this->creatableItems)->filter(function ($item) use ($searchTerm) {
+ $searchText = strtolower($item['name'].' '.$item['description'].' '.$item['category']);
+
+ return str_contains($searchText, $searchTerm);
+ })->values()->toArray();
+ }
+
+ return $this->creatableItems;
+ }
+
+ private function filterCreatableItemsByCategory($categoryKeyword)
+ {
+ // Map keywords to category names
+ $categoryMap = [
+ 'server' => 'Quick Actions',
+ 'servers' => 'Quick Actions',
+ 'app' => 'Applications',
+ 'apps' => 'Applications',
+ 'application' => 'Applications',
+ 'applications' => 'Applications',
+ 'db' => 'Databases',
+ 'database' => 'Databases',
+ 'databases' => 'Databases',
+ 'service' => 'Services',
+ 'services' => 'Services',
+ 'project' => 'Applications',
+ 'projects' => 'Applications',
+ ];
+
+ $category = $categoryMap[$categoryKeyword] ?? null;
+
+ if (! $category) {
+ return [];
+ }
+
+ return collect($this->creatableItems)
+ ->filter(fn ($item) => $item['category'] === $category)
+ ->values()
+ ->toArray();
+ }
+
+ public function getSelectedResourceNameProperty()
+ {
+ if (! $this->selectedResourceType) {
+ return null;
+ }
+
+ // Load creatable items if not loaded yet
+ if (empty($this->creatableItems)) {
+ $this->loadCreatableItems();
+ }
+
+ // Find the item by type - check regular items first, then services
+ $item = collect($this->creatableItems)->firstWhere('type', $this->selectedResourceType);
+
+ if (! $item) {
+ $item = collect($this->services)->firstWhere('type', $this->selectedResourceType);
+ }
+
+ return $item ? $item['name'] : null;
+ }
+
+ public function getServicesProperty()
+ {
+ // Cache services in a static property to avoid reloading on every access
+ static $cachedServices = null;
+
+ if ($cachedServices !== null) {
+ return $cachedServices;
+ }
+
+ $user = auth()->user();
+
+ if (! $user->can('createAnyResource')) {
+ $cachedServices = [];
+
+ return $cachedServices;
+ }
+
+ // Load all services
+ $allServices = get_service_templates();
+ $items = collect();
+
+ foreach ($allServices as $serviceKey => $service) {
+ $items->push([
+ 'name' => str($serviceKey)->headline()->toString(),
+ 'description' => data_get($service, 'slogan', 'Deploy '.str($serviceKey)->headline()),
+ 'type' => 'one-click-service-'.$serviceKey,
+ 'category' => 'Services',
+ 'resourceType' => 'service',
+ ]);
+ }
+
+ $cachedServices = $items->toArray();
+
+ return $cachedServices;
}
public function render()
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/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php
index dccd1e499..ebdc014ae 100644
--- a/app/Livewire/Project/Application/DeploymentNavbar.php
+++ b/app/Livewire/Project/Application/DeploymentNavbar.php
@@ -50,6 +50,28 @@ public function force_start()
}
}
+ public function copyLogsToClipboard(): string
+ {
+ $logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
+
+ if (! $logs) {
+ return '';
+ }
+
+ $markdown = "# Deployment Logs\n\n";
+ $markdown .= "```\n";
+
+ foreach ($logs as $log) {
+ if (isset($log['output'])) {
+ $markdown .= $log['output']."\n";
+ }
+ }
+
+ $markdown .= "```\n";
+
+ return $markdown;
+ }
+
public function cancel()
{
$deployment_uuid = $this->application_deployment_queue->deployment_uuid;
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index ae9bd314b..b42f29fa5 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -544,6 +544,9 @@ public function submit($showToaster = true)
{
try {
$this->authorize('update', $this->application);
+
+ $this->validate();
+
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
@@ -584,7 +587,6 @@ public function submit($showToaster = true)
return;
}
}
- $this->validate();
if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) {
$this->resetDefaultLabels();
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/DockerImage.php b/app/Livewire/Project/New/DockerImage.php
index dbb223de2..e105c956a 100644
--- a/app/Livewire/Project/New/DockerImage.php
+++ b/app/Livewire/Project/New/DockerImage.php
@@ -12,7 +12,11 @@
class DockerImage extends Component
{
- public string $dockerImage = '';
+ public string $imageName = '';
+
+ public string $imageTag = '';
+
+ public string $imageSha256 = '';
public array $parameters;
@@ -26,12 +30,41 @@ public function mount()
public function submit()
{
+ // Strip 'sha256:' prefix if user pasted it
+ if ($this->imageSha256) {
+ $this->imageSha256 = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
+ }
+
+ // Remove @sha256 from image name if user added it
+ if ($this->imageName) {
+ $this->imageName = preg_replace('/@sha256$/i', '', trim($this->imageName));
+ }
+
$this->validate([
- 'dockerImage' => 'required',
+ 'imageName' => ['required', 'string'],
+ 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
+ 'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
]);
+ // Validate that either tag or sha256 is provided, but not both
+ if ($this->imageTag && $this->imageSha256) {
+ $this->addError('imageTag', 'Provide either a tag or SHA256 digest, not both.');
+ $this->addError('imageSha256', 'Provide either a tag or SHA256 digest, not both.');
+
+ return;
+ }
+
+ // Build the full Docker image string
+ if ($this->imageSha256) {
+ $dockerImage = $this->imageName.'@sha256:'.$this->imageSha256;
+ } elseif ($this->imageTag) {
+ $dockerImage = $this->imageName.':'.$this->imageTag;
+ } else {
+ $dockerImage = $this->imageName.':latest';
+ }
+
$parser = new DockerImageParser;
- $parser->parse($this->dockerImage);
+ $parser->parse($dockerImage);
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
@@ -45,6 +78,16 @@ public function submit()
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
+
+ // Determine the image tag based on whether it's a hash or regular tag
+ $imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
+
+ // Append @sha256 to image name if using digest and not already present
+ $imageName = $parser->getFullImageNameWithoutTag();
+ if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) {
+ $imageName .= '@sha256';
+ }
+
$application = Application::create([
'name' => 'docker-image-'.new Cuid2,
'repository_project_id' => 0,
@@ -52,7 +95,7 @@ public function submit()
'git_branch' => 'main',
'build_pack' => 'dockerimage',
'ports_exposes' => 80,
- 'docker_registry_image_name' => $parser->getFullImageNameWithoutTag(),
+ 'docker_registry_image_name' => $imageName,
'docker_registry_image_tag' => $parser->getTag(),
'environment_id' => $environment->id,
'destination_id' => $destination->id,
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/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 2933a8cca..7f0caaba3 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -34,6 +34,8 @@ class FileStorage extends Component
public bool $permanently_delete = true;
+ public bool $isReadOnly = false;
+
protected $rules = [
'fileStorage.is_directory' => 'required',
'fileStorage.fs_path' => 'required',
@@ -52,6 +54,8 @@ public function mount()
$this->workdir = null;
$this->fs_path = $this->fileStorage->fs_path;
}
+
+ $this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
}
public function convertToDirectory()
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index 26cd54425..db171db24 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -14,6 +14,22 @@ class Storage extends Component
public $fileStorage;
+ public $isSwarm = false;
+
+ public string $name = '';
+
+ public string $mount_path = '';
+
+ public ?string $host_path = null;
+
+ public string $file_storage_path = '';
+
+ public ?string $file_storage_content = null;
+
+ public string $file_storage_directory_source = '';
+
+ public string $file_storage_directory_destination = '';
+
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
@@ -27,6 +43,18 @@ public function getListeners()
public function mount()
{
+ if (str($this->resource->getMorphClass())->contains('Standalone')) {
+ $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
+ } else {
+ $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
+ }
+
+ if ($this->resource->getMorphClass() === \App\Models\Application::class) {
+ if ($this->resource->destination->server->isSwarm()) {
+ $this->isSwarm = true;
+ }
+ }
+
$this->refreshStorages();
}
@@ -39,30 +67,151 @@ public function refreshStoragesFromEvent()
public function refreshStorages()
{
$this->fileStorage = $this->resource->fileStorages()->get();
- $this->dispatch('$refresh');
+ $this->resource->refresh();
}
- public function addNewVolume($data)
+ public function getFilesProperty()
+ {
+ return $this->fileStorage->where('is_directory', false);
+ }
+
+ public function getDirectoriesProperty()
+ {
+ return $this->fileStorage->where('is_directory', true);
+ }
+
+ public function getVolumeCountProperty()
+ {
+ return $this->resource->persistentStorages()->count();
+ }
+
+ public function getFileCountProperty()
+ {
+ return $this->files->count();
+ }
+
+ public function getDirectoryCountProperty()
+ {
+ return $this->directories->count();
+ }
+
+ public function submitPersistentVolume()
{
try {
$this->authorize('update', $this->resource);
+ $this->validate([
+ 'name' => 'required|string',
+ 'mount_path' => 'required|string',
+ 'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable',
+ ]);
+
+ $name = $this->resource->uuid.'-'.$this->name;
+
LocalPersistentVolume::create([
- 'name' => $data['name'],
- 'mount_path' => $data['mount_path'],
- 'host_path' => $data['host_path'],
+ 'name' => $name,
+ 'mount_path' => $this->mount_path,
+ 'host_path' => $this->host_path,
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->getMorphClass(),
]);
$this->resource->refresh();
- $this->dispatch('success', 'Storage added successfully');
- $this->dispatch('clearAddStorage');
- $this->dispatch('refreshStorages');
+ $this->dispatch('success', 'Volume added successfully');
+ $this->dispatch('closeStorageModal', 'volume');
+ $this->clearForm();
+ $this->refreshStorages();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
+ public function submitFileStorage()
+ {
+ try {
+ $this->authorize('update', $this->resource);
+
+ $this->validate([
+ 'file_storage_path' => 'required|string',
+ 'file_storage_content' => 'nullable|string',
+ ]);
+
+ $this->file_storage_path = trim($this->file_storage_path);
+ $this->file_storage_path = str($this->file_storage_path)->start('/')->value();
+
+ if ($this->resource->getMorphClass() === \App\Models\Application::class) {
+ $fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
+ } elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
+ $fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
+ } else {
+ throw new \Exception('No valid resource type for file mount storage type!');
+ }
+
+ \App\Models\LocalFileVolume::create([
+ 'fs_path' => $fs_path,
+ 'mount_path' => $this->file_storage_path,
+ 'content' => $this->file_storage_content,
+ 'is_directory' => false,
+ 'resource_id' => $this->resource->id,
+ 'resource_type' => get_class($this->resource),
+ ]);
+
+ $this->dispatch('success', 'File mount added successfully');
+ $this->dispatch('closeStorageModal', 'file');
+ $this->clearForm();
+ $this->refreshStorages();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function submitFileStorageDirectory()
+ {
+ try {
+ $this->authorize('update', $this->resource);
+
+ $this->validate([
+ 'file_storage_directory_source' => 'required|string',
+ 'file_storage_directory_destination' => 'required|string',
+ ]);
+
+ $this->file_storage_directory_source = trim($this->file_storage_directory_source);
+ $this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value();
+ $this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
+ $this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
+
+ \App\Models\LocalFileVolume::create([
+ 'fs_path' => $this->file_storage_directory_source,
+ 'mount_path' => $this->file_storage_directory_destination,
+ 'is_directory' => true,
+ 'resource_id' => $this->resource->id,
+ 'resource_type' => get_class($this->resource),
+ ]);
+
+ $this->dispatch('success', 'Directory mount added successfully');
+ $this->dispatch('closeStorageModal', 'directory');
+ $this->clearForm();
+ $this->refreshStorages();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function clearForm()
+ {
+ $this->name = '';
+ $this->mount_path = '';
+ $this->host_path = null;
+ $this->file_storage_path = '';
+ $this->file_storage_content = null;
+ $this->file_storage_directory_destination = '';
+
+ if (str($this->resource->getMorphClass())->contains('Standalone')) {
+ $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
+ } else {
+ $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
+ }
+ }
+
public function render()
{
return view('livewire.project.service.storage');
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/Project/Shared/Storages/Add.php b/app/Livewire/Project/Shared/Storages/Add.php
deleted file mode 100644
index 006d41c14..000000000
--- a/app/Livewire/Project/Shared/Storages/Add.php
+++ /dev/null
@@ -1,174 +0,0 @@
- 'required|string',
- 'mount_path' => 'required|string',
- 'host_path' => 'string|nullable',
- 'file_storage_path' => 'string',
- 'file_storage_content' => 'nullable|string',
- 'file_storage_directory_source' => 'string',
- 'file_storage_directory_destination' => 'string',
- ];
-
- protected $listeners = ['clearAddStorage' => 'clear'];
-
- protected $validationAttributes = [
- 'name' => 'name',
- 'mount_path' => 'mount',
- 'host_path' => 'host',
- 'file_storage_path' => 'file storage path',
- 'file_storage_content' => 'file storage content',
- 'file_storage_directory_source' => 'file storage directory source',
- 'file_storage_directory_destination' => 'file storage directory destination',
- ];
-
- public function mount()
- {
- if (str($this->resource->getMorphClass())->contains('Standalone')) {
- $this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
- } else {
- $this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
- }
- $this->uuid = $this->resource->uuid;
- $this->parameters = get_route_parameters();
- if (data_get($this->parameters, 'application_uuid')) {
- $applicationUuid = $this->parameters['application_uuid'];
- $application = Application::where('uuid', $applicationUuid)->first();
- if (! $application) {
- abort(404);
- }
- if ($application->destination->server->isSwarm()) {
- $this->isSwarm = true;
- $this->rules['host_path'] = 'required|string';
- }
- }
- }
-
- public function submitFileStorage()
- {
- try {
- $this->authorize('update', $this->resource);
-
- $this->validate([
- 'file_storage_path' => 'string',
- 'file_storage_content' => 'nullable|string',
- ]);
-
- $this->file_storage_path = trim($this->file_storage_path);
- $this->file_storage_path = str($this->file_storage_path)->start('/')->value();
-
- if ($this->resource->getMorphClass() === \App\Models\Application::class) {
- $fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
- } elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
- $fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
- } else {
- throw new \Exception('No valid resource type for file mount storage type!');
- }
-
- LocalFileVolume::create(
- [
- 'fs_path' => $fs_path,
- 'mount_path' => $this->file_storage_path,
- 'content' => $this->file_storage_content,
- 'is_directory' => false,
- 'resource_id' => $this->resource->id,
- 'resource_type' => get_class($this->resource),
- ],
- );
- $this->dispatch('refreshStorages');
- } catch (\Throwable $e) {
- return handleError($e, $this);
- }
- }
-
- public function submitFileStorageDirectory()
- {
- try {
- $this->authorize('update', $this->resource);
-
- $this->validate([
- 'file_storage_directory_source' => 'string',
- 'file_storage_directory_destination' => 'string',
- ]);
-
- $this->file_storage_directory_source = trim($this->file_storage_directory_source);
- $this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value();
- $this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
- $this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
-
- LocalFileVolume::create(
- [
- 'fs_path' => $this->file_storage_directory_source,
- 'mount_path' => $this->file_storage_directory_destination,
- 'is_directory' => true,
- 'resource_id' => $this->resource->id,
- 'resource_type' => get_class($this->resource),
- ],
- );
- $this->dispatch('refreshStorages');
- } catch (\Throwable $e) {
- return handleError($e, $this);
- }
- }
-
- public function submitPersistentVolume()
- {
- try {
- $this->authorize('update', $this->resource);
-
- $this->validate([
- 'name' => 'required|string',
- 'mount_path' => 'required|string',
- 'host_path' => 'string|nullable',
- ]);
- $name = $this->uuid.'-'.$this->name;
- $this->dispatch('addNewVolume', [
- 'name' => $name,
- 'mount_path' => $this->mount_path,
- 'host_path' => $this->host_path,
- ]);
- } catch (\Throwable $e) {
- return handleError($e, $this);
- }
- }
-
- public function clear()
- {
- $this->name = '';
- $this->mount_path = '';
- $this->host_path = null;
- }
-}
diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php
index 3928ee1d4..4f57cbfa6 100644
--- a/app/Livewire/Project/Shared/Storages/Show.php
+++ b/app/Livewire/Project/Shared/Storages/Show.php
@@ -37,6 +37,11 @@ class Show extends Component
'host_path' => 'host',
];
+ public function mount()
+ {
+ $this->isReadOnly = $this->storage->isReadOnlyVolume();
+ }
+
public function submit()
{
$this->authorize('update', $this->resource);
diff --git a/app/Livewire/SettingsBackup.php b/app/Livewire/SettingsBackup.php
index 57cb79fca..84f5c6081 100644
--- a/app/Livewire/SettingsBackup.php
+++ b/app/Livewire/SettingsBackup.php
@@ -120,6 +120,8 @@ public function addCoolifyDatabase()
public function submit()
{
+ $this->validate();
+
$this->database->update([
'name' => $this->name,
'description' => $this->description,
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/Application.php b/app/Models/Application.php
index 4f1796790..595ba1cde 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -182,6 +182,21 @@ protected static function booted()
]);
$application->compose_parsing_version = self::$parserVersion;
$application->save();
+
+ // Add default NIXPACKS_NODE_VERSION environment variable for Nixpacks applications
+ if ($application->build_pack === 'nixpacks') {
+ EnvironmentVariable::create([
+ 'key' => 'NIXPACKS_NODE_VERSION',
+ 'value' => '22',
+ 'is_multiline' => false,
+ 'is_literal' => false,
+ 'is_buildtime' => true,
+ 'is_runtime' => false,
+ 'is_preview' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+ }
});
static::forceDeleting(function ($application) {
$application->update(['fqdn' => null]);
@@ -739,9 +754,9 @@ public function environment_variables()
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
->orderByRaw("
- CASE
- WHEN LOWER(key) LIKE 'service_%' THEN 1
- WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ CASE
+ WHEN is_required = true THEN 1
+ WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC
@@ -767,9 +782,9 @@ public function environment_variables_preview()
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
->orderByRaw("
- CASE
- WHEN LOWER(key) LIKE 'service_%' THEN 1
- WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ CASE
+ WHEN is_required = true THEN 1
+ WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC
diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php
index 8df6877ab..4e8eee10f 100644
--- a/app/Models/ApplicationDeploymentQueue.php
+++ b/app/Models/ApplicationDeploymentQueue.php
@@ -41,11 +41,9 @@ class ApplicationDeploymentQueue extends Model
{
protected $guarded = [];
- public function application(): Attribute
+ public function application()
{
- return Attribute::make(
- get: fn () => Application::find($this->application_id),
- );
+ return $this->belongsTo(Application::class);
}
public function server(): Attribute
diff --git a/app/Models/Environment.php b/app/Models/Environment.php
index 437be7d87..c2ad9d2cb 100644
--- a/app/Models/Environment.php
+++ b/app/Models/Environment.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use OpenApi\Attributes as OA;
@@ -19,6 +20,7 @@
)]
class Environment extends BaseModel
{
+ use ClearsGlobalSearchCache;
use HasSafeStringAttribute;
protected $guarded = [];
@@ -33,6 +35,11 @@ protected static function booted()
});
}
+ public static function ownedByCurrentTeam()
+ {
+ return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name');
+ }
+
public function isEmpty()
{
return $this->applications()->count() == 0 &&
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index b3e71d75d..376ea9c5e 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -5,6 +5,7 @@
use App\Events\FileStorageChanged;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Symfony\Component\Yaml\Yaml;
class LocalFileVolume extends BaseModel
{
@@ -192,4 +193,61 @@ public function scopeWherePlainMountPath($query, $path)
{
return $query->get()->where('plain_mount_path', $path);
}
+
+ // Check if this volume is read-only by parsing the docker-compose content
+ public function isReadOnlyVolume(): bool
+ {
+ try {
+ // Only check for services
+ $service = $this->service;
+ if (! $service || ! method_exists($service, 'service')) {
+ return false;
+ }
+
+ $actualService = $service->service;
+ if (! $actualService || ! $actualService->docker_compose_raw) {
+ return false;
+ }
+
+ // Parse the docker-compose content
+ $compose = Yaml::parse($actualService->docker_compose_raw);
+ if (! isset($compose['services'])) {
+ return false;
+ }
+
+ // Find the service that this volume belongs to
+ $serviceName = $service->name;
+ if (! isset($compose['services'][$serviceName]['volumes'])) {
+ return false;
+ }
+
+ $volumes = $compose['services'][$serviceName]['volumes'];
+
+ // Check each volume to find a match
+ foreach ($volumes as $volume) {
+ // Volume can be string like "host:container:ro" or "host:container"
+ if (is_string($volume)) {
+ $parts = explode(':', $volume);
+
+ // Check if this volume matches our fs_path and mount_path
+ if (count($parts) >= 2) {
+ $hostPath = $parts[0];
+ $containerPath = $parts[1];
+ $options = $parts[2] ?? null;
+
+ // Match based on fs_path and mount_path
+ if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) {
+ return $options === 'ro';
+ }
+ }
+ }
+ }
+
+ return false;
+ } catch (\Throwable $e) {
+ ray($e->getMessage(), 'Error checking read-only volume');
+
+ return false;
+ }
+ }
}
diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php
index 00dc15fea..e7862478b 100644
--- a/app/Models/LocalPersistentVolume.php
+++ b/app/Models/LocalPersistentVolume.php
@@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
+use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends Model
{
@@ -48,4 +49,69 @@ protected function hostPath(): Attribute
}
);
}
+
+ // Check if this volume is read-only by parsing the docker-compose content
+ public function isReadOnlyVolume(): bool
+ {
+ try {
+ // Get the resource (can be application, service, or database)
+ $resource = $this->resource;
+ if (! $resource) {
+ return false;
+ }
+
+ // Only check for services
+ if (! method_exists($resource, 'service')) {
+ return false;
+ }
+
+ $actualService = $resource->service;
+ if (! $actualService || ! $actualService->docker_compose_raw) {
+ return false;
+ }
+
+ // Parse the docker-compose content
+ $compose = Yaml::parse($actualService->docker_compose_raw);
+ if (! isset($compose['services'])) {
+ return false;
+ }
+
+ // Find the service that this volume belongs to
+ $serviceName = $resource->name;
+ if (! isset($compose['services'][$serviceName]['volumes'])) {
+ return false;
+ }
+
+ $volumes = $compose['services'][$serviceName]['volumes'];
+
+ // Check each volume to find a match
+ foreach ($volumes as $volume) {
+ // Volume can be string like "host:container:ro" or "host:container"
+ if (is_string($volume)) {
+ $parts = explode(':', $volume);
+
+ // Check if this volume matches our mount_path
+ if (count($parts) >= 2) {
+ $containerPath = $parts[1];
+ $options = $parts[2] ?? null;
+
+ // Match based on mount_path
+ // Remove leading slash from mount_path if present for comparison
+ $mountPath = str($this->mount_path)->ltrim('/')->toString();
+ $containerPathClean = str($containerPath)->ltrim('/')->toString();
+
+ if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
+ return $options === 'ro';
+ }
+ }
+ }
+ }
+
+ return false;
+ } catch (\Throwable $e) {
+ ray($e->getMessage(), 'Error checking read-only persistent volume');
+
+ return false;
+ }
+ }
}
diff --git a/app/Models/Project.php b/app/Models/Project.php
index 1c46042e3..a9bf76803 100644
--- a/app/Models/Project.php
+++ b/app/Models/Project.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use OpenApi\Attributes as OA;
use Visus\Cuid2\Cuid2;
@@ -24,6 +25,7 @@
)]
class Project extends BaseModel
{
+ use ClearsGlobalSearchCache;
use HasSafeStringAttribute;
protected $guarded = [];
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/Models/Service.php b/app/Models/Service.php
index d42d471c6..c4b8623e0 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -547,6 +547,21 @@ public function extraFields()
}
$fields->put('Grafana', $data->toArray());
break;
+ case $image->contains('elasticsearch'):
+ $data = collect([]);
+ $elastic_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ELASTICSEARCH')->first();
+ if ($elastic_password) {
+ $data = $data->merge([
+ 'Password (default user: elastic)' => [
+ 'key' => data_get($elastic_password, 'key'),
+ 'value' => data_get($elastic_password, 'value'),
+ 'rules' => 'required',
+ 'isPassword' => true,
+ ],
+ ]);
+ }
+ $fields->put('Elasticsearch', $data->toArray());
+ break;
case $image->contains('directus'):
$data = collect([]);
$admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first();
@@ -1231,9 +1246,9 @@ public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
- CASE
- WHEN LOWER(key) LIKE 'service_%' THEN 1
- WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
+ CASE
+ WHEN is_required = true THEN 1
+ WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC
@@ -1263,6 +1278,21 @@ public function saveComposeConfigs()
$commands[] = "cd $workdir";
$commands[] = 'rm -f .env || true';
+ $envs = collect([]);
+
+ // Generate SERVICE_NAME_* environment variables from docker-compose services
+ if ($this->docker_compose) {
+ try {
+ $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose);
+ $services = data_get($dockerCompose, 'services', []);
+ foreach ($services as $serviceName => $_) {
+ $envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
+ }
+ } catch (\Exception $e) {
+ ray($e->getMessage());
+ }
+ }
+
$envs_from_coolify = $this->environment_variables()->get();
$sorted = $envs_from_coolify->sortBy(function ($env) {
if (str($env->key)->startsWith('SERVICE_')) {
@@ -1274,7 +1304,6 @@ public function saveComposeConfigs()
return 3;
});
- $envs = collect([]);
foreach ($sorted as $env) {
$envs->push("{$env->key}={$env->real_value}");
}
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.
Frequency: {$this->frequency}.
S3 Error: {$this->s3_error}";
+
+ if ($this->s3_storage_url) {
+ $message .= "
s3_storage_url}\">Check S3 Configuration";
+ }
+
+ return new PushoverMessage(
+ title: 'Database backup succeeded locally, S3 upload failed',
+ level: 'warning',
+ message: $message,
+ );
+ }
+
+ public function toSlack(): SlackMessage
+ {
+ $title = '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.";
+
+ $description .= "\n\n*Frequency:* {$this->frequency}";
+ $description .= "\n\n*S3 Error:* {$this->s3_error}";
+
+ if ($this->s3_storage_url) {
+ $description .= "\n\n*S3 Storage:* <{$this->s3_storage_url}|Check Configuration>";
+ }
+
+ return new SlackMessage(
+ title: $title,
+ description: $description,
+ color: SlackMessage::warningColor()
+ );
+ }
+}
diff --git a/app/Rules/DockerImageFormat.php b/app/Rules/DockerImageFormat.php
new file mode 100644
index 000000000..a6a78a76c
--- /dev/null
+++ b/app/Rules/DockerImageFormat.php
@@ -0,0 +1,41 @@
+ strrpos($imageString, '/'))) {
- $mainPart = substr($imageString, 0, $lastColon);
- $this->tag = substr($imageString, $lastColon + 1);
+ // Check for @sha256: format first (e.g., nginx@sha256:abc123...)
+ if (preg_match('/^(.+)@sha256:([a-f0-9]{64})$/i', $imageString, $matches)) {
+ $mainPart = $matches[1];
+ $this->tag = $matches[2];
+ $this->isImageHash = true;
} else {
- $mainPart = $imageString;
- $this->tag = 'latest';
+ // Split by : to handle the tag, but be careful with registry ports
+ $lastColon = strrpos($imageString, ':');
+ $hasSlash = str_contains($imageString, '/');
+
+ // If the last colon appears after the last slash, it's a tag
+ // Otherwise it might be a port in the registry URL
+ if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) {
+ $mainPart = substr($imageString, 0, $lastColon);
+ $this->tag = substr($imageString, $lastColon + 1);
+
+ // Check if the tag is a SHA256 hash
+ $this->isImageHash = $this->isSha256Hash($this->tag);
+ } else {
+ $mainPart = $imageString;
+ $this->tag = 'latest';
+ $this->isImageHash = false;
+ }
}
// Split the main part by / to handle registry and image name
@@ -41,6 +54,37 @@ public function parse(string $imageString): self
return $this;
}
+ /**
+ * Check if the given string is a SHA256 hash
+ */
+ private function isSha256Hash(string $hash): bool
+ {
+ // SHA256 hashes are 64 characters long and contain only hexadecimal characters
+ return preg_match('/^[a-f0-9]{64}$/i', $hash) === 1;
+ }
+
+ /**
+ * Check if the current tag is an image hash
+ */
+ public function isImageHash(): bool
+ {
+ return $this->isImageHash;
+ }
+
+ /**
+ * Get the full image name with hash if present
+ */
+ public function getFullImageNameWithHash(): string
+ {
+ $imageName = $this->getFullImageNameWithoutTag();
+
+ if ($this->isImageHash) {
+ return $imageName.'@sha256:'.$this->tag;
+ }
+
+ return $imageName.':'.$this->tag;
+ }
+
public function getFullImageNameWithoutTag(): string
{
if ($this->registryUrl) {
@@ -73,6 +117,10 @@ public function toString(): string
}
$parts[] = $this->imageName;
+ if ($this->isImageHash) {
+ return implode('/', $parts).'@sha256:'.$this->tag;
+ }
+
return implode('/', $parts).':'.$this->tag;
}
}
diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php
index ae587aa87..b9af70aba 100644
--- a/app/Traits/ClearsGlobalSearchCache.php
+++ b/app/Traits/ClearsGlobalSearchCache.php
@@ -10,77 +10,119 @@ trait ClearsGlobalSearchCache
protected static function bootClearsGlobalSearchCache()
{
static::saving(function ($model) {
- // Only clear cache if searchable fields are being changed
- if ($model->hasSearchableChanges()) {
- $teamId = $model->getTeamIdForCache();
- if (filled($teamId)) {
- GlobalSearch::clearTeamCache($teamId);
+ try {
+ // Only clear cache if searchable fields are being changed
+ if ($model->hasSearchableChanges()) {
+ $teamId = $model->getTeamIdForCache();
+ if (filled($teamId)) {
+ GlobalSearch::clearTeamCache($teamId);
+ }
}
+ } catch (\Throwable $e) {
+ // Silently fail cache clearing - don't break the save operation
+ ray('Failed to clear global search cache on saving: '.$e->getMessage());
}
});
static::created(function ($model) {
- // Always clear cache when model is created
- $teamId = $model->getTeamIdForCache();
- if (filled($teamId)) {
- GlobalSearch::clearTeamCache($teamId);
+ try {
+ // Always clear cache when model is created
+ $teamId = $model->getTeamIdForCache();
+ if (filled($teamId)) {
+ GlobalSearch::clearTeamCache($teamId);
+ }
+ } catch (\Throwable $e) {
+ // Silently fail cache clearing - don't break the create operation
+ ray('Failed to clear global search cache on creation: '.$e->getMessage());
}
});
static::deleted(function ($model) {
- // Always clear cache when model is deleted
- $teamId = $model->getTeamIdForCache();
- if (filled($teamId)) {
- GlobalSearch::clearTeamCache($teamId);
+ try {
+ // Always clear cache when model is deleted
+ $teamId = $model->getTeamIdForCache();
+ if (filled($teamId)) {
+ GlobalSearch::clearTeamCache($teamId);
+ }
+ } catch (\Throwable $e) {
+ // Silently fail cache clearing - don't break the delete operation
+ ray('Failed to clear global search cache on deletion: '.$e->getMessage());
}
});
}
private function hasSearchableChanges(): bool
{
- // Define searchable fields based on model type
- $searchableFields = ['name', 'description'];
+ try {
+ // Define searchable fields based on model type
+ $searchableFields = ['name', 'description'];
- // Add model-specific searchable fields
- if ($this instanceof \App\Models\Application) {
- $searchableFields[] = 'fqdn';
- $searchableFields[] = 'docker_compose_domains';
- } elseif ($this instanceof \App\Models\Server) {
- $searchableFields[] = 'ip';
- } elseif ($this instanceof \App\Models\Service) {
- // Services don't have direct fqdn, but name and description are covered
- }
- // Database models only have name and description as searchable
-
- // Check if any searchable field is dirty
- foreach ($searchableFields as $field) {
- if ($this->isDirty($field)) {
- return true;
+ // Add model-specific searchable fields
+ if ($this instanceof \App\Models\Application) {
+ $searchableFields[] = 'fqdn';
+ $searchableFields[] = 'docker_compose_domains';
+ } elseif ($this instanceof \App\Models\Server) {
+ $searchableFields[] = 'ip';
+ } elseif ($this instanceof \App\Models\Service) {
+ // Services don't have direct fqdn, but name and description are covered
+ } elseif ($this instanceof \App\Models\Project || $this instanceof \App\Models\Environment) {
+ // Projects and environments only have name and description as searchable
}
- }
+ // Database models only have name and description as searchable
- return false;
+ // Check if any searchable field is dirty
+ foreach ($searchableFields as $field) {
+ // Check if attribute exists before checking if dirty
+ if (array_key_exists($field, $this->getAttributes()) && $this->isDirty($field)) {
+ return true;
+ }
+ }
+
+ return false;
+ } catch (\Throwable $e) {
+ // If checking changes fails, assume changes exist to be safe
+ ray('Failed to check searchable changes: '.$e->getMessage());
+
+ return true;
+ }
}
private function getTeamIdForCache()
{
- // For database models, team is accessed through environment.project.team
- if (method_exists($this, 'team')) {
- if ($this instanceof \App\Models\Server) {
- $team = $this->team;
- } else {
- $team = $this->team();
+ try {
+ // For Project models (has direct team_id)
+ if ($this instanceof \App\Models\Project) {
+ return $this->team_id ?? null;
}
- if (filled($team)) {
- return is_object($team) ? $team->id : null;
+
+ // For Environment models (get team_id through project)
+ if ($this instanceof \App\Models\Environment) {
+ return $this->project?->team_id;
}
- }
- // For models with direct team_id property
- if (property_exists($this, 'team_id') || isset($this->team_id)) {
- return $this->team_id;
- }
+ // For database models, team is accessed through environment.project.team
+ if (method_exists($this, 'team')) {
+ if ($this instanceof \App\Models\Server) {
+ $team = $this->team;
+ } else {
+ $team = $this->team();
+ }
+ if (filled($team)) {
+ return is_object($team) ? $team->id : null;
+ }
+ }
- return null;
+ // For models with direct team_id property
+ if (property_exists($this, 'team_id') || isset($this->team_id)) {
+ return $this->team_id ?? null;
+ }
+
+ return null;
+ } catch (\Throwable $e) {
+ // If we can't determine team ID, return null
+ ray('Failed to get team ID for cache: '.$e->getMessage());
+
+ return null;
+ }
}
}
diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php
index b568e090c..36243e119 100644
--- a/bootstrap/helpers/constants.php
+++ b/bootstrap/helpers/constants.php
@@ -21,13 +21,23 @@
'bitnami/mariadb',
'bitnami/mongodb',
'bitnami/redis',
+ 'bitnamilegacy/mariadb',
+ 'bitnamilegacy/mongodb',
+ 'bitnamilegacy/redis',
+ 'bitnamisecure/mariadb',
+ 'bitnamisecure/mongodb',
+ 'bitnamisecure/redis',
'mysql',
'bitnami/mysql',
+ 'bitnamilegacy/mysql',
+ 'bitnamisecure/mysql',
'mysql/mysql-server',
'mariadb',
'postgis/postgis',
'postgres',
'bitnami/postgresql',
+ 'bitnamilegacy/postgresql',
+ 'bitnamisecure/postgresql',
'supabase/postgres',
'elestio/postgres',
'mongo',
diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php
index 5dbd46b5e..aa7be3236 100644
--- a/bootstrap/helpers/databases.php
+++ b/bootstrap/helpers/databases.php
@@ -237,12 +237,11 @@ function removeOldBackups($backup): void
{
try {
if ($backup->executions) {
- // If local backup is disabled, mark all executions as having local storage deleted
- if ($backup->disable_local_backup && $backup->save_s3) {
- $backup->executions()
- ->where('local_storage_deleted', false)
- ->update(['local_storage_deleted' => true]);
- } else {
+ // Delete old local backups (only if local backup is NOT disabled)
+ // Note: When disable_local_backup is enabled, each execution already marks its own
+ // local_storage_deleted status at the time of backup, so we don't need to retroactively
+ // update old executions
+ if (! $backup->disable_local_backup) {
$localBackupsToDelete = deleteOldBackupsLocally($backup);
if ($localBackupsToDelete->isNotEmpty()) {
$backup->executions()
@@ -261,18 +260,18 @@ function removeOldBackups($backup): void
}
}
- // Delete executions where both local and S3 storage are marked as deleted
- // or where only S3 is enabled and S3 storage is deleted
- if ($backup->disable_local_backup && $backup->save_s3) {
- $backup->executions()
- ->where('s3_storage_deleted', true)
- ->delete();
- } else {
- $backup->executions()
- ->where('local_storage_deleted', true)
- ->where('s3_storage_deleted', true)
- ->delete();
- }
+ // Delete execution records where all backup copies are gone
+ // Case 1: Both local and S3 backups are deleted
+ $backup->executions()
+ ->where('local_storage_deleted', true)
+ ->where('s3_storage_deleted', true)
+ ->delete();
+
+ // Case 2: Local backup is deleted and S3 was never used (s3_uploaded is null)
+ $backup->executions()
+ ->where('local_storage_deleted', true)
+ ->whereNull('s3_uploaded')
+ ->delete();
} catch (\Exception $e) {
throw $e;
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 1491e4712..b63c3fc3b 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -1119,3 +1119,53 @@ function escapeDollarSign($value)
return str_replace($search, $replace, $value);
}
+
+/**
+ * Generate Docker build arguments from environment variables collection
+ * Returns only keys (no values) since values are sourced from environment via export
+ *
+ * @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
+ * @return \Illuminate\Support\Collection Collection of formatted --build-arg strings (keys only)
+ */
+function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
+{
+ $variables = collect($variables);
+
+ return $variables->map(function ($var) {
+ $key = is_array($var) ? data_get($var, 'key') : $var->key;
+
+ // Only return the key - Docker will get the value from the environment
+ return "--build-arg {$key}";
+ });
+}
+
+/**
+ * Generate Docker environment flags from environment variables collection
+ *
+ * @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
+ * @return string Space-separated environment flags
+ */
+function generateDockerEnvFlags($variables): string
+{
+ $variables = collect($variables);
+
+ return $variables
+ ->map(function ($var) {
+ $key = is_array($var) ? data_get($var, 'key') : $var->key;
+ $value = is_array($var) ? data_get($var, 'value') : $var->value;
+ $isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false);
+
+ if ($isMultiline) {
+ // For multiline variables, strip surrounding quotes and escape for bash
+ $raw_value = trim($value, "'");
+ $escaped_value = str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $raw_value);
+
+ return "-e {$key}=\"{$escaped_value}\"";
+ }
+
+ $escaped_value = escapeshellarg($value);
+
+ return "-e {$key}={$escaped_value}";
+ })
+ ->implode(' ');
+}
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index 25cc5d0a6..a588ed882 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -1172,6 +1172,9 @@ function serviceParser(Service $resource): Collection
$parsedServices = collect([]);
+ // Generate SERVICE_NAME variables for docker compose services
+ $serviceNameEnvironments = generateDockerComposeServiceName($services);
+
$allMagicEnvironments = collect([]);
// Presave services
foreach ($services as $serviceName => $service) {
@@ -1988,7 +1991,7 @@ function serviceParser(Service $resource): Collection
$payload['volumes'] = $volumesParsed;
}
if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
- $payload['environment'] = $environment->merge($coolifyEnvironments);
+ $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
}
if ($logging) {
$payload['logging'] = $logging;
diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php
index 5bc1d005e..924bad307 100644
--- a/bootstrap/helpers/proxy.php
+++ b/bootstrap/helpers/proxy.php
@@ -108,7 +108,63 @@ function connectProxyToNetworks(Server $server)
return $commands->flatten();
}
-function generate_default_proxy_configuration(Server $server)
+function extractCustomProxyCommands(Server $server, string $existing_config): array
+{
+ $custom_commands = [];
+ $proxy_type = $server->proxyType();
+
+ if ($proxy_type !== ProxyTypes::TRAEFIK->value || empty($existing_config)) {
+ return $custom_commands;
+ }
+
+ try {
+ $yaml = Yaml::parse($existing_config);
+ $existing_commands = data_get($yaml, 'services.traefik.command', []);
+
+ if (empty($existing_commands)) {
+ return $custom_commands;
+ }
+
+ // Define default commands that Coolify generates
+ $default_command_prefixes = [
+ '--ping=',
+ '--api.',
+ '--entrypoints.http.address=',
+ '--entrypoints.https.address=',
+ '--entrypoints.http.http.encodequerysemicolons=',
+ '--entryPoints.http.http2.maxConcurrentStreams=',
+ '--entrypoints.https.http.encodequerysemicolons=',
+ '--entryPoints.https.http2.maxConcurrentStreams=',
+ '--entrypoints.https.http3',
+ '--providers.file.',
+ '--certificatesresolvers.',
+ '--providers.docker',
+ '--providers.swarm',
+ '--log.level=',
+ '--accesslog.',
+ ];
+
+ // Extract commands that don't match default prefixes (these are custom)
+ foreach ($existing_commands as $command) {
+ $is_default = false;
+ foreach ($default_command_prefixes as $prefix) {
+ if (str_starts_with($command, $prefix)) {
+ $is_default = true;
+ break;
+ }
+ }
+ if (! $is_default) {
+ $custom_commands[] = $command;
+ }
+ }
+ } catch (\Exception $e) {
+ // If we can't parse the config, return empty array
+ // Silently fail to avoid breaking the proxy regeneration
+ }
+
+ return $custom_commands;
+}
+function generateDefaultProxyConfiguration(Server $server, array $custom_commands = [])
{
$proxy_path = $server->proxyPath();
$proxy_type = $server->proxyType();
@@ -228,6 +284,13 @@ function generate_default_proxy_configuration(Server $server)
$config['services']['traefik']['command'][] = '--providers.docker=true';
$config['services']['traefik']['command'][] = '--providers.docker.exposedbydefault=false';
}
+
+ // Append custom commands (e.g., trustedIPs for Cloudflare)
+ if (! empty($custom_commands)) {
+ foreach ($custom_commands as $custom_command) {
+ $config['services']['traefik']['command'][] = $custom_command;
+ }
+ }
} elseif ($proxy_type === 'CADDY') {
$config = [
'networks' => $array_of_networks->toArray(),
diff --git a/conductor.json b/conductor.json
new file mode 100644
index 000000000..eaae44500
--- /dev/null
+++ b/conductor.json
@@ -0,0 +1,7 @@
+{
+ "scripts": {
+ "setup": "./scripts/conductor-setup.sh",
+ "run": "spin up; spin down"
+ },
+ "runScriptMode": "nonconcurrent"
+}
\ No newline at end of file
diff --git a/config/constants.php b/config/constants.php
index 749d6435b..01eaa7fa1 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,7 +2,7 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.433',
+ 'version' => '4.0.0-beta.435',
'helper_version' => '1.0.11',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
diff --git a/database/migrations/2025_10_03_154100_update_clickhouse_image.php b/database/migrations/2025_10_03_154100_update_clickhouse_image.php
new file mode 100644
index 000000000..e52bbcc16
--- /dev/null
+++ b/database/migrations/2025_10_03_154100_update_clickhouse_image.php
@@ -0,0 +1,32 @@
+string('image')->default('bitnamilegacy/clickhouse')->change();
+ });
+ // Optionally, update any existing rows with the old default to the new one
+ DB::table('standalone_clickhouses')
+ ->where('image', 'bitnami/clickhouse')
+ ->update(['image' => 'bitnamilegacy/clickhouse']);
+ }
+
+ public function down()
+ {
+ Schema::table('standalone_clickhouses', function (Blueprint $table) {
+ $table->string('image')->default('bitnami/clickhouse')->change();
+ });
+ // Optionally, revert any changed values
+ DB::table('standalone_clickhouses')
+ ->where('image', 'bitnamilegacy/clickhouse')
+ ->update(['image' => 'bitnami/clickhouse']);
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table.php b/database/migrations/2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table.php
new file mode 100644
index 000000000..d80f2621b
--- /dev/null
+++ b/database/migrations/2025_10_07_120723_add_s3_uploaded_to_scheduled_database_backup_executions_table.php
@@ -0,0 +1,28 @@
+boolean('s3_uploaded')->nullable()->after('filename');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('scheduled_database_backup_executions', function (Blueprint $table) {
+ $table->dropColumn('s3_uploaded');
+ });
+ }
+};
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index e8402b7af..9992b70ba 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -1,5 +1,6 @@
services:
coolify:
+ image: coolify:dev
build:
context: .
dockerfile: ./docker/development/Dockerfile
@@ -41,6 +42,7 @@ services:
volumes:
- dev_redis_data:/data
soketi:
+ image: coolify-realtime:dev
build:
context: .
dockerfile: ./docker/coolify-realtime/Dockerfile
@@ -73,6 +75,7 @@ services:
networks:
- coolify
testing-host:
+ image: coolify-testing-host:dev
build:
context: .
dockerfile: ./docker/testing-host/Dockerfile
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index b5cf3360a..2e5cc5e84 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.433"
+ "version": "4.0.0-beta.435"
},
"nightly": {
- "version": "4.0.0-beta.434"
+ "version": "4.0.0-beta.436"
},
"helper": {
"version": "1.0.11"
diff --git a/public/ente-photos-icon-green.png b/public/ente-photos-icon-green.png
new file mode 100644
index 000000000..b74aa472d
Binary files /dev/null and b/public/ente-photos-icon-green.png differ
diff --git a/public/svgs/ente-photos.svg b/public/svgs/ente-photos.svg
new file mode 100644
index 000000000..e6a469e91
--- /dev/null
+++ b/public/svgs/ente-photos.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/svgs/ente.png b/public/svgs/ente.png
new file mode 100644
index 000000000..f510a7bf7
Binary files /dev/null and b/public/svgs/ente.png differ
diff --git a/resources/css/app.css b/resources/css/app.css
index 77fa2d66b..c1dc7e56d 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -20,8 +20,11 @@ @theme {
--color-warning: #fcd452;
--color-success: #16a34a;
--color-error: #dc2626;
+ --color-coollabs-50: #f5f0ff;
--color-coollabs: #6b16ed;
--color-coollabs-100: #7317ff;
+ --color-coollabs-200: #5a12c7;
+ --color-coollabs-300: #4a0fa3;
--color-coolgray-100: #181818;
--color-coolgray-200: #202020;
--color-coolgray-300: #242424;
@@ -91,11 +94,11 @@ option {
}
button[isError]:not(:disabled) {
- @apply text-white bg-red-600 hover:bg-red-700;
+ @apply text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white dark:hover:bg-red-800 dark:hover:text-white;
}
button[isHighlighted]:not(:disabled) {
- @apply text-white bg-coollabs hover:bg-coollabs-100;
+ @apply text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20 border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white dark:hover:bg-coollabs-100 dark:hover:text-white;
}
h1 {
@@ -118,6 +121,11 @@ a {
@apply hover:text-black dark:hover:text-white;
}
+button:focus-visible,
+a:focus-visible {
+ @apply outline-none ring-2 ring-coollabs dark:ring-warning ring-offset-2 dark:ring-offset-coolgray-100;
+}
+
label {
@apply dark:text-neutral-400;
}
diff --git a/resources/css/utilities.css b/resources/css/utilities.css
index cbbe2ef8e..bedfb51bc 100644
--- a/resources/css/utilities.css
+++ b/resources/css/utilities.css
@@ -63,7 +63,7 @@ @utility select {
}
@utility button {
- @apply flex gap-2 justify-center items-center px-2 py-1 text-sm text-black normal-case rounded-sm border outline-0 cursor-pointer bg-neutral-200/50 border-neutral-300 hover:bg-neutral-300 dark:bg-coolgray-200 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-500 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300;
+ @apply flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200 dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100;
}
@utility alert-success {
@@ -83,11 +83,11 @@ @utility add-tag {
}
@utility dropdown-item {
- @apply flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50;
+ @apply flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs;
}
@utility dropdown-item-no-padding {
- @apply flex relative gap-2 justify-start items-center py-1 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50;
+ @apply flex relative gap-2 justify-start items-center py-1 w-full text-xs transition-colors cursor-pointer select-none dark:text-white hover:bg-neutral-100 dark:hover:bg-coollabs outline-none data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs;
}
@utility badge {
@@ -155,15 +155,15 @@ @utility kbd-custom {
}
@utility box {
- @apply relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-black hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline;
+ @apply relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm;
}
@utility box-boarding {
- @apply flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 dark:text-white bg-neutral-50 border border-neutral-200 dark:border-black hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:text-black hover:no-underline text-black;
+ @apply flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem] dark:bg-coolgray-100 dark:text-white bg-neutral-50 border border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100 dark:hover:bg-coollabs-100 dark:hover:text-white hover:text-black hover:no-underline text-black rounded-sm;
}
@utility box-without-bg {
- @apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem] border border-neutral-200 dark:border-black;
+ @apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem] border border-neutral-200 dark:border-coolgray-300 rounded-sm;
}
@utility box-without-bg-without-border {
diff --git a/resources/views/components/applications/advanced.blade.php b/resources/views/components/applications/advanced.blade.php
index 46ea54e99..e36583741 100644
--- a/resources/views/components/applications/advanced.blade.php
+++ b/resources/views/components/applications/advanced.blade.php
@@ -19,7 +19,7 @@
@else
-