chore(setup): create core skeleton

This commit is contained in:
peaklabs-dev 2025-11-30 16:50:04 +01:00
parent 4a579ceca7
commit 3cb36b0c41
No known key found for this signature in database
2353 changed files with 687 additions and 297375 deletions

View file

@ -1,41 +0,0 @@
# AI Instructions Synchronization Guide
**This file has moved!**
All AI documentation and synchronization guidelines are now in the `.ai/` directory.
## New Locations
- **Sync Guide**: [.ai/meta/sync-guide.md](.ai/meta/sync-guide.md)
- **Maintaining Docs**: [.ai/meta/maintaining-docs.md](.ai/meta/maintaining-docs.md)
- **Documentation Hub**: [.ai/README.md](.ai/README.md)
## Quick Overview
All AI instructions are now organized in `.ai/` directory:
```
.ai/
├── README.md # Navigation hub
├── core/ # Project information
├── development/ # Dev workflows
├── patterns/ # Code patterns
└── meta/ # Documentation guides
```
### For AI Assistants
- **Claude Code**: Use `CLAUDE.md` (references `.ai/` files)
- **Cursor IDE**: Use `.cursor/rules/coolify-ai-docs.mdc` (references `.ai/` files)
- **All Tools**: Browse `.ai/` directory for detailed documentation
### Key Principles
1. **Single Source of Truth**: Each piece of information exists in ONE file only
2. **Cross-Reference**: Other files reference the source, don't duplicate
3. **Organized by Topic**: Core, Development, Patterns, Meta
4. **Version Consistency**: All versions in `.ai/core/technology-stack.md`
## For More Information
See [.ai/meta/sync-guide.md](.ai/meta/sync-guide.md) for complete synchronization guidelines and [.ai/meta/maintaining-docs.md](.ai/meta/maintaining-docs.md) for documentation maintenance instructions.

View file

@ -1,148 +0,0 @@
# Coolify AI Documentation
Welcome to the Coolify AI documentation hub. This directory contains all AI assistant instructions organized by topic for easy navigation and maintenance.
## Quick Start
- **For Claude Code**: Start with [CLAUDE.md in root directory](../CLAUDE.md)
- **For Cursor IDE**: Check `.cursor/rules/coolify-ai-docs.mdc` which references this directory
- **For Other AI Tools**: Continue reading below
## Documentation Structure
### 📚 Core Documentation
Essential project information and architecture:
- **[Technology Stack](core/technology-stack.md)** - All versions, packages, and dependencies (Laravel 12.4.1, PHP 8.4.7, etc.)
- **[Project Overview](core/project-overview.md)** - What Coolify is and how it works
- **[Application Architecture](core/application-architecture.md)** - System design and component relationships
- **[Deployment Architecture](core/deployment-architecture.md)** - How deployments work end-to-end, including Coolify Docker Compose extensions (custom fields)
### 💻 Development
Day-to-day development practices:
- **[Workflow](development/development-workflow.md)** - Development setup, commands, and daily workflows
- **[Testing Patterns](development/testing-patterns.md)** - How to write and run tests (Unit vs Feature, Docker requirements)
- **[Laravel Boost](development/laravel-boost.md)** - Laravel-specific guidelines and best practices
### 🎨 Patterns
Code patterns and best practices by domain:
- **[Database Patterns](patterns/database-patterns.md)** - Eloquent, migrations, relationships
- **[Frontend Patterns](patterns/frontend-patterns.md)** - Livewire, Alpine.js, Tailwind CSS
- **[Security Patterns](patterns/security-patterns.md)** - Authentication, authorization, security best practices
- **[Form Components](patterns/form-components.md)** - Enhanced form components with authorization
- **[API & Routing](patterns/api-and-routing.md)** - API design, routing conventions, REST patterns
### 📖 Meta
Documentation about documentation:
- **[Maintaining Docs](meta/maintaining-docs.md)** - How to update and improve this documentation
- **[Sync Guide](meta/sync-guide.md)** - Keeping documentation synchronized across tools
## Quick Decision Tree
**What do you need help with?**
### Running Commands
→ [development/development-workflow.md](development/development-workflow.md)
- Frontend: `npm run dev`, `npm run build`
- Backend: `php artisan serve`, `php artisan migrate`
- Tests: Docker for Feature tests, mocking for Unit tests
- Code quality: `./vendor/bin/pint`, `./vendor/bin/phpstan`
### Writing Tests
→ [development/testing-patterns.md](development/testing-patterns.md)
- **Unit tests**: No database, use mocking, run outside Docker
- **Feature tests**: Can use database, must run inside Docker
- Command: `docker exec coolify php artisan test`
### Building UI
→ [patterns/frontend-patterns.md](patterns/frontend-patterns.md) or [patterns/form-components.md](patterns/form-components.md)
- Livewire components with server-side state
- Alpine.js for client-side interactivity
- Tailwind CSS 4.1.4 for styling
- Form components with built-in authorization
### Database Work
→ [patterns/database-patterns.md](patterns/database-patterns.md)
- Eloquent ORM patterns
- Migration best practices
- Relationship definitions
- Query optimization
### Security & Auth
→ [patterns/security-patterns.md](patterns/security-patterns.md)
- Team-based access control
- Policy and gate patterns
- Form authorization (canGate, canResource)
- API security
### Laravel-Specific Questions
→ [development/laravel-boost.md](development/laravel-boost.md)
- Laravel 12 patterns
- Livewire 3 best practices
- Pest testing patterns
- Laravel conventions
### Docker Compose Extensions
→ [core/deployment-architecture.md](core/deployment-architecture.md#coolify-docker-compose-extensions)
- Custom fields: `exclude_from_hc`, `content`, `isDirectory`
- How to use inline file content
- Health check exclusion patterns
- Volume creation control
### Version Numbers
→ [core/technology-stack.md](core/technology-stack.md)
- **Single source of truth** for all version numbers
- Don't duplicate versions elsewhere, reference this file
## Navigation Tips
1. **Start broad**: Begin with project-overview or ../CLAUDE.md
2. **Get specific**: Navigate to topic-specific files for details
3. **Cross-reference**: Files link to related topics
4. **Single source**: Version numbers and critical data exist in ONE place only
## For AI Assistants
### Important Patterns to Follow
**Testing Commands:**
- Unit tests: `./vendor/bin/pest tests/Unit` (no database, outside Docker)
- Feature tests: `docker exec coolify php artisan test` (requires database, inside Docker)
- NEVER run Feature tests outside Docker - they will fail with database connection errors
**Version Numbers:**
- Always use exact versions from [technology-stack.md](core/technology-stack.md)
- Laravel 12.4.1, PHP 8.4.7, Tailwind 4.1.4
- Don't use "v12" or "8.4" - be precise
**Form Authorization:**
- ALWAYS include `canGate` and `:canResource` on form components
- See [form-components.md](patterns/form-components.md) for examples
**Livewire Components:**
- MUST have exactly ONE root element
- See [frontend-patterns.md](patterns/frontend-patterns.md) for details
**Code Style:**
- Run `./vendor/bin/pint` before finalizing changes
- Follow PSR-12 standards
- Use PHP 8.4 features (constructor promotion, typed properties, etc.)
## Contributing
When updating documentation:
1. Read [meta/maintaining-docs.md](meta/maintaining-docs.md)
2. Follow the single source of truth principle
3. Update cross-references when moving content
4. Test all links work
5. Run Pint on markdown files if applicable
## Questions?
- **Claude Code users**: Check [../CLAUDE.md](../CLAUDE.md) first
- **Cursor IDE users**: Check `.cursor/rules/coolify-ai-docs.mdc`
- **Documentation issues**: See [meta/maintaining-docs.md](meta/maintaining-docs.md)
- **Sync issues**: See [meta/sync-guide.md](meta/sync-guide.md)

View file

@ -1,604 +0,0 @@
# Coolify Application Architecture
## Laravel Project Structure
### **Core Application Directory** ([app/](mdc:app))
```
app/
├── Actions/ # Business logic actions (Action pattern)
├── Console/ # Artisan commands
├── Contracts/ # Interface definitions
├── Data/ # Data Transfer Objects (Spatie Laravel Data)
├── Enums/ # Enumeration classes
├── Events/ # Event classes
├── Exceptions/ # Custom exception classes
├── Helpers/ # Utility helper classes
├── Http/ # HTTP layer (Controllers, Middleware, Requests)
├── Jobs/ # Background job classes
├── Listeners/ # Event listeners
├── Livewire/ # Livewire components (Frontend)
├── Models/ # Eloquent models (Domain entities)
├── Notifications/ # Notification classes
├── Policies/ # Authorization policies
├── Providers/ # Service providers
├── Repositories/ # Repository pattern implementations
├── Services/ # Service layer classes
├── Traits/ # Reusable trait classes
└── View/ # View composers and creators
```
## Core Domain Models
### **Infrastructure Management**
#### **[Server.php](mdc:app/Models/Server.php)** (46KB, 1343 lines)
- **Purpose**: Physical/virtual server management
- **Key Relationships**:
- `hasMany(Application::class)` - Deployed applications
- `hasMany(StandalonePostgresql::class)` - Database instances
- `belongsTo(Team::class)` - Team ownership
- **Key Features**:
- SSH connection management
- Resource monitoring
- Proxy configuration (Traefik/Caddy)
- Docker daemon interaction
#### **[Application.php](mdc:app/Models/Application.php)** (74KB, 1734 lines)
- **Purpose**: Application deployment and management
- **Key Relationships**:
- `belongsTo(Server::class)` - Deployment target
- `belongsTo(Environment::class)` - Environment context
- `hasMany(ApplicationDeploymentQueue::class)` - Deployment history
- **Key Features**:
- Git repository integration
- Docker build and deployment
- Environment variable management
- SSL certificate handling
#### **[Service.php](mdc:app/Models/Service.php)** (58KB, 1325 lines)
- **Purpose**: Multi-container service orchestration
- **Key Relationships**:
- `hasMany(ServiceApplication::class)` - Service components
- `hasMany(ServiceDatabase::class)` - Service databases
- `belongsTo(Environment::class)` - Environment context
- **Key Features**:
- Docker Compose generation
- Service dependency management
- Health check configuration
### **Team & Project Organization**
#### **[Team.php](mdc:app/Models/Team.php)** (8.9KB, 308 lines)
- **Purpose**: Multi-tenant team management
- **Key Relationships**:
- `hasMany(User::class)` - Team members
- `hasMany(Project::class)` - Team projects
- `hasMany(Server::class)` - Team servers
- **Key Features**:
- Resource limits and quotas
- Team-based access control
- Subscription management
#### **[Project.php](mdc:app/Models/Project.php)** (4.3KB, 156 lines)
- **Purpose**: Project organization and grouping
- **Key Relationships**:
- `hasMany(Environment::class)` - Project environments
- `belongsTo(Team::class)` - Team ownership
- **Key Features**:
- Environment isolation
- Resource organization
#### **[Environment.php](mdc:app/Models/Environment.php)**
- **Purpose**: Environment-specific configuration
- **Key Relationships**:
- `hasMany(Application::class)` - Environment applications
- `hasMany(Service::class)` - Environment services
- `belongsTo(Project::class)` - Project context
### **Database Management Models**
#### **Standalone Database Models**
- **[StandalonePostgresql.php](mdc:app/Models/StandalonePostgresql.php)** (11KB, 351 lines)
- **[StandaloneMysql.php](mdc:app/Models/StandaloneMysql.php)** (11KB, 351 lines)
- **[StandaloneMariadb.php](mdc:app/Models/StandaloneMariadb.php)** (10KB, 337 lines)
- **[StandaloneMongodb.php](mdc:app/Models/StandaloneMongodb.php)** (12KB, 370 lines)
- **[StandaloneRedis.php](mdc:app/Models/StandaloneRedis.php)** (12KB, 394 lines)
- **[StandaloneKeydb.php](mdc:app/Models/StandaloneKeydb.php)** (11KB, 347 lines)
- **[StandaloneDragonfly.php](mdc:app/Models/StandaloneDragonfly.php)** (11KB, 347 lines)
- **[StandaloneClickhouse.php](mdc:app/Models/StandaloneClickhouse.php)** (10KB, 336 lines)
**Common Features**:
- Database configuration management
- Backup scheduling and execution
- Connection string generation
- Health monitoring
### **Configuration & Settings**
#### **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** (7.6KB, 219 lines)
- **Purpose**: Application environment variable management
- **Key Features**:
- Encrypted value storage
- Build-time vs runtime variables
- Shared variable inheritance
#### **[InstanceSettings.php](mdc:app/Models/InstanceSettings.php)** (3.2KB, 124 lines)
- **Purpose**: Global Coolify instance configuration
- **Key Features**:
- FQDN and port configuration
- Auto-update settings
- Security configurations
## Architectural Patterns
### **Action Pattern** ([app/Actions/](mdc:app/Actions))
Using [lorisleiva/laravel-actions](mdc:composer.json) for business logic encapsulation:
```php
// Example Action structure
class DeployApplication extends Action
{
public function handle(Application $application): void
{
// Business logic for deployment
}
public function asJob(Application $application): void
{
// Queue job implementation
}
}
```
**Key Action Categories**:
- **Application/**: Deployment and management actions
- **Database/**: Database operations
- **Server/**: Server management actions
- **Service/**: Service orchestration actions
### **Repository Pattern** ([app/Repositories/](mdc:app/Repositories))
Data access abstraction layer:
- Encapsulates database queries
- Provides testable data layer
- Abstracts complex query logic
### **Service Layer** ([app/Services/](mdc:app/Services))
Business logic services:
- External API integrations
- Complex business operations
- Cross-cutting concerns
## Data Flow Architecture
### **Request Lifecycle**
1. **HTTP Request** → [routes/web.php](mdc:routes/web.php)
2. **Middleware** → Authentication, authorization
3. **Livewire Component** → [app/Livewire/](mdc:app/Livewire)
4. **Action/Service** → Business logic execution
5. **Model/Repository** → Data persistence
6. **Response** → Livewire reactive update
### **Background Processing**
1. **Job Dispatch** → Queue system (Redis)
2. **Job Processing** → [app/Jobs/](mdc:app/Jobs)
3. **Action Execution** → Business logic
4. **Event Broadcasting** → Real-time updates
5. **Notification** → User feedback
## Security Architecture
### **Multi-Tenant Isolation**
```php
// Team-based query scoping
class Application extends Model
{
public function scopeOwnedByCurrentTeam($query)
{
return $query->whereHas('environment.project.team', function ($q) {
$q->where('id', currentTeam()->id);
});
}
}
```
### **Authorization Layers**
1. **Team Membership** → User belongs to team
2. **Resource Ownership** → Resource belongs to team
3. **Policy Authorization** → [app/Policies/](mdc:app/Policies)
4. **Environment Isolation** → Project/environment boundaries
### **Data Protection**
- **Environment Variables**: Encrypted at rest
- **SSH Keys**: Secure storage and transmission
- **API Tokens**: Sanctum-based authentication
- **Audit Logging**: [spatie/laravel-activitylog](mdc:composer.json)
## Configuration Hierarchy
### **Global Configuration**
- **[InstanceSettings](mdc:app/Models/InstanceSettings.php)**: System-wide settings
- **[config/](mdc:config)**: Laravel configuration files
### **Team Configuration**
- **[Team](mdc:app/Models/Team.php)**: Team-specific settings
- **[ServerSetting](mdc:app/Models/ServerSetting.php)**: Server configurations
### **Project Configuration**
- **[ProjectSetting](mdc:app/Models/ProjectSetting.php)**: Project settings
- **[Environment](mdc:app/Models/Environment.php)**: Environment variables
### **Application Configuration**
- **[ApplicationSetting](mdc:app/Models/ApplicationSetting.php)**: App-specific settings
- **[EnvironmentVariable](mdc:app/Models/EnvironmentVariable.php)**: Runtime configuration
## Event-Driven Architecture
### **Event Broadcasting** ([app/Events/](mdc:app/Events))
Real-time updates using Laravel Echo and WebSockets:
```php
// Example event structure
class ApplicationDeploymentStarted implements ShouldBroadcast
{
public function broadcastOn(): array
{
return [
new PrivateChannel("team.{$this->application->team->id}"),
];
}
}
```
### **Event Listeners** ([app/Listeners/](mdc:app/Listeners))
- Deployment status updates
- Resource monitoring alerts
- Notification dispatching
- Audit log creation
## Database Design Patterns
### **Polymorphic Relationships**
```php
// Environment variables can belong to multiple resource types
class EnvironmentVariable extends Model
{
public function resource(): MorphTo
{
return $this->morphTo();
}
}
```
### **Team-Based Soft Scoping**
All major resources include team-based query scoping:
```php
// Automatic team filtering
$applications = Application::ownedByCurrentTeam()->get();
$servers = Server::ownedByCurrentTeam()->get();
```
### **Configuration Inheritance**
Environment variables cascade from:
1. **Shared Variables** → Team-wide defaults
2. **Project Variables** → Project-specific overrides
3. **Application Variables** → Application-specific values
## Integration Patterns
### **Git Provider Integration**
Abstracted git operations supporting:
- **GitHub**: [app/Models/GithubApp.php](mdc:app/Models/GithubApp.php)
- **GitLab**: [app/Models/GitlabApp.php](mdc:app/Models/GitlabApp.php)
- **Bitbucket**: Webhook integration
- **Gitea**: Self-hosted Git support
### **Docker Integration**
- **Container Management**: Direct Docker API communication
- **Image Building**: Dockerfile and Buildpack support
- **Network Management**: Custom Docker networks
- **Volume Management**: Persistent storage handling
### **SSH Communication**
- **[phpseclib/phpseclib](mdc:composer.json)**: Secure SSH connections
- **Multiplexing**: Connection pooling for efficiency
- **Key Management**: [PrivateKey](mdc:app/Models/PrivateKey.php) model
## Testing Architecture
### **Test Structure** ([tests/](mdc:tests))
```
tests/
├── Feature/ # Integration tests
├── Unit/ # Unit tests
├── Browser/ # Dusk browser tests
├── Traits/ # Test helper traits
├── Pest.php # Pest configuration
└── TestCase.php # Base test case
```
### **Testing Patterns**
- **Feature Tests**: Full request lifecycle testing
- **Unit Tests**: Individual class/method testing
- **Browser Tests**: End-to-end user workflows
- **Database Testing**: Factories and seeders
## Performance Considerations
### **Query Optimization**
- **Eager Loading**: Prevent N+1 queries
- **Query Scoping**: Team-based filtering
- **Database Indexing**: Optimized for common queries
### **Caching Strategy**
- **Redis**: Session and cache storage
- **Model Caching**: Frequently accessed data
- **Query Caching**: Expensive query results
### **Background Processing**
- **Queue Workers**: Horizon-managed job processing
- **Job Batching**: Related job grouping
- **Failed Job Handling**: Automatic retry logic
## Container Status Monitoring System
### **Overview**
Container health status is monitored and updated through **multiple independent paths**. When modifying status logic, **ALL paths must be updated** to ensure consistency.
### **Critical Implementation Locations**
#### **1. SSH-Based Status Updates (Scheduled)**
**File**: [app/Actions/Docker/GetContainersStatus.php](mdc:app/Actions/Docker/GetContainersStatus.php)
**Method**: `aggregateApplicationStatus()` (lines 487-540)
**Trigger**: Scheduled job or manual refresh
**Frequency**: Every minute (via `ServerCheckJob`)
**Status Aggregation Logic**:
```php
// Tracks multiple status flags
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasUnknown = false; // ⚠️ CRITICAL: Must track unknown
$hasExited = false;
// ... more states
// Priority: restarting > degraded > running (unhealthy > unknown > healthy)
if ($hasRunning) {
if ($hasUnhealthy) return 'running (unhealthy)';
elseif ($hasUnknown) return 'running (unknown)';
else return 'running (healthy)';
}
```
#### **2. Sentinel-Based Status Updates (Real-time)**
**File**: [app/Jobs/PushServerUpdateJob.php](mdc:app/Jobs/PushServerUpdateJob.php)
**Method**: `aggregateMultiContainerStatuses()` (lines 269-298)
**Trigger**: Sentinel push updates from remote servers
**Frequency**: Every ~30 seconds (real-time)
**Status Aggregation Logic**:
```php
// ⚠️ MUST match GetContainersStatus logic
$hasRunning = false;
$hasUnhealthy = false;
$hasUnknown = false; // ⚠️ CRITICAL: Added to fix bug
foreach ($relevantStatuses as $status) {
if (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) $hasUnhealthy = true;
if (str($status)->contains('unknown')) $hasUnknown = true; // ⚠️ CRITICAL
}
}
// Priority: unhealthy > unknown > healthy
if ($hasRunning) {
if ($hasUnhealthy) $aggregatedStatus = 'running (unhealthy)';
elseif ($hasUnknown) $aggregatedStatus = 'running (unknown)';
else $aggregatedStatus = 'running (healthy)';
}
```
#### **3. Multi-Server Status Aggregation**
**File**: [app/Actions/Shared/ComplexStatusCheck.php](mdc:app/Actions/Shared/ComplexStatusCheck.php)
**Method**: `resource()` (lines 48-210)
**Purpose**: Aggregates status across multiple servers for applications
**Used by**: Applications with multiple destinations
**Key Features**:
- Aggregates statuses from main + additional servers
- Handles excluded containers (`:excluded` suffix)
- Calculates overall application health from all containers
**Status Format with Excluded Containers**:
```php
// When all containers excluded from health checks:
return 'running:unhealthy:excluded'; // Container running but unhealthy, monitoring disabled
return 'running:unknown:excluded'; // Container running, health unknown, monitoring disabled
return 'running:healthy:excluded'; // Container running and healthy, monitoring disabled
return 'degraded:excluded'; // Some containers down, monitoring disabled
return 'exited:excluded'; // All containers stopped, monitoring disabled
```
#### **4. Service-Level Status Aggregation**
**File**: [app/Models/Service.php](mdc:app/Models/Service.php)
**Method**: `complexStatus()` (lines 176-288)
**Purpose**: Aggregates status for multi-container services
**Used by**: Docker Compose services
**Status Calculation**:
```php
// Aggregates status from all service applications and databases
// Handles excluded containers separately
// Returns status with :excluded suffix when all containers excluded
if (!$hasNonExcluded && $complexStatus === null && $complexHealth === null) {
// All services excluded - calculate from excluded containers
return "{$excludedStatus}:excluded";
}
```
### **Status Flow Diagram**
```
┌─────────────────────────────────────────────────────────────┐
│ Container Status Sources │
└─────────────────────────────────────────────────────────────┘
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌─────────────────┐ ┌──────────────┐
│ SSH-Based │ │ Sentinel-Based │ │ Multi-Server │
│ (Scheduled) │ │ (Real-time) │ │ Aggregation │
├───────────────┤ ├─────────────────┤ ├──────────────┤
│ ServerCheck │ │ PushServerUp- │ │ ComplexStatus│
│ Job │ │ dateJob │ │ Check │
│ │ │ │ │ │
│ Every ~1min │ │ Every ~30sec │ │ On demand │
└───────┬───────┘ └────────┬────────┘ └──────┬───────┘
│ │ │
└────────────────────┼────────────────────┘
┌───────────────────────┐
│ Application/Service │
│ Status Property │
└───────────────────────┘
┌───────────────────────┐
│ UI Display (Livewire) │
└───────────────────────┘
```
### **Status Priority System**
All status aggregation locations **MUST** follow the same priority:
**For Running Containers**:
1. **unhealthy** - Container has failing health checks
2. **unknown** - Container health status cannot be determined
3. **healthy** - Container is healthy
**For Non-Running States**:
1. **restarting**`degraded (unhealthy)`
2. **running + exited**`degraded (unhealthy)`
3. **dead/removing**`degraded (unhealthy)`
4. **paused**`paused`
5. **created/starting**`starting`
6. **exited**`exited (unhealthy)`
### **Excluded Containers**
When containers have `exclude_from_hc: true` flag or `restart: no`:
**Behavior**:
- Status is still calculated from container state
- `:excluded` suffix is appended to indicate monitoring disabled
- UI shows "(Monitoring Disabled)" badge
- Action buttons respect the actual container state
**Format**: `{actual-status}:excluded`
**Examples**: `running:unknown:excluded`, `degraded:excluded`, `exited:excluded`
**All-Excluded Scenario**:
When ALL containers are excluded from health checks:
- All three status update paths (PushServerUpdateJob, GetContainersStatus, ComplexStatusCheck) **MUST** calculate status from excluded containers
- Status is returned with `:excluded` suffix (e.g., `running:healthy:excluded`)
- **NEVER** skip status updates - always calculate from excluded containers
- This ensures consistent status regardless of which update mechanism runs
- Shared logic is in `app/Traits/CalculatesExcludedStatus.php`
### **Important Notes for Developers**
**Container Status Aggregation Service**:
The container status aggregation logic is centralized in `App\Services\ContainerStatusAggregator`.
**Status Format Standard**:
- **Backend/Storage**: Colon format (`running:healthy`, `degraded:unhealthy`)
- **UI/Display**: Transform to human format (`Running (Healthy)`, `Degraded (Unhealthy)`)
1. **Using the ContainerStatusAggregator Service**:
- Import `App\Services\ContainerStatusAggregator` in any class needing status aggregation
- Two methods available:
- `aggregateFromStrings(Collection $statusStrings, int $maxRestartCount = 0)` - For pre-formatted status strings
- `aggregateFromContainers(Collection $containers, int $maxRestartCount = 0)` - For raw Docker container objects
- Returns colon format: `running:healthy`, `degraded:unhealthy`, etc.
- Automatically handles crash loop detection via `$maxRestartCount` parameter
2. **State Machine Priority** (handled by service):
- Restarting → `degraded:unhealthy` (highest priority)
- Crash loop (exited with restarts) → `degraded:unhealthy`
- Mixed state (running + exited) → `degraded:unhealthy`
- Running → `running:unhealthy` / `running:unknown` / `running:healthy`
- Dead/Removing → `degraded:unhealthy`
- Paused → `paused:unknown`
- Starting/Created → `starting:unknown`
- Exited → `exited:unhealthy` (lowest priority)
3. **Test both update paths**:
- Run unit tests: `./vendor/bin/pest tests/Unit/ContainerStatusAggregatorTest.php`
- Run integration tests: `./vendor/bin/pest tests/Unit/`
- Test SSH updates (manual refresh)
- Test Sentinel updates (wait 30 seconds)
4. **Handle excluded containers**:
- All containers excluded (`exclude_from_hc: true`) - Use `CalculatesExcludedStatus` trait
- Mixed excluded/non-excluded containers - Filter then use `ContainerStatusAggregator`
- Containers with `restart: no` - Treated same as `exclude_from_hc: true`
5. **Use shared trait for excluded containers**:
- Import `App\Traits\CalculatesExcludedStatus` in status calculation classes
- Use `getExcludedContainersFromDockerCompose()` to parse exclusions
- Use `calculateExcludedStatus()` for full Docker inspect objects (ComplexStatusCheck)
- Use `calculateExcludedStatusFromStrings()` for status strings (PushServerUpdateJob, GetContainersStatus)
### **Related Tests**
- **[tests/Unit/ContainerStatusAggregatorTest.php](mdc:tests/Unit/ContainerStatusAggregatorTest.php)**: Core state machine logic (42 comprehensive tests)
- **[tests/Unit/ContainerHealthStatusTest.php](mdc:tests/Unit/ContainerHealthStatusTest.php)**: Health status aggregation integration
- **[tests/Unit/PushServerUpdateJobStatusAggregationTest.php](mdc:tests/Unit/PushServerUpdateJobStatusAggregationTest.php)**: Sentinel update logic
- **[tests/Unit/ExcludeFromHealthCheckTest.php](mdc:tests/Unit/ExcludeFromHealthCheckTest.php)**: Excluded container handling
### **Common Bugs to Avoid**
**Prevented by ContainerStatusAggregator Service**:
- ❌ **Old Bug**: Forgetting to track `$hasUnknown` flag → ✅ Now centralized in service
- ❌ **Old Bug**: Inconsistent priority across paths → ✅ Single source of truth
- ❌ **Old Bug**: Forgetting to update all 4 locations → ✅ Only one location to update
**Still Relevant**:
**Bug**: Forgetting to filter excluded containers before aggregation
**Fix**: Always use `CalculatesExcludedStatus` trait to filter before calling `ContainerStatusAggregator`
**Bug**: Not passing `$maxRestartCount` for crash loop detection
**Fix**: Calculate max restart count from containers and pass to `aggregateFromStrings()`/`aggregateFromContainers()`
**Bug**: Not handling excluded containers with `:excluded` suffix
**Fix**: Check for `:excluded` suffix in UI logic and button visibility

View file

@ -1,666 +0,0 @@
# Coolify Deployment Architecture
## Deployment Philosophy
Coolify orchestrates **Docker-based deployments** across multiple servers with automated configuration generation, zero-downtime deployments, and comprehensive monitoring.
## Core Deployment Components
### Deployment Models
- **[Application.php](mdc:app/Models/Application.php)** - Main application entity with deployment configurations
- **[ApplicationDeploymentQueue.php](mdc:app/Models/ApplicationDeploymentQueue.php)** - Deployment job orchestration
- **[Service.php](mdc:app/Models/Service.php)** - Multi-container service definitions
- **[Server.php](mdc:app/Models/Server.php)** - Target deployment infrastructure
### Infrastructure Management
- **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - SSH key management for secure server access
- **[StandaloneDocker.php](mdc:app/Models/StandaloneDocker.php)** - Single container deployments
- **[SwarmDocker.php](mdc:app/Models/SwarmDocker.php)** - Docker Swarm orchestration
## Deployment Workflow
### 1. Source Code Integration
```
Git Repository → Webhook → Coolify → Build & Deploy
```
#### Source Control Models
- **[GithubApp.php](mdc:app/Models/GithubApp.php)** - GitHub integration and webhooks
- **[GitlabApp.php](mdc:app/Models/GitlabApp.php)** - GitLab CI/CD integration
#### Deployment Triggers
- **Git push** to configured branches
- **Manual deployment** via UI
- **Scheduled deployments** via cron
- **API-triggered** deployments
### 2. Build Process
```
Source Code → Docker Build → Image Registry → Deployment
```
#### Build Configurations
- **Dockerfile detection** and custom Dockerfile support
- **Buildpack integration** for framework detection
- **Multi-stage builds** for optimization
- **Cache layer** management for faster builds
### 3. Deployment Orchestration
```
Queue Job → Configuration Generation → Container Deployment → Health Checks
```
## Deployment Actions
### Location: [app/Actions/](mdc:app/Actions)
#### Application Deployment Actions
- **Application/** - Core application deployment logic
- **Docker/** - Docker container management
- **Service/** - Multi-container service orchestration
- **Proxy/** - Reverse proxy configuration
#### Database Actions
- **Database/** - Database deployment and management
- Automated backup scheduling
- Connection management and health checks
#### Server Management Actions
- **Server/** - Server provisioning and configuration
- SSH connection establishment
- Docker daemon management
## Configuration Generation
### Dynamic Configuration
- **[ConfigurationGenerator.php](mdc:app/Services/ConfigurationGenerator.php)** - Generates deployment configurations
- **[ConfigurationRepository.php](mdc:app/Services/ConfigurationRepository.php)** - Configuration management
### Generated Configurations
#### Docker Compose Files
```yaml
# Generated docker-compose.yml structure
version: '3.8'
services:
app:
image: ${APP_IMAGE}
environment:
- ${ENV_VARIABLES}
labels:
- traefik.enable=true
- traefik.http.routers.app.rule=Host(`${FQDN}`)
volumes:
- ${VOLUME_MAPPINGS}
networks:
- coolify
```
#### Nginx Configurations
- **Reverse proxy** setup
- **SSL termination** with automatic certificates
- **Load balancing** for multiple instances
- **Custom headers** and routing rules
## Container Orchestration
### Docker Integration
- **[DockerImageParser.php](mdc:app/Services/DockerImageParser.php)** - Parse and validate Docker images
- **Container lifecycle** management
- **Resource allocation** and limits
- **Network isolation** and communication
### Volume Management
- **[LocalFileVolume.php](mdc:app/Models/LocalFileVolume.php)** - Persistent file storage
- **[LocalPersistentVolume.php](mdc:app/Models/LocalPersistentVolume.php)** - Data persistence
- **Backup integration** for volume data
### Network Configuration
- **Custom Docker networks** for isolation
- **Service discovery** between containers
- **Port mapping** and exposure
- **SSL/TLS termination**
## Environment Management
### Environment Isolation
- **[Environment.php](mdc:app/Models/Environment.php)** - Development, staging, production environments
- **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** - Application-specific variables
- **[SharedEnvironmentVariable.php](mdc:app/Models/SharedEnvironmentVariable.php)** - Cross-application variables
### Configuration Hierarchy
```
Instance Settings → Server Settings → Project Settings → Application Settings
```
## Preview Environments
### Git-Based Previews
- **[ApplicationPreview.php](mdc:app/Models/ApplicationPreview.php)** - Preview environment management
- **Automatic PR/MR previews** for feature branches
- **Isolated environments** for testing
- **Automatic cleanup** after merge/close
### Preview Workflow
```
Feature Branch → Auto-Deploy → Preview URL → Review → Cleanup
```
## SSL & Security
### Certificate Management
- **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate automation
- **Let's Encrypt** integration for free certificates
- **Custom certificate** upload support
- **Automatic renewal** and monitoring
### Security Patterns
- **Private Docker networks** for container isolation
- **SSH key-based** server authentication
- **Environment variable** encryption
- **Access control** via team permissions
## Backup & Recovery
### Database Backups
- **[ScheduledDatabaseBackup.php](mdc:app/Models/ScheduledDatabaseBackup.php)** - Automated database backups
- **[ScheduledDatabaseBackupExecution.php](mdc:app/Models/ScheduledDatabaseBackupExecution.php)** - Backup execution tracking
- **S3-compatible storage** for backup destinations
### Application Backups
- **Volume snapshots** for persistent data
- **Configuration export** for disaster recovery
- **Cross-region replication** for high availability
## Monitoring & Logging
### Real-Time Monitoring
- **[ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php)** - Live deployment monitoring
- **WebSocket-based** log streaming
- **Container health checks** and alerts
- **Resource usage** tracking
### Deployment Logs
- **Build process** logging
- **Container startup** logs
- **Application runtime** logs
- **Error tracking** and alerting
## Queue System
### Background Jobs
Location: [app/Jobs/](mdc:app/Jobs)
- **Deployment jobs** for async processing
- **Server monitoring** jobs
- **Backup scheduling** jobs
- **Notification delivery** jobs
### Queue Processing
- **Redis-backed** job queues
- **Laravel Horizon** for queue monitoring
- **Failed job** retry mechanisms
- **Queue worker** auto-scaling
## Multi-Server Deployment
### Server Types
- **Standalone servers** - Single Docker host
- **Docker Swarm** - Multi-node orchestration
- **Remote servers** - SSH-based deployment
- **Local development** - Docker Desktop integration
### Load Balancing
- **Traefik integration** for automatic load balancing
- **Health check** based routing
- **Blue-green deployments** for zero downtime
- **Rolling updates** with configurable strategies
## Deployment Strategies
### Zero-Downtime Deployment
```
Old Container → New Container Build → Health Check → Traffic Switch → Old Container Cleanup
```
### Blue-Green Deployment
- **Parallel environments** for safe deployments
- **Instant rollback** capability
- **Database migration** handling
- **Configuration synchronization**
### Rolling Updates
- **Gradual instance** replacement
- **Configurable update** strategy
- **Automatic rollback** on failure
- **Health check** validation
## API Integration
### Deployment API
Routes: [routes/api.php](mdc:routes/api.php)
- **RESTful endpoints** for deployment management
- **Webhook receivers** for CI/CD integration
- **Status reporting** endpoints
- **Deployment triggering** via API
### Authentication
- **Laravel Sanctum** API tokens
- **Team-based** access control
- **Rate limiting** for API calls
- **Audit logging** for API usage
## Error Handling & Recovery
### Deployment Failure Recovery
- **Automatic rollback** on deployment failure
- **Health check** failure handling
- **Container crash** recovery
- **Resource exhaustion** protection
### Monitoring & Alerting
- **Failed deployment** notifications
- **Resource threshold** alerts
- **SSL certificate** expiry warnings
- **Backup failure** notifications
## Performance Optimization
### Build Optimization
- **Docker layer** caching
- **Multi-stage builds** for smaller images
- **Build artifact** reuse
- **Parallel build** processing
### Docker Build Cache Preservation
Coolify provides settings to preserve Docker build cache across deployments, addressing cache invalidation issues.
#### The Problem
By default, Coolify injects `ARG` statements into user Dockerfiles for build-time variables. This breaks Docker's cache mechanism because:
1. **ARG declarations invalidate cache** - Any change in ARG values after the `ARG` instruction invalidates all subsequent layers
2. **SOURCE_COMMIT changes every commit** - Causes full rebuilds even when code changes are minimal
#### Application Settings
Two toggles in **Advanced Settings** control this behavior:
| Setting | Default | Description |
|---------|---------|-------------|
| `inject_build_args_to_dockerfile` | `true` | Controls whether Coolify adds `ARG` statements to Dockerfile |
| `include_source_commit_in_build` | `false` | Controls whether `SOURCE_COMMIT` is included in build context |
**Database columns:** `application_settings.inject_build_args_to_dockerfile`, `application_settings.include_source_commit_in_build`
#### Buildpack Coverage
| Build Pack | ARG Injection | Method |
|------------|---------------|--------|
| **Dockerfile** | ✅ Yes | `add_build_env_variables_to_dockerfile()` |
| **Docker Compose** (with `build:`) | ✅ Yes | `modify_dockerfiles_for_compose()` |
| **PR Deployments** (Dockerfile only) | ✅ Yes | `add_build_env_variables_to_dockerfile()` |
| **Nixpacks** | ❌ No | Generates its own Dockerfile internally |
| **Static** | ❌ No | Uses internal Dockerfile |
| **Docker Image** | ❌ No | No build phase |
#### How It Works
**When `inject_build_args_to_dockerfile` is enabled (default):**
```dockerfile
# Coolify modifies your Dockerfile to add:
FROM node:20
ARG MY_VAR=value
ARG COOLIFY_URL=...
ARG SOURCE_COMMIT=abc123 # (if include_source_commit_in_build is true)
# ... rest of your Dockerfile
```
**When `inject_build_args_to_dockerfile` is disabled:**
- Coolify does NOT modify the Dockerfile
- `--build-arg` flags are still passed (harmless without matching `ARG` in Dockerfile)
- User must manually add `ARG` statements for any build-time variables they need
**When `include_source_commit_in_build` is disabled (default):**
- `SOURCE_COMMIT` is NOT included in build-time variables
- `SOURCE_COMMIT` is still available at **runtime** (in container environment)
- Docker cache preserved across different commits
#### Recommended Configuration
| Use Case | inject_build_args | include_source_commit | Cache Behavior |
|----------|-------------------|----------------------|----------------|
| Maximum cache preservation | `false` | `false` | Best cache retention |
| Need build-time vars, no commit | `true` | `false` | Cache breaks on var changes |
| Need commit at build-time | `true` | `true` | Cache breaks every commit |
| Manual ARG management | `false` | `true` | Cache preserved (no ARG in Dockerfile) |
#### Implementation Details
**Files:**
- `app/Jobs/ApplicationDeploymentJob.php`:
- `set_coolify_variables()` - Conditionally adds SOURCE_COMMIT to Docker build context based on `include_source_commit_in_build` setting
- `generate_coolify_env_variables(bool $forBuildTime)` - Distinguishes build-time vs. runtime variables; excludes cache-busting variables like SOURCE_COMMIT from build context unless explicitly enabled
- `generate_env_variables()` - Populates `$this->env_args` with build-time ARG values, respecting `include_source_commit_in_build` toggle
- `add_build_env_variables_to_dockerfile()` - Injects ARG statements into Dockerfiles after FROM instructions; skips injection if `inject_build_args_to_dockerfile` is disabled
- `modify_dockerfiles_for_compose()` - Applies ARG injection to Docker Compose service Dockerfiles; respects `inject_build_args_to_dockerfile` toggle
- `app/Models/ApplicationSetting.php` - Defines `inject_build_args_to_dockerfile` and `include_source_commit_in_build` boolean properties
- `app/Livewire/Project/Application/Advanced.php` - Livewire component providing UI bindings for cache preservation toggles
- `resources/views/livewire/project/application/advanced.blade.php` - Checkbox UI elements for user-facing toggles
**Note:** Docker Compose services without a `build:` section (image-only) are automatically skipped.
### Runtime Optimization
- **Container resource** limits
- **Auto-scaling** based on metrics
- **Connection pooling** for databases
- **CDN integration** for static assets
## Compliance & Governance
### Audit Trail
- **Deployment history** tracking
- **Configuration changes** logging
- **User action** auditing
- **Resource access** monitoring
### Backup Compliance
- **Retention policies** for backups
- **Encryption at rest** for sensitive data
- **Cross-region** backup replication
- **Recovery testing** automation
## Integration Patterns
### CI/CD Integration
- **GitHub Actions** compatibility
- **GitLab CI** pipeline integration
- **Custom webhook** endpoints
- **Build status** reporting
### External Services
- **S3-compatible** storage integration
- **External database** connections
- **Third-party monitoring** tools
- **Custom notification** channels
---
## Coolify Docker Compose Extensions
Coolify extends standard Docker Compose with custom fields (often called "magic fields") that provide Coolify-specific functionality. These extensions are processed during deployment and stripped before sending the final compose file to Docker, maintaining full compatibility with Docker's compose specification.
### Overview
**Why Custom Fields?**
- Enable Coolify-specific features without breaking Docker Compose compatibility
- Simplify configuration by embedding content directly in compose files
- Allow fine-grained control over health check monitoring
- Reduce external file dependencies
**Processing Flow:**
1. User defines compose file with custom fields
2. Coolify parses and processes custom fields (creates files, stores settings)
3. Custom fields are stripped from final compose sent to Docker
4. Docker receives standard, valid compose file
### Service-Level Extensions
#### `exclude_from_hc`
**Type:** Boolean
**Default:** `false`
**Purpose:** Exclude specific services from health check monitoring while still showing their status
**Example Usage:**
```yaml
services:
watchtower:
image: containrrr/watchtower
exclude_from_hc: true # Don't monitor this service's health
backup:
image: postgres:16
exclude_from_hc: true # Backup containers don't need monitoring
restart: always
```
**Behavior:**
- Container status is still calculated from Docker state (running, exited, etc.)
- Status displays with `:excluded` suffix (e.g., `running:healthy:excluded`)
- UI shows "Monitoring Disabled" indicator
- Functionally equivalent to `restart: no` for health check purposes
- See [Container Status with All Excluded](application-architecture.md#container-status-when-all-containers-excluded) for detailed status handling
**Use Cases:**
- Sidecar containers (watchtower, log collectors)
- Backup/maintenance containers
- One-time initialization containers
- Containers that intentionally restart frequently
**Implementation:**
- Parsed: `bootstrap/helpers/parsers.php`
- Status logic: `app/Traits/CalculatesExcludedStatus.php`
- Validation: `tests/Unit/ExcludeFromHealthCheckTest.php`
### Volume-Level Extensions
Volume extensions only work with **long syntax** (array/object format), not short syntax (string format).
#### `content`
**Type:** String (supports multiline with `|` or `>`)
**Purpose:** Embed file content directly in compose file for automatic creation during deployment
**Example Usage:**
```yaml
services:
app:
image: node:20
volumes:
# Inline entrypoint script
- type: bind
source: ./entrypoint.sh
target: /app/entrypoint.sh
content: |
#!/bin/sh
set -e
echo "Starting application..."
npm run migrate
exec "$@"
# Configuration file with environment variables
- type: bind
source: ./config.xml
target: /etc/app/config.xml
content: |
<?xml version='1.0' encoding='UTF-8'?>
<config>
<database>
<host>${DB_HOST}</host>
<port>${DB_PORT}</port>
</database>
</config>
```
**Behavior:**
- Content is written to the host at `source` path before container starts
- File is created with mode `644` (readable by all, writable by owner)
- Environment variables in content are interpolated at deployment time
- Content is stored in `LocalFileVolume` model (encrypted at rest)
- Original `docker_compose_raw` retains content for editing
**Use Cases:**
- Entrypoint scripts
- Configuration files
- Environment-specific settings
- Small initialization scripts
- Templates that require dynamic content
**Limitations:**
- Not suitable for large files (use git repo or external storage instead)
- Binary files not supported
- Changes require redeployment
**Real-World Examples:**
- `templates/compose/traccar.yaml` - XML configuration file
- `templates/compose/supabase.yaml` - Multiple config files
- `templates/compose/chaskiq.yaml` - Entrypoint script
**Implementation:**
- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `content` field extraction)
- Storage: `app/Models/LocalFileVolume.php`
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
#### `is_directory` / `isDirectory`
**Type:** Boolean
**Default:** `true` (if neither `content` nor explicit flag provided)
**Purpose:** Indicate whether bind mount source should be created as directory or file
**Example Usage:**
```yaml
services:
app:
volumes:
# Explicit file
- type: bind
source: ./config.json
target: /app/config.json
is_directory: false # Create as file
# Explicit directory
- type: bind
source: ./logs
target: /var/log/app
is_directory: true # Create as directory
# Auto-detected as file (has content)
- type: bind
source: ./script.sh
target: /entrypoint.sh
content: |
#!/bin/sh
echo "Hello"
# is_directory: false implied by content presence
```
**Behavior:**
- If `is_directory: true` → Creates directory with `mkdir -p`
- If `is_directory: false` → Creates empty file with `touch`
- If `content` provided → Implies `is_directory: false`
- If neither specified → Defaults to `true` (directory)
**Naming Conventions:**
- `is_directory` (snake_case) - **Preferred**, consistent with PHP/Laravel conventions
- `isDirectory` (camelCase) - **Legacy support**, both work identically
**Use Cases:**
- Disambiguating files vs directories when no content provided
- Ensuring correct bind mount type for Docker
- Pre-creating mount points before container starts
**Implementation:**
- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `is_directory`/`isDirectory` field extraction)
- Storage: `app/Models/LocalFileVolume.php` (`is_directory` column)
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
### Custom Field Stripping
**Function:** `stripCoolifyCustomFields()` in `bootstrap/helpers/docker.php`
All custom fields are removed before the compose file is sent to Docker. This happens in two contexts:
**1. Validation (User-Triggered)**
```php
// In validateComposeFile() - Edit Docker Compose modal
$yaml_compose = Yaml::parse($compose);
$yaml_compose = stripCoolifyCustomFields($yaml_compose); // Strip custom fields
// Send to docker compose config for validation
```
**2. Deployment (Automatic)**
```php
// In Service::parse() - During deployment
$docker_compose = parseCompose($docker_compose_raw);
// Custom fields are processed and then stripped
// Final compose sent to Docker has no custom fields
```
**What Gets Stripped:**
- Service-level: `exclude_from_hc`
- Volume-level: `content`, `isDirectory`, `is_directory`
**What's Preserved:**
- All standard Docker Compose fields
- Environment variables
- Standard volume definitions (after custom fields removed)
### Important Notes
#### Long vs Short Volume Syntax
**✅ Long Syntax (Works with Custom Fields):**
```yaml
volumes:
- type: bind
source: ./data
target: /app/data
content: "Hello" # ✅ Custom fields work here
```
**❌ Short Syntax (Custom Fields Ignored):**
```yaml
volumes:
- "./data:/app/data" # ❌ Cannot add custom fields to strings
```
#### Docker Compose Compatibility
Custom fields are **Coolify-specific** and won't work with standalone `docker compose` CLI:
```bash
# ❌ Won't work - Docker doesn't recognize custom fields
docker compose -f compose.yaml up
# ✅ Works - Use Coolify's deployment (strips custom fields first)
# Deploy through Coolify UI or API
```
#### Editing Custom Fields
When editing in "Edit Docker Compose" modal:
- Custom fields are preserved in the editor
- "Validate" button strips them temporarily for Docker validation
- "Save" button preserves them in `docker_compose_raw`
- They're processed again on next deployment
### Template Examples
See these templates for real-world usage:
**Service Exclusions:**
- `templates/compose/budibase.yaml` - Excludes watchtower from monitoring
- `templates/compose/pgbackweb.yaml` - Excludes backup service
- `templates/compose/elasticsearch-with-kibana.yaml` - Excludes elasticsearch
**Inline Content:**
- `templates/compose/traccar.yaml` - XML configuration (multiline)
- `templates/compose/supabase.yaml` - Multiple config files
- `templates/compose/searxng.yaml` - Settings file
- `templates/compose/invoice-ninja.yaml` - Nginx config
**Directory Flags:**
- `templates/compose/paperless.yaml` - Explicit directory creation
### Testing
**Unit Tests:**
- `tests/Unit/StripCoolifyCustomFieldsTest.php` - Custom field stripping logic
- `tests/Unit/ExcludeFromHealthCheckTest.php` - Health check exclusion behavior
- `tests/Unit/ContainerStatusAggregatorTest.php` - Status aggregation with exclusions
**Test Coverage:**
- ✅ All custom fields (exclude_from_hc, content, isDirectory, is_directory)
- ✅ Multiline content (YAML `|` syntax)
- ✅ Short vs long volume syntax
- ✅ Field stripping without data loss
- ✅ Standard Docker Compose field preservation

View file

@ -1,156 +0,0 @@
# Coolify Project Overview
## What is Coolify?
Coolify is an **open-source & self-hostable alternative to Heroku / Netlify / Vercel**. It's a comprehensive deployment platform that helps you manage servers, applications, and databases on your own hardware with just an SSH connection.
## Core Mission
**"Imagine having the ease of a cloud but with your own servers. That is Coolify."**
- **No vendor lock-in** - All configurations saved to your servers
- **Self-hosted** - Complete control over your infrastructure
- **SSH-only requirement** - Works with VPS, Bare Metal, Raspberry PIs, anything
- **Docker-first** - Container-based deployment architecture
## Key Features
### 🚀 **Application Deployment**
- Git-based deployments (GitHub, GitLab, Bitbucket, Gitea)
- Docker & Docker Compose support
- Preview deployments for pull requests
- Zero-downtime deployments
- Build cache optimization
### 🖥️ **Server Management**
- Multi-server orchestration
- Real-time monitoring and logs
- SSH key management
- Proxy configuration (Traefik/Caddy)
- Resource usage tracking
### 🗄️ **Database Management**
- PostgreSQL, MySQL, MariaDB, MongoDB
- Redis, KeyDB, Dragonfly, ClickHouse
- Automated backups with S3 integration
- Database clustering support
### 🔧 **Infrastructure as Code**
- Docker Compose generation
- Environment variable management
- SSL certificate automation
- Custom domain configuration
### 👥 **Team Collaboration**
- Multi-tenant team organization
- Role-based access control
- Project and environment isolation
- Team-wide resource sharing
### 📊 **Monitoring & Observability**
- Real-time application logs
- Server resource monitoring
- Deployment status tracking
- Webhook integrations
- Notification systems (Email, Discord, Slack, Telegram)
## Target Users
### **DevOps Engineers**
- Infrastructure automation
- Multi-environment management
- CI/CD pipeline integration
### **Developers**
- Easy application deployment
- Development environment provisioning
- Preview deployments for testing
### **Small to Medium Businesses**
- Cost-effective Heroku alternative
- Self-hosted control and privacy
- Scalable infrastructure management
### **Agencies & Consultants**
- Client project isolation
- Multi-tenant management
- White-label deployment solutions
## Business Model
### **Open Source (Free)**
- Complete feature set
- Self-hosted deployment
- Community support
- No feature restrictions
### **Cloud Version (Paid)**
- Managed Coolify instance
- High availability
- Premium support
- Email notifications included
- Same price as self-hosted server (~$4-5/month)
## Architecture Philosophy
### **Server-Side First**
- Laravel backend with Livewire frontend
- Minimal JavaScript footprint
- Real-time updates via WebSockets
- Progressive enhancement approach
### **Docker-Native**
- Container-first deployment strategy
- Docker Compose orchestration
- Image building and registry integration
- Volume and network management
### **Security-Focused**
- SSH-based server communication
- Environment variable encryption
- Team-based access isolation
- Audit logging and activity tracking
## Project Structure
```
coolify/
├── app/ # Laravel application core
│ ├── Models/ # Domain models (Application, Server, Service)
│ ├── Livewire/ # Frontend components
│ ├── Actions/ # Business logic actions
│ └── Jobs/ # Background job processing
├── resources/ # Frontend assets and views
├── database/ # Migrations and seeders
├── docker/ # Docker configuration
├── scripts/ # Installation and utility scripts
└── tests/ # Test suites (Pest, Dusk)
```
## Key Differentiators
### **vs. Heroku**
- ✅ Self-hosted (no vendor lock-in)
- ✅ Multi-server support
- ✅ No usage-based pricing
- ✅ Full infrastructure control
### **vs. Vercel/Netlify**
- ✅ Backend application support
- ✅ Database management included
- ✅ Multi-environment workflows
- ✅ Custom server infrastructure
### **vs. Docker Swarm/Kubernetes**
- ✅ User-friendly web interface
- ✅ Git-based deployment workflows
- ✅ Integrated monitoring and logging
- ✅ No complex YAML configuration
## Development Principles
- **Simplicity over complexity**
- **Convention over configuration**
- **Security by default**
- **Developer experience focused**
- **Community-driven development**

View file

@ -1,245 +0,0 @@
# Coolify Technology Stack
Complete technology stack, dependencies, and infrastructure components.
## Backend Framework
### **Laravel 12.4.1** (PHP Framework)
- **Purpose**: Core application framework
- **Key Features**:
- Eloquent ORM for database interactions
- Artisan CLI for development tasks
- Queue system for background jobs
- Event-driven architecture
### **PHP 8.4.7**
- **Requirement**: `^8.4` in composer.json
- **Features Used**:
- Typed properties and return types
- Attributes for validation and configuration
- Match expressions
- Constructor property promotion
## Frontend Stack
### **Livewire 3.5.20** (Primary Frontend Framework)
- **Purpose**: Server-side rendering with reactive components
- **Location**: `app/Livewire/`
- **Key Components**:
- Dashboard - Main interface
- ActivityMonitor - Real-time monitoring
- MonacoEditor - Code editor
### **Alpine.js** (Client-Side Interactivity)
- **Purpose**: Lightweight JavaScript for DOM manipulation
- **Integration**: Works seamlessly with Livewire components
- **Usage**: Declarative directives in Blade templates
### **Tailwind CSS 4.1.4** (Styling Framework)
- **Configuration**: `postcss.config.cjs`
- **Extensions**:
- `@tailwindcss/forms` - Form styling
- `@tailwindcss/typography` - Content typography
- `tailwind-scrollbar` - Custom scrollbars
### **Vue.js 3.5.13** (Component Framework)
- **Purpose**: Enhanced interactive components
- **Integration**: Used alongside Livewire for complex UI
- **Build Tool**: Vite with Vue plugin
## Database & Caching
### **PostgreSQL 15** (Primary Database)
- **Purpose**: Main application data storage
- **Features**: JSONB support, advanced indexing
- **Models**: `app/Models/`
### **Redis 7** (Caching & Real-time)
- **Purpose**:
- Session storage
- Queue backend
- Real-time data caching
- WebSocket session management
### **Supported Databases** (For User Applications)
- **PostgreSQL**: StandalonePostgresql
- **MySQL**: StandaloneMysql
- **MariaDB**: StandaloneMariadb
- **MongoDB**: StandaloneMongodb
- **Redis**: StandaloneRedis
- **KeyDB**: StandaloneKeydb
- **Dragonfly**: StandaloneDragonfly
- **ClickHouse**: StandaloneClickhouse
## Authentication & Security
### **Laravel Sanctum 4.0.8**
- **Purpose**: API token authentication
- **Usage**: Secure API access for external integrations
### **Laravel Fortify 1.25.4**
- **Purpose**: Authentication scaffolding
- **Features**: Login, registration, password reset
### **Laravel Socialite 5.18.0**
- **Purpose**: OAuth provider integration
- **Providers**:
- GitHub, GitLab, Google
- Microsoft Azure, Authentik, Discord, Clerk
- Custom OAuth implementations
## Background Processing
### **Laravel Horizon 5.30.3**
- **Purpose**: Queue monitoring and management
- **Features**: Real-time queue metrics, failed job handling
### **Queue System**
- **Backend**: Redis-based queues
- **Jobs**: `app/Jobs/`
- **Processing**: Background deployment and monitoring tasks
## Development Tools
### **Build Tools**
- **Vite 6.2.6**: Modern build tool and dev server
- **Laravel Vite Plugin**: Laravel integration
- **PostCSS**: CSS processing pipeline
### **Code Quality**
- **Laravel Pint**: PHP code style fixer
- **Rector**: PHP automated refactoring
- **PHPStan**: Static analysis tool
### **Testing Framework**
- **Pest 3.8.0**: Modern PHP testing framework
- **Laravel Dusk**: Browser automation testing
- **PHPUnit**: Unit testing foundation
## External Integrations
### **Git Providers**
- **GitHub**: Repository integration and webhooks
- **GitLab**: Self-hosted and cloud GitLab support
- **Bitbucket**: Atlassian integration
- **Gitea**: Self-hosted Git service
### **Cloud Storage**
- **AWS S3**: league/flysystem-aws-s3-v3
- **SFTP**: league/flysystem-sftp-v3
- **Local Storage**: File system integration
### **Notification Services**
- **Email**: resend/resend-laravel
- **Discord**: Custom webhook integration
- **Slack**: Webhook notifications
- **Telegram**: Bot API integration
- **Pushover**: Push notifications
### **Monitoring & Logging**
- **Sentry**: sentry/sentry-laravel - Error tracking
- **Laravel Ray**: spatie/laravel-ray - Debug tool
- **Activity Log**: spatie/laravel-activitylog
## DevOps & Infrastructure
### **Docker & Containerization**
- **Docker**: Container runtime
- **Docker Compose**: Multi-container orchestration
- **Docker Swarm**: Container clustering (optional)
### **Web Servers & Proxies**
- **Nginx**: Primary web server
- **Traefik**: Reverse proxy and load balancer
- **Caddy**: Alternative reverse proxy
### **Process Management**
- **S6 Overlay**: Process supervisor
- **Supervisor**: Alternative process manager
### **SSL/TLS**
- **Let's Encrypt**: Automatic SSL certificates
- **Custom Certificates**: Manual SSL management
## Terminal & Code Editing
### **XTerm.js 5.5.0**
- **Purpose**: Web-based terminal emulator
- **Features**: SSH session management, real-time command execution
- **Addons**: Fit addon for responsive terminals
### **Monaco Editor**
- **Purpose**: Code editor component
- **Features**: Syntax highlighting, auto-completion
- **Integration**: Environment variable editing, configuration files
## API & Documentation
### **OpenAPI/Swagger**
- **Documentation**: openapi.json (373KB)
- **Generator**: zircote/swagger-php
- **API Routes**: `routes/api.php`
### **WebSocket Communication**
- **Laravel Echo**: Real-time event broadcasting
- **Pusher**: WebSocket service integration
- **Soketi**: Self-hosted WebSocket server
## Package Management
### **PHP Dependencies** (composer.json)
```json
{
"require": {
"php": "^8.4",
"laravel/framework": "12.4.1",
"livewire/livewire": "^3.5.20",
"spatie/laravel-data": "^4.13.1",
"lorisleiva/laravel-actions": "^2.8.6"
}
}
```
### **JavaScript Dependencies** (package.json)
```json
{
"devDependencies": {
"vite": "^6.2.6",
"tailwindcss": "^4.1.4",
"@vitejs/plugin-vue": "5.2.3"
},
"dependencies": {
"@xterm/xterm": "^5.5.0",
"ioredis": "5.6.0"
}
}
```
## Configuration Files
### **Build Configuration**
- **vite.config.js**: Frontend build setup
- **postcss.config.cjs**: CSS processing
- **rector.php**: PHP refactoring rules
- **pint.json**: Code style configuration
### **Testing Configuration**
- **phpunit.xml**: Unit test configuration
- **phpunit.dusk.xml**: Browser test configuration
- **tests/Pest.php**: Pest testing setup
## Version Requirements
### **Minimum Requirements**
- **PHP**: 8.4+
- **Node.js**: 18+ (for build tools)
- **PostgreSQL**: 15+
- **Redis**: 7+
- **Docker**: 20.10+
- **Docker Compose**: 2.0+
### **Recommended Versions**
- **Ubuntu**: 22.04 LTS or 24.04 LTS
- **Memory**: 2GB+ RAM
- **Storage**: 20GB+ available space
- **Network**: Stable internet connection for deployments

View file

@ -1,648 +0,0 @@
# Coolify Development Workflow
## Development Environment Setup
### Prerequisites
- **PHP 8.4+** - Latest PHP version for modern features
- **Node.js 18+** - For frontend asset compilation
- **Docker & Docker Compose** - Container orchestration
- **PostgreSQL 15** - Primary database
- **Redis 7** - Caching and queues
### Local Development Setup
#### Using Docker (Recommended)
```bash
# Clone the repository
git clone https://github.com/coollabsio/coolify.git
cd coolify
# Copy environment configuration
cp .env.example .env
# Start development environment
docker-compose -f docker-compose.dev.yml up -d
# Install PHP dependencies
docker-compose exec app composer install
# Install Node.js dependencies
docker-compose exec app npm install
# Generate application key
docker-compose exec app php artisan key:generate
# Run database migrations
docker-compose exec app php artisan migrate
# Seed development data
docker-compose exec app php artisan db:seed
```
#### Native Development
```bash
# Install PHP dependencies
composer install
# Install Node.js dependencies
npm install
# Setup environment
cp .env.example .env
php artisan key:generate
# Setup database
createdb coolify_dev
php artisan migrate
php artisan db:seed
# Start development servers
php artisan serve &
npm run dev &
php artisan queue:work &
```
## Development Tools & Configuration
### Code Quality Tools
- **[Laravel Pint](mdc:pint.json)** - PHP code style fixer
- **[Rector](mdc:rector.php)** - PHP automated refactoring (989B, 35 lines)
- **PHPStan** - Static analysis for type safety
- **ESLint** - JavaScript code quality
### Development Configuration Files
- **[docker-compose.dev.yml](mdc:docker-compose.dev.yml)** - Development Docker setup (3.4KB, 126 lines)
- **[vite.config.js](mdc:vite.config.js)** - Frontend build configuration (1.0KB, 42 lines)
- **[.editorconfig](mdc:.editorconfig)** - Code formatting standards (258B, 19 lines)
### Git Configuration
- **[.gitignore](mdc:.gitignore)** - Version control exclusions (522B, 40 lines)
- **[.gitattributes](mdc:.gitattributes)** - Git file handling (185B, 11 lines)
## Development Workflow Process
### 1. Feature Development
```bash
# Create feature branch
git checkout -b feature/new-deployment-strategy
# Make changes following coding standards
# Run code quality checks
./vendor/bin/pint
./vendor/bin/rector process --dry-run
./vendor/bin/phpstan analyse
# Run tests
./vendor/bin/pest
./vendor/bin/pest --coverage
# Commit changes
git add .
git commit -m "feat: implement blue-green deployment strategy"
```
### 2. Code Review Process
```bash
# Push feature branch
git push origin feature/new-deployment-strategy
# Create pull request with:
# - Clear description of changes
# - Screenshots for UI changes
# - Test coverage information
# - Breaking change documentation
```
### 3. Testing Requirements
- **Unit tests** for new models and services
- **Feature tests** for API endpoints
- **Browser tests** for UI changes
- **Integration tests** for deployment workflows
## Coding Standards & Conventions
### PHP Coding Standards
```php
// Follow PSR-12 coding standards
class ApplicationDeploymentService
{
public function __construct(
private readonly DockerService $dockerService,
private readonly ConfigurationGenerator $configGenerator
) {}
public function deploy(Application $application): ApplicationDeploymentQueue
{
return DB::transaction(function () use ($application) {
$deployment = $application->deployments()->create([
'status' => 'queued',
'commit_sha' => $application->getLatestCommitSha(),
]);
DeployApplicationJob::dispatch($deployment);
return $deployment;
});
}
}
```
### Laravel Best Practices
```php
// Use Laravel conventions
class Application extends Model
{
// Mass assignment protection
protected $fillable = [
'name', 'git_repository', 'git_branch', 'fqdn'
];
// Type casting
protected $casts = [
'environment_variables' => 'array',
'build_pack' => BuildPack::class,
'created_at' => 'datetime',
];
// Relationships
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
public function deployments(): HasMany
{
return $this->hasMany(ApplicationDeploymentQueue::class);
}
}
```
### Frontend Standards
```javascript
// Alpine.js component structure
document.addEventListener('alpine:init', () => {
Alpine.data('deploymentMonitor', () => ({
status: 'idle',
logs: [],
init() {
this.connectWebSocket();
},
connectWebSocket() {
Echo.private(`application.${this.applicationId}`)
.listen('DeploymentStarted', (e) => {
this.status = 'deploying';
})
.listen('DeploymentCompleted', (e) => {
this.status = 'completed';
});
}
}));
});
```
### CSS/Tailwind Standards
```html
<!-- Use semantic class names and consistent spacing -->
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Application Status
</h3>
<div class="space-y-3">
<!-- Content with consistent spacing -->
</div>
</div>
</div>
```
## Database Development
### Migration Best Practices
```php
// Create descriptive migration files
class CreateApplicationDeploymentQueuesTable extends Migration
{
public function up(): void
{
Schema::create('application_deployment_queues', function (Blueprint $table) {
$table->id();
$table->foreignId('application_id')->constrained()->cascadeOnDelete();
$table->string('status')->default('queued');
$table->string('commit_sha')->nullable();
$table->text('build_logs')->nullable();
$table->text('deployment_logs')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->timestamps();
$table->index(['application_id', 'status']);
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('application_deployment_queues');
}
}
```
### Model Factory Development
```php
// Create comprehensive factories for testing
class ApplicationFactory extends Factory
{
protected $model = Application::class;
public function definition(): array
{
return [
'name' => $this->faker->words(2, true),
'fqdn' => $this->faker->domainName,
'git_repository' => 'https://github.com/' . $this->faker->userName . '/' . $this->faker->word . '.git',
'git_branch' => 'main',
'build_pack' => BuildPack::NIXPACKS,
'server_id' => Server::factory(),
'environment_id' => Environment::factory(),
];
}
public function withCustomDomain(): static
{
return $this->state(fn (array $attributes) => [
'fqdn' => $this->faker->domainName,
]);
}
}
```
## API Development
### Controller Standards
```php
class ApplicationController extends Controller
{
public function __construct()
{
$this->middleware('auth:sanctum');
$this->middleware('team.access');
}
public function index(Request $request): AnonymousResourceCollection
{
$applications = $request->user()
->currentTeam
->applications()
->with(['server', 'environment', 'latestDeployment'])
->paginate();
return ApplicationResource::collection($applications);
}
public function store(StoreApplicationRequest $request): ApplicationResource
{
$application = $request->user()
->currentTeam
->applications()
->create($request->validated());
return new ApplicationResource($application);
}
public function deploy(Application $application): JsonResponse
{
$this->authorize('deploy', $application);
$deployment = app(ApplicationDeploymentService::class)
->deploy($application);
return response()->json([
'message' => 'Deployment started successfully',
'deployment_id' => $deployment->id,
]);
}
}
```
### API Resource Development
```php
class ApplicationResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'fqdn' => $this->fqdn,
'status' => $this->status,
'git_repository' => $this->git_repository,
'git_branch' => $this->git_branch,
'build_pack' => $this->build_pack,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
// Conditional relationships
'server' => new ServerResource($this->whenLoaded('server')),
'environment' => new EnvironmentResource($this->whenLoaded('environment')),
'latest_deployment' => new DeploymentResource($this->whenLoaded('latestDeployment')),
// Computed attributes
'deployment_url' => $this->getDeploymentUrl(),
'can_deploy' => $this->canDeploy(),
];
}
}
```
## Livewire Component Development
### Component Structure
```php
class ApplicationShow extends Component
{
public Application $application;
public bool $showLogs = false;
protected $listeners = [
'deployment.started' => 'refreshDeploymentStatus',
'deployment.completed' => 'refreshDeploymentStatus',
];
public function mount(Application $application): void
{
$this->authorize('view', $application);
$this->application = $application;
}
public function deploy(): void
{
$this->authorize('deploy', $this->application);
try {
app(ApplicationDeploymentService::class)->deploy($this->application);
$this->dispatch('deployment.started', [
'application_id' => $this->application->id
]);
session()->flash('success', 'Deployment started successfully');
} catch (Exception $e) {
session()->flash('error', 'Failed to start deployment: ' . $e->getMessage());
}
}
public function refreshDeploymentStatus(): void
{
$this->application->refresh();
}
public function render(): View
{
return view('livewire.application.show', [
'deployments' => $this->application
->deployments()
->latest()
->limit(10)
->get()
]);
}
}
```
## Queue Job Development
### Job Structure
```php
class DeployApplicationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $maxExceptions = 1;
public function __construct(
public ApplicationDeploymentQueue $deployment
) {}
public function handle(
DockerService $dockerService,
ConfigurationGenerator $configGenerator
): void {
$this->deployment->update(['status' => 'running', 'started_at' => now()]);
try {
// Generate configuration
$config = $configGenerator->generateDockerCompose($this->deployment->application);
// Build and deploy
$imageTag = $dockerService->buildImage($this->deployment->application);
$dockerService->deployContainer($this->deployment->application, $imageTag);
$this->deployment->update([
'status' => 'success',
'finished_at' => now()
]);
// Broadcast success
broadcast(new DeploymentCompleted($this->deployment));
} catch (Exception $e) {
$this->deployment->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
'finished_at' => now()
]);
broadcast(new DeploymentFailed($this->deployment));
throw $e;
}
}
public function backoff(): array
{
return [1, 5, 10];
}
public function failed(Throwable $exception): void
{
$this->deployment->update([
'status' => 'failed',
'error_message' => $exception->getMessage(),
'finished_at' => now()
]);
}
}
```
## Testing Development
### Test Structure
```php
// Feature test example
test('user can deploy application via API', function () {
$user = User::factory()->create();
$application = Application::factory()->create([
'team_id' => $user->currentTeam->id
]);
// Mock external services
$this->mock(DockerService::class, function ($mock) {
$mock->shouldReceive('buildImage')->andReturn('app:latest');
$mock->shouldReceive('deployContainer')->andReturn(true);
});
$response = $this->actingAs($user)
->postJson("/api/v1/applications/{$application->id}/deploy");
$response->assertStatus(200)
->assertJson([
'message' => 'Deployment started successfully'
]);
expect($application->deployments()->count())->toBe(1);
expect($application->deployments()->first()->status)->toBe('queued');
});
```
## Documentation Standards
### Code Documentation
```php
/**
* Deploy an application to the specified server.
*
* This method creates a new deployment queue entry and dispatches
* a background job to handle the actual deployment process.
*
* @param Application $application The application to deploy
* @param array $options Additional deployment options
* @return ApplicationDeploymentQueue The created deployment queue entry
*
* @throws DeploymentException When deployment cannot be started
* @throws ServerConnectionException When server is unreachable
*/
public function deploy(Application $application, array $options = []): ApplicationDeploymentQueue
{
// Implementation
}
```
### API Documentation
```php
/**
* @OA\Post(
* path="/api/v1/applications/{application}/deploy",
* summary="Deploy an application",
* description="Triggers a new deployment for the specified application",
* operationId="deployApplication",
* tags={"Applications"},
* security={{"bearerAuth":{}}},
* @OA\Parameter(
* name="application",
* in="path",
* required=true,
* @OA\Schema(type="integer"),
* description="Application ID"
* ),
* @OA\Response(
* response=200,
* description="Deployment started successfully",
* @OA\JsonContent(
* @OA\Property(property="message", type="string"),
* @OA\Property(property="deployment_id", type="integer")
* )
* )
* )
*/
```
## Performance Optimization
### Database Optimization
```php
// Use eager loading to prevent N+1 queries
$applications = Application::with([
'server:id,name,ip',
'environment:id,name',
'latestDeployment:id,application_id,status,created_at'
])->get();
// Use database transactions for consistency
DB::transaction(function () use ($application) {
$deployment = $application->deployments()->create(['status' => 'queued']);
$application->update(['last_deployment_at' => now()]);
DeployApplicationJob::dispatch($deployment);
});
```
### Caching Strategies
```php
// Cache expensive operations
public function getServerMetrics(Server $server): array
{
return Cache::remember(
"server.{$server->id}.metrics",
now()->addMinutes(5),
fn () => $this->fetchServerMetrics($server)
);
}
```
## Deployment & Release Process
### Version Management
- **[versions.json](mdc:versions.json)** - Version tracking (355B, 19 lines)
- **[CHANGELOG.md](mdc:CHANGELOG.md)** - Release notes (187KB, 7411 lines)
- **[cliff.toml](mdc:cliff.toml)** - Changelog generation (3.2KB, 85 lines)
### Release Workflow
```bash
# Create release branch
git checkout -b release/v4.1.0
# Update version numbers
# Update CHANGELOG.md
# Run full test suite
./vendor/bin/pest
npm run test
# Create release commit
git commit -m "chore: release v4.1.0"
# Create and push tag
git tag v4.1.0
git push origin v4.1.0
# Merge to main
git checkout main
git merge release/v4.1.0
```
## Contributing Guidelines
### Pull Request Process
1. **Fork** the repository
2. **Create** feature branch from `main`
3. **Implement** changes with tests
4. **Run** code quality checks
5. **Submit** pull request with clear description
6. **Address** review feedback
7. **Merge** after approval
### Code Review Checklist
- [ ] Code follows project standards
- [ ] Tests cover new functionality
- [ ] Documentation is updated
- [ ] No breaking changes without migration
- [ ] Performance impact considered
- [ ] Security implications reviewed
### Issue Reporting
- Use issue templates
- Provide reproduction steps
- Include environment details
- Add relevant logs/screenshots
- Label appropriately

View file

@ -1,402 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.7
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/socialite (SOCIALITE) - v5
- livewire/livewire (LIVEWIRE) - v3
- laravel/dusk (DUSK) - v8
- laravel/pint (PINT) - v1
- laravel/telescope (TELESCOPE) - v5
- pestphp/pest (PEST) - v3
- phpunit/phpunit (PHPUNIT) - v11
- rector/rector (RECTOR) - v2
- laravel-echo (ECHO) - v2
- tailwindcss (TAILWINDCSS) - v4
- vue (VUE) - v3
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
=== php rules ===
## PHP
- Always use curly braces for control structures, even if it has one line.
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== laravel/core rules ===
## Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
## Laravel 12
- Use the `search-docs` tool to get version specific documentation.
- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that.
### Laravel 10 Structure
- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
- Middleware registration happens in `app/Http/Kernel.php`
- Exception handling is in `app/Exceptions/Handler.php`
- Console commands and schedule register in `app/Console/Kernel.php`
- Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
### 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 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.
=== livewire/core rules ===
## Livewire Core
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== livewire/v3 rules ===
## Livewire 3
### Key Changes From Livewire 2
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
### New Directives
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
### Alpine
- Alpine is now included with Livewire, don't manually include Alpine.js.
- Plugins included with Alpine: persist, intersect, collapse, and focus.
### Lifecycle Hooks
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
<code-snippet name="livewire:load example" lang="js">
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
alert('Your session expired');
}
});
Livewire.hook('message.failed', (message, component) => {
console.error(message);
});
});
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== pest/core rules ===
## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
- 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.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### 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.
### 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.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== tailwindcss/core rules ===
## Tailwind Core
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing, don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff"
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
=== tests rules ===
## 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.
</laravel-boost-guidelines>

View file

@ -1,648 +0,0 @@
# 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.
### 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
### Core Testing Tools
- **Pest PHP 3.8+** - Primary testing framework with expressive syntax
- **Laravel Dusk** - Browser automation and end-to-end testing
- **PHPUnit** - Underlying unit testing framework
- **Mockery** - Mocking and stubbing for isolated tests
### Testing Configuration
- **[tests/Pest.php](mdc:tests/Pest.php)** - Pest configuration and global setup (1.5KB, 45 lines)
- **[tests/TestCase.php](mdc:tests/TestCase.php)** - Base test case class (163B, 11 lines)
- **[tests/CreatesApplication.php](mdc:tests/CreatesApplication.php)** - Application factory trait (375B, 22 lines)
- **[tests/DuskTestCase.php](mdc:tests/DuskTestCase.php)** - Browser testing setup (1.4KB, 58 lines)
## Test Directory Structure
### Test Organization
- **[tests/Feature/](mdc:tests/Feature)** - Feature and integration tests
- **[tests/Unit/](mdc:tests/Unit)** - Unit tests for isolated components
- **[tests/Browser/](mdc:tests/Browser)** - Laravel Dusk browser tests
- **[tests/Traits/](mdc:tests/Traits)** - Shared testing utilities
## Unit Testing Patterns
### Model Testing
```php
// Testing Eloquent models
test('application model has correct relationships', function () {
$application = Application::factory()->create();
expect($application->server)->toBeInstanceOf(Server::class);
expect($application->environment)->toBeInstanceOf(Environment::class);
expect($application->deployments)->toBeInstanceOf(Collection::class);
});
test('application can generate deployment configuration', function () {
$application = Application::factory()->create([
'name' => 'test-app',
'git_repository' => 'https://github.com/user/repo.git'
]);
$config = $application->generateDockerCompose();
expect($config)->toContain('test-app');
expect($config)->toContain('image:');
expect($config)->toContain('networks:');
});
```
### Service Layer Testing
```php
// Testing service classes
test('configuration generator creates valid docker compose', function () {
$generator = new ConfigurationGenerator();
$application = Application::factory()->create();
$compose = $generator->generateDockerCompose($application);
expect($compose)->toBeString();
expect(yaml_parse($compose))->toBeArray();
expect($compose)->toContain('version: "3.8"');
});
test('docker image parser validates image names', function () {
$parser = new DockerImageParser();
expect($parser->isValid('nginx:latest'))->toBeTrue();
expect($parser->isValid('invalid-image-name'))->toBeFalse();
expect($parser->parse('nginx:1.21'))->toEqual([
'registry' => 'docker.io',
'namespace' => 'library',
'repository' => 'nginx',
'tag' => '1.21'
]);
});
```
### Action Testing
```php
// Testing Laravel Actions
test('deploy application action creates deployment queue', function () {
$application = Application::factory()->create();
$action = new DeployApplicationAction();
$deployment = $action->handle($application);
expect($deployment)->toBeInstanceOf(ApplicationDeploymentQueue::class);
expect($deployment->status)->toBe('queued');
expect($deployment->application_id)->toBe($application->id);
});
test('server validation action checks ssh connectivity', function () {
$server = Server::factory()->create([
'ip' => '192.168.1.100',
'port' => 22
]);
$action = new ValidateServerAction();
// Mock SSH connection
$this->mock(SshConnection::class, function ($mock) {
$mock->shouldReceive('connect')->andReturn(true);
$mock->shouldReceive('execute')->with('docker --version')->andReturn('Docker version 20.10.0');
});
$result = $action->handle($server);
expect($result['ssh_connection'])->toBeTrue();
expect($result['docker_installed'])->toBeTrue();
});
```
## Feature Testing Patterns
### API Testing
```php
// Testing API endpoints
test('authenticated user can list applications', function () {
$user = User::factory()->create();
$team = Team::factory()->create();
$user->teams()->attach($team);
$applications = Application::factory(3)->create([
'team_id' => $team->id
]);
$response = $this->actingAs($user)
->getJson('/api/v1/applications');
$response->assertStatus(200)
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'fqdn', 'status', 'created_at']
]
]);
});
test('user cannot access applications from other teams', function () {
$user = User::factory()->create();
$otherTeam = Team::factory()->create();
$application = Application::factory()->create([
'team_id' => $otherTeam->id
]);
$response = $this->actingAs($user)
->getJson("/api/v1/applications/{$application->id}");
$response->assertStatus(403);
});
```
### Deployment Testing
```php
// Testing deployment workflows
test('application deployment creates docker containers', function () {
$application = Application::factory()->create([
'git_repository' => 'https://github.com/laravel/laravel.git',
'git_branch' => 'main'
]);
// Mock Docker operations
$this->mock(DockerService::class, function ($mock) {
$mock->shouldReceive('buildImage')->andReturn('app:latest');
$mock->shouldReceive('createContainer')->andReturn('container_id');
$mock->shouldReceive('startContainer')->andReturn(true);
});
$deployment = $application->deploy();
expect($deployment->status)->toBe('queued');
// Process the deployment job
$this->artisan('queue:work --once');
$deployment->refresh();
expect($deployment->status)->toBe('success');
});
test('failed deployment triggers rollback', function () {
$application = Application::factory()->create();
// Mock failed deployment
$this->mock(DockerService::class, function ($mock) {
$mock->shouldReceive('buildImage')->andThrow(new DeploymentException('Build failed'));
});
$deployment = $application->deploy();
$this->artisan('queue:work --once');
$deployment->refresh();
expect($deployment->status)->toBe('failed');
expect($deployment->error_message)->toContain('Build failed');
});
```
### Webhook Testing
```php
// Testing webhook endpoints
test('github webhook triggers deployment', function () {
$application = Application::factory()->create([
'git_repository' => 'https://github.com/user/repo.git',
'git_branch' => 'main'
]);
$payload = [
'ref' => 'refs/heads/main',
'repository' => [
'clone_url' => 'https://github.com/user/repo.git'
],
'head_commit' => [
'id' => 'abc123',
'message' => 'Update application'
]
];
$response = $this->postJson("/webhooks/github/{$application->id}", $payload);
$response->assertStatus(200);
expect($application->deployments()->count())->toBe(1);
expect($application->deployments()->first()->commit_sha)->toBe('abc123');
});
test('webhook validates payload signature', function () {
$application = Application::factory()->create();
$payload = ['invalid' => 'payload'];
$response = $this->postJson("/webhooks/github/{$application->id}", $payload);
$response->assertStatus(400);
});
```
## Browser Testing (Laravel Dusk)
### End-to-End Testing
```php
// Testing complete user workflows
test('user can create and deploy application', function () {
$user = User::factory()->create();
$server = Server::factory()->create(['team_id' => $user->currentTeam->id]);
$this->browse(function (Browser $browser) use ($user, $server) {
$browser->loginAs($user)
->visit('/applications/create')
->type('name', 'Test Application')
->type('git_repository', 'https://github.com/laravel/laravel.git')
->type('git_branch', 'main')
->select('server_id', $server->id)
->press('Create Application')
->assertPathIs('/applications/*')
->assertSee('Test Application')
->press('Deploy')
->waitForText('Deployment started', 10)
->assertSee('Deployment started');
});
});
test('user can monitor deployment logs in real-time', function () {
$user = User::factory()->create();
$application = Application::factory()->create(['team_id' => $user->currentTeam->id]);
$this->browse(function (Browser $browser) use ($user, $application) {
$browser->loginAs($user)
->visit("/applications/{$application->id}")
->press('Deploy')
->waitForText('Deployment started')
->click('@logs-tab')
->waitFor('@deployment-logs')
->assertSee('Building Docker image')
->waitForText('Deployment completed', 30);
});
});
```
### UI Component Testing
```php
// Testing Livewire components
test('server status component updates in real-time', function () {
$user = User::factory()->create();
$server = Server::factory()->create(['team_id' => $user->currentTeam->id]);
$this->browse(function (Browser $browser) use ($user, $server) {
$browser->loginAs($user)
->visit("/servers/{$server->id}")
->assertSee('Status: Online')
->waitFor('@server-metrics')
->assertSee('CPU Usage')
->assertSee('Memory Usage')
->assertSee('Disk Usage');
// Simulate server going offline
$server->update(['status' => 'offline']);
$browser->waitForText('Status: Offline', 5)
->assertSee('Status: Offline');
});
});
```
## Database Testing Patterns
### Migration Testing
```php
// Testing database migrations
test('applications table has correct structure', function () {
expect(Schema::hasTable('applications'))->toBeTrue();
expect(Schema::hasColumns('applications', [
'id', 'name', 'fqdn', 'git_repository', 'git_branch',
'server_id', 'environment_id', 'created_at', 'updated_at'
]))->toBeTrue();
});
test('foreign key constraints are properly set', function () {
$application = Application::factory()->create();
expect($application->server)->toBeInstanceOf(Server::class);
expect($application->environment)->toBeInstanceOf(Environment::class);
// Test cascade deletion
$application->server->delete();
expect(Application::find($application->id))->toBeNull();
});
```
### Factory Testing
```php
// Testing model factories
test('application factory creates valid models', function () {
$application = Application::factory()->create();
expect($application->name)->toBeString();
expect($application->git_repository)->toStartWith('https://');
expect($application->server_id)->toBeInt();
expect($application->environment_id)->toBeInt();
});
test('application factory can create with custom attributes', function () {
$application = Application::factory()->create([
'name' => 'Custom App',
'git_branch' => 'develop'
]);
expect($application->name)->toBe('Custom App');
expect($application->git_branch)->toBe('develop');
});
```
## Queue Testing
### Job Testing
```php
// Testing background jobs
test('deploy application job processes successfully', function () {
$application = Application::factory()->create();
$deployment = ApplicationDeploymentQueue::factory()->create([
'application_id' => $application->id,
'status' => 'queued'
]);
$job = new DeployApplicationJob($deployment);
// Mock external dependencies
$this->mock(DockerService::class, function ($mock) {
$mock->shouldReceive('buildImage')->andReturn('app:latest');
$mock->shouldReceive('deployContainer')->andReturn(true);
});
$job->handle();
$deployment->refresh();
expect($deployment->status)->toBe('success');
});
test('failed job is retried with exponential backoff', function () {
$application = Application::factory()->create();
$deployment = ApplicationDeploymentQueue::factory()->create([
'application_id' => $application->id
]);
$job = new DeployApplicationJob($deployment);
// Mock failure
$this->mock(DockerService::class, function ($mock) {
$mock->shouldReceive('buildImage')->andThrow(new Exception('Network error'));
});
expect(fn() => $job->handle())->toThrow(Exception::class);
// Job should be retried
expect($job->tries)->toBe(3);
expect($job->backoff())->toBe([1, 5, 10]);
});
```
## Security Testing
### Authentication Testing
```php
// Testing authentication and authorization
test('unauthenticated users cannot access protected routes', function () {
$response = $this->get('/dashboard');
$response->assertRedirect('/login');
});
test('users can only access their team resources', function () {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$team1 = Team::factory()->create();
$team2 = Team::factory()->create();
$user1->teams()->attach($team1);
$user2->teams()->attach($team2);
$application = Application::factory()->create(['team_id' => $team1->id]);
$response = $this->actingAs($user2)
->get("/applications/{$application->id}");
$response->assertStatus(403);
});
```
### Input Validation Testing
```php
// Testing input validation and sanitization
test('application creation validates required fields', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/v1/applications', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['name', 'git_repository', 'server_id']);
});
test('malicious input is properly sanitized', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/v1/applications', [
'name' => '<script>alert("xss")</script>',
'git_repository' => 'javascript:alert("xss")',
'server_id' => 'invalid'
]);
$response->assertStatus(422);
});
```
## Performance Testing
### Load Testing
```php
// Testing application performance under load
test('application list endpoint handles concurrent requests', function () {
$user = User::factory()->create();
$applications = Application::factory(100)->create(['team_id' => $user->currentTeam->id]);
$startTime = microtime(true);
$response = $this->actingAs($user)
->getJson('/api/v1/applications');
$endTime = microtime(true);
$responseTime = ($endTime - $startTime) * 1000; // Convert to milliseconds
$response->assertStatus(200);
expect($responseTime)->toBeLessThan(500); // Should respond within 500ms
});
```
### Memory Usage Testing
```php
// Testing memory efficiency
test('deployment process does not exceed memory limits', function () {
$initialMemory = memory_get_usage();
$application = Application::factory()->create();
$deployment = $application->deploy();
// Process deployment
$this->artisan('queue:work --once');
$finalMemory = memory_get_usage();
$memoryIncrease = $finalMemory - $initialMemory;
expect($memoryIncrease)->toBeLessThan(50 * 1024 * 1024); // Less than 50MB
});
```
## Test Utilities and Helpers
### Custom Assertions
```php
// Custom test assertions
expect()->extend('toBeValidDockerCompose', function () {
$yaml = yaml_parse($this->value);
return $yaml !== false &&
isset($yaml['version']) &&
isset($yaml['services']) &&
is_array($yaml['services']);
});
expect()->extend('toHaveValidSshConnection', function () {
$server = $this->value;
try {
$connection = new SshConnection($server);
return $connection->test();
} catch (Exception $e) {
return false;
}
});
```
### Test Traits
```php
// Shared testing functionality
trait CreatesTestServers
{
protected function createTestServer(array $attributes = []): Server
{
return Server::factory()->create(array_merge([
'name' => 'Test Server',
'ip' => '127.0.0.1',
'port' => 22,
'team_id' => $this->user->currentTeam->id
], $attributes));
}
}
trait MocksDockerOperations
{
protected function mockDockerService(): void
{
$this->mock(DockerService::class, function ($mock) {
$mock->shouldReceive('buildImage')->andReturn('test:latest');
$mock->shouldReceive('createContainer')->andReturn('container_123');
$mock->shouldReceive('startContainer')->andReturn(true);
$mock->shouldReceive('stopContainer')->andReturn(true);
});
}
}
```
## Continuous Integration Testing
### GitHub Actions Integration
```yaml
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
- name: Install dependencies
run: composer install
- name: Run tests
run: ./vendor/bin/pest
```
### Test Coverage
```php
// Generate test coverage reports
test('application has adequate test coverage', function () {
$coverage = $this->getCoverageData();
expect($coverage['application'])->toBeGreaterThan(80);
expect($coverage['models'])->toBeGreaterThan(90);
expect($coverage['actions'])->toBeGreaterThan(85);
});
```

View file

@ -1,172 +0,0 @@
# Maintaining AI Documentation
Guidelines for creating and maintaining AI documentation to ensure consistency and effectiveness across all AI tools (Claude Code, Cursor IDE, etc.).
## Documentation Structure
All AI documentation lives in the `.ai/` directory with the following structure:
```
.ai/
├── README.md # Navigation hub
├── core/ # Core project information
├── development/ # Development practices
├── patterns/ # Code patterns and best practices
└── meta/ # Documentation maintenance guides
```
> **Note**: `CLAUDE.md` is in the repository root, not in the `.ai/` directory.
## Required File Structure
When creating new documentation files:
```markdown
# Title
Brief description of what this document covers.
## Section 1
- **Main Points in Bold**
- Sub-points with details
- Examples and explanations
## Section 2
### Subsection
Content with code examples:
```language
// ✅ DO: Show good examples
const goodExample = true;
// ❌ DON'T: Show anti-patterns
const badExample = false;
```
```
## File References
- Use relative paths: `See [technology-stack.md](../core/technology-stack.md)`
- For code references: `` `app/Models/Application.php` ``
- Keep links working across different tools
## Content Guidelines
### DO:
- Start with high-level overview
- Include specific, actionable requirements
- Show examples of correct implementation
- Reference existing code when possible
- Keep documentation DRY by cross-referencing
- Use bullet points for clarity
- Include both DO and DON'T examples
### DON'T:
- Create theoretical examples when real code exists
- Duplicate content across multiple files
- Use tool-specific formatting that won't work elsewhere
- Make assumptions about versions - specify exact versions
## Rule Improvement Triggers
Update documentation when you notice:
- New code patterns not covered by existing docs
- Repeated similar implementations across files
- Common error patterns that could be prevented
- New libraries or tools being used consistently
- Emerging best practices in the codebase
## Analysis Process
When updating documentation:
1. Compare new code with existing rules
2. Identify patterns that should be standardized
3. Look for references to external documentation
4. Check for consistent error handling patterns
5. Monitor test patterns and coverage
## Rule Updates
### Add New Documentation When:
- A new technology/pattern is used in 3+ files
- Common bugs could be prevented by documentation
- Code reviews repeatedly mention the same feedback
- New security or performance patterns emerge
### Modify Existing Documentation When:
- Better examples exist in the codebase
- Additional edge cases are discovered
- Related documentation has been updated
- Implementation details have changed
## Quality Checks
Before committing documentation changes:
- [ ] Documentation is actionable and specific
- [ ] Examples come from actual code
- [ ] References are up to date
- [ ] Patterns are consistently enforced
- [ ] Cross-references work correctly
- [ ] Version numbers are exact and current
## Continuous Improvement
- Monitor code review comments
- Track common development questions
- Update docs after major refactors
- Add links to relevant documentation
- Cross-reference related docs
## Deprecation
When patterns become outdated:
1. Mark outdated patterns as deprecated
2. Remove docs that no longer apply
3. Update references to deprecated patterns
4. Document migration paths for old patterns
## Synchronization
### Single Source of Truth
- Each piece of information should exist in exactly ONE location
- Other files should reference the source, not duplicate it
- Example: Version numbers live in `core/technology-stack.md`, other files reference it
### Cross-Tool Compatibility
- **CLAUDE.md**: Main instructions for Claude Code users (references `.ai/` files)
- **.cursor/rules/**: Single master file pointing to `.ai/` documentation
- **Both tools**: Should get same information from `.ai/` directory
### When to Update What
**Version Changes** (Laravel, PHP, packages):
1. Update `core/technology-stack.md` (single source)
2. Verify CLAUDE.md references it correctly
3. No other files should duplicate version numbers
**Workflow Changes** (commands, setup):
1. Update `development/workflow.md`
2. Ensure CLAUDE.md quick reference is updated
3. Verify all cross-references work
**Pattern Changes** (how to write code):
1. Update appropriate file in `patterns/`
2. Add/update examples from real codebase
3. Cross-reference from related docs
## Documentation Files
Keep documentation files only when explicitly needed. Don't create docs that merely describe obvious functionality - the code itself should be clear.
## Breaking Changes
When making breaking changes to documentation structure:
1. Update this maintaining-docs.md file
2. Update `.ai/README.md` navigation
3. Update CLAUDE.md references
4. Update `.cursor/rules/coolify-ai-docs.mdc`
5. Test all cross-references still work
6. Document the changes in sync-guide.md

View file

@ -1,214 +0,0 @@
# 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 with a **single source of truth** approach:
1. **CLAUDE.md** - Main entry point for Claude Code (references `.ai/` directory)
2. **.cursor/rules/coolify-ai-docs.mdc** - Master reference file for Cursor IDE (references `.ai/` directory)
3. **.ai/** - Single source of truth containing all detailed documentation
All AI tools (Claude Code, Cursor IDE, etc.) reference the same `.ai/` directory to ensure consistency.
## Structure
### CLAUDE.md (Root Directory)
- **Purpose**: Entry point for Claude Code with quick-reference guide
- **Format**: Single markdown file
- **Includes**:
- Quick-reference development commands
- High-level architecture overview
- Essential patterns and guidelines
- References to detailed `.ai/` documentation
### .cursor/rules/coolify-ai-docs.mdc
- **Purpose**: Master reference file for Cursor IDE
- **Format**: Single .mdc file with frontmatter
- **Content**: Quick decision tree and references to `.ai/` directory
- **Note**: Replaces all previous topic-specific .mdc files
### .ai/ Directory (Single Source of Truth)
- **Purpose**: All detailed, topic-specific documentation
- **Format**: Organized markdown files by category
- **Structure**:
```
.ai/
├── README.md # Navigation hub
├── core/ # Project information
│ ├── technology-stack.md # Version numbers (SINGLE SOURCE OF TRUTH)
│ ├── project-overview.md
│ ├── application-architecture.md
│ └── deployment-architecture.md
├── development/ # Development practices
│ ├── development-workflow.md
│ ├── testing-patterns.md
│ └── laravel-boost.md
├── patterns/ # Code patterns
│ ├── database-patterns.md
│ ├── frontend-patterns.md
│ ├── security-patterns.md
│ ├── form-components.md
│ └── api-and-routing.md
└── meta/ # Documentation guides
├── maintaining-docs.md
└── sync-guide.md (this file)
```
- **Used by**: All AI tools through CLAUDE.md or coolify-ai-docs.mdc
## Cross-References
All systems reference the `.ai/` directory as the source of truth:
- **CLAUDE.md** → references `.ai/` files for detailed documentation
- **.cursor/rules/coolify-ai-docs.mdc** → references `.ai/` files for detailed documentation
- **.ai/README.md** → provides navigation to all documentation
## Maintaining Consistency
### 1. Core Principles (MUST be consistent)
These are defined ONCE in `.ai/core/technology-stack.md`:
- Laravel version (currently Laravel 12.4.1)
- PHP version (8.4.7)
- All package versions (Livewire 3.5.20, Tailwind 4.1.4, etc.)
**Exception**: CLAUDE.md is permitted to show essential version numbers as a quick reference for convenience. These must stay synchronized with `technology-stack.md`. When updating versions, update both locations.
Other critical patterns defined in `.ai/`:
- 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 version numbers** (Laravel, PHP, packages):
1. Update `.ai/core/technology-stack.md` (single source of truth)
2. Update CLAUDE.md quick reference section (essential versions only)
3. Verify both files stay synchronized
4. Never duplicate version numbers in other locations
**For workflow changes** (how to run commands, development setup):
1. Update `.ai/development/development-workflow.md`
2. Update quick reference in CLAUDE.md if needed
3. Verify `.cursor/rules/coolify-ai-docs.mdc` references are correct
**For architectural patterns** (how code should be structured):
1. Update appropriate file in `.ai/core/`
2. Add cross-references from related docs
3. Update CLAUDE.md if it needs to highlight this pattern
**For code patterns** (how to write code):
1. Update appropriate file in `.ai/patterns/`
2. Add examples from real codebase
3. Cross-reference from related docs
**For testing patterns**:
1. Update `.ai/development/testing-patterns.md`
2. Ensure CLAUDE.md testing section references it
### 3. Update Checklist
When making significant changes:
- [ ] Identify if change affects core principles (version numbers, critical patterns)
- [ ] Update primary location in `.ai/` directory
- [ ] Check if CLAUDE.md needs quick-reference update
- [ ] Verify `.cursor/rules/coolify-ai-docs.mdc` references are still accurate
- [ ] Update cross-references in related `.ai/` files
- [ ] Verify all relative paths work correctly
- [ ] Test links in markdown files
- [ ] Run: `./vendor/bin/pint` on modified files (if applicable)
### 4. Common Inconsistencies to Watch
- **Version numbers**: Should ONLY exist in `.ai/core/technology-stack.md`
- **Testing instructions**: Docker execution requirements must be consistent
- **File paths**: Ensure relative paths work from their location
- **Command syntax**: Docker commands, artisan commands must be accurate
- **Cross-references**: Links must point to current file locations
## File Organization
```
/
├── CLAUDE.md # Claude Code entry point
├── .AI_INSTRUCTIONS_SYNC.md # Redirect to this file
├── .cursor/
│ └── rules/
│ └── coolify-ai-docs.mdc # Cursor IDE master reference
└── .ai/ # SINGLE SOURCE OF TRUTH
├── README.md # Navigation hub
├── core/ # Project information
├── development/ # Development practices
├── patterns/ # Code patterns
└── meta/ # Documentation guides
```
## Recent Updates
### 2025-11-18 - Documentation Consolidation
- ✅ Consolidated all documentation into `.ai/` directory
- ✅ Created single source of truth for version numbers
- ✅ Reduced CLAUDE.md from 719 to 319 lines
- ✅ Replaced 11 .cursor/rules/*.mdc files with single coolify-ai-docs.mdc
- ✅ Organized by topic: core/, development/, patterns/, meta/
- ✅ Standardized version numbers (Laravel 12.4.1, PHP 8.4.7, Tailwind 4.1.4)
- ✅ Created comprehensive navigation with .ai/README.md
### 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
## Maintenance Commands
```bash
# Check for version inconsistencies (should only be in technology-stack.md)
# Note: CLAUDE.md is allowed to show quick reference versions
grep -r "Laravel 12" .ai/ CLAUDE.md .cursor/rules/coolify-ai-docs.mdc
grep -r "PHP 8.4" .ai/ CLAUDE.md .cursor/rules/coolify-ai-docs.mdc
# Check for broken cross-references to old .mdc files
grep -r "\.cursor/rules/.*\.mdc" .ai/ CLAUDE.md
# Format all documentation
./vendor/bin/pint CLAUDE.md .ai/**/*.md
# Search for specific patterns across all docs
grep -r "pattern_to_check" CLAUDE.md .ai/ .cursor/rules/
# Verify all markdown links work (from repository root)
find .ai -name "*.md" -exec grep -H "\[.*\](.*)" {} \;
```
## Contributing
When contributing documentation:
1. **Check `.ai/` directory** for existing documentation
2. **Update `.ai/` files** - this is the single source of truth
3. **Use cross-references** - never duplicate content
4. **Update CLAUDE.md** if adding critical quick-reference information
5. **Verify `.cursor/rules/coolify-ai-docs.mdc`** still references correctly
6. **Test all links** work from their respective locations
7. **Update this sync-guide.md** if changing organizational structure
8. **Verify consistency** before submitting PR
## Questions?
If unsure about where to document something:
- **Version numbers**`.ai/core/technology-stack.md` (ONLY location)
- **Quick reference / commands** → CLAUDE.md + `.ai/development/development-workflow.md`
- **Detailed patterns / examples**`.ai/patterns/[topic].md`
- **Architecture / concepts**`.ai/core/[topic].md`
- **Development practices**`.ai/development/[topic].md`
- **Documentation guides**`.ai/meta/[topic].md`
**Golden Rule**: Each piece of information exists in ONE location in `.ai/`, other files reference it.
When in doubt, prefer detailed documentation in `.ai/` and lightweight references in CLAUDE.md and coolify-ai-docs.mdc.

View file

@ -1,469 +0,0 @@
# Coolify API & Routing Architecture
## Routing Structure
Coolify implements **multi-layered routing** with web interfaces, RESTful APIs, webhook endpoints, and real-time communication channels.
## Route Files
### Core Route Definitions
- **[routes/web.php](mdc:routes/web.php)** - Web application routes (21KB, 362 lines)
- **[routes/api.php](mdc:routes/api.php)** - RESTful API endpoints (13KB, 185 lines)
- **[routes/webhooks.php](mdc:routes/webhooks.php)** - Webhook receivers (815B, 22 lines)
- **[routes/channels.php](mdc:routes/channels.php)** - WebSocket channel definitions (829B, 33 lines)
- **[routes/console.php](mdc:routes/console.php)** - Artisan command routes (592B, 20 lines)
## Web Application Routing
### Authentication Routes
```php
// Laravel Fortify authentication
Route::middleware('guest')->group(function () {
Route::get('/login', [AuthController::class, 'login']);
Route::get('/register', [AuthController::class, 'register']);
Route::get('/forgot-password', [AuthController::class, 'forgotPassword']);
});
```
### Dashboard & Core Features
```php
// Main application routes
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/dashboard', Dashboard::class)->name('dashboard');
Route::get('/projects', ProjectIndex::class)->name('projects');
Route::get('/servers', ServerIndex::class)->name('servers');
Route::get('/teams', TeamIndex::class)->name('teams');
});
```
### Resource Management Routes
```php
// Server management
Route::prefix('servers')->group(function () {
Route::get('/{server}', ServerShow::class)->name('server.show');
Route::get('/{server}/edit', ServerEdit::class)->name('server.edit');
Route::get('/{server}/logs', ServerLogs::class)->name('server.logs');
});
// Application management
Route::prefix('applications')->group(function () {
Route::get('/{application}', ApplicationShow::class)->name('application.show');
Route::get('/{application}/deployments', ApplicationDeployments::class);
Route::get('/{application}/environment-variables', ApplicationEnvironmentVariables::class);
Route::get('/{application}/logs', ApplicationLogs::class);
});
```
## RESTful API Architecture
### API Versioning
```php
// API route structure
Route::prefix('v1')->group(function () {
// Application endpoints
Route::apiResource('applications', ApplicationController::class);
Route::apiResource('servers', ServerController::class);
Route::apiResource('teams', TeamController::class);
});
```
### Authentication & Authorization
```php
// Sanctum API authentication
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', function (Request $request) {
return $request->user();
});
// Team-scoped resources
Route::middleware('team.access')->group(function () {
Route::apiResource('applications', ApplicationController::class);
});
});
```
### Application Management API
```php
// Application CRUD operations
Route::prefix('applications')->group(function () {
Route::get('/', [ApplicationController::class, 'index']);
Route::post('/', [ApplicationController::class, 'store']);
Route::get('/{application}', [ApplicationController::class, 'show']);
Route::patch('/{application}', [ApplicationController::class, 'update']);
Route::delete('/{application}', [ApplicationController::class, 'destroy']);
// Deployment operations
Route::post('/{application}/deploy', [ApplicationController::class, 'deploy']);
Route::post('/{application}/restart', [ApplicationController::class, 'restart']);
Route::post('/{application}/stop', [ApplicationController::class, 'stop']);
Route::get('/{application}/logs', [ApplicationController::class, 'logs']);
});
```
### Server Management API
```php
// Server operations
Route::prefix('servers')->group(function () {
Route::get('/', [ServerController::class, 'index']);
Route::post('/', [ServerController::class, 'store']);
Route::get('/{server}', [ServerController::class, 'show']);
Route::patch('/{server}', [ServerController::class, 'update']);
Route::delete('/{server}', [ServerController::class, 'destroy']);
// Server actions
Route::post('/{server}/validate', [ServerController::class, 'validate']);
Route::get('/{server}/usage', [ServerController::class, 'usage']);
Route::post('/{server}/cleanup', [ServerController::class, 'cleanup']);
});
```
### Database Management API
```php
// Database operations
Route::prefix('databases')->group(function () {
Route::get('/', [DatabaseController::class, 'index']);
Route::post('/', [DatabaseController::class, 'store']);
Route::get('/{database}', [DatabaseController::class, 'show']);
Route::patch('/{database}', [DatabaseController::class, 'update']);
Route::delete('/{database}', [DatabaseController::class, 'destroy']);
// Database actions
Route::post('/{database}/backup', [DatabaseController::class, 'backup']);
Route::post('/{database}/restore', [DatabaseController::class, 'restore']);
Route::get('/{database}/logs', [DatabaseController::class, 'logs']);
});
```
## Webhook Architecture
### Git Integration Webhooks
```php
// GitHub webhook endpoints
Route::post('/webhooks/github/{application}', [GitHubWebhookController::class, 'handle'])
->name('webhooks.github');
// GitLab webhook endpoints
Route::post('/webhooks/gitlab/{application}', [GitLabWebhookController::class, 'handle'])
->name('webhooks.gitlab');
// Generic Git webhooks
Route::post('/webhooks/git/{application}', [GitWebhookController::class, 'handle'])
->name('webhooks.git');
```
### Deployment Webhooks
```php
// Deployment status webhooks
Route::post('/webhooks/deployment/{deployment}/success', [DeploymentWebhookController::class, 'success']);
Route::post('/webhooks/deployment/{deployment}/failure', [DeploymentWebhookController::class, 'failure']);
Route::post('/webhooks/deployment/{deployment}/progress', [DeploymentWebhookController::class, 'progress']);
```
### Third-Party Integration Webhooks
```php
// Monitoring webhooks
Route::post('/webhooks/monitoring/{server}', [MonitoringWebhookController::class, 'handle']);
// Backup status webhooks
Route::post('/webhooks/backup/{backup}', [BackupWebhookController::class, 'handle']);
// SSL certificate webhooks
Route::post('/webhooks/ssl/{certificate}', [SslWebhookController::class, 'handle']);
```
## WebSocket Channel Definitions
### Real-Time Channels
```php
// Private channels for team members
Broadcast::channel('team.{teamId}', function ($user, $teamId) {
return $user->teams->contains('id', $teamId);
});
// Application deployment channels
Broadcast::channel('application.{applicationId}', function ($user, $applicationId) {
return $user->hasAccessToApplication($applicationId);
});
// Server monitoring channels
Broadcast::channel('server.{serverId}', function ($user, $serverId) {
return $user->hasAccessToServer($serverId);
});
```
### Presence Channels
```php
// Team collaboration presence
Broadcast::channel('team.{teamId}.presence', function ($user, $teamId) {
if ($user->teams->contains('id', $teamId)) {
return ['id' => $user->id, 'name' => $user->name];
}
});
```
## API Controllers
### Location: [app/Http/Controllers/Api/](mdc:app/Http/Controllers)
#### Resource Controllers
```php
class ApplicationController extends Controller
{
public function index(Request $request)
{
return ApplicationResource::collection(
$request->user()->currentTeam->applications()
->with(['server', 'environment'])
->paginate()
);
}
public function store(StoreApplicationRequest $request)
{
$application = $request->user()->currentTeam
->applications()
->create($request->validated());
return new ApplicationResource($application);
}
public function deploy(Application $application)
{
$deployment = $application->deploy();
return response()->json([
'message' => 'Deployment started',
'deployment_id' => $deployment->id
]);
}
}
```
### API Responses & Resources
```php
// API Resource classes
class ApplicationResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
'fqdn' => $this->fqdn,
'status' => $this->status,
'git_repository' => $this->git_repository,
'git_branch' => $this->git_branch,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'server' => new ServerResource($this->whenLoaded('server')),
'environment' => new EnvironmentResource($this->whenLoaded('environment')),
];
}
}
```
## API Authentication
### Sanctum Token Authentication
```php
// API token generation
Route::post('/auth/tokens', function (Request $request) {
$request->validate([
'name' => 'required|string',
'abilities' => 'array'
]);
$token = $request->user()->createToken(
$request->name,
$request->abilities ?? []
);
return response()->json([
'token' => $token->plainTextToken,
'abilities' => $token->accessToken->abilities
]);
});
```
### Team-Based Authorization
```php
// Team access middleware
class EnsureTeamAccess
{
public function handle($request, Closure $next)
{
$teamId = $request->route('team');
if (!$request->user()->teams->contains('id', $teamId)) {
abort(403, 'Access denied to team resources');
}
return $next($request);
}
}
```
## Rate Limiting
### API Rate Limits
```php
// API throttling configuration
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Deployment rate limiting
RateLimiter::for('deployments', function (Request $request) {
return Limit::perMinute(10)->by($request->user()->id);
});
```
### Webhook Rate Limiting
```php
// Webhook throttling
RateLimiter::for('webhooks', function (Request $request) {
return Limit::perMinute(100)->by($request->ip());
});
```
## Route Model Binding
### Custom Route Bindings
```php
// Custom model binding for applications
Route::bind('application', function ($value) {
return Application::where('uuid', $value)
->orWhere('id', $value)
->firstOrFail();
});
// Team-scoped model binding
Route::bind('team_application', function ($value, $route) {
$teamId = $route->parameter('team');
return Application::whereHas('environment.project', function ($query) use ($teamId) {
$query->where('team_id', $teamId);
})->findOrFail($value);
});
```
## API Documentation
### OpenAPI Specification
- **[openapi.json](mdc:openapi.json)** - API documentation (373KB, 8316 lines)
- **[openapi.yaml](mdc:openapi.yaml)** - YAML format documentation (184KB, 5579 lines)
### Documentation Generation
```php
// Swagger/OpenAPI annotations
/**
* @OA\Get(
* path="/api/v1/applications",
* summary="List applications",
* tags={"Applications"},
* security={{"bearerAuth":{}}},
* @OA\Response(
* response=200,
* description="List of applications",
* @OA\JsonContent(type="array", @OA\Items(ref="#/components/schemas/Application"))
* )
* )
*/
```
## Error Handling
### API Error Responses
```php
// Standardized error response format
class ApiExceptionHandler
{
public function render($request, Throwable $exception)
{
if ($request->expectsJson()) {
return response()->json([
'message' => $exception->getMessage(),
'error_code' => $this->getErrorCode($exception),
'timestamp' => now()->toISOString()
], $this->getStatusCode($exception));
}
return parent::render($request, $exception);
}
}
```
### Validation Error Handling
```php
// Form request validation
class StoreApplicationRequest extends FormRequest
{
public function rules()
{
return [
'name' => 'required|string|max:255',
'git_repository' => 'required|url',
'git_branch' => 'required|string',
'server_id' => 'required|exists:servers,id',
'environment_id' => 'required|exists:environments,id'
];
}
public function failedValidation(Validator $validator)
{
throw new HttpResponseException(
response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors()
], 422)
);
}
}
```
## Real-Time API Integration
### WebSocket Events
```php
// Broadcasting deployment events
class DeploymentStarted implements ShouldBroadcast
{
public $application;
public $deployment;
public function broadcastOn()
{
return [
new PrivateChannel("application.{$this->application->id}"),
new PrivateChannel("team.{$this->application->team->id}")
];
}
public function broadcastWith()
{
return [
'deployment_id' => $this->deployment->id,
'status' => 'started',
'timestamp' => now()
];
}
}
```
### API Event Streaming
```php
// Server-Sent Events for real-time updates
Route::get('/api/v1/applications/{application}/events', function (Application $application) {
return response()->stream(function () use ($application) {
while (true) {
$events = $application->getRecentEvents();
foreach ($events as $event) {
echo "data: " . json_encode($event) . "\n\n";
}
usleep(1000000); // 1 second
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
]);
});
```

View file

@ -1,324 +0,0 @@
# Coolify Database Architecture & Patterns
## Database Strategy
Coolify uses **PostgreSQL 15** as the primary database with **Redis 7** for caching and real-time features. The architecture supports managing multiple external databases across different servers.
## Primary Database (PostgreSQL)
### Core Tables & Models
#### User & Team Management
- **[User.php](mdc:app/Models/User.php)** - User authentication and profiles
- **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure
- **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Team collaboration invitations
- **[PersonalAccessToken.php](mdc:app/Models/PersonalAccessToken.php)** - API token management
#### Infrastructure Management
- **[Server.php](mdc:app/Models/Server.php)** - Physical/virtual server definitions (46KB, complex)
- **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - SSH key management
- **[ServerSetting.php](mdc:app/Models/ServerSetting.php)** - Server-specific configurations
#### Project Organization
- **[Project.php](mdc:app/Models/Project.php)** - Project containers for applications
- **[Environment.php](mdc:app/Models/Environment.php)** - Environment isolation (staging, production, etc.)
- **[ProjectSetting.php](mdc:app/Models/ProjectSetting.php)** - Project-specific settings
#### Application Deployment
- **[Application.php](mdc:app/Models/Application.php)** - Main application entity (74KB, highly complex)
- **[ApplicationSetting.php](mdc:app/Models/ApplicationSetting.php)** - Application configurations
- **[ApplicationDeploymentQueue.php](mdc:app/Models/ApplicationDeploymentQueue.php)** - Deployment orchestration
- **[ApplicationPreview.php](mdc:app/Models/ApplicationPreview.php)** - Preview environment management
#### Service Management
- **[Service.php](mdc:app/Models/Service.php)** - Service definitions (58KB, complex)
- **[ServiceApplication.php](mdc:app/Models/ServiceApplication.php)** - Service components
- **[ServiceDatabase.php](mdc:app/Models/ServiceDatabase.php)** - Service-attached databases
## Database Type Support
### Standalone Database Models
Each database type has its own dedicated model with specific configurations:
#### SQL Databases
- **[StandalonePostgresql.php](mdc:app/Models/StandalonePostgresql.php)** - PostgreSQL instances
- **[StandaloneMysql.php](mdc:app/Models/StandaloneMysql.php)** - MySQL instances
- **[StandaloneMariadb.php](mdc:app/Models/StandaloneMariadb.php)** - MariaDB instances
#### NoSQL & Analytics
- **[StandaloneMongodb.php](mdc:app/Models/StandaloneMongodb.php)** - MongoDB instances
- **[StandaloneClickhouse.php](mdc:app/Models/StandaloneClickhouse.php)** - ClickHouse analytics
#### Caching & In-Memory
- **[StandaloneRedis.php](mdc:app/Models/StandaloneRedis.php)** - Redis instances
- **[StandaloneKeydb.php](mdc:app/Models/StandaloneKeydb.php)** - KeyDB instances
- **[StandaloneDragonfly.php](mdc:app/Models/StandaloneDragonfly.php)** - Dragonfly instances
## Configuration Management
### Environment Variables
- **[EnvironmentVariable.php](mdc:app/Models/EnvironmentVariable.php)** - Application-specific environment variables
- **[SharedEnvironmentVariable.php](mdc:app/Models/SharedEnvironmentVariable.php)** - Shared across applications
### Settings Hierarchy
- **[InstanceSettings.php](mdc:app/Models/InstanceSettings.php)** - Global Coolify instance settings
- **[ServerSetting.php](mdc:app/Models/ServerSetting.php)** - Server-specific settings
- **[ProjectSetting.php](mdc:app/Models/ProjectSetting.php)** - Project-level settings
- **[ApplicationSetting.php](mdc:app/Models/ApplicationSetting.php)** - Application settings
## Storage & Backup Systems
### Storage Management
- **[S3Storage.php](mdc:app/Models/S3Storage.php)** - S3-compatible storage configurations
- **[LocalFileVolume.php](mdc:app/Models/LocalFileVolume.php)** - Local filesystem volumes
- **[LocalPersistentVolume.php](mdc:app/Models/LocalPersistentVolume.php)** - Persistent volume management
### Backup Infrastructure
- **[ScheduledDatabaseBackup.php](mdc:app/Models/ScheduledDatabaseBackup.php)** - Automated backup scheduling
- **[ScheduledDatabaseBackupExecution.php](mdc:app/Models/ScheduledDatabaseBackupExecution.php)** - Backup execution tracking
### Task Scheduling
- **[ScheduledTask.php](mdc:app/Models/ScheduledTask.php)** - Cron job management
- **[ScheduledTaskExecution.php](mdc:app/Models/ScheduledTaskExecution.php)** - Task execution history
## Notification & Integration Models
### Notification Channels
- **[EmailNotificationSettings.php](mdc:app/Models/EmailNotificationSettings.php)** - Email notifications
- **[DiscordNotificationSettings.php](mdc:app/Models/DiscordNotificationSettings.php)** - Discord integration
- **[SlackNotificationSettings.php](mdc:app/Models/SlackNotificationSettings.php)** - Slack integration
- **[TelegramNotificationSettings.php](mdc:app/Models/TelegramNotificationSettings.php)** - Telegram bot
- **[PushoverNotificationSettings.php](mdc:app/Models/PushoverNotificationSettings.php)** - Pushover notifications
### Source Control Integration
- **[GithubApp.php](mdc:app/Models/GithubApp.php)** - GitHub App integration
- **[GitlabApp.php](mdc:app/Models/GitlabApp.php)** - GitLab integration
### OAuth & Authentication
- **[OauthSetting.php](mdc:app/Models/OauthSetting.php)** - OAuth provider configurations
## Docker & Container Management
### Container Orchestration
- **[StandaloneDocker.php](mdc:app/Models/StandaloneDocker.php)** - Standalone Docker containers
- **[SwarmDocker.php](mdc:app/Models/SwarmDocker.php)** - Docker Swarm management
### SSL & Security
- **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate management
## Database Migration Strategy
### Migration Location: [database/migrations/](mdc:database/migrations)
#### Migration Patterns
```php
// Typical Coolify migration structure
Schema::create('applications', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('fqdn')->nullable();
$table->json('environment_variables')->nullable();
$table->foreignId('destination_id');
$table->foreignId('source_id');
$table->timestamps();
});
```
### Schema Versioning
- **Incremental migrations** for database evolution
- **Data migrations** for complex transformations
- **Rollback support** for deployment safety
## Eloquent Model Patterns
### Base Model Structure
- **[BaseModel.php](mdc:app/Models/BaseModel.php)** - Common model functionality
- **UUID primary keys** for distributed systems
- **Soft deletes** for audit trails
- **Activity logging** with Spatie package
### **CRITICAL: Mass Assignment Protection**
**When adding new database columns, you MUST update the model's `$fillable` array.** Without this, Laravel will silently ignore mass assignment operations like `Model::create()` or `$model->update()`.
**Checklist for new columns:**
1. ✅ Create migration file
2. ✅ Run migration
3. ✅ **Add column to model's `$fillable` array**
4. ✅ Update any Livewire components that sync this property
5. ✅ Test that the column can be read and written
**Example:**
```php
class Server extends BaseModel
{
protected $fillable = [
'name',
'ip',
'port',
'is_validating', // ← MUST add new columns here
];
}
```
### Relationship Patterns
```php
// Typical relationship structure in Application model
class Application extends Model
{
public function server()
{
return $this->belongsTo(Server::class);
}
public function environment()
{
return $this->belongsTo(Environment::class);
}
public function deployments()
{
return $this->hasMany(ApplicationDeploymentQueue::class);
}
public function environmentVariables()
{
return $this->hasMany(EnvironmentVariable::class);
}
}
```
### Model Traits
```php
// Common traits used across models
use SoftDeletes;
use LogsActivity;
use HasFactory;
use HasUuids;
```
## Caching Strategy (Redis)
### Cache Usage Patterns
- **Session storage** - User authentication sessions
- **Queue backend** - Background job processing
- **Model caching** - Expensive query results
- **Real-time data** - WebSocket state management
### Cache Keys Structure
```
coolify:session:{session_id}
coolify:server:{server_id}:status
coolify:deployment:{deployment_id}:logs
coolify:user:{user_id}:teams
```
## Query Optimization Patterns
### Eager Loading
```php
// Optimized queries with relationships
$applications = Application::with([
'server',
'environment.project',
'environmentVariables',
'deployments' => function ($query) {
$query->latest()->limit(5);
}
])->get();
```
### Chunking for Large Datasets
```php
// Processing large datasets efficiently
Server::chunk(100, function ($servers) {
foreach ($servers as $server) {
// Process server monitoring
}
});
```
### Database Indexes
- **Primary keys** on all tables
- **Foreign key indexes** for relationships
- **Composite indexes** for common queries
- **Unique constraints** for business rules
## Data Consistency Patterns
### Database Transactions
```php
// Atomic operations for deployment
DB::transaction(function () {
$application = Application::create($data);
$application->environmentVariables()->createMany($envVars);
$application->deployments()->create(['status' => 'queued']);
});
```
### Model Events
```php
// Automatic cleanup on model deletion
class Application extends Model
{
protected static function booted()
{
static::deleting(function ($application) {
$application->environmentVariables()->delete();
$application->deployments()->delete();
});
}
}
```
## Backup & Recovery
### Database Backup Strategy
- **Automated PostgreSQL backups** via scheduled tasks
- **Point-in-time recovery** capability
- **Cross-region backup** replication
- **Backup verification** and testing
### Data Export/Import
- **Application configurations** export/import
- **Environment variable** bulk operations
- **Server configurations** backup and restore
## Performance Monitoring
### Query Performance
- **Laravel Telescope** for development debugging
- **Slow query logging** in production
- **Database connection** pooling
- **Read replica** support for scaling
### Metrics Collection
- **Database size** monitoring
- **Connection count** tracking
- **Query execution time** analysis
- **Cache hit rates** monitoring
## Multi-Tenancy Pattern
### Team-Based Isolation
```php
// Global scope for team-based filtering
class Application extends Model
{
protected static function booted()
{
static::addGlobalScope('team', function (Builder $builder) {
if (auth()->user()) {
$builder->whereHas('environment.project', function ($query) {
$query->where('team_id', auth()->user()->currentTeam->id);
});
}
});
}
}
```
### Data Separation
- **Team-scoped queries** by default
- **Cross-team access** controls
- **Admin access** patterns
- **Data isolation** guarantees

View file

@ -1,447 +0,0 @@
# Enhanced Form Components with Authorization
## Overview
Coolify's form components now feature **built-in authorization** that automatically handles permission-based UI control, dramatically reducing code duplication and improving security consistency.
## Enhanced Components
All form components now support the `canGate` authorization system:
- **[Input.php](mdc:app/View/Components/Forms/Input.php)** - Text, password, and other input fields
- **[Select.php](mdc:app/View/Components/Forms/Select.php)** - Dropdown selection components
- **[Textarea.php](mdc:app/View/Components/Forms/Textarea.php)** - Multi-line text areas
- **[Checkbox.php](mdc:app/View/Components/Forms/Checkbox.php)** - Boolean toggle components
- **[Button.php](mdc:app/View/Components/Forms/Button.php)** - Action buttons
## Authorization Parameters
### Core Parameters
```php
public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete'
public mixed $canResource = null; // Resource model instance to check against
public bool $autoDisable = true; // Automatically disable if no permission
```
### How It Works
```php
// Automatic authorization logic in each component
if ($this->canGate && $this->canResource && $this->autoDisable) {
$hasPermission = Gate::allows($this->canGate, $this->canResource);
if (! $hasPermission) {
$this->disabled = true;
// For Checkbox: also sets $this->instantSave = false;
}
}
```
## Usage Patterns
### ✅ Recommended: Single Line Pattern
**Before (Verbose, 6+ lines per element):**
```html
@can('update', $application)
<x-forms.input id="application.name" label="Name" />
<x-forms.checkbox instantSave id="application.settings.is_static" label="Static Site" />
<x-forms.button type="submit">Save</x-forms.button>
@else
<x-forms.input disabled id="application.name" label="Name" />
<x-forms.checkbox disabled id="application.settings.is_static" label="Static Site" />
@endcan
```
**After (Clean, 1 line per element):**
```html
<x-forms.input canGate="update" :canResource="$application" id="application.name" label="Name" />
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="application.settings.is_static" label="Static Site" />
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
```
**Result: 90% code reduction!**
### Component-Specific Examples
#### Input Fields
```html
<!-- Basic input with authorization -->
<x-forms.input
canGate="update"
:canResource="$application"
id="application.name"
label="Application Name" />
<!-- Password input with authorization -->
<x-forms.input
type="password"
canGate="update"
:canResource="$application"
id="application.database_password"
label="Database Password" />
<!-- Required input with authorization -->
<x-forms.input
required
canGate="update"
:canResource="$application"
id="application.fqdn"
label="Domain" />
```
#### Select Dropdowns
```html
<!-- Build pack selection -->
<x-forms.select
canGate="update"
:canResource="$application"
id="application.build_pack"
label="Build Pack"
required>
<option value="nixpacks">Nixpacks</option>
<option value="static">Static</option>
<option value="dockerfile">Dockerfile</option>
</x-forms.select>
<!-- Server selection -->
<x-forms.select
canGate="createAnyResource"
:canResource="auth()->user()->currentTeam"
id="server_id"
label="Target Server">
@foreach($servers as $server)
<option value="{{ $server->id }}">{{ $server->name }}</option>
@endforeach
</x-forms.select>
```
#### Checkboxes with InstantSave
```html
<!-- Static site toggle -->
<x-forms.checkbox
instantSave
canGate="update"
:canResource="$application"
id="application.settings.is_static"
label="Is it a static site?"
helper="Enable if your application serves static files" />
<!-- Debug mode toggle -->
<x-forms.checkbox
instantSave
canGate="update"
:canResource="$application"
id="application.settings.is_debug_enabled"
label="Debug Mode"
helper="Enable debug logging for troubleshooting" />
<!-- Build server toggle -->
<x-forms.checkbox
instantSave
canGate="update"
:canResource="$application"
id="application.settings.is_build_server_enabled"
label="Use Build Server"
helper="Use a dedicated build server for compilation" />
```
#### Textareas
```html
<!-- Configuration textarea -->
<x-forms.textarea
canGate="update"
:canResource="$application"
id="application.docker_compose_raw"
label="Docker Compose Configuration"
rows="10"
monacoEditorLanguage="yaml"
useMonacoEditor />
<!-- Custom commands -->
<x-forms.textarea
canGate="update"
:canResource="$application"
id="application.post_deployment_command"
label="Post-Deployment Commands"
placeholder="php artisan migrate"
helper="Commands to run after deployment" />
```
#### Buttons
```html
<!-- Save button -->
<x-forms.button
canGate="update"
:canResource="$application"
type="submit">
Save Configuration
</x-forms.button>
<!-- Deploy button -->
<x-forms.button
canGate="deploy"
:canResource="$application"
wire:click="deploy">
Deploy Application
</x-forms.button>
<!-- Delete button -->
<x-forms.button
canGate="delete"
:canResource="$application"
wire:click="confirmDelete"
class="button-danger">
Delete Application
</x-forms.button>
```
## Advanced Usage
### Custom Authorization Logic
```html
<!-- Disable auto-control for complex permissions -->
<x-forms.input
canGate="update"
:canResource="$application"
autoDisable="false"
:disabled="$application->is_deployed || !$application->canModifySettings()"
id="deployment.setting"
label="Advanced Setting" />
```
### Multiple Permission Checks
```html
<!-- Combine multiple authorization requirements -->
<x-forms.checkbox
canGate="deploy"
:canResource="$application"
autoDisable="false"
:disabled="!$application->hasDockerfile() || !Gate::allows('deploy', $application)"
id="docker.setting"
label="Docker-Specific Setting" />
```
### Conditional Resources
```html
<!-- Different resources based on context -->
<x-forms.button
:canGate="$isEditing ? 'update' : 'view'"
:canResource="$resource"
type="submit">
{{ $isEditing ? 'Save Changes' : 'View Details' }}
</x-forms.button>
```
## Supported Gates
### Resource-Level Gates
- `view` - Read access to resource details
- `update` - Modify resource configuration and settings
- `deploy` - Deploy, restart, or manage resource state
- `delete` - Remove or destroy resource
- `clone` - Duplicate resource to another location
### Global Gates
- `createAnyResource` - Create new resources of any type
- `manageTeam` - Team administration permissions
- `accessServer` - Server-level access permissions
## Supported Resources
### Primary Resources
- `$application` - Application instances and configurations
- `$service` - Docker Compose services and components
- `$database` - Database instances (PostgreSQL, MySQL, etc.)
- `$server` - Physical or virtual server instances
### Container Resources
- `$project` - Project containers and environments
- `$environment` - Environment-specific configurations
- `$team` - Team and organization contexts
### Infrastructure Resources
- `$privateKey` - SSH private keys and certificates
- `$source` - Git sources and repositories
- `$destination` - Deployment destinations and targets
## Component Behavior
### Input Components (Input, Select, Textarea)
When authorization fails:
- **disabled = true** - Field becomes non-editable
- **Visual styling** - Opacity reduction and disabled cursor
- **Form submission** - Values are ignored in forms
- **User feedback** - Clear visual indication of restricted access
### Checkbox Components
When authorization fails:
- **disabled = true** - Checkbox becomes non-clickable
- **instantSave = false** - Automatic saving is disabled
- **State preservation** - Current value is maintained but read-only
- **Visual styling** - Disabled appearance with reduced opacity
### Button Components
When authorization fails:
- **disabled = true** - Button becomes non-clickable
- **Event blocking** - Click handlers are ignored
- **Visual styling** - Disabled appearance and cursor
- **Loading states** - Loading indicators are disabled
## Migration Guide
### Converting Existing Forms
**Old Pattern:**
```html
<form wire:submit='submit'>
@can('update', $application)
<x-forms.input id="name" label="Name" />
<x-forms.select id="type" label="Type">...</x-forms.select>
<x-forms.checkbox instantSave id="enabled" label="Enabled" />
<x-forms.button type="submit">Save</x-forms.button>
@else
<x-forms.input disabled id="name" label="Name" />
<x-forms.select disabled id="type" label="Type">...</x-forms.select>
<x-forms.checkbox disabled id="enabled" label="Enabled" />
@endcan
</form>
```
**New Pattern:**
```html
<form wire:submit='submit'>
<x-forms.input canGate="update" :canResource="$application" id="name" label="Name" />
<x-forms.select canGate="update" :canResource="$application" id="type" label="Type">...</x-forms.select>
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="enabled" label="Enabled" />
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
</form>
```
### Gradual Migration Strategy
1. **Start with new forms** - Use the new pattern for all new components
2. **Convert high-traffic areas** - Migrate frequently used forms first
3. **Batch convert similar forms** - Group similar authorization patterns
4. **Test thoroughly** - Verify authorization behavior matches expectations
5. **Remove old patterns** - Clean up legacy @can/@else blocks
## Testing Patterns
### Component Authorization Tests
```php
// Test authorization integration in components
test('input component respects authorization', function () {
$user = User::factory()->member()->create();
$application = Application::factory()->create();
// Member should see disabled input
$component = Livewire::actingAs($user)
->test(TestComponent::class, [
'canGate' => 'update',
'canResource' => $application
]);
expect($component->get('disabled'))->toBeTrue();
});
test('checkbox disables instantSave for unauthorized users', function () {
$user = User::factory()->member()->create();
$application = Application::factory()->create();
$component = Livewire::actingAs($user)
->test(CheckboxComponent::class, [
'instantSave' => true,
'canGate' => 'update',
'canResource' => $application
]);
expect($component->get('disabled'))->toBeTrue();
expect($component->get('instantSave'))->toBeFalse();
});
```
### Integration Tests
```php
// Test full form authorization behavior
test('application form respects member permissions', function () {
$member = User::factory()->member()->create();
$application = Application::factory()->create();
$this->actingAs($member)
->get(route('application.edit', $application))
->assertSee('disabled')
->assertDontSee('Save Configuration');
});
```
## Best Practices
### Consistent Gate Usage
- Use `update` for configuration changes
- Use `deploy` for operational actions
- Use `view` for read-only access
- Use `delete` for destructive actions
### Resource Context
- Always pass the specific resource being acted upon
- Use team context for creation permissions
- Consider nested resource relationships
### Error Handling
- Provide clear feedback for disabled components
- Use helper text to explain permission requirements
- Consider tooltips for disabled buttons
### Performance
- Authorization checks are cached per request
- Use eager loading for resource relationships
- Consider query optimization for complex permissions
## Common Patterns
### Application Configuration Forms
```html
<!-- Application settings with consistent authorization -->
<x-forms.input canGate="update" :canResource="$application" id="application.name" label="Name" />
<x-forms.select canGate="update" :canResource="$application" id="application.build_pack" label="Build Pack">...</x-forms.select>
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="application.settings.is_static" label="Static Site" />
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
```
### Service Configuration Forms
```html
<!-- Service stack configuration with authorization -->
<x-forms.input canGate="update" :canResource="$service" id="service.name" label="Service Name" />
<x-forms.input canGate="update" :canResource="$service" id="service.description" label="Description" />
<x-forms.checkbox canGate="update" :canResource="$service" instantSave id="service.connect_to_docker_network" label="Connect To Predefined Network" />
<x-forms.button canGate="update" :canResource="$service" type="submit">Save</x-forms.button>
<!-- Service-specific fields -->
<x-forms.input canGate="update" :canResource="$service" type="{{ data_get($field, 'isPassword') ? 'password' : 'text' }}"
required="{{ str(data_get($field, 'rules'))?->contains('required') }}"
id="fields.{{ $serviceName }}.value"></x-forms.input>
<!-- Service restart modal - wrapped with @can -->
@can('update', $service)
<x-modal-confirmation title="Confirm Service Application Restart?"
buttonTitle="Restart"
submitAction="restartApplication({{ $application->id }})" />
@endcan
```
### Server Management Forms
```html
<!-- Server configuration with appropriate gates -->
<x-forms.input canGate="update" :canResource="$server" id="server.name" label="Server Name" />
<x-forms.select canGate="update" :canResource="$server" id="server.type" label="Server Type">...</x-forms.select>
<x-forms.button canGate="delete" :canResource="$server" wire:click="deleteServer">Delete Server</x-forms.button>
```
### Resource Creation Forms
```html
<!-- New resource creation -->
<x-forms.input canGate="createAnyResource" :canResource="auth()->user()->currentTeam" id="name" label="Name" />
<x-forms.select canGate="createAnyResource" :canResource="auth()->user()->currentTeam" id="server_id" label="Server">...</x-forms.select>
<x-forms.button canGate="createAnyResource" :canResource="auth()->user()->currentTeam" type="submit">Create Application</x-forms.button>
```

View file

@ -1,696 +0,0 @@
# Coolify Frontend Architecture & Patterns
## Frontend Philosophy
Coolify uses a **server-side first** approach with minimal JavaScript, leveraging Livewire for reactivity and Alpine.js for lightweight client-side interactions.
## Core Frontend Stack
### Livewire 3.5+ (Primary Framework)
- **Server-side rendering** with reactive components
- **Real-time updates** without page refreshes
- **State management** handled on the server
- **WebSocket integration** for live updates
### Alpine.js (Client-Side Interactivity)
- **Lightweight JavaScript** for DOM manipulation
- **Declarative directives** in HTML
- **Component-like behavior** without build steps
- **Perfect companion** to Livewire
### Tailwind CSS 4.1+ (Styling)
- **Utility-first** CSS framework
- **Custom design system** for deployment platform
- **Responsive design** built-in
- **Dark mode support**
## Livewire Component Structure
### Location: [app/Livewire/](mdc:app/Livewire)
#### Core Application Components
- **[Dashboard.php](mdc:app/Livewire/Dashboard.php)** - Main dashboard interface
- **[ActivityMonitor.php](mdc:app/Livewire/ActivityMonitor.php)** - Real-time activity tracking
- **[MonacoEditor.php](mdc:app/Livewire/MonacoEditor.php)** - Code editor component
#### Server Management
- **Server/** directory - Server configuration and monitoring
- Real-time server status updates
- SSH connection management
- Resource monitoring
#### Project & Application Management
- **Project/** directory - Project organization
- Application deployment interfaces
- Environment variable management
- Service configuration
#### Settings & Configuration
- **Settings/** directory - System configuration
- **[SettingsEmail.php](mdc:app/Livewire/SettingsEmail.php)** - Email notification setup
- **[SettingsOauth.php](mdc:app/Livewire/SettingsOauth.php)** - OAuth provider configuration
- **[SettingsBackup.php](mdc:app/Livewire/SettingsBackup.php)** - Backup configuration
#### User & Team Management
- **Team/** directory - Team collaboration features
- **Profile/** directory - User profile management
- **Security/** directory - Security settings
## Blade Template Organization
### Location: [resources/views/](mdc:resources/views)
#### Layout Structure
- **layouts/** - Base layout templates
- **components/** - Reusable UI components
- **livewire/** - Livewire component views
#### Feature-Specific Views
- **server/** - Server management interfaces
- **auth/** - Authentication pages
- **emails/** - Email templates
- **errors/** - Error pages
## Interactive Components
### Monaco Editor Integration
- **Code editing** for configuration files
- **Syntax highlighting** for multiple languages
- **Live validation** and error detection
- **Integration** with deployment process
### Terminal Emulation (XTerm.js)
- **Real-time terminal** access to servers
- **WebSocket-based** communication
- **Multi-session** support
- **Secure connection** through SSH
### Real-Time Updates
- **WebSocket connections** via Laravel Echo
- **Live deployment logs** streaming
- **Server monitoring** with live metrics
- **Activity notifications** in real-time
## Alpine.js Patterns
### Common Directives Used
```html
<!-- State management -->
<div x-data="{ open: false }">
<!-- Event handling -->
<button x-on:click="open = !open">
<!-- Conditional rendering -->
<div x-show="open">
<!-- Data binding -->
<input x-model="searchTerm">
<!-- Component initialization -->
<div x-init="initializeComponent()">
```
### Integration with Livewire
```html
<!-- Livewire actions with Alpine state -->
<button
x-data="{ loading: false }"
x-on:click="loading = true"
wire:click="deploy"
wire:loading.attr="disabled"
wire:target="deploy"
>
<span x-show="!loading">Deploy</span>
<span x-show="loading">Deploying...</span>
</button>
```
## Tailwind CSS Patterns
### Design System
- **Consistent spacing** using Tailwind scale
- **Color palette** optimized for deployment platform
- **Typography** hierarchy for technical content
- **Component classes** for reusable elements
### Responsive Design
```html
<!-- Mobile-first responsive design -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<!-- Content adapts to screen size -->
</div>
```
### Dark Mode Support
```html
<!-- Dark mode variants -->
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<!-- Automatic dark mode switching -->
</div>
```
## Build Process
### Vite Configuration ([vite.config.js](mdc:vite.config.js))
- **Fast development** with hot module replacement
- **Optimized production** builds
- **Asset versioning** for cache busting
- **CSS processing** with PostCSS
### Asset Compilation
```bash
# Development
npm run dev
# Production build
npm run build
```
## State Management Patterns
### Server-Side State (Livewire)
- **Component properties** for persistent state
- **Session storage** for user preferences
- **Database models** for application state
- **Cache layer** for performance
### Client-Side State (Alpine.js)
- **Local component state** for UI interactions
- **Form validation** and user feedback
- **Modal and dropdown** state management
- **Temporary UI states** (loading, hover, etc.)
## Real-Time Features
### WebSocket Integration
```php
// Livewire component with real-time updates
class ActivityMonitor extends Component
{
public function getListeners()
{
return [
'deployment.started' => 'refresh',
'deployment.finished' => 'refresh',
'server.status.changed' => 'updateServerStatus',
];
}
}
```
### Event Broadcasting
- **Laravel Echo** for client-side WebSocket handling
- **Pusher protocol** for real-time communication
- **Private channels** for user-specific events
- **Presence channels** for collaborative features
## Performance Patterns
### Lazy Loading
```php
// Livewire lazy loading
class ServerList extends Component
{
public function placeholder()
{
return view('components.loading-skeleton');
}
}
```
### Caching Strategies
- **Fragment caching** for expensive operations
- **Image optimization** with lazy loading
- **Asset bundling** and compression
- **CDN integration** for static assets
## Enhanced Form Components
### Built-in Authorization System
Coolify features **enhanced form components** with automatic authorization handling:
```html
<!-- ✅ New Pattern: Single line with built-in authorization -->
<x-forms.input canGate="update" :canResource="$application" id="application.name" label="Name" />
<x-forms.checkbox instantSave canGate="update" :canResource="$application" id="application.settings.is_static" label="Static Site" />
<x-forms.button canGate="update" :canResource="$application" type="submit">Save</x-forms.button>
<!-- ❌ Old Pattern: Verbose @can/@else blocks (deprecated) -->
@can('update', $application)
<x-forms.input id="application.name" label="Name" />
@else
<x-forms.input disabled id="application.name" label="Name" />
@endcan
```
### Authorization Parameters
```php
// Available on all form components (Input, Select, Textarea, Checkbox, Button)
public ?string $canGate = null; // Gate name: 'update', 'view', 'deploy', 'delete'
public mixed $canResource = null; // Resource model instance to check against
public bool $autoDisable = true; // Automatically disable if no permission (default: true)
```
### Benefits
- **90% code reduction** for authorization-protected forms
- **Consistent security** across all form components
- **Automatic disabling** for unauthorized users
- **Smart behavior** (disables instantSave on checkboxes for unauthorized users)
For complete documentation, see **[form-components.md](.ai/patterns/form-components.md)**
## Form Handling Patterns
### Livewire Component Data Synchronization Pattern
**IMPORTANT**: All Livewire components must use the **manual `syncData()` pattern** for synchronizing component properties with Eloquent models.
#### Property Naming Convention
- **Component properties**: Use camelCase (e.g., `$gitRepository`, `$isStatic`)
- **Database columns**: Use snake_case (e.g., `git_repository`, `is_static`)
- **View bindings**: Use camelCase matching component properties (e.g., `id="gitRepository"`)
#### The syncData() Method Pattern
```php
use Livewire\Attributes\Validate;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
class MyComponent extends Component
{
use AuthorizesRequests;
public Application $application;
// Properties with validation attributes
#[Validate(['required'])]
public string $name;
#[Validate(['string', 'nullable'])]
public ?string $description = null;
#[Validate(['boolean', 'required'])]
public bool $isStatic = false;
public function mount()
{
$this->authorize('view', $this->application);
$this->syncData(); // Load from model
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Sync TO model (camelCase → snake_case)
$this->application->name = $this->name;
$this->application->description = $this->description;
$this->application->is_static = $this->isStatic;
$this->application->save();
} else {
// Sync FROM model (snake_case → camelCase)
$this->name = $this->application->name;
$this->description = $this->application->description;
$this->isStatic = $this->application->is_static;
}
}
public function submit()
{
$this->authorize('update', $this->application);
$this->syncData(toModel: true); // Save to model
$this->dispatch('success', 'Saved successfully.');
}
}
```
#### Validation with #[Validate] Attributes
All component properties should have `#[Validate]` attributes:
```php
// Boolean properties
#[Validate(['boolean'])]
public bool $isEnabled = false;
// Required strings
#[Validate(['string', 'required'])]
public string $name;
// Nullable strings
#[Validate(['string', 'nullable'])]
public ?string $description = null;
// With constraints
#[Validate(['integer', 'min:1'])]
public int $timeout;
```
#### Benefits of syncData() Pattern
- **Explicit Control**: Clear visibility of what's being synchronized
- **Type Safety**: #[Validate] attributes provide compile-time validation info
- **Easy Debugging**: Single method to check for data flow issues
- **Maintainability**: All sync logic in one place
- **Flexibility**: Can add custom logic (encoding, transformations, etc.)
#### Creating New Form Components with syncData()
#### Step-by-Step Component Creation Guide
**Step 1: Define properties in camelCase with #[Validate] attributes**
```php
use Livewire\Attributes\Validate;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class MyFormComponent extends Component
{
use AuthorizesRequests;
// The model we're syncing with
public Application $application;
// Component properties in camelCase with validation
#[Validate(['string', 'required'])]
public string $name;
#[Validate(['string', 'nullable'])]
public ?string $gitRepository = null;
#[Validate(['string', 'nullable'])]
public ?string $installCommand = null;
#[Validate(['boolean'])]
public bool $isStatic = false;
}
```
**Step 2: Implement syncData() method**
```php
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Sync TO model (component camelCase → database snake_case)
$this->application->name = $this->name;
$this->application->git_repository = $this->gitRepository;
$this->application->install_command = $this->installCommand;
$this->application->is_static = $this->isStatic;
$this->application->save();
} else {
// Sync FROM model (database snake_case → component camelCase)
$this->name = $this->application->name;
$this->gitRepository = $this->application->git_repository;
$this->installCommand = $this->application->install_command;
$this->isStatic = $this->application->is_static;
}
}
```
**Step 3: Implement mount() to load initial data**
```php
public function mount()
{
$this->authorize('view', $this->application);
$this->syncData(); // Load data from model to component properties
}
```
**Step 4: Implement action methods with authorization**
```php
public function instantSave()
{
try {
$this->authorize('update', $this->application);
$this->syncData(toModel: true); // Save component properties to model
$this->dispatch('success', 'Settings saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {
$this->authorize('update', $this->application);
$this->syncData(toModel: true); // Save component properties to model
$this->dispatch('success', 'Changes saved successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
```
**Step 5: Create Blade view with camelCase bindings**
```blade
<div>
<form wire:submit="submit">
<x-forms.input
canGate="update"
:canResource="$application"
id="name"
label="Name"
required />
<x-forms.input
canGate="update"
:canResource="$application"
id="gitRepository"
label="Git Repository" />
<x-forms.input
canGate="update"
:canResource="$application"
id="installCommand"
label="Install Command" />
<x-forms.checkbox
instantSave
canGate="update"
:canResource="$application"
id="isStatic"
label="Static Site" />
<x-forms.button
canGate="update"
:canResource="$application"
type="submit">
Save Changes
</x-forms.button>
</form>
</div>
```
**Key Points**:
- Use `wire:model="camelCase"` and `id="camelCase"` in Blade views
- Component properties are camelCase, database columns are snake_case
- Always include authorization checks (`authorize()`, `canGate`, `canResource`)
- Use `instantSave` for checkboxes that save immediately without form submission
#### Special Patterns
**Pattern 1: Related Models (e.g., Application → Settings)**
```php
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Sync main model
$this->application->name = $this->name;
$this->application->save();
// Sync related model
$this->application->settings->is_static = $this->isStatic;
$this->application->settings->save();
} else {
// From main model
$this->name = $this->application->name;
// From related model
$this->isStatic = $this->application->settings->is_static;
}
}
```
**Pattern 2: Custom Encoding/Decoding**
```php
public function syncData(bool $toModel = false): void
{
if ($toModel) {
$this->validate();
// Encode before saving
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
} else {
// Decode when loading
$this->customLabels = $this->application->parseContainerLabels();
}
}
```
**Pattern 3: Error Rollback**
```php
public function submit()
{
$this->authorize('update', $this->resource);
$original = $this->model->getOriginal();
try {
$this->syncData(toModel: true);
$this->dispatch('success', 'Saved successfully.');
} catch (\Throwable $e) {
// Rollback on error
$this->model->setRawAttributes($original);
$this->model->save();
$this->syncData(); // Reload from model
return handleError($e, $this);
}
}
```
#### Property Type Patterns
**Required Strings**
```php
#[Validate(['string', 'required'])]
public string $name; // No ?, no default, always has value
```
**Nullable Strings**
```php
#[Validate(['string', 'nullable'])]
public ?string $description = null; // ?, = null, can be empty
```
**Booleans**
```php
#[Validate(['boolean'])]
public bool $isEnabled = false; // Always has default value
```
**Integers with Constraints**
```php
#[Validate(['integer', 'min:1'])]
public int $timeout; // Required
#[Validate(['integer', 'min:1', 'nullable'])]
public ?int $port = null; // Nullable
```
#### Testing Checklist
After creating a new component with syncData(), verify:
- [ ] All checkboxes save correctly (especially `instantSave` ones)
- [ ] All form inputs persist to database
- [ ] Custom encoded fields (like labels) display correctly if applicable
- [ ] Form validation works for all fields
- [ ] No console errors in browser
- [ ] Authorization checks work (`@can` directives and `authorize()` calls)
- [ ] Error rollback works if exceptions occur
- [ ] Related models save correctly if applicable (e.g., Application + ApplicationSetting)
#### Common Pitfalls to Avoid
1. **snake_case in component properties**: Always use camelCase for component properties (e.g., `$gitRepository` not `$git_repository`)
2. **Missing #[Validate] attributes**: Every property should have validation attributes for type safety
3. **Forgetting to call syncData()**: Must call `syncData()` in `mount()` to load initial data
4. **Missing authorization**: Always use `authorize()` in methods and `canGate`/`canResource` in views
5. **View binding mismatch**: Use camelCase in Blade (e.g., `id="gitRepository"` not `id="git_repository"`)
6. **wire:model vs wire:model.live**: Use `.live` for `instantSave` checkboxes to avoid timing issues
7. **Validation sync**: If using `rules()` method, keep it in sync with `#[Validate]` attributes
8. **Related models**: Don't forget to save both main and related models in syncData() method
### Livewire Forms
```php
class ServerCreateForm extends Component
{
public $name;
public $ip;
protected $rules = [
'name' => 'required|min:3',
'ip' => 'required|ip',
];
public function save()
{
$this->validate();
// Save logic
}
}
```
### Real-Time Validation
- **Live validation** as user types
- **Server-side validation** rules
- **Error message** display
- **Success feedback** patterns
## Component Communication
### Parent-Child Communication
```php
// Parent component
$this->emit('serverCreated', $server->id);
// Child component
protected $listeners = ['serverCreated' => 'refresh'];
```
### Cross-Component Events
- **Global events** for application-wide updates
- **Scoped events** for feature-specific communication
- **Browser events** for JavaScript integration
## Error Handling & UX
### Loading States
- **Skeleton screens** during data loading
- **Progress indicators** for long operations
- **Optimistic updates** with rollback capability
### Error Display
- **Toast notifications** for user feedback
- **Inline validation** errors
- **Global error** handling
- **Retry mechanisms** for failed operations
## Accessibility Patterns
### ARIA Labels and Roles
```html
<button
aria-label="Deploy application"
aria-describedby="deploy-help"
wire:click="deploy"
>
Deploy
</button>
```
### Keyboard Navigation
- **Tab order** management
- **Keyboard shortcuts** for power users
- **Focus management** in modals and forms
- **Screen reader** compatibility
## Mobile Optimization
### Touch-Friendly Interface
- **Larger tap targets** for mobile devices
- **Swipe gestures** where appropriate
- **Mobile-optimized** forms and navigation
### Progressive Enhancement
- **Core functionality** works without JavaScript
- **Enhanced experience** with JavaScript enabled
- **Offline capabilities** where possible

File diff suppressed because it is too large Load diff

View file

@ -1,8 +0,0 @@
_____ _ _ __
/ ____| | (_)/ _|
| | ___ ___ | |_| |_ _ _
| | / _ \ / _ \| | | _| | | |
| |___| (_) | (_) | | | | | |_| |
\_____\___/ \___/|_|_|_| \__, |
__/ |
|___/

View file

@ -1,11 +0,0 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

View file

@ -1,156 +0,0 @@
---
title: Coolify AI Documentation
description: Master reference to all Coolify AI documentation in .ai/ directory
globs: **/*
alwaysApply: true
---
# Coolify AI Documentation
All Coolify AI documentation has been consolidated in the **`.ai/`** directory for better organization and single source of truth.
## Quick Start
- **For Claude Code**: Start with `CLAUDE.md` in the root directory
- **For Cursor IDE**: Start with `.ai/README.md` for navigation
- **For All AI Tools**: Browse `.ai/` directory by topic
## Documentation Structure
All detailed documentation lives in `.ai/` with the following organization:
### 📚 Core Documentation
- **[Technology Stack](.ai/core/technology-stack.md)** - All versions, packages, dependencies (SINGLE SOURCE OF TRUTH for versions)
- **[Project Overview](.ai/core/project-overview.md)** - What Coolify is, high-level architecture
- **[Application Architecture](.ai/core/application-architecture.md)** - System design, components, relationships
- **[Deployment Architecture](.ai/core/deployment-architecture.md)** - Deployment flows, Docker, proxies
### 💻 Development
- **[Development Workflow](.ai/development/development-workflow.md)** - Dev setup, commands, daily workflows
- **[Testing Patterns](.ai/development/testing-patterns.md)** - How to write/run tests, Docker requirements
- **[Laravel Boost](.ai/development/laravel-boost.md)** - Laravel-specific guidelines (SINGLE SOURCE for Laravel Boost)
### 🎨 Code Patterns
- **[Database Patterns](.ai/patterns/database-patterns.md)** - Eloquent, migrations, relationships
- **[Frontend Patterns](.ai/patterns/frontend-patterns.md)** - Livewire, Alpine.js, Tailwind CSS
- **[Security Patterns](.ai/patterns/security-patterns.md)** - Auth, authorization, security
- **[Form Components](.ai/patterns/form-components.md)** - Enhanced forms with authorization
- **[API & Routing](.ai/patterns/api-and-routing.md)** - API design, routing conventions
### 📖 Meta
- **[Maintaining Docs](.ai/meta/maintaining-docs.md)** - How to update/improve documentation
- **[Sync Guide](.ai/meta/sync-guide.md)** - Keeping docs synchronized
## Quick Decision Tree
**What are you working on?**
### Running Commands
→ `.ai/development/development-workflow.md`
- `npm run dev` / `npm run build` - Frontend
- `php artisan serve` / `php artisan migrate` - Backend
- `docker exec coolify php artisan test` - Feature tests (requires Docker)
- `./vendor/bin/pest tests/Unit` - Unit tests (no Docker needed)
- `./vendor/bin/pint` - Code formatting
### Writing Tests
→ `.ai/development/testing-patterns.md`
- **Unit tests**: No database, use mocking, run outside Docker
- **Feature tests**: Can use database, MUST run inside Docker
- Critical: Docker execution requirements prevent database connection errors
### Building UI
→ `.ai/patterns/frontend-patterns.md` + `.ai/patterns/form-components.md`
- Livewire 3.5.20 with server-side state
- Alpine.js for client interactions
- Tailwind CSS 4.1.4 styling
- Form components with `canGate` authorization
### Database Work
→ `.ai/patterns/database-patterns.md`
- Eloquent ORM patterns
- Migration best practices
- Relationship definitions
- Query optimization
### Security & Authorization
→ `.ai/patterns/security-patterns.md` + `.ai/patterns/form-components.md`
- Team-based access control
- Policy and gate patterns
- Form authorization (`canGate`, `canResource`)
- API security with Sanctum
### Laravel-Specific
→ `.ai/development/laravel-boost.md`
- Laravel 12.4.1 patterns
- Livewire 3 best practices
- Pest testing patterns
- Laravel conventions
### Version Numbers
→ `.ai/core/technology-stack.md`
- **SINGLE SOURCE OF TRUTH** for all version numbers
- Laravel 12.4.1, PHP 8.4.7, Tailwind 4.1.4, etc.
- Never duplicate versions - always reference this file
## Critical Patterns (Always Follow)
### Testing Commands
```bash
# Unit tests (no database, outside Docker)
./vendor/bin/pest tests/Unit
# Feature tests (requires database, inside Docker)
docker exec coolify php artisan test
```
**NEVER** run Feature tests outside Docker - they will fail with database connection errors.
### Form Authorization
ALWAYS include authorization on form components:
```blade
<x-forms.input canGate="update" :canResource="$resource" id="name" label="Name" />
```
### Livewire Components
MUST have exactly ONE root element. No exceptions.
### Version Numbers
Use exact versions from `technology-stack.md`:
- ✅ Laravel 12.4.1
- ❌ Laravel 12 or "v12"
### Code Style
```bash
# Always run before committing
./vendor/bin/pint
```
## For AI Assistants
### Important Notes
1. **Single Source of Truth**: Each piece of information exists in ONE location only
2. **Cross-Reference, Don't Duplicate**: Link to other files instead of copying content
3. **Version Precision**: Always use exact versions from `technology-stack.md`
4. **Docker for Feature Tests**: This is non-negotiable for database-dependent tests
5. **Form Authorization**: Security requirement, not optional
### When to Use Which File
- **Quick commands**: `CLAUDE.md` or `development-workflow.md`
- **Detailed patterns**: Topic-specific files in `.ai/patterns/`
- **Testing**: `.ai/development/testing-patterns.md`
- **Laravel specifics**: `.ai/development/laravel-boost.md`
- **Versions**: `.ai/core/technology-stack.md`
## Maintaining Documentation
When updating documentation:
1. Read `.ai/meta/maintaining-docs.md` first
2. Follow single source of truth principle
3. Update cross-references when moving content
4. Test all links work
5. See `.ai/meta/sync-guide.md` for sync guidelines
## Migration Note
This file replaces all previous `.cursor/rules/*.mdc` files. All content has been migrated to `.ai/` directory for better organization and to serve as single source of truth for all AI tools (Claude Code, Cursor IDE, etc.).

View file

@ -1,38 +0,0 @@
/.phpunit.cache
/node_modules
/public/build
/public/hot
/public/storage
/vendor
.env
.env.backup
.env.secrets
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
/.fleet
/.idea
/.vscode
/.npm
/.bash_history
/_data
.rnd
/.ssh
.ignition.json
.env.dusk.local
docker/coolify-realtime/node_modules
/storage/*.key
/storage/app/backups
/storage/app/ssh/keys
/storage/app/ssh/mux
/storage/app/tmp
/storage/app/debugbar
/storage/logs
/storage/pail

View file

@ -14,5 +14,5 @@ trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
[compose.yaml]
indent_size = 4

View file

@ -1,34 +1,22 @@
# Coolify Configuration
APP_ENV=local
APP_NAME="Coolify Development"
APP_ID=development
APP_KEY=
APP_DEBUG=true
APP_NAME=Coolify
APP_URL=http://localhost
APP_PORT=8000
APP_DEBUG=true
SSH_MUX_ENABLED=true
APP_FORCE_HTTPS=false
APP_KEY=
# PostgreSQL Database Configuration
DB_DATABASE=coolify
DB_DATABASE=coolify-db
DB_USERNAME=coolify
DB_PASSWORD=password
DB_PASSWORD=Password
DB_HOST=host.docker.internal
DB_PORT=5432
# Ray Configuration
# Set to true to enable Ray
RAY_ENABLED=false
# Set custom ray port
REDIS_PASSWORD=Password
RAY_ENABLED=true
# RAY_PORT=
# Enable Laravel Telescope for debugging
TELESCOPE_ENABLED=false
# Selenium Driver URL for Dusk
DUSK_DRIVER_URL=http://selenium:4444
# Special Keys for Andras
# For cache purging
BUNNY_API_KEY=
# For asset uploads
BUNNY_STORAGE_API_KEY=

View file

@ -1,15 +0,0 @@
APP_ENV=production
APP_NAME="Coolify Staging"
APP_ID=development
APP_KEY=
APP_URL=http://localhost
APP_PORT=8000
SSH_MUX_ENABLED=true
# PostgreSQL Database Configuration
DB_DATABASE=coolify
DB_USERNAME=coolify
DB_PASSWORD=password
DB_HOST=localhost
DB_PORT=5432

65
.env.example Normal file
View file

@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

View file

@ -1,18 +0,0 @@
APP_ID=
APP_NAME=Coolify
APP_KEY=
DB_USERNAME=coolify
DB_PASSWORD=
REDIS_PASSWORD=
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
ROOT_USERNAME=
ROOT_USER_EMAIL=
ROOT_USER_PASSWORD=
REGISTRY_URL=ghcr.io

9
.env.production.example Normal file
View file

@ -0,0 +1,9 @@
APP_NAME=Coolify
APP_URL=
APP_FORCE_HTTPS=
APP_KEY=
DB_USERNAME=
DB_PASSWORD=
REDIS_PASSWORD=

14
.env.testing Normal file
View file

@ -0,0 +1,14 @@
APP_ENV=local
APP_DEBUG=true
APP_NAME="Coolify Development"
APP_URL=http://localhost
APP_PORT=8000
APP_FORCE_HTTPS=false
APP_KEY=
RAY_ENABLED=true
# RAY_PORT=
# Special Keys for Andras
BUNNY_API_KEY=
BUNNY_STORAGE_API_KEY=

View file

@ -1,13 +0,0 @@
IS_WINDOWS_DOCKER_DESKTOP=true
APP_ID=coolify-windows-docker-desktop
APP_NAME=Coolify
APP_KEY=base64:ssTlCmrIE/q7whnKMvT6DwURikg69COzGsAwFVROm80=
DB_USERNAME=coolify
DB_PASSWORD=coolify
REDIS_PASSWORD=coolify
PUSHER_APP_ID=coolify
PUSHER_APP_KEY=coolify
PUSHER_APP_SECRET=coolify

2
.gitattributes vendored
View file

@ -8,4 +8,4 @@
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
.styleci.yml export-ignore

View file

@ -1,2 +0,0 @@
open_collective: coollabsio
github: coollabsio

View file

@ -1,65 +0,0 @@
name: 🐞 Bug Report
description: "File a new bug report."
title: "[Bug]: "
labels: ["🐛 Bug", "🔍 Triage"]
body:
- type: markdown
attributes:
value: |
> [!IMPORTANT]
> **Please ensure you are using the latest version of Coolify before submitting an issue, as the bug may have already been fixed in a recent update.** (Of course, if you're experiencing an issue on the latest version that wasn't present in a previous version, please let us know.)
# 💎 Bounty Program (with [algora.io](https://console.algora.io/org/coollabsio/bounties/new))
- If you would like to prioritize the issue resolution, consider adding a bounty to this issue through our [Bounty Program](https://console.algora.io/org/coollabsio/bounties/new).
- type: textarea
attributes:
label: Error Message and Logs
description: Provide a detailed description of the error or exception you encountered, along with any relevant log output.
validations:
required: true
- type: textarea
attributes:
label: Steps to Reproduce
description: Please provide a step-by-step guide to reproduce the issue. Be as detailed as possible, otherwise we may not be able to assist you.
value: |
1.
2.
3.
4.
validations:
required: true
- type: input
attributes:
label: Example Repository URL
description: If applicable, provide a URL to a repository demonstrating the issue.
- type: input
attributes:
label: Coolify Version
description: Please provide the Coolify version you are using. This can be found in the top left corner of your Coolify dashboard.
placeholder: "v4.0.0-beta.335"
validations:
required: true
- type: dropdown
attributes:
label: Are you using Coolify Cloud?
options:
- "No (self-hosted)"
- "Yes (Coolify Cloud)"
validations:
required: true
- type: input
attributes:
label: Operating System and Version (self-hosted)
description: Run `cat /etc/os-release` or `lsb_release -a` in your terminal and provide the operating system and version.
placeholder: "Ubuntu 22.04"
- type: textarea
attributes:
label: Additional Information
description: Any other relevant details about the issue.

View file

@ -1,31 +0,0 @@
name: 💎 Enhancement Bounty
description: "Propose a new feature, service, or improvement with an attached bounty."
title: "[Enhancement]: "
labels: ["✨ Enhancement", "🔍 Triage"]
body:
- type: markdown
attributes:
value: |
> [!IMPORTANT]
> **This issue template is exclusively for proposing new features, services, or improvements with an attached bounty.** Enhancements without a bounty can be discussed in the appropriate category of [Github Discussions](https://github.com/coollabsio/coolify/discussions).
# 💎 Add a Bounty (with [algora.io](https://console.algora.io/org/coollabsio/bounties/new))
- [Click here to add the required bounty](https://console.algora.io/org/coollabsio/bounties/new)
- type: dropdown
attributes:
label: Request Type
description: Select the type of request you are making.
options:
- New Feature
- New Service
- Improvement
validations:
required: true
- type: textarea
attributes:
label: Description
description: Provide a detailed description of the feature, improvement, or service you are proposing.
validations:
required: true

View file

@ -1,18 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: 🤔 Questions and Community Support
url: https://coollabs.io/discord
about: If you have any questions, reach out to us on Discord inside the "#support" channel.
- name: 💡 Feature Request
url: https://github.com/coollabsio/coolify/discussions/categories/feature-requests
about: Suggest a new feature for Coolify.
- name: ⚙️ Service Request
url: https://github.com/coollabsio/coolify/discussions/categories/service-requests
about: Request a new service integration for Coolify.
- name: 🔧 Improvements
url: https://github.com/coollabsio/coolify/discussions/categories/improvements
about: Suggest improvements to existing features for Coolify.

View file

@ -1,13 +0,0 @@
## Submit Checklist (REMOVE THIS SECTION BEFORE SUBMITTING)
- [ ] I have selected the `next` branch as the destination for my PR, not `main`.
- [ ] I have listed all changes in the `Changes` section.
- [ ] I have filled out the `Issues` section with the issue/discussion link(s) (if applicable).
- [ ] I have tested my changes.
- [ ] I have considered backwards compatibility.
- [ ] I have removed this checklist and any unused sections.
## Changes
-
## Issues
- fix #

View file

@ -1,22 +0,0 @@
name: Lock closed Issues, Discussions, and PRs
on:
schedule:
- cron: '0 1 * * *'
permissions:
issues: write
discussions: write
pull-requests: write
jobs:
lock-threads:
runs-on: ubuntu-latest
steps:
- name: Lock threads after 30 days of inactivity
uses: dessant/lock-threads@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '30'
discussion-inactive-days: '30'
pr-inactive-days: '30'

View file

@ -1,32 +0,0 @@
name: Manage Stale Issues and PRs
on:
schedule:
- cron: '0 2 * * *'
permissions:
issues: write
pull-requests: write
jobs:
manage-stale:
runs-on: ubuntu-latest
steps:
- name: Manage stale issues and PRs
uses: actions/stale@v9
id: stale
with:
stale-issue-message: 'This issue will be automatically closed in a few days if no response is received. Please provide an update with the requested information.'
stale-pr-message: 'This pull request requires attention. If no changes or response is received within the next few days, it will be automatically closed. Please update your PR or leave a comment with the requested information.'
close-issue-message: 'This issue has been automatically closed due to inactivity.'
close-pr-message: 'Thank you for your contribution. Due to inactivity, this PR was automatically closed. If you would like to continue working on this change in the future, feel free to reopen this PR or submit a new one.'
days-before-stale: 14
days-before-close: 7
stale-issue-label: '⏱︎ Stale'
stale-pr-label: '⏱︎ Stale'
only-labels: '💤 Waiting for feedback, 💤 Waiting for changes'
remove-stale-when-updated: true
operations-per-run: 100
labels-to-remove-when-unstale: '⏱︎ Stale, 💤 Waiting for feedback, 💤 Waiting for changes'
close-issue-reason: 'not_planned'
exempt-all-milestones: false

View file

@ -1,49 +0,0 @@
name: Add comment based on label
on:
pull_request_target:
types:
- labeled
permissions:
pull-requests: write
jobs:
add-comment:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- label: "⚙️ Service"
body: |
Hi @${{ github.event.pull_request.user.login }}! 👋
It appears to us that you are either adding a new service or making changes to an existing one.
We kindly ask you to also review and update the **Coolify Documentation** to include this new service or it's new configuration needs.
This will help ensure that our documentation remains accurate and up-to-date for all users.
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
How to Contribute a new Service to the Docs: https://coolify.io/docs/get-started/contribute/service#adding-a-new-service-template-to-the-coolify-documentation
- label: "🛠️ Feature"
body: |
Hi @${{ github.event.pull_request.user.login }}! 👋
It appears to us that you are adding a new feature to Coolify.
We kindly ask you to also update the **Coolify Documentation** to include information about this new feature.
This will help ensure that our documentation remains accurate and up-to-date for all users.
Coolify Docs Repository: https://github.com/coollabsio/coolify-docs
How to Contribute to the Docs: https://coolify.io/docs/get-started/contribute/documentation
# - label: "✨ Enhancement"
# body: |
# It appears to us that you are making an enhancement to Coolify.
# We kindly ask you to also review and update the Coolify Documentation to include information about this enhancement if applicable.
# This will help ensure that our documentation remains accurate and up-to-date for all users.
steps:
- name: Add comment
if: github.event.label.name == matrix.label
run: gh pr comment "$NUMBER" --body "$BODY"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.pull_request.number }}
BODY: ${{ matrix.body }}

View file

@ -1,86 +0,0 @@
name: Remove Labels and Assignees on Issue Close
on:
issues:
types: [closed]
pull_request:
types: [closed]
pull_request_target:
types: [closed]
permissions:
issues: write
pull-requests: write
jobs:
remove-labels-and-assignees:
runs-on: ubuntu-latest
steps:
- name: Remove labels and assignees
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { owner, repo } = context.repo;
async function processIssue(issueNumber, isFromPR = false, prBaseBranch = null) {
try {
if (isFromPR && prBaseBranch !== 'v4.x') {
return;
}
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: issueNumber
});
const labelsToKeep = currentLabels
.filter(label => label.name === '⏱︎ Stale')
.map(label => label.name);
await github.rest.issues.setLabels({
owner,
repo,
issue_number: issueNumber,
labels: labelsToKeep
});
const { data: issue } = await github.rest.issues.get({
owner,
repo,
issue_number: issueNumber
});
if (issue.assignees && issue.assignees.length > 0) {
await github.rest.issues.removeAssignees({
owner,
repo,
issue_number: issueNumber,
assignees: issue.assignees.map(assignee => assignee.login)
});
}
} catch (error) {
if (error.status !== 404) {
console.error(`Error processing issue ${issueNumber}:`, error);
}
}
}
if (context.eventName === 'issues') {
await processIssue(context.payload.issue.number);
}
if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') {
const pr = context.payload.pull_request;
await processIssue(pr.number);
if (pr.merged && pr.base.ref === 'v4.x' && pr.body) {
const issueReferences = pr.body.match(/#(\d+)/g);
if (issueReferences) {
for (const reference of issueReferences) {
const issueNumber = parseInt(reference.substring(1));
await processIssue(issueNumber, true, pr.base.ref);
}
}
}
}

View file

@ -1,22 +0,0 @@
name: Cleanup Untagged GHCR Images
on:
workflow_dispatch:
permissions:
packages: write
jobs:
cleanup-all-packages:
runs-on: ubuntu-latest
strategy:
matrix:
package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host']
steps:
- name: Delete untagged ${{ matrix.package }} images
uses: actions/delete-package-versions@v5
with:
package-name: ${{ matrix.package }}
package-type: 'container'
min-versions-to-keep: 0
delete-only-untagged-versions: 'true'

View file

@ -1,117 +0,0 @@
name: Coolify Helper Image Development
on:
push:
branches: [ "next" ]
paths:
- .github/workflows/coolify-helper-next.yml
- docker/coolify-helper/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,116 +0,0 @@
name: Coolify Helper Image
on:
push:
branches: [ "v4.x" ]
paths:
- .github/workflows/coolify-helper.yml
- docker/coolify-helper/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}

View file

@ -1,121 +0,0 @@
name: Production Build (v4)
on:
push:
branches: ["v4.x"]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}

View file

@ -1,120 +0,0 @@
name: Coolify Realtime Development
on:
push:
branches: [ "next" ]
paths:
- .github/workflows/coolify-realtime-next.yml
- docker/coolify-realtime/Dockerfile
- docker/coolify-realtime/terminal-server.js
- docker/coolify-realtime/package.json
- docker/coolify-realtime/package-lock.json
- docker/coolify-realtime/soketi-entrypoint.sh
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,120 +0,0 @@
name: Coolify Realtime
on:
push:
branches: [ "v4.x" ]
paths:
- .github/workflows/coolify-realtime.yml
- docker/coolify-realtime/Dockerfile
- docker/coolify-realtime/terminal-server.js
- docker/coolify-realtime/package.json
- docker/coolify-realtime/package-lock.json
- docker/coolify-realtime/soketi-entrypoint.sh
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Get Version
id: version
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_PROD_RELEASE_CHANNEL }}

View file

@ -1,133 +0,0 @@
name: Staging Build
on:
push:
branches-ignore:
- v4.x
- v3.x
- '**v5.x**'
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
cache-from: |
type=gha,scope=build-${{ matrix.arch }}
type=registry,ref=${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,104 +0,0 @@
name: Coolify Testing Host
on:
push:
branches: [ "next" ]
paths:
- .github/workflows/coolify-testing-host.yml
- docker/testing-host/Dockerfile
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-testing-host"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/testing-host/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -1,36 +0,0 @@
name: Generate Changelog
on:
push:
branches: [ v4.x ]
workflow_dispatch:
permissions:
contents: write
jobs:
changelog:
name: Generate changelog
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --verbose
env:
OUTPUT: CHANGELOG.md
GITHUB_REPO: ${{ github.repository }}
- name: Commit
run: |
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
git add CHANGELOG.md
git commit -m "docs: update changelog"
git push https://${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git v4.x

28
.gitignore vendored
View file

@ -4,37 +4,21 @@
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
.env
.env.backup
.env.secrets
.env.production
.phpactor.json
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
/auth.json
/.fleet
/.idea
/.nova
/.vscode
/.npm
/.bash_history
/_data
_testing_hosts/
_volumes/
.lesshst
psysh_history
.psql_history
_ide_helper.php
.gitignore
.phpstorm.meta.php
_ide_helper_models.php
.rnd
/.ssh
scripts/load-test/*
.ignition.json
.env.dusk.local
docker/coolify-realtime/node_modules
/.zed
.DS_Store
CHANGELOG.md
/.workspaces

View file

@ -1,11 +0,0 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

View file

@ -1,4 +0,0 @@
{
"$schema": "/phpactor.schema.json",
"language_server_phpstan.enabled": true
}

320
CLAUDE.md
View file

@ -1,320 +0,0 @@
# CLAUDE.md
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. All detailed documentation is in the `.ai/` directory. Both Claude Code and Cursor IDE use the same source files in `.ai/` for consistency.
>
> **Maintaining Instructions**: When updating AI instructions, see [.ai/meta/sync-guide.md](.ai/meta/sync-guide.md) and [.ai/meta/maintaining-docs.md](.ai/meta/maintaining-docs.md) for guidelines.
## Project Overview
Coolify is an open-source, self-hostable platform for deploying applications and managing servers - an alternative to Heroku/Netlify/Vercel. It's built with Laravel (PHP) and uses Docker for containerization.
## Development Commands
### Frontend Development
- `npm run dev` - Start Vite development server for frontend assets
- `npm run build` - Build frontend assets for production
### Backend Development
Only run artisan commands inside "coolify" container when in development.
- `php artisan serve` - Start Laravel development server
- `php artisan migrate` - Run database migrations
- `php artisan queue:work` - Start queue worker for background jobs
- `php artisan horizon` - Start Laravel Horizon for queue monitoring
- `php artisan tinker` - Start interactive PHP REPL
### Code Quality
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
- `./vendor/bin/pest tests/Unit` - Run unit tests only (no database, can run outside Docker)
- `./vendor/bin/pest` - Run ALL tests (includes Feature tests, may require 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
### Technology Stack
- **Backend**: Laravel 12.4.1 (PHP 8.4.7)
- **Frontend**: Livewire 3.5.20 with Alpine.js and Tailwind CSS 4.1.4
- **Database**: PostgreSQL 15 (primary), Redis 7 (cache/queues)
- **Real-time**: Soketi (WebSocket server)
- **Containerization**: Docker & Docker Compose
- **Queue Management**: Laravel Horizon 5.30.3
> **Note**: For complete version information and all dependencies, see [.ai/core/technology-stack.md](.ai/core/technology-stack.md)
### Key Components
#### Core Models
- `Application` - Deployed applications with Git integration (74KB, highly complex)
- `Server` - Remote servers managed by Coolify (46KB, complex)
- `Service` - Docker Compose services (58KB, complex)
- `Database` - Standalone database instances (PostgreSQL, MySQL, MongoDB, Redis, etc.)
- `Team` - Multi-tenancy support
- `Project` - Grouping of environments and resources
- `Environment` - Environment isolation (staging, production, etc.)
#### Job System
- Uses Laravel Horizon for queue management
- Key jobs: `ApplicationDeploymentJob`, `ServerCheckJob`, `DatabaseBackupJob`
- `ServerManagerJob` and `ServerConnectionCheckJob` handle job scheduling
#### Deployment Flow
1. Git webhook triggers deployment
2. `ApplicationDeploymentJob` handles build and deployment
3. Docker containers are managed on target servers
4. Proxy configuration (Nginx/Traefik) is updated
#### Server Management
- SSH-based server communication via `ExecuteRemoteCommand` trait
- Docker installation and management
- Proxy configuration generation
- Resource monitoring and cleanup
### Directory Structure
- `app/Actions/` - Domain-specific actions (Application, Database, Server, etc.)
- `app/Jobs/` - Background queue jobs
- `app/Livewire/` - Frontend components (full-stack with Livewire)
- `app/Models/` - Eloquent models
- `app/Rules/` - Custom validation rules
- `app/Http/Middleware/` - HTTP middleware
- `bootstrap/helpers/` - Helper functions for various domains
- `database/migrations/` - Database schema evolution
- `routes/` - Application routing (web.php, api.php, webhooks.php, channels.php)
- `resources/views/livewire/` - Livewire component views
- `tests/` - Pest tests (Feature and Unit)
## Development Guidelines
### Frontend Philosophy
Coolify uses a **server-side first** approach with minimal JavaScript:
- **Livewire** for server-side rendering with reactive components
- **Alpine.js** for lightweight client-side interactions
- **Tailwind CSS** for utility-first styling with dark mode support
- **Enhanced Form Components** with built-in authorization system
- Real-time updates via WebSocket without page refreshes
### Form Authorization Pattern
**IMPORTANT**: When creating or editing forms, ALWAYS include authorization:
#### For Form Components (Input, Select, Textarea, Checkbox, Button):
Use `canGate` and `canResource` attributes for automatic authorization:
```html
<x-forms.input canGate="update" :canResource="$resource" id="name" label="Name" />
<x-forms.select canGate="update" :canResource="$resource" id="type" label="Type">...</x-forms.select>
<x-forms.checkbox instantSave canGate="update" :canResource="$resource" id="enabled" label="Enabled" />
<x-forms.button canGate="update" :canResource="$resource" type="submit">Save</x-forms.button>
```
#### For Modal Components:
Wrap with `@can` directives:
```html
@can('update', $resource)
<x-modal-confirmation title="Confirm Action?" buttonTitle="Confirm">...</x-modal-confirmation>
<x-modal-input buttonTitle="Edit" title="Edit Settings">...</x-modal-input>
@endcan
```
#### In Livewire Components:
Always add the `AuthorizesRequests` trait and check permissions:
```php
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
class MyComponent extends Component
{
use AuthorizesRequests;
public function mount()
{
$this->authorize('view', $this->resource);
}
public function update()
{
$this->authorize('update', $this->resource);
// ... update logic
}
}
```
### Livewire Component Structure
- Components located in `app/Livewire/`
- Views in `resources/views/livewire/`
- State management handled on the server
- Use wire:model for two-way data binding
- Dispatch events for component communication
- **CRITICAL**: Livewire component views **MUST** have exactly ONE root element. ALL content must be contained within this single root element. Placing ANY elements (`<style>`, `<script>`, `<div>`, comments, or any other HTML) outside the root element will break Livewire's component tracking and cause `wire:click` and other directives to fail silently.
### Code Organization Patterns
- **Actions Pattern**: Use Actions for complex business logic (`app/Actions/`)
- **Livewire Components**: Handle UI and user interactions
- **Jobs**: Handle asynchronous operations
- **Traits**: Provide shared functionality (e.g., `ExecuteRemoteCommand`)
- **Helper Functions**: Domain-specific helpers in `bootstrap/helpers/`
### Database Patterns
- Use Eloquent ORM for database interactions
- Implement relationships properly (HasMany, BelongsTo, etc.)
- Use database transactions for critical operations
- Leverage query scopes for reusable queries
- Apply indexes for performance-critical queries
- **CRITICAL**: When adding new database columns, ALWAYS update the model's `$fillable` array to allow mass assignment
### Security Best Practices
- **Authentication**: Multi-provider auth via Laravel Fortify & Sanctum
- **Authorization**: Team-based access control with policies and enhanced form components
- **Form Component Security**: Built-in `canGate` authorization system for UI components
- **API Security**: Token-based auth with IP allowlisting
- **Secrets Management**: Never log or expose sensitive data
- **Input Validation**: Always validate user input with Form Requests or Rules
- **SQL Injection Prevention**: Use Eloquent ORM or parameterized queries
### API Development
- RESTful endpoints in `routes/api.php`
- Use API Resources for response formatting
- Implement rate limiting for public endpoints
- Version APIs when making breaking changes
- Document endpoints with clear examples
### Testing Strategy
- **Framework**: Pest for expressive testing
- **Structure**: Feature tests for user flows, Unit tests for isolated logic
- **Coverage**: Test critical paths and edge cases
- **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
- Name routes consistently (resource.action)
- Implement proper HTTP verbs (GET, POST, PUT, DELETE)
### Error Handling
- Use `handleError()` helper for consistent error handling
- Log errors with appropriate context
- Return user-friendly error messages
- Implement proper HTTP status codes
### Performance Considerations
- Use eager loading to prevent N+1 queries
- Implement caching for frequently accessed data
- Queue heavy operations
- Optimize database queries with proper indexes
- Use chunking for large data operations
### Code Style
- Follow PSR-12 coding standards
- Use Laravel Pint for automatic formatting
- Write descriptive variable and method names
- Keep methods small and focused
- Document complex logic with clear comments
## Cloud Instance Considerations
We have a cloud instance of Coolify (hosted version) with:
- 2 Horizon worker servers
- Thousands of connected servers
- Thousands of active users
- High-availability requirements
When developing features:
- Consider scalability implications
- Test with large datasets
- Implement efficient queries
- Use queues for heavy operations
- Consider rate limiting and resource constraints
- Implement proper error recovery mechanisms
## Important Reminders
- Always run code formatting: `./vendor/bin/pint`
- Test your changes: `./vendor/bin/pest`
- Check for static analysis issues: `./vendor/bin/phpstan`
- Use existing patterns and helpers
- Follow the established directory structure
- Maintain backward compatibility
- Document breaking changes
- Consider performance impact on large-scale deployments
## Additional Documentation
This file contains high-level guidelines for Claude Code. For **more detailed, topic-specific documentation**, refer to the `.ai/` directory:
> **Documentation Hub**: The `.ai/` directory contains comprehensive, detailed documentation organized by topic. Start with [.ai/README.md](.ai/README.md) for navigation, then explore specific topics below.
### Core Documentation
- [Technology Stack](.ai/core/technology-stack.md) - All versions, packages, and dependencies (single source of truth)
- [Project Overview](.ai/core/project-overview.md) - What Coolify is and how it works
- [Application Architecture](.ai/core/application-architecture.md) - System design and component relationships
- [Deployment Architecture](.ai/core/deployment-architecture.md) - How deployments work end-to-end
### Development Practices
- [Development Workflow](.ai/development/development-workflow.md) - Development setup, commands, and workflows
- [Testing Patterns](.ai/development/testing-patterns.md) - Testing strategies and examples (Docker requirements!)
- [Laravel Boost](.ai/development/laravel-boost.md) - Laravel-specific guidelines and best practices
### Code Patterns
- [Database Patterns](.ai/patterns/database-patterns.md) - Eloquent, migrations, relationships
- [Frontend Patterns](.ai/patterns/frontend-patterns.md) - Livewire, Alpine.js, Tailwind CSS
- [Security Patterns](.ai/patterns/security-patterns.md) - Authentication, authorization, security
- [Form Components](.ai/patterns/form-components.md) - Enhanced form components with authorization
- [API & Routing](.ai/patterns/api-and-routing.md) - API design and routing conventions
### Meta Documentation
- [Maintaining Docs](.ai/meta/maintaining-docs.md) - How to update and improve AI documentation
- [Sync Guide](.ai/meta/sync-guide.md) - Keeping documentation synchronized
## Laravel Boost Guidelines
> **Full Guidelines**: See [.ai/development/laravel-boost.md](.ai/development/laravel-boost.md) for complete Laravel Boost guidelines.
### Essential Laravel Patterns
- Use PHP 8.4 constructor property promotion and typed properties
- Follow PSR-12 (run `./vendor/bin/pint` before committing)
- Use Eloquent ORM, avoid raw queries
- Use Form Request classes for validation
- Queue heavy operations with Laravel Horizon
- Never use `env()` outside config files
- Use named routes with `route()` function
- Laravel 12 with Laravel 10 structure (no bootstrap/app.php)
### Testing Requirements
- **Unit tests**: No database, use mocking, run with `./vendor/bin/pest tests/Unit`
- **Feature tests**: Can use database, run with `docker exec coolify php artisan test`
- Every change must have tests
- Use Pest for all tests
### Livewire & Frontend
- Livewire components require single root element
- Use `wire:model.live` for real-time updates
- Alpine.js included with Livewire
- Tailwind CSS 4.1.4 (use new utilities, not deprecated ones)
- Use `gap` utilities for spacing, not margins
Random other things you should remember:
- App\Models\Application::team must return a relationship instance., always use team()

View file

@ -1,94 +0,0 @@
# Citizen Code of Conduct
## 1. Purpose
A primary goal of Coolify is to be inclusive to the largest number of contributors, with the most varied and diverse backgrounds possible. As such, we are committed to providing a friendly, safe and welcoming environment for all, regardless of gender, sexual orientation, ability, ethnicity, socioeconomic status, and religion (or lack thereof).
This code of conduct outlines our expectations for all those who participate in our community, as well as the consequences for unacceptable behavior.
We invite all those who participate in Coolify to help us create safe and positive experiences for everyone.
## 2. Open [Source/Culture/Tech] Citizenship
A supplemental goal of this Code of Conduct is to increase open [source/culture/tech] citizenship by encouraging participants to recognize and strengthen the relationships between our actions and their effects on our community.
Communities mirror the societies in which they exist and positive action is essential to counteract the many forms of inequality and abuses of power that exist in society.
If you see someone who is making an extra effort to ensure our community is welcoming, friendly, and encourages all participants to contribute to the fullest extent, we want to know.
## 3. Expected Behavior
The following behaviors are expected and requested of all community members:
* Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community.
* Exercise consideration and respect in your speech and actions.
* Attempt collaboration before conflict.
* Refrain from demeaning, discriminatory, or harassing behavior and speech.
* Be mindful of your surroundings and of your fellow participants. Alert community leaders if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential.
* Remember that community event venues may be shared with members of the public; please be respectful to all patrons of these locations.
## 4. Unacceptable Behavior
The following behaviors are considered harassment and are unacceptable within our community:
* Violence, threats of violence or violent language directed against another person.
* Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and language.
* Posting or displaying sexually explicit or violent material.
* Posting or threatening to post other people's personally identifying information ("doxing").
* Personal insults, particularly those related to gender, sexual orientation, race, religion, or disability.
* Inappropriate photography or recording.
* Inappropriate physical contact. You should have someone's consent before touching them.
* Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate touching, groping, and unwelcomed sexual advances.
* Deliberate intimidation, stalking or following (online or in person).
* Advocating for, or encouraging, any of the above behavior.
* Sustained disruption of community events, including talks and presentations.
## 5. Weapons Policy
No weapons will be allowed at Coolify events, community spaces, or in other spaces covered by the scope of this Code of Conduct. Weapons include but are not limited to guns, explosives (including fireworks), and large knives such as those used for hunting or display, as well as any other item used for the purpose of causing injury or harm to others. Anyone seen in possession of one of these items will be asked to leave immediately, and will only be allowed to return without the weapon. Community members are further expected to comply with all state and local laws on this matter.
## 6. Consequences of Unacceptable Behavior
Unacceptable behavior from any community member, including sponsors and those with decision-making authority, will not be tolerated.
Anyone asked to stop unacceptable behavior is expected to comply immediately.
If a community member engages in unacceptable behavior, the community organizers may take any action they deem appropriate, up to and including a temporary ban or permanent expulsion from the community without warning (and without refund in the case of a paid event).
## 7. Reporting Guidelines
If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a community organizer as soon as possible. hi@coollabs.io.
Additionally, community organizers are available to help community members engage with local law enforcement or to otherwise help those experiencing unacceptable behavior feel safe. In the context of in-person events, organizers will also provide escorts as desired by the person experiencing distress.
## 8. Addressing Grievances
If you feel you have been falsely or unfairly accused of violating this Code of Conduct, you should notify coollabsio with a concise description of your grievance. Your grievance will be handled in accordance with our existing governing policies.
## 9. Scope
We expect all community participants (contributors, paid or otherwise; sponsors; and other guests) to abide by this Code of Conduct in all community venues--online and in-person--as well as in all one-on-one communications pertaining to community business.
This code of conduct and its related procedures also applies to unacceptable behavior occurring outside the scope of community activities when such behavior has the potential to adversely affect the safety and well-being of community members.
## 10. Contact info
hi@coollabs.io
## 11. License and attribution
The Citizen Code of Conduct is distributed by [Stumptown Syndicate](http://stumptownsyndicate.org) under a [Creative Commons Attribution-ShareAlike license](http://creativecommons.org/licenses/by-sa/3.0/).
Portions of text derived from the [Django Code of Conduct](https://www.djangoproject.com/conduct/) and the [Geek Feminism Anti-Harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy).
_Revision 2.3. Posted 6 March 2017._
_Revision 2.2. Posted 4 February 2016._
_Revision 2.1. Posted 23 June 2014._
_Revision 2.0, adopted by the [Stumptown Syndicate](http://stumptownsyndicate.org) board on 10 January 2013. Posted 17 March 2013._

178
README.md
View file

@ -1,177 +1,3 @@
<div align="center">
# v5.x
# Coolify
An open-source & self-hostable Heroku / Netlify / Vercel alternative.
![Latest Release Version](https://img.shields.io/badge/dynamic/json?labelColor=grey&color=6366f1&label=Latest%20released%20version&url=https%3A%2F%2Fcdn.coollabs.io%2Fcoolify%2Fversions.json&query=coolify.v4.version&style=for-the-badge
) [![Bounty Issues](https://img.shields.io/static/v1?labelColor=grey&color=6366f1&label=Algora&message=%F0%9F%92%8E+Bounty+issues&style=for-the-badge)](https://console.algora.io/org/coollabsio/bounties/new)
</div>
## About the Project
Coolify is an open-source & self-hostable alternative to Heroku / Netlify / Vercel / etc.
It helps you manage your servers, applications, and databases on your own hardware; you only need an SSH connection. You can manage VPS, Bare Metal, Raspberry PIs, and anything else.
Imagine having the ease of a cloud but with your own servers. That is **Coolify**.
No vendor lock-in, which means that all the configurations for your applications/databases/etc are saved to your server. So, if you decide to stop using Coolify (oh nooo), you could still manage your running resources. You lose the automations and all the magic. 🪄️
For more information, take a look at our landing page at [coolify.io](https://coolify.io).
## Installation
```bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
```
You can find the installation script source [here](./scripts/install.sh).
> [!NOTE]
> Please refer to the [docs](https://coolify.io/docs/installation) for more information about the installation.
## Support
Contact us at [coolify.io/docs/contact](https://coolify.io/docs/contact).
## Cloud
If you do not want to self-host Coolify, there is a paid cloud version available: [app.coolify.io](https://app.coolify.io)
For more information & pricing, take a look at our landing page [coolify.io](https://coolify.io).
## Why should I use the Cloud version?
The recommended way to use Coolify is to have one server for Coolify and one (or more) for the resources you are deploying. A server is around 4-5$/month.
By subscribing to the cloud version, you get the Coolify server for the same price, but with:
- High-availability
- Free email notifications
- Better support
- Less maintenance for you
## Donations
To stay completely free and open-source, with no feature behind the paywall and evolve the project, we need your help. If you like Coolify, please consider donating to help us fund the project's future development.
[coolify.io/sponsorships](https://coolify.io/sponsorships)
Thank you so much!
### Big Sponsors
* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions!
* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform
* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [COMIT](https://comit.international?ref=coolify.io) - New York Times awardwinning contractor
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [Dade2](https://dade2.net/?ref=coolify.io) - IT Consulting, Cloud Solutions & System Integration
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers
* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase
* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data
* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform
* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
### Small Sponsors
<a href="https://open-elements.com/?utm_source=coolify.io"><img width="60px" alt="OpenElements" src="https://github.com/OpenElements.png"/></a>
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="XamanApp" src="https://github.com/XamanApp.png"/></a>
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
<a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a>
<a href="https://github.com/iujlaki"><img width="60px" alt="Imre Ujlaki" src="https://github.com/iujlaki.png"/></a>
<a href="https://bsky.app/profile/jyc.dev"><img width="60px" alt="jyc.dev" src="https://github.com/jycouet.png"/></a>
<a href="https://github.com/therealjp?utm_source=coolify.io"><img width="60px" alt="TheRealJP" src="https://github.com/therealjp.png"/></a>
<a href="https://360creators.com/?utm_source=coolify.io"><img width="60px" alt="360Creators" src="https://opencollective-production.s3.us-west-1.amazonaws.com/account-avatar/503e0953-bff7-4296-b4cc-5e36d40eecc0/icon-360creators.png"/></a>
<a href="https://github.com/aniftyco"><img width="60px" alt="NiftyCo" src="https://github.com/aniftyco.png"/></a>
<a href="https://dry.software/?utm_source=coolify.io"><img width="60px" alt="Dry Software" src="https://github.com/dry-software.png"/></a>
<a href="https://lightspeed.run/?utm_source=coolify.io"><img width="60px" alt="Lightspeed.run" src="https://github.com/lightspeedrun.png"/></a>
<a href="https://linkdr.com?utm_source=coolify.io"><img width="60px" alt="LinkDr" src="https://github.com/LLM-Inc.png"/></a>
<a href="http://gravitywiz.com/?utm_source=coolify.io"><img width="60px" alt="Gravity Wiz" src="https://github.com/gravitywiz.png"/></a>
<a href="https://bitlaunch.io/?utm_source=coolify.io"><img width="60px" alt="BitLaunch" src="https://github.com/bitlaunchio.png"/></a>
<a href="https://bestforandroid.com/?utm_source=coolify.io"><img width="60px" alt="Best for Android" src="https://github.com/bestforandroid.png"/></a>
<a href="https://il.ly/?utm_source=coolify.io"><img width="60px" alt="Ilias Ism" src="https://github.com/Illyism.png"/></a>
<a href="https://formbricks.com/?utm_source=coolify.io"><img width="60px" alt="Formbricks" src="https://github.com/formbricks.png"/></a>
<a href="https://www.serversearcher.com/"><img width="60px" alt="Server Searcher" src="https://github.com/serversearcher.png"/></a>
<a href="https://www.reshot.ai/?utm_source=coolify.io"><img width="60px" alt="Reshot" src="https://coolify.io/images/reshotai.png"/></a>
<a href="https://cirun.io/?utm_source=coolify.io"><img width="60px" alt="Cirun" src="https://coolify.io/images/cirun-logo.png"/></a>
<a href="https://typebot.io/?utm_source=coolify.io"><img width="60px" alt="Typebot" src="https://cdn.bsky.app/img/avatar/plain/did:plc:gwxcta3pccyim4z5vuultdqx/bafkreig23hci7e2qpdxicsshnuzujbcbcgmydxhbybkewszdezhdodv42m@jpeg"/></a>
<a href="https://cccareers.org/?utm_source=coolify.io"><img width="60px" alt="Creating Coding Careers" src="https://github.com/cccareers.png"/></a>
<a href="https://internetgarden.co/?utm_source=coolify.io"><img width="60px" alt="Internet Garden" src="https://coolify.io/images/internetgarden.ico"/></a>
<a href="https://web3.career/?utm_source=coolify.io"><img width="60px" alt="Web3 Jobs" src="https://coolify.io/images/web3jobs.png"/></a>
<a href="https://codext.link/coolify-io?utm_source=coolify.io"><img width="60px" alt="Codext" src="https://coolify.io/images/codext.jpg"/></a>
<a href="https://github.com/monocursive"><img width="60px" alt="Michael Mazurczak" src="https://github.com/monocursive.png"/></a>
<a href="https://fider.io/?utm_source=coolify.io"><img width="60px" alt="Fider" src="https://github.com/getfider.png"/></a>
<a href="https://www.flint.sh/en/home?utm_source=coolify.io"><img width="60px" alt="Flint" src="https://github.com/Flint-company.png"/></a>
<a href="https://github.com/urtho"><img width="60px" alt="Paweł Pierścionek" src="https://github.com/urtho.png"/></a>
<a href="https://www.runpod.io/?utm_source=coolify.io"><img width="60px" alt="RunPod" src="https://coolify.io/images/runpod.svg"/></a>
<a href="https://dartnode.com/?utm_source=coolify.io"><img width="60px" alt="DartNode" src="https://github.com/dartnode.png"/></a>
<a href="https://github.com/whitesidest"><img width="60px" alt="Tyler Whitesides" src="https://avatars.githubusercontent.com/u/12365916?s=52&v=4"/></a>
<a href="https://serpapi.com/?utm_source=coolify.io"><img width="60px" alt="SerpAPI" src="https://github.com/serpapi.png"/></a>
<a href="https://aquarela.io"><img width="60px" alt="Aquarela" src="https://github.com/aquarela-io.png"/></a>
<a href="https://cryptojobslist.com/?utm_source=coolify.io"><img width="60px" alt="Crypto Jobs List" src="https://github.com/cryptojobslist.png"/></a>
<a href="https://www.youtube.com/@AlfredNutile?utm_source=coolify.io"><img width="60px" alt="Alfred Nutile" src="https://github.com/alnutile.png"/></a>
<a href="https://startupfa.me?utm_source=coolify.io"><img width="60px" alt="Startup Fame" src="https://github.com/startupfame.png"/></a>
<a href="https://barrad.me/?utm_source=coolify.io"><img width="60px" alt="Younes Barrad" src="https://github.com/Flowko.png"/></a>
<a href="https://jonasjaeger.com?utm_source=coolify.io"><img width="60px" alt="Jonas Jaeger" src="https://github.com/toxin20.png"/></a>
<a href="https://pixel.ao/?utm_source=coolify.io"><img width="60px" alt="Pixel Infinito" src="https://github.com/pixelinfinito.png"/></a>
<a href="https://github.com/corentinclichy"><img width="60px" alt="Corentin Clichy" src="https://github.com/corentinclichy.png"/></a>
<a href="https://x.com/mrsmith9ja?utm_source=coolify.io"><img width="60px" alt="Thompson Edolo" src="https://github.com/verygreenboi.png"/></a>
<a href="https://devhuset.no?utm_source=coolify.io"><img width="60px" alt="Devhuset" src="https://github.com/devhuset.png"/></a>
<a href="https://arvensis.systems/?utm_source=coolify.io"><img width="60px" alt="Arvensis Systems" src="https://coolify.io/images/arvensis.png"/></a>
<a href="https://github.com/Niki2k1"><img width="60px" alt="Niklas Lausch" src="https://github.com/Niki2k1.png"/></a>
<a href="https://capgo.app/?utm_source=coolify.io"><img width="60px" alt="Cap-go" src="https://github.com/cap-go.png"/></a>
<a href="https://interviewpal.com/?utm_source=coolify.io"><img width="60px" alt="InterviewPal" src="/public/svgs/interviewpal.svg"/></a>
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
## Recognitions
<p>
<a href="https://news.ycombinator.com/item?id=26624341">
<img
style="width: 250px; height: 54px;" width="250" height="54"
alt="Featured on Hacker News"
src="https://hackernews-badge.vercel.app/api?id=26624341"
/>
</a>
</p>
<a href="https://www.producthunt.com/posts/coolify?ref=badge-featured&utm_medium=badge&utm_souce=badge-coolify" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=338273&theme=light" alt="Coolify - An&#0032;open&#0045;source&#0032;&#0038;&#0032;self&#0045;hostable&#0032;Heroku&#0044;&#0032;Netlify&#0032;alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://trendshift.io/repositories/634" target="_blank"><img src="https://trendshift.io/api/badge/repositories/634" alt="coollabsio%2Fcoolify | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
## Core Maintainers
| Andras Bacsai | 🏔️ Peak |
|------------|------------|
| <img src="https://github.com/andrasbacsai.png" width="200px" alt="Andras Bacsai" /> | <img src="https://github.com/peaklabs-dev.png" width="200px" alt="peaklabs-dev" /> |
| <a href="https://github.com/andrasbacsai"><img src="https://api.iconify.design/devicon:github.svg" width="25px"></a> <a href="https://x.com/heyandras"><img src="https://api.iconify.design/devicon:twitter.svg" width="25px"></a> <a href="https://bsky.app/profile/heyandras.dev"><img src="https://api.iconify.design/simple-icons:bluesky.svg" width="25px"></a> | <a href="https://github.com/peaklabs-dev"><img src="https://api.iconify.design/devicon:github.svg" width="25px"></a> <a href="https://x.com/peaklabs_dev"><img src="https://api.iconify.design/devicon:twitter.svg" width="25px"></a> <a href="https://bsky.app/profile/peaklabs.dev"><img src="https://api.iconify.design/simple-icons:bluesky.svg" width="25px"></a> |
## Repo Activity
![Alt](https://repobeats.axiom.co/api/embed/eab1c8066f9c59d0ad37b76c23ebb5ccac4278ae.svg "Repobeats analytics image")
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=coollabsio/coolify&type=Date)](https://star-history.com/#coollabsio/coolify&Date)
- WIP

View file

@ -1,133 +0,0 @@
# Coolify Release Guide
This guide outlines the release process for Coolify, intended for developers and those interested in understanding how Coolify releases are managed and deployed.
## Table of Contents
- [Release Process](#release-process)
- [Version Types](#version-types)
- [Stable](#stable)
- [Nightly](#nightly)
- [Beta](#beta)
- [Version Availability](#version-availability)
- [Self-Hosted](#self-hosted)
- [Cloud](#cloud)
- [Manually Update to Specific Versions](#manually-update-to-specific-versions)
## Release Process
1. **Development on `next` or Feature Branches**
- Improvements, fixes, and new features are developed on the `next` branch or separate feature branches.
2. **Merging to `main`**
- Once ready, changes are merged from the `next` branch into the `main` branch (via a pull request).
3. **Building the Release**
- After merging to `main`, GitHub Actions automatically builds release images for all architectures and pushes them to the GitHub Container Registry and Docker Hub with the specific version tag and the `latest` tag.
4. **Creating a GitHub Release**
- A new GitHub release is manually created with details of the changes made in the version.
5. **Updating the CDN**
- To make a new version publicly available, the version information on the CDN needs to be updated manually. After that the new version number will be available at [https://cdn.coollabs.io/coolify/versions.json](https://cdn.coollabs.io/coolify/versions.json).
> [!NOTE]
> The CDN update may not occur immediately after the GitHub release. It can take hours or even days due to additional testing, stability checks, or potential hotfixes. **The update becomes available only after the CDN is updated. After the CDN is updated, a discord announcement will be made in the Production Release channel.**
## Version Types
<details>
<summary><strong>Stable (coming soon)</strong></summary>
- **Stable**
- The production version suitable for stable, production environments (recommended).
- **Update Frequency:** Every 2 to 4 weeks, with more frequent possible fixes.
- **Release Size:** Larger but less frequent releases. Multiple nightly versions are consolidated into a single stable release.
- **Versioning Scheme:** Follows semantic versioning (e.g., `v4.0.0`, `4.1.0`, etc.).
- **Installation Command:**
```bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
```
</details>
<details>
<summary><strong>Nightly</strong></summary>
- **Nightly**
- The latest development version, suitable for testing the latest changes and experimenting with new features.
- **Update Frequency:** Daily or bi-weekly updates.
- **Release Size:** Smaller, more frequent releases.
- **Versioning Scheme:** Follows semantic versioning (e.g., `4.1.0-nightly.1`, `4.1.0-nightly.2`, etc.).
- **Installation Command:**
```bash
curl -fsSL https://cdn.coollabs.io/coolify-nightly/install.sh | bash -s next
```
</details>
<details>
<summary><strong>Beta</strong></summary>
- **Beta**
- Test releases for the upcoming stable version.
- **Purpose:** Allows users to test and provide feedback on new features and changes before they become stable.
- **Update Frequency:** Available if we think beta testing is necessary.
- **Release Size:** Same size as stable release as it will become the next stabe release after some time.
- **Versioning Scheme:** Follows semantic versioning (e.g., `4.1.0-beta.1`, `4.1.0-beta.2`, etc.).
- **Installation Command:**
```bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
```
</details>
> [!WARNING]
> Do not use nightly/beta builds in production as there is no guarantee of stability.
## Version Availability
When a new version is released and a new GitHub release is created, it doesn't immediately become available for your instance. Here's how version availability works for different instance types.
### Self-Hosted
- **Update Frequency:** More frequent updates, especially on the nightly release channel.
- **Update Availability:** New versions are available once the CDN has been updated.
- **Update Methods:**
1. **Manual Update in Instance Settings:**
- Go to `Settings > Update Check Frequency` and click the `Check Manually` button.
- If an update is available, an upgrade button will appear on the sidebar.
2. **Automatic Update:**
- If enabled, the instance will update automatically at the time set in the settings.
3. **Re-run Installation Script:**
- Run the installation script again to upgrade to the latest version available on the CDN:
```bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
```
> [!IMPORTANT]
> If a new release is available on GitHub but your instance hasn't updated yet or no upgrade button is shown in the UI, the CDN might not have been updated yet. This intentional delay ensures stability and allows for hotfixes before official release.
### Cloud
- **Update Frequency:** Less frequent as it's a managed service.
- **Update Availability:** New versions are available once Andras has updated the cloud version manually.
- **Update Method:**
- Updates are managed by Andras, who ensures each cloud version is thoroughly tested and stable before releasing it.
> [!IMPORTANT]
> The cloud version of Coolify may be several versions behind the latest GitHub releases even if the CDN is updated. This is intentional to ensure stability and reliability for cloud users and Andras will manully update the cloud version when the update is ready.
## Manually Update/ Downgrade to Specific Versions
> [!CAUTION]
> Updating to unreleased versions is not recommended and can cause issues.
> [!IMPORTANT]
> Downgrading is supported but not recommended and can cause issues because of database migrations and other changes.
To update your Coolify instance to a specific version, use the following command:
```bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash -s <version>
```
Replace `<version>` with the version you want to update to (for example `4.0.0-beta.332`).

View file

@ -1,29 +0,0 @@
# Coolify Technology Stack
## Frontend
- Livewire and Alpine.js
- Blade (PHP templating engine)
- Tailwind CSS
- Monaco Editor (Code editor component)
- XTerm.js (Terminal component)
## Backend
- Laravel 11 (PHP Framework)
- PostgreSQL 15 (Database)
- Redis 7 (Caching & Real-time features)
- Soketi (WebSocket Server)
## DevOps & Infrastructure
- Docker & Docker Compose
- Nginx (Web Server)
- S6 Overlay (Process Supervisor)
- GitHub Actions (CI/CD)
## Languages
- PHP 8.4
- JavaScript
- Shell/Bash scripts

View file

@ -1,16 +0,0 @@
<?php
namespace App\Actions\Application;
use App\Models\Application;
use Lorisleiva\Actions\Concerns\AsAction;
class GenerateConfig
{
use AsAction;
public function handle(Application $application, bool $is_json = false)
{
return $application->generateConfig(is_json: $is_json);
}
}

View file

@ -1,37 +0,0 @@
<?php
namespace App\Actions\Application;
use Laravel\Horizon\Contracts\JobRepository;
use Lorisleiva\Actions\Concerns\AsAction;
class IsHorizonQueueEmpty
{
use AsAction;
public function handle()
{
$hostname = gethostname();
$recent = app(JobRepository::class)->getRecent();
if ($recent) {
$running = $recent->filter(function ($job) use ($hostname) {
$payload = json_decode($job->payload);
$tags = data_get($payload, 'tags');
return $job->status != 'completed' &&
$job->status != 'failed' &&
isset($tags) &&
is_array($tags) &&
in_array('server:'.$hostname, $tags);
});
if ($running->count() > 0) {
echo 'false';
return false;
}
}
echo 'true';
return true;
}
}

View file

@ -1,16 +0,0 @@
<?php
namespace App\Actions\Application;
use App\Models\Application;
use Lorisleiva\Actions\Concerns\AsAction;
class LoadComposeFile
{
use AsAction;
public function handle(Application $application)
{
$application->loadComposeFile();
}
}

View file

@ -1,60 +0,0 @@
<?php
namespace App\Actions\Application;
use App\Actions\Server\CleanupDocker;
use App\Events\ServiceStatusChanged;
use App\Models\Application;
use Lorisleiva\Actions\Concerns\AsAction;
class StopApplication
{
use AsAction;
public string $jobQueue = 'high';
public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true)
{
$servers = collect([$application->destination->server]);
if ($application?->additional_servers?->count() > 0) {
$servers = $servers->merge($application->additional_servers);
}
foreach ($servers as $server) {
try {
if (! $server->isFunctional()) {
return 'Server is not functional';
}
if ($server->isSwarm()) {
instant_remote_process(["docker stack rm {$application->uuid}"], $server);
return;
}
$containers = $previewDeployments
? getCurrentApplicationContainerStatus($server, $application->id, includePullrequests: true)
: getCurrentApplicationContainerStatus($server, $application->id, 0);
$containersToStop = $containers->pluck('Names')->toArray();
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
"docker stop -t 30 $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
if ($application->build_pack === 'dockercompose') {
$application->deleteConnectedNetworks();
}
if ($dockerCleanup) {
CleanupDocker::dispatch($server, false, false);
}
} catch (\Exception $e) {
return $e->getMessage();
}
}
ServiceStatusChanged::dispatch($application->environment->project->team->id);
}
}

View file

@ -1,41 +0,0 @@
<?php
namespace App\Actions\Application;
use App\Models\Application;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class StopApplicationOneServer
{
use AsAction;
public function handle(Application $application, Server $server)
{
if ($application->destination->server->isSwarm()) {
return;
}
if (! $server->isFunctional()) {
return 'Server is not functional';
}
try {
$containers = getCurrentApplicationContainerStatus($server, $application->id, 0);
if ($containers->count() > 0) {
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
instant_remote_process(
[
"docker stop -t 30 $containerName",
"docker rm -f $containerName",
],
$server
);
}
}
}
} catch (\Exception $e) {
return $e->getMessage();
}
}
}

View file

@ -1,54 +0,0 @@
<?php
namespace App\Actions\CoolifyTask;
use App\Data\CoolifyTaskArgs;
use App\Jobs\CoolifyTask;
use Spatie\Activitylog\Models\Activity;
/**
* The initial step to run a `CoolifyTask`: a remote SSH process
* with monitoring/tracking/trace feature. Such thing is made
* possible using an Activity model and some attributes.
*/
class PrepareCoolifyTask
{
protected Activity $activity;
protected CoolifyTaskArgs $remoteProcessArgs;
public function __construct(CoolifyTaskArgs $remoteProcessArgs)
{
$this->remoteProcessArgs = $remoteProcessArgs;
if ($remoteProcessArgs->model) {
$properties = $remoteProcessArgs->toArray();
unset($properties['model']);
$this->activity = activity()
->withProperties($properties)
->performedOn($remoteProcessArgs->model)
->event($remoteProcessArgs->type)
->log('[]');
} else {
$this->activity = activity()
->withProperties($remoteProcessArgs->toArray())
->event($remoteProcessArgs->type)
->log('[]');
}
}
public function __invoke(): Activity
{
$job = new CoolifyTask(
activity: $this->activity,
ignore_errors: $this->remoteProcessArgs->ignore_errors,
call_event_on_finish: $this->remoteProcessArgs->call_event_on_finish,
call_event_data: $this->remoteProcessArgs->call_event_data,
);
dispatch($job);
$this->activity->refresh();
return $this->activity;
}
}

View file

@ -1,194 +0,0 @@
<?php
namespace App\Actions\CoolifyTask;
use App\Enums\ActivityTypes;
use App\Enums\ProcessStatus;
use App\Helpers\SshMultiplexingHelper;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Server;
use Illuminate\Process\ProcessResult;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Spatie\Activitylog\Models\Activity;
class RunRemoteProcess
{
public Activity $activity;
public bool $hide_from_output;
public bool $ignore_errors;
public $call_event_on_finish = null;
public $call_event_data = null;
protected $time_start;
protected $current_time;
protected $last_write_at = 0;
protected $throttle_interval_ms = 200;
protected int $counter = 1;
/**
* Create a new job instance.
*/
public function __construct(Activity $activity, bool $hide_from_output = false, bool $ignore_errors = false, $call_event_on_finish = null, $call_event_data = null)
{
if ($activity->getExtraProperty('type') !== ActivityTypes::INLINE->value && $activity->getExtraProperty('type') !== ActivityTypes::COMMAND->value) {
throw new \RuntimeException('Incompatible Activity to run a remote command.');
}
$this->activity = $activity;
$this->hide_from_output = $hide_from_output;
$this->ignore_errors = $ignore_errors;
$this->call_event_on_finish = $call_event_on_finish;
$this->call_event_data = $call_event_data;
}
public static function decodeOutput(?Activity $activity = null): string
{
if (is_null($activity)) {
return '';
}
try {
$decoded = json_decode(
data_get($activity, 'description'),
associative: true,
flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
);
} catch (\JsonException $exception) {
return '';
}
return collect($decoded)
->sortBy(fn ($i) => $i['order'])
->map(fn ($i) => $i['output'])
->implode('');
}
public function __invoke(): ProcessResult
{
$this->time_start = hrtime(true);
$status = ProcessStatus::IN_PROGRESS;
$timeout = config('constants.ssh.command_timeout');
$process = Process::timeout($timeout)->start($this->getCommand(), $this->handleOutput(...));
$this->activity->properties = $this->activity->properties->merge([
'process_id' => $process->id(),
]);
$processResult = $process->wait();
if ($this->activity->properties->get('status') === ProcessStatus::ERROR->value) {
$status = ProcessStatus::ERROR;
} else {
if ($processResult->exitCode() == 0) {
$status = ProcessStatus::FINISHED;
} else {
$status = ProcessStatus::ERROR;
}
}
$this->activity->properties = $this->activity->properties->merge([
'exitCode' => $processResult->exitCode(),
'stdout' => $processResult->output(),
'stderr' => $processResult->errorOutput(),
'status' => $status->value,
]);
$this->activity->save();
if ($this->call_event_on_finish) {
try {
$eventClass = "App\\Events\\$this->call_event_on_finish";
if (! is_null($this->call_event_data)) {
event(new $eventClass($this->call_event_data));
} else {
event(new $eventClass($this->activity->causer_id));
}
} catch (\Throwable $e) {
Log::error('Error calling event: '.$e->getMessage());
}
}
if ($processResult->exitCode() != 0 && ! $this->ignore_errors) {
throw new \RuntimeException($processResult->errorOutput(), $processResult->exitCode());
}
return $processResult;
}
protected function getCommand(): string
{
$server_uuid = $this->activity->getExtraProperty('server_uuid');
$command = $this->activity->getExtraProperty('command');
$server = Server::whereUuid($server_uuid)->firstOrFail();
return SshMultiplexingHelper::generateSshCommand($server, $command);
}
protected function handleOutput(string $type, string $output)
{
if ($this->hide_from_output) {
return;
}
$this->current_time = $this->elapsedTime();
$this->activity->description = $this->encodeOutput($type, $output);
if ($this->isAfterLastThrottle()) {
// Let's write to database.
DB::transaction(function () {
$this->activity->save();
$this->last_write_at = $this->current_time;
});
}
}
protected function elapsedTime(): int
{
$timeMs = (hrtime(true) - $this->time_start) / 1_000_000;
return intval($timeMs);
}
public function encodeOutput($type, $output)
{
$outputStack = json_decode($this->activity->description, associative: true, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$outputStack[] = [
'type' => $type,
'output' => $output,
'timestamp' => hrtime(true),
'batch' => ApplicationDeploymentJob::$batch_counter,
'order' => $this->getLatestCounter(),
];
return json_encode($outputStack, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
}
protected function getLatestCounter(): int
{
$description = json_decode($this->activity->description, associative: true, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
if ($description === null || count($description) === 0) {
return 1;
}
return end($description)['order'] + 1;
}
/**
* Determines if it's time to write again to database.
*
* @return bool
*/
protected function isAfterLastThrottle()
{
// If DB was never written, then we immediately decide we have to write.
if ($this->last_write_at === 0) {
return true;
}
return ($this->current_time - $this->throttle_interval_ms) > $this->last_write_at;
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
class RestartDatabase
{
use AsAction;
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
{
$server = $database->destination->server;
if (! $server->isFunctional()) {
return 'Server is not functional';
}
StopDatabase::run($database, dockerCleanup: false);
return StartDatabase::run($database);
}
}

View file

@ -1,167 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Models\StandaloneClickhouse;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class StartClickhouse
{
use AsAction;
public StandaloneClickhouse $database;
public array $commands = [];
public string $configuration_dir;
public function handle(StandaloneClickhouse $database)
{
$this->database = $database;
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
$this->commands = [
"echo 'Starting database.'",
"mkdir -p $this->configuration_dir",
];
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$environment_variables = $this->generate_environment_variables();
$docker_compose = [
'services' => [
$container_name => [
'image' => $this->database->image,
'container_name' => $container_name,
'environment' => $environment_variables,
'restart' => RESTART_MODE,
'networks' => [
$this->database->destination->network,
],
'ulimits' => [
'nofile' => [
'soft' => 262144,
'hard' => 262144,
],
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => "clickhouse-client --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'",
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
'mem_reservation' => $this->database->limits_memory_reservation,
'cpus' => (float) $this->database->limits_cpus,
'cpu_shares' => $this->database->limits_cpu_shares,
],
],
'networks' => [
$this->database->destination->network => [
'external' => true,
'name' => $this->database->destination->network,
'attachable' => true,
],
],
];
if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
}
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
}
if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
}
if (count($persistent_storages) > 0) {
$docker_compose['services'][$container_name]['volumes'] = $persistent_storages;
}
if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = $persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray();
}
if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names;
}
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}
private function generate_local_persistent_volumes()
{
$local_persistent_volumes = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) {
$local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path;
} else {
$volume_name = $persistentStorage->name;
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
}
}
return $local_persistent_volumes;
}
private function generate_local_persistent_volumes_only_volume_names()
{
$local_persistent_volumes_names = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path) {
continue;
}
$name = $persistentStorage->name;
$local_persistent_volumes_names[$name] = [
'name' => $name,
'external' => false,
];
}
return $local_persistent_volumes_names;
}
private function generate_environment_variables()
{
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_ADMIN_USER'))->isEmpty()) {
$environment_variables->push("CLICKHOUSE_ADMIN_USER={$this->database->clickhouse_admin_user}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_ADMIN_PASSWORD'))->isEmpty()) {
$environment_variables->push("CLICKHOUSE_ADMIN_PASSWORD={$this->database->clickhouse_admin_password}");
}
add_coolify_default_environment_variables($this->database, $environment_variables, $environment_variables);
return $environment_variables->all();
}
}

View file

@ -1,59 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
class StartDatabase
{
use AsAction;
public string $jobQueue = 'high';
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
{
$server = $database->destination->server;
if (! $server->isFunctional()) {
return 'Server is not functional';
}
switch ($database->getMorphClass()) {
case \App\Models\StandalonePostgresql::class:
$activity = StartPostgresql::run($database);
break;
case \App\Models\StandaloneRedis::class:
$activity = StartRedis::run($database);
break;
case \App\Models\StandaloneMongodb::class:
$activity = StartMongodb::run($database);
break;
case \App\Models\StandaloneMysql::class:
$activity = StartMysql::run($database);
break;
case \App\Models\StandaloneMariadb::class:
$activity = StartMariadb::run($database);
break;
case \App\Models\StandaloneKeydb::class:
$activity = StartKeydb::run($database);
break;
case \App\Models\StandaloneDragonfly::class:
$activity = StartDragonfly::run($database);
break;
case \App\Models\StandaloneClickhouse::class:
$activity = StartClickhouse::run($database);
break;
}
if ($database->is_public && $database->public_port) {
StartDatabaseProxy::dispatch($database);
}
return $activity;
}
}

View file

@ -1,124 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class StartDatabaseProxy
{
use AsAction;
public string $jobQueue = 'high';
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database)
{
$databaseType = $database->database_type;
$network = data_get($database, 'destination.network');
$server = data_get($database, 'destination.server');
$containerName = data_get($database, 'uuid');
$proxyContainerName = "{$database->uuid}-proxy";
$isSSLEnabled = $database->enable_ssl ?? false;
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$databaseType = $database->databaseType();
$network = $database->service->uuid;
$server = data_get($database, 'service.destination.server');
$proxyContainerName = "{$database->service->uuid}-proxy";
$containerName = "{$database->name}-{$database->service->uuid}";
}
$internalPort = match ($databaseType) {
'standalone-mariadb', 'standalone-mysql' => 3306,
'standalone-postgresql', 'standalone-supabase/postgres' => 5432,
'standalone-redis', 'standalone-keydb', 'standalone-dragonfly' => 6379,
'standalone-clickhouse' => 9000,
'standalone-mongodb' => 27017,
default => throw new \Exception("Unsupported database type: $databaseType"),
};
if ($isSSLEnabled) {
$internalPort = match ($databaseType) {
'standalone-redis', 'standalone-keydb', 'standalone-dragonfly' => 6380,
default => $internalPort,
};
}
$configuration_dir = database_proxy_dir($database->uuid);
if (isDev()) {
$configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy';
}
$nginxconf = <<<EOF
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
events {
worker_connections 1024;
}
stream {
server {
listen $database->public_port;
proxy_pass $containerName:$internalPort;
}
}
EOF;
$docker_compose = [
'services' => [
$proxyContainerName => [
'image' => 'nginx:stable-alpine',
'container_name' => $proxyContainerName,
'restart' => RESTART_MODE,
'ports' => [
"$database->public_port:$database->public_port",
],
'networks' => [
$network,
],
'volumes' => [
[
'type' => 'bind',
'source' => "$configuration_dir/nginx.conf",
'target' => '/etc/nginx/nginx.conf',
],
],
'healthcheck' => [
'test' => [
'CMD-SHELL',
'stat /etc/nginx/nginx.conf || exit 1',
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 3,
'start_period' => '1s',
],
],
],
'networks' => [
$network => [
'external' => true,
'name' => $network,
'attachable' => true,
],
],
];
$dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2));
$nginxconf_base64 = base64_encode($nginxconf);
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
instant_remote_process([
"mkdir -p $configuration_dir",
"echo '{$nginxconf_base64}' | base64 -d | tee $configuration_dir/nginx.conf > /dev/null",
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
"docker compose --project-directory {$configuration_dir} pull",
"docker compose --project-directory {$configuration_dir} up -d",
], $server);
}
}

View file

@ -1,265 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneDragonfly;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class StartDragonfly
{
use AsAction;
public StandaloneDragonfly $database;
public array $commands = [];
public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneDragonfly $database)
{
$this->database = $database;
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
$this->commands = [
"echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir",
"echo 'Directories created successfully.'",
];
if (! $this->database->enable_ssl) {
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
$this->database->sslCertificates()->delete();
$this->database->fileStorages()
->where('resource_type', $this->database->getMorphClass())
->where('resource_id', $this->database->id)
->get()
->filter(function ($storage) {
return in_array($storage->mount_path, [
'/etc/dragonfly/certs/server.crt',
'/etc/dragonfly/certs/server.key',
]);
})
->each(function ($storage) {
$storage->delete();
});
} else {
$this->commands[] = "echo 'Setting up SSL for this database.'";
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$this->ssl_certificate = $this->database->sslCertificates()->first();
if (! $this->ssl_certificate) {
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
$this->ssl_certificate = SslHelper::generateSslCertificate(
commonName: $this->database->uuid,
resourceType: $this->database->getMorphClass(),
resourceId: $this->database->id,
serverId: $server->id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $this->configuration_dir,
mountPath: '/etc/dragonfly/certs',
);
}
}
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$environment_variables = $this->generate_environment_variables();
$startCommand = $this->buildStartCommand();
$docker_compose = [
'services' => [
$container_name => [
'image' => $this->database->image,
'command' => $startCommand,
'container_name' => $container_name,
'environment' => $environment_variables,
'restart' => RESTART_MODE,
'networks' => [
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => "redis-cli -a {$this->database->dragonfly_password} ping",
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
'mem_reservation' => $this->database->limits_memory_reservation,
'cpus' => (float) $this->database->limits_cpus,
'cpu_shares' => $this->database->limits_cpu_shares,
],
],
'networks' => [
$this->database->destination->network => [
'external' => true,
'name' => $this->database->destination->network,
'attachable' => true,
],
],
];
if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
}
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
}
if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
}
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
$persistent_storages
);
}
if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
$persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
}
if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names;
}
if ($this->database->enable_ssl) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
[
'type' => 'bind',
'source' => '/data/coolify/ssl/coolify-ca.crt',
'target' => '/etc/dragonfly/certs/coolify-ca.crt',
'read_only' => true,
],
]
);
}
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}
private function buildStartCommand(): string
{
$command = "dragonfly --requirepass {$this->database->dragonfly_password}";
if ($this->database->enable_ssl) {
$sslArgs = [
'--tls',
'--tls_cert_file /etc/dragonfly/certs/server.crt',
'--tls_key_file /etc/dragonfly/certs/server.key',
'--tls_ca_cert_file /etc/dragonfly/certs/coolify-ca.crt',
];
$command .= ' '.implode(' ', $sslArgs);
}
return $command;
}
private function generate_local_persistent_volumes()
{
$local_persistent_volumes = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) {
$local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path;
} else {
$volume_name = $persistentStorage->name;
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
}
}
return $local_persistent_volumes;
}
private function generate_local_persistent_volumes_only_volume_names()
{
$local_persistent_volumes_names = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path) {
continue;
}
$name = $persistentStorage->name;
$local_persistent_volumes_names[$name] = [
'name' => $name,
'external' => false,
];
}
return $local_persistent_volumes_names;
}
private function generate_environment_variables()
{
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
$environment_variables->push("REDIS_PASSWORD={$this->database->dragonfly_password}");
}
return $environment_variables->all();
}
}

View file

@ -1,310 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb;
use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class StartKeydb
{
use AsAction;
public StandaloneKeydb $database;
public array $commands = [];
public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneKeydb $database)
{
$this->database = $database;
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
$this->commands = [
"echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir",
"echo 'Directories created successfully.'",
];
if (! $this->database->enable_ssl) {
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
$this->database->sslCertificates()->delete();
$this->database->fileStorages()
->where('resource_type', $this->database->getMorphClass())
->where('resource_id', $this->database->id)
->get()
->filter(function ($storage) {
return in_array($storage->mount_path, [
'/etc/keydb/certs/server.crt',
'/etc/keydb/certs/server.key',
]);
})
->each(function ($storage) {
$storage->delete();
});
} else {
$this->commands[] = "echo 'Setting up SSL for this database.'";
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$this->ssl_certificate = $this->database->sslCertificates()->first();
if (! $this->ssl_certificate) {
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
$this->ssl_certificate = SslHelper::generateSslCertificate(
commonName: $this->database->uuid,
resourceType: $this->database->getMorphClass(),
resourceId: $this->database->id,
serverId: $server->id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $this->configuration_dir,
mountPath: '/etc/keydb/certs',
);
}
}
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$environment_variables = $this->generate_environment_variables();
$this->add_custom_keydb();
$startCommand = $this->buildStartCommand();
$docker_compose = [
'services' => [
$container_name => [
'image' => $this->database->image,
'command' => $startCommand,
'container_name' => $container_name,
'environment' => $environment_variables,
'restart' => RESTART_MODE,
'networks' => [
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => "keydb-cli --pass {$this->database->keydb_password} ping",
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
'mem_reservation' => $this->database->limits_memory_reservation,
'cpus' => (float) $this->database->limits_cpus,
'cpu_shares' => $this->database->limits_cpu_shares,
],
],
'networks' => [
$this->database->destination->network => [
'external' => true,
'name' => $this->database->destination->network,
'attachable' => true,
],
],
];
if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
}
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
}
if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
}
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
$persistent_storages
);
}
if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
$persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
}
if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names;
}
if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
[
'type' => 'bind',
'source' => $this->configuration_dir.'/keydb.conf',
'target' => '/etc/keydb/keydb.conf',
'read_only' => true,
],
]
);
}
if ($this->database->enable_ssl) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
[
'type' => 'bind',
'source' => '/data/coolify/ssl/coolify-ca.crt',
'target' => '/etc/keydb/certs/coolify-ca.crt',
'read_only' => true,
],
]
);
}
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}
private function generate_local_persistent_volumes()
{
$local_persistent_volumes = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) {
$local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path;
} else {
$volume_name = $persistentStorage->name;
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
}
}
return $local_persistent_volumes;
}
private function generate_local_persistent_volumes_only_volume_names()
{
$local_persistent_volumes_names = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path) {
continue;
}
$name = $persistentStorage->name;
$local_persistent_volumes_names[$name] = [
'name' => $name,
'external' => false,
];
}
return $local_persistent_volumes_names;
}
private function generate_environment_variables()
{
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
$environment_variables->push("REDIS_PASSWORD={$this->database->keydb_password}");
}
add_coolify_default_environment_variables($this->database, $environment_variables, $environment_variables);
return $environment_variables->all();
}
private function add_custom_keydb()
{
if (is_null($this->database->keydb_conf) || empty($this->database->keydb_conf)) {
return;
}
$filename = 'keydb.conf';
Storage::disk('local')->put("tmp/keydb.conf_{$this->database->uuid}", $this->database->keydb_conf);
$path = Storage::path("tmp/keydb.conf_{$this->database->uuid}");
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
Storage::disk('local')->delete("tmp/keydb.conf_{$this->database->uuid}");
}
private function buildStartCommand(): string
{
$hasKeydbConf = ! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf);
$keydbConfPath = '/etc/keydb/keydb.conf';
if ($hasKeydbConf) {
$confContent = $this->database->keydb_conf;
$hasRequirePass = str_contains($confContent, 'requirepass');
if ($hasRequirePass) {
$command = "keydb-server $keydbConfPath";
} else {
$command = "keydb-server $keydbConfPath --requirepass {$this->database->keydb_password}";
}
} else {
$command = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes";
}
if ($this->database->enable_ssl) {
$sslArgs = [
'--tls-port 6380',
'--tls-cert-file /etc/keydb/certs/server.crt',
'--tls-key-file /etc/keydb/certs/server.key',
'--tls-ca-cert-file /etc/keydb/certs/coolify-ca.crt',
'--tls-auth-clients optional',
];
$command .= ' '.implode(' ', $sslArgs);
}
return $command;
}
}

View file

@ -1,292 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMariadb;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class StartMariadb
{
use AsAction;
public StandaloneMariadb $database;
public array $commands = [];
public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMariadb $database)
{
$this->database = $database;
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
$this->commands = [
"echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir",
"echo 'Directories created successfully.'",
];
if (! $this->database->enable_ssl) {
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
$this->database->sslCertificates()->delete();
$this->database->fileStorages()
->where('resource_type', $this->database->getMorphClass())
->where('resource_id', $this->database->id)
->get()
->filter(function ($storage) {
return in_array($storage->mount_path, [
'/etc/mysql/certs/server.crt',
'/etc/mysql/certs/server.key',
]);
})
->each(function ($storage) {
$storage->delete();
});
} else {
$this->commands[] = "echo 'Setting up SSL for this database.'";
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$this->ssl_certificate = $this->database->sslCertificates()->first();
if (! $this->ssl_certificate) {
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
$this->ssl_certificate = SslHelper::generateSslCertificate(
commonName: $this->database->uuid,
resourceType: $this->database->getMorphClass(),
resourceId: $this->database->id,
serverId: $server->id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $this->configuration_dir,
mountPath: '/etc/mysql/certs',
);
}
}
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$environment_variables = $this->generate_environment_variables();
$this->add_custom_mysql();
$docker_compose = [
'services' => [
$container_name => [
'image' => $this->database->image,
'container_name' => $container_name,
'environment' => $environment_variables,
'restart' => RESTART_MODE,
'networks' => [
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
'mem_reservation' => $this->database->limits_memory_reservation,
'cpus' => (float) $this->database->limits_cpus,
'cpu_shares' => $this->database->limits_cpu_shares,
],
],
'networks' => [
$this->database->destination->network => [
'external' => true,
'name' => $this->database->destination->network,
'attachable' => true,
],
],
];
if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
}
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
}
if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
}
if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names;
}
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
$persistent_storages
);
}
if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
$persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
}
if ($this->database->enable_ssl) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
[
'type' => 'bind',
'source' => '/data/coolify/ssl/coolify-ca.crt',
'target' => '/etc/mysql/certs/coolify-ca.crt',
'read_only' => true,
],
]
);
}
if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
[
[
'type' => 'bind',
'source' => $this->configuration_dir.'/custom-config.cnf',
'target' => '/etc/mysql/conf.d/custom-config.cnf',
'read_only' => true,
],
]
);
}
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if ($this->database->enable_ssl) {
$docker_compose['services'][$container_name]['command'] = [
'mariadbd',
'--ssl-cert=/etc/mysql/certs/server.crt',
'--ssl-key=/etc/mysql/certs/server.key',
'--ssl-ca=/etc/mysql/certs/coolify-ca.crt',
'--require-secure-transport=1',
];
}
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, 'chown mysql:mysql /etc/mysql/certs/server.crt /etc/mysql/certs/server.key');
}
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}
private function generate_local_persistent_volumes()
{
$local_persistent_volumes = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) {
$local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path;
} else {
$volume_name = $persistentStorage->name;
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
}
}
return $local_persistent_volumes;
}
private function generate_local_persistent_volumes_only_volume_names()
{
$local_persistent_volumes_names = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path) {
continue;
}
$name = $persistentStorage->name;
$local_persistent_volumes_names[$name] = [
'name' => $name,
'external' => false,
];
}
return $local_persistent_volumes_names;
}
private function generate_environment_variables()
{
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) {
$environment_variables->push("MARIADB_ROOT_PASSWORD={$this->database->mariadb_root_password}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_DATABASE'))->isEmpty()) {
$environment_variables->push("MARIADB_DATABASE={$this->database->mariadb_database}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_USER'))->isEmpty()) {
$environment_variables->push("MARIADB_USER={$this->database->mariadb_user}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_PASSWORD'))->isEmpty()) {
$environment_variables->push("MARIADB_PASSWORD={$this->database->mariadb_password}");
}
add_coolify_default_environment_variables($this->database, $environment_variables, $environment_variables);
return $environment_variables->all();
}
private function add_custom_mysql()
{
if (is_null($this->database->mariadb_conf) || empty($this->database->mariadb_conf)) {
return;
}
$filename = 'custom-config.cnf';
$content = $this->database->mariadb_conf;
$content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
}

View file

@ -1,348 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMongodb;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class StartMongodb
{
use AsAction;
public StandaloneMongodb $database;
public array $commands = [];
public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMongodb $database)
{
$this->database = $database;
$startCommand = 'mongod';
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
if (isDev()) {
$this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name;
}
$this->commands = [
"echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir",
"echo 'Directories created successfully.'",
];
if (! $this->database->enable_ssl) {
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
$this->database->sslCertificates()->delete();
$this->database->fileStorages()
->where('resource_type', $this->database->getMorphClass())
->where('resource_id', $this->database->id)
->get()
->filter(function ($storage) {
return in_array($storage->mount_path, [
'/etc/mongo/certs/server.pem',
]);
})
->each(function ($storage) {
$storage->delete();
});
} else {
$this->commands[] = "echo 'Setting up SSL for this database.'";
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$this->ssl_certificate = $this->database->sslCertificates()->first();
if (! $this->ssl_certificate) {
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
$this->ssl_certificate = SslHelper::generateSslCertificate(
commonName: $this->database->uuid,
resourceType: $this->database->getMorphClass(),
resourceId: $this->database->id,
serverId: $server->id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $this->configuration_dir,
mountPath: '/etc/mongo/certs',
isPemKeyFileRequired: true,
);
}
}
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$environment_variables = $this->generate_environment_variables();
$this->add_custom_mongo_conf();
$docker_compose = [
'services' => [
$container_name => [
'image' => $this->database->image,
'command' => $startCommand,
'container_name' => $container_name,
'environment' => $environment_variables,
'restart' => RESTART_MODE,
'networks' => [
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => [
'CMD',
'echo',
'ok',
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
'mem_reservation' => $this->database->limits_memory_reservation,
'cpus' => (float) $this->database->limits_cpus,
'cpu_shares' => $this->database->limits_cpu_shares,
],
],
'networks' => [
$this->database->destination->network => [
'external' => true,
'name' => $this->database->destination->network,
'attachable' => true,
],
],
];
if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
}
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
}
if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
}
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
$persistent_storages
);
}
if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
$persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
}
if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names;
}
if (! empty($this->database->mongo_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[[
'type' => 'bind',
'source' => $this->configuration_dir.'/mongod.conf',
'target' => '/etc/mongo/mongod.conf',
'read_only' => true,
]]
);
$docker_compose['services'][$container_name]['command'] = ['mongod', '--config', '/etc/mongo/mongod.conf'];
}
$this->add_default_database();
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[[
'type' => 'bind',
'source' => $this->configuration_dir.'/docker-entrypoint-initdb.d',
'target' => '/docker-entrypoint-initdb.d',
'read_only' => true,
]]
);
if ($this->database->enable_ssl) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
[
'type' => 'bind',
'source' => '/data/coolify/ssl/coolify-ca.crt',
'target' => '/etc/mongo/certs/ca.pem',
'read_only' => true,
],
]
);
}
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if ($this->database->enable_ssl) {
$commandParts = ['mongod'];
if (! empty($this->database->mongo_conf)) {
$commandParts = ['mongod', '--config', '/etc/mongo/mongod.conf'];
}
$sslConfig = match ($this->database->ssl_mode) {
'allow' => [
'--tlsMode=allowTLS',
'--tlsAllowConnectionsWithoutCertificates',
'--tlsAllowInvalidHostnames',
],
'prefer' => [
'--tlsMode=preferTLS',
'--tlsAllowConnectionsWithoutCertificates',
'--tlsAllowInvalidHostnames',
],
'require' => [
'--tlsMode=requireTLS',
'--tlsAllowConnectionsWithoutCertificates',
'--tlsAllowInvalidHostnames',
],
'verify-full' => [
'--tlsMode=requireTLS',
'--tlsAllowInvalidHostnames',
],
default => [],
};
$commandParts = [...$commandParts, ...$sslConfig];
$commandParts[] = '--tlsCAFile';
$commandParts[] = '/etc/mongo/certs/ca.pem';
$commandParts[] = '--tlsCertificateKeyFile';
$commandParts[] = '/etc/mongo/certs/server.pem';
$docker_compose['services'][$container_name]['command'] = $commandParts;
}
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, 'chown mongodb:mongodb /etc/mongo/certs/server.pem');
}
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}
private function generate_local_persistent_volumes()
{
$local_persistent_volumes = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) {
$local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path;
} else {
$volume_name = $persistentStorage->name;
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
}
}
return $local_persistent_volumes;
}
private function generate_local_persistent_volumes_only_volume_names()
{
$local_persistent_volumes_names = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path) {
continue;
}
$name = $persistentStorage->name;
$local_persistent_volumes_names[$name] = [
'name' => $name,
'external' => false,
];
}
return $local_persistent_volumes_names;
}
private function generate_environment_variables()
{
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) {
$environment_variables->push("MONGO_INITDB_ROOT_USERNAME={$this->database->mongo_initdb_root_username}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_PASSWORD'))->isEmpty()) {
$environment_variables->push("MONGO_INITDB_ROOT_PASSWORD={$this->database->mongo_initdb_root_password}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_DATABASE'))->isEmpty()) {
$environment_variables->push("MONGO_INITDB_DATABASE={$this->database->mongo_initdb_database}");
}
add_coolify_default_environment_variables($this->database, $environment_variables, $environment_variables);
return $environment_variables->all();
}
private function add_custom_mongo_conf()
{
if (is_null($this->database->mongo_conf) || empty($this->database->mongo_conf)) {
return;
}
$filename = 'mongod.conf';
$content = $this->database->mongo_conf;
$content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
private function add_default_database()
{
$content = "db = db.getSiblingDB(\"{$this->database->mongo_initdb_database}\");db.createCollection('init_collection');db.createUser({user: \"{$this->database->mongo_initdb_root_username}\", pwd: \"{$this->database->mongo_initdb_root_password}\",roles: [{role:\"readWrite\",db:\"{$this->database->mongo_initdb_database}\"}]});";
$content_base64 = base64_encode($content);
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null";
}
}

View file

@ -1,295 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMysql;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class StartMysql
{
use AsAction;
public StandaloneMysql $database;
public array $commands = [];
public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMysql $database)
{
$this->database = $database;
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
$this->commands = [
"echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir",
"echo 'Directories created successfully.'",
];
if (! $this->database->enable_ssl) {
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
$this->database->sslCertificates()->delete();
$this->database->fileStorages()
->where('resource_type', $this->database->getMorphClass())
->where('resource_id', $this->database->id)
->get()
->filter(function ($storage) {
return in_array($storage->mount_path, [
'/etc/mysql/certs/server.crt',
'/etc/mysql/certs/server.key',
]);
})
->each(function ($storage) {
$storage->delete();
});
} else {
$this->commands[] = "echo 'Setting up SSL for this database.'";
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$this->ssl_certificate = $this->database->sslCertificates()->first();
if (! $this->ssl_certificate) {
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
$this->ssl_certificate = SslHelper::generateSslCertificate(
commonName: $this->database->uuid,
resourceType: $this->database->getMorphClass(),
resourceId: $this->database->id,
serverId: $server->id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $this->configuration_dir,
mountPath: '/etc/mysql/certs',
);
}
}
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$environment_variables = $this->generate_environment_variables();
$this->add_custom_mysql();
$docker_compose = [
'services' => [
$container_name => [
'image' => $this->database->image,
'container_name' => $container_name,
'environment' => $environment_variables,
'restart' => RESTART_MODE,
'networks' => [
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
'mem_reservation' => $this->database->limits_memory_reservation,
'cpus' => (float) $this->database->limits_cpus,
'cpu_shares' => $this->database->limits_cpu_shares,
],
],
'networks' => [
$this->database->destination->network => [
'external' => true,
'name' => $this->database->destination->network,
'attachable' => true,
],
],
];
if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
}
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
}
if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
}
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
$persistent_storages
);
}
if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
$persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
}
if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names;
}
if ($this->database->enable_ssl) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
[
'type' => 'bind',
'source' => '/data/coolify/ssl/coolify-ca.crt',
'target' => '/etc/mysql/certs/coolify-ca.crt',
'read_only' => true,
],
]
);
}
if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
[
'type' => 'bind',
'source' => $this->configuration_dir.'/custom-config.cnf',
'target' => '/etc/mysql/conf.d/custom-config.cnf',
'read_only' => true,
],
]
);
}
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if ($this->database->enable_ssl) {
$docker_compose['services'][$container_name]['command'] = [
'mysqld',
'--ssl-cert=/etc/mysql/certs/server.crt',
'--ssl-key=/etc/mysql/certs/server.key',
'--ssl-ca=/etc/mysql/certs/coolify-ca.crt',
'--require-secure-transport=1',
];
}
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->mysql_user}:{$this->database->mysql_user} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
}
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}
private function generate_local_persistent_volumes()
{
$local_persistent_volumes = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) {
$local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path;
} else {
$volume_name = $persistentStorage->name;
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
}
}
return $local_persistent_volumes;
}
private function generate_local_persistent_volumes_only_volume_names()
{
$local_persistent_volumes_names = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path) {
continue;
}
$name = $persistentStorage->name;
$local_persistent_volumes_names[$name] = [
'name' => $name,
'external' => false,
];
}
return $local_persistent_volumes_names;
}
private function generate_environment_variables()
{
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) {
$environment_variables->push("MYSQL_ROOT_PASSWORD={$this->database->mysql_root_password}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_DATABASE'))->isEmpty()) {
$environment_variables->push("MYSQL_DATABASE={$this->database->mysql_database}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_USER'))->isEmpty()) {
$environment_variables->push("MYSQL_USER={$this->database->mysql_user}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_PASSWORD'))->isEmpty()) {
$environment_variables->push("MYSQL_PASSWORD={$this->database->mysql_password}");
}
add_coolify_default_environment_variables($this->database, $environment_variables, $environment_variables);
return $environment_variables->all();
}
private function add_custom_mysql()
{
if (is_null($this->database->mysql_conf) || empty($this->database->mysql_conf)) {
return;
}
$filename = 'custom-config.cnf';
$content = $this->database->mysql_conf;
$content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null";
}
}

View file

@ -1,333 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandalonePostgresql;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class StartPostgresql
{
use AsAction;
public StandalonePostgresql $database;
public array $commands = [];
public array $init_scripts = [];
public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandalonePostgresql $database)
{
$this->database = $database;
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
if (isDev()) {
$this->configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$container_name;
}
$this->commands = [
"echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir",
"mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d/",
"echo 'Directories created successfully.'",
];
if (! $this->database->enable_ssl) {
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
$this->database->sslCertificates()->delete();
$this->database->fileStorages()
->where('resource_type', $this->database->getMorphClass())
->where('resource_id', $this->database->id)
->get()
->filter(function ($storage) {
return in_array($storage->mount_path, [
'/var/lib/postgresql/certs/server.crt',
'/var/lib/postgresql/certs/server.key',
]);
})
->each(function ($storage) {
$storage->delete();
});
} else {
$this->commands[] = "echo 'Setting up SSL for this database.'";
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$this->ssl_certificate = $this->database->sslCertificates()->first();
if (! $this->ssl_certificate) {
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
$this->ssl_certificate = SslHelper::generateSslCertificate(
commonName: $this->database->uuid,
resourceType: $this->database->getMorphClass(),
resourceId: $this->database->id,
serverId: $server->id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $this->configuration_dir,
mountPath: '/var/lib/postgresql/certs',
);
}
}
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$environment_variables = $this->generate_environment_variables();
$this->generate_init_scripts();
$this->add_custom_conf();
$docker_compose = [
'services' => [
$container_name => [
'image' => $this->database->image,
'container_name' => $container_name,
'environment' => $environment_variables,
'restart' => RESTART_MODE,
'networks' => [
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => [
'CMD-SHELL',
"psql -U {$this->database->postgres_user} -d {$this->database->postgres_db} -c 'SELECT 1' || exit 1",
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
'mem_reservation' => $this->database->limits_memory_reservation,
'cpus' => (float) $this->database->limits_cpus,
'cpu_shares' => $this->database->limits_cpu_shares,
],
],
'networks' => [
$this->database->destination->network => [
'external' => true,
'name' => $this->database->destination->network,
'attachable' => true,
],
],
];
if (filled($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
}
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
}
if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
}
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
$persistent_storages
);
}
if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
$persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
}
if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names;
}
if (count($this->init_scripts) > 0) {
foreach ($this->init_scripts as $init_script) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
[[
'type' => 'bind',
'source' => $init_script,
'target' => '/docker-entrypoint-initdb.d/'.basename($init_script),
'read_only' => true,
]]
);
}
}
$command = ['postgres'];
if (filled($this->database->postgres_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
[[
'type' => 'bind',
'source' => $this->configuration_dir.'/custom-postgres.conf',
'target' => '/etc/postgresql/postgresql.conf',
'read_only' => true,
]]
);
$command = array_merge($command, ['-c', 'config_file=/etc/postgresql/postgresql.conf']);
}
if ($this->database->enable_ssl) {
$command = array_merge($command, [
'-c', 'ssl=on',
'-c', 'ssl_cert_file=/var/lib/postgresql/certs/server.crt',
'-c', 'ssl_key_file=/var/lib/postgresql/certs/server.key',
]);
}
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
if (count($command) > 1) {
$docker_compose['services'][$container_name]['command'] = $command;
}
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
}
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}
private function generate_local_persistent_volumes()
{
$local_persistent_volumes = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) {
$local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path;
} else {
$volume_name = $persistentStorage->name;
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
}
}
return $local_persistent_volumes;
}
private function generate_local_persistent_volumes_only_volume_names()
{
$local_persistent_volumes_names = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path) {
continue;
}
$name = $persistentStorage->name;
$local_persistent_volumes_names[$name] = [
'name' => $name,
'external' => false,
];
}
return $local_persistent_volumes_names;
}
private function generate_environment_variables()
{
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) {
$environment_variables->push("POSTGRES_USER={$this->database->postgres_user}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('PGUSER'))->isEmpty()) {
$environment_variables->push("PGUSER={$this->database->postgres_user}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_PASSWORD'))->isEmpty()) {
$environment_variables->push("POSTGRES_PASSWORD={$this->database->postgres_password}");
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_DB'))->isEmpty()) {
$environment_variables->push("POSTGRES_DB={$this->database->postgres_db}");
}
add_coolify_default_environment_variables($this->database, $environment_variables, $environment_variables);
return $environment_variables->all();
}
private function generate_init_scripts()
{
$this->commands[] = "rm -rf $this->configuration_dir/docker-entrypoint-initdb.d/*";
if (blank($this->database->init_scripts) || count($this->database->init_scripts) === 0) {
return;
}
foreach ($this->database->init_scripts as $init_script) {
$filename = data_get($init_script, 'filename');
$content = data_get($init_script, 'content');
$content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/{$filename} > /dev/null";
$this->init_scripts[] = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
}
}
private function add_custom_conf()
{
$filename = 'custom-postgres.conf';
$config_file_path = "$this->configuration_dir/$filename";
if (blank($this->database->postgres_conf)) {
$this->commands[] = "rm -f $config_file_path";
return;
}
$content = $this->database->postgres_conf;
if (! str($content)->contains('listen_addresses')) {
$content .= "\nlisten_addresses = '*'";
$this->database->postgres_conf = $content;
$this->database->save();
}
$content_base64 = base64_encode($content);
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $config_file_path > /dev/null";
}
}

View file

@ -1,324 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis;
use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class StartRedis
{
use AsAction;
public StandaloneRedis $database;
public array $commands = [];
public string $configuration_dir;
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneRedis $database)
{
$this->database = $database;
$container_name = $this->database->uuid;
$this->configuration_dir = database_configuration_dir().'/'.$container_name;
$this->commands = [
"echo 'Starting database.'",
"echo 'Creating directories.'",
"mkdir -p $this->configuration_dir",
"echo 'Directories created successfully.'",
];
if (! $this->database->enable_ssl) {
$this->commands[] = "rm -rf $this->configuration_dir/ssl";
$this->database->sslCertificates()->delete();
$this->database->fileStorages()
->where('resource_type', $this->database->getMorphClass())
->where('resource_id', $this->database->id)
->get()
->filter(function ($storage) {
return in_array($storage->mount_path, [
'/etc/redis/certs/server.crt',
'/etc/redis/certs/server.key',
]);
})
->each(function ($storage) {
$storage->delete();
});
} else {
$this->commands[] = "echo 'Setting up SSL for this database.'";
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
$this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
return;
}
$this->ssl_certificate = $this->database->sslCertificates()->first();
if (! $this->ssl_certificate) {
$this->commands[] = "echo 'No SSL certificate found, generating new SSL certificate for this database.'";
$this->ssl_certificate = SslHelper::generateSslCertificate(
commonName: $this->database->uuid,
resourceType: $this->database->getMorphClass(),
resourceId: $this->database->id,
serverId: $server->id,
caCert: $caCert->ssl_certificate,
caKey: $caCert->ssl_private_key,
configurationDir: $this->configuration_dir,
mountPath: '/etc/redis/certs',
);
}
}
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->database->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$environment_variables = $this->generate_environment_variables();
$this->add_custom_redis();
$startCommand = $this->buildStartCommand();
$docker_compose = [
'services' => [
$container_name => [
'image' => $this->database->image,
'command' => $startCommand,
'container_name' => $container_name,
'environment' => $environment_variables,
'restart' => RESTART_MODE,
'networks' => [
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
'test' => [
'CMD-SHELL',
'redis-cli',
'ping',
],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
'start_period' => '5s',
],
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
'mem_reservation' => $this->database->limits_memory_reservation,
'cpus' => (float) $this->database->limits_cpus,
'cpu_shares' => $this->database->limits_cpu_shares,
],
],
'networks' => [
$this->database->destination->network => [
'external' => true,
'name' => $this->database->destination->network,
'attachable' => true,
],
],
];
if (! is_null($this->database->limits_cpuset)) {
data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset);
}
if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) {
$docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration();
}
if (count($this->database->ports_mappings_array) > 0) {
$docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array;
}
$docker_compose['services'][$container_name]['volumes'] ??= [];
if (count($persistent_storages) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
$persistent_storages
);
}
if (count($persistent_file_volumes) > 0) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
$persistent_file_volumes->map(function ($item) {
return "$item->fs_path:$item->mount_path";
})->toArray()
);
}
if (count($volume_names) > 0) {
$docker_compose['volumes'] = $volume_names;
}
if ($this->database->enable_ssl) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
[
'type' => 'bind',
'source' => '/data/coolify/ssl/coolify-ca.crt',
'target' => '/etc/redis/certs/coolify-ca.crt',
'read_only' => true,
],
]
);
}
if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) {
$docker_compose['services'][$container_name]['volumes'][] = [
'type' => 'bind',
'source' => $this->configuration_dir.'/redis.conf',
'target' => '/usr/local/etc/redis/redis.conf',
'read_only' => true,
];
}
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
$readme = generate_readme_file($this->database->name, now());
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}
private function generate_local_persistent_volumes()
{
$local_persistent_volumes = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) {
$local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path;
} else {
$volume_name = $persistentStorage->name;
$local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path;
}
}
return $local_persistent_volumes;
}
private function generate_local_persistent_volumes_only_volume_names()
{
$local_persistent_volumes_names = [];
foreach ($this->database->persistentStorages as $persistentStorage) {
if ($persistentStorage->host_path) {
continue;
}
$name = $persistentStorage->name;
$local_persistent_volumes_names[$name] = [
'name' => $name,
'external' => false,
];
}
return $local_persistent_volumes_names;
}
private function generate_environment_variables()
{
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
if ($env->is_shared) {
$environment_variables->push("$env->key=$env->real_value");
if ($env->key === 'REDIS_PASSWORD') {
$this->database->update(['redis_password' => $env->real_value]);
}
if ($env->key === 'REDIS_USERNAME') {
$this->database->update(['redis_username' => $env->real_value]);
}
} else {
if ($env->key === 'REDIS_PASSWORD') {
$env->update(['value' => $this->database->redis_password]);
} elseif ($env->key === 'REDIS_USERNAME') {
$env->update(['value' => $this->database->redis_username]);
}
$environment_variables->push("$env->key=$env->real_value");
}
}
add_coolify_default_environment_variables($this->database, $environment_variables, $environment_variables);
return $environment_variables->all();
}
private function buildStartCommand(): string
{
$hasRedisConf = ! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf);
$redisConfPath = '/usr/local/etc/redis/redis.conf';
if ($hasRedisConf) {
$confContent = $this->database->redis_conf;
$hasRequirePass = str_contains($confContent, 'requirepass');
if ($hasRequirePass) {
$command = "redis-server $redisConfPath";
} else {
$command = "redis-server $redisConfPath --requirepass {$this->database->redis_password}";
}
} else {
$command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes";
}
if ($this->database->enable_ssl) {
$sslArgs = [
'--tls-port 6380',
'--tls-cert-file /etc/redis/certs/server.crt',
'--tls-key-file /etc/redis/certs/server.key',
'--tls-ca-cert-file /etc/redis/certs/coolify-ca.crt',
'--tls-auth-clients optional',
];
}
if (! empty($sslArgs)) {
$command .= ' '.implode(' ', $sslArgs);
}
return $command;
}
private function add_custom_redis()
{
if (is_null($this->database->redis_conf) || empty($this->database->redis_conf)) {
return;
}
$filename = 'redis.conf';
Storage::disk('local')->put("tmp/redis.conf_{$this->database->uuid}", $this->database->redis_conf);
$path = Storage::path("tmp/redis.conf_{$this->database->uuid}");
instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server);
Storage::disk('local')->delete("tmp/redis.conf_{$this->database->uuid}");
}
}

View file

@ -1,56 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Actions\Server\CleanupDocker;
use App\Events\ServiceStatusChanged;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
class StopDatabase
{
use AsAction;
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true)
{
try {
$server = $database->destination->server;
if (! $server->isFunctional()) {
return 'Server is not functional';
}
$this->stopContainer($database, $database->uuid, 30);
if ($dockerCleanup) {
CleanupDocker::dispatch($server, false, false);
}
if ($database->is_public) {
StopDatabaseProxy::run($database);
}
return 'Database stopped successfully';
} catch (\Exception $e) {
return 'Database stop failed: '.$e->getMessage();
} finally {
ServiceStatusChanged::dispatch($database->environment->project->team->id);
}
}
private function stopContainer($database, string $containerName, int $timeout = 30): void
{
$server = $database->destination->server;
instant_remote_process(command: [
"docker stop -t $timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
}

View file

@ -1,38 +0,0 @@
<?php
namespace App\Actions\Database;
use App\Events\DatabaseProxyStopped;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
class StopDatabaseProxy
{
use AsAction;
public string $jobQueue = 'high';
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|ServiceDatabase|StandaloneDragonfly|StandaloneClickhouse $database)
{
$server = data_get($database, 'destination.server');
$uuid = $database->uuid;
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$uuid = $database->service->uuid;
$server = data_get($database, 'service.server');
}
instant_remote_process(["docker rm -f {$uuid}-proxy"], $server);
$database->save();
DatabaseProxyStopped::dispatch();
}
}

View file

@ -1,535 +0,0 @@
<?php
namespace App\Actions\Docker;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Shared\ComplexStatusCheck;
use App\Events\ServiceChecked;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Lorisleiva\Actions\Concerns\AsAction;
class GetContainersStatus
{
use AsAction;
use CalculatesExcludedStatus;
public string $jobQueue = 'high';
public $applications;
public ?Collection $containers;
public ?Collection $containerReplicates;
public $server;
protected ?Collection $applicationContainerStatuses;
protected ?Collection $applicationContainerRestartCounts;
protected ?Collection $serviceContainerStatuses;
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{
$this->containers = $containers;
$this->containerReplicates = $containerReplicates;
$this->server = $server;
if (! $this->server->isFunctional()) {
return 'Server is not functional.';
}
$this->applications = $this->server->applications();
$skip_these_applications = collect([]);
foreach ($this->applications as $application) {
if ($application->additional_servers->count() > 0) {
$skip_these_applications->push($application);
ComplexStatusCheck::run($application);
$this->applications = $this->applications->filter(function ($value, $key) use ($application) {
return $value->id !== $application->id;
});
}
}
$this->applications = $this->applications->filter(function ($value, $key) use ($skip_these_applications) {
return ! $skip_these_applications->pluck('id')->contains($value->id);
});
if ($this->containers === null) {
['containers' => $this->containers, 'containerReplicates' => $this->containerReplicates] = $this->server->getContainers();
}
if (is_null($this->containers)) {
return;
}
if ($this->containerReplicates) {
foreach ($this->containerReplicates as $containerReplica) {
$name = data_get($containerReplica, 'Name');
$this->containers = $this->containers->map(function ($container) use ($name, $containerReplica) {
if (data_get($container, 'Spec.Name') === $name) {
$replicas = data_get($containerReplica, 'Replicas');
$running = str($replicas)->explode('/')[0];
$total = str($replicas)->explode('/')[1];
if ($running === $total) {
data_set($container, 'State.Status', 'running');
data_set($container, 'State.Health.Status', 'healthy');
} else {
data_set($container, 'State.Status', 'starting');
data_set($container, 'State.Health.Status', 'unhealthy');
}
}
return $container;
});
}
}
$databases = $this->server->databases();
$services = $this->server->services()->get();
$previews = $this->server->previews();
$foundApplications = [];
$foundApplicationPreviews = [];
$foundDatabases = [];
$foundServices = [];
foreach ($this->containers as $container) {
if ($this->server->isSwarm()) {
$labels = data_get($container, 'Spec.Labels');
$uuid = data_get($labels, 'coolify.name');
} else {
$labels = data_get($container, 'Config.Labels');
}
$containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status');
if ($containerStatus === 'restarting') {
$healthSuffix = $containerHealth ?? 'unknown';
$containerStatus = "restarting:$healthSuffix";
} elseif ($containerStatus === 'exited') {
// Keep as-is, no health suffix for exited containers
} else {
$healthSuffix = $containerHealth ?? 'unknown';
$containerStatus = "$containerStatus:$healthSuffix";
}
$labels = Arr::undot(format_docker_labels_to_json($labels));
$applicationId = data_get($labels, 'coolify.applicationId');
if ($applicationId) {
$pullRequestId = data_get($labels, 'coolify.pullRequestId');
if ($pullRequestId) {
if (str($applicationId)->contains('-')) {
$applicationId = str($applicationId)->before('-');
}
$preview = ApplicationPreview::where('application_id', $applicationId)->where('pull_request_id', $pullRequestId)->first();
if ($preview) {
$foundApplicationPreviews[] = $preview->id;
$statusFromDb = $preview->status;
if ($statusFromDb !== $containerStatus) {
$preview->update(['status' => $containerStatus]);
} else {
$preview->update(['last_online_at' => now()]);
}
} else {
// Notify user that this container should not be there.
}
} else {
$application = $this->applications->where('id', $applicationId)->first();
if ($application) {
$foundApplications[] = $application->id;
// Store container status for aggregation
if (! isset($this->applicationContainerStatuses)) {
$this->applicationContainerStatuses = collect();
}
if (! $this->applicationContainerStatuses->has($applicationId)) {
$this->applicationContainerStatuses->put($applicationId, collect());
}
$containerName = data_get($labels, 'com.docker.compose.service');
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
// Track restart counts for applications
$restartCount = data_get($container, 'RestartCount', 0);
if (! isset($this->applicationContainerRestartCounts)) {
$this->applicationContainerRestartCounts = collect();
}
if (! $this->applicationContainerRestartCounts->has($applicationId)) {
$this->applicationContainerRestartCounts->put($applicationId, collect());
}
if ($containerName) {
$this->applicationContainerRestartCounts->get($applicationId)->put($containerName, $restartCount);
}
} else {
// Notify user that this container should not be there.
}
}
} else {
$uuid = data_get($labels, 'com.docker.compose.service');
$type = data_get($labels, 'coolify.type');
if ($uuid) {
if ($type === 'service') {
$database_id = data_get($labels, 'coolify.service.subId');
if ($database_id) {
$service_db = ServiceDatabase::where('id', $database_id)->first();
if ($service_db) {
$uuid = data_get($service_db, 'service.uuid');
if ($uuid) {
$isPublic = data_get($service_db, 'is_public');
if ($isPublic) {
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
if ($this->server->isSwarm()) {
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
} else {
return data_get($value, 'Name') === "/$uuid-proxy";
}
})->first();
if (! $foundTcpProxy) {
StartDatabaseProxy::run($service_db);
// $this->server->team?->notify(new ContainerRestarted("TCP Proxy for {$service_db->service->name}", $this->server));
}
}
}
}
}
} else {
$database = $databases->where('uuid', $uuid)->first();
if ($database) {
$isPublic = data_get($database, 'is_public');
$foundDatabases[] = $database->id;
$statusFromDb = $database->status;
if ($statusFromDb !== $containerStatus) {
$database->update(['status' => $containerStatus]);
} else {
$database->update(['last_online_at' => now()]);
}
if ($isPublic) {
$foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) {
if ($this->server->isSwarm()) {
return data_get($value, 'Spec.Name') === "coolify-proxy_$uuid";
} else {
return data_get($value, 'Name') === "/$uuid-proxy";
}
})->first();
if (! $foundTcpProxy) {
StartDatabaseProxy::run($database);
// $this->server->team?->notify(new ContainerRestarted("TCP Proxy for database", $this->server));
}
}
} else {
// Notify user that this container should not be there.
}
}
}
if (data_get($container, 'Name') === '/coolify-db') {
$foundDatabases[] = 0;
}
}
$serviceLabelId = data_get($labels, 'coolify.serviceId');
if ($serviceLabelId) {
$subType = data_get($labels, 'coolify.service.subType');
$subId = data_get($labels, 'coolify.service.subId');
$parentService = $services->where('id', $serviceLabelId)->first();
if (! $parentService) {
continue;
}
// Store container status for aggregation
if (! isset($this->serviceContainerStatuses)) {
$this->serviceContainerStatuses = collect();
}
$key = $serviceLabelId.':'.$subType.':'.$subId;
if (! $this->serviceContainerStatuses->has($key)) {
$this->serviceContainerStatuses->put($key, collect());
}
$containerName = data_get($labels, 'com.docker.compose.service');
if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
}
// Mark service as found
if ($subType === 'application') {
$service = $parentService->applications()->where('id', $subId)->first();
} else {
$service = $parentService->databases()->where('id', $subId)->first();
}
if ($service) {
$foundServices[] = "$service->id-$service->name";
}
}
}
$exitedServices = collect([]);
foreach ($services as $service) {
$apps = $service->applications()->get();
$dbs = $service->databases()->get();
foreach ($apps as $app) {
if (in_array("$app->id-$app->name", $foundServices)) {
continue;
} else {
$exitedServices->push($app);
}
}
foreach ($dbs as $db) {
if (in_array("$db->id-$db->name", $foundServices)) {
continue;
} else {
$exitedServices->push($db);
}
}
}
$exitedServices = $exitedServices->unique('uuid');
foreach ($exitedServices as $exitedService) {
if (str($exitedService->status)->startsWith('exited')) {
continue;
}
$name = data_get($exitedService, 'name');
$fqdn = data_get($exitedService, 'fqdn');
if ($name) {
if ($fqdn) {
$containerName = "$name, available at $fqdn";
} else {
$containerName = $name;
}
} else {
if ($fqdn) {
$containerName = $fqdn;
} else {
$containerName = null;
}
}
$projectUuid = data_get($service, 'environment.project.uuid');
$serviceUuid = data_get($service, 'uuid');
$environmentName = data_get($service, 'environment.name');
if ($projectUuid && $serviceUuid && $environmentName) {
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/service/'.$serviceUuid;
} else {
$url = null;
}
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
$exitedService->update(['status' => 'exited']);
}
$notRunningApplications = $this->applications->pluck('id')->diff($foundApplications);
foreach ($notRunningApplications as $applicationId) {
$application = $this->applications->where('id', $applicationId)->first();
if (str($application->status)->startsWith('exited')) {
continue;
}
// Only protection: If no containers at all, Docker query might have failed
if ($this->containers->isEmpty()) {
continue;
}
// If container was recently restarting (crash loop), keep it as degraded for a grace period
// This prevents false "exited" status during the brief moment between container removal and recreation
$recentlyRestarted = $application->restart_count > 0 &&
$application->last_restart_at &&
$application->last_restart_at->greaterThan(now()->subSeconds(30));
if ($recentlyRestarted) {
// Keep it as degraded if it was recently in a crash loop
$application->update(['status' => 'degraded:unhealthy']);
} else {
// Reset restart count when application exits completely
$application->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
}
}
$notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews);
foreach ($notRunningApplicationPreviews as $previewId) {
$preview = $previews->where('id', $previewId)->first();
if (str($preview->status)->startsWith('exited')) {
continue;
}
// Only protection: If no containers at all, Docker query might have failed
if ($this->containers->isEmpty()) {
continue;
}
$preview->update(['status' => 'exited']);
}
$notRunningDatabases = $databases->pluck('id')->diff($foundDatabases);
foreach ($notRunningDatabases as $database) {
$database = $databases->where('id', $database)->first();
if (str($database->status)->startsWith('exited')) {
continue;
}
$database->update(['status' => 'exited']);
$name = data_get($database, 'name');
$fqdn = data_get($database, 'fqdn');
$containerName = $name;
$projectUuid = data_get($database, 'environment.project.uuid');
$environmentName = data_get($database, 'environment.name');
$databaseUuid = data_get($database, 'uuid');
if ($projectUuid && $databaseUuid && $environmentName) {
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/database/'.$databaseUuid;
} else {
$url = null;
}
// $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url));
}
// Aggregate multi-container application statuses
if (isset($this->applicationContainerStatuses) && $this->applicationContainerStatuses->isNotEmpty()) {
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
$application = $this->applications->where('id', $applicationId)->first();
if (! $application) {
continue;
}
// Track restart counts first
$maxRestartCount = 0;
if (isset($this->applicationContainerRestartCounts) && $this->applicationContainerRestartCounts->has($applicationId)) {
$containerRestartCounts = $this->applicationContainerRestartCounts->get($applicationId);
$maxRestartCount = $containerRestartCounts->max() ?? 0;
}
// Wrap all database updates in a transaction to ensure consistency
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
$previousRestartCount = $application->restart_count ?? 0;
if ($maxRestartCount > $previousRestartCount) {
// Restart count increased - this is a crash restart
$application->update([
'restart_count' => $maxRestartCount,
'last_restart_at' => now(),
'last_restart_type' => 'crash',
]);
// Send notification
$containerName = $application->name;
$projectUuid = data_get($application, 'environment.project.uuid');
$environmentName = data_get($application, 'environment.name');
$applicationUuid = data_get($application, 'uuid');
if ($projectUuid && $applicationUuid && $environmentName) {
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
} else {
$url = null;
}
}
// Aggregate status after tracking restart counts
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount);
if ($aggregatedStatus) {
$statusFromDb = $application->status;
if ($statusFromDb !== $aggregatedStatus) {
$application->update(['status' => $aggregatedStatus]);
} else {
$application->update(['last_online_at' => now()]);
}
}
});
}
}
// Aggregate multi-container service statuses
$this->aggregateServiceContainerStatuses($services);
ServiceChecked::dispatch($this->server->team->id);
}
private function aggregateApplicationStatus($application, Collection $containerStatuses, int $maxRestartCount = 0): ?string
{
// Parse docker compose to check for excluded containers
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, calculate status from excluded containers
if ($relevantStatuses->isEmpty()) {
return $this->calculateExcludedStatusFromStrings($containerStatuses);
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount);
}
private function aggregateServiceContainerStatuses($services)
{
if (! isset($this->serviceContainerStatuses) || $this->serviceContainerStatuses->isEmpty()) {
return;
}
foreach ($this->serviceContainerStatuses as $key => $containerStatuses) {
// Parse key: serviceId:subType:subId
[$serviceId, $subType, $subId] = explode(':', $key);
$service = $services->where('id', $serviceId)->first();
if (! $service) {
continue;
}
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null;
if ($subType === 'application') {
$subResource = $service->applications()->where('id', $subId)->first();
} elseif ($subType === 'database') {
$subResource = $service->databases()->where('id', $subId)->first();
}
if (! $subResource) {
continue;
}
// Parse docker compose from service to check for excluded containers
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, calculate status from excluded containers
if ($relevantStatuses->isEmpty()) {
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
if ($aggregatedStatus) {
$statusFromDb = $subResource->status;
if ($statusFromDb !== $aggregatedStatus) {
$subResource->update(['status' => $aggregatedStatus]);
} else {
$subResource->update(['last_online_at' => now()]);
}
}
continue;
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses);
// Update service sub-resource status with aggregated result
if ($aggregatedStatus) {
$statusFromDb = $subResource->status;
if ($statusFromDb !== $aggregatedStatus) {
$subResource->update(['status' => $aggregatedStatus]);
} else {
$subResource->update(['last_online_at' => now()]);
}
}
}
}
}

View file

@ -1,70 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
abort(403);
}
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
],
'password' => ['required', Password::defaults(), 'confirmed'],
])->validate();
if (User::count() == 0) {
// If this is the first user, make them the root user
// Team is already created in the database/seeders/ProductionSeeder.php
$user = User::create([
'id' => 0,
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$team = $user->teams()->first();
// Disable registration after first user is created
$settings = instanceSettings();
$settings->is_registration_enabled = false;
$settings->save();
} else {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$team = $user->teams()->first();
if (isCloud()) {
$user->sendVerificationEmail();
} else {
$user->markEmailAsVerified();
}
}
// Set session variable
session(['currentTeam' => $user->currentTeam = $team]);
return $user;
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => ['required', Password::defaults(), 'confirmed'],
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
$user->deleteAllSessions();
}
}

View file

@ -1,31 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => ['required', Password::defaults(), 'confirmed'],
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View file

@ -1,60 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if (
$input['email'] !== $user->email &&
$user instanceof MustVerifyEmail
) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}

View file

@ -1,417 +0,0 @@
<?php
namespace App\Actions\Proxy;
use App\Enums\ProxyTypes;
use App\Models\Server;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\Yaml\Yaml;
class CheckProxy
{
use AsAction;
// It should return if the proxy should be started (true) or not (false)
public function handle(Server $server, $fromUI = false): bool
{
if (! $server->isFunctional()) {
return false;
}
if ($server->isBuildServer()) {
if ($server->proxy) {
$server->proxy = null;
$server->save();
}
return false;
}
$proxyType = $server->proxyType();
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop) && ! $fromUI) {
return false;
}
if (! $server->isProxyShouldRun()) {
if ($fromUI) {
throw new \Exception('Proxy should not run. You selected the Custom Proxy.');
} else {
return false;
}
}
// Determine proxy container name based on environment
$proxyContainerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
if ($server->isSwarm()) {
$status = getContainerStatus($server, $proxyContainerName);
$server->proxy->set('status', $status);
$server->save();
if ($status === 'running') {
return false;
}
return true;
} else {
$status = getContainerStatus($server, $proxyContainerName);
if ($status === 'running') {
$server->proxy->set('status', 'running');
$server->save();
return false;
}
if ($server->settings->is_cloudflare_tunnel) {
return false;
}
$ip = $server->ip;
if ($server->id === 0) {
$ip = 'host.docker.internal';
}
$portsToCheck = [];
try {
if ($server->proxyType() !== ProxyTypes::NONE->value) {
$proxyCompose = GetProxyConfiguration::run($server);
if (isset($proxyCompose)) {
$yaml = Yaml::parse($proxyCompose);
$configPorts = [];
if ($server->proxyType() === ProxyTypes::TRAEFIK->value) {
$ports = data_get($yaml, 'services.traefik.ports');
} elseif ($server->proxyType() === ProxyTypes::CADDY->value) {
$ports = data_get($yaml, 'services.caddy.ports');
}
if (isset($ports)) {
foreach ($ports as $port) {
$configPorts[] = str($port)->before(':')->value();
}
}
// Combine default ports with config ports
$portsToCheck = array_merge($portsToCheck, $configPorts);
}
} else {
$portsToCheck = [];
}
} catch (\Exception $e) {
Log::error('Error checking proxy: '.$e->getMessage());
}
if (count($portsToCheck) === 0) {
return false;
}
$portsToCheck = array_values(array_unique($portsToCheck));
// Check port conflicts in parallel
$conflicts = $this->checkPortConflictsInParallel($server, $portsToCheck, $proxyContainerName);
foreach ($conflicts as $port => $conflict) {
if ($conflict) {
if ($fromUI) {
throw new \Exception("Port $port is in use.<br>You must stop the process using this port.<br><br>Docs: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/docs'>https://coolify.io/docs</a><br>Discord: <a target='_blank' class='dark:text-white hover:underline' href='https://coolify.io/discord'>https://coolify.io/discord</a>");
} else {
return false;
}
}
}
return true;
}
}
/**
* Check multiple ports for conflicts in parallel
* Returns an array with port => conflict_status mapping
*/
private function checkPortConflictsInParallel(Server $server, array $ports, string $proxyContainerName): array
{
if (empty($ports)) {
return [];
}
try {
// Build concurrent port check commands
$results = Process::concurrently(function ($pool) use ($server, $ports, $proxyContainerName) {
foreach ($ports as $port) {
$commands = $this->buildPortCheckCommands($server, $port, $proxyContainerName);
$pool->command($commands['ssh_command'])->timeout(10);
}
});
// Process results
$conflicts = [];
foreach ($ports as $index => $port) {
$result = $results[$index] ?? null;
if ($result) {
$conflicts[$port] = $this->parsePortCheckResult($result, $port, $proxyContainerName);
} else {
// If process failed, assume no conflict to avoid false positives
$conflicts[$port] = false;
}
}
return $conflicts;
} catch (\Throwable $e) {
Log::warning('Parallel port checking failed: '.$e->getMessage().'. Falling back to sequential checking.');
// Fallback to sequential checking if parallel fails
$conflicts = [];
foreach ($ports as $port) {
$conflicts[$port] = $this->isPortConflict($server, $port, $proxyContainerName);
}
return $conflicts;
}
}
/**
* Build the SSH command for checking a specific port
*/
private function buildPortCheckCommands(Server $server, string $port, string $proxyContainerName): array
{
// First check if our own proxy is using this port (which is fine)
$getProxyContainerId = "docker ps -a --filter name=$proxyContainerName --format '{{.ID}}'";
$checkProxyPortScript = "
CONTAINER_ID=\$($getProxyContainerId);
if [ ! -z \"\$CONTAINER_ID\" ]; then
if docker inspect \$CONTAINER_ID --format '{{json .NetworkSettings.Ports}}' | grep -q '\"$port/tcp\"'; then
echo 'proxy_using_port';
exit 0;
fi;
fi;
";
// Command sets for different ways to check ports, ordered by preference
$portCheckScript = "
$checkProxyPortScript
# Try ss command first
if command -v ss >/dev/null 2>&1; then
ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null);
if [ -z \"\$ss_output\" ]; then
echo 'port_free';
exit 0;
fi;
count=\$(echo \"\$ss_output\" | grep -c ':$port ');
if [ \$count -eq 0 ]; then
echo 'port_free';
exit 0;
fi;
# Check for dual-stack or docker processes
if [ \$count -le 2 ] && (echo \"\$ss_output\" | grep -q 'docker\\|coolify'); then
echo 'port_free';
exit 0;
fi;
echo \"port_conflict|\$ss_output\";
exit 0;
fi;
# Try netstat as fallback
if command -v netstat >/dev/null 2>&1; then
netstat_output=\$(netstat -tuln 2>/dev/null | grep ':$port ');
if [ -z \"\$netstat_output\" ]; then
echo 'port_free';
exit 0;
fi;
count=\$(echo \"\$netstat_output\" | grep -c 'LISTEN');
if [ \$count -eq 0 ]; then
echo 'port_free';
exit 0;
fi;
if [ \$count -le 2 ] && (echo \"\$netstat_output\" | grep -q 'docker\\|coolify'); then
echo 'port_free';
exit 0;
fi;
echo \"port_conflict|\$netstat_output\";
exit 0;
fi;
# Final fallback using nc
if nc -z -w1 127.0.0.1 $port >/dev/null 2>&1; then
echo 'port_conflict|nc_detected';
else
echo 'port_free';
fi;
";
$sshCommand = \App\Helpers\SshMultiplexingHelper::generateSshCommand($server, $portCheckScript);
return [
'ssh_command' => $sshCommand,
'script' => $portCheckScript,
];
}
/**
* Parse the result from port check command
*/
private function parsePortCheckResult($processResult, string $port, string $proxyContainerName): bool
{
$exitCode = $processResult->exitCode();
$output = trim($processResult->output());
$errorOutput = trim($processResult->errorOutput());
if ($exitCode !== 0) {
return false;
}
if ($output === 'proxy_using_port' || $output === 'port_free') {
return false; // No conflict
}
if (str_starts_with($output, 'port_conflict|')) {
$details = substr($output, strlen('port_conflict|'));
// Additional logic to detect dual-stack scenarios
if ($details !== 'nc_detected') {
// Check for dual-stack scenario - typically 1-2 listeners (IPv4+IPv6)
$lines = explode("\n", $details);
if (count($lines) <= 2) {
// Look for IPv4 and IPv6 in the listing
if ((strpos($details, '0.0.0.0:'.$port) !== false && strpos($details, ':::'.$port) !== false) ||
(strpos($details, '*:'.$port) !== false && preg_match('/\*:'.$port.'.*IPv[46]/', $details))) {
return false; // This is just a normal dual-stack setup
}
}
}
return true; // Real port conflict
}
return false;
}
/**
* Smart port checker that handles dual-stack configurations
* Returns true only if there's a real port conflict (not just dual-stack)
*/
private function isPortConflict(Server $server, string $port, string $proxyContainerName): bool
{
// First check if our own proxy is using this port (which is fine)
try {
$getProxyContainerId = "docker ps -a --filter name=$proxyContainerName --format '{{.ID}}'";
$containerId = trim(instant_remote_process([$getProxyContainerId], $server));
if (! empty($containerId)) {
$checkProxyPort = "docker inspect $containerId --format '{{json .NetworkSettings.Ports}}' | grep '\"$port/tcp\"'";
try {
instant_remote_process([$checkProxyPort], $server);
// Our proxy is using the port, which is fine
return false;
} catch (\Throwable $e) {
// Our container exists but not using this port
}
}
} catch (\Throwable $e) {
// Container not found or error checking, continue with regular checks
}
// Command sets for different ways to check ports, ordered by preference
$commandSets = [
// Set 1: Use ss to check listener counts by protocol stack
[
'available' => 'command -v ss >/dev/null 2>&1',
'check' => [
// Get listening process details
"ss_output=\$(ss -Htuln state listening sport = :$port 2>/dev/null) && echo \"\$ss_output\"",
// Count IPv4 listeners
"echo \"\$ss_output\" | grep -c ':$port '",
],
],
// Set 2: Use netstat as alternative to ss
[
'available' => 'command -v netstat >/dev/null 2>&1',
'check' => [
// Get listening process details
"netstat_output=\$(netstat -tuln 2>/dev/null) && echo \"\$netstat_output\" | grep ':$port '",
// Count listeners
"echo \"\$netstat_output\" | grep ':$port ' | grep -c 'LISTEN'",
],
],
// Set 3: Use lsof as last resort
[
'available' => 'command -v lsof >/dev/null 2>&1',
'check' => [
// Get process using the port
"lsof -i :$port -P -n | grep 'LISTEN'",
// Count listeners
"lsof -i :$port -P -n | grep 'LISTEN' | wc -l",
],
],
];
// Try each command set until we find one available
foreach ($commandSets as $set) {
try {
// Check if the command is available
instant_remote_process([$set['available']], $server);
// Run the actual check commands
$output = instant_remote_process($set['check'], $server, true);
// Parse the output lines
$lines = explode("\n", trim($output));
// Get the detailed output and listener count
$details = trim(implode("\n", array_slice($lines, 0, -1)));
$count = intval(trim($lines[count($lines) - 1] ?? '0'));
// If no listeners or empty result, port is free
if ($count == 0 || empty($details)) {
return false;
}
// Try to detect if this is our coolify-proxy
if (strpos($details, 'docker') !== false || strpos($details, $proxyContainerName) !== false) {
// It's likely our docker or proxy, which is fine
return false;
}
// Check for dual-stack scenario - typically 1-2 listeners (IPv4+IPv6)
// If exactly 2 listeners and both have same port, likely dual-stack
if ($count <= 2) {
// Check if it looks like a standard dual-stack setup
$isDualStack = false;
// Look for IPv4 and IPv6 in the listing (ss output format)
if (preg_match('/LISTEN.*:'.$port.'\s/', $details) &&
(preg_match('/\*:'.$port.'\s/', $details) ||
preg_match('/:::'.$port.'\s/', $details))) {
$isDualStack = true;
}
// For netstat format
if (strpos($details, '0.0.0.0:'.$port) !== false &&
strpos($details, ':::'.$port) !== false) {
$isDualStack = true;
}
// For lsof format (IPv4 and IPv6)
if (strpos($details, '*:'.$port) !== false &&
preg_match('/\*:'.$port.'.*IPv4/', $details) &&
preg_match('/\*:'.$port.'.*IPv6/', $details)) {
$isDualStack = true;
}
if ($isDualStack) {
return false; // This is just a normal dual-stack setup
}
}
// If we get here, it's likely a real port conflict
return true;
} catch (\Throwable $e) {
// This command set failed, try the next one
continue;
}
}
// Fallback to simpler check if all above methods fail
try {
// Just try to bind to the port directly to see if it's available
$checkCommand = "nc -z -w1 127.0.0.1 $port >/dev/null 2>&1 && echo 'in-use' || echo 'free'";
$result = instant_remote_process([$checkCommand], $server, true);
return trim($result) === 'in-use';
} catch (\Throwable $e) {
// If everything fails, assume the port is free to avoid false positives
return false;
}
}
}

View file

@ -1,53 +0,0 @@
<?php
namespace App\Actions\Proxy;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Lorisleiva\Actions\Concerns\AsAction;
class GetProxyConfiguration
{
use AsAction;
public function handle(Server $server, bool $forceRegenerate = false): string
{
$proxyType = $server->proxyType();
if ($proxyType === 'NONE') {
return 'OK';
}
$proxy_path = $server->proxyPath();
$proxy_configuration = null;
// If not forcing regeneration, try to read existing configuration
if (! $forceRegenerate) {
$payload = [
"mkdir -p $proxy_path",
"cat $proxy_path/docker-compose.yml 2>/dev/null",
];
$proxy_configuration = instant_remote_process($payload, $server, false);
}
// Generate default configuration if:
// 1. Force regenerate is requested
// 2. Configuration file doesn't exist or is empty
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
// 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)) {
throw new \Exception('Could not get or generate proxy configuration');
}
ProxyDashboardCacheService::isTraefikDashboardAvailableFromConfiguration($server, $proxy_configuration);
return $proxy_configuration;
}
}

View file

@ -1,27 +0,0 @@
<?php
namespace App\Actions\Proxy;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class SaveProxyConfiguration
{
use AsAction;
public function handle(Server $server, string $configuration): void
{
$proxy_path = $server->proxyPath();
$docker_compose_yml_base64 = base64_encode($configuration);
// Update the saved settings hash
$server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value;
$server->save();
// Transfer the configuration file to the server
instant_remote_process([
"mkdir -p $proxy_path",
"echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
], $server);
}
}

View file

@ -1,101 +0,0 @@
<?php
namespace App\Actions\Proxy;
use App\Enums\ProxyTypes;
use App\Events\ProxyStatusChanged;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
class StartProxy
{
use AsAction;
public function handle(Server $server, bool $async = true, bool $force = false, bool $restarting = false): string|Activity
{
$proxyType = $server->proxyType();
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) {
return 'OK';
}
$server->proxy->set('status', 'starting');
$server->save();
$server->refresh();
if (! $restarting) {
ProxyStatusChangedUI::dispatch($server->team_id);
}
$commands = collect([]);
$proxy_path = $server->proxyPath();
$configuration = GetProxyConfiguration::run($server);
if (! $configuration) {
throw new \Exception('Configuration is not synced');
}
SaveProxyConfiguration::run($server, $configuration);
$docker_compose_yml_base64 = base64_encode($configuration);
$server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
$server->save();
if ($server->isSwarmManager()) {
$commands = $commands->merge([
"mkdir -p $proxy_path/dynamic",
"cd $proxy_path",
"echo 'Creating required Docker Compose file.'",
"echo 'Starting coolify-proxy.'",
'docker stack deploy --detach=true -c docker-compose.yml coolify-proxy',
"echo 'Successfully started coolify-proxy.'",
]);
} else {
if (isDev()) {
if ($proxyType === ProxyTypes::CADDY->value) {
$proxy_path = '/data/coolify/proxy/caddy';
}
}
$caddyfile = 'import /dynamic/*.caddy';
$commands = $commands->merge([
"mkdir -p $proxy_path/dynamic",
"cd $proxy_path",
"echo '$caddyfile' > $proxy_path/dynamic/Caddyfile",
"echo 'Creating required Docker Compose file.'",
"echo 'Pulling docker image.'",
'docker compose pull',
'if docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
" echo 'Stopping and removing existing coolify-proxy.'",
' docker stop coolify-proxy 2>/dev/null || true',
' docker rm -f coolify-proxy 2>/dev/null || true',
' # Wait for container to be fully removed',
' for i in {1..10}; do',
' if ! docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then',
' break',
' fi',
' echo "Waiting for coolify-proxy to be removed... ($i/10)"',
' sleep 1',
' done',
" echo 'Successfully stopped and removed existing coolify-proxy.'",
'fi',
]);
// Ensure required networks exist BEFORE docker compose up (networks are declared as external)
$commands = $commands->merge(ensureProxyNetworksExist($server));
$commands = $commands->merge([
"echo 'Starting coolify-proxy.'",
'docker compose up -d --wait --remove-orphans',
"echo 'Successfully started coolify-proxy.'",
]);
$commands = $commands->merge(connectProxyToNetworks($server));
}
if ($async) {
return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id);
} else {
instant_remote_process($commands, $server);
$server->proxy->set('type', $proxyType);
$server->save();
ProxyStatusChanged::dispatch($server->id);
return 'OK';
}
}
}

View file

@ -1,51 +0,0 @@
<?php
namespace App\Actions\Proxy;
use App\Events\ProxyStatusChanged;
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Lorisleiva\Actions\Concerns\AsAction;
class StopProxy
{
use AsAction;
public function handle(Server $server, bool $forceStop = true, int $timeout = 30, bool $restarting = false)
{
try {
$containerName = $server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
$server->proxy->status = 'stopping';
$server->save();
if (! $restarting) {
ProxyStatusChangedUI::dispatch($server->team_id);
}
instant_remote_process(command: [
"docker stop -t=$timeout $containerName 2>/dev/null || true",
"docker rm -f $containerName 2>/dev/null || true",
'# Wait for container to be fully removed',
'for i in {1..10}; do',
" if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then",
' break',
' fi',
' sleep 1',
'done',
], server: $server, throwError: false);
$server->proxy->force_stop = $forceStop;
$server->proxy->status = 'exited';
$server->save();
} catch (\Throwable $e) {
return handleError($e);
} finally {
ProxyDashboardCacheService::clearCache($server);
if (! $restarting) {
ProxyStatusChanged::dispatch($server->id);
}
}
}
}

View file

@ -1,222 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class CheckUpdates
{
use AsAction;
public string $jobQueue = 'high';
public function handle(Server $server)
{
try {
if ($server->serverStatus() === false) {
return [
'error' => 'Server is not reachable or not ready.',
];
}
// Try first method - using instant_remote_process
$output = instant_remote_process(['cat /etc/os-release'], $server);
// Parse os-release into an associative array
$osInfo = [];
foreach (explode("\n", $output) as $line) {
if (empty($line)) {
continue;
}
if (strpos($line, '=') === false) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$osInfo[$key] = trim($value, '"');
}
// Get the main OS identifier
$osId = $osInfo['ID'] ?? '';
// $osIdLike = $osInfo['ID_LIKE'] ?? '';
// $versionId = $osInfo['VERSION_ID'] ?? '';
// Normalize OS types based on install.sh logic
switch ($osId) {
case 'manjaro':
case 'manjaro-arm':
case 'endeavouros':
$osType = 'arch';
break;
case 'pop':
case 'linuxmint':
case 'zorin':
$osType = 'ubuntu';
break;
case 'fedora-asahi-remix':
$osType = 'fedora';
break;
default:
$osType = $osId;
}
// Determine package manager based on OS type
$packageManager = match ($osType) {
'arch' => 'pacman',
'alpine' => 'apk',
'ubuntu', 'debian', 'raspbian' => 'apt',
'centos', 'fedora', 'rhel', 'ol', 'rocky', 'almalinux', 'amzn' => 'dnf',
'sles', 'opensuse-leap', 'opensuse-tumbleweed' => 'zypper',
default => null
};
switch ($packageManager) {
case 'zypper':
$output = instant_remote_process(['LANG=C zypper -tx list-updates'], $server);
$out = $this->parseZypperOutput($output);
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
return $out;
case 'dnf':
$output = instant_remote_process(['LANG=C dnf list -q --updates --refresh'], $server);
$out = $this->parseDnfOutput($output);
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
return $out;
case 'apt':
instant_remote_process(['apt-get update -qq'], $server);
$output = instant_remote_process(['LANG=C apt list --upgradable 2>/dev/null'], $server);
$out = $this->parseAptOutput($output);
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
return $out;
default:
return [
'osId' => $osId,
'error' => 'Unsupported package manager',
'package_manager' => $packageManager,
];
}
} catch (\Throwable $e) {
return [
'osId' => $osId,
'package_manager' => $packageManager,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
];
}
}
private function parseZypperOutput(string $output): array
{
$updates = [];
try {
$xml = simplexml_load_string($output);
if ($xml === false) {
return [
'total_updates' => 0,
'updates' => [],
'error' => 'Failed to parse XML output',
];
}
// Navigate to the update-list node
$updateList = $xml->xpath('//update-list/update');
foreach ($updateList as $update) {
$updates[] = [
'package' => (string) $update['name'],
'new_version' => (string) $update['edition'],
'current_version' => (string) $update['edition-old'],
'architecture' => (string) $update['arch'],
'repository' => (string) $update->source['alias'],
'summary' => (string) $update->summary,
'description' => (string) $update->description,
];
}
return [
'total_updates' => count($updates),
'updates' => $updates,
];
} catch (\Throwable $e) {
return [
'total_updates' => 0,
'updates' => [],
'error' => 'Error parsing zypper output: '.$e->getMessage(),
];
}
}
private function parseDnfOutput(string $output): array
{
$updates = [];
$lines = explode("\n", $output);
foreach ($lines as $line) {
if (empty($line)) {
continue;
}
// Split by multiple spaces/tabs and filter out empty elements
$parts = array_values(array_filter(preg_split('/\s+/', $line)));
if (count($parts) >= 3) {
$package = $parts[0];
$new_version = $parts[1];
$repository = $parts[2];
// Extract architecture from package name (e.g., "cloud-init.noarch" -> "noarch")
$architecture = str_contains($package, '.') ? explode('.', $package)[1] : 'noarch';
$updates[] = [
'package' => $package,
'new_version' => $new_version,
'repository' => $repository,
'architecture' => $architecture,
'current_version' => 'unknown', // DNF doesn't show current version in check-update output
];
}
}
return [
'total_updates' => count($updates),
'updates' => $updates,
];
}
private function parseAptOutput(string $output): array
{
$updates = [];
$lines = explode("\n", $output);
foreach ($lines as $line) {
// Skip the "Listing... Done" line and empty lines
if (empty($line) || str_contains($line, 'Listing...')) {
continue;
}
// Example line: package/stable 2.0-1 amd64 [upgradable from: 1.0-1]
if (preg_match('/^(.+?)\/(\S+)\s+(\S+)\s+(\S+)\s+\[upgradable from: (.+?)\]/', $line, $matches)) {
$updates[] = [
'package' => $matches[1],
'repository' => $matches[2],
'new_version' => $matches[3],
'architecture' => $matches[4],
'current_version' => $matches[5],
];
}
}
return [
'total_updates' => count($updates),
'updates' => $updates,
];
}
}

View file

@ -1,60 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class CleanupDocker
{
use AsAction;
public string $jobQueue = 'high';
public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false)
{
$settings = instanceSettings();
$realtimeImage = config('constants.coolify.realtime_image');
$realtimeImageVersion = config('constants.coolify.realtime_version');
$realtimeImageWithVersion = "$realtimeImage:$realtimeImageVersion";
$realtimeImageWithoutPrefix = 'coollabsio/coolify-realtime';
$realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion";
$helperImageVersion = getHelperVersion();
$helperImage = config('constants.coolify.helper_image');
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
$commands = [
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
'docker image prune -af --filter "label!=coolify.managed=true"',
'docker builder prune -af',
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$realtimeImageWithoutPrefixVersion --filter reference=$realtimeImageWithoutPrefix | grep $realtimeImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
];
if ($deleteUnusedVolumes) {
$commands[] = 'docker volume prune -af';
}
if ($deleteUnusedNetworks) {
$commands[] = 'docker network prune -f';
}
$cleanupLog = [];
foreach ($commands as $command) {
$commandOutput = instant_remote_process([$command], $server, false);
if ($commandOutput !== null) {
$cleanupLog[] = [
'command' => $command,
'output' => $commandOutput,
];
}
}
return $cleanupLog;
}
}

View file

@ -1,60 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class ConfigureCloudflared
{
use AsAction;
public function handle(Server $server, string $cloudflare_token, string $ssh_domain): Activity
{
try {
$config = [
'services' => [
'coolify-cloudflared' => [
'container_name' => 'coolify-cloudflared',
'image' => 'cloudflare/cloudflared:latest',
'restart' => RESTART_MODE,
'network_mode' => 'host',
'command' => 'tunnel run',
'environment' => [
"TUNNEL_TOKEN={$cloudflare_token}",
'TUNNEL_METRICS=127.0.0.1:60123',
],
'healthcheck' => [
'test' => ['CMD', 'cloudflared', 'tunnel', '--metrics', '127.0.0.1:60123', 'ready'],
'interval' => '5s',
'timeout' => '30s',
'retries' => 5,
],
],
],
];
$config = Yaml::dump($config, 12, 2);
$docker_compose_yml_base64 = base64_encode($config);
$commands = collect([
'mkdir -p /tmp/cloudflared',
'cd /tmp/cloudflared',
"echo '$docker_compose_yml_base64' | base64 -d | tee docker-compose.yml > /dev/null",
'echo Pulling latest Cloudflare Tunnel image.',
'docker compose pull',
'echo Stopping existing Cloudflare Tunnel container.',
'docker rm -f coolify-cloudflared || true',
'echo Starting new Cloudflare Tunnel container.',
'docker compose up --wait --wait-timeout 15 --remove-orphans || docker logs coolify-cloudflared',
]);
return remote_process($commands, $server, callEventOnFinish: 'CloudflareTunnelChanged', callEventData: [
'server_id' => $server->id,
'ssh_domain' => $ssh_domain,
]);
} catch (\Throwable $e) {
throw $e;
}
}
}

View file

@ -1,103 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\CloudProviderToken;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Server\HetznerDeletionFailed;
use App\Services\HetznerService;
use Lorisleiva\Actions\Concerns\AsAction;
class DeleteServer
{
use AsAction;
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null)
{
$server = Server::withTrashed()->find($serverId);
// Delete from Hetzner even if server is already gone from Coolify
if ($deleteFromHetzner && ($hetznerServerId || ($server && $server->hetzner_server_id))) {
$this->deleteFromHetznerById(
$hetznerServerId ?? $server->hetzner_server_id,
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
$teamId ?? $server->team_id
);
}
ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
// If server is already deleted from Coolify, skip this part
if (! $server) {
return; // Server already force deleted from Coolify
}
ray('force deleting server from Coolify', ['server_id' => $server->id]);
try {
$server->forceDelete();
} catch (\Throwable $e) {
ray('Failed to force delete server from Coolify', [
'error' => $e->getMessage(),
'server_id' => $server->id,
]);
logger()->error('Failed to force delete server from Coolify', [
'error' => $e->getMessage(),
'server_id' => $server->id,
]);
}
}
private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProviderTokenId, int $teamId): void
{
try {
// Use the provided token, or fallback to first available team token
$token = null;
if ($cloudProviderTokenId) {
$token = CloudProviderToken::find($cloudProviderTokenId);
}
if (! $token) {
$token = CloudProviderToken::where('team_id', $teamId)
->where('provider', 'hetzner')
->first();
}
if (! $token) {
ray('No Hetzner token found for team, skipping Hetzner deletion', [
'team_id' => $teamId,
'hetzner_server_id' => $hetznerServerId,
]);
return;
}
$hetznerService = new HetznerService($token->token);
$hetznerService->deleteServer($hetznerServerId);
ray('Deleted server from Hetzner', [
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
} catch (\Throwable $e) {
ray('Failed to delete server from Hetzner', [
'error' => $e->getMessage(),
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
// Log the error but don't prevent the server from being deleted from Coolify
logger()->error('Failed to delete server from Hetzner', [
'error' => $e->getMessage(),
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
// Notify the team about the failure
$team = Team::find($teamId);
$team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage()));
}
}
}

View file

@ -1,153 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Lorisleiva\Actions\Concerns\AsAction;
class InstallDocker
{
use AsAction;
private string $dockerVersion;
public function handle(Server $server)
{
$this->dockerVersion = config('constants.docker.minimum_required_version');
$supported_os_type = $server->validateOS();
if (! $supported_os_type) {
throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://coolify.io/docs/installation#manually">documentation</a>.');
}
if (! $server->sslCertificates()->where('is_ca_certificate', true)->exists()) {
$serverCert = SslHelper::generateSslCertificate(
commonName: 'Coolify CA Certificate',
serverId: $server->id,
isCaCertificate: true,
validityDays: 10 * 365
);
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
$commands = collect([
"mkdir -p $caCertPath",
"chown -R 9999:root $caCertPath",
"chmod -R 700 $caCertPath",
"rm -rf $caCertPath/coolify-ca.crt",
"echo '{$serverCert->ssl_certificate}' > $caCertPath/coolify-ca.crt",
"chmod 644 $caCertPath/coolify-ca.crt",
]);
remote_process($commands, $server);
}
$config = base64_encode('{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}');
$found = StandaloneDocker::where('server_id', $server->id);
if ($found->count() == 0 && $server->id) {
StandaloneDocker::create([
'name' => 'coolify',
'network' => 'coolify',
'server_id' => $server->id,
]);
}
$command = collect([]);
if (isDev() && $server->id === 0) {
$command = $command->merge([
"echo 'Installing Docker Engine...'",
"echo 'Configuring Docker Engine (merging existing configuration with the required)...'",
'sleep 4',
"echo 'Restarting Docker Engine...'",
'ls -l /tmp',
]);
return remote_process($command, $server);
} else {
$command = $command->merge([
"echo 'Installing Docker Engine...'",
]);
if ($supported_os_type->contains('debian')) {
$command = $command->merge([$this->getDebianDockerInstallCommand()]);
} elseif ($supported_os_type->contains('rhel')) {
$command = $command->merge([$this->getRhelDockerInstallCommand()]);
} elseif ($supported_os_type->contains('sles')) {
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
} else {
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
}
$command = $command->merge([
"echo 'Configuring Docker Engine (merging existing configuration with the required)...'",
'test -s /etc/docker/daemon.json && cp /etc/docker/daemon.json "/etc/docker/daemon.json.original-$(date +"%Y%m%d-%H%M%S")"',
"test ! -s /etc/docker/daemon.json && echo '{$config}' | base64 -d | tee /etc/docker/daemon.json > /dev/null",
"echo '{$config}' | base64 -d | tee /etc/docker/daemon.json.coolify > /dev/null",
'jq . /etc/docker/daemon.json.coolify | tee /etc/docker/daemon.json.coolify.pretty > /dev/null',
'mv /etc/docker/daemon.json.coolify.pretty /etc/docker/daemon.json.coolify',
"jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null",
'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json',
"echo 'Restarting Docker Engine...'",
'systemctl enable docker >/dev/null 2>&1 || true',
'systemctl restart docker',
]);
if ($server->isSwarm()) {
$command = $command->merge([
'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true',
]);
} else {
$command = $command->merge([
'docker network create --attachable coolify >/dev/null 2>&1 || true',
]);
$command = $command->merge([
"echo 'Done!'",
]);
}
return remote_process($command, $server);
}
}
private function getDebianDockerInstallCommand(): string
{
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
'install -m 0755 -d /etc/apt/keyrings && '.
'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && '.
'chmod a+r /etc/apt/keyrings/docker.asc && '.
'. /etc/os-release && '.
'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list && '.
'apt-get update && '.
'apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin'.
')';
}
private function getRhelDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
'dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && '.
'dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
'systemctl start docker && '.
'systemctl enable docker'.
')';
}
private function getSuseDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion} || (".
'zypper addrepo https://download.docker.com/linux/sles/docker-ce.repo && '.
'zypper refresh && '.
'zypper install -y --no-confirm docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin && '.
'systemctl start docker && '.
'systemctl enable docker'.
')';
}
private function getGenericDockerInstallCommand(): string
{
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
}
}

View file

@ -1,57 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class InstallPrerequisites
{
use AsAction;
public string $jobQueue = 'high';
public function handle(Server $server)
{
$supported_os_type = $server->validateOS();
if (! $supported_os_type) {
throw new \Exception('Server OS type is not supported for automated installation. Please install prerequisites manually.');
}
$command = collect([]);
if ($supported_os_type->contains('debian')) {
$command = $command->merge([
"echo 'Installing Prerequisites...'",
'apt-get update -y',
'command -v curl >/dev/null || apt install -y curl',
'command -v wget >/dev/null || apt install -y wget',
'command -v git >/dev/null || apt install -y git',
'command -v jq >/dev/null || apt install -y jq',
]);
} elseif ($supported_os_type->contains('rhel')) {
$command = $command->merge([
"echo 'Installing Prerequisites...'",
'command -v curl >/dev/null || dnf install -y curl',
'command -v wget >/dev/null || dnf install -y wget',
'command -v git >/dev/null || dnf install -y git',
'command -v jq >/dev/null || dnf install -y jq',
]);
} elseif ($supported_os_type->contains('sles')) {
$command = $command->merge([
"echo 'Installing Prerequisites...'",
'zypper update -y',
'command -v curl >/dev/null || zypper install -y curl',
'command -v wget >/dev/null || zypper install -y wget',
'command -v git >/dev/null || zypper install -y git',
'command -v jq >/dev/null || zypper install -y jq',
]);
} else {
throw new \Exception('Unsupported OS type for prerequisites installation');
}
$command->push("echo 'Prerequisites installed successfully.'");
return remote_process($command, $server);
}
}

View file

@ -1,41 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Application;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
class ResourcesCheck
{
use AsAction;
public function handle()
{
$seconds = 60;
try {
Application::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
ServiceApplication::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
ServiceDatabase::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandalonePostgresql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneRedis::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneMongodb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneMysql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneMariadb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneKeydb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneDragonfly::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
StandaloneClickhouse::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
} catch (\Throwable $e) {
return handleError($e);
}
}
}

View file

@ -1,16 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class RestartContainer
{
use AsAction;
public function handle(Server $server, string $containerName)
{
$server->restartContainer($containerName);
}
}

View file

@ -1,17 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Enums\ActivityTypes;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class RunCommand
{
use AsAction;
public function handle(Server $server, $command)
{
return remote_process(command: [$command], server: $server, ignore_errors: true, type: ActivityTypes::COMMAND->value);
}
}

View file

@ -1,221 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class StartLogDrain
{
use AsAction;
public string $jobQueue = 'high';
public function handle(Server $server)
{
if ($server->settings->is_logdrain_newrelic_enabled) {
$type = 'newrelic';
} elseif ($server->settings->is_logdrain_highlight_enabled) {
$type = 'highlight';
} elseif ($server->settings->is_logdrain_axiom_enabled) {
$type = 'axiom';
} elseif ($server->settings->is_logdrain_custom_enabled) {
$type = 'custom';
} else {
$type = 'none';
}
if ($type !== 'none') {
StopLogDrain::run($server);
}
try {
if ($type === 'none') {
return 'No log drain is enabled.';
} elseif ($type === 'newrelic') {
if (! $server->settings->is_logdrain_newrelic_enabled) {
throw new \Exception('New Relic log drain is not enabled.');
}
$config = base64_encode("
[SERVICE]
Flush 5
Daemon off
Tag container_logs
Log_Level debug
Parsers_File parsers.conf
[INPUT]
Name forward
Buffer_Chunk_Size 1M
Buffer_Max_Size 6M
[FILTER]
Name grep
Match *
Exclude log 127.0.0.1
[FILTER]
Name modify
Match *
Set coolify.server_name {$server->name}
Rename COOLIFY_APP_NAME coolify.app_name
Rename COOLIFY_PROJECT_NAME coolify.project_name
Rename COOLIFY_SERVER_IP coolify.server_ip
Rename COOLIFY_ENVIRONMENT_NAME coolify.environment_name
[OUTPUT]
Name nrlogs
Match *
license_key \${LICENSE_KEY}
# https://log-api.eu.newrelic.com/log/v1 - EU
# https://log-api.newrelic.com/log/v1 - US
base_uri \${BASE_URI}
");
} elseif ($type === 'highlight') {
if (! $server->settings->is_logdrain_highlight_enabled) {
throw new \Exception('Highlight log drain is not enabled.');
}
$config = base64_encode('
[SERVICE]
Flush 5
Daemon off
Log_Level debug
Parsers_File parsers.conf
[INPUT]
Name forward
tag ${HIGHLIGHT_PROJECT_ID}
Buffer_Chunk_Size 1M
Buffer_Max_Size 6M
[OUTPUT]
Name forward
Match *
Host otel.highlight.io
Port 24224
');
} elseif ($type === 'axiom') {
if (! $server->settings->is_logdrain_axiom_enabled) {
throw new \Exception('Axiom log drain is not enabled.');
}
$config = base64_encode("
[SERVICE]
Flush 5
Daemon off
Log_Level debug
Parsers_File parsers.conf
[INPUT]
Name forward
Buffer_Chunk_Size 1M
Buffer_Max_Size 6M
[FILTER]
Name grep
Match *
Exclude log 127.0.0.1
[FILTER]
Name modify
Match *
Set coolify.server_name {$server->name}
Rename COOLIFY_APP_NAME coolify.app_name
Rename COOLIFY_PROJECT_NAME coolify.project_name
Rename COOLIFY_SERVER_IP coolify.server_ip
Rename COOLIFY_ENVIRONMENT_NAME coolify.environment_name
[OUTPUT]
Name http
Match *
Host api.axiom.co
Port 443
URI /v1/datasets/\${AXIOM_DATASET_NAME}/ingest
# Authorization Bearer should be an API token
Header Authorization Bearer \${AXIOM_API_KEY}
compress gzip
format json
json_date_key _time
json_date_format iso8601
tls On
");
} elseif ($type === 'custom') {
if (! $server->settings->is_logdrain_custom_enabled) {
throw new \Exception('Custom log drain is not enabled.');
}
$config = base64_encode($server->settings->logdrain_custom_config);
$parsers = base64_encode($server->settings->logdrain_custom_config_parser);
} else {
throw new \Exception('Unknown log drain type.');
}
if ($type !== 'custom') {
$parsers = base64_encode("
[PARSER]
Name empty_line_skipper
Format regex
Regex /^(?!\s*$).+/
");
}
$compose = base64_encode('
services:
coolify-log-drain:
image: cr.fluentbit.io/fluent/fluent-bit:2.0
container_name: coolify-log-drain
command: -c /fluent-bit.conf
env_file:
- .env
volumes:
- ./fluent-bit.conf:/fluent-bit.conf
- ./parsers.conf:/parsers.conf
ports:
- 127.0.0.1:24224:24224
labels:
- coolify.managed=true
restart: unless-stopped
');
$readme = base64_encode('# New Relic Log Drain
This log drain is based on [Fluent Bit](https://fluentbit.io/) and New Relic Log Forwarder.
Files:
- `fluent-bit.conf` - configuration file for Fluent Bit
- `docker-compose.yml` - docker-compose file to run Fluent Bit
- `.env` - environment variables for Fluent Bit
');
$license_key = $server->settings->logdrain_newrelic_license_key;
$base_uri = $server->settings->logdrain_newrelic_base_uri;
$base_path = config('constants.coolify.base_config_path');
$config_path = $base_path.'/log-drains';
$fluent_bit_config = $config_path.'/fluent-bit.conf';
$parsers_config = $config_path.'/parsers.conf';
$compose_path = $config_path.'/docker-compose.yml';
$readme_path = $config_path.'/README.md';
$command = [
"echo 'Saving configuration'",
"mkdir -p $config_path",
"echo '{$parsers}' | base64 -d | tee $parsers_config > /dev/null",
"echo '{$config}' | base64 -d | tee $fluent_bit_config > /dev/null",
"echo '{$compose}' | base64 -d | tee $compose_path > /dev/null",
"echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
"test -f $config_path/.env && rm $config_path/.env",
];
if ($type === 'newrelic') {
$add_envs_command = [
"echo LICENSE_KEY=$license_key >> $config_path/.env",
"echo BASE_URI=$base_uri >> $config_path/.env",
];
} elseif ($type === 'highlight') {
$add_envs_command = [
"echo HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id} >> $config_path/.env",
];
} elseif ($type === 'axiom') {
$add_envs_command = [
"echo AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name} >> $config_path/.env",
"echo AXIOM_API_KEY={$server->settings->logdrain_axiom_api_key} >> $config_path/.env",
];
} elseif ($type === 'custom') {
$add_envs_command = [
"touch $config_path/.env",
];
} else {
throw new \Exception('Unknown log drain type.');
}
$restart_command = [
"echo 'Starting Fluent Bit'",
"cd $config_path && docker compose up -d",
];
$command = array_merge($command, $add_envs_command, $restart_command);
return instant_remote_process($command, $server);
} catch (\Throwable $e) {
return handleError($e);
}
}
}

View file

@ -1,71 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Events\SentinelRestarted;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class StartSentinel
{
use AsAction;
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null)
{
if ($server->isSwarm() || $server->isBuildServer()) {
return;
}
if ($restart) {
StopSentinel::run($server);
}
$version = $latestVersion ?? get_latest_sentinel_version();
$metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days');
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
$token = data_get($server, 'settings.sentinel_token');
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
$image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version;
if (! $endpoint) {
throw new \RuntimeException('You should set FQDN in Instance Settings.');
}
$environments = [
'TOKEN' => $token,
'DEBUG' => $debug ? 'true' : 'false',
'PUSH_ENDPOINT' => $endpoint,
'PUSH_INTERVAL_SECONDS' => $pushInterval,
'COLLECTOR_ENABLED' => $server->isMetricsEnabled() ? 'true' : 'false',
'COLLECTOR_REFRESH_RATE_SECONDS' => $refreshRate,
'COLLECTOR_RETENTION_PERIOD_DAYS' => $metricsHistory,
];
$labels = [
'coolify.managed' => 'true',
];
if (isDev()) {
// data_set($environments, 'DEBUG', 'true');
if ($customImage && ! empty($customImage)) {
$image = $customImage;
}
$mountDir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/sentinel';
}
$dockerEnvironments = '-e "'.implode('" -e "', array_map(fn ($key, $value) => "$key=$value", array_keys($environments), $environments)).'"';
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
instant_remote_process([
'docker rm -f coolify-sentinel || true',
"mkdir -p $mountDir",
$dockerCommand,
"chown -R 9999:root $mountDir",
"chmod -R 700 $mountDir",
], $server);
$server->settings->is_sentinel_enabled = true;
$server->settings->save();
$server->sentinelHeartbeat();
// Dispatch event to notify UI components
SentinelRestarted::dispatch($server, $version);
}
}

View file

@ -1,20 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class StopLogDrain
{
use AsAction;
public function handle(Server $server)
{
try {
return instant_remote_process(['docker rm -f coolify-log-drain'], $server, false);
} catch (\Throwable $e) {
return handleError($e);
}
}
}

View file

@ -1,17 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
class StopSentinel
{
use AsAction;
public function handle(Server $server)
{
instant_remote_process(['docker rm -f coolify-sentinel'], $server, false);
$server->sentinelHeartbeat(isReset: true);
}
}

View file

@ -1,133 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep;
use Lorisleiva\Actions\Concerns\AsAction;
class UpdateCoolify
{
use AsAction;
public ?Server $server = null;
public ?string $latestVersion = null;
public ?string $currentVersion = null;
public function handle($manual_update = false)
{
if (isDev()) {
Sleep::for(10)->seconds();
return;
}
$settings = instanceSettings();
$this->server = Server::find(0);
if (! $this->server) {
return;
}
CleanupDocker::dispatch($this->server, false, false);
// Fetch fresh version from CDN instead of using cache
try {
$response = Http::retry(3, 1000)->timeout(10)
->get(config('constants.coolify.versions_url'));
if ($response->successful()) {
$versions = $response->json();
$this->latestVersion = data_get($versions, 'coolify.v4.version');
} else {
// Fallback to cache if CDN unavailable
$cacheVersion = get_latest_version_of_coolify();
// Validate cache version against current running version
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
'cached_version' => $cacheVersion,
'current_version' => config('constants.coolify.version'),
]);
throw new \Exception(
'Cannot determine latest version: CDN unavailable and cache version '.
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
);
}
$this->latestVersion = $cacheVersion;
Log::warning('Failed to fetch fresh version from CDN (unsuccessful response), using validated cache', [
'version' => $cacheVersion,
]);
}
} catch (\Throwable $e) {
$cacheVersion = get_latest_version_of_coolify();
// Validate cache version against current running version
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
'error' => $e->getMessage(),
'cached_version' => $cacheVersion,
'current_version' => config('constants.coolify.version'),
]);
throw new \Exception(
'Cannot determine latest version: CDN unavailable and cache version '.
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
);
}
$this->latestVersion = $cacheVersion;
Log::warning('Failed to fetch fresh version from CDN, using validated cache', [
'error' => $e->getMessage(),
'version' => $cacheVersion,
]);
}
$this->currentVersion = config('constants.coolify.version');
if (! $manual_update) {
if (! $settings->is_auto_update_enabled) {
return;
}
if ($this->latestVersion === $this->currentVersion) {
return;
}
if (version_compare($this->latestVersion, $this->currentVersion, '<')) {
return;
}
}
// ALWAYS check for downgrades (even for manual updates)
if (version_compare($this->latestVersion, $this->currentVersion, '<')) {
Log::error('Downgrade prevented', [
'target_version' => $this->latestVersion,
'current_version' => $this->currentVersion,
'manual_update' => $manual_update,
]);
throw new \Exception(
"Cannot downgrade from {$this->currentVersion} to {$this->latestVersion}. ".
'If you need to downgrade, please do so manually via Docker commands.'
);
}
$this->update();
$settings->new_version_available = false;
$settings->save();
}
private function update()
{
$helperImage = config('constants.coolify.helper_image');
$latest_version = getHelperVersion();
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
instant_remote_process(["docker pull -q $image"], $this->server, false);
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion",
], $this->server);
}
}

View file

@ -1,52 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Contracts\Activity;
class UpdatePackage
{
use AsAction;
public string $jobQueue = 'high';
public function handle(Server $server, string $osId, ?string $package = null, ?string $packageManager = null, bool $all = false): Activity|array
{
try {
if ($server->serverStatus() === false) {
return [
'error' => 'Server is not reachable or not ready.',
];
}
switch ($packageManager) {
case 'zypper':
$commandAll = 'zypper update -y';
$commandInstall = 'zypper install -y '.$package;
break;
case 'dnf':
$commandAll = 'dnf update -y';
$commandInstall = 'dnf update -y '.$package;
break;
case 'apt':
$commandAll = 'apt update && apt upgrade -y';
$commandInstall = 'apt install -y '.$package;
break;
default:
return [
'error' => 'OS not supported',
];
}
if ($all) {
return remote_process([$commandAll], $server);
}
return remote_process([$commandInstall], $server);
} catch (\Exception $e) {
return [
'error' => $e->getMessage(),
];
}
}
}

Some files were not shown because too many files have changed in this diff Show more