diff --git a/.ai/core/application-architecture.md b/.ai/core/application-architecture.md
index 64038d139..c1fe7c470 100644
--- a/.ai/core/application-architecture.md
+++ b/.ai/core/application-architecture.md
@@ -283,14 +283,22 @@ ### **Polymorphic Relationships**
### **Team-Based Soft Scoping**
-All major resources include team-based query scoping:
+All major resources include team-based query scoping with request-level caching:
```php
-// Automatic team filtering
-$applications = Application::ownedByCurrentTeam()->get();
-$servers = Server::ownedByCurrentTeam()->get();
+// ✅ CORRECT - Use cached methods (request-level cache via once())
+$applications = Application::ownedByCurrentTeamCached();
+$servers = Server::ownedByCurrentTeamCached();
+
+// ✅ CORRECT - Filter cached collection in memory
+$activeServers = Server::ownedByCurrentTeamCached()->where('is_active', true);
+
+// Only use query builder when you need eager loading or fresh data
+$projects = Project::ownedByCurrentTeam()->with('environments')->get();
```
+See [Database Patterns](.ai/patterns/database-patterns.md#request-level-caching-with-ownedbycurrentteamcached) for full documentation.
+
### **Configuration Inheritance**
Environment variables cascade from:
diff --git a/.ai/core/deployment-architecture.md b/.ai/core/deployment-architecture.md
index 272f00e4c..927bdc8de 100644
--- a/.ai/core/deployment-architecture.md
+++ b/.ai/core/deployment-architecture.md
@@ -270,6 +270,84 @@ ### Build Optimization
- **Build artifact** reuse
- **Parallel build** processing
+### Docker Build Cache Preservation
+
+Coolify provides settings to preserve Docker build cache across deployments, addressing cache invalidation issues.
+
+#### The Problem
+
+By default, Coolify injects `ARG` statements into user Dockerfiles for build-time variables. This breaks Docker's cache mechanism because:
+1. **ARG declarations invalidate cache** - Any change in ARG values after the `ARG` instruction invalidates all subsequent layers
+2. **SOURCE_COMMIT changes every commit** - Causes full rebuilds even when code changes are minimal
+
+#### Application Settings
+
+Two toggles in **Advanced Settings** control this behavior:
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `inject_build_args_to_dockerfile` | `true` | Controls whether Coolify adds `ARG` statements to Dockerfile |
+| `include_source_commit_in_build` | `false` | Controls whether `SOURCE_COMMIT` is included in build context |
+
+**Database columns:** `application_settings.inject_build_args_to_dockerfile`, `application_settings.include_source_commit_in_build`
+
+#### Buildpack Coverage
+
+| Build Pack | ARG Injection | Method |
+|------------|---------------|--------|
+| **Dockerfile** | ✅ Yes | `add_build_env_variables_to_dockerfile()` |
+| **Docker Compose** (with `build:`) | ✅ Yes | `modify_dockerfiles_for_compose()` |
+| **PR Deployments** (Dockerfile only) | ✅ Yes | `add_build_env_variables_to_dockerfile()` |
+| **Nixpacks** | ❌ No | Generates its own Dockerfile internally |
+| **Static** | ❌ No | Uses internal Dockerfile |
+| **Docker Image** | ❌ No | No build phase |
+
+#### How It Works
+
+**When `inject_build_args_to_dockerfile` is enabled (default):**
+```dockerfile
+# Coolify modifies your Dockerfile to add:
+FROM node:20
+ARG MY_VAR=value
+ARG COOLIFY_URL=...
+ARG SOURCE_COMMIT=abc123 # (if include_source_commit_in_build is true)
+# ... rest of your Dockerfile
+```
+
+**When `inject_build_args_to_dockerfile` is disabled:**
+- Coolify does NOT modify the Dockerfile
+- `--build-arg` flags are still passed (harmless without matching `ARG` in Dockerfile)
+- User must manually add `ARG` statements for any build-time variables they need
+
+**When `include_source_commit_in_build` is disabled (default):**
+- `SOURCE_COMMIT` is NOT included in build-time variables
+- `SOURCE_COMMIT` is still available at **runtime** (in container environment)
+- Docker cache preserved across different commits
+
+#### Recommended Configuration
+
+| Use Case | inject_build_args | include_source_commit | Cache Behavior |
+|----------|-------------------|----------------------|----------------|
+| Maximum cache preservation | `false` | `false` | Best cache retention |
+| Need build-time vars, no commit | `true` | `false` | Cache breaks on var changes |
+| Need commit at build-time | `true` | `true` | Cache breaks every commit |
+| Manual ARG management | `false` | `true` | Cache preserved (no ARG in Dockerfile) |
+
+#### Implementation Details
+
+**Files:**
+- `app/Jobs/ApplicationDeploymentJob.php`:
+ - `set_coolify_variables()` - 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
@@ -428,7 +506,7 @@ #### `content`
- `templates/compose/chaskiq.yaml` - Entrypoint script
**Implementation:**
-- Parsed: `bootstrap/helpers/parsers.php` (line 717)
+- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `content` field extraction)
- Storage: `app/Models/LocalFileVolume.php`
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
@@ -481,7 +559,7 @@ #### `is_directory` / `isDirectory`
- Pre-creating mount points before container starts
**Implementation:**
-- Parsed: `bootstrap/helpers/parsers.php` (line 718)
+- 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`
diff --git a/.ai/patterns/database-patterns.md b/.ai/patterns/database-patterns.md
index 1e40ea152..5a9d16f71 100644
--- a/.ai/patterns/database-patterns.md
+++ b/.ai/patterns/database-patterns.md
@@ -243,6 +243,59 @@ ### Database Indexes
- **Composite indexes** for common queries
- **Unique constraints** for business rules
+### Request-Level Caching with ownedByCurrentTeamCached()
+
+Many models have both `ownedByCurrentTeam()` (returns query builder) and `ownedByCurrentTeamCached()` (returns cached collection). **Always prefer the cached version** to avoid duplicate database queries within the same request.
+
+**Models with cached methods available:**
+- `Server`, `PrivateKey`, `Project`
+- `Application`
+- `StandalonePostgresql`, `StandaloneMysql`, `StandaloneRedis`, `StandaloneMariadb`, `StandaloneMongodb`, `StandaloneKeydb`, `StandaloneDragonfly`, `StandaloneClickhouse`
+- `Service`, `ServiceApplication`, `ServiceDatabase`
+
+**Usage patterns:**
+```php
+// ✅ CORRECT - Uses request-level cache (via Laravel's once() helper)
+$servers = Server::ownedByCurrentTeamCached();
+
+// ❌ AVOID - Makes a new database query each time
+$servers = Server::ownedByCurrentTeam()->get();
+
+// ✅ CORRECT - Filter cached collection in memory
+$activeServers = Server::ownedByCurrentTeamCached()->where('is_active', true);
+$server = Server::ownedByCurrentTeamCached()->firstWhere('id', $serverId);
+$serverIds = Server::ownedByCurrentTeamCached()->pluck('id');
+
+// ❌ AVOID - Making filtered database queries when data is already cached
+$activeServers = Server::ownedByCurrentTeam()->where('is_active', true)->get();
+```
+
+**When to use which:**
+- `ownedByCurrentTeamCached()` - **Default choice** for reading team data
+- `ownedByCurrentTeam()` - Only when you need to chain query builder methods that can't be done on collections (like `with()` for eager loading), or when you explicitly need a fresh database query
+
+**Implementation pattern for new models:**
+```php
+/**
+ * Get query builder for resources owned by current team.
+ * If you need all resources without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
+public static function ownedByCurrentTeam()
+{
+ return self::whereTeamId(currentTeam()->id);
+}
+
+/**
+ * Get all resources owned by current team (cached for request duration).
+ */
+public static function ownedByCurrentTeamCached()
+{
+ return once(function () {
+ return self::ownedByCurrentTeam()->get();
+ });
+}
+```
+
## Data Consistency Patterns
### Database Transactions
diff --git a/.github/workflows/coolify-helper-next.yml b/.github/workflows/coolify-helper-next.yml
index fec54d54a..2e50abbe7 100644
--- a/.github/workflows/coolify-helper-next.yml
+++ b/.github/workflows/coolify-helper-next.yml
@@ -44,8 +44,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
@@ -86,8 +86,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml
index 0c9996ec8..ed6fc3bcb 100644
--- a/.github/workflows/coolify-helper.yml
+++ b/.github/workflows/coolify-helper.yml
@@ -44,8 +44,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
@@ -85,8 +85,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
diff --git a/.github/workflows/coolify-production-build.yml b/.github/workflows/coolify-production-build.yml
index 21871b103..477274751 100644
--- a/.github/workflows/coolify-production-build.yml
+++ b/.github/workflows/coolify-production-build.yml
@@ -51,8 +51,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
@@ -91,8 +91,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
diff --git a/.github/workflows/coolify-realtime-next.yml b/.github/workflows/coolify-realtime-next.yml
index 7ab4dcc42..8937ea27d 100644
--- a/.github/workflows/coolify-realtime-next.yml
+++ b/.github/workflows/coolify-realtime-next.yml
@@ -48,8 +48,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
@@ -90,8 +90,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
diff --git a/.github/workflows/coolify-realtime.yml b/.github/workflows/coolify-realtime.yml
index 5efe445c5..d8784dd50 100644
--- a/.github/workflows/coolify-realtime.yml
+++ b/.github/workflows/coolify-realtime.yml
@@ -48,8 +48,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
@@ -90,8 +90,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml
index 67b7b03e8..494ef6939 100644
--- a/.github/workflows/coolify-staging-build.yml
+++ b/.github/workflows/coolify-staging-build.yml
@@ -64,8 +64,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
@@ -110,8 +110,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
diff --git a/.github/workflows/coolify-testing-host.yml b/.github/workflows/coolify-testing-host.yml
index 24133887a..0c1371573 100644
--- a/.github/workflows/coolify-testing-host.yml
+++ b/.github/workflows/coolify-testing-host.yml
@@ -44,8 +44,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
@@ -81,8 +81,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 321e9b367..46769f34e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8452 @@ # Changelog
## [unreleased]
+### 🐛 Bug Fixes
+
+- Update syncData method to use data_get for safer property access
+- Update version numbers to 4.0.0-beta.441 and 4.0.0-beta.442
+- Enhance menu item styles and update theme color meta tag
+- Clean up input attributes for PostgreSQL settings in general.blade.php
+- Update docker stop command to use --time instead of --timeout
+- Clean up utility classes and improve readability in Blade templates
+- Enhance styling for page width component in Blade template
+- Remove debugging output from StartPostgresql command handling
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.440] - 2025-11-04
+
+### 🐛 Bug Fixes
+
+- Fix SPA toggle nginx regeneration and add confirmation modal
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.439] - 2025-11-03
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.438] - 2025-10-29
+
+### 🚀 Features
+
+- Display service logos in original colors with consistent sizing
+- Add warnings for system-wide GitHub Apps
+- Show message when no resources use GitHub App
+- Add dynamic viewport-based height for compose editor
+- Add funding information for Coollabs including sponsorship plans and channels
+- Update Evolution API slogan to better reflect its capabilities
+- *(templates)* Update plane compose to v1.0.0
+- Add token validation functionality for Hetzner and DigitalOcean providers
+- Add dev_helper_version to instance settings and update related functionality
+- Add RestoreDatabase command for PostgreSQL dump restoration
+- Update ApplicationSetting model to include additional boolean casts
+- Enhance General component with additional properties and validation rules
+- Update version numbers to 4.0.0-beta.440 and 4.0.0-beta.441
+
+### 🐛 Bug Fixes
+
+- Handle redis_password in API database creation
+- Make modals scrollable on small screens
+- Resolve Livewire wire:model binding error in domains input
+- Make environment variable forms responsive
+- Make proxy logs page responsive
+- Improve proxy logs form layout for better responsive behavior
+- Prevent horizontal overflow in log text
+- Use break-all to force line wrapping in logs
+- Ensure deployment failure notifications are sent reliably
+- GitHub source creation and configuration issues
+- Make system-wide warning reactive in Create view
+- Prevent system-wide warning callout from making modal too wide
+- Constrain callout width with max-w-2xl and wrap text properly
+- Center system-wide warning callout in modal
+- Left-align callout on regular view, keep centered in modal
+- Allow callout to take full width in regular view
+- Change app_id and installation_id to integer values in createGithubAppManually method
+- Use x-cloak instead of inline style to prevent FOUC
+- Clarify warning message for allowed IPs configuration
+- Server URL generation in ServerPatchCheck notification
+- Monaco editor empty for docker compose applications
+- Update sponsor link from Darweb to Dade2 in README
+- *(database)* Prevent malformed URLs when server IP is empty
+- Optimize caching in Dockerfile and GitHub Actions workflow
+- Remove wire:ignore from modal and add wire:key to EditCompose component
+- Add wire:ignore directive to modal component for improved functionality
+- Clean up formatting and remove unnecessary key binding in stack form component
+- Add null checks and validation to OAuth bulk update method
+- *(docs)* Update documentation URL to version 2 in evolution-api.yaml
+- *(templates)* Remove volumes from Plane's compose
+- *(templates)* Add redis env to live service in Plane
+- *(templates)* Update minio image to use coollabsio fork in Plane
+- Prevent login rate limit bypass via spoofed headers
+- Correct login rate limiter key format to include IP address
+- Change SMTP port input type to number for better validation
+- Remove unnecessary step attribute from maximum storage input fields
+- Update boarding flow logic to complete onboarding when server is created
+- Convert network aliases to string for display
+- Improve custom_network_aliases handling and testing
+- Remove duplicate custom_labels from config hash calculation
+- Improve run script and enhance sticky header style
+
+### 💼 Other
+
+- *(deps-dev)* Bump vite from 6.3.6 to 6.4.1
+
+### 🚜 Refactor
+
+- Remove deprecated next() method
+- Replace allowed IPs validation logic with regex
+- Remove redundant
+- Streamline allowed IPs validation and enhance UI warnings for API access
+- Remove staging URL logic from ServerPatchCheck constructor
+- Streamline Docker build process with matrix strategy for multi-architecture support
+- Simplify project data retrieval and enhance OAuth settings handling
+- Improve handling of custom network aliases
+- Remove unused submodules
+- Update subproject commit hashes
+- Remove SynchronizesModelData trait and implement syncData method for model synchronization
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+- Add service & database deployment logging plan
+
+### 🧪 Testing
+
+- Add unit tests for ServerPatchCheck notification URL generation
+- Fix ServerPatchCheckNotification tests to avoid global state pollution
+
+### ⚙️ Miscellaneous Tasks
+
+- Add category field to siyuan.yaml
+- Update siyuan category in service templates
+- Add spacing and format callout text in modal
+- Update version numbers to 4.0.0-beta.439 and 4.0.0-beta.440
+- Add .workspaces to .gitignore
+
+## [4.0.0-beta.437] - 2025-10-21
+
+### 🚀 Features
+
+- *(templates)* Add sparkyfitness compose template and logo
+- *(servide)* Add siyuan template
+- Add onboarding guide link to global search no results state
+- Add category filter dropdown to service selection
+
+### 🐛 Bug Fixes
+
+- *(service)* Update image version & healthcheck start period
+- Filter deprecated server types for Hetzner
+- Eliminate dark mode white screen flicker on page transitions
+
+### 💼 Other
+
+- Preserve clean docker_compose_raw without Coolify additions
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+## [4.0.0-beta.435] - 2025-10-15
+
+### 🚀 Features
+
+- *(docker)* Enhance Docker image handling with new validation and parsing logic
+- *(docker)* Improve Docker image submission logic with enhanced parsing
+- *(docker)* Refine Docker image processing in application creation
+- Add Ente Photos service template
+- *(storage)* Add read-only volume handling and UI notifications
+- *(service)* Add Elasticsearch password handling in extraFields method
+- *(application)* Add default NIXPACKS_NODE_VERSION environment variable for Nixpacks applications
+- *(proxy)* Enhance proxy configuration regeneration by extracting custom commands
+- *(backup)* Enhance backup job with S3 upload handling and notifications
+- *(storage)* Implement transaction handling in storage settings submission
+- *(project)* Enhance project index with resource creation capabilities
+- *(dashboard)* Enhance project and server sections with modal input for resource creation
+- *(global-search)* Enhance resource creation functionality in search modal
+- *(global-search)* Add navigation routes and enhance search functionality
+- *(conductor)* Add setup script and configuration file
+- *(conductor)* Add run script and update runScriptMode configuration
+- *(docker-compose)* Add image specifications for coolify, soketi, and testing-host services
+- *(cleanup)* Add force deletion of stuck servers and orphaned SSL certificates
+- *(deployment)* Save build-time .env file before build and enhance logging for Dockerfile
+- Implement Hetzner deletion failure notification system with email and messaging support
+- Enhance proxy status notifications with detailed messages for various states
+- Add retry functionality for server validation process
+- Add retry mechanism with rate limit handling to API requests in HetznerService
+- Implement ValidHostname validation rule and integrate it into server creation process
+- Add support for selecting additional SSH keys from Hetzner in server creation form
+- Enhance datalist component with unified input container and improved option handling
+- Add modal support for creating private keys in server creation form and enhance UI for private key selection
+- Add IPv4/IPv6 network configuration for Hetzner server creation
+- Add pricing display to Hetzner server creation button
+- Add cloud-init script support for Hetzner server creation
+- Add cloud-init scripts management UI in Security section
+- Add cloud-init scripts to global search
+- Add artisan command to clear global search cache
+- Add YAML validation for cloud-init scripts
+- Add clear button for cloud-init script dropdown
+- Add custom webhook notification support
+- Add webhook placeholder to Test notification
+- Add WebhookChannel placeholder implementation
+- Implement actual webhook delivery
+- Implement actual webhook delivery with Ray debugging
+- Improve webhook URL field UI
+- Add UUIDs and URLs to webhook notifications
+- *(onboarding)* Redesign user onboarding flow with modern UI/UX
+- Replace terminal dropdown with searchable datalist component
+- *(onboarding)* Add Hetzner integration and fix navigation issues
+- Use new homarr image
+- *(templates)* Actually use the new image now
+- *(templates)* Pin homarr image version to v1.40.0
+- *(template)* Added newapi
+- Add mail environment variables to docmost.yaml
+- Add Email Envs, Install more required packages by pdsadmin
+- Make an empty pds.env file to trick pdsadmin into working correctly
+- Not many know how to setup this without reading pds docs
+- Make the other email env also required
+- *(templates)* Added Lobe Chat service
+- *(service)* Add Gramps Web template
+- *(campfire)* Add Docker Compose configuration for Once Campfire service
+- Add Hetzner affiliate link to token form
+- Update Hetzner affiliate link text and URL
+- Add CPU vendor information to server types in Hetzner integration
+- Implement TrustHosts middleware to handle FQDN and IP address trust logic
+- Implement TrustHosts middleware to handle FQDN and IP address trust logic
+- Allow safe environment variable defaults in array-format volumes
+- Add signoz template
+- *(signoz)* Replace png icon by svg icon
+- *(signoz)* Remove explicit 'networks' setting
+- *(signoz)* Add predefined environment variables to configure Telemetry, SMTP and email sending for Alert Manager
+- *(signoz)* Generate URLs for `otel-collector` service
+- *(signoz)* Update documentation link
+- *(signoz)* Add healthcheck to otel-collector service
+- *(signoz)* Use latest tag instead of hardcoded versions
+- *(signoz)* Remove redundant users.xml volume from clickhouse container
+- *(signoz)* Replace clickhouse' config.xml volume with simpler configuration
+- *(signoz)* Remove deprecated parameters of signoz container
+- *(signoz)* Remove volumes from signoz.yaml
+- *(signoz)* Assume there is a single zookeeper container
+- *(signoz)* Update Clickhouse config to include all settings required by Signoz
+- *(signoz)* Update config.xml and users.xml to ensure clickhouse boots correctly
+- *(signoz)* Update otel-collector configuration to match upstream
+- *(signoz)* Fix otel-collector config for version v0.128.0
+- *(signoz)* Remove unecessary port mapping for otel-collector
+- *(signoz)* Add SIGNOZ_JWT_SECRET env var generation
+- *(signoz)* Upgrade clickhouse image to 25.5.6
+- *(signoz)* Use latest tag for signoz/zookeeper
+- *(signoz)* Update variables for SMTP configuration
+- *(signoz)* Replace deprecated `TELEMETRY_ENABLED` by `SIGNOZ_STATSREPORTER_ENABLED`
+- *(signoz)* Pin service image tags and `exclude_from_hc` flag to services excluded from health checks
+- *(templates)* Add SMTP configuration to ente-photos compose templates
+- *(templates)* Add SMTP encryption configuration to ente-photos compose templates
+
+### 🐛 Bug Fixes
+
+- Region env variable
+- Ente photos
+- *(elasticsearch)* Update Elasticsearch and Kibana configuration for enhanced security and setup
+- *(ui)* Make the deployments indicator toast in the bottom-left above the sidebar
+- *(environment)* Clear computed property cache after adding environment variables
+- *(backup)* Update backup job to use backup_log_uuid for container naming
+- *(core)* Set default base_directory and include in submit method
+- *(deployment)* Add warning for NIXPACKS_NODE_VERSION in node configurations
+- *(deployment)* Save runtime environment variables when skipping build
+- *(job)* Correct build logs URL structure in ApplicationPullRequestUpdateJob
+- *(tests)* Update Docker command for running feature tests without `-it` flag
+- On team creation, redirect to the new team instantly
+- *(project)* Update redirect logic after resource creation to include environment UUID
+- *(dashboard)* Add cursor pointer to modal input buttons for better UX
+- *(modal-confirmation)* Refine escape key handling to ensure modal closes only when open
+- *(conductor-setup)* Update script permissions for execution
+- *(conductor)* Update run script command to 'spin up'
+- *(conductor)* Update run script to include 'spin down' command
+- *(docker-compose)* Set pull_policy to 'never' for coolify, soketi, and testing-host services
+- *(migration)* Disable transaction for concurrent index creation
+- Properly handle transaction for concurrent index operations
+- Use correct property declaration for withinTransaction
+- *(api-tokens)* Update settings link for API enablement message
+- *(css)* Update success color to match design specifications
+- *(css)* Update focus styles for input and button utilities to improve accessibility
+- *(css)* Remove unnecessary tracking classes from status components for consistency
+- *(css)* Update focus styles for Checkbox and modal input components to enhance accessibility
+- Refresh server data before showing notification to ensure accurate proxy status
+- Update Hetzner server status handling to prevent unnecessary database updates and improve UI responsiveness
+- Improve error logging and handling in ServerConnectionCheckJob for Hetzner server status
+- Correct dispatch logic for Hetzner server status refresh in checkHetznerServerStatus method
+- Streamline proxy status handling in StartProxy and Navbar components
+- Improve placeholder text for token name input in cloud provider token form
+- Update cloud provider token form with improved placeholder and guidance for API token creation
+- *(ci)* Sanitize branch names for Docker tag compatibility
+- Set cloud-init script dropdown to empty by default
+- Reset cloud-init fields when closing server creation modal
+- Improve cloud-init scripts UI styling and behavior
+- Allow typing in global search while data loads
+- Hide 'No results found' message while data is loading
+- Populate webhook notification settings for existing teams
+- Register WebhookNotificationSettings with NotificationPolicy
+- Add missing server_patch_webhook_notifications field
+- Move POST badge before input field
+- Use btn-primary for POST badge background
+- *(onboarding)* Auto-select first SSH key for better UX
+- Prevent container name conflict when updating database port mappings
+- Missing 422 error code in openapi spec
+- Allow all environment variable fields in API endpoints
+- Fixed version
+- Fix documentation url
+- Bluesky PDS template
+- Bluesky PDS template finally works normally
+- Add back template info
+- Now it automatically generates the JWT secret and the PLC rotation key
+- Syntax error on vars
+- Remove the SERVICE_EMAIL_ADMIN and make it normal
+- Both email envs are needed in order for the PDS to start, so set the other one as required
+- Add back template info
+- Healthcheck doesn’t need to be 5s
+- Make email envs not required
+- Domain on coolify
+- *(templates)* Update Lobe-chat openai base_url env + required envs
+- *(templates)* Lobechat environnement variable
+- *(lobe-chat)* Update Docker image tag to a specific version 1.135.5
+- Enable docker network connection for pgadmin service
+- *(template/filebrowser)* Correct routing and healthcheck for Filebrowser
+- *(template/filebrowser)* Correct healthcheck for Filebrowser
+- *(campfire)* Update port configuration from 80 to 3000 in Docker Compose file
+- *(campfire)* Correct port comment from 3000 to 80 in Docker Compose file
+- *(campfire)* Update service definition to use image instead of build in Docker Compose file
+- *(templates)* Remove mattermost healthcheck command according to lack of shell in new version
+- Prevent duplicate services on image change and enable real-time UI refresh
+- Enhance run script to remove existing containers before starting
+- Prevent TypeError in database General components with null server
+- Add authorization checks to database Livewire components
+- Add missing save_runtime_environment_variables() in deploy_simple_dockerfile
+- *(git)* Handle Git redirects and improve URL parsing for tangled.sh and other Git hosts
+- Improve logging and add shell escaping for git ls-remote
+- Update run script to use bun for development
+- Restore original run script functionality in conductor.json
+- Use computed imageTag variable for digest-based Docker images
+- Improve Docker image digest handling and add auto-parse feature
+- 'new image' quick action not progressing to resource selection
+- Use wasChanged() instead of isDirty() in updated hooks
+- Prevent command injection in git ls-remote operations
+- Handle null environment variable values in bash escaping
+- Critical privilege escalation in team invitation system
+- Add authentication context to TeamPolicyTest
+- Ensure negative cache results are stored in TrustHosts middleware
+- Use wasChanged() instead of isDirty() in updated hook
+- Prevent command injection in Docker Compose parsing - add pre-save validation
+- Use canonical parser for Windows path validation
+- Correct variable name typo in generateGitLsRemoteCommands method
+- Update version numbers to 4.0.0-beta.436 and 4.0.0-beta.437
+- Ensure authorization checks are in place for viewing and updating the application
+- Ensure authorization check is performed during component mount
+- *(signoz)* Remove example secrets to avoid triggering GitGuardian
+- *(signoz)* Remove hardcoded container names
+- *(signoz)* Remove HTTP collector FQDN in otel-collector
+- *(n8n)* Add DB_SQLITE_POOL_SIZE environment variable for configuration
+- *(template)* Remove default values for environment variables
+- Update metamcp image version and clean up environment variable syntax
+
+### 💼 Other
+
+- Ente config
+- Cofig variables
+- Lean Config
+- Env
+- Services & Env variables
+- Product hunt Ente Logo
+- Remove volumes
+- Add ray logging for Hetzner createServer API request/response
+- Escape all shell directory paths in Git deployment commands
+- Remove content from docker_compose_raw to prevent file overwrites
+- *(templates)* Metamcp app
+
+### 🚜 Refactor
+
+- *(environment-variables)* Adjust ordering logic for environment variables
+- Update ente photos configuration for improved service management
+- *(deployment)* Streamline environment variable generation in ApplicationDeploymentJob
+- *(deployment)* Enhance deployment data retrieval and relationships
+- *(deployment)* Standardize environment variable handling in ApplicationDeploymentJob
+- *(deployment)* Update environment variable handling for Docker builds
+- *(navbar, app)* Improve layout and styling for better responsiveness
+- *(switch-team)* Remove label from team selection component for cleaner UI
+- *(global-search, environment)* Streamline environment retrieval with new query method
+- *(backup)* Make backup_log_uuid initialization lazy
+- *(checkbox, utilities, global-search)* Enhance focus styles for better accessibility
+- *(forms)* Simplify wire:dirty class bindings for input, select, and textarea components
+- Replace direct SslCertificate queries with server relationship methods for consistency
+- *(ui)* Improve cloud-init script save checkbox visibility and styling
+- Enable cloud-init save checkbox at all times with backend validation
+- Improve cloud-init script UX and remove description field
+- Improve cloud-init script management UI and cache control
+- Remove debug sleep from global search modal
+- Reduce cloud-init label width for better layout
+- Remove SendsWebhook interface
+- Reposition POST badge as button
+- Migrate database components from legacy model binding to explicit properties
+- Volumes set back to ./pds-data:/pds
+- *(campfire)* Streamline environment variable definitions in Docker Compose file
+- Improve validation error handling and coding standards
+- Preserve exception chain in validation error handling
+- Harden and deduplicate validateShellSafePath
+- Replace random ID generation with Cuid2 for unique HTML IDs in form components
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+- *(tests)* Update testing guidelines for unit and feature tests
+- *(sync)* Create AI Instructions Synchronization Guide and update CLAUDE.md references
+- *(database-patterns)* Add critical note on mass assignment protection for new columns
+- Clarify cloud-init script compatibility
+- Update changelog
+- Update changelog
+
+### 🎨 Styling
+
+- *(campfire)* Format environment variables for better readability in Docker Compose file
+- *(campfire)* Update comment for DISABLE_SSL environment variable for clarity
+
+### 🧪 Testing
+
+- Improve Git ls-remote parsing tests with uppercase SHA and negative cases
+- Add coverage for newline and tab rejection in volume strings
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update Coolify version numbers to 4.0.0-beta.435 and 4.0.0-beta.436
+- Update package-lock.json
+- *(service)* Update convex template and image
+- *(signoz)* Remove unused ports
+- *(signoz)* Bump version to 0.77.0
+- *(signoz)* Bump version to 0.78.1
+
+## [4.0.0-beta.434] - 2025-10-03
+
+### 🚀 Features
+
+- *(deployments)* Enhance Docker build argument handling for multiline variables
+- *(deployments)* Add log copying functionality to clipboard in dev
+- *(deployments)* Generate SERVICE_NAME environment variables from Docker Compose services
+
+### 🐛 Bug Fixes
+
+- *(deployments)* Enhance builder container management and environment variable handling
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update version numbers for Coolify releases
+- *(versions)* Bump Coolify stable version to 4.0.0-beta.434
+
+## [4.0.0-beta.433] - 2025-10-01
+
+### 🚀 Features
+
+- *(user-deletion)* Implement file locking to prevent concurrent user deletions and enhance error handling
+- *(ui)* Enhance resource operations interface with dynamic selection for cloning and moving resources
+- *(global-search)* Integrate projects and environments into global search functionality
+- *(storage)* Consolidate storage management into a single component with enhanced UI
+- *(deployments)* Add support for Coolify variables in Dockerfile
+
+### 🐛 Bug Fixes
+
+- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow
+- *(ui)* Update docker registry image helper text for clarity
+- *(ui)* Correct HTML structure and improve clarity in Docker cleanup options
+- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow
+- *(api)* Correct OpenAPI schema annotations for array items
+- *(ui)* Improve queued deployment status readability in dark mode
+- *(git)* Handle additional repository URL cases for 'tangled' and improve branch assignment logic
+- *(git)* Enhance error handling for missing branch information during deployment
+- *(git)* Trim whitespace from repository, branch, and commit SHA fields
+- *(deployments)* Order deployments by ID for consistent retrieval
+
+### 💼 Other
+
+- *(storage)* Enhance file storage management with new properties and UI improvements
+- *(core)* Update projects property type and enhance UI styling
+- *(components)* Adjust SVG icon sizes for consistency across applications and services
+- *(components)* Auto-focus first input in modal on open
+- *(styles)* Enhance focus styles for buttons and links
+- *(components)* Enhance close button accessibility in modal
+
+### 🚜 Refactor
+
+- *(global-search)* Change event listener to window level for global search modal
+- *(dashboard)* Remove deployment loading logic and introduce DeploymentsIndicator component for better UI management
+- *(dashboard)* Replace project navigation method with direct link in UI
+- *(global-search)* Improve event handling and cleanup in global search component
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update coolify version to 4.0.0-beta.433 and nightly version to 4.0.0-beta.434 in configuration files
+
+## [4.0.0-beta.432] - 2025-09-29
+
+### 🚀 Features
+
+- *(application)* Implement order-based pattern matching for watch paths with negation support
+- *(github)* Enhance Docker Compose input fields for better user experience
+- *(dev-seeders)* Add PersonalAccessTokenSeeder to create development API tokens
+- *(application)* Add conditional .env file creation for Symfony apps during PHP deployment
+- *(application)* Enhance watch path parsing to support negation syntax
+- *(application)* Add normalizeWatchPaths method to improve watch path handling
+- *(validation)* Enhance ValidGitRepositoryUrl to support additional safe characters and add comprehensive unit tests for various Git repository URL formats
+- *(deployment)* Implement detection for Laravel/Symfony frameworks and configure NIXPACKS PHP environment variables accordingly
+
+### 🐛 Bug Fixes
+
+- *(application)* Restrict GitHub-based application settings to non-public repositories
+- *(traits)* Update saved_outputs handling in ExecuteRemoteCommand to use collection methods for better performance
+- *(application)* Enhance domain handling by replacing both dots and dashes with underscores for HTML form binding
+- *(constants)* Reduce command timeout from 7200 to 3600 seconds for improved performance
+- *(github)* Update repository URL to point to the v4.x branch for development
+- *(models)* Update sorting of scheduled database backups to order by creation date instead of name
+- *(socialite)* Add custom base URL support for GitLab provider in OAuth settings
+- *(configuration-checker)* Update message to clarify redeployment requirement for configuration changes
+- *(application)* Reduce docker stop timeout from 30 to 10 seconds for improved application shutdown efficiency
+- *(application)* Increase docker stop timeout from 10 to 30 seconds for better application shutdown handling
+- *(validation)* Update git:// URL validation to support port numbers and tilde characters in paths
+- Resolve scroll lock issue after closing quick search modal with escape key
+- Prevent quick search modal duplication from keyboard shortcuts
+
+### 🚜 Refactor
+
+- *(tests)* Simplify matchWatchPaths tests and update implementation for better clarity
+- *(deployment)* Improve environment variable handling in ApplicationDeploymentJob
+- *(deployment)* Remove commented-out code and streamline environment variable handling in ApplicationDeploymentJob
+- *(application)* Improve handling of docker compose domains by normalizing keys and ensuring valid JSON structure
+- *(forms)* Update wire:model bindings to use 'blur' instead of 'blur-sm' for input fields across multiple views
+
+### 📚 Documentation
+
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(application)* Remove debugging statement from loadComposeFile method
+- *(workflows)* Update Claude GitHub Action configuration to support new event types and improve permissions
+
+## [4.0.0-beta.431] - 2025-09-24
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.430] - 2025-09-24
+
+### 🚀 Features
+
+- *(add-watch-paths-for-services)* Show watch paths field for docker compose applications
+
+### 🐛 Bug Fixes
+
+- *(PreviewCompose)* Adds port to preview urls
+- *(deployment-job)* Enhance build time variable analysis
+- *(docker)* Adjust openssh-client installation in Dockerfile to avoid version bug
+- *(docker)* Streamline openssh-client installation in Dockerfile
+- *(team)* Normalize email case in invite link generation
+- *(README)* Update Juxtdigital description to reflect current services
+- *(environment-variable-warning)* Enhance warning logic to check for problematic variable values
+- *(install)* Ensure proper quoting of environment file paths to prevent issues with spaces
+- *(security)* Implement authorization checks for terminal access management
+- *(ui)* Improve mobile sidebar close behavior
+
+### 🚜 Refactor
+
+- *(installer)* Improve install script
+- *(upgrade)* Improve upgrade script
+- *(installer, upgrade)* Enhance environment variable management
+- *(upgrade)* Enhance logging and quoting in upgrade scripts
+- *(upgrade)* Replace warning div with a callout component for better UI consistency
+- *(ui)* Replace warning and error divs with callout components for improved consistency and readability
+- *(ui)* Improve styling and consistency in environment variable warning and docker cleanup components
+- *(security)* Streamline update check functionality and improve UI button interactions in patches view
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Increment coolify version numbers to 4.0.0-beta.431 and 4.0.0-beta.432 in configuration files
+- *(versions)* Update coolify version numbers to 4.0.0-beta.432 and 4.0.0-beta.433 in configuration files
+- Remove unused files
+- Adjust wording
+- *(workflow)* Update pull request trigger to pull_request_target and refine permissions for enhanced security
+
+## [4.0.0-beta.429] - 2025-09-23
+
+### 🚀 Features
+
+- *(environment)* Replace is_buildtime_only with is_runtime and is_buildtime flags for environment variables, updating related logic and views
+- *(deployment)* Handle buildtime and runtime variables during deployment
+- *(search)* Implement global search functionality with caching and modal interface
+- *(search)* Enable query logging for global search caching
+- *(environment)* Add dynamic checkbox options for environment variable settings based on user permissions and variable types
+- *(redaction)* Implement sensitive information redaction in logs and commands
+- Improve detection of special network modes
+- *(api)* Add endpoint to update backup configuration by UUID and backup ID; modify response to include backup id
+- *(databases)* Enhance backup management API with new endpoints and improved data handling
+- *(github)* Add GitHub app management endpoints
+- *(github)* Add update and delete endpoints for GitHub apps
+- *(databases)* Enhance backup update and deletion logic with validation
+- *(environment-variables)* Implement environment variable analysis for build-time issues
+- *(databases)* Implement unique UUID generation for backup execution
+- *(cloud-check)* Enhance subscription reporting in CloudCheckSubscription command
+- *(cloud-check)* Enhance CloudCheckSubscription command with fix options
+- *(stripe)* Enhance subscription handling and verification process
+- *(private-key-refresh)* Add refresh dispatch on private key update and connection check
+- *(comments)* Add automated comments for labeled pull requests to guide documentation updates
+- *(comments)* Ping PR author
+
+### 🐛 Bug Fixes
+
+- *(docker)* Enhance container status aggregation to include restarting and exited states
+- *(environment)* Correct grammatical errors in helper text for environment variable sorting checkbox
+- *(ui)* Change order and fix ui on small screens
+- Order for git deploy types
+- *(deployment)* Enhance Dockerfile modification for build-time variables and secrets during deployment in case of docker compose buildpack
+- Hide sensitive email change fields in team member responses
+- *(domains)* Trim whitespace from domains before validation
+- *(databases)* Update backup retrieval logic to include team context
+- *(environment-variables)* Update affected services in environment variable analysis
+- *(team)* Clear stripe_subscription_id on subscription end
+- *(github)* Update authentication method for GitHub app operations
+- *(databases)* Restrict database updates to allowed fields only
+- *(cache)* Add Model import to ClearsGlobalSearchCache trait for improved functionality
+- *(environment-variables)* Correct method call syntax in analyzeBuildVariable function
+- *(clears-global-search-cache)* Refine team retrieval logic in getTeamIdForCache method
+- *(subscription-job)* Enhance retry logic for VerifyStripeSubscriptionStatusJob
+- *(environment-variable)* Update checkbox visibility and helper text for build and runtime options
+- *(deployment-job)* Escape single quotes in build arguments for Docker Compose command
+
+### 🚜 Refactor
+
+- *(environment)* Conditionally render Docker Build Secrets checkbox based on build pack type
+- *(search)* Optimize cache clearing logic to only trigger on searchable field changes
+- *(environment)* Streamline rendering of Docker Build Secrets checkbox and adjust layout for environment variable settings
+- *(proxy)* Streamline proxy configuration form layout and improve button placements
+- *(remoteProcess)* Remove redundant file transfer functions for improved clarity
+- *(github)* Enhance API request handling and validation
+- *(databases)* Remove deprecated backup parameters from API documentation
+- *(databases)* Streamline backup queries to use team context
+- *(databases)* Update backup queries to use team-specific method
+- *(server)* Update dispatch messages and streamline data synchronization
+- *(cache)* Update team retrieval method in ClearsGlobalSearchCache trait
+- *(database-backup)* Move unique UUID generation for backup execution to database loop
+- *(cloud-commands)* Consolidate and enhance subscription management commands
+- *(toast-component)* Improve layout and icon handling in toast notifications
+- *(private-key-update)* Implement transaction for private key association and connection validation
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+- *(claude)* Update testing guidelines and add note on Application::team relationship
+
+### 🎨 Styling
+
+- *(environment-variable)* Adjust SVG icon margin for improved layout in locked state
+- *(proxy)* Adjust padding in proxy configuration form for better visual alignment
+
+### ⚙️ Miscellaneous Tasks
+
+- Change order of runtime and buildtime
+- *(docker-compose)* Update soketi image version to 1.0.10 in production and Windows configurations
+- *(versions)* Update coolify version numbers to 4.0.0-beta.430 and 4.0.0-beta.431 in configuration files
+
+## [4.0.0-beta.428] - 2025-09-15
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.427] - 2025-09-15
+
+### 🚀 Features
+
+- Add Ente Photos service template
+- *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic
+- *(ui)* Display current version in settings dropdown and update UI accordingly
+- *(settings)* Add option to restrict PR deployments to repository members and contributors
+- *(command)* Implement SSH command retry logic with exponential backoff and logging for better error handling
+- *(ssh)* Add Sentry tracking for SSH retry events to enhance error monitoring
+- *(exceptions)* Introduce NonReportableException to handle known errors and update Handler for selective reporting
+- *(sudo-helper)* Add helper functions for command parsing and ownership management with sudo
+- *(dev-command)* Dispatch CheckHelperImageJob during instance initialization to enhance setup process
+- *(ssh-multiplexing)* Enhance multiplexed connection management with health checks and metadata caching
+- *(ssh-multiplexing)* Add connection age metadata handling to improve multiplexed connection management
+- *(database-backup)* Enhance error handling and output management in DatabaseBackupJob
+- *(application)* Display parsing version in development mode and clean up domain conflict modal markup
+- *(deployment)* Add SERVICE_NAME variables for service discovery
+- *(storages)* Add method to retrieve the first storage ID for improved stability in storage display
+- *(environment)* Add 'is_literal' attribute to environment variable for enhanced configuration options
+- *(pre-commit)* Automate generation of service templates and OpenAPI documentation during pre-commit hook
+- *(execute-container)* Enhance container command form with auto-connect feature for single container scenarios
+- *(environment)* Introduce 'is_buildtime_only' attribute to environment variables for improved build-time configuration
+- *(templates)* Add n8n service with PostgreSQL and worker support for enhanced workflow automation
+- *(user-management)* Implement user deletion command with phased resource and subscription cancellation, including dry run option
+- *(sentinel)* Add support for custom Docker images in StartSentinel and related methods
+- *(sentinel)* Add slide-over for viewing Sentinel logs and custom Docker image input for development
+- *(executions)* Add 'Load All' button to view all logs and implement loadAllLogs method for complete log retrieval
+- *(auth)* Enhance user login flow to handle team invitations, attaching users to invited teams upon first login and maintaining personal team logic for regular logins
+- *(laravel-boost)* Add Laravel Boost guidelines and MCP server configuration to enhance development experience
+- *(deployment)* Enhance deployment status reporting with detailed information on active deployments and team members
+- *(deployment)* Implement cancellation checks during deployment process to enhance user control and prevent unnecessary execution
+- *(deployment)* Introduce 'use_build_secrets' setting for enhanced security during Docker builds and update related logic in deployment process
+
+### 🐛 Bug Fixes
+
+- *(ui)* Transactional email settings link on members page (#6491)
+- *(api)* Add custom labels generation for applications with readonly container label setting enabled
+- *(ui)* Add cursor pointer to upgrade button for better user interaction
+- *(templates)* Update SECRET_KEY environment variable in getoutline.yaml to use SERVICE_HEX_32_OUTLINE
+- *(command)* Enhance database deletion command to support multiple database types
+- *(command)* Enhance cleanup process for stuck application previews by adding force delete for trashed records
+- *(user)* Ensure email attributes are stored in lowercase for consistency and prevent case-related issues
+- *(webhook)* Replace delete with forceDelete for application previews to ensure immediate removal
+- *(ssh)* Introduce SshRetryHandler and SshRetryable trait for enhanced SSH command retry logic with exponential backoff and error handling
+- Appwrite template - 500 errors, missing env vars etc.
+- *(LocalFileVolume)* Add missing directory creation command for workdir in saveStorageOnServer method
+- *(ScheduledTaskJob)* Replace generic Exception with NonReportableException for better error handling
+- *(web-routes)* Enhance backup response messages to clarify local and S3 availability
+- *(proxy)* Replace CheckConfiguration with GetProxyConfiguration and SaveConfiguration with SaveProxyConfiguration for improved clarity and consistency in proxy management
+- *(private-key)* Implement transaction handling and error verification for private key storage operations
+- *(deployment)* Add COOLIFY_* environment variables to Nixpacks build context for enhanced deployment configuration
+- *(application)* Add functionality to stop and remove Docker containers on server
+- *(templates)* Update 'compose' configuration for Appwrite service to enhance compatibility and streamline deployment
+- *(security)* Update contact email for reporting vulnerabilities to enhance privacy
+- *(feedback)* Update feedback email address to improve communication with users
+- *(security)* Update contact email for vulnerability reports to improve security communication
+- *(navbar)* Restrict subscription link visibility to admin users in cloud environment
+- *(docker)* Enhance container status aggregation for multi-container applications, including exclusion handling based on docker-compose configuration
+- *(application)* Improve watch paths handling by trimming and filtering empty paths to prevent unnecessary triggers
+- *(server)* Update server usability check to reflect actual Docker availability status
+- *(server)* Add build server check to disable Sentinel and update related logic
+- *(server)* Implement refreshServer method and update navbar event listener for improved server state management
+- *(deployment)* Prevent removal of running containers for pull request deployments in case of failure
+- *(docker)* Redirect stderr to stdout for container log retrieval to capture error messages
+- *(clone)* Update destinations method call to ensure correct retrieval of selected destination
+
+### 🚜 Refactor
+
+- *(jobs)* Pull github changelogs from cdn instead of github
+- *(command)* Streamline database deletion process to handle multiple database types and improve user experience
+- *(command)* Improve database collection logic for deletion command by using unique identifiers and enhancing user experience
+- *(command)* Remove InitChangelog command as it is no longer needed
+- *(command)* Streamline Init command by removing unnecessary options and enhancing error handling for various operations
+- *(webhook)* Replace direct forceDelete calls with DeleteResourceJob dispatch for application previews
+- *(command)* Replace forceDelete calls with DeleteResourceJob dispatch for all stuck resources in cleanup process
+- *(command)* Simplify SSH command retry logic by removing unnecessary logging and improving delay calculation
+- *(ssh)* Enhance error handling in SSH command execution and improve connection validation logging
+- *(backlog)* Remove outdated guidelines and project manager agent files to streamline task management documentation
+- *(error-handling)* Remove ray debugging statements from CheckUpdates and shared helper functions to clean up error reporting
+- *(file-transfer)* Replace base64 encoding with direct file transfer method across multiple database actions for improved clarity and efficiency
+- *(remoteProcess)* Remove debugging statement from transfer_file_to_server function to clean up code
+- *(dns-validation)* Rename DNS validation functions for consistency and clarity, and remove unused code
+- *(file-transfer)* Replace base64 encoding with direct file transfer method in various components for improved clarity and efficiency
+- *(private-key)* Remove debugging statement from storeInFileSystem method for cleaner code
+- *(github-webhook)* Restructure application processing by grouping applications by server for improved deployment handling
+- *(deployment)* Enhance queuing logic to support concurrent deployments by including pull request ID in checks
+- *(remoteProcess)* Remove debugging statement from transfer_file_to_container function for cleaner code
+- *(deployment)* Streamline next deployment queuing logic by repositioning queue_next_deployment call
+- *(deployment)* Add validation for pull request existence in deployment process to enhance error handling
+- *(database)* Remove volume_configuration_dir and streamline configuration directory usage in MongoDB and PostgreSQL handlers
+- *(application-source)* Improve layout and accessibility of Git repository links in the application source view
+- *(models)* Remove 'is_readonly' attribute from multiple database models for consistency
+- *(webhook)* Remove Webhook model and related logic; add migrations to drop webhooks and kubernetes tables
+- *(clone)* Consolidate application cloning logic into a dedicated function for improved maintainability and readability
+- *(clone)* Integrate preview cloning logic directly into application cloning function for improved clarity and maintainability
+- *(application)* Enhance environment variable retrieval in configuration change check for improved accuracy
+- *(clone)* Enhance application cloning by separating production and preview environment variable handling
+- *(deployment)* Add environment variable copying logic to Docker build commands for pull requests
+- *(environment)* Standardize service name formatting by replacing '-' and '.' with '_' in environment variable keys
+- *(deployment)* Update environment file handling in Docker commands to use '/artifacts/' path and streamline variable management
+- *(openapi)* Remove 'is_build_time' attribute from environment variable definitions to streamline configuration
+- *(environment)* Remove 'is_build_time' attribute from environment variable handling across the application to simplify configuration
+- *(environment)* Streamline environment variable handling by replacing sorting methods with direct property access and enhancing query ordering for improved performance
+- *(stripe-jobs)* Comment out internal notification calls and add subscription status verification before sending failure notifications
+- *(deployment)* Streamline environment variable handling for dockercompose and improve sorting of runtime variables
+- *(remoteProcess)* Remove command log comments for file transfers to simplify code
+- *(remoteProcess)* Remove file transfer handling from remote_process and instant_remote_process functions to simplify code
+- *(deployment)* Update environment file paths in docker compose commands to use working directory for improved consistency
+- *(server)* Remove debugging ray call from validateConnection method for cleaner code
+- *(deployment)* Conditionally cleanup build secrets based on Docker BuildKit support and remove redundant calls for improved efficiency
+- *(deployment)* Remove redundant environment variable documentation from Dockerfile comments to streamline the deployment process
+- *(deployment)* Streamline Docker BuildKit detection and environment variable handling for enhanced security during application deployment
+- *(deployment)* Optimize BuildKit capabilities detection and remove unnecessary comments for cleaner deployment logic
+- *(deployment)* Rename method for modifying Dockerfile to improve clarity and streamline build secrets integration
+
+### 📚 Documentation
+
+- Update changelog
+- *(testing-patterns)* Add important note to always run tests inside the `coolify` container for clarity
+
+### ⚙️ Miscellaneous Tasks
+
+- Update coolify version to 4.0.0-beta.427 and nightly version to 4.0.0-beta.428
+- Use main value then fallback to service_ values
+- Remove webhooks table cleanup
+- *(cleanup)* Remove deprecated ServerCheck and related job classes to streamline codebase
+- *(versions)* Update sentinel version from 0.0.15 to 0.0.16 in versions.json files
+- *(constants)* Update realtime_version from 1.0.10 to 1.0.11
+- *(versions)* Increment coolify version to 4.0.0-beta.428 and update realtime_version to 1.0.10
+- *(docker)* Add a blank line for improved readability in Dockerfile
+- *(versions)* Bump coolify version to 4.0.0-beta.429 and nightly version to 4.0.0-beta.430
+
+## [4.0.0-beta.426] - 2025-08-28
+
+### 🚜 Refactor
+
+- *(policy)* Simplify ServiceDatabasePolicy methods to always return true and add manageBackups method
+
+### 📚 Documentation
+
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- Update coolify version to 4.0.0-beta.426 and nightly version to 4.0.0-beta.427
+
+## [4.0.0-beta.425] - 2025-08-28
+
+### 🚀 Features
+
+- *(domains)* Implement domain conflict detection and user confirmation modal across application components
+- *(domains)* Add force_domain_override option and enhance domain conflict detection responses
+
+### 🐛 Bug Fixes
+
+- *(previews)* Simplify FQDN generation logic by removing unnecessary empty check
+- *(templates)* Update Matrix service compose configuration for improved compatibility and clarity
+
+### 🚜 Refactor
+
+- *(urls)* Replace generateFqdn with generateUrl for consistent URL generation across applications
+- *(domains)* Rename check_domain_usage to checkDomainUsage and update references across the application
+- *(auth)* Simplify access control logic in CanAccessTerminal and ServerPolicy by allowing all users to perform actions
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- Update coolify version to 4.0.0-beta.425 and nightly version to 4.0.0-beta.426
+
+## [4.0.0-beta.424] - 2025-08-27
+
+### 💼 Other
+
+- Allow deploy from container image hash
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+## [4.0.0-beta.423] - 2025-08-27
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.422] - 2025-08-27
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.421] - 2025-08-26
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.420.9] - 2025-08-26
+
+### 🚀 Features
+
+- *(policies)* Add EnvironmentVariablePolicy for managing environment variables ( it was missing )
+
+### 🐛 Bug Fixes
+
+- *(backups)* S3 backup upload is failing
+- *(backups)* Rollback helper update for now
+- *(parsers)* Replace hyphens with underscores in service names for consistency. this allows to properly parse custom domains in docker compose based applications
+- *(parsers)* Implement parseDockerVolumeString function to handle various Docker volume formats and modes, including environment variables and Windows paths. Add unit tests for comprehensive coverage.
+- *(git)* Submodule update command uses an unsupported option (#6454)
+- *(service)* Swap URL for FQDN on matrix template (#6466)
+- *(parsers)* Enhance volume string handling by preserving mode in application and service parsers. Update related unit tests for validation.
+- *(docker)* Update parser version in FQDN generation for service-specific URLs
+- *(parsers)* Do not modify service names, only for getting fqdns and related envs
+- *(compose)* Temporary allow to edit volumes in apps (compose based) and services
+
+### 🚜 Refactor
+
+- *(git)* Improve submodule cloning
+- *(parsers)* Remove unnecessary hyphen-to-underscore replacement for service names in serviceParser function
+
+### 📚 Documentation
+
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(core)* Update version
+- *(core)* Update version
+- *(versions)* Update coolify version to 4.0.0-beta.421 and nightly version to 4.0.0-beta.422
+- Update version
+- Update development node version
+- Update coolify version to 4.0.0-beta.423 and nightly version to 4.0.0-beta.424
+- Update coolify version to 4.0.0-beta.424 and nightly version to 4.0.0-beta.425
+
+## [4.0.0-beta.420.8] - 2025-08-26
+
+### 🚜 Refactor
+
+- *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.420.7] - 2025-08-26
+
+### 🚀 Features
+
+- *(service)* Add TriliumNext service (#5970)
+- *(service)* Add Matrix service (#6029)
+- *(service)* Add GitHub Action runner service (#6209)
+- *(terminal)* Dispatch focus event for terminal after connection and enhance focus handling in JavaScript
+- *(lang)* Add Polish language & improve forgot_password translation (#6306)
+- *(service)* Update Authentik template (#6264)
+- *(service)* Add sequin template (#6105)
+- *(service)* Add pi-hole template (#6020)
+- *(services)* Add Chroma service (#6201)
+- *(service)* Add OpenPanel template (#5310)
+- *(service)* Add librechat template (#5654)
+- *(service)* Add Homebox service (#6116)
+- *(service)* Add pterodactyl & wings services (#5537)
+- *(service)* Add Bluesky PDS template (#6302)
+- *(input)* Add autofocus attribute to input component for improved accessibility
+- *(core)* Finally fqdn is fqdn and url is url. haha
+- *(user)* Add changelog read tracking and unread count method
+- *(templates)* Add new service templates and update existing compose files for various applications
+- *(changelog)* Implement automated changelog fetching from GitHub and enhance changelog read tracking
+- *(drizzle-gateway)* Add new drizzle-gateway service with configuration and logo
+- *(drizzle-gateway)* Enhance service configuration by adding Master Password field and updating compose file path
+- *(templates)* Add new service templates for Homebox, LibreChat, Pterodactyl, and Wings with corresponding configurations and logos
+- *(templates)* Add Bluesky PDS service template and update compose file with new environment variable
+- *(readme)* Add CubePath as a big sponsor and include new small sponsors with logos
+- *(api)* Add create_environment endpoint to ProjectController for environment creation in projects
+- *(api)* Add endpoints for managing environments in projects, including listing, creating, and deleting environments
+- *(backup)* Add disable local backup option and related logic for S3 uploads
+- *(dev patches)* Add functionality to send test email with patch data in development mode
+- *(templates)* Added category per service
+- *(email)* Implement email change request and verification process
+- Generate category for services
+- *(service)* Add elasticsearch template (#6300)
+- *(sanitization)* Integrate DOMPurify for HTML sanitization across components
+- *(cleanup)* Add command for sanitizing name fields across models
+- *(sanitization)* Enhance HTML sanitization with improved DOMPurify configuration
+- *(validation)* Centralize validation patterns for names and descriptions
+- *(git-settings)* Add support for shallow cloning in application settings
+- *(auth)* Implement authorization checks for server updates across multiple components
+- *(auth)* Implement authorization for PrivateKey management
+- *(auth)* Implement authorization for Docker and server management
+- *(validation)* Add custom validation rules for Git repository URLs and branches
+- *(security)* Add authorization checks for package updates in Livewire components
+- *(auth)* Implement authorization checks for application management
+- *(auth)* Enhance API error handling for authorization exceptions
+- *(auth)* Add comprehensive authorization checks for all kind of resource creations
+- *(auth)* Implement authorization checks for database management
+- *(auth)* Refine authorization checks for S3 storage and service management
+- *(auth)* Implement comprehensive authorization checks across API controllers
+- *(auth)* Introduce resource creation authorization middleware and policies for enhanced access control
+- *(auth)* Add middleware for resource creation authorization
+- *(auth)* Enhance authorization checks in Livewire components for resource management
+- *(validation)* Add ValidIpOrCidr rule for validating IP addresses and CIDR notations; update API access settings UI and add comprehensive tests
+- *(docs)* Update architecture and development guidelines; enhance form components with built-in authorization system and improve routing documentation
+- *(docs)* Expand authorization documentation for custom Alpine.js components; include manual protection patterns and implementation guidelines
+- *(sentinel)* Implement SentinelRestarted event and update Livewire components to handle server restart notifications
+- *(api)* Enhance IP access control in middleware and settings; support CIDR notation and special case for 0.0.0.0 to allow all IPs
+- *(acl)* Change views/backend code to able to use proper ACL's later on. Currently it is not enabled.
+- *(docs)* Add Backlog.md guidelines and project manager backlog agent; enhance CLAUDE.md with new links for task management
+- *(docs)* Add tasks for implementing Docker build caching and optimizing staging builds; include detailed acceptance criteria and implementation plans
+- *(docker)* Implement Docker cleanup processing in ScheduledJobManager; refactor server task scheduling to streamline cleanup job dispatching
+- *(docs)* Expand Backlog.md guidelines with comprehensive usage instructions, CLI commands, and best practices for task management to enhance project organization and collaboration
+
+### 🐛 Bug Fixes
+
+- *(service)* Triliumnext platform and link
+- *(application)* Update service environment variables when generating domain for Docker Compose
+- *(application)* Add option to suppress toast notifications when loading compose file
+- *(git)* Tracking issue due to case sensitivity
+- *(git)* Tracking issue due to case sensitivity
+- *(git)* Tracking issue due to case sensitivity
+- *(ui)* Delete button width on small screens (#6308)
+- *(service)* Matrix entrypoint
+- *(ui)* Add flex-wrap to prevent overflow on small screens (#6307)
+- *(docker)* Volumes get delete when stopping a service if `Delete Unused Volumes` is activated (#6317)
+- *(docker)* Cleanup always running on deletion
+- *(proxy)* Remove hardcoded port 80/443 checks (#6275)
+- *(service)* Update healthcheck of penpot backend container (#6272)
+- *(api)* Duplicated logs in application endpoint (#6292)
+- *(service)* Documenso signees always pending (#6334)
+- *(api)* Update service upsert to retain name and description values if not set
+- *(database)* Custom postgres configs with SSL (#6352)
+- *(policy)* Update delete method to check for admin status in S3StoragePolicy
+- *(container)* Sort containers alphabetically by name in ExecuteContainerCommand and update filtering in Terminal Index
+- *(application)* Streamline environment variable updates for Docker Compose services and enhance FQDN generation logic
+- *(constants)* Update 'Change Log' to 'Changelog' in settings dropdown
+- *(constants)* Update coolify version to 4.0.0-beta.420.7
+- *(parsers)* Clarify comments and update variable checks for FQDN and URL handling
+- *(terminal)* Update text color for terminal availability message and improve readability
+- *(drizzle-gateway)* Remove healthcheck from drizzle-gateway compose file and update service template
+- *(templates)* Should generate old SERVICE_FQDN service templates as well
+- *(constants)* Update official service template URL to point to the v4.x branch for accuracy
+- *(git)* Use exact refspec in ls-remote to avoid matching similarly named branches (e.g., changeset-release/main). Use refs/heads/ or provider-specific PR refs.
+- *(ApplicationPreview)* Change null check to empty check for fqdn in generate_preview_fqdn method
+- *(email notifications)* Enhance EmailChannel to validate team membership for recipients and handle errors gracefully
+- *(service api)* Separate create and update service functionalities
+- *(templates)* Added a category tag for the docs service filter
+- *(application)* Clear Docker Compose specific data when switching away from dockercompose
+- *(database)* Conditionally set started_at only if the database is running
+- *(ui)* Handle null values in postgres metrics (#6388)
+- Disable env sorting by default
+- *(proxy)* Filter host network from default proxy (#6383)
+- *(modal)* Enhance confirmation text handling
+- *(notification)* Update unread count display and improve HTML rendering
+- *(select)* Remove unnecessary sanitization for logo rendering
+- *(tags)* Update tag display to limit name length and adjust styling
+- *(init)* Improve error handling for deployment and template pulling processes
+- *(settings-dropdown)* Adjust unread count badge size and display logic for better consistency
+- *(sanitization)* Enhance DOMPurify hook to remove Alpine.js directives for improved XSS protection
+- *(servercheck)* Properly check server statuses with and without Sentinel
+- *(errors)* Update error pages to provide navigation options
+- *(github-deploy-key)* Update background color for selected private keys in deployment key selection UI
+- *(auth)* Enhance authorization checks in application management
+
+### 💼 Other
+
+- *(settings-dropdown)* Add icons to buttons for improved UI in settings dropdown
+- *(ui)* Introduce task for simplifying resource operations UI by replacing boxes with dropdown selections to enhance user experience and streamline interactions
+
+### 🚜 Refactor
+
+- *(jobs)* Remove logging for ScheduledJobManager and ServerResourceManager start and completion
+- *(services)* Update validation rules to be optional
+- *(service)* Improve langfuse
+- *(service)* Improve openpanel template
+- *(service)* Improve librechat
+- *(public-git-repository)* Enhance form structure and add autofocus to repository URL input
+- *(public-git-repository)* Remove commented-out code for cleaner template
+- *(templates)* Update service template file handling to use dynamic file name from constants
+- *(parsers)* Streamline domain handling in applicationParser and improve DNS validation logic
+- *(templates)* Replace SERVICE_FQDN variables with SERVICE_URL in compose files for consistency
+- *(links)* Replace inline SVGs with reusable external link component for consistency and improved maintainability
+- *(previews)* Improve layout and add deployment/application logs links for previews
+- *(docker compose)* Remove deprecated newParser function and associated test file to streamline codebase
+- *(shared helpers)* Remove unused parseServiceVolumes function to clean up codebase
+- *(parsers)* Update volume parsing logic to use beforeLast and afterLast for improved accuracy
+- *(validation)* Implement centralized validation patterns across components
+- *(jobs)* Rename job classes to indicate deprecation status
+- Update check frequency logic for cloud and self-hosted environments; streamline server task scheduling and timezone handling
+- *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility
+
+### 📚 Documentation
+
+- *(claude)* Clarify that artisan commands should only be run inside the "coolify" container during development
+- Add AGENTS.md for project guidance and development instructions
+- Update changelog
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(service)* Improve matrix service
+- *(service)* Format runner service
+- *(service)* Improve sequin
+- *(service)* Add `NOT_SECURED` env to Postiz (#6243)
+- *(service)* Improve evolution-api environment variables (#6283)
+- *(service)* Update Langfuse template to v3 (#6301)
+- *(core)* Remove unused argument
+- *(deletion)* Rename isDeleteOperation to deleteConnectedNetworks
+- *(docker)* Remove unused arguments on StopService
+- *(service)* Homebox formatting
+- Clarify usage of custom redis configuration (#6321)
+- *(changelogs)* Add .gitignore for changelogs directory and remove outdated changelog files for May, June, and July 2025
+- *(service)* Change affine images (#6366)
+- Elasticsearch URL, fromatting and add category
+- Update service-templates json files
+- *(docs)* Remove AGENTS.md file; enhance CLAUDE.md with detailed form authorization patterns and service configuration examples
+- *(cleanup)* Remove unused GitLab view files for change, new, and show pages
+- *(workflows)* Add backlog directory to build triggers for production and staging workflows
+- *(config)* Disable auto_commit in backlog configuration to prevent automatic commits
+- *(versions)* Update coolify version to 4.0.0-beta.420.8 and nightly version to 4.0.0-beta.420.9 in versions.json and constants.php
+- *(docker)* Update soketi image version to 1.0.10 in production and Windows configurations
+
+### ◀️ Revert
+
+- *(parser)* Enhance FQDN generation logic for services and applications
+
+## [4.0.0-beta.420.6] - 2025-07-18
+
+### 🚀 Features
+
+- *(service)* Enable password protection for the Wireguard Ul
+- *(queues)* Improve Horizon config to reduce CPU and RAM usage (#6212)
+- *(service)* Add Gowa service (#6164)
+- *(container)* Add updatedSelectedContainer method to connect to non-default containers and update wire:model for improved reactivity
+- *(application)* Implement environment variable updates for Docker Compose applications, including creation, updating, and deletion of SERVICE_FQDN and SERVICE_URL variables
+
+### 🐛 Bug Fixes
+
+- *(installer)* Public IPv4 link does not work
+- *(composer)* Version constraint of prompts
+- *(service)* Budibase secret keys (#6205)
+- *(service)* Wg-easy host should be just the FQDN
+- *(ui)* Search box overlaps the sidebar navigation (#6176)
+- *(webhooks)* Exclude webhook routes from CSRF protection (#6200)
+- *(services)* Update environment variable naming convention to use underscores instead of dashes for SERVICE_FQDN and SERVICE_URL
+
+### 🚜 Refactor
+
+- *(service)* Improve gowa
+- *(previews)* Streamline preview domain generation logic in ApplicationDeploymentJob for improved clarity and maintainability
+- *(services)* Simplify environment variable updates by using updateOrCreate and add cleanup for removed FQDNs
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(service)* Update Nitropage template (#6181)
+- *(versions)* Update all version
+- *(bump)* Update composer deps
+- *(version)* Bump Coolify version to 4.0.0-beta.420.6
+
+## [4.0.0-beta.420.4] - 2025-07-08
+
+### 🚀 Features
+
+- *(scheduling)* Add command to manually run scheduled database backups and tasks with options for chunking, delays, and dry runs
+- *(scheduling)* Add frequency filter option for manual execution of scheduled jobs
+- *(logging)* Implement scheduled logs command and enhance backup/task scheduling with cron checks
+- *(logging)* Add frequency filters for scheduled logs command to support hourly, daily, weekly, and monthly job views
+- *(scheduling)* Introduce ScheduledJobManager and ServerResourceManager for enhanced job scheduling and resource management
+- *(previews)* Implement soft delete and cleanup for ApplicationPreview, enhancing resource management in DeleteResourceJob
+
+### 🐛 Bug Fixes
+
+- *(service)* Update Postiz compose configuration for improved server availability
+- *(install.sh)* Use IPV4_PUBLIC_IP variable in output instead of repeated curl
+- *(env)* Generate literal env variables better
+- *(deployment)* Update x-data initialization in deployment view for improved functionality
+- *(deployment)* Enhance COOLIFY_URL and COOLIFY_FQDN variable generation for better compatibility
+- *(deployment)* Improve docker-compose domain handling and environment variable generation
+- *(deployment)* Refactor domain parsing and environment variable generation using Spatie URL library
+- *(deployment)* Update COOLIFY_URL and COOLIFY_FQDN generation to use Spatie URL library for improved accuracy
+- *(scheduling)* Change redis cleanup command frequency from hourly to weekly for better resource management
+- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.5 and 4.0.0-beta.420.6
+- *(database)* Ensure internal port defaults correctly for unsupported database types in StartDatabaseProxy
+- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.6 and 4.0.0-beta.420.7
+- *(scheduling)* Remove unnecessary padding from scheduled task form layout for improved UI consistency
+- *(horizon)* Update queue configuration to use environment variable for dynamic queue management
+- *(horizon)* Add silenced jobs
+- *(application)* Sanitize service names for HTML form binding and ensure original names are stored in docker compose domains
+- *(previews)* Adjust padding for rate limit message in application previews
+- *(previews)* Order application previews by pull request ID in descending order
+- *(previews)* Add unique wire keys for preview containers and services based on pull request ID
+- *(previews)* Enhance domain generation logic for application previews, ensuring unique domains are created when none are set
+- *(previews)* Refine preview domain generation for Docker Compose applications, ensuring correct method usage based on build pack type
+- *(ui)* Typo on proxy request handler tooltip (#6192)
+- *(backups)* Large database backups are not working (#6217)
+- *(backups)* Error message if there is no exception
+
+### 🚜 Refactor
+
+- *(previews)* Streamline preview URL generation by utilizing application method
+- *(application)* Adjust layout and spacing in general application view for improved UI
+- *(postgresql)* Improve layout and spacing in SSL and Proxy configuration sections for better UI consistency
+- *(scheduling)* Replace deprecated job checks with ScheduledJobManager and ServerResourceManager for improved scheduling efficiency
+- *(previews)* Move preview domain generation logic to ApplicationPreview model for better encapsulation and consistency across webhook handlers
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+## [4.0.0-beta.420.3] - 2025-07-03
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.420.2] - 2025-07-03
+
+### 🚀 Features
+
+- *(template)* Added excalidraw (#6095)
+- *(template)* Add excalidraw service configuration with documentation and tags
+
+### 🐛 Bug Fixes
+
+- *(terminal)* Ensure shell execution only uses valid shell if available in terminal command
+- *(ui)* Improve destination selection description for clarity in resource segregation
+- *(jobs)* Update middleware to use expireAfter for WithoutOverlapping in multiple job classes
+- Removing eager loading (#6071)
+- *(template)* Adjust health check interval and retries for excalidraw service
+- *(ui)* Env variable settings wrong order
+- *(service)* Ensure configuration changes are properly tracked and dispatched
+
+### 🚜 Refactor
+
+- *(ui)* Enhance project cloning interface with improved table layout for server and resource selection
+- *(terminal)* Simplify command construction for SSH execution
+- *(settings)* Streamline instance admin checks and initialization of settings in Livewire components
+- *(policy)* Optimize team membership checks in S3StoragePolicy
+- *(popup)* Improve styling and structure of the small popup component
+- *(shared)* Enhance FQDN generation logic for services in newParser function
+- *(redis)* Enhance CleanupRedis command with dry-run option and improved key deletion logic
+- *(init)* Standardize method naming conventions and improve command structure in Init.php
+- *(shared)* Improve error handling in getTopLevelNetworks function to return network name on invalid docker-compose.yml
+- *(database)* Improve error handling for unsupported database types in StartDatabaseProxy
+
+### 📚 Documentation
+
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Bump coolify and nightly versions to 4.0.0-beta.420.3 and 4.0.0-beta.420.4 respectively
+- *(versions)* Update coolify and nightly versions to 4.0.0-beta.420.4 and 4.0.0-beta.420.5 respectively
+
+## [4.0.0-beta.420.1] - 2025-06-26
+
+### 🐛 Bug Fixes
+
+- *(server)* Prepend 'mux_' to UUID in muxFilename method for consistent naming
+- *(ui)* Enhance terminal access messaging to clarify server functionality and terminal status
+- *(database)* Proxy ssl port if ssl is enabled
+
+### 🚜 Refactor
+
+- *(ui)* Separate views for instance settings to separate paths to make it cleaner
+- *(ui)* Remove unnecessary step3ButtonText attributes from modal confirmation components for cleaner code
+
+### 📚 Documentation
+
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update Coolify versions to 4.0.0-beta.420.2 and 4.0.0-beta.420.3 in multiple files
+
+## [4.0.0-beta.420] - 2025-06-26
+
+### 🚀 Features
+
+- *(service)* Add Miniflux service (#5843)
+- *(service)* Add Pingvin Share service (#5969)
+- *(auth)* Add Discord OAuth Provider (#5552)
+- *(auth)* Add Clerk OAuth Provider (#5553)
+- *(auth)* Add Zitadel OAuth Provider (#5490)
+- *(core)* Set custom API rate limit (#5984)
+- *(service)* Enhance service status handling and UI updates
+- *(cleanup)* Add functionality to delete teams with no members or servers in CleanupStuckedResources command
+- *(ui)* Add heart icon and enhance popup messaging for sponsorship support
+- *(settings)* Add sponsorship popup toggle and corresponding database migration
+- *(migrations)* Add optimized indexes to activity_log for improved query performance
+
+### 🐛 Bug Fixes
+
+- *(service)* Audiobookshelf healthcheck command (#5993)
+- *(service)* Downgrade Evolution API phone version (#5977)
+- *(service)* Pingvinshare-with-clamav
+- *(ssh)* Scp requires square brackets for ipv6 (#6001)
+- *(github)* Changing github app breaks the webhook. it does not anymore
+- *(parser)* Improve FQDN generation and update environment variable handling
+- *(ui)* Enhance status refresh buttons with loading indicators
+- *(ui)* Update confirmation button text for stopping database and service
+- *(routes)* Update middleware for deploy route to use 'api.ability:deploy'
+- *(ui)* Refine API token creation form and update helper text for clarity
+- *(ui)* Adjust layout of deployments section for improved alignment
+- *(ui)* Adjust project grid layout and refine server border styling for better visibility
+- *(ui)* Update border styling for consistency across components and enhance loading indicators
+- *(ui)* Add padding to section headers in settings views for improved spacing
+- *(ui)* Reduce gap between input fields in email settings for better alignment
+- *(docker)* Conditionally enable gzip compression in Traefik labels based on configuration
+- *(parser)* Enable gzip compression conditionally for Pocketbase images and streamline service creation logic
+- *(ui)* Update padding for trademarks policy and enhance spacing in advanced settings section
+- *(ui)* Correct closing tag for sponsorship link in layout popups
+- *(ui)* Refine wording in sponsorship donation prompt in layout popups
+- *(ui)* Update navbar icon color and enhance popup layout for sponsorship support
+- *(ui)* Add target="_blank" to sponsorship links in layout popups for improved user experience
+- *(models)* Refine comment wording in User model for clarity on user deletion criteria
+- *(models)* Improve user deletion logic in User model to handle team member roles and prevent deletion if user is alone in root team
+- *(ui)* Update wording in sponsorship prompt for clarity and engagement
+- *(shared)* Refactor gzip handling for Pocketbase in newParser function for improved clarity
+
+### 🚜 Refactor
+
+- *(service)* Update Hoarder to their new name karakeep (#5964)
+- *(service)* Karakeep naming and formatting
+- *(service)* Improve miniflux
+- *(core)* Rename API rate limit ENV
+- *(ui)* Simplify container selection form in execute-container-command view
+- *(email)* Streamline SMTP and resend settings logic for improved clarity
+- *(invitation)* Rename methods for consistency and enhance invitation deletion logic
+- *(user)* Streamline user deletion process and enhance team management logic
+
+### 📚 Documentation
+
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(service)* Update Evolution API image to the official one (#6031)
+- *(versions)* Bump coolify versions to v4.0.0-beta.420 and v4.0.0-beta.421
+- *(dependencies)* Update composer dependencies to latest versions including resend-laravel to ^0.19.0 and aws-sdk-php to 3.347.0
+- *(versions)* Update Coolify version to 4.0.0-beta.420.1 and add new services (karakeep, miniflux, pingvinshare) to service templates
+
+## [4.0.0-beta.419] - 2025-06-17
+
+### 🚀 Features
+
+- *(core)* Add 'postmarketos' to supported OS list
+- *(service)* Add memos service template (#5032)
+- *(ui)* Upgrade to Tailwind v4 (#5710)
+- *(service)* Add Navidrome service template (#5022)
+- *(service)* Add Passbolt service (#5769)
+- *(service)* Add Vert service (#5663)
+- *(service)* Add Ryot service (#5232)
+- *(service)* Add Marimo service (#5559)
+- *(service)* Add Diun service (#5113)
+- *(service)* Add Observium service (#5613)
+- *(service)* Add Leantime service (#5792)
+- *(service)* Add Limesurvey service (#5751)
+- *(service)* Add Paymenter service (#5809)
+- *(service)* Add CodiMD service (#4867)
+- *(modal)* Add dispatchAction property to confirmation modal
+- *(security)* Implement server patching functionality
+- *(service)* Add Typesense service (#5643)
+- *(service)* Add Yamtrack service (#5845)
+- *(service)* Add PG Back Web service (#5079)
+- *(service)* Update Maybe service and adjust it for the new release (#5795)
+- *(oauth)* Set redirect uri as optional and add default value (#5760)
+- *(service)* Add apache superset service (#4891)
+- *(service)* Add One Time Secret service (#5650)
+- *(service)* Add Seafile service (#5817)
+- *(service)* Add Netbird-Client service (#5873)
+- *(service)* Add OrangeHRM and Grist services (#5212)
+- *(rules)* Add comprehensive documentation for Coolify architecture and development practices for AI tools, especially for cursor
+- *(server)* Implement server patch check notifications
+- *(api)* Add latest query param to Service restart API (#5881)
+- *(api)* Add connect_to_docker_network setting to App creation API (#5691)
+- *(routes)* Restrict backup download access to team admins and owners
+- *(destination)* Update confirmation modal text and add persistent storage warning for server deployment
+- *(terminal-access)* Implement terminal access control for servers and containers, including UI updates and backend logic
+- *(ca-certificate)* Add CA certificate management functionality with UI integration and routing
+- *(security-patches)* Add update check initialization and enhance notification messaging in UI
+- *(previews)* Add force deploy without cache functionality and update deploy method to accept force rebuild parameter
+- *(security-patterns)* Expand sensitive patterns list to include additional security-related variables
+- *(database-backup)* Add MongoDB credential extraction and backup handling to DatabaseBackupJob
+- *(activity-monitor)* Implement auto-scrolling functionality and dynamic content observation for improved user experience
+- *(utf8-handling)* Implement UTF-8 sanitization for command outputs and enhance error handling in logs processing
+- *(navbar)* Add Traefik dashboard availability check and server IP handling; refactor dynamic configurations loading
+- *(proxy-dashboard)* Implement ProxyDashboardCacheService to manage Traefik dashboard cache; clear cache on configuration changes and proxy actions
+- *(terminal-connection)* Enhance terminal connection handling with auto-connect feature and improved status messaging
+- *(terminal)* Implement resize handling with ResizeObserver for improved terminal responsiveness
+- *(migration)* Add is_sentinel_enabled column to server_settings with default true
+- *(seeder)* Dispatch StartProxy action for each server in ProductionSeeder
+- *(seeder)* Add CheckAndStartSentinelJob dispatch for each server in ProductionSeeder
+- *(seeder)* Conditionally dispatch StartProxy action based on proxy check result
+- *(service)* Update Changedetection template (#5937)
+
+### 🐛 Bug Fixes
+
+- *(constants)* Adding 'fedora-asahi-remix' as a supported OS (#5646)
+- *(authentik)* Update docker-compose configuration for authentik service
+- *(api)* Allow nullable destination_uuid (#5683)
+- *(service)* Fix documenso startup and mail (#5737)
+- *(docker)* Fix production dockerfile
+- *(service)* Navidrome service
+- *(service)* Passbolt
+- *(service)* Add missing ENVs to NTFY service (#5629)
+- *(service)* NTFY is behind a proxy
+- *(service)* Vert logo and ENVs
+- *(service)* Add platform to Observium service
+- *(ActivityMonitor)* Prevent multiple event dispatches during polling
+- *(service)* Convex ENVs and update image versions (#5827)
+- *(service)* Paymenter
+- *(ApplicationDeploymentJob)* Ensure correct COOLIFY_FQDN/COOLIFY_URL values (#4719)
+- *(service)* Snapdrop no matching manifest error (#5849)
+- *(service)* Use the same volume between chatwoot and sidekiq (#5851)
+- *(api)* Validate docker_compose_raw input in ApplicationsController
+- *(api)* Enhance validation for docker_compose_raw in ApplicationsController
+- *(select)* Update PostgreSQL versions and titles in resource selection
+- *(database)* Include DatabaseStatusChanged event in activityMonitor dispatch
+- *(css)* Tailwind v5 things
+- *(service)* Diun ENV for consistency
+- *(service)* Memos service name
+- *(css)* 8+ issue with new tailwind v4
+- *(css)* `bg-coollabs-gradient` not working anymore
+- *(ui)* Add back missing service navbar components
+- *(deploy)* Update resource timestamp handling in deploy_resource method
+- *(patches)* DNF reboot logic is flipped
+- *(deployment)* Correct syntax for else statement in docker compose build command
+- *(shared)* Remove unused relation from queryDatabaseByUuidWithinTeam function
+- *(deployment)* Correct COOLIFY_URL and COOLIFY_FQDN assignments based on parsing version in preview deployments
+- *(docker)* Ensure correct parsing of environment variables by limiting explode to 2 parts
+- *(project)* Update selected environment handling to use environment name instead of UUID
+- *(ui)* Update server status display and improve server addition layout
+- *(service)* Neon WS Proxy service not working on ARM64 (#5887)
+- *(server)* Enhance error handling in server patch check notifications
+- *(PushServerUpdateJob)* Add null checks before updating application and database statuses
+- *(environment-variables)* Update label text for build variable checkboxes to improve clarity
+- *(service-management)* Update service stop and restart messages for improved clarity and formatting
+- *(preview-form)* Update helper text formatting in preview URL template input for better readability
+- *(application-management)* Improve stop messages for application, database, and service to enhance clarity and formatting
+- *(application-configuration)* Prevent access to preview deployments for deploy_key applications and update menu visibility accordingly
+- *(select-component)* Handle exceptions during parameter retrieval and environment selection in the mount method
+- *(previews)* Escape container names in stopContainers method to prevent shell injection vulnerabilities
+- *(docker)* Add protection against empty container queries in GetContainersStatus to prevent unnecessary updates
+- *(modal-confirmation)* Decode HTML entities in confirmation text to ensure proper display
+- *(select-component)* Enhance user interaction by adding cursor styles and disabling selection during processing
+- *(deployment-show)* Remove unnecessary fixed positioning for button container to improve layout responsiveness
+- *(email-notifications)* Change notify method to notifyNow for immediate test email delivery
+- *(service-templates)* Update Convex service configuration to use FQDN variables
+- *(database-heading)* Simplify stop database message for clarity
+- *(navbar)* Remove unnecessary x-init directive for loading proxy configuration
+- *(patches)* Add padding to loading message for better visibility during update checks
+- *(terminal-connection)* Improve error handling and stability for auto-connection; enhance component readiness checks and retry logic
+- *(terminal)* Add unique wire:key to terminal component for improved reactivity and state management
+- *(css)* Adjust utility classes in utilities.css for consistent application of Tailwind directives
+- *(css)* Refine utility classes in utilities.css for proper Tailwind directive application
+- *(install)* Update Docker installation script to use dynamic OS_TYPE and correct installation URL
+- *(cloudflare)* Add error handling to automated Cloudflare configuration script
+- *(navbar)* Add error handling for proxy status check to improve user feedback
+- *(web)* Update user team retrieval method for consistent authentication handling
+- *(cloudflare)* Update refresh method to correctly set Cloudflare tunnel status and improve user notification on IP address update
+- *(service)* Update service template for affine and add migration service for improved deployment process
+- *(supabase)* Update Supabase service images and healthcheck methods for improved reliability
+- *(terminal)* Now it should work
+- *(degraded-status)* Remove unnecessary whitespace in badge element for cleaner HTML
+- *(routes)* Add name to security route for improved route management
+- *(migration)* Update default value handling for is_sentinel_enabled column in server_settings
+- *(seeder)* Conditionally dispatch CheckAndStartSentinelJob based on server's sentinel status
+- *(service)* Disable healthcheck logging for Gotenberg (#6005)
+- *(service)* Joplin volume name (#5930)
+- *(server)* Update sentinelUpdatedAt assignment to use server's sentinel_updated_at property
+
+### 💼 Other
+
+- Add support for postmarketOS (#5608)
+- *(core)* Simplify events for app/db/service status changes
+
+### 🚜 Refactor
+
+- *(service)* Observium
+- *(service)* Improve leantime
+- *(service)* Imporve limesurvey
+- *(service)* Improve CodiMD
+- *(service)* Typsense
+- *(services)* Improve yamtrack
+- *(service)* Improve paymenter
+- *(service)* Consolidate configuration change dispatch logic and remove unused navbar component
+- *(sidebar)* Simplify server patching link by removing button element
+- *(slide-over)* Streamline button element and improve code readability
+- *(service)* Enhance modal confirmation component with event dispatching for service stop actions
+- *(slide-over)* Enhance class merging for improved component styling
+- *(core)* Use property promotion
+- *(service)* Improve maybe
+- *(applications)* Remove unused docker compose raw decoding
+- *(service)* Make TYPESENSE_API_KEY required
+- *(ui)* Show toast when server does not work and on stop
+- *(service)* Improve superset
+- *(service)* Improve Onetimesecret
+- *(service)* Improve Seafile
+- *(service)* Improve orangehrm
+- *(service)* Improve grist
+- *(application)* Enhance application stopping logic to support multiple servers
+- *(pricing-plans)* Improve label class binding for payment frequency selection
+- *(error-handling)* Replace generic Exception with RuntimeException for improved error specificity
+- *(error-handling)* Change Exception to RuntimeException for clearer error reporting
+- *(service)* Remove informational dispatch during service stop for cleaner execution
+- *(server-ui)* Improve layout and messaging in advanced settings and charts views
+- *(terminal-access)* Streamline resource retrieval and enhance terminal access messaging in UI
+- *(terminal)* Enhance terminal connection management and error handling, including improved reconnection logic and cleanup procedures
+- *(application-deployment)* Separate handling of FAILED and CANCELLED_BY_USER statuses for clearer logic and notification
+- *(jobs)* Update middleware to include job-specific identifiers for WithoutOverlapping
+- *(jobs)* Modify middleware to use job-specific identifier for WithoutOverlapping
+- *(environment-variables)* Remove debug logging from bulk submit handling for cleaner code
+- *(environment-variables)* Simplify application build pack check in environment variable handling
+- *(logs)* Adjust padding in logs view for improved layout consistency
+- *(application-deployment)* Streamline post-deployment process by always dispatching container status check
+- *(service-management)* Enhance container stopping logic by implementing parallel processing and removing deprecated methods
+- *(activity-monitor)* Change activity property visibility and update view references for consistency
+- *(activity-monitor)* Enhance layout responsiveness by adjusting class bindings and structure for better display
+- *(service-management)* Update stopContainersInParallel method to enforce Server type hint for improved type safety
+- *(service-management)* Rearrange docker cleanup logic in StopService to improve readability
+- *(database-management)* Simplify docker cleanup logic in StopDatabase to enhance readability
+- *(activity-monitor)* Consolidate activity monitoring logic and remove deprecated NewActivityMonitor component
+- *(activity-monitor)* Update dispatch method to use activityMonitor instead of deprecated newActivityMonitor
+- *(push-server-update)* Enhance application preview handling by incorporating pull request IDs and adding status update protections
+- *(docker-compose)* Replace hardcoded Docker Compose configuration with external YAML template for improved database detection testing
+- *(test-database-detection)* Rename services for clarity, add new database configurations, and update application service dependencies
+- *(database-detection)* Enhance isDatabaseImage function to utilize service configuration for improved detection accuracy
+- *(install-scripts)* Update Docker installation process to include manual installation fallback and improve error handling
+- *(logs-view)* Update logs display for service containers with improved headings and dynamic key binding
+- *(logs)* Enhance container loading logic and improve UI for logs display across various resource types
+- *(cloudflare-tunnel)* Enhance layout and structure of Cloudflare Tunnel documentation and confirmation modal
+- *(terminal-connection)* Streamline auto-connection logic and improve component readiness checks
+- *(logs)* Remove unused methods and debug functionality from Logs.php for cleaner code
+- *(remoteProcess)* Update sanitize_utf8_text function to accept nullable string parameter for improved type safety
+- *(events)* Remove ProxyStarted event and associated ProxyStartedNotification listener for code cleanup
+- *(navbar)* Remove unnecessary parameters from server navbar component for cleaner implementation
+- *(proxy)* Remove commented-out listener and method for cleaner code structure
+- *(events)* Update ProxyStatusChangedUI constructor to accept nullable teamId for improved flexibility
+- *(cloudflare)* Update server retrieval method for improved query efficiency
+- *(navbar)* Remove unused PHP use statement for cleaner code
+- *(proxy)* Streamline proxy status handling and improve dashboard availability checks
+- *(navbar)* Simplify proxy status handling and enhance loading indicators for better user experience
+- *(resource-operations)* Filter out build servers from the server list and clean up commented-out code in the resource operations view
+- *(execute-container-command)* Simplify connection logic and improve terminal availability checks
+- *(navigation)* Remove wire:navigate directive from configuration links for cleaner HTML structure
+- *(proxy)* Update StartProxy calls to use named parameter for async option
+- *(clone-project)* Enhance server retrieval by including destinations and filtering out build servers
+- *(ui)* Terminal
+- *(ui)* Remove terminal header from execute-container-command view
+- *(ui)* Remove unnecessary padding from deployment, backup, and logs sections
+
+### 📚 Documentation
+
+- Update changelog
+- *(service)* Add new docs link for zipline (#5912)
+- Update changelog
+- Update changelog
+- Update changelog
+
+### 🎨 Styling
+
+- *(css)* Update padding utility for password input and add newline in app.css
+- *(css)* Refine badge utility styles in utilities.css
+- *(css)* Enhance badge utility styles in utilities.css
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update coolify version to 4.0.0-beta.419 and nightly version to 4.0.0-beta.420 in configuration files
+- *(service)* Rename hoarder server to karakeep (#5607)
+- *(service)* Update Supabase services (#5708)
+- *(service)* Remove unused documenso env
+- *(service)* Formatting and cleanup of ryot
+- *(docs)* Remove changelog and add it to gitignore
+- *(versions)* Update version to 4.0.0-beta.419
+- *(service)* Diun formatting
+- *(docs)* Update CHANGELOG.md
+- *(service)* Switch convex vars
+- *(service)* Pgbackweb formatting and naming update
+- *(service)* Remove typesense default API key
+- *(service)* Format yamtrack healthcheck
+- *(core)* Remove unused function
+- *(ui)* Remove unused stopEvent code
+- *(service)* Remove unused env
+- *(tests)* Update test environment database name and add new feature test for converting container environment variables to array
+- *(service)* Update Immich service (#5886)
+- *(service)* Remove unused logo
+- *(api)* Update API docs
+- *(dependencies)* Update package versions in composer.json and composer.lock for improved compatibility and performance
+- *(dependencies)* Update package versions in package.json and package-lock.json for improved stability and features
+- *(version)* Update coolify-realtime to version 1.0.9 in docker-compose and versions files
+- *(version)* Update coolify version to 4.0.0-beta.420 and nightly version to 4.0.0-beta.421
+- *(service)* Changedetection remove unused code
+
+## [4.0.0-beta.417] - 2025-05-07
+
+### 🐛 Bug Fixes
+
+- *(select)* Update fallback logo path to use absolute URL for improved reliability
+
+### 📚 Documentation
+
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update coolify version to 4.0.0-beta.418
+
+## [4.0.0-beta.416] - 2025-05-05
+
+### 🚀 Features
+
+- *(migration)* Add 'is_migrated' and 'custom_type' columns to service_applications and service_databases tables
+- *(backup)* Implement custom database type selection and enhance scheduled backups management
+- *(README)* Add Gozunga and Macarne to sponsors list
+- *(redis)* Add scheduled cleanup command for Redis keys and enhance cleanup logic
+
+### 🐛 Bug Fixes
+
+- *(service)* Graceful shutdown of old container (#5731)
+- *(ServerCheck)* Enhance proxy container check to ensure it is running before proceeding
+- *(applications)* Include pull_request_id in deployment queue check to prevent duplicate deployments
+- *(database)* Update label for image input field to improve clarity
+- *(ServerCheck)* Set default proxy status to 'exited' to handle missing container state
+- *(database)* Reduce container stop timeout from 300 to 30 seconds for improved responsiveness
+- *(ui)* System theming for charts (#5740)
+- *(dev)* Mount points?!
+- *(dev)* Proxy mount point
+- *(ui)* Allow adding scheduled backups for non-migrated databases
+- *(DatabaseBackupJob)* Escape PostgreSQL password in backup command (#5759)
+- *(ui)* Correct closing div tag in service index view
+
+### 🚜 Refactor
+
+- *(Database)* Streamline container shutdown process and reduce timeout duration
+- *(core)* Streamline container stopping process and reduce timeout duration; update related methods for consistency
+- *(database)* Update DB facade usage for consistency across service files
+- *(database)* Enhance application conversion logic and add existence checks for databases and applications
+- *(actions)* Standardize method naming for network and configuration deletion across application and service classes
+- *(logdrain)* Consolidate log drain stopping logic to reduce redundancy
+- *(StandaloneMariadb)* Add type hint for destination method to improve code clarity
+- *(DeleteResourceJob)* Streamline resource deletion logic and improve conditional checks for database types
+- *(jobs)* Update middleware to prevent job release after expiration for CleanupInstanceStuffsJob, RestartProxyJob, and ServerCheckJob
+- *(jobs)* Unify middleware configuration to prevent job release after expiration for DockerCleanupJob and PushServerUpdateJob
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(seeder)* Update git branch from 'main' to 'v4.x' for multiple examples in ApplicationSeeder
+- *(versions)* Update coolify version to 4.0.0-beta.417 and nightly version to 4.0.0-beta.418
+
+## [4.0.0-beta.415] - 2025-04-29
+
+### 🐛 Bug Fixes
+
+- *(ui)* Remove required attribute from image input in service application view
+- *(ui)* Change application image validation to be nullable in service application view
+- *(Server)* Correct proxy path formatting for Traefik proxy type
+
+### 📚 Documentation
+
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update coolify version to 4.0.0-beta.416 and nightly version to 4.0.0-beta.417 in configuration files; fix links in deployment view
+
+## [4.0.0-beta.414] - 2025-04-28
+
+### 🐛 Bug Fixes
+
+- *(ui)* Disable livewire navigate feature (causing spam of setInterval())
+
+## [4.0.0-beta.413] - 2025-04-28
+
+### 💼 Other
+
+- Adjust Workflows for v5 (#5689)
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(workflows)* Adjust workflow for announcement
+
+## [4.0.0-beta.411] - 2025-04-23
+
+### 🚀 Features
+
+- *(deployment)* Add repository_project_id handling for private GitHub apps and clean up unused Caddy label logic
+- *(api)* Enhance OpenAPI specifications with token variable and additional key attributes
+- *(docker)* Add HTTP Basic Authentication support and enhance hostname parsing in Docker run conversion
+- *(api)* Add HTTP Basic Authentication fields to OpenAPI specifications and enhance PrivateKey model descriptions
+- *(README)* Add InterviewPal sponsorship link and corresponding SVG icon
+
+### 🐛 Bug Fixes
+
+- *(backup-edit)* Conditionally enable S3 checkbox based on available validated S3 storage
+- *(source)* Update no sources found message for clarity
+- *(api)* Correct middleware for service update route to ensure proper permissions
+- *(api)* Handle JSON response in service creation and update methods for improved error handling
+- Add 201 json code to servers validate api response
+- *(docker)* Ensure password hashing only occurs when HTTP Basic Authentication is enabled
+- *(docker)* Enhance hostname and GPU option validation in Docker run to compose conversion
+- *(terminal)* Enhance WebSocket client verification with authorized IPs in terminal server
+- *(ApplicationDeploymentJob)* Ensure source is an object before checking GitHub app properties
+
+### 🚜 Refactor
+
+- *(jobs)* Comment out unused Caddy label handling in ApplicationDeploymentJob and simplify proxy path logic in Server model
+- *(database)* Simplify database type checks in ServiceDatabase and enhance image validation in Docker helper
+- *(shared)* Remove unused ray debugging statement from newParser function
+- *(applications)* Remove redundant error response in create_env method
+- *(api)* Restructure routes to include versioning and maintain existing feedback endpoint
+- *(api)* Remove token variable from OpenAPI specifications for clarity
+- *(environment-variables)* Remove protected variable checks from delete methods for cleaner logic
+- *(http-basic-auth)* Rename 'http_basic_auth_enable' to 'http_basic_auth_enabled' across application files for consistency
+- *(docker)* Remove debug statement and enhance hostname handling in Docker run conversion
+- *(server)* Simplify proxy path logic and remove unnecessary conditions
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update coolify version to 4.0.0-beta.411 and nightly version to 4.0.0-beta.412 in configuration files
+- *(versions)* Update coolify version to 4.0.0-beta.412 and nightly version to 4.0.0-beta.413 in configuration files
+- *(versions)* Update coolify version to 4.0.0-beta.413 and nightly version to 4.0.0-beta.414 in configuration files
+- *(versions)* Update realtime version to 1.0.8 in versions.json
+- *(versions)* Update realtime version to 1.0.8 in versions.json
+- *(docker)* Update soketi image version to 1.0.8 in production configuration files
+- *(versions)* Update coolify version to 4.0.0-beta.414 and nightly version to 4.0.0-beta.415 in configuration files
+
+## [4.0.0-beta.410] - 2025-04-18
+
+### 🚀 Features
+
+- Add HTTP Basic Authentication
+- *(readme)* Add new sponsors Supadata AI and WZ-IT to the README
+- *(core)* Enable magic env variables for compose based applications
+
+### 🐛 Bug Fixes
+
+- *(application)* Append base directory to git branch URLs for improved path handling
+- *(templates)* Correct casing of "denokv" to "denoKV" in service templates JSON
+- *(navbar)* Update error message link to use route for environment variables navigation
+- Unsend template
+- Replace ports with expose
+- *(templates)* Update Unsend compose configuration for improved service integration
+
+### 🚜 Refactor
+
+- *(jobs)* Update WithoutOverlapping middleware to use expireAfter for better queue management
+
+### 📚 Documentation
+
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Bump coolify version to 4.0.0-beta.410 and update nightly version to 4.0.0-beta.411 in configuration files
+- *(templates)* Update plausible and clickhouse images to latest versions and remove mail service
+
+## [4.0.0-beta.409] - 2025-04-16
+
+### 🐛 Bug Fixes
+
+- *(parser)* Transform associative array labels into key=value format for better compatibility
+- *(redis)* Update username and password input handling to clarify database sync requirements
+- *(source)* Update connected source display to handle cases with no source connected
+
+### 🚜 Refactor
+
+- *(source)* Conditionally display connected source and change source options based on private key presence
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Bump coolify version to 4.0.0-beta.409 in configuration files
+
+## [4.0.0-beta.408] - 2025-04-14
+
+### 🚀 Features
+
+- *(OpenApi)* Enhance OpenAPI specifications by adding UUID parameters for application, project, and service updates; improve deployment listing with pagination parameters; update command signature for OpenApi generation
+- *(subscription)* Enhance subscription management with loading states and Stripe status checks
+
+### 🐛 Bug Fixes
+
+- *(pre-commit)* Correct input redirection for /dev/tty and add OpenAPI generation command
+- *(pricing-plans)* Adjust grid class for improved layout consistency in subscription pricing plans
+- *(migrations)* Make stripe_comment field nullable in subscriptions table
+- *(mongodb)* Also apply custom config when SSL is enabled
+- *(templates)* Correct casing of denoKV references in service templates and YAML files
+- *(deployment)* Handle missing destination in deployment process to prevent errors
+
+### 💼 Other
+
+- Add missing openapi items to PrivateKey
+
+### 🚜 Refactor
+
+- *(commands)* Reorganize OpenAPI and Services generation commands into a new namespace for better structure; remove old command files
+- *(Dockerfile)* Remove service generation command from the build process to streamline Dockerfile and improve build efficiency
+- *(navbar-delete-team)* Simplify modal confirmation layout and enhance button styling for better user experience
+- *(Server)* Remove debug logging from isReachableChanged method to clean up code and improve performance
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update nightly version to 4.0.0-beta.410
+- *(pre-commit)* Remove OpenAPI generation command from pre-commit hook
+- *(versions)* Update realtime version to 1.0.7 and bump dependencies in package.json
+
+## [4.0.0-beta.407] - 2025-04-09
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.406] - 2025-04-05
+
+### 🚀 Features
+
+- *(Deploy)* Add info dispatch for proxy check initiation
+- *(EnvironmentVariable)* Add handling for Redis credentials in the environment variable component
+- *(EnvironmentVariable)* Implement protection for critical environment variables and enhance deletion logic
+- *(Application)* Add networkAliases attribute for handling network aliases as JSON or comma-separated values
+- *(GithubApp)* Update default events to include 'pull_request' and streamline event handling
+- *(CleanupDocker)* Add support for realtime image management in Docker cleanup process
+- *(Deployment)* Enhance queue_application_deployment to handle existing deployments and return appropriate status messages
+- *(SourceManagement)* Add functionality to change Git source and display current source in the application settings
+
+### 🐛 Bug Fixes
+
+- *(CheckProxy)* Update port conflict check to ensure accurate grep matching
+- *(CheckProxy)* Refine port conflict detection with improved grep patterns
+- *(CheckProxy)* Enhance port conflict detection by adjusting ss command for better output
+- *(api)* Add back validateDataApplications (#5539)
+- *(CheckProxy, Status)* Prevent proxy checks when force_stop is active; remove debug statement in General
+- *(Status)* Conditionally check proxy status and refresh button based on force_stop state
+- *(General)* Change redis_password property to nullable string
+- *(DeployController)* Update request handling to use input method and enhance OpenAPI description for deployment endpoint
+
+### 💼 Other
+
+- Add missing UUID to openapi spec
+
+### 🚜 Refactor
+
+- *(Server)* Use data_get for safer access to settings properties in isFunctional method
+- *(Application)* Rename network_aliases to custom_network_aliases across the application for clarity and consistency
+- *(ApplicationDeploymentJob)* Streamline environment variable handling by introducing generate_coolify_env_variables method and consolidating logic for pull request and main branch scenarios
+- *(ApplicationDeploymentJob, ApplicationDeploymentQueue)* Improve deployment status handling and log entry management with transaction support
+- *(SourceManagement)* Sort sources by name and improve UI for changing Git source with better error handling
+- *(Email)* Streamline SMTP and resend settings handling in copyFromInstanceSettings method
+- *(Email)* Enhance error handling in SMTP and resend methods by passing context to handleError function
+- *(DynamicConfigurations)* Improve handling of dynamic configuration content by ensuring fallback to empty string when content is null
+- *(ServicesGenerate)* Update command signature from 'services:generate' to 'generate:services' for consistency; update Dockerfile to run service generation during build; update Odoo image version to 18 and add extra addons volume in compose configuration
+- *(Dockerfile)* Streamline RUN commands for improved readability and maintainability by adding line continuations
+- *(Dockerfile)* Reintroduce service generation command in the build process for consistency and ensure proper asset compilation
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Bump version to 406
+- *(versions)* Bump version to 407 and 408 for coolify and nightly
+- *(versions)* Bump version to 408 for coolify and 409 for nightly
+
+## [4.0.0-beta.405] - 2025-04-04
+
+### 🚀 Features
+
+- *(api)* Update OpenAPI spec for services (#5448)
+- *(proxy)* Enhance proxy handling and port conflict detection
+
+### 🐛 Bug Fixes
+
+- *(api)* Used ssh keys can be deleted
+- *(email)* Transactional emails not sending
+
+### 🚜 Refactor
+
+- *(CheckProxy)* Replace 'which' with 'command -v' for command availability checks
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Bump version to 406
+- *(versions)* Bump version to 407
+
+## [4.0.0-beta.404] - 2025-04-03
+
+### 🚀 Features
+
+- *(lang)* Added Azerbaijani language updated turkish language. (#5497)
+- *(lang)* Added Portuguese from Brazil language (#5500)
+- *(lang)* Add Indonesian language translations (#5513)
+
+### 🐛 Bug Fixes
+
+- *(docs)* Comment out execute for now
+- *(installation)* Mount the docker config
+- *(installation)* Path to config file for docker login
+- *(service)* Add health check to Bugsink service (#5512)
+- *(email)* Emails are not sent in multiple cases
+- *(deployments)* Use graceful shutdown instead of `rm`
+- *(docs)* Contribute service url (#5517)
+- *(proxy)* Proxy restart does not work on domain
+- *(ui)* Only show copy button on https
+- *(database)* Custom config for MongoDB (#5471)
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(service)* Remove unused code in Bugsink service
+- *(versions)* Update version to 404
+- *(versions)* Bump version to 403 (#5520)
+- *(versions)* Bump version to 404
+
+## [4.0.0-beta.402] - 2025-04-01
+
+### 🚀 Features
+
+- *(deployments)* Add list application deployments api route
+- *(deploy)* Add pull request ID parameter to deploy endpoint
+- *(api)* Add pull request ID parameter to applications endpoint
+- *(api)* Add endpoints for retrieving application logs and deployments
+- *(lang)* Added Norwegian language (#5280)
+- *(dep)* Bump all dependencies
+
+### 🐛 Bug Fixes
+
+- Only get apps for the current team
+- *(DeployController)* Cast 'pr' query parameter to integer
+- *(deploy)* Validate team ID before deployment
+- *(wakapi)* Typo in env variables and add some useful variables to wakapi.yaml (#5424)
+- *(ui)* Instance Backup settings
+
+### 🚜 Refactor
+
+- *(dev)* Remove OpenAPI generation functionality
+- *(migration)* Enhance local file volumes migration with logging
+
+### ⚙️ Miscellaneous Tasks
+
+- *(service)* Update minecraft service ENVs
+- *(service)* Add more vars to infisical.yaml (#5418)
+- *(service)* Add google variables to plausible.yaml (#5429)
+- *(service)* Update authentik.yaml versions (#5373)
+- *(core)* Remove redocs
+- *(versions)* Update coolify version numbers to 4.0.0-beta.403 and 4.0.0-beta.404
+
+## [4.0.0-beta.401] - 2025-03-28
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+## [4.0.0-beta.400] - 2025-03-27
+
+### 🚀 Features
+
+- *(database)* Disable MongoDB SSL by default in migration
+- *(database)* Add CA certificate generation for database servers
+- *(application)* Add SPA configuration and update Nginx generation logic
+
+### 🐛 Bug Fixes
+
+- *(file-storage)* Double save on compose volumes
+- *(parser)* Add logging support for applications in services
+
+### 🚜 Refactor
+
+- *(proxy)* Improve port availability checks with multiple methods
+- *(database)* Update MongoDB SSL configuration for improved security
+- *(database)* Enhance SSL configuration handling for various databases
+- *(notifications)* Update Telegram button URL for staging environment
+- *(models)* Remove unnecessary cloud check in isEnabled method
+- *(database)* Streamline event listeners in Redis General component
+- *(database)* Remove redundant database status display in MongoDB view
+- *(database)* Update import statements for Auth in database components
+- *(database)* Require PEM key file for SSL certificate regeneration
+- *(database)* Change MySQL daemon command to MariaDB daemon
+- *(nightly)* Update version numbers and enhance upgrade script
+- *(versions)* Update version numbers for coolify and nightly
+- *(email)* Validate team membership for email recipients
+- *(shared)* Simplify deployment status check logic
+- *(shared)* Add logging for running deployment jobs
+- *(shared)* Enhance job status check to include 'reserved'
+- *(email)* Improve error handling by passing context to handleError
+- *(email)* Streamline email sending logic and improve configuration handling
+- *(email)* Remove unnecessary whitespace in email sending logic
+- *(email)* Allow custom email recipients in email sending logic
+- *(email)* Enhance sender information formatting in email logic
+- *(proxy)* Remove redundant stop call in restart method
+- *(file-storage)* Add loadStorageOnServer method for improved error handling
+- *(docker)* Parse and sanitize YAML compose file before encoding
+- *(file-storage)* Improve layout and structure of input fields
+- *(email)* Update label for test email recipient input
+- *(database-backup)* Remove existing Docker container before backup upload
+- *(database)* Improve decryption and deduplication of local file volumes
+- *(database)* Remove debug output from volume update process
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update version numbers for coolify and nightly
+
+### ◀️ Revert
+
+- Encrypting mount and fs_path
+
+## [4.0.0-beta.399] - 2025-03-25
+
+### 🚀 Features
+
+- *(service)* Neon
+- *(migration)* Add `ssl_certificates` table and model
+- *(migration)* Add ssl setting to `standalone_postgresqls` table
+- *(ui)* Add ssl settings to Postgres ui
+- *(db)* Add ssl mode to Postgres URLs
+- *(db)* Setup ssl during Postgres start
+- *(migration)* Encrypt local file volumes content and paths
+- *(ssl)* Ssl generation helper
+- *(ssl)* Migrate to `ECC`certificates using `secp521r1`
+- *(ssl)* Improve SSL helper
+- *(ssl)* Add a Coolify CA Certificate to all servers
+- *(seeder)* Call CA SSL seeder in prod and dev
+- *(ssl)* Add Coolify CA Certificate when adding a new server
+- *(installer)* Create CA folder during installation
+- *(ssl)* Improve SSL helper
+- *(ssl)* Use new improved helper for SSL generation
+- *(ui)* Add CA cert UI
+- *(ui)* New copy button component
+- *(ui)* Use new copy button component everywhere
+- *(ui)* Improve server advanced view
+- *(migration)* Add CN and alternative names to DB
+- *(databases)* Add CA SSL crt location to Postgres URLs
+- *(ssl)* Improve ssl generation
+- *(ssl)* Regenerate SSL certs job
+- *(ssl)* Regenerate certificate and valid until UI
+- *(ssl)* Regenerate CA cert and all other certs logic
+- *(ssl)* Add full MySQL SSL Support
+- *(ssl)* Add full MariaDB SSL support
+- *(ssl)* Add `openssl.conf` to configure SSL extension properly
+- *(ssl)* Improve SSL generation and security a lot
+- *(ssl)* Check for SSL renewal twice daily
+- *(ssl)* Add SSL relationships to all DBs
+- Add full SSL support to MongoDB
+- *(ssl)* Fix some issues and improve ssl generation helper
+- *(ssl)* Ability to create `.pem` certs and add `clientAuth` to `extendedKeyUsage`
+- *(ssl)* New modes for MongoDB and get `caCert` and `mountPath` correctly
+- *(ssl)* Full SSL support for Redis
+- New mode implementation for MongoDB
+- *(ssl)* Improve Redis and remove modes
+- Full SSL support for DrangonflyDB
+- SSL notification
+- *(github-source)* Enhance GitHub App configuration with manual and private key support
+- *(ui)* Improve GitHub repository selection and styling
+- *(database)* Implement two-step confirmation for database deletion
+- *(assets)* Add new SVG logo for Coolify
+- *(install)* Enhance Docker address pool configuration and validation
+- *(install)* Improve Docker address pool management and service restart logic
+- *(install)* Add missing env variable to install script
+- *(LocalFileVolume)* Add binary file detection and update UI logic
+- *(templates)* Change glance for v0.7
+- *(templates)* Add Freescout service template
+- *(service)* Add Evolution API template
+- *(service)* Add evolution-api and neon-ws-proxy templates
+- *(svg)* Add coolify and evolution-api SVG logos
+- *(api)* Add api to create custom services
+- *(api)* Separate create and one-click routes
+- *(api)* Update Services api routes and handlers
+- *(api)* Unify service creation endpoint and enhance validation
+- *(notifications)* Add discord ping functionality and settings
+- *(user)* Implement session deletion on password reset
+- *(github)* Enhance repository loading and validation in applications
+
+### 🐛 Bug Fixes
+
+- *(api)* Docker compose based apps creationg through api
+- *(database)* Improve database type detection for Supabase Postgres images
+- *(ssl)* Permission of ssl crt and key inside the container
+- *(ui)* Make sure file mounts do not showing the encrypted values
+- *(ssl)* Make default ssl mode require not verify-full as it does not need a ca cert
+- *(ui)* Select component should not always uses title case
+- *(db)* SSL certificates table and model
+- *(migration)* Ssl certificates table
+- *(databases)* Fix database name users new `uuid` instead of DB one
+- *(database)* Fix volume and file mounts and naming
+- *(migration)* Store subjectAlternativeNames as a json array in the db
+- *(ssl)* Make sure the subjectAlternativeNames are unique and stored correctly
+- *(ui)* Certificate expiration data is null before starting the DB
+- *(deletion)* Fix DB deletion
+- *(ssl)* Improve SSL cert file mounts
+- *(ssl)* Always create ca crt on disk even if it is already there
+- *(ssl)* Use mountPath parameter not a hardcoded path
+- *(ssl)* Use 1 instead of on for mysql
+- *(ssl)* Do not remove SSL directory
+- *(ssl)* Wrong ssl cert is loaded to the server and UI error when regenerating SSL
+- *(ssl)* Make sure when regenerating the CA cert it is not overwritten with a server cert
+- *(ssl)* Regenerating certs for a specific DB
+- *(ssl)* Fix MariaDB and MySQL need CA cert
+- *(ssl)* Add mount path to DB to fix regeneration of certs
+- *(ssl)* Fix SSL regeneration to sign with CA cert and use mount path
+- *(ssl)* Get caCert correctly
+- *(ssl)* Remove caCert even if it is a folder by accident
+- *(ssl)* Ger caCert and `mountPath` correctly
+- *(ui)* Only show Regenerate SSL Certificates button when there is a cert
+- *(ssl)* Server id
+- *(ssl)* When regenerating SSL certs the cert is not singed with the new CN
+- *(ssl)* Adjust ca paths for MySQL
+- *(ssl)* Remove mode selection for MariaDB as it is not supported
+- *(ssl)* Permission issue with MariDB cert and key and paths
+- *(ssl)* Rename Redis mode to verify-ca as it is not verify-full
+- *(ui)* Remove unused mode for MongoDB
+- *(ssl)* KeyDB port and caCert args are missing
+- *(ui)* Enable SSL is not working correctly for KeyDB
+- *(ssl)* Add `--tls` arg to DrangflyDB
+- *(notification)* Always send SSL notifications
+- *(database)* Change default value of enable_ssl to false for multiple tables
+- *(ui)* Correct grammatical error in 404 page
+- *(seeder)* Update GitHub app name in GithubAppSeeder
+- *(plane)* Update APP_RELEASE to v0.25.2 in environment configuration
+- *(domain)* Dispatch refreshStatus event after successful domain update
+- *(database)* Correct container name generation for service databases
+- *(database)* Limit container name length for database proxy
+- *(database)* Handle unsupported database types in StartDatabaseProxy
+- *(database)* Simplify container name generation in StartDatabaseProxy
+- *(install)* Handle potential errors in Docker address pool configuration
+- *(backups)* Retention settings
+- *(redis)* Set default redis_username for new instances
+- *(core)* Improve instantSave logic and error handling
+- *(general)* Correct link to framework specific documentation
+- *(core)* Redirect healthcheck route for dockercompose applications
+- *(api)* Use name from request payload
+- *(issue#4746)* Do not use setGitImportSettings inside of generateGitLsRemoteCommands
+- Correct some spellings
+- *(service)* Replace deprecated credentials env variables on keycloak service
+- *(keycloak)* Update keycloak image version to 26.1
+- *(console)* Handle missing root user in password reset command
+- *(ssl)* Handle missing CA certificate in SSL regeneration job
+- *(copy-button)* Ensure text is safely passed to clipboard
+
+### 💼 Other
+
+- Bump Coolify to 4.0.0-beta.400
+- *(migration)* Add SSL fields to database tables
+- SSL Support for KeyDB
+
+### 🚜 Refactor
+
+- *(ui)* Unhide log toggle in application settings
+- *(nginx)* Streamline default Nginx configuration and improve error handling
+- *(install)* Clean up install script and enhance Docker installation logic
+- *(ScheduledTask)* Clean up code formatting and remove unused import
+- *(app)* Remove unused MagicBar component and related code
+- *(database)* Streamline SSL configuration handling across database types
+- *(application)* Streamline healthcheck parsing from Dockerfile
+- *(notifications)* Standardize getRecipients method signatures
+- *(configuration)* Centralize configuration management in ConfigurationRepository
+- *(docker)* Update image references to use centralized registry URL
+- *(env)* Add centralized registry URL to environment configuration
+- *(storage)* Simplify file storage iteration in Blade template
+- *(models)* Add is_directory attribute to LocalFileVolume model
+- *(modal)* Add ignoreWire attribute to modal-confirmation component
+- *(invite-link)* Adjust layout for better responsiveness in form
+- *(invite-link)* Enhance form layout for improved responsiveness
+- *(network)* Enhance docker network creation with ipv6 fallback
+- *(network)* Check for existing coolify network before creation
+- *(database)* Enhance encryption process for local file volumes
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+- *(CONTRIBUTING)* Add note about Laravel Horizon accessibility
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(migration)* Remove unused columns
+- *(ssl)* Improve code in ssl helper
+- *(migration)* Ssl cert and key should not be nullable
+- *(ssl)* Rename CA cert to `coolify-ca.crt` because of conflicts
+- Rename ca crt folder to ssl
+- *(ui)* Improve valid until handling
+- Improve code quality suggested by code rabbit
+- *(supabase)* Update Supabase service template and Postgres image version
+- *(versions)* Update version numbers for coolify and nightly
+
+## [4.0.0-beta.398] - 2025-03-01
+
+### 🚀 Features
+
+- *(billing)* Add Stripe past due subscription status tracking
+- *(ui)* Add past due subscription warning banner
+
+### 🐛 Bug Fixes
+
+- *(billing)* Restrict Stripe subscription status update to 'active' only
+
+### 💼 Other
+
+- Bump Coolify to 4.0.0-beta.398
+
+### 🚜 Refactor
+
+- *(billing)* Enhance Stripe subscription status handling and notifications
+
+## [4.0.0-beta.397] - 2025-02-28
+
+### 🐛 Bug Fixes
+
+- *(billing)* Handle 'past_due' subscription status in Stripe processing
+- *(revert)* Label parsing
+- *(helpers)* Initialize command variable in parseCommandFromMagicEnvVariable
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.396] - 2025-02-28
+
+### 🚀 Features
+
+- *(ui)* Add wire:key to two-step confirmation settings
+- *(database)* Add index to scheduled task executions for improved query performance
+- *(database)* Add index to scheduled database backup executions
+
+### 🐛 Bug Fixes
+
+- *(core)* Production dockerfile
+- *(ui)* Update storage configuration guidance link
+- *(ui)* Set default SMTP encryption to starttls
+- *(notifications)* Correct environment URL path in application notifications
+- *(config)* Update default PostgreSQL host to coolify-db instead of postgres
+- *(docker)* Improve Docker compose file validation process
+- *(ui)* Restrict service retrieval to current team
+- *(core)* Only validate custom compose files
+- *(mail)* Set default mailer to array when not specified
+- *(ui)* Correct redirect routes after task deletion
+- *(core)* Adding a new server should not try to make the default docker network
+- *(core)* Clean up unnecessary files during application image build
+- *(core)* Improve label generation and merging for applications and services
+
+### 💼 Other
+
+- Bump all dependencies (#5216)
+
+### 🚜 Refactor
+
+- *(ui)* Simplify file storage modal confirmations
+- *(notifications)* Improve transactional email settings handling
+- *(scheduled-tasks)* Improve scheduled task creation and management
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- Bump helper and realtime version
+
+## [4.0.0-beta.395] - 2025-02-22
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.394] - 2025-02-17
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.393] - 2025-02-15
+
+### 📚 Documentation
+
+- Update changelog
+
+## [4.0.0-beta.392] - 2025-02-13
+
+### 🚀 Features
+
+- *(ui)* Add top padding to pricing plans view
+- *(core)* Add error logging and cron parsing to docker/server schedules
+- *(core)* Prevent using servers with existing resources as build servers
+- *(ui)* Add textarea switching option in service compose editor
+
+### 🐛 Bug Fixes
+
+- Pull latest image from registry when using build server
+- *(deployment)* Improve server selection for deployment cancellation
+- *(deployment)* Improve log line rendering and formatting
+- *(s3-storage)* Optimize team admin notification query
+- *(core)* Improve connection testing with dynamic disk configuration for s3 backups
+- *(core)* Update service status refresh event handling
+- *(ui)* Adjust polling intervals for database and service status checks
+- *(service)* Update Fider service template healthcheck command
+- *(core)* Improve server selection error handling in Docker component
+- *(core)* Add server functionality check before dispatching container status
+- *(ui)* Disable sticky scroll in Monaco editor
+- *(ui)* Add literal and multiline env support to services.
+- *(services)* Owncloud docs link
+- *(template)* Remove db-migration step from `infisical.yaml` (#5209)
+- *(service)* Penpot (#5047)
+
+### 🚜 Refactor
+
+- Use pull flag on docker compose up
+
+### 📚 Documentation
+
+- Update changelog
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- Rollback Coolify version to 4.0.0-beta.392
+- Bump Coolify version to 4.0.0-beta.393
+- Bump Coolify version to 4.0.0-beta.394
+- Bump Coolify version to 4.0.0-beta.395
+- Bump Coolify version to 4.0.0-beta.396
+- *(services)* Update zipline to use new Database env var. (#5210)
+- *(service)* Upgrade authentik service
+- *(service)* Remove unused env from zipline
+
+## [4.0.0-beta.391] - 2025-02-04
+
+### 🚀 Features
+
+- Add application api route
+- Container logs
+- Remove ansi color from log
+- Add lines query parameter
+- *(changelog)* Add git cliff for automatic changelog generation
+- *(workflows)* Improve changelog generation and workflows
+- *(ui)* Add periodic status checking for services
+- *(deployment)* Ensure private key is stored in filesystem before deployment
+- *(slack)* Show message title in notification previews (#5063)
+- *(i18n)* Add Arabic translations (#4991)
+- *(i18n)* Add French translations (#4992)
+- *(services)* Update `service-templates.json`
+
+### 🐛 Bug Fixes
+
+- *(core)* Improve deployment failure Slack notification formatting
+- *(core)* Update Slack notification formatting to use bold correctly
+- *(core)* Enhance Slack deployment success notification formatting
+- *(ui)* Simplify service templates loading logic
+- *(ui)* Align title and add button vertically in various views
+- Handle pullrequest:updated for reliable preview deployments
+- *(ui)* Fix typo on team page (#5105)
+- Cal.com documentation link give 404 (#5070)
+- *(slack)* Notification settings URL in `HighDiskUsage` message (#5071)
+- *(ui)* Correct typo in Storage delete dialog (#5061)
+- *(lang)* Add missing italian translations (#5057)
+- *(service)* Improve duplicati.yaml (#4971)
+- *(service)* Links in homepage service (#5002)
+- *(service)* Added SMTP credentials to getoutline yaml template file (#5011)
+- *(service)* Added `KEY` Variable to Beszel Template (#5021)
+- *(cloudflare-tunnels)* Dead links to docs (#5104)
+- System-wide GitHub apps (#5114)
+
+### 🚜 Refactor
+
+- Simplify service start and restart workflows
+
+### 📚 Documentation
+
+- *(services)* Reword nitropage url and slogan
+- *(readme)* Add Convex to special sponsors section
+- Update changelog
+
+### ⚙️ Miscellaneous Tasks
+
+- *(config)* Increase default PHP memory limit to 256M
+- Add openapi response
+- *(workflows)* Make naming more clear and remove unused code
+- Bump Coolify version to 4.0.0-beta.392/393
+- *(ci)* Update changelog generation workflow to target 'next' branch
+- *(ci)* Update changelog generation workflow to target main branch
+
+## [4.0.0-beta.390] - 2025-01-28
+
+### 🚀 Features
+
+- *(template)* Add Open Web UI
+- *(templates)* Add Open Web UI service template
+- *(ui)* Update GitHub source creation advanced section label
+- *(core)* Add dynamic label reset for application settings
+- *(ui)* Conditionally enable advanced application settings based on label readonly status
+- *(env)* Added COOLIFY_RESOURCE_UUID environment variable
+- *(vite)* Add Cloudflare async script and style tag attributes
+- *(meta)* Add comprehensive SEO and social media meta tags
+- *(core)* Add name to default proxy configuration
+
+### 🐛 Bug Fixes
+
+- *(ui)* Update database control UI to check server functionality before displaying actions
+- *(ui)* Typo in upgrade message
+- *(ui)* Cloudflare tunnel configuration should be an info, not a warning
+- *(s3)* DigitalOcean storage buckets do not work
+- *(ui)* Correct typo in container label helper text
+- Disable certain parts if readonly label is turned off
+- Cleanup old scheduled_task_executions
+- Validate cron expression in Scheduled Task update
+- *(core)* Check cron expression on save
+- *(database)* Detect more postgres database image types
+- *(templates)* Update service templates
+- Remove quotes in COOLIFY_CONTAINER_NAME
+- *(templates)* Update Trigger.dev service templates with v3 configuration
+- *(database)* Adjust MongoDB restore command and import view styling
+- *(core)* Improve public repository URL parsing for branch and base directory
+- *(core)* Increase HTTP/2 max concurrent streams to 250 (default)
+- *(ui)* Update docker compose file helper text to clarify repository modification
+- *(ui)* Skip SERVICE_FQDN and SERVICE_URL variables during update
+- *(core)* Stopping database is not disabling db proxy
+- *(core)* Remove --remove-orphans flag from proxy startup command to prevent other proxy deletions (db)
+- *(api)* Domain check when updating domain
+- *(ui)* Always redirect to dashboard after team switch
+- *(backup)* Escape special characters in database backup commands
+
+### 💼 Other
+
+- Trigger.dev templates - wrong key length issue
+- Trigger.dev template - missing ports and wrong env usage
+- Trigger.dev template - fixed otel config
+- Trigger.dev template - fixed otel config
+- Trigger.dev template - fixed port config
+
+### 🚜 Refactor
+
+- *(s3)* Improve S3 bucket endpoint formatting
+- *(vite)* Improve environment variable handling in Vite configuration
+- *(ui)* Simplify GitHub App registration UI and layout
+
+### ⚙️ Miscellaneous Tasks
+
+- *(version)* Bump Coolify version to 4.0.0-beta.391
+
+### ◀️ Revert
+
+- Remove Cloudflare async tag attributes
+
+## [4.0.0-beta.389] - 2025-01-23
+
+### 🚀 Features
+
+- *(docs)* Update tech stack
+- *(terminal)* Show terminal unavailable if the container does not have a shell on the global terminal UI
+- *(ui)* Improve deployment UI
+
+### 🐛 Bug Fixes
+
+- *(service)* Infinite loading and lag with invoiceninja service (#4876)
+- *(service)* Invoiceninja service
+- *(workflows)* `Waiting for changes` label should also be considered and improved messages
+- *(workflows)* Remove tags only if the PR has been merged into the main branch
+- *(terminal)* Terminal shows that it is not available, even though it is
+- *(labels)* Docker labels do not generated correctly
+- *(helper)* Downgrade Nixpacks to v1.29.0
+- *(labels)* Generate labels when they are empty not when they are already generated
+- *(storage)* Hetzner storage buckets not working
+
+### 📚 Documentation
+
+- Add TECH_STACK.md (#4883)
+
+### ⚙️ Miscellaneous Tasks
+
+- *(versions)* Update coolify versions to v4.0.0-beta.389
+- *(core)* EnvironmentVariable Model now extends BaseModel to remove duplicated code
+- *(versions)* Update coolify versions to v4.0.0-beta.3909
+
+## [4.0.0-beta.388] - 2025-01-22
+
+### 🚀 Features
+
+- *(core)* Add SOURCE_COMMIT variable to build environment in ApplicationDeploymentJob
+- *(service)* Update affine.yaml with AI environment variables (#4918)
+- *(service)* Add new service Flipt (#4875)
+
+### 🐛 Bug Fixes
+
+- *(core)* Update environment variable generation logic in ApplicationDeploymentJob to handle different build packs
+- *(env)* Shared variables can not be updated
+- *(ui)* Metrics stuck in loading state
+- *(ui)* Use `wire:navigate` to navigate to the server settings page
+- *(service)* Plunk API & health check endpoint (#4925)
+
+## [4.0.0-beta.386] - 2025-01-22
+
+### 🐛 Bug Fixes
+
+- *(redis)* Update environment variable keys from standalone_redis_id to resourceable_id
+- *(routes)* Local API docs not available on domain or IP
+- *(routes)* Local API docs not available on domain or IP
+- *(core)* Update application_id references to resourable_id and resourable_type for Nixpacks configuration
+- *(core)* Correct spelling of 'resourable' to 'resourceable' in Nixpacks configuration for ApplicationDeploymentJob
+- *(ui)* Traefik dashboard url not working
+- *(ui)* Proxy status badge flashing during navigation
+
+### 🚜 Refactor
+
+- *(workflows)* Replace jq with PHP script for version retrieval in workflows
+
+### ⚙️ Miscellaneous Tasks
+
+- *(dep)* Bump helper version to 1.0.5
+- *(docker)* Add blank line for readability in Dockerfile
+- *(versions)* Update coolify versions to v4.0.0-beta.388
+- *(versions)* Update coolify versions to v4.0.0-beta.389 and add helper version retrieval script
+
+## [4.0.0-beta.385] - 2025-01-21
+
+### 🚀 Features
+
+- *(core)* Wip version of coolify.json
+
+### 🐛 Bug Fixes
+
+- *(email)* Transactional email sending
+- *(ui)* Add missing save button for new Docker Cleanup page
+- *(ui)* Show preview deployment environment variables
+- *(ui)* Show error on terminal if container has no shell (bash/sh)
+- *(parser)* Resource URL should only be parsed if there is one
+- *(core)* Compose parsing for apps
+
+### ⚙️ Miscellaneous Tasks
+
+- *(dep)* Bump nixpacks version
+- *(dep)* Version++
+
+## [4.0.0-beta.384] - 2025-01-21
+
+### 🐛 Bug Fixes
+
+- *(ui)* Backups link should not redirected to general
+- Envs with special chars during build
+- *(db)* `finished_at` timestamps are not set for existing deployments
+- Load service templates on cloud
+
+## [4.0.0-beta.383] - 2025-01-20
+
+### 🐛 Bug Fixes
+
+- *(service)* Add healthcheck to Cloudflared service (#4859)
+- Remove wire:navigate from import backups
+
+## [4.0.0-beta.382] - 2025-01-17
+
+### 🚀 Features
+
+- Add log file check message in upgrade script for better troubleshooting
+- Add root user details to install script
+
+### 🐛 Bug Fixes
+
+- Create the private key before the server in the prod seeder
+- Update ProductionSeeder to check for private key instead of server's private key
+- *(ui)* Missing underline for docs link in the Swarm section (#4860)
+- *(service)* Change chatwoot service postgres image from `postgres:12` to `pgvector/pgvector:pg12`
+- Docker image parser
+- Add public key attribute to privatekey model
+- Correct service update logic in Docker Compose parser
+- Update CDN URL in install script to point to nightly version
+
+### 🚜 Refactor
+
+- Comment out RootUserSeeder call in ProductionSeeder for clarity
+- Streamline ProductionSeeder by removing debug logs and unnecessary checks, while ensuring essential seeding operations remain intact
+- Remove debug echo statements from Init command to clean up output and improve readability
+
+## [4.0.0-beta.381] - 2025-01-17
+
+### 🚀 Features
+
+- Able to import full db backups for pg/mysql/mariadb
+- Restore backup from server file
+- Docker volume data cloning
+- Move volume data cloning to a Job
+- Volume cloning for ResourceOperations
+- Remote server volume cloning
+- Add horizon server details to queue
+- Enhance horizon:manage command with worker restart check
+- Add is_coolify_host to the server api responses
+- DB migration for Backup retention
+- UI for backup retention settings
+- New global s3 and local backup deletion function
+- Use new backup deletion functions
+- Add calibre-web service
+- Add actual-budget service
+- Add rallly service
+- Template for Gotenberg, a Docker-powered stateless API for PDF files
+- Enhance import command options with additional guidance and improved checkbox label
+- Purify for better sanitization
+- Move docker cleanup to its own tab
+- DB and Model for docker cleanup executions
+- DockerCleanupExecutions relationship
+- DockerCleanupDone event
+- Get command and output for logs from CleanupDocker
+- New sidebar menu and order
+- Docker cleanup executions UI
+- Add execution log to dockerCleanupJob
+- Improve deployment UI
+- Root user envs and seeding
+- Email, username and password validation when they are set via envs
+- Improved error handling and log output
+- Add root user configuration variables to production environment
+
+### 🐛 Bug Fixes
+
+- Compose envs
+- Scheduled tasks and backups are executed by server timezone.
+- Show backup timezone on the UI
+- Disappearing UI after livewire event received
+- Add default vector db for anythingllm
+- We need XSRF-TOKEN for terminal
+- Prevent default link behavior for resource and settings actions in dashboard
+- Increase default php memory limit
+- Show if only build servers are added to your team
+- Update Livewire button click method to use camelCase
+- Local dropzonejs
+- Import backups due to js stuff should not be navigated
+- Install inetutils on Arch Linux
+- Use ip in place of hostname from inetutils in arch
+- Update import command to append file redirection for database restoration
+- Ui bug on pw confirmation
+- Exclude system and computed fields from model replication
+- Service cloning on a separate server
+- Application cloning
+- `Undefined variable $fs_path` for databases
+- Service and database cloning and label generation
+- Labels and URL generation when cloning
+- Clone naming for different database data volumes
+- Implement all the cloneMe changes for ResourceOperations as well
+- Volume and fileStorages cloning
+- View text and helpers
+- Teable
+- Trigger with external db
+- Set `EXPERIMENTAL_FEATURES` to false for labelstudio
+- Monaco editor disabled state
+- Edge case where executions could be null
+- Create destination properly
+- Getcontainer status should timeout after 30s
+- Enable response for temporary unavailability in sentinel push endpoint
+- Use timeout in cleanup resources
+- Add timeout to sentinel process checks for improved reliability
+- Horizon job checker
+- Update response message for sentinel push route
+- Add own servers on cloud
+- Application deployment
+- Service update statsu
+- If $SERVICE found in the service specific configuration, then search for it in the db
+- Instance wide GitHub apps are not available on other teams then the source team
+- Function calls
+- UI
+- Deletion of single backup
+- Backup job deletion - delete all backups from s3 and local
+- Use new removeOldBackups function
+- Retention functions and folder deletion for local backups
+- Storage retention setting
+- Db without s3 should still backup
+- Wording
+- `Undefined variable $service` when creating a new service
+- Nodebb service
+- Calibre-web service
+- Rallly and actualbudget service
+- Removed container_name
+- Added healthcheck for gotenberg template
+- Gotenberg
+- *(template)* Gotenberg healthcheck, use /health instead of /version
+- Use wire:navigate on sidebar
+- Use wire:navigate on dashboard
+- Use wire:navigate on projects page
+- More wire:navigate
+- Even more wire:navigate
+- Service navigation
+- Logs icons everywhere + terminal
+- Redis DB should use the new resourceable columns
+- Joomla service
+- Add back letters to prod password requirement
+- Check System and GitHub time and throw and error if it is over 50s out of sync
+- Error message and server time getting
+- Error rendering
+- Render html correctly now
+- Indent
+- Potential fix for permissions update
+- Expiration time claim ('exp') must be a numeric value
+- Sanitize html error messages
+- Production password rule and cleanup code
+- Use json as it is just better than string for huge amount of logs
+- Use `wire:navigate` on server sidebar
+- Use finished_at for the end time instead of created_at
+- Cancelled deployments should not show end and duration time
+- Redirect to server index instead of show on error in Advanced and DockerCleanup components
+- Disable registration after creating the root user
+- RootUserSeeder
+- Regex username validation
+- Add spacing around echo outputs
+- Success message
+- Silent return if envs are empty or not set.
+
+### 💼 Other
+
+- Arrrrr
+- Dep
+- Docker dep
+
+### 🚜 Refactor
+
+- Rename parameter in DatabaseBackupJob for clarity
+- Improve checkbox component accessibility and styling
+- Remove unused tags method from ApplicationDeploymentJob
+- Improve deployment status check in isAnyDeploymentInprogress function
+- Extend HorizonServiceProvider from HorizonApplicationServiceProvider
+- Streamline job status retrieval and clean up repository interface
+- Enhance ApplicationDeploymentJob and HorizonServiceProvider for improved job handling
+- Remove commented-out unsubscribe route from API
+- Update redirect calls to use a consistent navigation method in deployment functions
+- AppServiceProvider
+- Github.php
+- Improve data formatting and UI
+
+### ⚙️ Miscellaneous Tasks
+
+- Improve Penpot healthchecks
+- Switch up readonly lables to make more sense
+- Remove unused computed fields
+- Use the new job dispatch
+- Disable volume data cloning for now
+- Improve code
+- Lowcoder service naming
+- Use new functions
+- Improve error styling
+- Css
+- More css as it still looks like shit
+- Final css touches
+- Ajust time to 50s (tests done)
+- Remove debug log, finally found it
+- Remove more logging
+- Remove limit on commit message
+- Remove dayjs
+- Remove unused code and fix import
+
+## [4.0.0-beta.380] - 2024-12-27
+
+### 🚀 Features
+
+- New ServerReachabilityChanged event
+- Use new ServerReachabilityChanged event instead of isDirty
+- Add infomaniak oauth
+- Add server disk usage check frequency
+- Add environment_uuid support and update API documentation
+- Add service/resource/project labels
+- Add coolify.environment label
+- Add database subtype
+- Migrate to new encryption options
+- New encryption options
+
+### 🐛 Bug Fixes
+
+- Render html on error page correctly
+- Invalid API response on missing project
+- Applications API response code + schema
+- Applications API writing to unavailable models
+- If an init script is renamed the old version is still on the server
+- Oauthseeder
+- Compose loading seq
+- Resource clone name + volume name generation
+- Update Dockerfile entrypoint path to /etc/entrypoint.d
+- Debug mode
+- Unreachable notifications
+- Remove duplicated ServerCheckJob call
+- Few fixes and use new ServerReachabilityChanged event
+- Use serverStatus not just status
+- Oauth seeder
+- Service ui structure
+- Check port 8080 and fallback to 80
+- Refactor database view
+- Always use docker cleanup frequency
+- Advanced server UI
+- Html css
+- Fix domain being override when update application
+- Use nixpacks predefined build variables, but still could update the default values from Coolify
+- Use local monaco-editor instead of Cloudflare
+- N8n timezone
+- Smtp encryption
+- Bind() to 0.0.0.0:80 failed
+- Oauth seeder
+- Unreachable notifications
+- Instance settings migration
+- Only encrypt instance email settings if there are any
+- Error message
+- Update healthcheck and port configurations to use port 8080
+
+### 🚜 Refactor
+
+- Rename `coolify.environment` to `coolify.environmentName`
+
+### ⚙️ Miscellaneous Tasks
+
+- Regenerate API spec, removing notification fields
+- Remove ray debugging
+- Version ++
+
+## [4.0.0-beta.378] - 2024-12-13
+
+### 🐛 Bug Fixes
+
+- Monaco editor light and dark mode switching
+- Service status indicator + oauth saving
+- Socialite for azure and authentik
+- Saving oauth
+- Fallback for copy button
+- Copy the right text
+- Maybe fallback is now working
+- Only show copy button on secure context
+
+## [4.0.0-beta.377] - 2024-12-13
+
+### 🚀 Features
+
+- Add deploy-only token permission
+- Able to deploy without cache on every commit
+- Update private key nam with new slug as well
+- Allow disabling default redirect, set status to 503
+- Add TLS configuration for default redirect in Server model
+- Slack notifications
+- Introduce root permission
+- Able to download schedule task logs
+- Migrate old email notification settings from the teams table
+- Migrate old discord notification settings from the teams table
+- Migrate old telegram notification settings from the teams table
+- Add slack notifications to a new table
+- Enable success messages again
+- Use new notification stuff inside team model
+- Some more notification settings and better defaults
+- New email notification settings
+- New shared function name `is_transactional_emails_enabled()`
+- New shared notifications functions
+- Email Notification Settings Model
+- Telegram notification settings Model
+- Discord notification settings Model
+- Slack notification settings Model
+- New Discord notification UI
+- New Slack notification UI
+- New telegram UI
+- Use new notification event names
+- Always sent notifications
+- Scheduled task success notification
+- Notification trait
+- Get discord Webhook form new table
+- Get Slack Webhook form new table
+- Use new table or instance settings for email
+- Use new place for settings and topic IDs for telegram
+- Encrypt instance email settings
+- Use encryption in instance settings model
+- Scheduled task success and failure notifications
+- Add docker cleanup success and failure notification settings columns
+- UI for docker cleanup success and failure notification
+- Docker cleanup email views
+- Docker cleanup success and failure notification files
+- Scheduled task success email
+- Send new docker cleanup notifications
+- :passport_control: integrate Authentik authentication with Coolify
+- *(notification)* Add Pushover
+- Add seeder command and configuration for database seeding
+- Add new password magic env with symbols
+- Add documenso service
+
+### 🐛 Bug Fixes
+
+- Resolve undefined searchInput reference in Alpine.js component
+- URL and sync new app name
+- Typos and naming
+- Client and webhook secret disappear after sync
+- Missing `mysql_password` API property
+- Incorrect MongoDB init API property
+- Old git versions does not have --cone implemented properly
+- Don't allow editing traefik config
+- Restart proxy
+- Dev mode
+- Ui
+- Display actual values for disk space checks in installer script
+- Proxy change behaviour
+- Add warning color
+- Import NotificationSlack correctly
+- Add middleware to new abilities, better ux for selecting permissions, etc.
+- Root + read:sensive could read senstive data with a middlewarew
+- Always have download logs button on scheduled tasks
+- Missing css
+- Development image
+- Dockerignore
+- DB migration error
+- Drop all unused smtp columns
+- Backward compatibility
+- Email notification channel enabled function
+- Instance email settins
+- Make sure resend is false if SMTP is true and vice versa
+- Email Notification saving
+- Slack and discord url now uses text filed because encryption makes the url very long
+- Notification trait
+- Encryption fixes
+- Docker cleanup email template
+- Add missing deployment notifications to telegram
+- New docker cleanup settings are now saved to the DB correctly
+- Ui + migrations
+- Docker cleanup email notifications
+- General notifications does not go through email channel
+- Test notifications to only send it to the right channel
+- Remove resale_license from db as well
+- Nexus service
+- Fileflows volume names
+- --cone
+- Provider error
+- Database migration
+- Seeder
+- Migration call
+- Slack helper
+- Telegram helper
+- Discord helper
+- Telegram topic IDs
+- Make pushover settings more clear
+- Typo in pushover user key
+- Use Livewire refresh method and lock properties
+- Create pushover settings for existing teams
+- Update token permission check from 'write' to 'root'
+- Pushover
+- Oauth seeder
+- Correct heading display for OAuth settings in settings-oauth.blade.php
+- Adjust spacing in login form for improved layout
+- Services env values should be sensitive
+- Documenso
+- Dolibarr
+- Typo
+- Update OauthSettingSeeder to handle new provider definitions and ensure authentik is recreated if missing
+- Improve OauthSettingSeeder to correctly delete non-existent providers and ensure proper handling of provider definitions
+- Encrypt resend API key in instance settings
+- Resend api key is already a text column
+
+### 💼 Other
+
+- Test rename GitHub app
+- Checkmate service and fix prowlar slogan (too long)
+
+### 🚜 Refactor
+
+- Update Traefik configuration for improved security and logging
+- Improve proxy configuration and code consistency in Server model
+- Rename name method to sanitizedName in BaseModel for clarity
+- Improve migration command and enhance application model with global scope and status checks
+- Unify notification icon
+- Remove unused Azure and Authentik service configurations from services.php
+- Change email column types in instance_settings migration from string to text
+- Change OauthSetting creation to updateOrCreate for better handling of existing records
+
+### ⚙️ Miscellaneous Tasks
+
+- Regenerate openapi spec
+- Composer dep bump
+- Dep bump
+- Upgrade cloudflared and minio
+- Remove comments and improve DB column naming
+- Remove unused seeder
+- Remove unused waitlist stuff
+- Remove wired.php (not used anymore)
+- Remove unused resale license job
+- Remove commented out internal notification
+- Remove more waitlist stuff
+- Remove commented out notification
+- Remove more waitlist stuff
+- Remove unused code
+- Fix typo
+- Remove comment out code
+- Some reordering
+- Remove resale license reference
+- Remove functions from shared.php
+- Public settings for email notification
+- Remove waitlist redirect
+- Remove log
+- Use new notification trait
+- Remove unused route
+- Remove unused email component
+- Comment status changes as it is disabled for now
+- Bump dep
+- Reorder navbar
+- Rename topicID to threadId like in the telegram API response
+- Update PHP configuration to set memory limit using environment variable
+
+## [4.0.0-beta.376] - 2024-12-07
+
+### 🐛 Bug Fixes
+
+- Api endpoint
+
+## [4.0.0-beta.374] - 2024-12-03
+
+### 🐛 Bug Fixes
+
+- Application view loading
+- Postiz service
+- Only able to select the right keys
+- Test email should not be required
+- A few inputs
+
+### 🧪 Testing
+
+- Setup database for upcoming tests
+
+## [4.0.0-beta.372] - 2024-11-26
+
+### 🚀 Features
+
+- Add MacOS template
+- Add Windows template
+- *(service)* :sparkles: add mealie
+- Add hex magic env var
+
+### 🐛 Bug Fixes
+
+- Service generate includes yml files as well (haha)
+- ServercheckJob should run every 5 minutes on cloud
+- New resource icons
+- Search should be more visible on scroll on new resource
+- Logdrain settings
+- Ui
+- Email should be retried with backoff
+- Alpine in body layout
+
+### 💼 Other
+
+- Caddy docker labels do not honor "strip prefix" option
+
+## [4.0.0-beta.371] - 2024-11-22
+
+### 🐛 Bug Fixes
+
+- Improve helper text for metrics input fields
+- Refine helper text for metrics input fields
+- If mux conn fails, still use it without mux + save priv key with better logic
+- Migration
+- Always validate ssh key
+- Make sure important jobs/actions are running on high prio queue
+- Do not send internal notification for backups and status jobs
+- Validateconnection
+- View issue
+- Heading
+- Remove mux cleanup
+- Db backup for services
+- Version should come from constants + fix stripe webhook error reporting
+- Undefined variable
+- Remove version.php as everything is coming from constants.php
+- Sentry error
+- Websocket connections autoreconnect
+- Sentry error
+- Sentry
+- Empty server API response
+- Incorrect server API patch response
+- Missing `uuid` parameter on server API patch
+- Missing `settings` property on servers API
+- Move servers API `delete_unused_*` properties
+- Servers API returning `port` as a string -> integer
+- Only return server uuid on server update
+
+## [4.0.0-beta.370] - 2024-11-15
+
+### 🐛 Bug Fixes
+
+- Modal (+ add) on dynamic config was not opening, removed x-cloak
+- AUTOUPDATE + checkbox opacity
+
+## [4.0.0-beta.369] - 2024-11-15
+
+### 🐛 Bug Fixes
+
+- Modal-input
+
+## [4.0.0-beta.368] - 2024-11-15
+
+### 🚀 Features
+
+- Check local horizon scheduler deployments
+- Add internal api docs to /docs/api with auth
+- Add proxy type change to create/update apis
+
+### 🐛 Bug Fixes
+
+- Show proper error message on invalid Git source
+- Convert HTTP to SSH source when using deploy key on GitHub
+- Cloud + stripe related
+- Terminal view loading in async
+- Cool 500 error (thanks hugodos)
+- Update schema in code decorator
+- Openapi docs
+- Add tests for git url converts
+- Minio / logto url generation
+- Admin view
+- Min docker version 26
+- Pull latest service-templates.json on init
+- Workflow files for coolify build
+- Autocompletes
+- Timezone settings validation
+- Invalid tz should not prevent other jobs to be executed
+- Testing-host should be built locally
+- Poll with modal issue
+- Terminal opening issue
+- If service img not found, use github as a source
+- Fallback to local coolify.png
+- Gather private ips
+- Cf tunnel menu should be visible when server is not validated
+- Deployment optimizations
+- Init script + optimize laravel
+- Default docker engine version + fix install script
+- Pull helper image on init
+- SPA static site default nginx conf
+
+### 💼 Other
+
+- Https://github.com/coollabsio/coolify/issues/4186
+- Separate resources by type in projects view
+- Improve s3 add view
+
+### ⚙️ Miscellaneous Tasks
+
+- Update dep
+
+## [4.0.0-beta.365] - 2024-11-11
+
+### 🚀 Features
+
+- Custom nginx configuration for static deployments + fix 404 redirects in nginx conf
+
+### 🐛 Bug Fixes
+
+- Trigger.dev db host & sslmode=disable
+- Manual update should be executed only once + better UX
+- Upgrade.sh
+- Missing privateKey
+
+## [4.0.0-beta.364] - 2024-11-08
+
+### 🐛 Bug Fixes
+
+- Define separate volumes for mattermost service template
+- Github app name is too long
+- ServerTimezone update
+
+### ⚙️ Miscellaneous Tasks
+
+- Edit www helper
+
+## [4.0.0-beta.363] - 2024-11-08
+
+### 🚀 Features
+
+- Add Firefox template
+- Add template for Wiki.js
+- Add upgrade logs to /data/coolify/source
+
+### 🐛 Bug Fixes
+
+- Saving resend api key
+- Wildcard domain save
+- Disable cloudflare tunnel on "localhost"
+
+## [4.0.0-beta.362] - 2024-11-08
+
+### 🐛 Bug Fixes
+
+- Notifications ui
+- Disable wire:navigate
+- Confirmation Settings css for light mode
+- Server wildcard
+
+## [4.0.0-beta.361] - 2024-11-08
+
+### 🚀 Features
+
+- Add Transmission template
+- Add transmission healhcheck
+- Add zipline template
+- Dify template
+- Required envs
+- Add EdgeDB
+- Show warning if people would like to use sslip with https
+- Add is shared to env variables
+- Variabel sync and support shared vars
+- Add notification settings to server_disk_usage
+- Add coder service tamplate and logo
+- Debug mode for sentinel
+- Add jitsi template
+- Add --gpu support for custom docker command
+
+### 🐛 Bug Fixes
+
+- Make sure caddy is not removed by cleanup
+- Libretranslate
+- Do not allow to change number of lines when streaming logs
+- Plunk
+- No manual timezones
+- Helper push
+- Format
+- Add port metadata and Coolify magic to generate the domain
+- Sentinel
+- Metrics
+- Generate sentinel url
+- Only enable Sentinel for new servers
+- Is_static through API
+- Allow setting standalone redis variables via ENVs (team variables...)
+- Check for username separately form password
+- Encrypt all existing redis passwords
+- Pull helper image on helper_version change
+- Redis database user and password
+- Able to update ipv4 / ipv6 instance settings
+- Metrics for dbs
+- Sentinel start fixed
+- Validate sentinel custom URL when enabling sentinel
+- Should be able to reset labels in read-only mode with manual click
+- No sentinel for swarm yet
+- Charts ui
+- Volume
+- Sentinel config changes restarts sentinel
+- Disable sentinel for now
+- Disable Sentinel temporarily
+- Disable Sentinel temporarily for non-dev environments
+- Access team's github apps only
+- Admins should now invite owner
+- Add experimental flag
+- GenerateSentinelUrl method
+- NumberOfLines could be null
+- Login / register view
+- Restart sentinel once a day
+- Changing private key manually won't trigger a notification
+- Grammar for helper
+- Fix my own grammar
+- Add telescope only in dev mode
+- New way to update container statuses
+- Only run server storage every 10 mins if sentinel is not active
+- Cloud admin view
+- Queries in kernel.php
+- Lower case emails only
+- Change emails to lowercase on init
+- Do not error on update email
+- Always authenticate with lowercase emails
+- Dashboard refactor
+- Add min/max length to input/texarea
+- Remove livewire legacy from help view
+- Remove unnecessary endpoints (magic)
+- Transactional email livewire
+- Destinations livewire refactor
+- Refactor destination/docker view
+- Logdrains validation
+- Reworded
+- Use Auth(), add new db proxy stop event refactor clickhouse view
+- Add user/pw to db view
+- Sort servers by name
+- Keydb view
+- Refactor tags view / remove obsolete one
+- Send discord/telegram notifications on high job queue
+- Server view refresh on validation
+- ShowBoarding
+- Show docker installation logs & ubuntu 24.10 notification
+- Do not overlap servercheckjob
+- Server limit check
+- Server validation
+- Clear route / view
+- Only skip docker installation on 24.10 if its not installed
+- For --gpus device support
+- Db/service start should be on high queue
+- Do not stop sentinel on Coolify restart
+- Run resourceCheck after new serviceCheckJob
+- Mongodb in dev
+- Better invitation errors
+- Loading indicator for db proxies
+- Do not execute gh workflow on template changes
+- Only use sentry in cloud
+- Update packagejson of coolify-realtime + add lock file
+- Update last online with old function
+- Seeder should not start sentinel
+- Start sentinel on seeder
+
+### 💼 Other
+
+- Add peppermint
+- Loggy
+- Add UI for redis password and username
+- Wireguard-easy template
+
+### 📚 Documentation
+
+- Update link to deploy api docs
+
+### ⚙️ Miscellaneous Tasks
+
+- Add transmission template desc
+- Update transmission docs link
+- Update version numbers to 4.0.0-beta.360 in configuration files
+- Update AWS environment variable names in unsend.yaml
+- Update AWS environment variable names in unsend.yaml
+- Update livewire/livewire dependency to version 3.4.9
+- Update version to 4.0.0-beta.361
+- Update Docker build and push actions to v6
+- Update Docker build and push actions to v6
+- Update Docker build and push actions to v6
+- Sync coolify-helper to dockerhub as well
+- Push realtime to dockerhub
+- Sync coolify-realtime to dockerhub
+- Rename workflows
+- Rename development to staging build
+- Sync coolify-testing-host to dockerhbu
+- Sync coolify prod image to dockerhub as well
+- Update Docker version to 26.0
+- Update project resource index page
+- Update project service configuration view
+
+## [4.0.0-beta.360] - 2024-10-11
+
+### ⚙️ Miscellaneous Tasks
+
+- Update livewire/livewire dependency to version 3.4.9
+
+## [4.0.0-beta.359] - 2024-10-11
+
+### 🐛 Bug Fixes
+
+- Use correct env variable for invoice ninja password
+
+### ⚙️ Miscellaneous Tasks
+
+- Update laravel/horizon dependency to version 5.29.1
+- Update service extra fields to use dynamic keys
+
+## [4.0.0-beta.358] - 2024-10-10
+
+### 🚀 Features
+
+- Add customHelper to stack-form
+- Add cloudbeaver template
+- Add ntfy template
+- Add qbittorrent template
+- Add Homebox template
+- Add owncloud service and logo
+- Add immich service
+- Auto generate url
+- Refactored to work with coolify auto env vars
+- Affine service template and logo
+- Add LibreTranslate template
+- Open version in a new tab
+
+### 🐛 Bug Fixes
+
+- Signup
+- Application domains should be http and https only
+- Validate and sanitize application domains
+- Sanitize and validate application domains
+
+### 💼 Other
+
+- Other DB options for freshrss
+- Nextcloud MariaDB and MySQL versions
+
+### ⚙️ Miscellaneous Tasks
+
+- Fix form submission and keydown event handling in modal-confirmation.blade.php
+- Update version numbers to 4.0.0-beta.359 in configuration files
+- Disable adding default environment variables in shared.php
+
+## [4.0.0-beta.357] - 2024-10-08
+
+### 🚀 Features
+
+- Add Mautic 4 and 5 to service templates
+- Add keycloak template
+- Add onedev template
+- Improve search functionality in project selection
+
+### 🐛 Bug Fixes
+
+- Update mattermost image tag and add default port
+- Remove env, change timezone
+- Postgres healthcheck
+- Azimutt template - still not working haha
+- New parser with SERVICE_URL_ envs
+- Improve service template readability
+- Update password variables in Service model
+- Scheduled database server
+- Select server view
+
+### 💼 Other
+
+- Keycloak
+
+### ⚙️ Miscellaneous Tasks
+
+- Add mattermost logo as svg
+- Add mattermost svg to compose
+- Update version to 4.0.0-beta.357
+
+## [4.0.0-beta.356] - 2024-10-07
+
+### 🚀 Features
+
+- Add Argilla service configuration to Service model
+- Add Invoice Ninja service configuration to Service model
+- Project search on frontend
+- Add ollama service with open webui and logo
+- Update setType method to use slug value for type
+- Refactor setType method to use slug value for type
+- Refactor setType method to use slug value for type
+- Add Supertokens template
+- Add easyappointments service template
+- Add dozzle template
+- Adds forgejo service with runners
+
+### 🐛 Bug Fixes
+
+- Reset description and subject fields after submitting feedback
+- Tag mass redeployments
+- Service env orders, application env orders
+- Proxy conf in dev
+- One-click services
+- Use local service-templates in dev
+- New services
+- Remove not used extra host
+- Chatwoot service
+- Directus
+- Database descriptions
+- Update services
+- Soketi
+- Select server view
+
+### 💼 Other
+
+- Update helper version
+- Outline
+- Directus
+- Supertokens
+- Supertokens json
+- Rabbitmq
+- Easyappointments
+- Soketi
+- Dozzle
+- Windmill
+- Coolify.json
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.356
+- Remove commented code for shared variable type validation
+- Update MariaDB image to version 11 and fix service environment variable orders
+- Update anythingllm.yaml volumes configuration
+- Update proxy configuration paths for Caddy and Nginx in dev
+- Update password form submission in modal-confirmation component
+- Update project query to order by name in uppercase
+- Update project query to order by name in lowercase
+- Update select.blade.php with improved search functionality
+- Add Nitropage service template and logo
+- Bump coolify-helper version to 1.0.2
+- Refactor loadServices2 method and remove unused code
+- Update version to 4.0.0-beta.357
+- Update service names and volumes in windmill.yaml
+- Update version to 4.0.0-beta.358
+- Ignore .ignition.json files in Docker and Git
+
+## [4.0.0-beta.355] - 2024-10-03
+
+### 🐛 Bug Fixes
+
+- Scheduled backup for services view
+- Parser, espacing container labels
+
+### ⚙️ Miscellaneous Tasks
+
+- Update homarr service template and remove unnecessary code
+- Update version to 4.0.0-beta.355
+
+## [4.0.0-beta.354] - 2024-10-03
+
+### 🚀 Features
+
+- Add it-tools service template and logo
+- Add homarr service tamplate and logo
+
+### 🐛 Bug Fixes
+
+- Parse proxy config and check the set ports usage
+- Update FQDN
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.354
+- Remove debug statement in Service model
+- Remove commented code in Server model
+- Fix application deployment queue filter logic
+- Refactor modal-confirmation component
+- Update it-tools service template and port configuration
+- Update homarr service template and remove unnecessary code
+
+## [4.0.0-beta.353] - 2024-10-03
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.353
+- Update service application view
+
+## [4.0.0-beta.352] - 2024-10-03
+
+### 🐛 Bug Fixes
+
+- Service application view
+- Add new supported database images
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.352
+- Refactor DatabaseBackupJob to handle missing team
+
+## [4.0.0-beta.351] - 2024-10-03
+
+### 🚀 Features
+
+- Add strapi template
+
+### 🐛 Bug Fixes
+
+- Able to support more database dynamically from Coolify's UI
+- Strapi template
+- Bitcoin core template
+- Api useBuildServer
+
+## [4.0.0-beta.349] - 2024-10-01
+
+### 🚀 Features
+
+- Add command to check application deployment queue
+- Support Hetzner S3
+- Handle HTTPS domain in ConfigureCloudflareTunnels
+- Backup all databases for mysql,mariadb,postgresql
+- Restart service without pulling the latest image
+
+### 🐛 Bug Fixes
+
+- Remove autofocuses
+- Ipv6 scp should use -6 flag
+- Cleanup stucked applicationdeploymentqueue
+- Realtime watch in development mode
+- Able to select root permission easier
+
+### 💼 Other
+
+- Show backup button on supported db service stacks
+
+### 🚜 Refactor
+
+- Remove deployment queue when deleting an application
+- Improve SSH command generation in Terminal.php and terminal-server.js
+- Fix indentation in modal-confirmation.blade.php
+- Improve parsing of commands for sudo in parseCommandsByLineForSudo
+- Improve popup component styling and button behavior
+- Encode delimiter in SshMultiplexingHelper
+- Remove inactivity timer in terminal-server.js
+- Improve socket reconnection interval in terminal.js
+- Remove unnecessary watch command from soketi service entrypoint
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version numbers to 4.0.0-beta.350 in configuration files
+- Update command signature and description for cleanup application deployment queue
+- Add missing import for Attribute class in ApplicationDeploymentQueue model
+- Update modal input in server form to prevent closing on outside click
+- Remove unnecessary command from SshMultiplexingHelper
+- Remove commented out code for uploading to S3 in DatabaseBackupJob
+- Update soketi service image to version 1.0.3
+
+## [4.0.0-beta.348] - 2024-10-01
+
+### 🚀 Features
+
+- Update resource deletion job to allow configurable options through API
+- Add query parameters for deleting configurations, volumes, docker cleanup, and connected networks
+
+### 🐛 Bug Fixes
+
+- In dev mode do not ask confirmation on delete
+- Mixpost
+- Handle deletion of 'hello' in confirmation modal for dev environment
+
+### 💼 Other
+
+- Server storage check
+
+### 🚜 Refactor
+
+- Update search input placeholder in resource index view
+
+### ⚙️ Miscellaneous Tasks
+
+- Fix docs link in running state
+- Update Coolify Realtime workflow to only trigger on the main branch
+- Refactor instanceSettings() function to improve code readability
+- Update Coolify Realtime image to version 1.0.2
+- Remove unnecessary code in DatabaseBackupJob.php
+- Add "Not Usable" indicator for storage items
+- Refactor instanceSettings() function and improve code readability
+- Update version numbers to 4.0.0-beta.349 and 4.0.0-beta.350
+
+## [4.0.0-beta.347] - 2024-09-28
+
+### 🚀 Features
+
+- Allow specify use_build_server when creating/updating an application
+- Add support for `use_build_server` in API endpoints for creating/updating applications
+- Add Mixpost template
+
+### 🐛 Bug Fixes
+
+- Filebrowser template
+- Edit is_build_server_enabled upon creating application on other application type
+- Save settings after assigning value
+
+### 💼 Other
+
+- Remove memlock as it caused problems for some users
+
+### ⚙️ Miscellaneous Tasks
+
+- Update Mailpit logo to use SVG format
+
+## [4.0.0-beta.346] - 2024-09-27
+
+### 🚀 Features
+
+- Add ContainerStatusTypes enum for managing container status
+
+### 🐛 Bug Fixes
+
+- Proxy fixes
+- Proxy
+- *(templates)* Filebrowser FQDN env variable
+- Handle edge case when build variables and env variables are in different format
+- Compose based terminal
+
+### 💼 Other
+
+- Manual cleanup button and unused volumes and network deletion
+- Force helper image removal
+- Use the new confirmation flow
+- Typo
+- Typo in install script
+- If API is disabeled do not show API token creation stuff
+- Disable API by default
+- Add debug bar
+
+### 🚜 Refactor
+
+- Update environment variable name for uptime-kuma service
+- Improve start proxy script to handle existing containers gracefully
+- Update delete server confirmation modal buttons
+- Remove unnecessary code
+
+### ⚙️ Miscellaneous Tasks
+
+- Add autocomplete attribute to input fields
+- Refactor API Tokens component to use isApiEnabled flag
+- Update versions.json file
+- Remove unused .env.development.example file
+- Update API Tokens view to include link to Settings menu
+- Update web.php to cast server port as integer
+- Update backup deletion labels to use language files
+- Update database startup heading title
+- Update database startup heading title
+- Custom vite envs
+- Update version numbers to 4.0.0-beta.348
+- Refactor code to improve SSH key handling and storage
+
+## [4.0.0-beta.343] - 2024-09-25
+
+### 🐛 Bug Fixes
+
+- Parser
+- Exited services statuses
+- Make sure to reload window if app status changes
+- Deploy key based deployments
+
+### 🚜 Refactor
+
+- Remove commented out code and improve environment variable handling in newParser function
+- Improve label positioning in input and checkbox components
+- Group and sort fields in StackForm by service name and password status
+- Improve layout and add checkbox for task enablement in scheduled task form
+- Update checkbox component to support full width option
+- Update confirmation label in danger.blade.php template
+- Fix typo in execute-container-command.blade.php
+- Update OS_TYPE for Asahi Linux in install.sh script
+- Add localhost as Server if it doesn't exist and not in cloud environment
+- Add localhost as Server if it doesn't exist and not in cloud environment
+- Update ProductionSeeder to fix issue with coolify_key assignment
+- Improve modal confirmation titles and button labels
+- Update install.sh script to remove redirection of upgrade output to /dev/null
+- Fix modal input closeOutside prop in configuration.blade.php
+- Add support for IPv6 addresses in sslip function
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version numbers to 4.0.0-beta.343
+- Update version numbers to 4.0.0-beta.344
+- Update version numbers to 4.0.0-beta.345
+- Update version numbers to 4.0.0-beta.346
+
+## [4.0.0-beta.342] - 2024-09-24
+
+### 🚀 Features
+
+- Add nullable constraint to 'fingerprint' column in private_keys table
+- *(api)* Add an endpoint to execute a command
+- *(api)* Add endpoint to execute a command
+
+### 🐛 Bug Fixes
+
+- Proxy status
+- Coolify-db should not be in the managed resources
+- Store original root key in the original location
+- Logto service
+- Cloudflared service
+- Migrations
+- Cloudflare tunnel configuration, ui, etc
+
+### 💼 Other
+
+- Volumes on development environment
+- Clean new volume name for dev volumes
+- Persist DBs, services and so on stored in data/coolify
+- Add SSH Key fingerprint to DB
+- Add a fingerprint to every private key on save, create...
+- Make sure invalid private keys can not be added
+- Encrypt private SSH keys in the DB
+- Add is_sftp and is_server_ssh_key coloums
+- New ssh key file name on disk
+- Store all keys on disk by default
+- Populate SSH key folder
+- Populate SSH keys in dev
+- Use new function names and logic everywhere
+- Create a Multiplexing Helper
+- SSH multiplexing
+- Remove unused code form multiplexing
+- SSH Key cleanup job
+- Private key with ID 2 on dev
+- Move more functions to the PrivateKey Model
+- Add ssh key fingerprint and generate one for existing keys
+- ID issues on dev seeders
+- Server ID 0
+- Make sure in use private keys are not deleted
+- Do not delete SSH Key from disk during server validation error
+- UI bug, do not write ssh key to disk in server dialog
+- SSH Multiplexing for Jobs
+- SSH algorhytm text
+- Few multiplexing things
+- Clear mux directory
+- Multiplexing do not write file manually
+- Integrate tow step process in the modal component WIP
+- Ability to hide labels
+- DB start, stop confirm
+- Del init script
+- General confirm
+- Preview deployments and typos
+- Service confirmation
+- Confirm file storage
+- Stop service confirm
+- DB image cleanup
+- Confirm ressource operation
+- Environment variabel deletion
+- Confirm scheduled tasks
+- Confirm API token
+- Confirm private key
+- Confirm server deletion
+- Confirm server settings
+- Proxy stop and restart confirmation
+- GH app deletion confirmation
+- Redeploy all confirmation
+- User deletion confirmation
+- Team deletion confirmation
+- Backup job confirmation
+- Delete volume confirmation
+- More conformations and fixes
+- Delete unused private keys button
+- Ray error because port is not uncommented
+- #3322 deploy DB alterations before updating
+- Css issue with advanced settings and remove cf tunnel in onboarding
+- New cf tunnel install flow
+- Made help text more clear
+- Cloudflare tunnel
+- Make helper text more clean to use a FQDN and not an URL
+
+### 🚜 Refactor
+
+- Update Docker cleanup label in Heading.php and Navbar.php
+- Remove commented out code in Navbar.php
+- Remove CleanupSshKeysJob from schedule in Kernel.php
+- Update getAJoke function to exclude offensive jokes
+- Update getAJoke function to use HTTPS for API request
+- Update CleanupHelperContainersJob to use more efficient Docker command
+- Update PrivateKey model to improve code readability and maintainability
+- Remove unnecessary code in PrivateKey model
+- Update PrivateKey model to use ownedByCurrentTeam() scope for cleanupUnusedKeys()
+- Update install.sh script to check if coolify-db volume exists before generating SSH key
+- Update ServerSeeder and PopulateSshKeysDirectorySeeder
+- Improve attribute sanitization in Server model
+- Update confirmation button text for deletion actions
+- Remove unnecessary code in shared.php file
+- Update environment variables for services in compose files
+- Update select.blade.php to improve trademarks policy display
+- Update select.blade.php to improve trademarks policy display
+- Fix typo in subscription URLs
+- Add Postiz service to compose file (disabled for now)
+- Update shared.php to include predefined ports for services
+- Simplify SSH key synchronization logic
+- Remove unused code in DatabaseBackupStatusJob and PopulateSshKeysDirectorySeeder
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version numbers to 4.0.0-beta.342
+- Update remove-labels-and-assignees-on-close.yml
+- Add SSH key for localhost in ProductionSeeder
+- Update SSH key generation in install.sh script
+- Update ProductionSeeder to call OauthSettingSeeder and PopulateSshKeysDirectorySeeder
+- Update install.sh to support Asahi Linux
+- Update install.sh version to 1.6
+- Remove unused middleware and uniqueId method in DockerCleanupJob
+- Refactor DockerCleanupJob to remove unused middleware and uniqueId method
+- Remove unused migration file for populating SSH keys and clearing mux directory
+- Add modified files to the commit
+- Refactor pre-commit hook to improve performance and readability
+- Update CONTRIBUTING.md with troubleshooting note about database migrations
+- Refactor pre-commit hook to improve performance and readability
+- Update cleanup command to use Redis instead of queue
+- Update Docker commands to start proxy
+
+## [4.0.0-beta.341] - 2024-09-18
+
+### 🚀 Features
+
+- Add buddy logo
+
+## [4.0.0-beta.336] - 2024-09-16
+
+### 🚀 Features
+
+- Make coolify full width by default
+- Fully functional terminal for command center
+- Custom terminal host
+
+### 🐛 Bug Fixes
+
+- Keep-alive ws connections
+- Add build.sh to debug logs
+- Update Coolify installer
+- Terminal
+- Generate https for minio
+- Install script
+- Handle WebSocket connection close in terminal.blade.php
+- Able to open terminal to any containers
+- Refactor run-command
+- If you exit a container manually, it should close the underlying tty as well
+- Move terminal to separate view on services
+- Only update helper image in DB
+- Generated fqdn for SERVICE_FQDN_APP_3000 magic envs
+
+### 💼 Other
+
+- Remove labels and assignees on issue close
+- Make sure this action is also triggered on PR issue close
+
+### 🚜 Refactor
+
+- Remove unnecessary code in ExecuteContainerCommand.php
+- Improve Docker network connection command in StartService.php
+- Terminal / run command
+- Add authorization check in ExecuteContainerCommand mount method
+- Remove unnecessary code in Terminal.php
+- Remove unnecessary code in Terminal.blade.php
+- Update WebSocket connection initialization in terminal.blade.php
+- Remove unnecessary console.log statements in terminal.blade.php
+
+### ⚙️ Miscellaneous Tasks
+
+- Update release version to 4.0.0-beta.336
+- Update coolify environment variable assignment with double quotes
+- Update shared.php to fix issues with source and network variables
+- Update terminal styling for better readability
+- Update button text for container connection form
+- Update Dockerfile and workflow for Coolify Realtime (v4)
+- Remove unused entrypoint script and update volume mapping
+- Update .env file and docker-compose configuration
+- Update APP_NAME environment variable in docker-compose.prod.yml
+- Update WebSocket URL in terminal.blade.php
+- Update Dockerfile and workflow for Coolify Realtime (v4)
+- Update Dockerfile and workflow for Coolify Realtime (v4)
+- Update Dockerfile and workflow for Coolify Realtime (v4)
+- Rename Command Center to Terminal in code and views
+- Update branch restriction for push event in coolify-helper.yml
+- Update terminal button text and layout in application heading view
+- Refactor terminal component and select form layout
+- Update coolify nightly version to 4.0.0-beta.335
+- Update helper version to 1.0.1
+- Fix syntax error in versions.json
+- Update version numbers to 4.0.0-beta.337
+- Update Coolify installer and scripts to include a function for fetching programming jokes
+- Update docker network connection command in ApplicationDeploymentJob.php
+- Add validation to prevent selecting 'default' server or container in RunCommand.php
+- Update versions.json to reflect latest version of realtime container
+- Update soketi image to version 1.0.1
+- Nightly - Update soketi image to version 1.0.1 and versions.json to reflect latest version of realtime container
+- Update version numbers to 4.0.0-beta.339
+- Update version numbers to 4.0.0-beta.340
+- Update version numbers to 4.0.0-beta.341
+
+### ◀️ Revert
+
+- Databasebackup
+
+## [4.0.0-beta.335] - 2024-09-12
+
+### 🐛 Bug Fixes
+
+- Cloudflare tunnel with new multiplexing feature
+
+### 💼 Other
+
+- SSH Multiplexing on docker desktop on Windows
+
+### ⚙️ Miscellaneous Tasks
+
+- Update release version to 4.0.0-beta.335
+- Update constants.ssh.mux_enabled in remoteProcess.php
+- Update listeners and proxy settings in server form and new server components
+- Remove unnecessary null check for proxy_type in generate_default_proxy_configuration
+- Remove unnecessary SSH command execution time logging
+
+## [4.0.0-beta.334] - 2024-09-12
+
+### ⚙️ Miscellaneous Tasks
+
+- Remove itsgoingd/clockwork from require-dev in composer.json
+- Update 'key' value of gitlab in Service.php to use environment variable
+
+## [4.0.0-beta.333] - 2024-09-11
+
+### 🐛 Bug Fixes
+
+- Disable mux_enabled during server validation
+- Move mc command to coolify image from helper
+- Keydb. add `:` delimiter for connection string
+
+### 💼 Other
+
+- Remote servers with port and user
+- Do not change localhost server name on revalidation
+- Release.md file
+
+### 🚜 Refactor
+
+- Improve handling of environment variable merging in upgrade script
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version numbers to 4.0.0-beta.333
+- Copy .env file to .env-{DATE} if it exists
+- Update .env file with new values
+- Update server check job middleware to use server ID instead of UUID
+- Add reminder to backup .env file before running install script again
+- Copy .env file to backup location during installation script
+- Add reminder to backup .env file during installation script
+- Update permissions in pr-build.yml and version numbers
+- Add minio/mc command to Dockerfile
+
+## [4.0.0-beta.332] - 2024-09-10
+
+### 🚀 Features
+
+- Expose project description in API response
+- Add elixir finetunes to the deployment job
+
+### 🐛 Bug Fixes
+
+- Reenable overlapping servercheckjob
+- Appwrite template + parser
+- Don't add `networks` key if `network_mode` is used
+- Remove debug statement in shared.php
+- Scp through cloudflare
+- Delete older versions of the helper image other than the latest one
+- Update remoteProcess.php to handle null values in logItem properties
+
+### 💼 Other
+
+- Set a default server timezone
+- Implement SSH Multiplexing
+- Enabel mux
+- Cleanup stale multiplexing connections
+
+### 🚜 Refactor
+
+- Improve environment variable handling in shared.php
+
+### ⚙️ Miscellaneous Tasks
+
+- Set timeout for ServerCheckJob to 60 seconds
+- Update appwrite.yaml to include OpenSSL key variable assignment
+
+## [4.0.0-beta.330] - 2024-09-06
+
+### 🐛 Bug Fixes
+
+- Parser
+- Plunk NEXT_PUBLIC_API_URI
+
+### 💼 Other
+
+- Pull helper image if not available otherwise s3 backup upload fails
+
+### 🚜 Refactor
+
+- Improve handling of server timezones in scheduled backups and tasks
+- Improve handling of server timezones in scheduled backups and tasks
+- Improve handling of server timezones in scheduled backups and tasks
+- Update cleanup schedule to run daily at midnight
+- Skip returning volume if driver type is cifs or nfs
+
+### ⚙️ Miscellaneous Tasks
+
+- Update coolify-helper.yml to get version from versions.json
+- Disable Ray by default
+- Enable Ray by default and update Dockerfile with latest versions of PACK and NIXPACKS
+- Update Ray configuration and Dockerfile
+- Add middleware for updating environment variables by UUID in `api.php` routes
+- Expose port 3000 in browserless.yaml template
+- Update Ray configuration and Dockerfile
+- Update coolify version to 4.0.0-beta.331
+- Update versions.json and sentry.php to 4.0.0-beta.332
+- Update version to 4.0.0-beta.332
+- Update DATABASE_URL in plunk.yaml to use plunk database
+- Add coolify.managed=true label to Docker image builds
+- Update docker image pruning command to exclude managed images
+- Update docker cleanup schedule to run daily at midnight
+- Update versions.json to version 1.0.1
+- Update coolify-helper.yml to include "next" branch in push trigger
+
+## [4.0.0-beta.326] - 2024-09-03
+
+### 🚀 Features
+
+- Update server_settings table to force docker cleanup
+- Update Docker Compose file with DB_URL environment variable
+- Refactor shared.php to improve environment variable handling
+
+### 🐛 Bug Fixes
+
+- Wrong executions order
+- Handle project not found error in environment_details API endpoint
+- Deployment running for - without "ago"
+- Update helper image pulling logic to only pull if the version is newer
+
+### 💼 Other
+
+- Plunk svg
+
+### 📚 Documentation
+
+- Update Plunk documentation link in compose/plunk.yaml
+
+### ⚙️ Miscellaneous Tasks
+
+- Update UI for displaying no executions found in scheduled task list
+- Update UI for displaying deployment status in deployment list
+- Update UI for displaying deployment status in deployment list
+- Ignore unnecessary files in production build workflow
+- Update server form layout and settings
+- Update Dockerfile with latest versions of PACK and NIXPACKS
+
+## [4.0.0-beta.324] - 2024-09-02
+
+### 🚀 Features
+
+- Preserve git repository with advanced file storages
+- Added Windmill template
+- Added Budibase template
+- Add shm-size for custom docker commands
+- Add custom docker container options to all databases
+- Able to select different postgres database
+- Add new logos for jobscollider and hostinger
+- Order scheduled task executions
+- Add Code Server environment variables to Service model
+- Add coolify build env variables to building phase
+- Add new logos for GlueOps, Ubicloud, Juxtdigital, Saasykit, and Massivegrid
+- Add new logos for GlueOps, Ubicloud, Juxtdigital, Saasykit, and Massivegrid
+
+### 🐛 Bug Fixes
+
+- Timezone not updated when systemd is missing
+- If volumes + file mounts are defined, should merge them together in the compose file
+- All mongo v4 backups should use the different backup command
+- Database custom environment variables
+- Connect compose apps to the right predefined network
+- Docker compose destination network
+- Server status when there are multiple servers
+- Sync fqdn change on the UI
+- Pr build names in case custom name is used
+- Application patch request instant_deploy
+- Canceling deployment on build server
+- Backup of password protected postgresql database
+- Docker cleanup job
+- Storages with preserved git repository
+- Parser parser parser
+- New parser only in dev
+- Parser parser
+- Numberoflines should be number
+- Docker cleanup job
+- Fix directory and file mount headings in file-storage.blade.php
+- Preview fqdn generation
+- Revert a few lines
+- Service ui sync bug
+- Setup script doesn't work on rhel based images with some curl variant already installed
+- Let's wait for healthy container during installation and wait an extra 20 seconds (for migrations)
+- Infra files
+- Log drain only for Applications
+- Copy large compose files through scp (not ssh)
+- Check if array is associative or not
+- Openapi endpoint urls
+- Convert environment variables to one format in shared.php
+- Logical volumes could be overwritten with new path
+- Env variable in value parsed
+- Pull coolify image only when the app needs to be updated
+
+### 💼 Other
+
+- Actually update timezone on the server
+- Cron jobs are executed based on the server timezone
+- Server timezone seeder
+- Recent backups UI
+- Use apt-get instead of apt
+- Typo
+- Only pull helper image if the version is newer than the one
+
+### 🚜 Refactor
+
+- Update event listeners in Show components
+- Refresh application to get latest database changes
+- Update RabbitMQ configuration to use environment variable for port
+- Remove debug statement in parseDockerComposeFile function
+- ParseServiceVolumes
+- Update OpenApi command to generate documentation
+- Remove unnecessary server status check in destination view
+- Remove unnecessary admin user email and password in budibase.yaml
+- Improve saving of custom internal name in Advanced.php
+- Add conditional check for volumes in generate_compose_file()
+- Improve storage mount forms in add.blade.php
+- Load environment variables based on resource type in sortEnvironmentVariables()
+- Remove unnecessary network cleanup in Init.php
+- Remove unnecessary environment variable checks in parseDockerComposeFile()
+- Add null check for docker_compose_raw in parseCompose()
+- Update dockerComposeParser to use YAML data from $yaml instead of $compose
+- Convert service variables to key-value pairs in parseDockerComposeFile function
+- Update database service name from mariadb to mysql
+- Remove unnecessary code in DatabaseBackupJob and BackupExecutions
+- Update Docker Compose parsing function to convert service variables to key-value pairs
+- Update Docker Compose parsing function to convert service variables to key-value pairs
+- Remove unused server timezone seeder and related code
+- Remove unused server timezone seeder and related code
+- Remove unused PullCoolifyImageJob from schedule
+- Update parse method in Advanced, All, ApplicationPreview, General, and ApplicationDeploymentJob classes
+- Remove commented out code for getIptables() in Dashboard.php
+- Update .env file path in install.sh script
+- Update SELF_HOSTED environment variable in docker-compose.prod.yml
+- Remove unnecessary code for creating coolify network in upgrade.sh
+- Update environment variable handling in StartClickhouse.php and ApplicationDeploymentJob.php
+- Improve handling of COOLIFY_URL in shared.php
+- Update build_args property type in ApplicationDeploymentJob
+- Update background color of sponsor section in README.md
+- Update Docker Compose location handling in PublicGitRepository
+- Upgrade process of Coolify
+
+### 🧪 Testing
+
+- More tests
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.324
+- New compose parser with tests
+- Update version to 1.3.4 in install.sh and 1.0.6 in upgrade.sh
+- Update memory limit to 64MB in horizon configuration
+- Update php packages
+- Update axios npm dependency to version 1.7.5
+- Update Coolify version to 4.0.0-beta.324 and fix file paths in upgrade script
+- Update Coolify version to 4.0.0-beta.324
+- Update Coolify version to 4.0.0-beta.325
+- Update Coolify version to 4.0.0-beta.326
+- Add cd command to change directory before removing .env file
+- Update Coolify version to 4.0.0-beta.327
+- Update Coolify version to 4.0.0-beta.328
+- Update sponsor links in README.md
+- Update version.json to versions.json in GitHub workflow
+- Cleanup stucked resources and scheduled backups
+- Update GitHub workflow to use versions.json instead of version.json
+- Update GitHub workflow to use versions.json instead of version.json
+- Update GitHub workflow to use versions.json instead of version.json
+- Update GitHub workflow to use jq container for version extraction
+- Update GitHub workflow to use jq container for version extraction
+
+## [4.0.0-beta.323] - 2024-08-08
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.323
+
+## [4.0.0-beta.322] - 2024-08-08
+
+### 🐛 Bug Fixes
+
+- Manual update process
+
+### 🚜 Refactor
+
+- Update Server model getContainers method to use collect() for containers and containerReplicates
+- Import ProxyTypes enum and use TRAEFIK instead of TRAEFIK_V2
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.322
+
+## [4.0.0-beta.321] - 2024-08-08
+
+### 🐛 Bug Fixes
+
+- Scheduledbackup not found
+
+### 🚜 Refactor
+
+- Update StandalonePostgresql database initialization and backup handling
+- Update cron expressions and add helper text for scheduled tasks
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.321
+
+## [4.0.0-beta.320] - 2024-08-08
+
+### 🚀 Features
+
+- Delete team in cloud without subscription
+- Coolify init should cleanup stuck networks in proxy
+- Add manual update check functionality to settings page
+- Update auto update and update check frequencies in settings
+- Update Upgrade component to check for latest version of Coolify
+- Improve homepage service template
+- Support map fields in Directus
+- Labels by proxy type
+- Able to generate only the required labels for resources
+
+### 🐛 Bug Fixes
+
+- Only append docker network if service/app is running
+- Remove lazy load from scheduled tasks
+- Plausible template
+- Service_url should not have a trailing slash
+- If usagebefore cannot be determined, cleanup docker with force
+- Async remote command
+- Only run logdrain if necessary
+- Remove network if it is only connected to coolify proxy itself
+- Dir mounts should have proper dirs
+- File storages (dir/file mount) handled properly
+- Do not use port exposes on docker compose buildpacks
+- Minecraft server template fixed
+- Graceful shutdown
+- Stop resources gracefully
+- Handle null and empty disk usage in DockerCleanupJob
+- Show latest version on manual update view
+- Empty string content should be saved as a file
+- Update Traefik labels on init
+- Add missing middleware for server check job
+
+### 🚜 Refactor
+
+- Update CleanupDatabase.php to adjust keep_days based on environment
+- Adjust keep_days in CleanupDatabase.php based on environment
+- Remove commented out code for cleaning up networks in CleanupDocker.php
+- Update livewire polling interval in heading.blade.php
+- Remove unused code for checking server status in Heading.php
+- Simplify log drain installation in ServerCheckJob
+- Remove unnecessary debug statement in ServerCheckJob
+- Simplify log drain installation and stop log drain if necessary
+- Cleanup unnecessary dynamic proxy configuration in Init command
+- Remove unnecessary debug statement in ApplicationDeploymentJob
+- Update timeout for graceful_shutdown_container in ApplicationDeploymentJob
+- Remove unused code and optimize CheckForUpdatesJob
+- Update ProxyTypes enum values to use TRAEFIK instead of TRAEFIK_V2
+- Update Traefik labels on init and cleanup unnecessary dynamic proxy configuration
+
+### 🎨 Styling
+
+- Linting
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.320
+- Add pull_request image builds to GH actions
+- Add comment explaining the purpose of disconnecting the network in cleanup_unused_network_from_coolify_proxy()
+- Update formbricks template
+- Update registration view to display a notice for first user that it will be an admin
+- Update server form to use password input for IP Address/Domain field
+- Update navbar to include service status check
+- Update navbar and configuration to improve service status check functionality
+- Update workflows to include PR build and merge manifest steps
+- Update UpdateCoolifyJob timeout to 10 minutes
+- Update UpdateCoolifyJob to dispatch CheckForUpdatesJob synchronously
+
+## [4.0.0-beta.319] - 2024-07-26
+
+### 🐛 Bug Fixes
+
+- Parse docker composer
+- Service env parsing
+- Service env variables
+- Activity type invalid
+- Update env on ui
+
+### 💼 Other
+
+- Service env parsing
+
+### ⚙️ Miscellaneous Tasks
+
+- Collect/create/update volumes in parseDockerComposeFile function
+
+## [4.0.0-beta.318] - 2024-07-24
+
+### 🚀 Features
+
+- Create/delete project endpoints
+- Add patch request to projects
+- Add server api endpoints
+- Add branddev logo to README.md
+- Update API endpoint summaries
+- Update Caddy button label in proxy.blade.php
+- Check custom internal name through server's applications.
+- New server check job
+
+### 🐛 Bug Fixes
+
+- Preview deployments should be stopped properly via gh webhook
+- Deleting application should delete preview deployments
+- Plane service images
+- Fix issue with deployment start command in ApplicationDeploymentJob
+- Directory will be created by default for compose host mounts
+- Restart proxy does not work + status indicator on the UI
+- Uuid in api docs type
+- Raw compose deployment .env not found
+- Api -> application patch endpoint
+- Remove pull always when uploading backup to s3
+- Handle array env vars
+- Link in task failed job notifications
+- Random generated uuid will be full length (not 7 characters)
+- Gitlab service
+- Gitlab logo
+- Bitbucket repository url
+- By default volumes that we cannot determine if they are directories or files are treated as directories
+- Domain update on services on the UI
+- Update SERVICE_FQDN/URL env variables when you change the domain
+- Several shared environment variables in one value, parsed correctly
+- Members of root team should not see instance admin stuff
+
+### 💼 Other
+
+- Formbricks template add required CRON_SECRET
+- Add required CRON_SECRET to Formbricks template
+
+### ⚙️ Miscellaneous Tasks
+
+- Update APP_BASE_URL to use SERVICE_FQDN_PLANE
+- Update resource-limits.blade.php with improved input field helpers
+- Update version numbers to 4.0.0-beta.319
+- Remove commented out code for docker image pruning
+
+## [4.0.0-beta.314] - 2024-07-15
+
+### 🚀 Features
+
+- Improve error handling in loadComposeFile method
+- Add readonly labels
+- Preserve git repository
+- Force cleanup server
+
+### 🐛 Bug Fixes
+
+- Typo in is_literal helper
+- Env is_literal helper text typo
+- Update docker compose pull command with --policy always
+- Plane service template
+- Vikunja
+- Docmost template
+- Drupal
+- Improve github source creation
+- Tag deployments
+- New docker compose parsing
+- Handle / in preselecting branches
+- Handle custom_internal_name check in ApplicationDeploymentJob.php
+- If git limit reached, ignore it and continue with a default selection
+- Backup downloads
+- Missing input for api endpoint
+- Volume detection (dir or file) is fixed
+- Supabase
+- Create file storage even if content is empty
+
+### 💼 Other
+
+- Add basedir + compose file in new compose based apps
+
+### 🚜 Refactor
+
+- Remove unused code and fix storage form layout
+- Update Docker Compose build command to include --pull flag
+- Update DockerCleanupJob to handle nullable usageBefore property
+- Server status job and docker cleanup job
+- Update DockerCleanupJob to use server settings for force cleanup
+- Update DockerCleanupJob to use server settings for force cleanup
+- Disable health check for Rust applications during deployment
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.315
+- Update version to 4.0.0-beta.316
+- Update bug report template
+- Update repository form with simplified URL input field
+- Update width of container in general.blade.php
+- Update checkbox labels in general.blade.php
+- Update general page of apps
+- Handle JSON parsing errors in format_docker_command_output_to_json
+- Update Traefik image version to v2.11
+- Update version to 4.0.0-beta.317
+- Update version to 4.0.0-beta.318
+- Update helper message with link to documentation
+- Disable health check by default
+- Remove commented out code for sending internal notification
+
+### ◀️ Revert
+
+- Pull policy
+- Advanced dropdown
+
+## [4.0.0-beta.308] - 2024-07-11
+
+### 🚀 Features
+
+- Cleanup unused docker networks from proxy
+- Compose parser v2
+- Display time interval for rollback images
+- Add security and storage access key env to twenty template
+- Add new logo for Latitude
+- Enable legacy model binding in Livewire configuration
+
+### 🐛 Bug Fixes
+
+- Do not overwrite hardcoded variables if they rely on another variable
+- Remove networks when deleting a docker compose based app
+- Api
+- Always set project name during app deployments
+- Remove volumes as well
+- Gitea pr previews
+- Prevent instance fqdn persisting to other servers dynamic proxy configs
+- Better volume cleanups
+- Cleanup parameter
+- Update redirect URL in unauthenticated exception handler
+- Respect top-level configs and secrets
+- Service status changed event
+- Disable sentinel until a few bugs are fixed
+- Service domains and envs are properly updated
+- *(reactive-resume)* New healthcheck command for MinIO
+- *(MinIO)* New command healthcheck
+- Update minio hc in services
+- Add validation for missing docker compose file
+
+### 🚜 Refactor
+
+- Add force parameter to StartProxy handle method
+- Comment out unused code for network cleanup
+- Reset default labels when docker_compose_domains is modified
+- Webhooks view
+- Tags view
+- Only get instanceSettings once from db
+- Update Dockerfile to set CI environment variable to true
+- Remove unnecessary code in AppServiceProvider.php
+- Update Livewire configuration views
+- Update Webhooks.php to use nullable type for webhook URLs
+- Add lazy loading to tags in Livewire configuration view
+- Update metrics.blade.php to improve alert message clarity
+- Update version numbers to 4.0.0-beta.312
+- Update version numbers to 4.0.0-beta.314
+
+### ⚙️ Miscellaneous Tasks
+
+- Update Plausible docker compose template to Plausible 2.1.0
+- Update Plausible docker compose template to Plausible 2.1.0
+- Update livewire/livewire dependency to version 3.4.9
+- Refactor checkIfDomainIsAlreadyUsed function
+- Update storage.blade.php view for livewire project service
+- Update version to 4.0.0-beta.310
+- Update composer dependencies
+- Add new logo for Latitude
+- Bump version to 4.0.0-beta.311
+
+### ◀️ Revert
+
+- Instancesettings
+
+## [4.0.0-beta.301] - 2024-06-24
+
+### 🚀 Features
+
+- Local fonts
+- More API endpoints
+- Bulk env update api endpoint
+- Update server settings metrics history days to 7
+- New app API endpoint
+- Private gh deployments through api
+- Lots of api endpoints
+- Api api api api api api
+- Rename CloudCleanupSubs to CloudCleanupSubscriptions
+- Early fraud warning webhook
+- Improve internal notification message for early fraud warning webhook
+- Add schema for uuid property in app update response
+
+### 🐛 Bug Fixes
+
+- Run user commands on high prio queue
+- Load js locally
+- Remove lemon + paddle things
+- Run container commands on high priority
+- Image logo
+- Remove both option for api endpoints. it just makes things complicated
+- Cleanup subs in cloud
+- Show keydbs/dragonflies/clickhouses
+- Only run cloud clean on cloud + remove root team
+- Force cleanup on busy servers
+- Check domain on new app via api
+- Custom container name will be the container name, not just internal network name
+- Api updates
+- Yaml everywhere
+- Add newline character to private key before saving
+- Add validation for webhook endpoint selection
+- Database input validators
+- Remove own app from domain checks
+- Return data of app update
+
+### 💼 Other
+
+- Update process
+- Glances service
+- Glances
+- Able to update application
+
+### 🚜 Refactor
+
+- Update Service model's saveComposeConfigs method
+- Add default environment to Service model's saveComposeConfigs method
+- Improve handling of default environment in Service model's saveComposeConfigs method
+- Remove commented out code in Service model's saveComposeConfigs method
+- Update stack-form.blade.php to include wire:target attribute for submit button
+- Update code to use str() instead of Str::of() for string manipulation
+- Improve formatting and readability of source.blade.php
+- Add is_build_time property to nixpacks_php_fallback_path and nixpacks_php_root_dir
+- Simplify code for retrieving subscription in Stripe webhook
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.302
+- Update version to 4.0.0-beta.303
+- Update version to 4.0.0-beta.305
+- Update version to 4.0.0-beta.306
+- Add log1x/laravel-webfonts package
+- Update version to 4.0.0-beta.307
+- Refactor ServerStatusJob constructor formatting
+- Update Monaco Editor for Docker Compose and Proxy Configuration
+- More details
+- Refactor shared.php helper functions
+
+## [4.0.0-beta.298] - 2024-06-24
+
+### 🚀 Features
+
+- Spanish translation
+- Cancelling a deployment will check if new could be started.
+- Add supaguide logo to donations section
+- Nixpacks now could reach local dbs internally
+- Add Tigris logo to other/logos directory
+- COOLIFY_CONTAINER_NAME predefined variable
+- Charts
+- Sentinel + charts
+- Container metrics
+- Add high priority queue
+- Add metrics warning for servers without Sentinel enabled
+- Add blacksmith logo to donations section
+- Preselect server and destination if only one found
+- More api endpoints
+- Add API endpoint to update application by UUID
+- Update statusnook logo filename in compose template
+
+### 🐛 Bug Fixes
+
+- Stripprefix middleware correctly labeled to http
+- Bitbucket link
+- Compose generator
+- Do no truncate repositories wtih domain (git) in it
+- In services should edit compose file for volumes and envs
+- Handle laravel deployment better
+- Db proxy status shown better in the UI
+- Show commit message on webhooks + prs
+- Metrics parsing
+- Charts
+- Application custom labels reset after saving
+- Static build with new nixpacks build process
+- Make server charts one livewire component with one interval selector
+- You can now add env variable from ui to services
+- Update compose environment with UI defined variables
+- Refresh deployable compose without reload
+- Remove cloud stripe notifications
+- App deployment should be in high queue
+- Remove zoom from modals
+- Get envs before sortby
+- MB is % lol
+- Projects with 0 envs
+
+### 💼 Other
+
+- Unnecessary notification
+
+### 🚜 Refactor
+
+- Update text color for stderr output in deployment show view
+- Update text color for stderr output in deployment show view
+- Remove debug code for saving environment variables
+- Update Docker build commands for better performance and flexibility
+- Update image sizes and add new logos to README.md
+- Update README.md with new logos and fix styling
+- Update shared.php to use correct key for retrieving sentinel version
+- Update container name assignment in Application model
+- Remove commented code for docker container removal
+- Update Application model to include getDomainsByUuid method
+- Update Project/Show component to sort environments by created_at
+- Update profile index view to display 2FA QR code in a centered container
+- Update dashboard.blade.php to use project's default environment for redirection
+- Update gitCommitLink method to handle null values in source.html_url
+- Update docker-compose generation to use multi-line literal block
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version numbers to 4.0.0-beta.298
+- Switch to database sessions from redis
+- Update dependencies and remove unused code
+- Update tailwindcss and vue versions in package.json
+- Update service template URL in constants.php
+- Update sentinel version to 0.0.8
+- Update chart styling and loading text
+- Update sentinel version to 0.0.9
+- Update Spanish translation for failed authentication messages
+- Add portuguese traslation
+- Add Turkish translations
+- Add Vietnamese translate
+- Add Treive logo to donations section
+- Update README.md with latest release version badge
+- Update latest release version badge in README.md
+- Update version to 4.0.0-beta.299
+- Move server delete component to the bottom of the page
+- Update version to 4.0.0-beta.301
+
+## [4.0.0-beta.297] - 2024-06-11
+
+### 🚀 Features
+
+- Easily redirect between www-and-non-www domains
+- Add logos for new sponsors
+- Add homepage template
+- Update homepage.yaml with environment variables and volumes
+
+### 🐛 Bug Fixes
+
+- Multiline build args
+- Setup script doesnt link to the correct source code file
+- Install.sh do not reinstall packages on arch
+- Just restart
+
+### 🚜 Refactor
+
+- Replaces duplications in code with a single function
+
+### ⚙️ Miscellaneous Tasks
+
+- Update page title in resource index view
+- Update logo file path in logto.yaml
+- Update logo file path in logto.yaml
+- Remove commented out code for docker container removal
+- Add isAnyDeploymentInprogress function to check if any deployments are in progress
+- Add ApplicationDeploymentJob and pint.json
+
+## [4.0.0-beta.295] - 2024-06-10
+
+### 🚀 Features
+
+- Able to change database passwords on the UI. It won't sync to the database.
+- Able to add several domains to compose based previews
+- Add bounty program link to bug report template
+- Add titles
+- Db proxy logs
+
+### 🐛 Bug Fixes
+
+- Custom docker compose commands, add project dir if needed
+- Autoupdate process
+- Backup executions view
+- Handle previously defined compose previews
+- Sort backup executions
+- Supabase service, newest versions
+- Set default name for Docker volumes if it is null
+- Multiline variable should be literal + should be multiline in bash with \
+- Gitlab merge request should close PR
+
+### 💼 Other
+
+- Rocketchat
+- New services based git apps
+
+### 🚜 Refactor
+
+- Append utm_source parameter to documentation URL
+- Update save_environment_variables method to use application's environment_variables instead of environment_variables_preview
+- Update deployment previews heading to "Deployments"
+- Remove unused variables and improve code readability
+- Initialize null properties in Github Change component
+- Improve pre and post deployment command inputs
+- Improve handling of Docker volumes in parseDockerComposeFile function
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version numbers to 4.0.0-beta.295
+- Update supported OS list with almalinux
+- Update install.sh to support PopOS
+- Update install.sh script to version 1.3.2 and handle Linux Mint as Ubuntu
+
+## [4.0.0-beta.294] - 2024-06-04
+
+### ⚙️ Miscellaneous Tasks
+
+- Update Dockerfile with latest versions of Docker, Docker Compose, Docker Buildx, Pack, and Nixpacks
+
+## [4.0.0-beta.289] - 2024-05-29
+
+### 🚀 Features
+
+- Add PHP memory limit environment variable to docker-compose.prod.yml
+- Add manual update option to UpdateCoolify handle method
+- Add port configuration for Vaultwarden service
+
+### 🐛 Bug Fixes
+
+- Sync upgrade process
+- Publish horizon
+- Add missing team model
+- Test new upgrade process?
+- Throw exception
+- Build server dirs not created on main server
+- Compose load with non-root user
+- Able to redeploy dockerfile based apps without cache
+- Compose previews does have env variables
+- Fine-tune cdn pulls
+- Spamming :D
+- Parse docker version better
+- Compose issues
+- SERVICE_FQDN has source port in it
+- Logto service
+- Allow invitations via email
+- Sort by defined order + fixed typo
+- Only ignore volumes with driver_opts
+- Check env in args for compose based apps
+
+### 🚜 Refactor
+
+- Update destination.blade.php to add group class for better styling
+- Applicationdeploymentjob
+- Improve code structure in ApplicationDeploymentJob.php
+- Remove unnecessary debug statement in ApplicationDeploymentJob.php
+- Remove unnecessary debug statements and improve code structure in RunRemoteProcess.php and ApplicationDeploymentJob.php
+- Remove unnecessary logging statements from UpdateCoolify
+- Update storage form inputs in show.blade.php
+- Improve Docker Compose parsing for services
+- Remove unnecessary port appending in updateCompose function
+- Remove unnecessary form class in profile index.blade.php
+- Update form layout in invite-link.blade.php
+- Add log entry when starting new application deployment
+- Improve Docker Compose parsing for services
+- Update Docker Compose parsing for services
+- Update slogan in shlink.yaml
+- Improve display of deployment time in index.blade.php
+- Remove commented out code for clearing Ray logs
+- Update save_environment_variables method to use application's environment_variables instead of environment_variables_preview
+
+### ⚙️ Miscellaneous Tasks
+
+- Update for version 289
+- Fix formatting issue in deployment index.blade.php file
+- Remove unnecessary wire:navigate attribute in breadcrumbs.blade.php
+- Rename docker dirs
+- Update laravel/socialite to version v5.14.0 and livewire/livewire to version 3.4.9
+- Update modal styles for better user experience
+- Update deployment index.blade.php script for better performance
+- Update version numbers to 4.0.0-beta.290
+- Update version numbers to 4.0.0-beta.291
+- Update version numbers to 4.0.0-beta.292
+- Update version numbers to 4.0.0-beta.293
+- Add upgrade guide link to upgrade.blade.php
+- Improve upgrade.blade.php with clearer instructions and formatting
+- Update version numbers to 4.0.0-beta.294
+- Add Lightspeed.run as a sponsor
+- Update Dockerfile to install vim
+
+## [4.0.0-beta.288] - 2024-05-28
+
+### 🐛 Bug Fixes
+
+- Do not allow service storage mount point modifications
+- Volume adding
+
+### ⚙️ Miscellaneous Tasks
+
+- Update Sentry release version to 4.0.0-beta.288
+
+## [4.0.0-beta.287] - 2024-05-27
+
+### 🚀 Features
+
+- Handle incomplete expired subscriptions in Stripe webhook
+- Add more persistent storage types
+
+### 🐛 Bug Fixes
+
+- Force load services from cdn on reload list
+
+### ⚙️ Miscellaneous Tasks
+
+- Update Sentry release version to 4.0.0-beta.287
+- Add Thompson Edolo as a sponsor
+- Add null checks for team in Stripe webhook
+
+## [4.0.0-beta.286] - 2024-05-27
+
+### 🚀 Features
+
+- If the time seems too long it remains at 0s
+- Improve Docker Engine start logic in ServerStatusJob
+- If proxy stopped manually, it won't start back again
+- Exclude_from_hc magic
+- Gitea manual webhooks
+- Add container logs in case the container does not start healthy
+
+### 🐛 Bug Fixes
+
+- Wrong time during a failed deployment
+- Removal of the failed deployment condition, addition of since started instead of finished time
+- Use local versions + service templates and query them every 10 minutes
+- Check proxy functionality before removing unnecessary coolify.yaml file and checking Docker Engine
+- Show first 20 users only in admin view
+- Add subpath for services
+- Ghost subdir
+- Do not pull templates in dev
+- Templates
+- Update error message for invalid token to mention invalid signature
+- Disable containerStopped job for now
+- Disable unreachable/revived notifications for now
+- JSON_UNESCAPED_UNICODE
+- Add wget to nixpacks builds
+- Pre and post deployment commands
+- Bitbucket commits link
+- Better way to add curl/wget to nixpacks
+- Root team able to download backups
+- Build server should not have a proxy
+- Improve build server functionalities
+- Sentry issue
+- Sentry
+- Sentry error + livewire downgrade
+- Sentry
+- Sentry
+- Sentry error
+- Sentry
+
+### 🚜 Refactor
+
+- Update edit-domain form in project service view
+- Add Huly services to compose file
+- Remove redundant heading in backup settings page
+- Add isBuildServer method to Server model
+- Update docker network creation in ApplicationDeploymentJob
+
+### ⚙️ Miscellaneous Tasks
+
+- Change pre and post deployment command length in applications table
+- Refactor container name logic in GetContainersStatus.php and ForcePasswordReset.php
+- Remove unnecessary content from Docker Compose file
+
+## [4.0.0-beta.285] - 2024-05-21
+
+### 🚀 Features
+
+- Add SerpAPI as a Github Sponsor
+- Admin view for deleting users
+- Scheduled task failed notification
+
+### 🐛 Bug Fixes
+
+- Optimize new resource creation
+- Show it docker compose has syntax errors
+
+### 💼 Other
+
+- Responsive here and there
+
+## [4.0.0-beta.284] - 2024-05-19
+
+### 🚀 Features
+
+- Add hc logs to healthchecks
+
+### ◀️ Revert
+
+- Hc return code check
+
+## [4.0.0-beta.283] - 2024-05-17
+
+### 🚀 Features
+
+- Update healthcheck test in StartMongodb action
+- Add pull_request_id filter to get_last_successful_deployment method in Application model
+
+### 🐛 Bug Fixes
+
+- PR deployments have good predefined envs
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.283
+
+## [4.0.0-beta.281] - 2024-05-17
+
+### 🚀 Features
+
+- Shows the latest deployment commit + message on status
+- New manual update process + remove next_channel
+- Add lastDeploymentInfo and lastDeploymentLink props to breadcrumbs and status components
+- Sort envs alphabetically and creation date
+- Improve sorting of environment variables in the All component
+
+### 🐛 Bug Fixes
+
+- Hc from localhost to 127.0.0.1
+- Use rc in hc
+- Telegram group chat notifications
+
+## [4.0.0-beta.280] - 2024-05-16
+
+### 🐛 Bug Fixes
+
+- Commit message length
+
+## [4.0.0-beta.279] - 2024-05-16
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version numbers to 4.0.0-beta.279
+- Limit commit message length to 50 characters in ApplicationDeploymentJob
+
+## [4.0.0-beta.278] - 2024-05-16
+
+### 🚀 Features
+
+- Adding new COOLIFY_ variables
+- Save commit message and better view on deployments
+- Toggle label escaping mechanism
+
+### 🐛 Bug Fixes
+
+- Use commit hash on webhooks
+
+### ⚙️ Miscellaneous Tasks
+
+- Refactor Service.php to handle missing admin user in extraFields() method
+- Update twenty CRM template with environment variables and dependencies
+- Refactor applications.php to remove unused imports and improve code readability
+- Refactor deployment index.blade.php for improved readability and rollback handling
+- Refactor GitHub app selection UI in project creation form
+- Update ServerLimitCheckJob.php to handle missing serverLimit value
+- Remove unnecessary code for saving commit message
+- Update DOCKER_VERSION to 26.0 in install.sh script
+- Update Docker and Docker Compose versions in Dockerfiles
+
+## [4.0.0-beta.277] - 2024-05-10
+
+### 🚀 Features
+
+- Add AdminRemoveUser command to remove users from the database
+
+### 🐛 Bug Fixes
+
+- Color for resource operation server and project name
+- Only show realtime error on non-cloud instances
+- Only allow push and mr gitlab events
+- Improve scheduled task adding/removing
+- Docker compose dependencies for pr previews
+- Properly populating dependencies
+
+### 💼 Other
+
+- Fix a few boxes here and there
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version numbers to 4.0.0-beta.278
+- Update hover behavior and cursor style in scheduled task executions view
+- Refactor scheduled task view to improve code readability and maintainability
+- Skip scheduled tasks if application or service is not running
+- Remove debug logging statements in Kernel.php
+- Handle invalid cron strings in Kernel.php
+
+## [4.0.0-beta.275] - 2024-05-06
+
+### 🚀 Features
+
+- Add container name to network aliases in ApplicationDeploymentJob
+- Add lazy loading for images in General.php and improve Docker Compose file handling in Application.php
+- Experimental sentinel
+- Start Sentinel on servers.
+- Pull new sentinel image and restart container
+- Init metrics
+
+### 🐛 Bug Fixes
+
+- Typo in tags.blade.php
+- Install.sh error
+- Env file
+- Comment out internal notification in email_verify method
+- Confirmation for custom labels
+- Change permissions on newly created dirs
+
+### 💼 Other
+
+- Fix tag view
+
+### 🚜 Refactor
+
+- Add SCHEDULER environment variable to StartSentinel.php
+
+### ⚙️ Miscellaneous Tasks
+
+- Dark mode should be the default
+- Improve menu item styling and spacing in service configuration and index views
+- Improve menu item styling and spacing in service configuration and index views
+- Improve menu item styling and spacing in project index and show views
+- Remove docker compose versions
+- Add Listmonk service template and logo
+- Refactor GetContainersStatus.php for improved readability and maintainability
+- Refactor ApplicationDeploymentJob.php for improved readability and maintainability
+- Add metrics and logs directories to installation script
+- Update sentinel version to 0.0.2 in versions.json
+- Update permissions on metrics and logs directories
+- Comment out server sentinel check in ServerStatusJob
+
+## [4.0.0-beta.273] - 2024-05-03
+
+### 🐛 Bug Fixes
+
+- Formbricks image origin
+- Add port even if traefik is used
+
+### ⚙️ Miscellaneous Tasks
+
+- Update version to 4.0.0-beta.275
+- Update DNS server validation helper text
+
+## [4.0.0-beta.267] - 2024-04-26
+
+### 🚀 Features
+
+- Initial datalist
+- Update service contribution docs URL
+- The final pricing plan, pay-as-you-go
+
+### 🐛 Bug Fixes
+
+- Move s3 storages to separate view
+- Mongo db backup
+- Backups
+- Autoupdate
+- Respect start period and chekc interval for hc
+- Parse HEALTHCHECK from dockerfile
+- Make s3 name and endpoint required
+- Able to update source path for predefined volumes
+- Get logs with non-root user
+- Mongo 4.0 db backup
+
+### 💼 Other
+
+- Update resource operations view
+
+### ◀️ Revert
+
+- Variable parsing
+
+## [4.0.0-beta.266] - 2024-04-24
+
+### 🐛 Bug Fixes
+
+- Refresh public ips on start
+
+## [4.0.0-beta.259] - 2024-04-17
+
+### 🚀 Features
+
+- Literal env variables
+- Lazy load stuffs + tell user if compose based deployments have missing envs
+- Can edit file/dir volumes from ui in compose based apps
+- Upgrade Appwrite service template to 1.5
+- Upgrade Appwrite service template to 1.5
+- Add db name to backup notifications
+
+### 🐛 Bug Fixes
+
+- Helper image only pulled if required, not every 10 mins
+- Make sure that confs when checking if it is changed sorted
+- Respect .env file (for default values)
+- Remove temporary cloudflared config
+- Remove lazy loading until bug figured out
+- Rollback feature
+- Base64 encode .env
+- $ in labels escaped
+- .env saved to deployment server, not to build server
+- Do no able to delete gh app without deleting resources
+- 500 error on edge case
+- Able to select server when creating new destination
+- N8n template
+
+### 💼 Other
+
+- Non-root user for remote servers
+- Non-root
+
+## [4.0.0-beta.258] - 2024-04-12
+
+### 🚀 Features
+
+- Dynamic mux time
+
+### 🐛 Bug Fixes
+
+- Check each required binaries one-by-one
+
+## [4.0.0-beta.256] - 2024-04-12
+
+### 🚀 Features
+
+- Upload large backups
+- Edit domains easier for compose
+- Able to delete configuration from server
+- Configuration checker for all resources
+- Allow tab in textarea
+
+### 🐛 Bug Fixes
+
+- Service config hash update
+- Redeploy if image not found in restart only mode
+
+### 💼 Other
+
+- New pricing
+- Fix allowTab logic
+- Use 2 space instead of tab
+
+## [4.0.0-beta.252] - 2024-04-09
+
+### 🚀 Features
+
+- Add amazon linux 2023
+
+### 🐛 Bug Fixes
+
+- Git submodule update
+- Unintended left padding on sidebar
+- Hashed random delimeter in ssh commands + make sure to remove the delimeter from the command
+
+## [4.0.0-beta.250] - 2024-04-05
+
+### 🚀 Features
+
+- *(application)* Update submodules after git checkout
+
+## [4.0.0-beta.249] - 2024-04-03
+
+### 🚀 Features
+
+- Able to make rsa/ed ssh keys
+
+### 🐛 Bug Fixes
+
+- Warning if you use multiple domains for a service
+- New github app creation
+- Always rebuild Dockerfile / dockerimage buildpacks
+- Do not rebuild dockerfile based apps twice
+- Make sure if envs are changed, rebuild is needed
+- Members cannot manage subscriptions
+- IsMember
+- Storage layout
+- How to update docker-compose, environment variables and fqdns
+
+### 💼 Other
+
+- Light buttons
+- Multiple server view
+
+## [4.0.0-beta.242] - 2024-03-25
+
+### 🚀 Features
+
+- Change page width
+- Watch paths
+
+### 🐛 Bug Fixes
+
+- Compose env has SERVICE, but not defined for Coolify
+- Public service database
+- Make sure service db proxy restarted
+- Restart service db proxies
+- Two factor
+- Ui for tags
+- Update resources view
+- Realtime connection check
+- Multline env in dev mode
+- Scheduled backup for other service databases (supabase)
+- PR deployments should not be distributed to 2 servers
+- Name/from address required for resend
+- Autoupdater
+- Async service loads
+- Disabled inputs are not trucated
+- Duplicated generated fqdns are now working
+- Uis
+- Ui for cftunnels
+- Search services
+- Trial users subscription page
+- Async public key loading
+- Unfunctional server should see resources
+
+### 💼 Other
+
+- Run cleanup every day
+- Fix
+- Fix log outputs
+- Automatic cloudflare tunnels
+- Backup executions
+
+## [4.0.0-beta.241] - 2024-03-20
+
+### 🚀 Features
+
+- Able to run scheduler/horizon programatically
+
+### 🐛 Bug Fixes
+
+- Volumes for prs
+- Shared env variable parsing
+
+### 💼 Other
+
+- Redesign
+- Redesign
+
+## [4.0.0-beta.240] - 2024-03-18
+
+### 🐛 Bug Fixes
+
+- Empty get logs number of lines
+- Only escape envs after v239+
+- 0 in env value
+- Consistent container name
+- Custom ip address should turn off rolling update
+- Multiline input
+- Raw compose deployment
+- Dashboard view if no project found
+
+## [4.0.0-beta.239] - 2024-03-14
+
+### 🐛 Bug Fixes
+
+- Duplicate dockerfile
+- Multiline env variables
+- Server stopped, service page not reachable
+
+## [4.0.0-beta.237] - 2024-03-14
+
+### 🚀 Features
+
+- Domains api endpoint
+- Resources api endpoint
+- Team api endpoint
+- Add deployment details to deploy endpoint
+- Add deployments api
+- Experimental caddy support
+- Dynamic configuration for caddy
+- Reset password
+- Show resources on source page
+
+### 🐛 Bug Fixes
+
+- Deploy api messages
+- Fqdn null in case docker compose bp
+- Reload caddy issue
+- /realtime endpoint
+- Proxy switch
+- Service ports for services + caddy
+- Failed deployments should send failed email/notification
+- Consider custom healthchecks in dockerfile
+- Create initial files async
+- Docker compose validation
+
+## [4.0.0-beta.235] - 2024-03-05
+
+### 🐛 Bug Fixes
+
+- Should note delete personal teams
+- Make sure to show some buttons
+- Sort repositories by name
+
+## [4.0.0-beta.224] - 2024-02-23
+
+### 🚀 Features
+
+- Custom server limit
+- Delay container/server jobs
+- Add static ipv4 ipv6 support
+- Server disabled by overflow
+- Preview deployment logs
+- Logs and execute commands with several servers
+
+### 🐛 Bug Fixes
+
+- Subscription / plan switch, etc
+- Firefly service
+- Force enable/disable server in case ultimate package quantity decreases
+- Server disabled
+- Custom dockerfile location always checked
+- Import to mysql and mariadb
+- Resource tab not loading if server is not reachable
+- Load unmanaged async
+- Do not show n/a networsk
+- Service container status updates
+- Public prs should not be commented
+- Pull request deployments + build servers
+- Env value generation
+- Sentry error
+- Service status updated
+
+### 💼 Other
+
+- Change + icon to hamburger.
+
+## [4.0.0-beta.222] - 2024-02-22
+
+### 🚀 Features
+
+- Able to add dynamic configurations from proxy dashboard
+
+### 🐛 Bug Fixes
+
+- Connections being stuck and not processed until proxy restarts
+- Use latest image if nothing is specified
+- No coolify.yaml found
+- Server validation
+- Statuses
+- Unknown image of service until it is uploaded
+
+## [4.0.0-beta.220] - 2024-02-19
+
+### 🚀 Features
+
+- Save github app permission locally
+- Minversion for services
+
+### 🐛 Bug Fixes
+
+- Add openbsd ssh server check
+- Resources
+- Empty build variables
+- *(server)* Revalidate server button not showing in server's page
+- Fluent bit ident level
+- Submodule cloning
+- Database status
+- Permission change updates from webhook
+- Server validation
+
+### 💼 Other
+
+- Updates
+
+## [4.0.0-beta.213] - 2024-02-12
+
+### 🚀 Features
+
+- Magic for traefik redirectregex in services
+- Revalidate server
+- Disable gzip compression on service applications
+
+### 🐛 Bug Fixes
+
+- Cleanup scheduled tasks
+- Padding left on input boxes
+- Use ls / command instead ls
+- Do not add the same server twice
+- Only show redeployment required if status is not exited
+
+## [4.0.0-beta.212] - 2024-02-08
+
+### 🚀 Features
+
+- Cleanup queue
+
+### 🐛 Bug Fixes
+
+- New menu on navbar
+- Make sure resources are deleted in async mode
+- Go to prod env from dashboard if there is no other envs defined
+- User proper image_tag, if set
+- New menu ui
+- Lock logdrain configuration when one of them are enabled
+- Add docker compose check during server validation
+- Get service stack as uuid, not name
+- Menu
+- Flex wrap deployment previews
+- Boolean docker options
+- Only add 'networks' key if 'network_mode' is absent
+
+## [4.0.0-beta.206] - 2024-02-05
+
+### 🚀 Features
+
+- Clone to env
+- Multi deployments
+
+### 🐛 Bug Fixes
+
+- Wrap tags and avoid horizontal overflow
+- Stripe webhooks
+- Feedback from self-hosted envs to discord
+
+### 💼 Other
+
+- Specific about newrelic logdrains
+
+## [4.0.0-beta.201] - 2024-01-29
+
+### 🚀 Features
+
+- Added manual webhook support for bitbucket
+- Add initial support for custom docker run commands
+- Cleanup unreachable servers
+- Tags and tag deploy webhooks
+
+### 🐛 Bug Fixes
+
+- Bitbucket manual deployments
+- Webhooks for multiple apps
+- Unhealthy deployments should be failed
+- Add env variables for wordpress template without database
+- Service deletion function
+- Service deletion fix
+- Dns validation + duplicated fqdns
+- Validate server navbar upated
+- Regenerate labels on application clone
+- Service deletion
+- Not able to use other shared envs
+- Sentry fix
+- Sentry
+- Sentry error
+- Sentry
+- Sentry error
+- Create dynamic directory
+- Migrate to new modal
+- Duplicate domain check
+- Tags
+
+### 💼 Other
+
+- New modal component
+
+## [4.0.0-beta.188] - 2024-01-11
+
+### 🚀 Features
+
+- Search between resources
+- Move resources between projects / environments
+- Clone any resource
+- Shared environments
+- Concurrent builds / server
+- Able to deploy multiple resources with webhook
+- Add PR comments
+- Dashboard live deployment view
+
+### 🐛 Bug Fixes
+
+- Preview deployments with nixpacks
+- Cleanup docker stuffs before upgrading
+- Service deletion command
+- Cpuset limits was determined in a way that apps only used 1 CPU max, ehh, sorry.
+- Service stack view
+- Change proxy view
+- Checkbox click
+- Git pull command for deploy key based previews
+- Server status job
+- Service deletion bug!
+- Links
+- Redis custom conf
+- Sentry error
+- Restrict concurrent deployments per server
+- Queue
+- Change env variable length
+
+### 💼 Other
+
+- Send notification email if payment
+
+### 🚜 Refactor
+
+- Compose file and install script
+
+## [4.0.0-beta.186] - 2024-01-11
+
+### 🚀 Features
+
+- Import backups
+
+### 🐛 Bug Fixes
+
+- Do not include thegameplan.json into build image
+- Submit error on postgresql
+- Email verification / forgot password
+- Escape build envs properly for nixpacks + docker build
+- Undead endpoint
+- Upload limit on ui
+- Save cmd output propely (merge)
+- Load profile on remote commands
+- Load profile and set envs on remote cmd
+- Restart should not update config hash
+
+## [4.0.0-beta.184] - 2024-01-09
+
+### 🐛 Bug Fixes
+
+- Healthy status
+- Show framework based notification in build logs
+- Traefik labels
+- Use ip for sslip in dev if remote server is used
+- Service labels without ports (unknown ports)
+- Sort and rename (unique part) of labels
+- Settings menu
+- Remove traefik debug in dev mode
+- Php pgsql to 8.2
+- Static buildpack should set port 80
+- Update navbar on build_pack change
+
+## [4.0.0-beta.183] - 2024-01-06
+
+### 🚀 Features
+
+- Add www-non-www redirects to traefik
+
+### 🐛 Bug Fixes
+
+- Database env variables
+
+## [4.0.0-beta.182] - 2024-01-04
+
+### 🐛 Bug Fixes
+
+- File storage save
+
+## [4.0.0-beta.181] - 2024-01-03
+
+### 🐛 Bug Fixes
+
+- Nixpacks buildpack
+
+## [4.0.0-beta.180] - 2024-01-03
+
+### 🐛 Bug Fixes
+
+- Nixpacks cache
+- Only add restart policy if its empty (compose)
+
+## [4.0.0-beta.179] - 2024-01-02
+
+### 🐛 Bug Fixes
+
+- Set deployment failed if new container is not healthy
+
+## [4.0.0-beta.177] - 2024-01-02
+
+### 🚀 Features
+
+- Raw docker compose deployments
+
+### 🐛 Bug Fixes
+
+- Duplicate compose variable
+
+## [4.0.0-beta.176] - 2023-12-31
+
+### 🐛 Bug Fixes
+
+- Horizon
+
+## [4.0.0-beta.175] - 2023-12-30
+
+### 🚀 Features
+
+- Add environment description + able to change name
+
+### 🐛 Bug Fixes
+
+- Sub
+- Wrong env variable parsing
+- Deploy key + docker compose
+
+## [4.0.0-beta.174] - 2023-12-27
+
+### 🐛 Bug Fixes
+
+- Restore falsely deleted coolify-db-backup
+
+## [4.0.0-beta.173] - 2023-12-27
+
+### 🐛 Bug Fixes
+
+- Cpu limit to float from int
+- Add source commit to final envs
+- Routing, switch back to old one
+- Deploy instead of restart in case swarm is used
+- Button title
+
+## [4.0.0-beta.163] - 2023-12-15
+
+### 🚀 Features
+
+- Custom docker compose commands
+
+### 🐛 Bug Fixes
+
+- Domains for compose bp
+- No action in webhooks
+- Add debug output to gitlab webhooks
+- Do not push dockerimage
+- Add alpha to swarm
+- Server not found
+- Do not autovalidate server on mount
+- Server update schedule
+- Swarm support ui
+- Server ready
+- Get swarm service logs
+- Docker compose apps env rewritten
+- Storage error on dbs
+- Why?!
+- Stay tuned
+
+### 💼 Other
+
+- Swarm
+- Swarm
+
+## [4.0.0-beta.155] - 2023-12-11
+
+### 🚀 Features
+
+- Autoupdate env during seed
+- Disable autoupdate
+- Randomly sleep between executions
+- Pull latest images for services
+
+### 🐛 Bug Fixes
+
+- Do not send telegram noti on intent payment failed
+- Database ui is realtime based
+- Live mode for github webhooks
+- Ui
+- Realtime connection popup could be disabled
+- Realtime check
+- Add new destination
+- Proxy logs
+- Db status check
+- Pusher host
+- Add ipv6
+- Realtime connection?!
+- Websocket
+- Better handling of errors with install script
+- Install script parse version
+- Only allow to modify in .env file if AUTOUPDATE is set
+- Is autoupdate not null
+- Run init command after production seeder
+- Init
+- Comma in traefik custom labels
+- Ignore if dynamic config could not be set
+- Service env variable ovewritten if it has a default value
+- Labelling
+- Non-ascii chars in labels
+- Labels
+- Init script echos
+- Update Coolify script
+- Null notify
+- Check queued deployments as well
+- Copy invitation
+- Password reset / invitation link requests
+- Add catch all route
+- Revert random container job delay
+- Backup executions view
+- Only check server status in container status job
+- Improve server status check times
+- Handle other types of generated values
+- Server checking status
+- Ui for adding new destination
+- Reset domains on compose file change
+
+### 💼 Other
+
+- Fix for comma in labels
+- Add image name to service stack + better options visibility
+
+### 🚜 Refactor
+
+- Service logs are now on one page
+- Application status changed realtime
+- Custom labels
+- Clone project
+
+## [4.0.0-beta.154] - 2023-12-07
+
+### 🚀 Features
+
+- Execute command in container
+
+### 🐛 Bug Fixes
+
+- Container selection
+- Service navbar using new realtime events
+- Do not create duplicated networks
+- Live event
+- Service start + event
+- Service deletion job
+- Double ws connection
+- Boarding view
+
+### 💼 Other
+
+- Env vars
+- Migrate to livewire 3
+
+## [4.0.0-beta.124] - 2023-11-13
+
+### 🚀 Features
+
+- Log drain (wip)
+- Enable/disable log drain by service
+- Log drainer container check
+- Add docker engine support install script to rhel based systems
+- Save timestamp configuration for logs
+- Custom log drain endpoints
+- Auto-restart tcp proxies for databases
+
+### 🐛 Bug Fixes
+
+- *(fider template)* Use the correct docs url
+- Fqdn for minio
+- Generate service fields
+- Mariadb backups
+- When to pull image
+- Do not allow to enter local ip addresses
+- Reset password
+- Only report nonruntime errors
+- Handle different label formats in services
+- Server adding process
+- Show defined resources in server tab, so you will know what you need to delete before you can delete the server.
+- Lots of regarding git + docker compose deployments
+- Pull request build variables
+- Double default password length
+- Do not remove deployment in case compose based failed
+- No container servers
+- Sentry issue
+- Dockercompose save ./ volumes under /data/coolify
+- Server view for link()
+- Default value do not overwrite existing env value
+- Use official install script with rancher (one will work for sure)
+- Add cf tunnel to boarding server view
+- Prevent autorefresh of proxy status
+- Missing docker image thing
+- Add hc for soketi
+- Deploy the right compose file
+- Bind volumes for compose bp
+- Use hc port 80 in case of static build
+- Switching to static build
+
+### 💼 Other
+
+- New deployment jobs
+- Compose based apps
+- Swarm
+- Swarm
+- Swarm
+- Swarm
+- Disable trial
+- Meilisearch
+- Broadcast
+- 🌮
+
+### 🚜 Refactor
+
+- Env variable generator
+
+### ◀️ Revert
+
+- Wip
+
+## [4.0.0-beta.109] - 2023-11-06
+
+### 🚀 Features
+
+- Deployment logs fullscreen
+- Service database backups
+- Make service databases public
+
+### 🐛 Bug Fixes
+
+- Missing environment variables prevewi on service
+- Invoice.paid should sleep for 5 seconds
+- Local dev repo
+- Deployments ui
+- Dockerfile build pack fix
+- Set labels on generate domain
+- Network service parse
+- Notification url in containerstatusjob
+- Gh webhook response 200 to installation_repositories
+- Delete destination
+- No id found
+- Missing $mailMessage
+- Set default from/sender names
+- No environments
+- Telegram text
+- Private key not found error
+- UI
+- Resourcesdelete command
+- Port number should be int
+- Separate delete with validation of server
+- Add nixpacks info
+- Remove filter
+- Container logs are now followable in full-screen and sorted by timestamp
+- Ui for labels
+- Ui
+- Deletions
+- Build_image not found
+- Github source view
+- Github source view
+- Dockercleanupjob should be released back
+- Ui
+- Local ip address
+- Revert workdir to basedir
+- Container status jobs for old pr deployments
+- Service updates
+
+## [4.0.0-beta.99] - 2023-10-24
+
+### 🚀 Features
+
+- Improve deployment time by a lot
+
+### 🐛 Bug Fixes
+
+- Space in build args
+- Lock SERVICE_FQDN envs
+- If user is invited, that means its email is verified
+- Force password reset on invited accounts
+- Add ssh options to git ls-remote
+- Git ls-remote
+- Remove coolify labels from ui
+
+### 💼 Other
+
+- Fix subs
+
+## [4.0.0-beta.97] - 2023-10-20
+
+### 🚀 Features
+
+- Standalone mongodb
+- Cloning project
+- Api tokens + deploy webhook
+- Start all kinds of things
+- Simple search functionality
+- Mysql, mariadb
+- Lock environment variables
+- Download local backups
+
+### 🐛 Bug Fixes
+
+- Service docs links
+- Add PGUSER to prevent HC warning
+- Preselect s3 storage if available
+- Port exposes change, shoud regenerate label
+- Boarding
+- Clone to with the same environment name
+- Cleanup stucked resources on start
+- Do not allow to delete env if a resource is defined
+- Service template generator + appwrite
+- Mongodb backup
+- Make sure coolfiy network exists on install
+- Syncbunny command
+- Encrypt mongodb password
+- Mongodb healtcheck command
+- Rate limit for api + add mariadb + mysql
+- Server settings guarded
+
+### 💼 Other
+
+- Generate services
+- Mongodb backup
+- Mongodb backup
+- Updates
+
+## [4.0.0-beta.93] - 2023-10-18
+
+### 🚀 Features
+
+- Able to customize docker labels on applications
+- Show if config is not applied
+
+### 🐛 Bug Fixes
+
+- Setup:dev script & contribution guide
+- Do not show configuration changed if config_hash is null
+- Add config_hash if its null (old deployments)
+- Label generation
+- Labels
+- Email channel no recepients
+- Limit horizon processes to 2 by default
+- Add custom port as ssh option to deploy_key based commands
+- Remove custom port from git repo url
+- ContainerStatus job
+
+### 💼 Other
+
+- PAT by team
+
+## [4.0.0-beta.92] - 2023-10-17
+
+### 🐛 Bug Fixes
+
+- Proxy start process
+
+## [4.0.0-beta.91] - 2023-10-17
+
+### 🐛 Bug Fixes
+
+- Always start proxy if not NONE is selected
+
+### 💼 Other
+
+- Add helper to service domains
+
+## [4.0.0-beta.90] - 2023-10-17
+
+### 🐛 Bug Fixes
+
+- Only include config.json if its exists and a file
+
+### 💼 Other
+
+- Wordpress
+
+## [4.0.0-beta.89] - 2023-10-17
+
+### 🐛 Bug Fixes
+
+- Noindex meta tag
+- Show docker build logs
+
+## [4.0.0-beta.88] - 2023-10-17
+
+### 🚀 Features
+
+- Use docker login credentials from server
+
+## [4.0.0-beta.87] - 2023-10-17
+
+### 🐛 Bug Fixes
+
+- Service status check is a bit better
+- Generate fqdn if you deleted a service app, but it requires fqdn
+- Cancel any deployments + queue next
+- Add internal domain names during build process
+
+## [4.0.0-beta.86] - 2023-10-15
+
+### 🐛 Bug Fixes
+
+- Build image before starting dockerfile buildpacks
+
+## [4.0.0-beta.85] - 2023-10-14
+
+### 🐛 Bug Fixes
+
+- Redis URL generated
+
+## [4.0.0-beta.83] - 2023-10-13
+
+### 🐛 Bug Fixes
+
+- Docker hub URL
+
+## [4.0.0-beta.70] - 2023-10-09
+
+### 🚀 Features
+
+- Add email verification for cloud
+- Able to deploy docker images
+- Add dockerfile location
+- Proxy logs on the ui
+- Add custom redis conf
+
+### 🐛 Bug Fixes
+
+- Server validation process
+- Fqdn could be null
+- Small
+- Server unreachable count
+- Do not reset unreachable count
+- Contact docs
+- Check connection
+- Server saving
+- No env goto envs from dashboard
+- Goto
+- Tcp proxy for dbs
+- Database backups
+- Only send email if transactional email set
+- Backupfailed notification is forced
+- Use port exposed for reverse proxy
+- Contact link
+- Use only ip addresses for servers
+- Deleted team and it is the current one
+- Add new team button
+- Transactional email link
+- Dashboard goto link
+- Only require registry image in case of dockerimage bp
+- Instant save build pack change
+- Public git
+- Cannot remove localhost
+- Check localhost connection
+- Send unreachable/revived notifications
+- Boarding + verification
+- Make sure proxy wont start in NONE mode
+- Service check status 10 sec
+- IsCloud in production seeder
+- Make sure to use IP address
+- Dockerfile location feature
+- Server ip could be hostname in self-hosted
+- Urls should be password fields
+- No backup for redis
+- Show database logs in case of its not healthy and running
+- Proxy check for ports, do not kill anything listening on port 80/443
+- Traefik dashboard ip
+- Db labels
+- Docker cleanup jobs
+- Timeout for instant remote processes
+- Dev containerjobs
+- Backup database one-by-one.
+- Turn off static deployment if you switch buildpacks
+
+### 💼 Other
+
+- Dockerimage
+- Updated dashboard
+- Fix
+- Fix
+- Coolify proxy access logs exposed in dev
+- Able to select environment on new resource
+- Delete server
+- Redis
+
+## [4.0.0-beta.58] - 2023-10-02
+
+### 🚀 Features
+
+- Reset root password
+- Attach Coolify defined networks to services
+- Delete resource command
+- Multiselect removable resources
+- Disable service, required version
+- Basedir / monorepo initial support
+- Init version of any git deployment
+- Deploy private repo with ssh key
+
+### 🐛 Bug Fixes
+
+- If waitlist is disabled, redirect to register
+- Add destination to new services
+- Predefined content for files
+- Move /data to ./_data in dev
+- UI
+- Show all storages in one place for services
+- Ui
+- Add _data to vite ignore
+- Only use _ in volume names for services
+- Volume names in services
+- Volume names
+- Service logs visible if the whole service stack is not running
+- Ui
+- Compose magic
+- Compose parser updated
+- Dev compose files
+- Traefik labels for multiport deployments
+- Visible version number
+- Remove SERVICE_ from deployable compose
+- Delete event to deleting
+- Move dev data to volumes to prevent permission issues
+- Traefik labelling in case of several http and https domain added
+- PR deployments use the first fqdn as base
+- Email notifications subscription fixed
+- Services - do not remove unnecessary things for now
+- Decrease max horizon processes to get lower memory usage
+- Test emails only available for user owned smtp/resend
+- Ui for self-hosted email settings
+- Set smtp notifications on by default
+- Select branch on other git
+- Private repository
+- Contribution guide
+- Public repository names
+- *(create)* Flex wrap on server & network selection
+- Better unreachable/revived server statuses
+- Able to set base dir for Dockerfile build pack
+
+### 💼 Other
+
+- Uptime kume hc updated
+- Switch back to /data (volume errors)
+- Notifications
+- Add shared email option to everyone
+
+## [4.0.0-beta.57] - 2023-10-02
+
+### 🚀 Features
+
+- Container logs
+
+### 🐛 Bug Fixes
+
+- Always pull helper image in dev
+- Only show last 1000 lines
+- Service status
+
+## [4.0.0-beta.47] - 2023-09-28
+
+### 🐛 Bug Fixes
+
+- Next helper image
+- Service templates
+- Sync:bunny
+- Update process if server has been renamed
+- Reporting handler
+- Localhost privatekey update
+- Remove private key in case you removed a github app
+- Only show manually added private keys on server view
+- Show source on all type of applications
+- Docker cleanup should be a job by server
+- File/dir based volumes are now read from the server
+- Respect server fqdn
+- If public repository does not have a main branch
+- Preselect branc on private repos
+- Deploykey branch
+- Backups are now working again
+- Not found base_branch in git webhooks
+- Coolify db backup
+- Preview deployments name, status etc
+- Services should have destination as well
+- Dockerfile expose is not overwritten
+- If app settings is not saved to db
+- Do not show subscription cancelled noti
+- Show real volume names
+- Only parse expose in dockerfiles if ports_exposes is empty
+- Add uuid to volume names
+- New volumes for services should have - instead of _
+
+### 💼 Other
+
+- Fix previews to preview
+
+## [4.0.0-beta.46] - 2023-09-28
+
+### 🐛 Bug Fixes
+
+- Containerstatusjob
+- Aaaaaaaaaaaaaaaaa
+- Services view
+- Services
+- Manually create network for services
+- Disable early updates
+- Sslip for localhost
+- ContainerStatusJob
+- Cannot delete env with available services
+- Sync command
+- Install script drops an error
+- Prevent sync version (it needs an option)
+- Instance fqdn setting
+- Sentry 4510197209
+- Sentry 4504136641
+- Sentry 4502634789
+
+## [4.0.0-beta.45] - 2023-09-24
+
+### 🚀 Features
+
+- Services
+- Image tag for services
+
+### 🐛 Bug Fixes
+
+- Applications with port mappins do a normal update (not rolling update)
+- Put back build pack chooser
+- Proxy configuration + starter
+- Show real storage name on services
+- New service template layout
+
+### 💼 Other
+
+- Fixed z-index for version link.
+- Add source button
+- Fixed z-index for magicbar
+- A bit better error
+- More visible feedback button
+- Update help modal
+- Help
+- Marketing emails
+
+## [4.0.0-beta.28] - 2023-09-08
+
+### 🚀 Features
+
+- Telegram topics separation
+- Developer view for env variables
+- Cache team settings
+- Generate public key from private keys
+- Able to invite more people at once
+- Trial
+- Dynamic trial period
+- Ssh-agent instead of filesystem based ssh keys
+- New container status checks
+- Generate ssh key
+- Sentry add email for better support
+- Healthcheck for apps
+- Add cloudflare tunnel support
+
+### 🐛 Bug Fixes
+
+- Db backup job
+- Sentry 4459819517
+- Sentry 4451028626
+- Ui
+- Retry notifications
+- Instance email settings
+- Ui
+- Test email on for admins or custom smtp
+- Coolify already exists should not throw error
+- Delete database related things when delete database
+- Remove -q from docker compose
+- Errors in views
+- Only send internal notifcations to enabled channels
+- Recovery code
+- Email sending error
+- Sentry 4469575117
+- Old docker version error
+- Errors
+- Proxy check, reduce jobs, etc
+- Queue after commit
+- Remove nixpkgarchive
+- Remove nixpkgarchive from ui
+- Webhooks should not run if server is not functional
+- Server is functional check
+- Confirm email before sending
+- Help should send cc on email
+- Sub type
+- Show help modal everywhere
+- Forgot password
+- Disable dockerfile based healtcheck for now
+- Add timeout for ssh commands
+- Prevent weird ui bug for validateServer
+- Lowercase email in forgot password
+- Lower case email on waitlist
+- Encrypt jobs
+- ProcessWithEnv()->run
+- Plus boarding step about Coolify
+- SaveConfigurationSync
+- Help uri
+- Sub for root
+- Redirect on server not found
+- Ip check
+- Uniqueips
+- Simply reply to help messages
+- Help
+- Rate limit
+- Collect billing address
+- Invitation
+- Smtp view
+- Ssh-agent revert
+- Restarting container state on ui
+- Generate new key
+- Missing upgrade js
+- Team error
+- 4.0.0-beta.37
+- Localhost
+- Proxy start (if not proxy defined, use Traefik)
+- Do not remove localhost in boarding
+- Allow non ip address (DNS)
+- InstallDocker id not found
+- Boarding
+- Errors
+- Proxy container status
+- Proxy configuration saving
+- Convert startProxy to action
+- Stop/start UI on apps and dbs
+- Improve localhost boarding process
+- Try to use old docker-compose
+- Boarding again
+- Send internal notifications of email errors
+- Add github app change on new app view
+- Delete environment variables on app/db delete
+- Save proxy configuration
+- Add proxy to network with periodic check
+- Proxy connections
+- Delete persistent storages on resource deletion
+- Prevent overwrite already existing env variables in services
+- Mappings
+- Sentry issue 4478125289
+- Make sure proxy path created
+- StartProxy
+- Server validation with cf tunnels
+- Only show traefik dashboard if its available
+- Services
+- Database schema
+- Report livewire errors
+- Links with path
+- Add traefik labels no matter if traefik is selected or not
+- Add expose port for containers
+- Also check docker socks permission on validation
+
+### 💼 Other
+
+- User should know that the public key
+- Services are not availble yet
+- Show registered users on waitlist page
+- Nixpacksarchive
+- Add Plausible analytics
+- Global env variables
+- Fix
+- Trial emails
+- Server check instead of app check
+- Show trial instead of sub
+- Server lost connection
+- Services
+- Services
+- Services
+- Ui for services
+- Services
+- Services
+- Services
+- Fixes
+- Fix typo
+
+## [4.0.0-beta.27] - 2023-09-08
+
+### 🐛 Bug Fixes
+
+- Bug
+
+## [4.0.0-beta.26] - 2023-09-08
+
+### 🚀 Features
+
+- Public database
+
+## [4.0.0-beta.25] - 2023-09-07
+
+### 🐛 Bug Fixes
+
+- SaveModel email settings
+
+## [4.0.0-beta.24] - 2023-09-06
+
+### 🚀 Features
+
+- Send request in cloud
+- Add discord notifications
+
+### 🐛 Bug Fixes
+
+- Form address
+- Show hosted email service, just disable for non pro subs
+- Add navbar for source + keys
+- Add docker network to build process
+- Overlapping apps
+- Do not show system wide git on cloud
+- Lowercase image names
+- Typo
+
+### 💼 Other
+
+- Backup existing database
+
+## [4.0.0-beta.23] - 2023-09-01
+
+### 🐛 Bug Fixes
+
+- Sentry bug
+- Button loading animation
+
+## [4.0.0-beta.22] - 2023-09-01
+
+### 🚀 Features
+
+- Add resend as transactional emails
+
+### 🐛 Bug Fixes
+
+- DockerCleanupjob
+- Validation
+- Webhook endpoint in cloud and no system wide gh app
+- Subscriptions
+- Password confirmation
+- Proxy start job
+- Dockerimage jobs are not overlapping
+
+## [4.0.0-beta.21] - 2023-08-27
+
+### 🚀 Features
+
+- Invite by email from waitlist
+- Rolling update
+
+### 🐛 Bug Fixes
+
+- Limits & server creation page
+- Fqdn on apps
+
+### 💼 Other
+
+- Boarding
+
+## [4.0.0-beta.20] - 2023-08-17
+
+### 🚀 Features
+
+- Send internal notification to discord
+- Monitor server connection
+
+### 🐛 Bug Fixes
+
+- Make coolify-db backups unique dir
+
+## [4.0.0-beta.19] - 2023-08-15
+
+### 🚀 Features
+
+- Pricing plans ans subs
+- Add s3 storages
+- Init postgresql database
+- Add backup notifications
+- Dockerfile build pack
+- Cloud
+- Force password reset + waitlist
+
+### 🐛 Bug Fixes
+
+- Remove buggregator from dev
+- Able to change localhost's private key
+- Readonly input box
+- Notifications
+- Licensing
+- Subscription link
+- Migrate db schema for smtp + discord
+- Text field
+- Null fqdn notifications
+- Remove old modal
+- Proxy stop/start ui
+- Proxy UI
+- Empty description
+- Input and textarea
+- Postgres_username name to not name, lol
+- DatabaseBackupJob.php
+- No storage
+- Backup now button
+- Ui + subscription
+- Self-hosted
+
+### 💼 Other
+
+- Scheduled backups
+
+## [4.0.0-beta.18] - 2023-07-14
+
+### 🚀 Features
+
+- Able to control multiplexing
+- Add runRemoteCommandSync
+- Github repo with deployment key
+- Add persistent volumes
+- Debuggable executeNow commands
+- Add private gh repos
+- Delete gh app
+- Installation/update github apps
+- Auto-deploy
+- Deploy key based deployments
+- Resource limits
+- Long running queue with 1 hour of timeout
+- Add arm build to dev
+- Disk cleanup threshold by server
+- Notify user of disk cleanup init
+
+### 🐛 Bug Fixes
+
+- Logo of CCCareers
+- Typo
+- Ssh
+- Nullable name on deploy_keys
+- Enviroments
+- Remove dd - oops
+- Add inprogress activity
+- Application view
+- Only set status in case the last command block is finished
+- Poll activity
+- Small typo
+- Show activity on load
+- Deployment should fail on error
+- Tests
+- Version
+- Status not needed
+- No project redirect
+- Gh actions
+- Set status
+- Seeders
+- Do not modify localhost
+- Deployment_uuid -> type_uuid
+- Read env from config, bc of cache
+- Private key change view
+- New destination
+- Do not update next channel all the time
+- Cancel deployment button
+- Public repo limit shown + branch should be preselected.
+- Better status on ui for apps
+- Arm coolify version
+- Formatting
+- Gh actions
+- Show github app secrets
+- Do not force next version updates
+- Debug log button
+- Deployment key based works
+- Deployment cancel/debug buttons
+- Upgrade button
+- Changing static build changes port
+- Overwrite default nginx configuration
+- Do not overlap docker image names
+- Oops
+- Found image name
+- Name length
+- Semicolons encoding by traefik
+- Base_dir wip & outputs
+- Cleanup docker images
+- Nginx try_files
+- Master is the default, not main
+- No ms in rate limit resets
+- Loading after button text
+- Default value
+- Localhost is usable
+- Update docker-compose prod
+- Cloud/checkoutid/lms
+- Type of license code
+- More verbose error
+- Version lol
+- Update prod compose
+- Version
+
+### 💼 Other
+
+- Extract process handling from async job.
+- Extract process handling from async job.
+- Extract process handling from async job.
+- Extract process handling from async job.
+- Extract process handling from async job.
+- Extract process handling from async job.
+- Extract process handling from async job.
+- Persisting data
+
+## [3.12.28] - 2023-03-16
+
+### 🐛 Bug Fixes
+
+- Revert from dockerhub if ghcr.io does not exists
+
+## [3.12.27] - 2023-03-07
+
+### 🐛 Bug Fixes
+
+- Show ip address as host in public dbs
+
+## [3.12.24] - 2023-03-04
+
+### 🐛 Bug Fixes
+
+- Nestjs buildpack
+
+## [3.12.22] - 2023-03-03
+
+### 🚀 Features
+
+- Add host path to any container
+
+### 🐛 Bug Fixes
+
+- Set PACK_VERSION to 0.27.0
+- PublishDirectory
+- Host volumes
+- Replace . & .. & $PWD with ~
+- Handle log format volumes
+
+## [3.12.19] - 2023-02-20
+
+### 🚀 Features
+
+- Github raw icon url
+- Remove svg support
+
+### 🐛 Bug Fixes
+
+- Typos in docs
+- Url
+- Network in compose files
+- Escape new line chars in wp custom configs
+- Applications cannot be deleted
+- Arm servics
+- Base directory not found
+- Cannot delete resource when you are not on root team
+- Empty port in docker compose
+
+## [3.12.18] - 2023-01-24
+
+### 🐛 Bug Fixes
+
+- CleanupStuckedContainers
+- CleanupStuckedContainers
+
+## [3.12.16] - 2023-01-20
+
+### 🐛 Bug Fixes
+
+- Stucked containers
+
+## [3.12.15] - 2023-01-20
+
+### 🐛 Bug Fixes
+
+- Cleanup function
+- Cleanup stucked containers
+- Deletion + cleanupStuckedContainers
+
+## [3.12.14] - 2023-01-19
+
+### 🐛 Bug Fixes
+
+- Www redirect
+
+## [3.12.13] - 2023-01-18
+
+### 🐛 Bug Fixes
+
+- Secrets
+
+## [3.12.12] - 2023-01-17
+
+### 🚀 Features
+
+- Init h2c (http2/grpc) support
+- Http + h2c paralel
+
+### 🐛 Bug Fixes
+
+- Build args docker compose
+- Grpc
+
+## [3.12.11] - 2023-01-16
+
+### 🐛 Bug Fixes
+
+- Compose file location
+- Docker log sequence
+- Delete apps with previews
+- Do not cleanup compose applications as unconfigured
+- Build env variables with docker compose
+- Public gh repo reload compose
+
+### 💼 Other
+
+- Trpc
+- Trpc
+- Trpc
+- Trpc
+- Trpc
+- Trpc
+- Trpc
+
+## [3.12.10] - 2023-01-11
+
+### 💼 Other
+
+- Add missing variables
+
+## [3.12.9] - 2023-01-11
+
+### 🚀 Features
+
+- Add Openblocks icon
+- Adding icon for whoogle
+- *(ui)* Add libretranslate service icon
+- Handle invite_only plausible analytics
+
+### 🐛 Bug Fixes
+
+- Custom gitlab git user
+- Add documentation link again
+- Remove prefetches
+- Doc link
+- Temporary disable dns check with dns servers
+- Local images for reverting
+- Secrets
+
+## [3.12.8] - 2022-12-27
+
+### 🐛 Bug Fixes
+
+- Parsing secrets
+- Read-only permission
+- Read-only iam
+- $ sign in secrets
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.12.5] - 2022-12-26
+
+### 🐛 Bug Fixes
+
+- Remove unused imports
+
+### 💼 Other
+
+- Conditional on environment
+
+## [3.12.2] - 2022-12-19
+
+### 🐛 Bug Fixes
+
+- Appwrite tmp volume
+- Do not replace secret
+- Root user for dbs on arm
+- Escape secrets
+- Escape env vars
+- Envs
+- Docker buildpack env
+- Secrets with newline
+- Secrets
+- Add default node_env variable
+- Add default node_env variable
+- Secrets
+- Secrets
+- Gh actions
+- Duplicate env variables
+- Cleanupstorage
+
+### 💼 Other
+
+- Trpc
+- Trpc
+- Trpc
+- Trpc
+- Trpc
+- Trpc
+- Trpc
+- Trpc
+- Trpc
+- Trpc
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.12.1] - 2022-12-13
+
+### 🐛 Bug Fixes
+
+- Build commands
+- Migration file
+- Adding missing appwrite volume
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.12.0] - 2022-12-09
+
+### 🚀 Features
+
+- Use registry for building
+- Docker registries working
+- Custom docker compose file location in repo
+- Save doNotTrackData to db
+- Add default sentry
+- Do not track in settings
+- System wide git out of beta
+- Custom previewseparator
+- Sentry frontend
+- Able to host static/php sites on arm
+- Save application data before deploying
+- SimpleDockerfile deployment
+- Able to push image to docker registry
+- Revert to remote image
+- *(api)* Name label
+
+### 🐛 Bug Fixes
+
+- 0 destinations redirect after creation
+- Seed
+- Sentry dsn update
+- Dnt
+- Ui
+- Only visible with publicrepo
+- Migrations
+- Prevent webhook errors to be logged
+- Login error
+- Remove beta from systemwide git
+- Git checkout
+- Remove sentry before migration
+- Webhook previewseparator
+- Apache on arm
+- Update PR/MRs with new previewSeparator
+- Static for arm
+- Failed builds should not push images
+- Turn off autodeploy for simpledockerfiles
+- Security hole
+- Rde
+- Delete resource on dashboard
+- Wrong port in case of docker compose
+- Public db icon on dashboard
+- Cleanup
+
+### 💼 Other
+
+- Pocketbase release
+
+## [3.11.10] - 2022-11-16
+
+### 🚀 Features
+
+- Only show expose if no proxy conf defined in template
+- Custom/private docker registries
+
+### 🐛 Bug Fixes
+
+- Local dev api/ws urls
+- Wrong template/type
+- Gitea icon is svg
+- Gh actions
+- Gh actions
+- Replace $$generate vars
+- Webhook traefik
+- Exposed ports
+- Wrong icons on dashboard
+- Escape % in secrets
+- Move debug log settings to build logs
+- Storage for compose bp + debug on
+- Hasura admin secret
+- Logs
+- Mounts
+- Load logs after build failed
+- Accept logged and not logged user in /base
+- Remote haproxy password/etc
+- Remove hardcoded sentry dsn
+- Nope in database strings
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+- Version++
+- Version++
+- Version++
+
+## [3.11.9] - 2022-11-15
+
+### 🐛 Bug Fixes
+
+- IsBot issue
+
+## [3.11.8] - 2022-11-14
+
+### 🐛 Bug Fixes
+
+- Default icon for new services
+
+## [3.11.1] - 2022-11-08
+
+### 🚀 Features
+
+- Rollback coolify
+
+### 🐛 Bug Fixes
+
+- Remove contribution docs
+- Umami template
+- Compose webhooks fixed
+- Variable replacements
+- Doc links
+- For rollback
+- N8n and weblate icon
+- Expose ports for services
+- Wp + mysql on arm
+- Show rollback button loading
+- No tags error
+- Update on mobile
+- Dashboard error
+- GetTemplates
+- Docker compose persistent volumes
+- Application persistent storage things
+- Volume names for undefined volume names in compose
+- Empty secrets on UI
+- Ports for services
+
+### 💼 Other
+
+- Secrets on apps
+- Fix
+- Fixes
+- Reload compose loading
+
+### 🚜 Refactor
+
+- Code
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+- Add jda icon for lavalink service
+- Version++
+
+### ◀️ Revert
+
+- Revert: revert
+
+## [3.11.0] - 2022-11-07
+
+### 🚀 Features
+
+- Initial support for specific git commit
+- Add default to latest commit and support for gitlab
+- Redirect catch-all rule
+
+### 🐛 Bug Fixes
+
+- Secret errors
+- Service logs
+- Heroku bp
+- Expose port is readonly on the wrong condition
+- Toast
+- Traefik proxy q 10s
+- App logs view
+- Tooltip
+- Toast, rde, webhooks
+- Pathprefix
+- Load public repos
+- Webhook simplified
+- Remote webhooks
+- Previews wbh
+- Webhooks
+- Websecure redirect
+- Wb for previews
+- Pr stopps main deployment
+- Preview wbh
+- Wh catchall for all
+- Remove old minio proxies
+- Template files
+- Compose icon
+- Templates
+- Confirm restart service
+- Template
+- Templates
+- Templates
+- Plausible analytics things
+- Appwrite webhook
+- Coolify instance proxy
+- Migrate template
+- Preview webhooks
+- Simplify webhooks
+- Remove ghost-mariadb from the list
+- More simplified webhooks
+- Umami + ghost issues
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.10.16] - 2022-10-12
+
+### 🐛 Bug Fixes
+
+- Single container logs and usage with compose
+
+### 💼 Other
+
+- New resource label
+
+## [3.10.15] - 2022-10-12
+
+### 🚀 Features
+
+- Monitoring by container
+
+### 🐛 Bug Fixes
+
+- Do not show nope as ip address for dbs
+- Add git sha to build args
+- Smart search for new services
+- Logs for not running containers
+- Update docker binaries
+- Gh release
+- Dev container
+- Gitlab auth and compose reload
+- Check compose domains in general
+- Port required if fqdn is set
+- Appwrite v1 missing containers
+- Dockerfile
+- Pull does not work remotely on huge compose file
+
+### ⚙️ Miscellaneous Tasks
+
+- Update staging release
+
+## [3.10.14] - 2022-10-05
+
+### 🚀 Features
+
+- Docker compose support
+- Docker compose
+- Docker compose
+
+### 🐛 Bug Fixes
+
+- Do not use npx
+- Pure docker based development
+
+### 💼 Other
+
+- Docker-compose support
+- Docker compose
+- Remove worker jobs
+- One less worker thread
+
+### 🧪 Testing
+
+- Remove prisma
+
+## [3.10.5] - 2022-09-26
+
+### 🚀 Features
+
+- Add migration button to appwrite
+- Custom certificate
+- Ssl cert on traefik config
+- Refresh resource status on dashboard
+- Ssl certificate sets custom ssl for applications
+- System-wide github apps
+- Cleanup unconfigured applications
+- Cleanup unconfigured services and databases
+
+### 🐛 Bug Fixes
+
+- Ui
+- Tooltip
+- Dropdown
+- Ssl certificate distribution
+- Db migration
+- Multiplex ssh connections
+- Able to search with id
+- Not found redirect
+- Settings db requests
+- Error during saving logs
+- Consider base directory in heroku bp
+- Basedirectory should be empty if null
+- Allow basedirectory for heroku
+- Stream logs for heroku bp
+- Debug log for bp
+- Scp without host verification & cert copy
+- Base directory & docker bp
+- Laravel php chooser
+- Multiplex ssh and ssl copy
+- Seed new preview secret types
+- Error notification
+- Empty preview value
+- Error notification
+- Seed
+- Service logs
+- Appwrite function network is not the default
+- Logs in docker bp
+- Able to delete apps in unconfigured state
+- Disable development low disk space
+- Only log things to console in dev mode
+- Do not get status of more than 10 resources defined by category
+- BaseDirectory
+- Dashboard statuses
+- Default buildImage and baseBuildImage
+- Initial deploy status
+- Show logs better
+- Do not start tcp proxy without main container
+- Cleanup stucked tcp proxies
+- Default 0 pending invitations
+- Handle forked repositories
+- Typo
+- Pr branches
+- Fork pr previews
+- Remove unnecessary things
+- Meilisearch data dir
+- Verify and configure remote docker engines
+- Add buildkit features
+- Nope if you are not logged in
+
+### 💼 Other
+
+- Responsive!
+- Fixes
+- Fix git icon
+- Dropdown as infobox
+- Small logs on mobile
+- Improvements
+- Fix destination view
+- Settings view
+- More UI improvements
+- Fixes
+- Fixes
+- Fix
+- Fixes
+- Beta features
+- Fix button
+- Service fixes
+- Fix basedirectory meaning
+- Resource button fix
+- Main resource search
+- Dev logs
+- Loading button
+- Fix gitlab importer view
+- Small fix
+- Beta flag
+- Hasura console notification
+- Fix
+- Fix
+- Fixes
+- Inprogress version of iam
+- Fix indicato
+- Iam & settings update
+- Send 200 for ping and installation wh
+- Settings icon
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+- Version++
+- Version++
+- Version++
+- Version++
+- Version++
+- Version++
+
+### ◀️ Revert
+
+- Show usage everytime
+
+## [3.10.2] - 2022-09-11
+
+### 🚀 Features
+
+- Add queue reset button
+- Previewapplications init
+- PreviewApplications finalized
+- Fluentbit
+- Show remote servers
+- *(layout)* Added drawer when user is in mobile
+- Re-apply ui improves
+- *(ui)* Improve header of pages
+- *(styles)* Make header css component
+- *(routes)* Improve ui for apps, databases and services logs
+
+### 🐛 Bug Fixes
+
+- Changing umami image URL to get latest version
+- Gitlab importer for public repos
+- Show error logs
+- Umami init sql
+- Plausible analytics actions
+- Login
+- Dev url
+- UpdateMany build logs
+- Fallback to db logs
+- Fluentbit configuration
+- Coolify update
+- Fluentbit and logs
+- Canceling build
+- Logging
+- Load more
+- Build logs
+- Versions of appwrite
+- Appwrite?!
+- Get building status
+- Await
+- Await #2
+- Update PR building status
+- Appwrite default version 1.0
+- Undead endpoint does not require JWT
+- *(routes)* Improve design of application page
+- *(routes)* Improve design of git sources page
+- *(routes)* Ui from destinations page
+- *(routes)* Ui from databases page
+- *(routes)* Ui from databases page
+- *(routes)* Ui from databases page
+- *(routes)* Ui from services page
+- *(routes)* More ui tweaks
+- *(routes)* More ui tweaks
+- *(routes)* More ui tweaks
+- *(routes)* More ui tweaks
+- *(routes)* Ui from settings page
+- *(routes)* Duplicates classes in services page
+- *(routes)* Searchbar ui
+- Github conflicts
+- *(routes)* More ui tweaks
+- *(routes)* More ui tweaks
+- *(routes)* More ui tweaks
+- *(routes)* More ui tweaks
+- Ui with headers
+- *(routes)* Header of settings page in databases
+- *(routes)* Ui from secrets table
+
+### 💼 Other
+
+- Fix plausible
+- Fix cleanup button
+- Fix buttons
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+- Minor changes
+- Minor changes
+- Minor changes
+- Whoops
+
+## [3.10.1] - 2022-09-10
+
+### 🐛 Bug Fixes
+
+- Show restarting apps
+- Show restarting application & logs
+- Remove unnecessary gitlab group name
+- Secrets for PR
+- Volumes for services
+- Build secrets for apps
+- Delete resource use window location
+
+### 💼 Other
+
+- Fix button
+- Fix follow button
+- Arm should be on next all the time
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.10.0] - 2022-09-08
+
+### 🚀 Features
+
+- New servers view
+
+### 🐛 Bug Fixes
+
+- Change to execa from utils
+- Save search input
+- Ispublic status on databases
+- Port checkers
+- Ui variables
+- Glitchtip env to pyhton boolean
+- Autoupdater
+
+### 💼 Other
+
+- Dashboard updates
+- Fix tooltip
+
+## [3.9.4] - 2022-09-07
+
+### 🐛 Bug Fixes
+
+- DnsServer formatting
+- Settings for service
+
+## [3.9.3] - 2022-09-07
+
+### 🐛 Bug Fixes
+
+- Pr previews
+
+## [3.9.2] - 2022-09-07
+
+### 🚀 Features
+
+- Add traefik acme json to coolify container
+- Database secrets
+
+### 🐛 Bug Fixes
+
+- Gitlab webhook
+- Use ip address instead of window location
+- Use ip instead of window location host
+- Service state update
+- Add initial DNS servers
+- Revert last change with domain check
+- Service volume generation
+- Minio default env variables
+- Add php 8.1/8.2
+- Edgedb ui
+- Edgedb stuff
+- Edgedb
+
+### 💼 Other
+
+- Fix login/register page
+- Update devcontainer
+- Add debug log
+- Fix initial loading icon bg
+- Fix loading start/stop db/services
+- Dashboard updates and a lot more
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+- Version++
+
+## [3.9.0] - 2022-09-06
+
+### 🐛 Bug Fixes
+
+- Debug api logging + gh actions
+- Workdir
+- Move restart button to settings
+
+## [3.9.1-rc.1] - 2022-09-06
+
+### 🚀 Features
+
+- *(routes)* Rework ui from login and register page
+
+### 🐛 Bug Fixes
+
+- Ssh pid agent name
+- Repository link trim
+- Fqdn or expose port required
+- Service deploymentEnabled
+- Expose port is not required
+- Remote verification
+- Dockerfile
+
+### 💼 Other
+
+- Database_branches
+- Login page
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+- Version++
+
+## [3.9.0-rc.1] - 2022-09-02
+
+### 🚀 Features
+
+- New service - weblate
+- Restart application
+- Show elapsed time on running builds
+- Github allow fual branches
+- Gitlab dual branch
+- Taiga
+
+### 🐛 Bug Fixes
+
+- Glitchtip things
+- Loading state on start
+- Ui
+- Submodule
+- Gitlab webhooks
+- UI + refactor
+- Exposedport on save
+- Appwrite letsencrypt
+- Traefik appwrite
+- Traefik
+- Finally works! :)
+- Rename components + remove PR/MR deployment from public repos
+- Settings missing id
+- Explainer component
+- Database name on logs view
+- Taiga
+
+### 💼 Other
+
+- Fixes
+- Change tooltips and info boxes
+- Added rc release
+
+### 🧪 Testing
+
+- Native binary target
+- Dockerfile
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.8.9] - 2022-08-30
+
+### 🐛 Bug Fixes
+
+- Oh god Prisma
+
+## [3.8.8] - 2022-08-30
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.8.6] - 2022-08-30
+
+### 🐛 Bug Fixes
+
+- Pr deployment
+- CompareVersions
+- Include
+- Include
+- Gitlab apps
+
+### 💼 Other
+
+- Fixes
+- Route to the correct path when creating destination from db config
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.8.5] - 2022-08-27
+
+### 🐛 Bug Fixes
+
+- Copy all files during install process
+- Typo
+- Process
+- White labeled icon on navbar
+- Whitelabeled icon
+- Next/nuxt deployment type
+- Again
+
+## [3.8.4] - 2022-08-27
+
+### 🐛 Bug Fixes
+
+- UI thinkgs
+- Delete team while it is active
+- Team switching
+- Queue cleanup
+- Decrypt secrets
+- Cleanup build cache as well
+- Pr deployments + remove public gits
+
+### 💼 Other
+
+- Dashbord fixes
+- Fixes
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.8.3] - 2022-08-26
+
+### 🐛 Bug Fixes
+
+- Secrets decryption
+
+## [3.8.2] - 2022-08-26
+
+### 🚀 Features
+
+- *(ui)* Rework home UI and with responsive design
+
+### 🐛 Bug Fixes
+
+- Never stop deplyo queue
+- Build queue system
+- High cpu usage
+- Worker
+- Better worker system
+
+### 💼 Other
+
+- Dashboard fine-tunes
+- Fine-tune
+- Fixes
+- Fix
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.8.1] - 2022-08-24
+
+### 🐛 Bug Fixes
+
+- Ui buttons
+- Clear queue on cancelling jobs
+- Cancelling jobs
+- Dashboard for admins
+
+## [3.8.0] - 2022-08-23
+
+### 🚀 Features
+
+- Searxng service
+
+### 🐛 Bug Fixes
+
+- Port checker
+- Cancel build after 5 seconds
+- ExposedPort checker
+- Batch secret =
+- Dashboard for non-root users
+- Stream build logs
+- Show build log start/end
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.7.0] - 2022-08-19
+
+### 🚀 Features
+
+- Add GlitchTip service
+
+### 🐛 Bug Fixes
+
+- Missing commas
+- ExposedPort is just optional
+
+### ⚙️ Miscellaneous Tasks
+
+- Add .pnpm-store in .gitignore
+- Version++
+
+## [3.6.0] - 2022-08-18
+
+### 🚀 Features
+
+- Import public repos (wip)
+- Public repo deployment
+- Force rebuild + env.PORT for port + public repo build
+
+### 🐛 Bug Fixes
+
+- Bots without exposed ports
+
+### 💼 Other
+
+- Fixes here and there
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.5.2] - 2022-08-17
+
+### 🐛 Bug Fixes
+
+- Restart containers on-failure instead of always
+- Show that Ghost values could be changed
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.5.1] - 2022-08-17
+
+### 🐛 Bug Fixes
+
+- Revert docker compose version to 2.6.1
+- Trim secrets
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.5.0] - 2022-08-17
+
+### 🚀 Features
+
+- Deploy bots (no domains)
+- Custom dns servers
+
+### 🐛 Bug Fixes
+
+- Dns button ui
+- Bot deployments
+- Bots
+- AutoUpdater & cleanupStorage jobs
+
+### 💼 Other
+
+- Typing
+
+## [3.4.0] - 2022-08-16
+
+### 🚀 Features
+
+- Appwrite service
+- Heroku deployments
+
+### 🐛 Bug Fixes
+
+- Replace docker compose with docker-compose on CSB
+- Dashboard ui
+- Create coolify-infra, if it does not exists
+- Gitpod conf and heroku buildpacks
+- Appwrite
+- Autoimport + readme
+- Services import
+- Heroku icon
+- Heroku icon
+
+## [3.3.4] - 2022-08-15
+
+### 🐛 Bug Fixes
+
+- Make it public button
+- Loading indicator
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.3.3] - 2022-08-14
+
+### 🐛 Bug Fixes
+
+- Decryption errors
+- Postgresql on ARM
+
+## [3.3.2] - 2022-08-12
+
+### 🐛 Bug Fixes
+
+- Debounce dashboard status requests
+
+### 💼 Other
+
+- Fider
+
+## [3.3.1] - 2022-08-12
+
+### 🐛 Bug Fixes
+
+- Empty buildpack icons
+
+## [3.2.3] - 2022-08-12
+
+### 🚀 Features
+
+- Databases on ARM
+- Mongodb arm support
+- New dashboard
+
+### 🐛 Bug Fixes
+
+- Cleanup stucked prisma-engines
+- Toast
+- Secrets
+- Cleanup prisma engine if there is more than 1
+- !isARM to isARM
+- Enterprise GH link
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.2.2] - 2022-08-11
+
+### 🐛 Bug Fixes
+
+- Coolify-network on verification
+
+## [3.2.1] - 2022-08-11
+
+### 🚀 Features
+
+- Init heroku buildpacks
+
+### 🐛 Bug Fixes
+
+- Follow/cancel buttons
+- Only remove coolify managed containers
+- White-labeled env
+- Schema
+
+### 💼 Other
+
+- Fix
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.2.0] - 2022-08-11
+
+### 🚀 Features
+
+- Persistent storage for all services
+- Cleanup clickhouse db
+
+### 🐛 Bug Fixes
+
+- Rde local ports
+- Empty remote destinations could be removed
+- Tips
+- Lowercase issues fider
+- Tooltip colors
+- Update clickhouse configuration
+- Cleanup command
+- Enterprise Github instance endpoint
+
+### 💼 Other
+
+- Local ssh port
+- Redesign a lot
+- Fixes
+- Loading indicator for plausible buttons
+
+## [3.1.4] - 2022-08-01
+
+### 🚀 Features
+
+- Moodle init
+- Remote docker engine init
+- Working on remote docker engine
+- Rde
+- Remote docker engine
+- Ipv4 and ipv6
+- Contributors
+- Add arch to database
+- Stop preview deployment
+
+### 🐛 Bug Fixes
+
+- Settings from api
+- Selectable destinations
+- Gitpod hardcodes
+- Typo
+- Typo
+- Expose port checker
+- States and exposed ports
+- CleanupStorage
+- Remote traefik webhook
+- Remote engine ip address
+- RemoteipAddress
+- Explanation for remote engine url
+- Tcp proxy
+- Lol
+- Webhook
+- Dns check for rde
+- Gitpod
+- Revert last commit
+- Dns check
+- Dns checker
+- Webhook
+- Df and more debug
+- Webhooks
+- Load previews async
+- Destination icon
+- Pr webhook
+- Cache image
+- No ssh key found
+- Prisma migration + update of docker and stuffs
+- Ui
+- Ui
+- Only 1 ssh-agent is needed
+- Reuse ssh connection
+- Ssh tunnel
+- Dns checking
+- Fider BASE_URL set correctly
+
+### 💼 Other
+
+- Error message https://github.com/coollabsio/coolify/issues/502
+- Changes
+- Settings
+- For removing app
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.1.3] - 2022-07-18
+
+### 🚀 Features
+
+- Init moodle and separate stuffs to shared package
+
+### 🐛 Bug Fixes
+
+- More types for API
+- More types
+- Do not rebuild in case image exists and sha not changed
+- Gitpod urls
+- Remove new service start process
+- Remove shared dir, deployment does not work
+- Gitlab custom url
+- Location url for services and apps
+
+## [3.1.2] - 2022-07-14
+
+### 🐛 Bug Fixes
+
+- Admin password reset should not timeout
+- Message for double branches
+- Turn off autodeploy if double branch is configured
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.1.1] - 2022-07-13
+
+### 🚀 Features
+
+- Gitpod integration
+
+### 🐛 Bug Fixes
+
+- Cleanup less often and can do it manually
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+- Version++
+
+## [3.1.0] - 2022-07-12
+
+### 🚀 Features
+
+- Ability to change deployment type for nextjs
+- Ability to change deployment type for nuxtjs
+- Gitpod ready code(almost)
+- Add Docker buildpack exposed port setting
+- Custom port for git instances
+
+### 🐛 Bug Fixes
+
+- GitLab pagination load data
+- Service domain checker
+- Wp missing ftp solution
+- Ftp WP issues
+- Ftp?!
+- Gitpod updates
+- Gitpod
+- Gitpod
+- Wordpress FTP permission issues
+- GitLab search fields
+- GitHub App button
+- GitLab loop on misconfigured source
+- Gitpod
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [3.0.3] - 2022-07-06
+
+### 🐛 Bug Fixes
+
+- Domain check
+- Domain check
+- TrustProxy for Fastify
+- Hostname issue
+
+## [3.0.2] - 2022-07-06
+
+### 🐛 Bug Fixes
+
+- New destination can be created
+- Include post
+- New destinations
+
+## [3.0.1] - 2022-07-06
+
+### 🐛 Bug Fixes
+
+- Seeding
+- Forgot that the version bump changed 😅
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.9.11] - 2022-06-20
+
+### 🐛 Bug Fixes
+
+- Be able to change database + service versions
+- Lock file
+
+## [2.9.10] - 2022-06-17
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.9.9] - 2022-06-10
+
+### 🐛 Bug Fixes
+
+- Host and reload for uvicorn
+- Remove package-lock
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.9.8] - 2022-06-10
+
+### 🐛 Bug Fixes
+
+- Persistent nocodb
+- Nocodb persistency
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.9.7] - 2022-06-09
+
+### 🐛 Bug Fixes
+
+- Plausible custom script
+- Plausible script and middlewares
+- Remove console log
+- Remove comments
+- Traefik middleware
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.9.6] - 2022-06-02
+
+### 🐛 Bug Fixes
+
+- Fider changed an env variable name
+- Pnpm command
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.9.5] - 2022-06-02
+
+### 🐛 Bug Fixes
+
+- Proxy stop missing argument
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.9.4] - 2022-06-01
+
+### 🐛 Bug Fixes
+
+- Demo version forms
+- Typo
+- Revert gh and gl cloning
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.9.3] - 2022-05-31
+
+### 🐛 Bug Fixes
+
+- Recurisve clone instead of submodule
+- Versions
+- Only reconfigure coolify proxy if its missconfigured
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.9.2] - 2022-05-31
+
+### 🐛 Bug Fixes
+
+- TrustProxy
+- Force restart proxy
+- Only restart coolify proxy in case of version prior to 2.9.2
+- Force restart proxy on seeding
+- Add GIT ENV variable for submodules
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.9.1] - 2022-05-31
+
+### 🐛 Bug Fixes
+
+- GitHub fixes
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.9.0] - 2022-05-31
+
+### 🚀 Features
+
+- PageLoader
+- Database + service usage
+
+### 🐛 Bug Fixes
+
+- Service checks
+- Remove console.log
+- Traefik
+- Remove debug things
+- WIP Traefik
+- Proxy for http
+- PR deployments view
+- Minio urls + domain checks
+- Remove gh token on git source changes
+- Do not fetch app state in case of missconfiguration
+- Demo instance save domain instantly
+- Instant save on demo instance
+- New source canceled view
+- Lint errors in database services
+- Otherfqdns
+- Host key verification
+- Ftp connection
+
+### 💼 Other
+
+- Appwrite
+- Testing WS
+- Traefik?!
+- Traefik
+- Traefik
+- Traefik migration
+- Traefik
+- Traefik
+- Traefik
+- Notifications and application usage
+- *(fix)* Traefik
+- Css
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.8.2] - 2022-05-16
+
+### 🐛 Bug Fixes
+
+- Gastby buildpack
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.8.1] - 2022-05-10
+
+### 🐛 Bug Fixes
+
+- WP custom db
+- UI
+
+## [2.6.1] - 2022-05-03
+
+### 🚀 Features
+
+- Basic server usage on dashboard
+- Show usage trends
+- Usage on dashboard
+- Custom script path for Plausible
+- WP could have custom db
+- Python image selection
+
+### 🐛 Bug Fixes
+
+- ExposedPorts
+- Logos for dbs
+- Do not run SSL renew in development
+- Check domain for coolify before saving
+- Remove debug info
+- Cancel jobs
+- Cancel old builds in database
+- Better DNS check to prevent errors
+- Check DNS in prod only
+- DNS check
+- Disable sentry for now
+- Cancel
+- Sentry
+- No image for Docker buildpack
+- Default packagemanager
+- Server usage only shown for root team
+- Expose ports for services
+- UI
+- Navbar UI
+- UI
+- UI
+- Remove RC python
+- UI
+- UI
+- UI
+- Default Python package
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+- Version++
+- Version++
+- Version++
+
+## [2.6.0] - 2022-05-02
+
+### 🚀 Features
+
+- Hasura as a service
+- Gzip compression
+- Laravel buildpack is working!
+- Laravel
+- Fider service
+- Database and services logs
+- DNS check settings for SSL generation
+- Cancel builds!
+
+### 🐛 Bug Fixes
+
+- Unami svg size
+- Team switching moved to IAM menu
+- Always use IP address for webhooks
+- Remove unnecessary test endpoint
+- UI
+- Migration
+- Fider envs
+- Checking low disk space
+- Build image
+- Update autoupdate env variable
+- Renew certificates
+- Webhook build images
+- Missing node versions
+
+### 💼 Other
+
+- Laravel
+
+## [2.4.11] - 2022-04-20
+
+### 🚀 Features
+
+- Deno DB migration
+- Show exited containers on UI & better UX
+- Query container state periodically
+- Install svelte-18n and init setup
+- Umami service
+- Coolify auto-updater
+- Autoupdater
+- Select base image for buildpacks
+
+### 🐛 Bug Fixes
+
+- Deno configurations
+- Text on deno buildpack
+- Correct branch shown in build logs
+- Vscode permission fix
+- I18n
+- Locales
+- Application logs is not reversed and queried better
+- Do not activate i18n for now
+- GitHub token cleanup on team switch
+- No logs found
+- Code cleanups
+- Reactivate posgtres password
+- Contribution guide
+- Simplify list services
+- Contribution
+- Contribution guide
+- Contribution guide
+- Packagemanager finder
+
+### 💼 Other
+
+- Umami service
+- Base image selector
+
+### 📚 Documentation
+
+- How to add new services
+- Update
+- Update
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+- Version++
+- Version++
+
+## [2.4.10] - 2022-04-17
+
+### 🚀 Features
+
+- Add persistent storage for services
+- Multiply dockerfile locations for docker buildpack
+- Testing fluentd logging driver
+- Fluentbit investigation
+- Initial deno support
+
+### 🐛 Bug Fixes
+
+- Switch from bitnami/redis to normal redis
+- Use redis-alpine
+- Wordpress extra config
+- Stop sFTP connection on wp stop
+- Change user's id in sftp wp instance
+- Use arm based certbot on arm
+- Buildlog line number is not string
+- Application logs paginated
+- Switch to stream on applications logs
+- Scroll to top for logs
+- Pull new images for services all the time it's started.
+- White-labeled custom logo
+- Application logs
+
+### 💼 Other
+
+- Show extraconfig if wp is running
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+- Version++
+
+## [2.4.9] - 2022-04-14
+
+### 🐛 Bug Fixes
+
+- Postgres root pw is pw field
+- Teams view
+- Improved tcp proxy monitoring for databases/ftp
+- Add HTTP proxy checks
+- Loading of new destinations
+- Better performance for cleanup images
+- Remove proxy container in case of dependent container is down
+- Restart local docker coolify proxy in case of something happens to it
+- Id of service container
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.4.8] - 2022-04-13
+
+### 🐛 Bug Fixes
+
+- Register should happen if coolify proxy cannot be started
+- GitLab typo
+- Remove system wide pw reset
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.4.7] - 2022-04-13
+
+### 🐛 Bug Fixes
+
+- Destinations to HAProxy
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.4.6] - 2022-04-13
+
+### 🐛 Bug Fixes
+
+- Cleanup images older than a day
+- Meilisearch service
+- Load all branches, not just the first 30
+- ProjectID for Github
+- DNS check before creating SSL cert
+- Try catch me
+- Restart policy for resources
+- No permission on first registration
+- Reverting postgres password for now
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.4.5] - 2022-04-12
+
+### 🐛 Bug Fixes
+
+- Types
+- Invitations
+- Timeout values
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.4.4] - 2022-04-12
+
+### 🐛 Bug Fixes
+
+- Haproxy build stuffs
+- Proxy
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.4.3] - 2022-04-12
+
+### 🐛 Bug Fixes
+
+- Remove unnecessary save button haha
+- Update dockerfile
+
+### ⚙️ Miscellaneous Tasks
+
+- Update packages
+- Version++
+- Update build scripts
+- Update build packages
+
+## [2.4.2] - 2022-04-09
+
+### 🐛 Bug Fixes
+
+- Missing install repositories GitHub
+- Return own and other sources better
+- Show config missing on sources
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.4.1] - 2022-04-09
+
+### 🐛 Bug Fixes
+
+- Enable https for Ghost
+- Postgres root passwor shown and set
+- Able to change postgres user password from ui
+- DB Connecting string generator
+
+### ⚙️ Miscellaneous Tasks
+
+- Version++
+
+## [2.4.0] - 2022-04-08
+
### 🚀 Features
- Use tags in update
diff --git a/CLAUDE.md b/CLAUDE.md
index b7c496e42..5cddb7fd0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -222,6 +222,7 @@ ### Performance Considerations
- Queue heavy operations
- Optimize database queries with proper indexes
- Use chunking for large data operations
+- **CRITICAL**: Use `ownedByCurrentTeamCached()` instead of `ownedByCurrentTeam()->get()`
### Code Style
- Follow PSR-12 coding standards
@@ -317,4 +318,5 @@ ### Livewire & Frontend
Random other things you should remember:
-- App\Models\Application::team must return a relationship instance., always use team()
\ No newline at end of file
+- App\Models\Application::team must return a relationship instance., always use team()
+- Always use `Model::ownedByCurrentTeamCached()` instead of `Model::ownedByCurrentTeam()->get()` for team-scoped queries to avoid duplicate database queries
\ No newline at end of file
diff --git a/app/Actions/Application/CleanupPreviewDeployment.php b/app/Actions/Application/CleanupPreviewDeployment.php
new file mode 100644
index 000000000..74e2ff615
--- /dev/null
+++ b/app/Actions/Application/CleanupPreviewDeployment.php
@@ -0,0 +1,176 @@
+ 0,
+ 'killed_containers' => 0,
+ 'status' => 'success',
+ ];
+
+ $server = $application->destination->server;
+
+ if (! $server->isFunctional()) {
+ return [
+ ...$result,
+ 'status' => 'failed',
+ 'message' => 'Server is not functional',
+ ];
+ }
+
+ // Step 1: Cancel all active deployments for this PR and kill helper containers
+ $result['cancelled_deployments'] = $this->cancelActiveDeployments(
+ $application,
+ $pull_request_id,
+ $server
+ );
+
+ // Step 2: Stop and remove all running PR containers
+ $result['killed_containers'] = $this->stopRunningContainers(
+ $application,
+ $pull_request_id,
+ $server
+ );
+
+ // Step 3: Find or use provided preview, then dispatch cleanup job for thorough cleanup
+ if (! $preview) {
+ $preview = ApplicationPreview::where('application_id', $application->id)
+ ->where('pull_request_id', $pull_request_id)
+ ->first();
+ }
+
+ if ($preview) {
+ DeleteResourceJob::dispatch($preview);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Cancel all active (QUEUED/IN_PROGRESS) deployments for this PR.
+ */
+ private function cancelActiveDeployments(
+ Application $application,
+ int $pull_request_id,
+ $server
+ ): int {
+ $activeDeployments = ApplicationDeploymentQueue::where('application_id', $application->id)
+ ->where('pull_request_id', $pull_request_id)
+ ->whereIn('status', [
+ ApplicationDeploymentStatus::QUEUED->value,
+ ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ])
+ ->get();
+
+ $cancelled = 0;
+ foreach ($activeDeployments as $deployment) {
+ try {
+ // Mark deployment as cancelled
+ $deployment->update([
+ 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
+ ]);
+
+ // Add cancellation log entry
+ $deployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
+
+ // Try to kill helper container if it exists
+ $this->killHelperContainer($deployment->deployment_uuid, $server);
+ $cancelled++;
+ } catch (\Throwable $e) {
+ \Log::warning("Failed to cancel deployment {$deployment->id}: {$e->getMessage()}");
+ }
+ }
+
+ return $cancelled;
+ }
+
+ /**
+ * Kill the helper container used during deployment.
+ */
+ private function killHelperContainer(string $deployment_uuid, $server): void
+ {
+ try {
+ $escapedUuid = escapeshellarg($deployment_uuid);
+ $checkCommand = "docker ps -a --filter name={$escapedUuid} --format '{{.Names}}'";
+ $containerExists = instant_remote_process([$checkCommand], $server);
+
+ if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
+ instant_remote_process(["docker rm -f {$escapedUuid}"], $server);
+ }
+ } catch (\Throwable $e) {
+ // Silently handle - container may already be gone
+ }
+ }
+
+ /**
+ * Stop and remove all running containers for this PR.
+ */
+ private function stopRunningContainers(
+ Application $application,
+ int $pull_request_id,
+ $server
+ ): int {
+ $killed = 0;
+
+ try {
+ if ($server->isSwarm()) {
+ $escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
+ instant_remote_process(["docker stack rm {$escapedStackName}"], $server);
+ $killed++;
+ } else {
+ $containers = getCurrentApplicationContainerStatus(
+ $server,
+ $application->id,
+ $pull_request_id
+ );
+
+ if ($containers->isNotEmpty()) {
+ foreach ($containers as $container) {
+ $containerName = data_get($container, 'Names');
+ if ($containerName) {
+ $escapedContainerName = escapeshellarg($containerName);
+ instant_remote_process(
+ ["docker rm -f {$escapedContainerName}"],
+ $server
+ );
+ $killed++;
+ }
+ }
+ }
+ }
+ } catch (\Throwable $e) {
+ \Log::warning("Error stopping containers for PR #{$pull_request_id}: {$e->getMessage()}");
+ }
+
+ return $killed;
+ }
+}
diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php
index ee3398b04..94651a3c1 100644
--- a/app/Actions/Application/StopApplication.php
+++ b/app/Actions/Application/StopApplication.php
@@ -39,7 +39,7 @@ public function handle(Application $application, bool $previewDeployments = fals
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
- "docker stop --time=30 $containerName",
+ "docker stop -t 30 $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php
index 600b1cb9a..bf9fdee72 100644
--- a/app/Actions/Application/StopApplicationOneServer.php
+++ b/app/Actions/Application/StopApplicationOneServer.php
@@ -26,7 +26,7 @@ public function handle(Application $application, Server $server)
if ($containerName) {
instant_remote_process(
[
- "docker stop --time=30 $containerName",
+ "docker stop -t 30 $containerName",
"docker rm -f $containerName",
],
$server
diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php
index 7fdfe9aeb..6da5465c6 100644
--- a/app/Actions/Database/StartClickhouse.php
+++ b/app/Actions/Database/StartClickhouse.php
@@ -105,7 +105,7 @@ public function handle(StandaloneClickhouse $database)
$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 --time=10 $container_name 2>/dev/null || true";
+ $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.'";
diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php
index d1bb119af..cd820523d 100644
--- a/app/Actions/Database/StartDragonfly.php
+++ b/app/Actions/Database/StartDragonfly.php
@@ -192,7 +192,7 @@ public function handle(StandaloneDragonfly $database)
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 --time=10 $container_name 2>/dev/null || true";
+ $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.'";
diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php
index 128469e24..863691e1e 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -208,7 +208,7 @@ public function handle(StandaloneKeydb $database)
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 --time=10 $container_name 2>/dev/null || true";
+ $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.'";
diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php
index 29dd7b8fe..498ba0b0b 100644
--- a/app/Actions/Database/StartMariadb.php
+++ b/app/Actions/Database/StartMariadb.php
@@ -209,7 +209,7 @@ public function handle(StandaloneMariadb $database)
$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 --time=10 $container_name 2>/dev/null || true";
+ $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.'";
diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php
index 5982b68be..9565990c1 100644
--- a/app/Actions/Database/StartMongodb.php
+++ b/app/Actions/Database/StartMongodb.php
@@ -260,7 +260,7 @@ public function handle(StandaloneMongodb $database)
$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 --time=10 $container_name 2>/dev/null || true";
+ $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) {
diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php
index c1df8d6db..337516405 100644
--- a/app/Actions/Database/StartMysql.php
+++ b/app/Actions/Database/StartMysql.php
@@ -210,7 +210,7 @@ public function handle(StandaloneMysql $database)
$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 --time=10 $container_name 2>/dev/null || true";
+ $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";
diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php
index 1ae0d56a0..41e39c811 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -223,7 +223,7 @@ public function handle(StandalonePostgresql $database)
$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 --time=10 $container_name 2>/dev/null || true";
+ $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) {
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index 4c99a0213..2eaf82fdd 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -205,7 +205,7 @@ public function handle(StandaloneRedis $database)
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 --time=10 $container_name 2>/dev/null || true";
+ $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.'";
diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php
index 5c881e743..c024c14e1 100644
--- a/app/Actions/Database/StopDatabase.php
+++ b/app/Actions/Database/StopDatabase.php
@@ -49,7 +49,7 @@ private function stopContainer($database, string $containerName, int $timeout =
{
$server = $database->destination->server;
instant_remote_process(command: [
- "docker stop --time=$timeout $containerName",
+ "docker stop -t $timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php
index 61a3c4615..a1476e120 100644
--- a/app/Actions/Docker/GetContainersStatus.php
+++ b/app/Actions/Docker/GetContainersStatus.php
@@ -461,9 +461,10 @@ private function aggregateApplicationStatus($application, Collection $containerS
}
// Use ContainerStatusAggregator service for state machine logic
+ // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded"
$aggregator = new ContainerStatusAggregator;
- return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount);
+ return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true);
}
private function aggregateServiceContainerStatuses($services)
@@ -518,8 +519,9 @@ private function aggregateServiceContainerStatuses($services)
}
// Use ContainerStatusAggregator service for state machine logic
+ // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded"
$aggregator = new ContainerStatusAggregator;
- $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses);
+ $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, preserveRestarting: true);
// Update service sub-resource status with aggregated result
if ($aggregatedStatus) {
diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php
index bfc65d8d2..20c997656 100644
--- a/app/Actions/Proxy/StartProxy.php
+++ b/app/Actions/Proxy/StartProxy.php
@@ -75,6 +75,10 @@ public function handle(Server $server, bool $async = true, bool $force = false,
' 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.'",
diff --git a/app/Actions/Proxy/StopProxy.php b/app/Actions/Proxy/StopProxy.php
index 8f1b8af1c..04d031ec6 100644
--- a/app/Actions/Proxy/StopProxy.php
+++ b/app/Actions/Proxy/StopProxy.php
@@ -24,7 +24,7 @@ public function handle(Server $server, bool $forceStop = true, int $timeout = 30
}
instant_remote_process(command: [
- "docker stop --time=$timeout $containerName 2>/dev/null || true",
+ "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',
diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php
index 6823dfb92..f90e00708 100644
--- a/app/Actions/Server/CheckUpdates.php
+++ b/app/Actions/Server/CheckUpdates.php
@@ -13,6 +13,9 @@ class CheckUpdates
public function handle(Server $server)
{
+ $osId = 'unknown';
+ $packageManager = null;
+
try {
if ($server->serverStatus() === false) {
return [
@@ -93,6 +96,16 @@ public function handle(Server $server)
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
+ return $out;
+ case 'pacman':
+ // Sync database first, then check for updates
+ // Using -Sy to refresh package database before querying available updates
+ instant_remote_process(['pacman -Sy'], $server);
+ $output = instant_remote_process(['pacman -Qu 2>/dev/null'], $server);
+ $out = $this->parsePacmanOutput($output);
+ $out['osId'] = $osId;
+ $out['package_manager'] = $packageManager;
+
return $out;
default:
return [
@@ -219,4 +232,45 @@ private function parseAptOutput(string $output): array
'updates' => $updates,
];
}
+
+ private function parsePacmanOutput(string $output): array
+ {
+ $updates = [];
+ $unparsedLines = [];
+ $lines = explode("\n", $output);
+
+ foreach ($lines as $line) {
+ if (empty($line)) {
+ continue;
+ }
+ // Format: package current_version -> new_version
+ if (preg_match('/^(\S+)\s+(\S+)\s+->\s+(\S+)$/', $line, $matches)) {
+ $updates[] = [
+ 'package' => $matches[1],
+ 'current_version' => $matches[2],
+ 'new_version' => $matches[3],
+ 'architecture' => 'unknown',
+ 'repository' => 'unknown',
+ ];
+ } else {
+ // Log unmatched lines for debugging purposes
+ $unparsedLines[] = $line;
+ }
+ }
+
+ $result = [
+ 'total_updates' => count($updates),
+ 'updates' => $updates,
+ ];
+
+ // Include unparsed lines in the result for debugging if any exist
+ if (! empty($unparsedLines)) {
+ $result['unparsed_lines'] = $unparsedLines;
+ \Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [
+ 'unparsed_lines' => $unparsedLines,
+ ]);
+ }
+
+ return $result;
+ }
}
diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php
index 6bf094c32..65a41db18 100644
--- a/app/Actions/Server/CleanupDocker.php
+++ b/app/Actions/Server/CleanupDocker.php
@@ -13,7 +13,6 @@ class CleanupDocker
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";
@@ -26,9 +25,31 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
+ $cleanupLog = [];
+
+ // Get all application image repositories to exclude from prune
+ $applications = $server->applications();
+ $applicationImageRepos = collect($applications)->map(function ($app) {
+ return $app->docker_registry_image_name ?? $app->uuid;
+ })->unique()->values();
+
+ // Clean up old application images while preserving N most recent for rollback
+ $applicationCleanupLog = $this->cleanupApplicationImages($server, $applications);
+ $cleanupLog = array_merge($cleanupLog, $applicationCleanupLog);
+
+ // Build image prune command that excludes application images and current Coolify infrastructure images
+ // This ensures we clean up non-Coolify images while preserving rollback images and current helper/realtime images
+ // Note: Only the current version is protected; old versions will be cleaned up by explicit commands below
+ // We pass the version strings so all registry variants are protected (ghcr.io, docker.io, no prefix)
+ $imagePruneCmd = $this->buildImagePruneCommand(
+ $applicationImageRepos,
+ $helperImageVersion,
+ $realtimeImageVersion
+ );
+
$commands = [
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
- 'docker image prune -af --filter "label!=coolify.managed=true"',
+ $imagePruneCmd,
'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",
@@ -44,7 +65,6 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
$commands[] = 'docker network prune -f';
}
- $cleanupLog = [];
foreach ($commands as $command) {
$commandOutput = instant_remote_process([$command], $server, false);
if ($commandOutput !== null) {
@@ -57,4 +77,140 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
return $cleanupLog;
}
+
+ /**
+ * Build a docker image prune command that excludes application image repositories.
+ *
+ * Since docker image prune doesn't support excluding by repository name directly,
+ * we use a shell script approach to delete unused images while preserving application images.
+ */
+ private function buildImagePruneCommand(
+ $applicationImageRepos,
+ string $helperImageVersion,
+ string $realtimeImageVersion
+ ): string {
+ // Step 1: Always prune dangling images (untagged)
+ $commands = ['docker image prune -f'];
+
+ // Build grep pattern to exclude application image repositories (matches repo:tag and repo_service:tag)
+ $appExcludePatterns = $applicationImageRepos->map(function ($repo) {
+ // Escape special characters for grep extended regex (ERE)
+ // ERE special chars: . \ + * ? [ ^ ] $ ( ) { } |
+ return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo);
+ })->implode('|');
+
+ // Build grep pattern to exclude Coolify infrastructure images (current version only)
+ // This pattern matches the image name regardless of registry prefix:
+ // - ghcr.io/coollabsio/coolify-helper:1.0.12
+ // - docker.io/coollabsio/coolify-helper:1.0.12
+ // - coollabsio/coolify-helper:1.0.12
+ // Pattern: (^|/)coollabsio/coolify-(helper|realtime):VERSION$
+ $escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperImageVersion);
+ $escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeImageVersion);
+ $infraExcludePattern = "(^|/)coollabsio/coolify-helper:{$escapedHelperVersion}$|(^|/)coollabsio/coolify-realtime:{$escapedRealtimeVersion}$";
+
+ // Delete unused images that:
+ // - Are not application images (don't match app repos)
+ // - Are not current Coolify infrastructure images (any registry)
+ // - Don't have coolify.managed=true label
+ // Images in use by containers will fail silently with docker rmi
+ // Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build)
+ $grepCommands = "grep -v ''";
+
+ // Add application repo exclusion if there are applications
+ if ($applicationImageRepos->isNotEmpty()) {
+ $grepCommands .= " | grep -v -E '^({$appExcludePatterns})[_:].+'";
+ }
+
+ // Add infrastructure image exclusion (matches any registry prefix)
+ $grepCommands .= " | grep -v -E '{$infraExcludePattern}'";
+
+ $commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ".
+ $grepCommands.' | '.
+ "xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true";
+
+ return implode(' && ', $commands);
+ }
+
+ private function cleanupApplicationImages(Server $server, $applications = null): array
+ {
+ $cleanupLog = [];
+
+ if ($applications === null) {
+ $applications = $server->applications();
+ }
+
+ $disableRetention = $server->settings->disable_application_image_retention ?? false;
+
+ foreach ($applications as $application) {
+ $imagesToKeep = $disableRetention ? 0 : ($application->settings->docker_images_to_keep ?? 2);
+ $imageRepository = $application->docker_registry_image_name ?? $application->uuid;
+
+ // Get the currently running image tag
+ $currentTagCommand = "docker inspect --format='{{.Config.Image}}' {$application->uuid} 2>/dev/null | grep -oP '(?<=:)[^:]+$' || true";
+ $currentTag = instant_remote_process([$currentTagCommand], $server, false);
+ $currentTag = trim($currentTag ?? '');
+
+ // List all images for this application with their creation timestamps
+ // Use wildcard to match both uuid:tag and uuid_servicename:tag (Docker Compose with build)
+ $listCommand = "docker images --format '{{.Repository}}:{{.Tag}}#{{.CreatedAt}}' --filter reference='{$imageRepository}*' 2>/dev/null || true";
+ $output = instant_remote_process([$listCommand], $server, false);
+
+ if (empty($output)) {
+ continue;
+ }
+
+ $images = collect(explode("\n", trim($output)))
+ ->filter()
+ ->map(function ($line) {
+ $parts = explode('#', $line);
+ $imageRef = $parts[0] ?? '';
+ $tagParts = explode(':', $imageRef);
+
+ return [
+ 'repository' => $tagParts[0] ?? '',
+ 'tag' => $tagParts[1] ?? '',
+ 'created_at' => $parts[1] ?? '',
+ 'image_ref' => $imageRef,
+ ];
+ })
+ ->filter(fn ($image) => ! empty($image['tag']));
+
+ // Separate images into categories
+ // PR images (pr-*) and build images (*-build) are excluded from retention
+ // Build images will be cleaned up by docker image prune -af
+ $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-'));
+ $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
+
+ // Always delete all PR images
+ foreach ($prImages as $image) {
+ $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true";
+ $deleteOutput = instant_remote_process([$deleteCommand], $server, false);
+ $cleanupLog[] = [
+ 'command' => $deleteCommand,
+ 'output' => $deleteOutput ?? 'PR image removed or was in use',
+ ];
+ }
+
+ // Filter out current running image from regular images and sort by creation date
+ $sortedRegularImages = $regularImages
+ ->filter(fn ($image) => $image['tag'] !== $currentTag)
+ ->sortByDesc('created_at')
+ ->values();
+
+ // Keep only N images (imagesToKeep), delete the rest
+ $imagesToDelete = $sortedRegularImages->skip($imagesToKeep);
+
+ foreach ($imagesToDelete as $image) {
+ $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true";
+ $deleteOutput = instant_remote_process([$deleteCommand], $server, false);
+ $cleanupLog[] = [
+ 'command' => $deleteCommand,
+ 'output' => $deleteOutput ?? 'Image removed or was in use',
+ ];
+ }
+ }
+
+ return $cleanupLog;
+ }
}
diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php
index 36c540950..eb53b32ee 100644
--- a/app/Actions/Server/InstallDocker.php
+++ b/app/Actions/Server/InstallDocker.php
@@ -78,6 +78,8 @@ public function handle(Server $server)
$command = $command->merge([$this->getRhelDockerInstallCommand()]);
} elseif ($supported_os_type->contains('sles')) {
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
+ } elseif ($supported_os_type->contains('arch')) {
+ $command = $command->merge([$this->getArchDockerInstallCommand()]);
} else {
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
}
@@ -146,8 +148,19 @@ private function getSuseDockerInstallCommand(): string
')';
}
+ private function getArchDockerInstallCommand(): string
+ {
+ // Use -Syu to perform full system upgrade before installing Docker
+ // Partial upgrades (-Sy without -u) are discouraged on Arch Linux
+ // as they can lead to broken dependencies and system instability
+ // Use --needed to skip reinstalling packages that are already up-to-date (idempotent)
+ return 'pacman -Syu --noconfirm --needed docker docker-compose && '.
+ 'systemctl enable docker.service && '.
+ 'systemctl start docker.service';
+ }
+
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}";
+ 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}";
}
}
diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php
index 1a7d3bbd9..84be7f206 100644
--- a/app/Actions/Server/InstallPrerequisites.php
+++ b/app/Actions/Server/InstallPrerequisites.php
@@ -46,6 +46,13 @@ public function handle(Server $server)
'command -v git >/dev/null || zypper install -y git',
'command -v jq >/dev/null || zypper install -y jq',
]);
+ } elseif ($supported_os_type->contains('arch')) {
+ // Use -Syu for full system upgrade to avoid partial upgrade issues on Arch Linux
+ // --needed flag skips packages that are already installed and up-to-date
+ $command = $command->merge([
+ "echo 'Installing Prerequisites for Arch Linux...'",
+ 'pacman -Syu --noconfirm --needed curl wget git jq',
+ ]);
} else {
throw new \Exception('Unsupported OS type for prerequisites installation');
}
diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php
index 0bf763d78..b5ebd92b2 100644
--- a/app/Actions/Server/UpdateCoolify.php
+++ b/app/Actions/Server/UpdateCoolify.php
@@ -3,6 +3,8 @@
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;
@@ -28,8 +30,59 @@ public function handle($manual_update = false)
if (! $this->server) {
return;
}
- CleanupDocker::dispatch($this->server, false, false);
- $this->latestVersion = get_latest_version_of_coolify();
+
+ // 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) {
@@ -42,6 +95,20 @@ public function handle($manual_update = false)
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();
@@ -49,16 +116,12 @@ public function handle($manual_update = false)
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);
+ $latestHelperImageVersion = getHelperVersion();
+ $upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
remote_process([
- 'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh',
- "bash /data/coolify/source/upgrade.sh $this->latestVersion",
+ "curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
+ "bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
], $this->server);
}
}
diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php
index 75d931f93..ab0ca9494 100644
--- a/app/Actions/Server/UpdatePackage.php
+++ b/app/Actions/Server/UpdatePackage.php
@@ -20,18 +20,43 @@ public function handle(Server $server, string $osId, ?string $package = null, ?s
'error' => 'Server is not reachable or not ready.',
];
}
+
+ // Validate that package name is provided when not updating all packages
+ if (! $all && ($package === null || $package === '')) {
+ return [
+ 'error' => "Package name required when 'all' is false.",
+ ];
+ }
+
+ // Sanitize package name to prevent command injection
+ // Only allow alphanumeric characters, hyphens, underscores, periods, plus signs, and colons
+ // These are valid characters in package names across most package managers
+ $sanitizedPackage = '';
+ if ($package !== null && ! $all) {
+ if (! preg_match('/^[a-zA-Z0-9._+:-]+$/', $package)) {
+ return [
+ 'error' => 'Invalid package name. Package names can only contain alphanumeric characters, hyphens, underscores, periods, plus signs, and colons.',
+ ];
+ }
+ $sanitizedPackage = escapeshellarg($package);
+ }
+
switch ($packageManager) {
case 'zypper':
$commandAll = 'zypper update -y';
- $commandInstall = 'zypper install -y '.$package;
+ $commandInstall = 'zypper install -y '.$sanitizedPackage;
break;
case 'dnf':
$commandAll = 'dnf update -y';
- $commandInstall = 'dnf update -y '.$package;
+ $commandInstall = 'dnf update -y '.$sanitizedPackage;
break;
case 'apt':
$commandAll = 'apt update && apt upgrade -y';
- $commandInstall = 'apt install -y '.$package;
+ $commandInstall = 'apt install -y '.$sanitizedPackage;
+ break;
+ case 'pacman':
+ $commandAll = 'pacman -Syu --noconfirm';
+ $commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage;
break;
default:
return [
diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php
index 3f4e96479..675f0f955 100644
--- a/app/Actions/Service/StopService.php
+++ b/app/Actions/Service/StopService.php
@@ -3,10 +3,12 @@
namespace App\Actions\Service;
use App\Actions\Server\CleanupDocker;
+use App\Enums\ProcessStatus;
use App\Events\ServiceStatusChanged;
use App\Models\Server;
use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction;
+use Spatie\Activitylog\Models\Activity;
class StopService
{
@@ -17,6 +19,17 @@ class StopService
public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true)
{
try {
+ // Cancel any in-progress deployment activities so status doesn't stay stuck at "starting"
+ Activity::where('properties->type_uuid', $service->uuid)
+ ->where(function ($q) {
+ $q->where('properties->status', ProcessStatus::IN_PROGRESS->value)
+ ->orWhere('properties->status', ProcessStatus::QUEUED->value);
+ })
+ ->each(function ($activity) {
+ $activity->properties = $activity->properties->put('status', ProcessStatus::CANCELLED->value);
+ $activity->save();
+ });
+
$server = $service->destination->server;
if (! $server->isFunctional()) {
return 'Server is not functional';
@@ -54,7 +67,7 @@ private function stopContainersInParallel(array $containersToStop, Server $serve
$timeout = count($containersToStop) > 5 ? 10 : 30;
$commands = [];
$containerList = implode(' ', $containersToStop);
- $commands[] = "docker stop --time=$timeout $containerList";
+ $commands[] = "docker stop -t $timeout $containerList";
$commands[] = "docker rm -f $containerList";
instant_remote_process(
command: $commands,
diff --git a/app/Console/Commands/CleanupNames.php b/app/Console/Commands/CleanupNames.php
index 2992e32b9..2451dc3ed 100644
--- a/app/Console/Commands/CleanupNames.php
+++ b/app/Console/Commands/CleanupNames.php
@@ -63,8 +63,6 @@ class CleanupNames extends Command
public function handle(): int
{
- $this->info('🔍 Scanning for invalid characters in name fields...');
-
if ($this->option('backup') && ! $this->option('dry-run')) {
$this->createBackup();
}
@@ -75,7 +73,7 @@ public function handle(): int
: $this->modelsToClean;
if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
- $this->error("❌ Unknown model: {$modelFilter}");
+ $this->error("Unknown model: {$modelFilter}");
$this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
return self::FAILURE;
@@ -88,19 +86,21 @@ public function handle(): int
$this->processModel($modelName, $modelClass);
}
- $this->displaySummary();
-
if (! $this->option('dry-run') && $this->totalCleaned > 0) {
$this->logChanges();
}
+ if ($this->option('dry-run')) {
+ $this->info("Name cleanup: would sanitize {$this->totalCleaned} records");
+ } else {
+ $this->info("Name cleanup: sanitized {$this->totalCleaned} records");
+ }
+
return self::SUCCESS;
}
protected function processModel(string $modelName, string $modelClass): void
{
- $this->info("\n📋 Processing {$modelName}...");
-
try {
$records = $modelClass::all(['id', 'name']);
$cleaned = 0;
@@ -128,21 +128,17 @@ protected function processModel(string $modelName, string $modelClass): void
$cleaned++;
$this->totalCleaned++;
- $this->warn(" 🧹 {$modelName} #{$record->id}:");
- $this->line(' From: '.$this->truncate($originalName, 80));
- $this->line(' To: '.$this->truncate($sanitizedName, 80));
+ // Only log in dry-run mode to preview changes
+ if ($this->option('dry-run')) {
+ $this->warn(" 🧹 {$modelName} #{$record->id}:");
+ $this->line(' From: '.$this->truncate($originalName, 80));
+ $this->line(' To: '.$this->truncate($sanitizedName, 80));
+ }
}
}
- if ($cleaned > 0) {
- $action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized';
- $this->info(" ✅ {$cleaned}/{$records->count()} records {$action}");
- } else {
- $this->info(' ✨ No invalid characters found');
- }
-
} catch (\Exception $e) {
- $this->error(" ❌ Error processing {$modelName}: ".$e->getMessage());
+ $this->error("Error processing {$modelName}: ".$e->getMessage());
}
}
@@ -165,28 +161,6 @@ protected function sanitizeName(string $name): string
return $sanitized;
}
- protected function displaySummary(): void
- {
- $this->info("\n".str_repeat('=', 60));
- $this->info('📊 CLEANUP SUMMARY');
- $this->info(str_repeat('=', 60));
-
- $this->line("Records processed: {$this->totalProcessed}");
- $this->line("Records with invalid characters: {$this->totalCleaned}");
-
- if ($this->option('dry-run')) {
- $this->warn("\n🔍 DRY RUN - No changes were made to the database");
- $this->info('Run without --dry-run to apply these changes');
- } else {
- if ($this->totalCleaned > 0) {
- $this->info("\n✅ Database successfully sanitized!");
- $this->info('Changes logged to storage/logs/name-cleanup.log');
- } else {
- $this->info("\n✨ No cleanup needed - all names are valid!");
- }
- }
- }
-
protected function logChanges(): void
{
$logFile = storage_path('logs/name-cleanup.log');
@@ -208,8 +182,6 @@ protected function logChanges(): void
protected function createBackup(): void
{
- $this->info('💾 Creating database backup...');
-
try {
$backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
@@ -229,15 +201,9 @@ protected function createBackup(): void
);
exec($command, $output, $returnCode);
-
- if ($returnCode === 0) {
- $this->info("✅ Backup created: {$backupFile}");
- } else {
- $this->warn('⚠️ Backup creation may have failed. Proceeding anyway...');
- }
} catch (\Exception $e) {
- $this->warn('⚠️ Could not create backup: '.$e->getMessage());
- $this->warn('Proceeding without backup...');
+ // Log failure but continue - backup is optional safeguard
+ Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]);
}
}
diff --git a/app/Console/Commands/CleanupRedis.php b/app/Console/Commands/CleanupRedis.php
index abf8010c0..199e168fc 100644
--- a/app/Console/Commands/CleanupRedis.php
+++ b/app/Console/Commands/CleanupRedis.php
@@ -18,10 +18,6 @@ public function handle()
$dryRun = $this->option('dry-run');
$skipOverlapping = $this->option('skip-overlapping');
- if ($dryRun) {
- $this->info('DRY RUN MODE - No data will be deleted');
- }
-
$deletedCount = 0;
$totalKeys = 0;
@@ -29,8 +25,6 @@ public function handle()
$keys = $redis->keys('*');
$totalKeys = count($keys);
- $this->info("Scanning {$totalKeys} keys for cleanup...");
-
foreach ($keys as $key) {
$keyWithoutPrefix = str_replace($prefix, '', $key);
$type = $redis->command('type', [$keyWithoutPrefix]);
@@ -51,14 +45,12 @@ public function handle()
// Clean up overlapping queues if not skipped
if (! $skipOverlapping) {
- $this->info('Cleaning up overlapping queues...');
$overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
$deletedCount += $overlappingCleaned;
}
// Clean up stale cache locks (WithoutOverlapping middleware)
if ($this->option('clear-locks')) {
- $this->info('Cleaning up stale cache locks...');
$locksCleaned = $this->cleanupCacheLocks($dryRun);
$deletedCount += $locksCleaned;
}
@@ -66,15 +58,14 @@ public function handle()
// Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
$isRestart = $this->option('restart');
if ($isRestart || $this->option('clear-locks')) {
- $this->info($isRestart ? 'Cleaning up stuck jobs (RESTART MODE - aggressive)...' : 'Checking for stuck jobs (runtime mode - conservative)...');
$jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart);
$deletedCount += $jobsCleaned;
}
if ($dryRun) {
- $this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
+ $this->info("Redis cleanup: would delete {$deletedCount} items");
} else {
- $this->info("Deleted {$deletedCount} out of {$totalKeys} keys");
+ $this->info("Redis cleanup: deleted {$deletedCount} items");
}
}
@@ -85,11 +76,8 @@ private function shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)
// Delete completed and failed jobs
if (in_array($status, ['completed', 'failed'])) {
- if ($dryRun) {
- $this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})");
- } else {
+ if (! $dryRun) {
$redis->command('del', [$keyWithoutPrefix]);
- $this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})");
}
return true;
@@ -115,11 +103,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR
foreach ($patterns as $pattern => $description) {
if (str_contains($keyWithoutPrefix, $pattern)) {
- if ($dryRun) {
- $this->line("Would delete {$description}: {$keyWithoutPrefix}");
- } else {
+ if (! $dryRun) {
$redis->command('del', [$keyWithoutPrefix]);
- $this->line("Deleted {$description}: {$keyWithoutPrefix}");
}
return true;
@@ -132,11 +117,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR
$weekAgo = now()->subDays(7)->timestamp;
if ($timestamp < $weekAgo) {
- if ($dryRun) {
- $this->line("Would delete old timestamped data: {$keyWithoutPrefix}");
- } else {
+ if (! $dryRun) {
$redis->command('del', [$keyWithoutPrefix]);
- $this->line("Deleted old timestamped data: {$keyWithoutPrefix}");
}
return true;
@@ -160,8 +142,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
}
}
- $this->info('Found '.count($queueKeys).' queue-related keys');
-
// Group queues by name pattern to find duplicates
$queueGroups = [];
foreach ($queueKeys as $queueKey) {
@@ -193,7 +173,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
{
$cleanedCount = 0;
- $this->line("Processing queue group: {$baseName} (".count($keys).' keys)');
// Sort keys to keep the most recent one
usort($keys, function ($a, $b) {
@@ -244,11 +223,8 @@ private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
}
if ($shouldDelete) {
- if ($dryRun) {
- $this->line(" Would delete empty queue: {$redundantKey}");
- } else {
+ if (! $dryRun) {
$redis->command('del', [$redundantKey]);
- $this->line(" Deleted empty queue: {$redundantKey}");
}
$cleanedCount++;
}
@@ -271,15 +247,12 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun)
if (count($uniqueItems) < count($items)) {
$duplicates = count($items) - count($uniqueItems);
- if ($dryRun) {
- $this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}");
- } else {
+ if (! $dryRun) {
// Rebuild the list with unique items
$redis->command('del', [$queueKey]);
foreach (array_reverse($uniqueItems) as $item) {
$redis->command('lpush', [$queueKey, $item]);
}
- $this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}");
}
$cleanedCount += $duplicates;
}
@@ -307,13 +280,9 @@ private function cleanupCacheLocks(bool $dryRun): int
}
}
if (empty($lockKeys)) {
- $this->info(' No cache locks found.');
-
return 0;
}
- $this->info(' Found '.count($lockKeys).' cache lock(s)');
-
foreach ($lockKeys as $lockKey) {
// Check TTL to identify stale locks
$ttl = $redis->ttl($lockKey);
@@ -326,18 +295,11 @@ private function cleanupCacheLocks(bool $dryRun): int
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
} else {
$redis->del($lockKey);
- $this->info(" ✓ Deleted STALE lock: {$lockKey}");
}
$cleanedCount++;
- } elseif ($ttl > 0) {
- $this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}");
}
}
- if ($cleanedCount === 0) {
- $this->info(' No stale locks found (all locks have expiration set)');
- }
-
return $cleanedCount;
}
@@ -453,17 +415,11 @@ private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $is
$redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
$redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
$redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]);
-
- $this->info(" ✓ Marked as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1).' min) - '.$reason);
}
$cleanedCount++;
}
}
- if ($cleanedCount === 0) {
- $this->info($isRestart ? ' No jobs to clean up' : ' No stuck jobs found (all jobs running normally)');
- }
-
return $cleanedCount;
}
}
diff --git a/app/Console/Commands/NotifyDemo.php b/app/Console/Commands/NotifyDemo.php
index 990a03869..8e9251ac0 100644
--- a/app/Console/Commands/NotifyDemo.php
+++ b/app/Console/Commands/NotifyDemo.php
@@ -56,7 +56,7 @@ private function showHelp()
php artisan app:demo-notify {channel}
-
Channels:
+
Channels:
email
discord
diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php
index e634feadb..0a98f1dc8 100644
--- a/app/Console/Commands/SyncBunny.php
+++ b/app/Console/Commands/SyncBunny.php
@@ -16,7 +16,7 @@ class SyncBunny extends Command
*
* @var string
*/
- protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}';
+ protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}';
/**
* The console command description.
@@ -50,6 +50,7 @@ private function syncReleasesToGitHubRepo(): bool
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
+ $output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
@@ -59,6 +60,7 @@ private function syncReleasesToGitHubRepo(): bool
// Create feature branch
$this->info('Creating feature branch...');
+ $output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
@@ -70,12 +72,25 @@ private function syncReleasesToGitHubRepo(): bool
// Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
+ $releasesDir = dirname($releasesPath);
+
+ // Ensure directory exists
+ if (! is_dir($releasesDir)) {
+ $this->info("Creating directory: $releasesDir");
+ if (! mkdir($releasesDir, 0755, true)) {
+ $this->error("Failed to create directory: $releasesDir");
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+ }
+
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write releases.json to: $releasesPath");
- $this->error('Possible reasons: directory does not exist, permission denied, or disk full.');
+ $this->error('Possible reasons: permission denied or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
@@ -83,6 +98,7 @@ private function syncReleasesToGitHubRepo(): bool
// Stage and commit
$this->info('Committing changes...');
+ $output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
@@ -120,6 +136,7 @@ private function syncReleasesToGitHubRepo(): bool
// Push to remote
$this->info('Pushing branch to remote...');
+ $output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
@@ -133,6 +150,7 @@ private function syncReleasesToGitHubRepo(): bool
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
+ $output = [];
exec($prCommand, $output, $returnCode);
// Clean up
@@ -158,6 +176,343 @@ private function syncReleasesToGitHubRepo(): bool
}
}
+ /**
+ * Sync both releases.json and versions.json to GitHub repository in one PR
+ */
+ private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
+ {
+ $this->info('Syncing releases.json and versions.json to GitHub repository...');
+ try {
+ // 1. Fetch releases from GitHub API
+ $this->info('Fetching releases from GitHub API...');
+ $response = Http::timeout(30)
+ ->get('https://api.github.com/repos/coollabsio/coolify/releases', [
+ 'per_page' => 30,
+ ]);
+
+ if (! $response->successful()) {
+ $this->error('Failed to fetch releases from GitHub: '.$response->status());
+
+ return false;
+ }
+
+ $releases = $response->json();
+
+ // 2. Read versions.json
+ if (! file_exists($versionsLocation)) {
+ $this->error("versions.json not found at: $versionsLocation");
+
+ return false;
+ }
+
+ $file = file_get_contents($versionsLocation);
+ $versionsJson = json_decode($file, true);
+ $actualVersion = data_get($versionsJson, 'coolify.v4.version');
+
+ $timestamp = time();
+ $tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
+ $branchName = 'update-releases-and-versions-'.$timestamp;
+ $versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
+
+ // 3. Clone the repository
+ $this->info('Cloning coolify-cdn repository...');
+ $output = [];
+ exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to clone repository: '.implode("\n", $output));
+
+ return false;
+ }
+
+ // 4. Create feature branch
+ $this->info('Creating feature branch...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to create branch: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // 5. Write releases.json
+ $this->info('Writing releases.json...');
+ $releasesPath = "$tmpDir/json/releases.json";
+ $releasesDir = dirname($releasesPath);
+
+ if (! is_dir($releasesDir)) {
+ if (! mkdir($releasesDir, 0755, true)) {
+ $this->error("Failed to create directory: $releasesDir");
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+ }
+
+ $releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
+ $this->error("Failed to write releases.json to: $releasesPath");
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // 6. Write versions.json
+ $this->info('Writing versions.json...');
+ $versionsPath = "$tmpDir/$versionsTargetPath";
+ $versionsDir = dirname($versionsPath);
+
+ if (! is_dir($versionsDir)) {
+ if (! mkdir($versionsDir, 0755, true)) {
+ $this->error("Failed to create directory: $versionsDir");
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+ }
+
+ $versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
+ $this->error("Failed to write versions.json to: $versionsPath");
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // 7. Stage both files
+ $this->info('Staging changes...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to stage changes: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // 8. Check for changes
+ $this->info('Checking for changes...');
+ $statusOutput = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to check repository status: '.implode("\n", $statusOutput));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ if (empty(array_filter($statusOutput))) {
+ $this->info('Both files are already up to date. No changes to commit.');
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return true;
+ }
+
+ // 9. Commit changes
+ $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
+ $commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to commit changes: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // 10. Push to remote
+ $this->info('Pushing branch to remote...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to push branch: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // 11. Create pull request
+ $this->info('Creating pull request...');
+ $prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
+ $prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
+ $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
+ $output = [];
+ exec($prCommand, $output, $returnCode);
+
+ // 12. Clean up
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ if ($returnCode !== 0) {
+ $this->error('Failed to create PR: '.implode("\n", $output));
+
+ return false;
+ }
+
+ $this->info('Pull request created successfully!');
+ if (! empty($output)) {
+ $this->info('PR URL: '.implode("\n", $output));
+ }
+ $this->info("Version synced: $actualVersion");
+ $this->info('Total releases synced: '.count($releases));
+
+ return true;
+ } catch (\Throwable $e) {
+ $this->error('Error syncing to GitHub: '.$e->getMessage());
+
+ return false;
+ }
+ }
+
+ /**
+ * Sync versions.json to GitHub repository via PR
+ */
+ private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
+ {
+ $this->info('Syncing versions.json to GitHub repository...');
+ try {
+ if (! file_exists($versionsLocation)) {
+ $this->error("versions.json not found at: $versionsLocation");
+
+ return false;
+ }
+
+ $file = file_get_contents($versionsLocation);
+ $json = json_decode($file, true);
+ $actualVersion = data_get($json, 'coolify.v4.version');
+
+ $timestamp = time();
+ $tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
+ $branchName = 'update-versions-'.$timestamp;
+ $targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
+
+ // Clone the repository
+ $this->info('Cloning coolify-cdn repository...');
+ exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to clone repository: '.implode("\n", $output));
+
+ return false;
+ }
+
+ // Create feature branch
+ $this->info('Creating feature branch...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to create branch: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // Write versions.json
+ $this->info('Writing versions.json...');
+ $versionsPath = "$tmpDir/$targetPath";
+ $versionsDir = dirname($versionsPath);
+
+ // Ensure directory exists
+ if (! is_dir($versionsDir)) {
+ $this->info("Creating directory: $versionsDir");
+ if (! mkdir($versionsDir, 0755, true)) {
+ $this->error("Failed to create directory: $versionsDir");
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+ }
+
+ $jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ $bytesWritten = file_put_contents($versionsPath, $jsonContent);
+
+ if ($bytesWritten === false) {
+ $this->error("Failed to write versions.json to: $versionsPath");
+ $this->error('Possible reasons: permission denied or disk full.');
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // Stage and commit
+ $this->info('Committing changes...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to stage changes: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ $this->info('Checking for changes...');
+ $statusOutput = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to check repository status: '.implode("\n", $statusOutput));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ if (empty(array_filter($statusOutput))) {
+ $this->info('versions.json is already up to date. No changes to commit.');
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return true;
+ }
+
+ $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
+ $commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to commit changes: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // Push to remote
+ $this->info('Pushing branch to remote...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to push branch: '.implode("\n", $output));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // Create pull request
+ $this->info('Creating pull request...');
+ $prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
+ $prBody = "Automated update of $envLabel versions.json to version $actualVersion";
+ $output = [];
+ $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
+ exec($prCommand, $output, $returnCode);
+
+ // Clean up
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ if ($returnCode !== 0) {
+ $this->error('Failed to create PR: '.implode("\n", $output));
+
+ return false;
+ }
+
+ $this->info('Pull request created successfully!');
+ if (! empty($output)) {
+ $this->info('PR URL: '.implode("\n", $output));
+ }
+ $this->info("Version synced: $actualVersion");
+
+ return true;
+ } catch (\Throwable $e) {
+ $this->error('Error syncing versions.json: '.$e->getMessage());
+
+ return false;
+ }
+ }
+
/**
* Execute the console command.
*/
@@ -167,6 +522,7 @@ public function handle()
$only_template = $this->option('templates');
$only_version = $this->option('release');
$only_github_releases = $this->option('github-releases');
+ $only_github_versions = $this->option('github-versions');
$nightly = $this->option('nightly');
$bunny_cdn = 'https://cdn.coollabs.io';
$bunny_cdn_path = 'coolify';
@@ -224,7 +580,7 @@ public function handle()
$install_script_location = "$parent_dir/other/nightly/$install_script";
$versions_location = "$parent_dir/other/nightly/$versions";
}
- if (! $only_template && ! $only_version && ! $only_github_releases) {
+ if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
if ($nightly) {
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
} else {
@@ -250,25 +606,47 @@ public function handle()
return;
} elseif ($only_version) {
if ($nightly) {
- $this->info('About to sync NIGHLTY versions.json to BunnyCDN.');
+ $this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.');
} else {
- $this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
+ $this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.');
}
$file = file_get_contents($versions_location);
$json = json_decode($file, true);
$actual_version = data_get($json, 'coolify.v4.version');
- $confirmed = confirm("Are you sure you want to sync to {$actual_version}?");
+ $this->info("Version: {$actual_version}");
+ $this->info('This will:');
+ $this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)');
+ $this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
+ $this->newLine();
+
+ $confirmed = confirm('Are you sure you want to proceed?');
if (! $confirmed) {
return;
}
- // Sync versions.json to BunnyCDN
+ // 1. Sync versions.json to BunnyCDN (deprecated but still needed)
+ $this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
]);
- $this->info('versions.json uploaded & purged...');
+ $this->info('✓ versions.json uploaded & purged to BunnyCDN');
+ $this->newLine();
+
+ // 2. Create GitHub PR with both releases.json and versions.json
+ $this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
+ $githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
+ if ($githubSuccess) {
+ $this->info('✓ GitHub PR created successfully with both files');
+ } else {
+ $this->error('✗ Failed to create GitHub PR');
+ }
+ $this->newLine();
+
+ $this->info('=== Summary ===');
+ $this->info('BunnyCDN sync: ✓ Complete');
+ $this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed'));
return;
} elseif ($only_github_releases) {
@@ -281,6 +659,22 @@ public function handle()
// Sync releases to GitHub repository
$this->syncReleasesToGitHubRepo();
+ return;
+ } elseif ($only_github_versions) {
+ $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
+ $file = file_get_contents($versions_location);
+ $json = json_decode($file, true);
+ $actual_version = data_get($json, 'coolify.v4.version');
+
+ $this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository.");
+ $confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?');
+ if (! $confirmed) {
+ return;
+ }
+
+ // Sync versions.json to GitHub repository
+ $this->syncVersionsToGitHubRepo($versions_location, $nightly);
+
return;
}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index 832bed5ae..d82d3a1b9 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -2,11 +2,11 @@
namespace App\Console;
-use App\Jobs\CheckAndStartSentinelJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob;
+use App\Jobs\CleanupOrphanedPreviewContainersJob;
use App\Jobs\PullChangelog;
use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob;
@@ -14,16 +14,11 @@
use App\Jobs\ServerManagerJob;
use App\Jobs\UpdateCoolifyJob;
use App\Models\InstanceSettings;
-use App\Models\Server;
-use App\Models\Team;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
-use Illuminate\Support\Facades\Log;
class Kernel extends ConsoleKernel
{
- private $allServers;
-
private Schedule $scheduleInstance;
private InstanceSettings $settings;
@@ -35,8 +30,6 @@ class Kernel extends ConsoleKernel
protected function schedule(Schedule $schedule): void
{
$this->scheduleInstance = $schedule;
- $this->allServers = Server::where('ip', '!=', '1.2.3.4');
-
$this->settings = instanceSettings();
$this->updateCheckFrequency = $this->settings->update_check_frequency ?: '0 * * * *';
@@ -88,29 +81,14 @@ protected function schedule(Schedule $schedule): void
$this->scheduleInstance->command('cleanup:database --yes')->daily();
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
+
+ // Cleanup orphaned PR preview containers daily
+ $this->scheduleInstance->job(new CleanupOrphanedPreviewContainersJob)->daily()->onOneServer();
}
}
private function pullImages(): void
{
- if (isCloud()) {
- $servers = $this->allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get();
- $own = Team::find(0)->servers;
- $servers = $servers->merge($own);
- } else {
- $servers = $this->allServers->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get();
- }
- foreach ($servers as $server) {
- try {
- if ($server->isSentinelEnabled()) {
- $this->scheduleInstance->job(function () use ($server) {
- CheckAndStartSentinelJob::dispatch($server);
- })->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
- }
- } catch (\Exception $e) {
- Log::error('Error pulling images: '.$e->getMessage());
- }
- }
$this->scheduleInstance->job(new CheckHelperImageJob)
->cron($this->updateCheckFrequency)
->timezone($this->instanceTimezone)
diff --git a/app/Events/ProxyStatusChangedUI.php b/app/Events/ProxyStatusChangedUI.php
index bd99a0f3c..3994dc0f8 100644
--- a/app/Events/ProxyStatusChangedUI.php
+++ b/app/Events/ProxyStatusChangedUI.php
@@ -14,12 +14,15 @@ class ProxyStatusChangedUI implements ShouldBroadcast
public ?int $teamId = null;
- public function __construct(?int $teamId = null)
+ public ?int $activityId = null;
+
+ public function __construct(?int $teamId = null, ?int $activityId = null)
{
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
$teamId = auth()->user()->currentTeam()->id;
}
$this->teamId = $teamId;
+ $this->activityId = $activityId;
}
public function broadcastOn(): array
diff --git a/app/Events/RestoreJobFinished.php b/app/Events/RestoreJobFinished.php
index d3adb7798..8690e01f6 100644
--- a/app/Events/RestoreJobFinished.php
+++ b/app/Events/RestoreJobFinished.php
@@ -17,17 +17,23 @@ public function __construct($data)
$tmpPath = data_get($data, 'tmpPath');
$container = data_get($data, 'container');
$serverId = data_get($data, 'serverId');
- if (filled($scriptPath) && filled($tmpPath) && filled($container) && filled($serverId)) {
- if (str($tmpPath)->startsWith('/tmp/')
- && str($scriptPath)->startsWith('/tmp/')
- && ! str($tmpPath)->contains('..')
- && ! str($scriptPath)->contains('..')
- && strlen($tmpPath) > 5 // longer than just "/tmp/"
- && strlen($scriptPath) > 5
- ) {
- $commands[] = "docker exec {$container} sh -c 'rm {$scriptPath}'";
- $commands[] = "docker exec {$container} sh -c 'rm {$tmpPath}'";
- instant_remote_process($commands, Server::find($serverId), throwError: true);
+
+ if (filled($container) && filled($serverId)) {
+ $commands = [];
+
+ if (isSafeTmpPath($scriptPath)) {
+ $commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($scriptPath)." 2>/dev/null || true'";
+ }
+
+ if (isSafeTmpPath($tmpPath)) {
+ $commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($tmpPath)." 2>/dev/null || true'";
+ }
+
+ if (! empty($commands)) {
+ $server = Server::find($serverId);
+ if ($server) {
+ instant_remote_process($commands, $server, throwError: false);
+ }
}
}
}
diff --git a/app/Events/S3RestoreJobFinished.php b/app/Events/S3RestoreJobFinished.php
new file mode 100644
index 000000000..e1f844558
--- /dev/null
+++ b/app/Events/S3RestoreJobFinished.php
@@ -0,0 +1,56 @@
+/dev/null || true';
+ }
+
+ // Clean up server temp file if still exists (should already be cleaned)
+ if (isSafeTmpPath($serverTmpPath)) {
+ $commands[] = 'rm -f '.escapeshellarg($serverTmpPath).' 2>/dev/null || true';
+ }
+
+ // Clean up any remaining files in database container (may already be cleaned)
+ if (filled($container)) {
+ if (isSafeTmpPath($containerTmpPath)) {
+ $commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($containerTmpPath).' 2>/dev/null || true';
+ }
+ if (isSafeTmpPath($scriptPath)) {
+ $commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($scriptPath).' 2>/dev/null || true';
+ }
+ }
+
+ if (! empty($commands)) {
+ $server = Server::find($serverId);
+ if ($server) {
+ instant_remote_process($commands, $server, throwError: false);
+ }
+ }
+ }
+ }
+}
diff --git a/app/Exceptions/RateLimitException.php b/app/Exceptions/RateLimitException.php
new file mode 100644
index 000000000..fde0235dd
--- /dev/null
+++ b/app/Exceptions/RateLimitException.php
@@ -0,0 +1,15 @@
+settings->force_disabled) {
throw new \RuntimeException('Server is disabled.');
@@ -168,7 +168,7 @@ public static function generateSshCommand(Server $server, string $command)
$ssh_command = "timeout $timeout ssh ";
$multiplexingSuccessful = false;
- if (self::isMultiplexingEnabled()) {
+ if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
try {
$multiplexingSuccessful = self::ensureMultiplexedConnection($server);
if ($multiplexingSuccessful) {
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 6b4f1efee..92c5f04a2 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -192,6 +192,7 @@ public function applications(Request $request)
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
+ 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
],
)
),
@@ -342,6 +343,7 @@ public function create_public_application(Request $request)
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
+ 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
],
)
),
@@ -492,6 +494,7 @@ public function create_private_gh_app_application(Request $request)
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
+ 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
],
)
),
@@ -626,6 +629,7 @@ public function create_private_deploy_key_application(Request $request)
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
+ 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
],
)
),
@@ -757,6 +761,7 @@ public function create_dockerfile_application(Request $request)
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
+ 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
],
)
),
@@ -927,7 +932,7 @@ private function create_application(Request $request, $type)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
- $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override'];
+ $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
@@ -940,6 +945,7 @@ private function create_application(Request $request, $type)
'is_http_basic_auth_enabled' => 'boolean',
'http_basic_auth_username' => 'string|nullable',
'http_basic_auth_password' => 'string|nullable',
+ 'autogenerate_domain' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -964,6 +970,7 @@ private function create_application(Request $request, $type)
}
$serverUuid = $request->server_uuid;
$fqdn = $request->domains;
+ $autogenerateDomain = $request->boolean('autogenerate_domain', true);
$instantDeploy = $request->instant_deploy;
$githubAppUuid = $request->github_app_uuid;
$useBuildServer = $request->use_build_server;
@@ -1087,6 +1094,11 @@ private function create_application(Request $request, $type)
$application->settings->save();
}
$application->refresh();
+ // Auto-generate domain if requested and no custom domain provided
+ if ($autogenerateDomain && blank($fqdn)) {
+ $application->fqdn = generateUrl(server: $server, random: $application->uuid);
+ $application->save();
+ }
if ($application->settings->is_container_label_readonly_enabled) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
@@ -1115,7 +1127,7 @@ private function create_application(Request $request, $type)
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
- 'domains' => data_get($application, 'domains'),
+ 'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'private-gh-app') {
$validationRules = [
@@ -1238,6 +1250,11 @@ private function create_application(Request $request, $type)
$application->save();
$application->refresh();
+ // Auto-generate domain if requested and no custom domain provided
+ if ($autogenerateDomain && blank($fqdn)) {
+ $application->fqdn = generateUrl(server: $server, random: $application->uuid);
+ $application->save();
+ }
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@@ -1270,7 +1287,7 @@ private function create_application(Request $request, $type)
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
- 'domains' => data_get($application, 'domains'),
+ 'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'private-deploy-key') {
@@ -1367,6 +1384,11 @@ private function create_application(Request $request, $type)
$application->environment_id = $environment->id;
$application->save();
$application->refresh();
+ // Auto-generate domain if requested and no custom domain provided
+ if ($autogenerateDomain && blank($fqdn)) {
+ $application->fqdn = generateUrl(server: $server, random: $application->uuid);
+ $application->save();
+ }
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@@ -1399,7 +1421,7 @@ private function create_application(Request $request, $type)
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
- 'domains' => data_get($application, 'domains'),
+ 'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'dockerfile') {
$validationRules = [
@@ -1461,6 +1483,11 @@ private function create_application(Request $request, $type)
$application->git_branch = 'main';
$application->save();
$application->refresh();
+ // Auto-generate domain if requested and no custom domain provided
+ if ($autogenerateDomain && blank($fqdn)) {
+ $application->fqdn = generateUrl(server: $server, random: $application->uuid);
+ $application->save();
+ }
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@@ -1489,7 +1516,7 @@ private function create_application(Request $request, $type)
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
- 'domains' => data_get($application, 'domains'),
+ 'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'dockerimage') {
$validationRules = [
@@ -1554,6 +1581,11 @@ private function create_application(Request $request, $type)
$application->git_branch = 'main';
$application->save();
$application->refresh();
+ // Auto-generate domain if requested and no custom domain provided
+ if ($autogenerateDomain && blank($fqdn)) {
+ $application->fqdn = generateUrl(server: $server, random: $application->uuid);
+ $application->save();
+ }
if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save();
@@ -1582,7 +1614,7 @@ private function create_application(Request $request, $type)
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
- 'domains' => data_get($application, 'domains'),
+ 'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'dockercompose') {
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override'];
@@ -1652,6 +1684,10 @@ private function create_application(Request $request, $type)
$service->save();
$service->parse(isNew: true);
+
+ // Apply service-specific application prerequisites
+ applyServiceApplicationPrerequisites($service);
+
if ($instantDeploy) {
StartService::dispatch($service);
}
diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php
new file mode 100644
index 000000000..5a03fe59a
--- /dev/null
+++ b/app/Http/Controllers/Api/CloudProviderTokensController.php
@@ -0,0 +1,531 @@
+makeHidden([
+ 'id',
+ 'token',
+ ]);
+
+ return serializeApiResponse($token);
+ }
+
+ /**
+ * Validate a provider token against the provider's API.
+ *
+ * @return array{valid: bool, error: string|null}
+ */
+ private function validateProviderToken(string $provider, string $token): array
+ {
+ try {
+ $response = match ($provider) {
+ 'hetzner' => Http::withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'),
+ 'digitalocean' => Http::withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ ])->timeout(10)->get('https://api.digitalocean.com/v2/account'),
+ default => null,
+ };
+
+ if ($response === null) {
+ return ['valid' => false, 'error' => 'Unsupported provider.'];
+ }
+
+ if ($response->successful()) {
+ return ['valid' => true, 'error' => null];
+ }
+
+ return ['valid' => false, 'error' => "Invalid {$provider} token. Please check your API token."];
+ } catch (\Throwable $e) {
+ Log::error('Failed to validate cloud provider token', [
+ 'provider' => $provider,
+ 'exception' => $e->getMessage(),
+ ]);
+
+ return ['valid' => false, 'error' => 'Failed to validate token with provider API.'];
+ }
+ }
+
+ #[OA\Get(
+ summary: 'List Cloud Provider Tokens',
+ description: 'List all cloud provider tokens for the authenticated team.',
+ path: '/cloud-tokens',
+ operationId: 'list-cloud-tokens',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Cloud Tokens'],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Get all cloud provider tokens.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'array',
+ items: new OA\Items(
+ type: 'object',
+ properties: [
+ 'uuid' => ['type' => 'string'],
+ 'name' => ['type' => 'string'],
+ 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']],
+ 'team_id' => ['type' => 'integer'],
+ 'servers_count' => ['type' => 'integer'],
+ 'created_at' => ['type' => 'string'],
+ 'updated_at' => ['type' => 'string'],
+ ]
+ )
+ )
+ ),
+ ]),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ ]
+ )]
+ public function index(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $tokens = CloudProviderToken::whereTeamId($teamId)
+ ->withCount('servers')
+ ->get()
+ ->map(function ($token) {
+ return $this->removeSensitiveData($token);
+ });
+
+ return response()->json($tokens);
+ }
+
+ #[OA\Get(
+ summary: 'Get Cloud Provider Token',
+ description: 'Get cloud provider token by UUID.',
+ path: '/cloud-tokens/{uuid}',
+ operationId: 'get-cloud-token-by-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Cloud Tokens'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Get cloud provider token by UUID',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'uuid' => ['type' => 'string'],
+ 'name' => ['type' => 'string'],
+ 'provider' => ['type' => 'string'],
+ 'team_id' => ['type' => 'integer'],
+ 'servers_count' => ['type' => 'integer'],
+ 'created_at' => ['type' => 'string'],
+ 'updated_at' => ['type' => 'string'],
+ ]
+ )
+ ),
+ ]),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function show(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $token = CloudProviderToken::whereTeamId($teamId)
+ ->whereUuid($request->uuid)
+ ->withCount('servers')
+ ->first();
+
+ if (is_null($token)) {
+ return response()->json(['message' => 'Cloud provider token not found.'], 404);
+ }
+
+ return response()->json($this->removeSensitiveData($token));
+ }
+
+ #[OA\Post(
+ summary: 'Create Cloud Provider Token',
+ description: 'Create a new cloud provider token. The token will be validated before being stored.',
+ path: '/cloud-tokens',
+ operationId: 'create-cloud-token',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Cloud Tokens'],
+ requestBody: new OA\RequestBody(
+ required: true,
+ description: 'Cloud provider token details',
+ content: new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['provider', 'token', 'name'],
+ properties: [
+ 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
+ 'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'],
+ 'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'],
+ ],
+ ),
+ ),
+ ),
+ responses: [
+ new OA\Response(
+ response: 201,
+ description: 'Cloud provider token created.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the token.'],
+ ]
+ )
+ ),
+ ]),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function store(Request $request)
+ {
+ $allowedFields = ['provider', 'token', 'name'];
+
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof \Illuminate\Http\JsonResponse) {
+ return $return;
+ }
+
+ // Use request body only (excludes any route parameters)
+ $body = $request->json()->all();
+
+ $validator = customApiValidator($body, [
+ 'provider' => 'required|string|in:hetzner,digitalocean',
+ 'token' => 'required|string',
+ 'name' => 'required|string|max:255',
+ ]);
+
+ $extraFields = array_diff(array_keys($body), $allowedFields);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ if (! empty($extraFields)) {
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ // Validate token with the provider's API
+ $validation = $this->validateProviderToken($body['provider'], $body['token']);
+
+ if (! $validation['valid']) {
+ return response()->json(['message' => $validation['error']], 400);
+ }
+
+ $cloudProviderToken = CloudProviderToken::create([
+ 'team_id' => $teamId,
+ 'provider' => $body['provider'],
+ 'token' => $body['token'],
+ 'name' => $body['name'],
+ ]);
+
+ return response()->json([
+ 'uuid' => $cloudProviderToken->uuid,
+ ])->setStatusCode(201);
+ }
+
+ #[OA\Patch(
+ summary: 'Update Cloud Provider Token',
+ description: 'Update cloud provider token name.',
+ path: '/cloud-tokens/{uuid}',
+ operationId: 'update-cloud-token-by-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Cloud Tokens'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
+ ],
+ requestBody: new OA\RequestBody(
+ required: true,
+ description: 'Cloud provider token updated.',
+ content: new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'name' => ['type' => 'string', 'description' => 'The friendly name for the token.'],
+ ],
+ ),
+ ),
+ ),
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Cloud provider token updated.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'uuid' => ['type' => 'string'],
+ ]
+ )
+ ),
+ ]),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ ]
+ )]
+ public function update(Request $request)
+ {
+ $allowedFields = ['name'];
+
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof \Illuminate\Http\JsonResponse) {
+ return $return;
+ }
+
+ // Use request body only (excludes route parameters like uuid)
+ $body = $request->json()->all();
+
+ $validator = customApiValidator($body, [
+ 'name' => 'required|string|max:255',
+ ]);
+
+ $extraFields = array_diff(array_keys($body), $allowedFields);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ if (! empty($extraFields)) {
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ // Use route parameter for UUID lookup
+ $token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
+ if (! $token) {
+ return response()->json(['message' => 'Cloud provider token not found.'], 404);
+ }
+
+ $token->update(array_intersect_key($body, array_flip($allowedFields)));
+
+ return response()->json([
+ 'uuid' => $token->uuid,
+ ]);
+ }
+
+ #[OA\Delete(
+ summary: 'Delete Cloud Provider Token',
+ description: 'Delete cloud provider token by UUID. Cannot delete if token is used by any servers.',
+ path: '/cloud-tokens/{uuid}',
+ operationId: 'delete-cloud-token-by-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Cloud Tokens'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the cloud provider token.',
+ required: true,
+ schema: new OA\Schema(
+ type: 'string',
+ format: 'uuid',
+ )
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Cloud provider token deleted.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'message' => ['type' => 'string', 'example' => 'Cloud provider token deleted.'],
+ ]
+ )
+ ),
+ ]),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function destroy(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ if (! $request->uuid) {
+ return response()->json(['message' => 'UUID is required.'], 422);
+ }
+
+ $token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first();
+
+ if (! $token) {
+ return response()->json(['message' => 'Cloud provider token not found.'], 404);
+ }
+
+ if ($token->hasServers()) {
+ return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
+ }
+
+ $token->delete();
+
+ return response()->json(['message' => 'Cloud provider token deleted.']);
+ }
+
+ #[OA\Post(
+ summary: 'Validate Cloud Provider Token',
+ description: 'Validate a cloud provider token against the provider API.',
+ path: '/cloud-tokens/{uuid}/validate',
+ operationId: 'validate-cloud-token-by-uuid',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Cloud Tokens'],
+ parameters: [
+ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'Token validation result.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'valid' => ['type' => 'boolean', 'example' => true],
+ 'message' => ['type' => 'string', 'example' => 'Token is valid.'],
+ ]
+ )
+ ),
+ ]),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function validateToken(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $cloudToken = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first();
+
+ if (! $cloudToken) {
+ return response()->json(['message' => 'Cloud provider token not found.'], 404);
+ }
+
+ $validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token);
+
+ return response()->json([
+ 'valid' => $validation['valid'],
+ 'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'],
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php
index 16a7b6f71..136fcf557 100644
--- a/app/Http/Controllers/Api/DeployController.php
+++ b/app/Http/Controllers/Api/DeployController.php
@@ -388,7 +388,11 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p
continue;
}
}
- ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr);
+ $result = $this->deploy_resource($resource, $force, $pr);
+ if (isset($result['status']) && $result['status'] === 429) {
+ return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
+ }
+ ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
if ($deployment_uuid) {
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
} else {
@@ -430,7 +434,11 @@ public function by_tags(string $tags, int $team_id, bool $force = false)
continue;
}
foreach ($applications as $resource) {
- ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force);
+ $result = $this->deploy_resource($resource, $force);
+ if (isset($result['status']) && $result['status'] === 429) {
+ return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
+ }
+ ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
if ($deployment_uuid) {
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
}
@@ -474,8 +482,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar
deployment_uuid: $deployment_uuid,
force_rebuild: $force,
pull_request_id: $pr,
+ is_api: true,
);
- if ($result['status'] === 'skipped') {
+ if ($result['status'] === 'queue_full') {
+ return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429];
+ } elseif ($result['status'] === 'skipped') {
$message = $result['message'];
} else {
$message = "Application {$resource->name} deployment queued.";
diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php
new file mode 100644
index 000000000..2645c2df1
--- /dev/null
+++ b/app/Http/Controllers/Api/HetznerController.php
@@ -0,0 +1,738 @@
+cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
+ }
+
+ #[OA\Get(
+ summary: 'Get Hetzner Locations',
+ description: 'Get all available Hetzner datacenter locations.',
+ path: '/hetzner/locations',
+ operationId: 'get-hetzner-locations',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Hetzner'],
+ parameters: [
+ new OA\Parameter(
+ name: 'cloud_provider_token_uuid',
+ in: 'query',
+ required: false,
+ description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
+ schema: new OA\Schema(type: 'string')
+ ),
+ new OA\Parameter(
+ name: 'cloud_provider_token_id',
+ in: 'query',
+ required: false,
+ deprecated: true,
+ description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
+ schema: new OA\Schema(type: 'string')
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'List of Hetzner locations.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'array',
+ items: new OA\Items(
+ type: 'object',
+ properties: [
+ 'id' => ['type' => 'integer'],
+ 'name' => ['type' => 'string'],
+ 'description' => ['type' => 'string'],
+ 'country' => ['type' => 'string'],
+ 'city' => ['type' => 'string'],
+ 'latitude' => ['type' => 'number'],
+ 'longitude' => ['type' => 'number'],
+ ]
+ )
+ )
+ ),
+ ]),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function locations(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $validator = customApiValidator($request->all(), [
+ 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
+ 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $validator->errors(),
+ ], 422);
+ }
+
+ $tokenUuid = $this->getCloudProviderTokenUuid($request);
+ $token = CloudProviderToken::whereTeamId($teamId)
+ ->whereUuid($tokenUuid)
+ ->where('provider', 'hetzner')
+ ->first();
+
+ if (! $token) {
+ return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
+ }
+
+ try {
+ $hetznerService = new HetznerService($token->token);
+ $locations = $hetznerService->getLocations();
+
+ return response()->json($locations);
+ } catch (\Throwable $e) {
+ return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500);
+ }
+ }
+
+ #[OA\Get(
+ summary: 'Get Hetzner Server Types',
+ description: 'Get all available Hetzner server types (instance sizes).',
+ path: '/hetzner/server-types',
+ operationId: 'get-hetzner-server-types',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Hetzner'],
+ parameters: [
+ new OA\Parameter(
+ name: 'cloud_provider_token_uuid',
+ in: 'query',
+ required: false,
+ description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
+ schema: new OA\Schema(type: 'string')
+ ),
+ new OA\Parameter(
+ name: 'cloud_provider_token_id',
+ in: 'query',
+ required: false,
+ deprecated: true,
+ description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
+ schema: new OA\Schema(type: 'string')
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'List of Hetzner server types.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'array',
+ items: new OA\Items(
+ type: 'object',
+ properties: [
+ 'id' => ['type' => 'integer'],
+ 'name' => ['type' => 'string'],
+ 'description' => ['type' => 'string'],
+ 'cores' => ['type' => 'integer'],
+ 'memory' => ['type' => 'number'],
+ 'disk' => ['type' => 'integer'],
+ 'prices' => [
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'location' => ['type' => 'string', 'description' => 'Datacenter location name'],
+ 'price_hourly' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'net' => ['type' => 'string'],
+ 'gross' => ['type' => 'string'],
+ ],
+ ],
+ 'price_monthly' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'net' => ['type' => 'string'],
+ 'gross' => ['type' => 'string'],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ]
+ )
+ )
+ ),
+ ]),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function serverTypes(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $validator = customApiValidator($request->all(), [
+ 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
+ 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $validator->errors(),
+ ], 422);
+ }
+
+ $tokenUuid = $this->getCloudProviderTokenUuid($request);
+ $token = CloudProviderToken::whereTeamId($teamId)
+ ->whereUuid($tokenUuid)
+ ->where('provider', 'hetzner')
+ ->first();
+
+ if (! $token) {
+ return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
+ }
+
+ try {
+ $hetznerService = new HetznerService($token->token);
+ $serverTypes = $hetznerService->getServerTypes();
+
+ return response()->json($serverTypes);
+ } catch (\Throwable $e) {
+ return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500);
+ }
+ }
+
+ #[OA\Get(
+ summary: 'Get Hetzner Images',
+ description: 'Get all available Hetzner system images (operating systems).',
+ path: '/hetzner/images',
+ operationId: 'get-hetzner-images',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Hetzner'],
+ parameters: [
+ new OA\Parameter(
+ name: 'cloud_provider_token_uuid',
+ in: 'query',
+ required: false,
+ description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
+ schema: new OA\Schema(type: 'string')
+ ),
+ new OA\Parameter(
+ name: 'cloud_provider_token_id',
+ in: 'query',
+ required: false,
+ deprecated: true,
+ description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
+ schema: new OA\Schema(type: 'string')
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'List of Hetzner images.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'array',
+ items: new OA\Items(
+ type: 'object',
+ properties: [
+ 'id' => ['type' => 'integer'],
+ 'name' => ['type' => 'string'],
+ 'description' => ['type' => 'string'],
+ 'type' => ['type' => 'string'],
+ 'os_flavor' => ['type' => 'string'],
+ 'os_version' => ['type' => 'string'],
+ 'architecture' => ['type' => 'string'],
+ ]
+ )
+ )
+ ),
+ ]),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function images(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $validator = customApiValidator($request->all(), [
+ 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
+ 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $validator->errors(),
+ ], 422);
+ }
+
+ $tokenUuid = $this->getCloudProviderTokenUuid($request);
+ $token = CloudProviderToken::whereTeamId($teamId)
+ ->whereUuid($tokenUuid)
+ ->where('provider', 'hetzner')
+ ->first();
+
+ if (! $token) {
+ return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
+ }
+
+ try {
+ $hetznerService = new HetznerService($token->token);
+ $images = $hetznerService->getImages();
+
+ // Filter out deprecated images (same as UI)
+ $filtered = array_filter($images, function ($image) {
+ if (isset($image['type']) && $image['type'] !== 'system') {
+ return false;
+ }
+
+ if (isset($image['deprecated']) && $image['deprecated'] === true) {
+ return false;
+ }
+
+ return true;
+ });
+
+ return response()->json(array_values($filtered));
+ } catch (\Throwable $e) {
+ return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500);
+ }
+ }
+
+ #[OA\Get(
+ summary: 'Get Hetzner SSH Keys',
+ description: 'Get all SSH keys stored in the Hetzner account.',
+ path: '/hetzner/ssh-keys',
+ operationId: 'get-hetzner-ssh-keys',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Hetzner'],
+ parameters: [
+ new OA\Parameter(
+ name: 'cloud_provider_token_uuid',
+ in: 'query',
+ required: false,
+ description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
+ schema: new OA\Schema(type: 'string')
+ ),
+ new OA\Parameter(
+ name: 'cloud_provider_token_id',
+ in: 'query',
+ required: false,
+ deprecated: true,
+ description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
+ schema: new OA\Schema(type: 'string')
+ ),
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'List of Hetzner SSH keys.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'array',
+ items: new OA\Items(
+ type: 'object',
+ properties: [
+ 'id' => ['type' => 'integer'],
+ 'name' => ['type' => 'string'],
+ 'fingerprint' => ['type' => 'string'],
+ 'public_key' => ['type' => 'string'],
+ ]
+ )
+ )
+ ),
+ ]),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ ]
+ )]
+ public function sshKeys(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $validator = customApiValidator($request->all(), [
+ 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
+ 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $validator->errors(),
+ ], 422);
+ }
+
+ $tokenUuid = $this->getCloudProviderTokenUuid($request);
+ $token = CloudProviderToken::whereTeamId($teamId)
+ ->whereUuid($tokenUuid)
+ ->where('provider', 'hetzner')
+ ->first();
+
+ if (! $token) {
+ return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
+ }
+
+ try {
+ $hetznerService = new HetznerService($token->token);
+ $sshKeys = $hetznerService->getSshKeys();
+
+ return response()->json($sshKeys);
+ } catch (\Throwable $e) {
+ return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500);
+ }
+ }
+
+ #[OA\Post(
+ summary: 'Create Hetzner Server',
+ description: 'Create a new server on Hetzner and register it in Coolify.',
+ path: '/servers/hetzner',
+ operationId: 'create-hetzner-server',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Hetzner'],
+ requestBody: new OA\RequestBody(
+ required: true,
+ description: 'Hetzner server creation parameters',
+ content: new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ required: ['location', 'server_type', 'image', 'private_key_uuid'],
+ properties: [
+ 'cloud_provider_token_uuid' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'],
+ 'cloud_provider_token_id' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', 'deprecated' => true],
+ 'location' => ['type' => 'string', 'example' => 'nbg1', 'description' => 'Hetzner location name'],
+ 'server_type' => ['type' => 'string', 'example' => 'cx11', 'description' => 'Hetzner server type name'],
+ 'image' => ['type' => 'integer', 'example' => 15512617, 'description' => 'Hetzner image ID'],
+ 'name' => ['type' => 'string', 'example' => 'my-server', 'description' => 'Server name (auto-generated if not provided)'],
+ 'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'],
+ 'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
+ 'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
+ 'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
+ 'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
+ 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
+ ],
+ ),
+ ),
+ ),
+ responses: [
+ new OA\Response(
+ response: 201,
+ description: 'Hetzner server created.',
+ content: [
+ new OA\MediaType(
+ mediaType: 'application/json',
+ schema: new OA\Schema(
+ type: 'object',
+ properties: [
+ 'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the server.'],
+ 'hetzner_server_id' => ['type' => 'integer', 'description' => 'The Hetzner server ID.'],
+ 'ip' => ['type' => 'string', 'description' => 'The server IP address.'],
+ ]
+ )
+ ),
+ ]),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ new OA\Response(
+ response: 404,
+ ref: '#/components/responses/404',
+ ),
+ new OA\Response(
+ response: 422,
+ ref: '#/components/responses/422',
+ ),
+ new OA\Response(
+ response: 429,
+ ref: '#/components/responses/429',
+ ),
+ ]
+ )]
+ public function createServer(Request $request)
+ {
+ $allowedFields = [
+ 'cloud_provider_token_uuid',
+ 'cloud_provider_token_id',
+ 'location',
+ 'server_type',
+ 'image',
+ 'name',
+ 'private_key_uuid',
+ 'enable_ipv4',
+ 'enable_ipv6',
+ 'hetzner_ssh_key_ids',
+ 'cloud_init_script',
+ 'instant_validate',
+ ];
+
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $return = validateIncomingRequest($request);
+ if ($return instanceof \Illuminate\Http\JsonResponse) {
+ return $return;
+ }
+
+ $validator = customApiValidator($request->all(), [
+ 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
+ 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
+ 'location' => 'required|string',
+ 'server_type' => 'required|string',
+ 'image' => 'required|integer',
+ 'name' => ['nullable', 'string', 'max:253', new ValidHostname],
+ 'private_key_uuid' => 'required|string',
+ 'enable_ipv4' => 'nullable|boolean',
+ 'enable_ipv6' => 'nullable|boolean',
+ 'hetzner_ssh_key_ids' => 'nullable|array',
+ 'hetzner_ssh_key_ids.*' => 'integer',
+ 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
+ 'instant_validate' => 'nullable|boolean',
+ ]);
+
+ $extraFields = array_diff(array_keys($request->all()), $allowedFields);
+ if ($validator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors();
+ if (! empty($extraFields)) {
+ foreach ($extraFields as $field) {
+ $errors->add($field, 'This field is not allowed.');
+ }
+ }
+
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $errors,
+ ], 422);
+ }
+
+ // Check server limit
+ if (Team::serverLimitReached()) {
+ return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
+ }
+
+ // Set defaults
+ if (! $request->name) {
+ $request->offsetSet('name', generate_random_name());
+ }
+ if (is_null($request->enable_ipv4)) {
+ $request->offsetSet('enable_ipv4', true);
+ }
+ if (is_null($request->enable_ipv6)) {
+ $request->offsetSet('enable_ipv6', true);
+ }
+ if (is_null($request->hetzner_ssh_key_ids)) {
+ $request->offsetSet('hetzner_ssh_key_ids', []);
+ }
+ if (is_null($request->instant_validate)) {
+ $request->offsetSet('instant_validate', false);
+ }
+
+ // Validate cloud provider token
+ $tokenUuid = $this->getCloudProviderTokenUuid($request);
+ $token = CloudProviderToken::whereTeamId($teamId)
+ ->whereUuid($tokenUuid)
+ ->where('provider', 'hetzner')
+ ->first();
+
+ if (! $token) {
+ return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
+ }
+
+ // Validate private key
+ $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
+ if (! $privateKey) {
+ return response()->json(['message' => 'Private key not found.'], 404);
+ }
+
+ try {
+ $hetznerService = new HetznerService($token->token);
+
+ // Get public key and MD5 fingerprint
+ $publicKey = $privateKey->getPublicKey();
+ $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
+
+ // Check if SSH key already exists on Hetzner
+ $existingSshKeys = $hetznerService->getSshKeys();
+ $existingKey = null;
+
+ foreach ($existingSshKeys as $key) {
+ if ($key['fingerprint'] === $md5Fingerprint) {
+ $existingKey = $key;
+ break;
+ }
+ }
+
+ // Upload SSH key if it doesn't exist
+ if ($existingKey) {
+ $sshKeyId = $existingKey['id'];
+ } else {
+ $sshKeyName = $privateKey->name;
+ $uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey);
+ $sshKeyId = $uploadedKey['id'];
+ }
+
+ // Normalize server name to lowercase for RFC 1123 compliance
+ $normalizedServerName = strtolower(trim($request->name));
+
+ // Prepare SSH keys array: Coolify key + user-selected Hetzner keys
+ $sshKeys = array_merge(
+ [$sshKeyId],
+ $request->hetzner_ssh_key_ids
+ );
+
+ // Remove duplicates
+ $sshKeys = array_unique($sshKeys);
+ $sshKeys = array_values($sshKeys);
+
+ // Prepare server creation parameters
+ $params = [
+ 'name' => $normalizedServerName,
+ 'server_type' => $request->server_type,
+ 'image' => $request->image,
+ 'location' => $request->location,
+ 'start_after_create' => true,
+ 'ssh_keys' => $sshKeys,
+ 'public_net' => [
+ 'enable_ipv4' => $request->enable_ipv4,
+ 'enable_ipv6' => $request->enable_ipv6,
+ ],
+ ];
+
+ // Add cloud-init script if provided
+ if (! empty($request->cloud_init_script)) {
+ $params['user_data'] = $request->cloud_init_script;
+ }
+
+ // Create server on Hetzner
+ $hetznerServer = $hetznerService->createServer($params);
+
+ // Determine IP address to use (prefer IPv4, fallback to IPv6)
+ $ipAddress = null;
+ if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
+ $ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
+ } elseif ($request->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
+ $ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
+ }
+
+ if (! $ipAddress) {
+ throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
+ }
+
+ // Create server in Coolify database
+ $server = Server::create([
+ 'name' => $normalizedServerName,
+ 'ip' => $ipAddress,
+ 'user' => 'root',
+ 'port' => 22,
+ 'team_id' => $teamId,
+ 'private_key_id' => $privateKey->id,
+ 'cloud_provider_token_id' => $token->id,
+ 'hetzner_server_id' => $hetznerServer['id'],
+ ]);
+
+ $server->proxy->set('status', 'exited');
+ $server->proxy->set('type', ProxyTypes::TRAEFIK->value);
+ $server->save();
+
+ // Validate server if requested
+ if ($request->instant_validate) {
+ \App\Actions\Server\ValidateServer::dispatch($server);
+ }
+
+ return response()->json([
+ 'uuid' => $server->uuid,
+ 'hetzner_server_id' => $hetznerServer['id'],
+ 'ip' => $ipAddress,
+ ])->setStatusCode(201);
+ } catch (RateLimitException $e) {
+ $response = response()->json(['message' => $e->getMessage()], 429);
+ if ($e->retryAfter !== null) {
+ $response->header('Retry-After', $e->retryAfter);
+ }
+
+ return $response;
+ } catch (\Throwable $e) {
+ return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500);
+ }
+ }
+}
diff --git a/app/Http/Controllers/Api/OpenApi.php b/app/Http/Controllers/Api/OpenApi.php
index 69f71feaf..33d21ba5d 100644
--- a/app/Http/Controllers/Api/OpenApi.php
+++ b/app/Http/Controllers/Api/OpenApi.php
@@ -61,6 +61,22 @@
),
]
)),
+ new OA\Response(
+ response: 429,
+ description: 'Rate limit exceeded.',
+ headers: [
+ new OA\Header(
+ header: 'Retry-After',
+ description: 'Number of seconds to wait before retrying.',
+ schema: new OA\Schema(type: 'integer', example: 60)
+ ),
+ ],
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'Rate limit exceeded. Please try again later.'),
+ ]
+ )),
],
)]
class OpenApi
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 2c4d0d361..587f49fa5 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -351,7 +351,7 @@ public function create_service(Request $request)
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
- if ($oneClickServiceName === 'cloudflared') {
+ if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = Service::create($servicePayload);
@@ -376,6 +376,10 @@ public function create_service(Request $request)
});
}
$service->parse(isNew: true);
+
+ // Apply service-specific application prerequisites
+ applyServiceApplicationPrerequisites($service);
+
if ($instantDeploy) {
StartService::dispatch($service);
}
diff --git a/app/Http/Controllers/MagicController.php b/app/Http/Controllers/MagicController.php
deleted file mode 100644
index 59c9b8b94..000000000
--- a/app/Http/Controllers/MagicController.php
+++ /dev/null
@@ -1,84 +0,0 @@
-json([
- 'servers' => Server::isUsable()->get(),
- ]);
- }
-
- public function destinations()
- {
- return response()->json([
- 'destinations' => Server::destinationsByServer(request()->query('server_id'))->sortBy('name'),
- ]);
- }
-
- public function projects()
- {
- return response()->json([
- 'projects' => Project::ownedByCurrentTeam()->get(),
- ]);
- }
-
- public function environments()
- {
- $project = Project::ownedByCurrentTeam()->whereUuid(request()->query('project_uuid'))->first();
- if (! $project) {
- return response()->json([
- 'environments' => [],
- ]);
- }
-
- return response()->json([
- 'environments' => $project->environments,
- ]);
- }
-
- public function newProject()
- {
- $project = Project::firstOrCreate(
- ['name' => request()->query('name') ?? generate_random_name()],
- ['team_id' => currentTeam()->id]
- );
-
- return response()->json([
- 'project_uuid' => $project->uuid,
- ]);
- }
-
- public function newEnvironment()
- {
- $environment = Environment::firstOrCreate(
- ['name' => request()->query('name') ?? generate_random_name()],
- ['project_id' => Project::ownedByCurrentTeam()->whereUuid(request()->query('project_uuid'))->firstOrFail()->id]
- );
-
- return response()->json([
- 'environment_name' => $environment->name,
- ]);
- }
-
- public function newTeam()
- {
- $team = Team::create(
- [
- 'name' => request()->query('name') ?? generate_random_name(),
- 'personal_team' => false,
- ],
- );
- auth()->user()->teams()->attach($team, ['role' => 'admin']);
- refreshSession();
-
- return redirect(request()->header('Referer'));
- }
-}
diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php
index 078494f82..183186711 100644
--- a/app/Http/Controllers/Webhook/Bitbucket.php
+++ b/app/Http/Controllers/Webhook/Bitbucket.php
@@ -2,8 +2,8 @@
namespace App\Http\Controllers\Webhook;
+use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
-use App\Livewire\Project\Service\Storage;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -15,23 +15,6 @@ class Bitbucket extends Controller
public function manual(Request $request)
{
try {
- if (app()->isDownForMaintenance()) {
- $epoch = now()->valueOf();
- $data = [
- 'attributes' => $request->attributes->all(),
- 'request' => $request->request->all(),
- 'query' => $request->query->all(),
- 'server' => $request->server->all(),
- 'files' => $request->files->all(),
- 'cookies' => $request->cookies->all(),
- 'headers' => $request->headers->all(),
- 'content' => $request->getContent(),
- ];
- $json = json_encode($data);
- Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Bitbicket::manual_bitbucket", $json);
-
- return;
- }
$return_payloads = collect([]);
$payload = $request->collect();
$headers = $request->headers->all();
@@ -107,7 +90,9 @@ public function manual(Request $request)
force_rebuild: false,
is_webhook: true
);
- if ($result['status'] === 'skipped') {
+ if ($result['status'] === 'queue_full') {
+ return response($result['message'], 429)->header('Retry-After', 60);
+ } elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@@ -161,7 +146,9 @@ public function manual(Request $request)
is_webhook: true,
git_type: 'bitbucket'
);
- if ($result['status'] === 'skipped') {
+ if ($result['status'] === 'queue_full') {
+ return response($result['message'], 429)->header('Retry-After', 60);
+ } elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@@ -185,9 +172,10 @@ public function manual(Request $request)
if ($x_bitbucket_event === 'pullrequest:rejected' || $x_bitbucket_event === 'pullrequest:fulfilled') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
- $found->delete();
- $container_name = generateApplicationContainerName($application, $pull_request_id);
- instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
+ // Use comprehensive cleanup that cancels active deployments,
+ // kills helper containers, and removes all PR containers
+ CleanupPreviewDeployment::run($application, $pull_request_id, $found);
+
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php
index 3e0c5a0b6..b46db0b59 100644
--- a/app/Http/Controllers/Webhook/Gitea.php
+++ b/app/Http/Controllers/Webhook/Gitea.php
@@ -2,12 +2,12 @@
namespace App\Http\Controllers\Webhook;
+use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
@@ -18,30 +18,6 @@ public function manual(Request $request)
try {
$return_payloads = collect([]);
$x_gitea_delivery = request()->header('X-Gitea-Delivery');
- if (app()->isDownForMaintenance()) {
- $epoch = now()->valueOf();
- $files = Storage::disk('webhooks-during-maintenance')->files();
- $gitea_delivery_found = collect($files)->filter(function ($file) use ($x_gitea_delivery) {
- return Str::contains($file, $x_gitea_delivery);
- })->first();
- if ($gitea_delivery_found) {
- return;
- }
- $data = [
- 'attributes' => $request->attributes->all(),
- 'request' => $request->request->all(),
- 'query' => $request->query->all(),
- 'server' => $request->server->all(),
- 'files' => $request->files->all(),
- 'cookies' => $request->cookies->all(),
- 'headers' => $request->headers->all(),
- 'content' => $request->getContent(),
- ];
- $json = json_encode($data);
- Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Gitea::manual_{$x_gitea_delivery}", $json);
-
- return;
- }
$x_gitea_event = Str::lower($request->header('X-Gitea-Event'));
$x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256=');
$content_type = $request->header('Content-Type');
@@ -123,7 +99,9 @@ public function manual(Request $request)
commit: data_get($payload, 'after', 'HEAD'),
is_webhook: true,
);
- if ($result['status'] === 'skipped') {
+ if ($result['status'] === 'queue_full') {
+ return response($result['message'], 429)->header('Retry-After', 60);
+ } elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@@ -193,7 +171,9 @@ public function manual(Request $request)
is_webhook: true,
git_type: 'gitea'
);
- if ($result['status'] === 'skipped') {
+ if ($result['status'] === 'queue_full') {
+ return response($result['message'], 429)->header('Retry-After', 60);
+ } elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@@ -217,9 +197,10 @@ public function manual(Request $request)
if ($action === 'closed') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
- $found->delete();
- $container_name = generateApplicationContainerName($application, $pull_request_id);
- instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
+ // Use comprehensive cleanup that cancels active deployments,
+ // kills helper containers, and removes all PR containers
+ CleanupPreviewDeployment::run($application, $pull_request_id, $found);
+
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index a1fcaa7f5..c8402cbf4 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -2,10 +2,10 @@
namespace App\Http\Controllers\Webhook;
+use App\Actions\Application\CleanupPreviewDeployment;
use App\Enums\ProcessStatus;
use App\Http\Controllers\Controller;
use App\Jobs\ApplicationPullRequestUpdateJob;
-use App\Jobs\DeleteResourceJob;
use App\Jobs\GithubAppPermissionJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
@@ -14,7 +14,6 @@
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
-use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
@@ -25,30 +24,6 @@ public function manual(Request $request)
try {
$return_payloads = collect([]);
$x_github_delivery = request()->header('X-GitHub-Delivery');
- if (app()->isDownForMaintenance()) {
- $epoch = now()->valueOf();
- $files = Storage::disk('webhooks-during-maintenance')->files();
- $github_delivery_found = collect($files)->filter(function ($file) use ($x_github_delivery) {
- return Str::contains($file, $x_github_delivery);
- })->first();
- if ($github_delivery_found) {
- return;
- }
- $data = [
- 'attributes' => $request->attributes->all(),
- 'request' => $request->request->all(),
- 'query' => $request->query->all(),
- 'server' => $request->server->all(),
- 'files' => $request->files->all(),
- 'cookies' => $request->cookies->all(),
- 'headers' => $request->headers->all(),
- 'content' => $request->getContent(),
- ];
- $json = json_encode($data);
- Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::manual_{$x_github_delivery}", $json);
-
- return;
- }
$x_github_event = Str::lower($request->header('X-GitHub-Event'));
$x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256=');
$content_type = $request->header('Content-Type');
@@ -136,7 +111,9 @@ public function manual(Request $request)
commit: data_get($payload, 'after', 'HEAD'),
is_webhook: true,
);
- if ($result['status'] === 'skipped') {
+ if ($result['status'] === 'queue_full') {
+ return response($result['message'], 429)->header('Retry-After', 60);
+ } elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@@ -222,7 +199,9 @@ public function manual(Request $request)
is_webhook: true,
git_type: 'github'
);
- if ($result['status'] === 'skipped') {
+ if ($result['status'] === 'queue_full') {
+ return response($result['message'], 429)->header('Retry-After', 60);
+ } elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@@ -246,41 +225,10 @@ public function manual(Request $request)
if ($action === 'closed') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
- // Cancel any active deployments for this PR immediately
- $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
- ->where('pull_request_id', $pull_request_id)
- ->whereIn('status', [
- \App\Enums\ApplicationDeploymentStatus::QUEUED->value,
- \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
- ])
- ->first();
+ // Use comprehensive cleanup that cancels active deployments,
+ // kills helper containers, and removes all PR containers
+ CleanupPreviewDeployment::run($application, $pull_request_id, $found);
- if ($activeDeployment) {
- try {
- // Mark deployment as cancelled
- $activeDeployment->update([
- 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
- ]);
-
- // Add cancellation log entry
- $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
-
- // Check if helper container exists and kill it
- $deployment_uuid = $activeDeployment->deployment_uuid;
- $server = $application->destination->server;
- $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
- $containerExists = instant_remote_process([$checkCommand], $server);
-
- if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
- instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
- $activeDeployment->addLogEntry('Deployment container stopped.');
- }
- } catch (\Throwable $e) {
- // Silently handle errors during deployment cancellation
- }
- }
-
- DeleteResourceJob::dispatch($found);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@@ -310,30 +258,6 @@ public function normal(Request $request)
$return_payloads = collect([]);
$id = null;
$x_github_delivery = $request->header('X-GitHub-Delivery');
- if (app()->isDownForMaintenance()) {
- $epoch = now()->valueOf();
- $files = Storage::disk('webhooks-during-maintenance')->files();
- $github_delivery_found = collect($files)->filter(function ($file) use ($x_github_delivery) {
- return Str::contains($file, $x_github_delivery);
- })->first();
- if ($github_delivery_found) {
- return;
- }
- $data = [
- 'attributes' => $request->attributes->all(),
- 'request' => $request->request->all(),
- 'query' => $request->query->all(),
- 'server' => $request->server->all(),
- 'files' => $request->files->all(),
- 'cookies' => $request->cookies->all(),
- 'headers' => $request->headers->all(),
- 'content' => $request->getContent(),
- ];
- $json = json_encode($data);
- Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::normal_{$x_github_delivery}", $json);
-
- return;
- }
$x_github_event = Str::lower($request->header('X-GitHub-Event'));
$x_github_hook_installation_target_id = $request->header('X-GitHub-Hook-Installation-Target-Id');
$x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256=');
@@ -385,7 +309,9 @@ public function normal(Request $request)
if (! $id || ! $branch) {
return response('Nothing to do. No id or branch found.');
}
- $applications = Application::where('repository_project_id', $id)->whereRelation('source', 'is_public', false);
+ $applications = Application::where('repository_project_id', $id)
+ ->where('source_id', $github_app->id)
+ ->whereRelation('source', 'is_public', false);
if ($x_github_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get();
if ($applications->isEmpty()) {
@@ -427,12 +353,15 @@ public function normal(Request $request)
force_rebuild: false,
is_webhook: true,
);
+ if ($result['status'] === 'queue_full') {
+ return response($result['message'], 429)->header('Retry-After', 60);
+ }
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
'application_uuid' => $application->uuid,
'application_name' => $application->name,
- 'deployment_uuid' => $result['deployment_uuid'],
+ 'deployment_uuid' => $result['deployment_uuid'] ?? null,
]);
} else {
$paths = str($application->watch_paths)->explode("\n");
@@ -491,7 +420,9 @@ public function normal(Request $request)
is_webhook: true,
git_type: 'github'
);
- if ($result['status'] === 'skipped') {
+ if ($result['status'] === 'queue_full') {
+ return response($result['message'], 429)->header('Retry-After', 60);
+ } elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@@ -515,53 +446,12 @@ public function normal(Request $request)
if ($action === 'closed' || $action === 'close') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
- // Cancel any active deployments for this PR immediately
- $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
- ->where('pull_request_id', $pull_request_id)
- ->whereIn('status', [
- \App\Enums\ApplicationDeploymentStatus::QUEUED->value,
- \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
- ])
- ->first();
-
- if ($activeDeployment) {
- try {
- // Mark deployment as cancelled
- $activeDeployment->update([
- 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
- ]);
-
- // Add cancellation log entry
- $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
-
- // Check if helper container exists and kill it
- $deployment_uuid = $activeDeployment->deployment_uuid;
- $server = $application->destination->server;
- $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
- $containerExists = instant_remote_process([$checkCommand], $server);
-
- if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
- instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
- $activeDeployment->addLogEntry('Deployment container stopped.');
- }
-
- } catch (\Throwable $e) {
- // Silently handle errors during deployment cancellation
- }
- }
-
- // Clean up any deployed containers
- $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
- if ($containers->isNotEmpty()) {
- $containers->each(function ($container) use ($application) {
- $container_name = data_get($container, 'Names');
- instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
- });
- }
-
+ // Delete the PR comment on GitHub (GitHub-specific feature)
ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED);
- DeleteResourceJob::dispatch($found);
+ // Use comprehensive cleanup that cancels active deployments,
+ // kills helper containers, and removes all PR containers
+ CleanupPreviewDeployment::run($application, $pull_request_id, $found);
$return_payloads->push([
'application' => $application->name,
@@ -624,23 +514,6 @@ public function install(Request $request)
{
try {
$installation_id = $request->get('installation_id');
- if (app()->isDownForMaintenance()) {
- $epoch = now()->valueOf();
- $data = [
- 'attributes' => $request->attributes->all(),
- 'request' => $request->request->all(),
- 'query' => $request->query->all(),
- 'server' => $request->server->all(),
- 'files' => $request->files->all(),
- 'cookies' => $request->cookies->all(),
- 'headers' => $request->headers->all(),
- 'content' => $request->getContent(),
- ];
- $json = json_encode($data);
- Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::install_{$installation_id}", $json);
-
- return;
- }
$source = $request->get('source');
$setup_action = $request->get('setup_action');
$github_app = GithubApp::where('uuid', $source)->firstOrFail();
diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php
index 3187663d4..5b8dd5686 100644
--- a/app/Http/Controllers/Webhook/Gitlab.php
+++ b/app/Http/Controllers/Webhook/Gitlab.php
@@ -2,12 +2,12 @@
namespace App\Http\Controllers\Webhook;
+use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
@@ -16,24 +16,6 @@ class Gitlab extends Controller
public function manual(Request $request)
{
try {
- if (app()->isDownForMaintenance()) {
- $epoch = now()->valueOf();
- $data = [
- 'attributes' => $request->attributes->all(),
- 'request' => $request->request->all(),
- 'query' => $request->query->all(),
- 'server' => $request->server->all(),
- 'files' => $request->files->all(),
- 'cookies' => $request->cookies->all(),
- 'headers' => $request->headers->all(),
- 'content' => $request->getContent(),
- ];
- $json = json_encode($data);
- Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Gitlab::manual_gitlab", $json);
-
- return;
- }
-
$return_payloads = collect([]);
$payload = $request->collect();
$headers = $request->headers->all();
@@ -149,7 +131,9 @@ public function manual(Request $request)
force_rebuild: false,
is_webhook: true,
);
- if ($result['status'] === 'skipped') {
+ if ($result['status'] === 'queue_full') {
+ return response($result['message'], 429)->header('Retry-After', 60);
+ } elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
@@ -220,7 +204,9 @@ public function manual(Request $request)
is_webhook: true,
git_type: 'gitlab'
);
- if ($result['status'] === 'skipped') {
+ if ($result['status'] === 'queue_full') {
+ return response($result['message'], 429)->header('Retry-After', 60);
+ } elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
@@ -243,22 +229,22 @@ public function manual(Request $request)
} elseif ($action === 'closed' || $action === 'close' || $action === 'merge') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
- $found->delete();
- $container_name = generateApplicationContainerName($application, $pull_request_id);
- instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
+ // Use comprehensive cleanup that cancels active deployments,
+ // kills helper containers, and removes all PR containers
+ CleanupPreviewDeployment::run($application, $pull_request_id, $found);
+
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
- 'message' => 'Preview Deployment closed',
+ 'message' => 'Preview deployment closed.',
+ ]);
+ } else {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'failed',
+ 'message' => 'No preview deployment found.',
]);
-
- return response($return_payloads);
}
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'No Preview Deployment found',
- ]);
} else {
$return_payloads->push([
'application' => $application->name,
diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php
index ae50aac42..d59adf0ca 100644
--- a/app/Http/Controllers/Webhook/Stripe.php
+++ b/app/Http/Controllers/Webhook/Stripe.php
@@ -6,7 +6,6 @@
use App\Jobs\StripeProcessJob;
use Exception;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Storage;
class Stripe extends Controller
{
@@ -20,23 +19,6 @@ public function events(Request $request)
$signature,
$webhookSecret
);
- if (app()->isDownForMaintenance()) {
- $epoch = now()->valueOf();
- $data = [
- 'attributes' => $request->attributes->all(),
- 'request' => $request->request->all(),
- 'query' => $request->query->all(),
- 'server' => $request->server->all(),
- 'files' => $request->files->all(),
- 'cookies' => $request->cookies->all(),
- 'headers' => $request->headers->all(),
- 'content' => $request->getContent(),
- ];
- $json = json_encode($data);
- Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Stripe::events_stripe", $json);
-
- return response('Webhook received. Cool cool cool cool cool.', 200);
- }
StripeProcessJob::dispatch($event);
return response('Webhook received. Cool cool cool cool cool.', 200);
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 297585562..cc1a44f9a 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -486,15 +486,38 @@ private function decide_what_to_do()
private function post_deployment()
{
- GetContainersStatus::dispatch($this->server);
+ // Mark deployment as complete FIRST, before any other operations
+ // This ensures the deployment status is FINISHED even if subsequent operations fail
$this->completeDeployment();
+
+ // Then handle side effects - these should not fail the deployment
+ try {
+ GetContainersStatus::dispatch($this->server);
+ } catch (\Exception $e) {
+ \Log::warning('Failed to dispatch GetContainersStatus for deployment '.$this->deployment_uuid.': '.$e->getMessage());
+ }
+
if ($this->pull_request_id !== 0) {
if ($this->application->is_github_based()) {
- ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
+ try {
+ ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
+ } catch (\Exception $e) {
+ \Log::warning('Failed to dispatch PR update for deployment '.$this->deployment_uuid.': '.$e->getMessage());
+ }
}
}
- $this->run_post_deployment_command();
- $this->application->isConfigurationChanged(true);
+
+ try {
+ $this->run_post_deployment_command();
+ } catch (\Exception $e) {
+ \Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
+ }
+
+ try {
+ $this->application->isConfigurationChanged(true);
+ } catch (\Exception $e) {
+ \Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
+ }
}
private function deploy_simple_dockerfile()
@@ -620,7 +643,7 @@ private function deploy_docker_compose_buildpack()
$this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.');
}
} else {
- $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'));
+ $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit);
// Always add .env file to services
$services = collect(data_get($composeFile, 'services', []));
$services = $services->map(function ($service, $name) {
@@ -670,13 +693,20 @@ private function deploy_docker_compose_buildpack()
$build_command = "DOCKER_BUILDKIT=1 {$build_command}";
}
- // Append build arguments if not using build secrets (matching default behavior)
+ // Inject build arguments after build subcommand if not using build secrets
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
$build_args_string = $this->build_args->implode(' ');
// Escape single quotes for bash -c context used by executeInDocker
$build_args_string = str_replace("'", "'\\''", $build_args_string);
- $build_command .= " {$build_args_string}";
- $this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.');
+
+ // Inject build args right after 'build' subcommand (not at the end)
+ $original_command = $build_command;
+ $build_command = injectDockerComposeBuildArgs($build_command, $build_args_string);
+
+ // Only log if build args were actually injected (command was modified)
+ if ($build_command !== $original_command) {
+ $this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.');
+ }
}
$this->execute_remote_command(
@@ -1363,7 +1393,7 @@ private function save_runtime_environment_variables()
$envs_base64 = base64_encode($environment_variables->implode("\n"));
// Write .env file to workdir (for container runtime)
- $this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for build phase.', hidden: true);
+ $this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for container.', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
@@ -1401,15 +1431,44 @@ private function generate_buildtime_environment_variables()
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
}
- $envs = collect([]);
- $coolify_envs = $this->generate_coolify_env_variables();
+ // Use associative array for automatic deduplication
+ $envs_dict = [];
- // Add COOLIFY variables
- $coolify_envs->each(function ($item, $key) use ($envs) {
- $envs->push($key.'='.escapeBashEnvValue($item));
- });
+ // 1. Add nixpacks plan variables FIRST (lowest priority - can be overridden)
+ if ($this->build_pack === 'nixpacks' &&
+ isset($this->nixpacks_plan_json) &&
+ $this->nixpacks_plan_json->isNotEmpty()) {
- // Add SERVICE_NAME variables for Docker Compose builds
+ $planVariables = data_get($this->nixpacks_plan_json, 'variables', []);
+
+ if (! empty($planVariables)) {
+ if (isDev()) {
+ $this->application_deployment_queue->addLogEntry('[DEBUG] Adding '.count($planVariables).' nixpacks plan variables to buildtime.env');
+ }
+
+ foreach ($planVariables as $key => $value) {
+ // Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority
+ if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) {
+ continue;
+ }
+
+ $escapedValue = escapeBashEnvValue($value);
+ $envs_dict[$key] = $escapedValue;
+
+ if (isDev()) {
+ $this->application_deployment_queue->addLogEntry("[DEBUG] Nixpacks var: {$key}={$escapedValue}");
+ }
+ }
+ }
+ }
+
+ // 2. Add COOLIFY variables (can override nixpacks, but shouldn't happen in practice)
+ $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
+ foreach ($coolify_envs as $key => $item) {
+ $envs_dict[$key] = escapeBashEnvValue($item);
+ }
+
+ // 3. Add SERVICE_NAME, SERVICE_FQDN, SERVICE_URL variables for Docker Compose builds
if ($this->build_pack === 'dockercompose') {
if ($this->pull_request_id === 0) {
// Generate SERVICE_NAME for dockercompose services from processed compose
@@ -1420,7 +1479,7 @@ private function generate_buildtime_environment_variables()
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
- $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.escapeBashEnvValue($serviceName));
+ $envs_dict['SERVICE_NAME_'.str($serviceName)->upper()] = escapeBashEnvValue($serviceName);
}
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
@@ -1433,8 +1492,8 @@ private function generate_buildtime_environment_variables()
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
- $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
- $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
+ $envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
+ $envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn);
}
}
} else {
@@ -1442,7 +1501,7 @@ private function generate_buildtime_environment_variables()
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
- $envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)));
+ $envs_dict['SERVICE_NAME_'.str($rawServiceName)->upper()] = escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
}
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
@@ -1455,17 +1514,16 @@ private function generate_buildtime_environment_variables()
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
- $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
- $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
+ $envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
+ $envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn);
}
}
}
}
- // Add build-time user variables only
+ // 4. Add user-defined build-time variables LAST (highest priority - can override everything)
if ($this->pull_request_id === 0) {
$sorted_environment_variables = $this->application->environment_variables()
- ->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
@@ -1483,7 +1541,12 @@ private function generate_buildtime_environment_variables()
// Strip outer quotes from real_value and apply proper bash escaping
$value = trim($env->real_value, "'");
$escapedValue = escapeBashEnvValue($value);
- $envs->push($env->key.'='.$escapedValue);
+
+ if (isDev() && isset($envs_dict[$env->key])) {
+ $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
+ }
+
+ $envs_dict[$env->key] = $escapedValue;
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
@@ -1495,7 +1558,12 @@ private function generate_buildtime_environment_variables()
} else {
// For normal vars, use double quotes to allow $VAR expansion
$escapedValue = escapeBashDoubleQuoted($env->real_value);
- $envs->push($env->key.'='.$escapedValue);
+
+ if (isDev() && isset($envs_dict[$env->key])) {
+ $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
+ }
+
+ $envs_dict[$env->key] = $escapedValue;
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
@@ -1507,7 +1575,6 @@ private function generate_buildtime_environment_variables()
}
} else {
$sorted_environment_variables = $this->application->environment_variables_preview()
- ->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
@@ -1525,7 +1592,12 @@ private function generate_buildtime_environment_variables()
// Strip outer quotes from real_value and apply proper bash escaping
$value = trim($env->real_value, "'");
$escapedValue = escapeBashEnvValue($value);
- $envs->push($env->key.'='.$escapedValue);
+
+ if (isDev() && isset($envs_dict[$env->key])) {
+ $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
+ }
+
+ $envs_dict[$env->key] = $escapedValue;
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
@@ -1537,7 +1609,12 @@ private function generate_buildtime_environment_variables()
} else {
// For normal vars, use double quotes to allow $VAR expansion
$escapedValue = escapeBashDoubleQuoted($env->real_value);
- $envs->push($env->key.'='.$escapedValue);
+
+ if (isDev() && isset($envs_dict[$env->key])) {
+ $this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
+ }
+
+ $envs_dict[$env->key] = $escapedValue;
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
@@ -1549,6 +1626,12 @@ private function generate_buildtime_environment_variables()
}
}
+ // Convert dictionary back to collection in KEY=VALUE format
+ $envs = collect([]);
+ foreach ($envs_dict as $key => $value) {
+ $envs->push($key.'='.$value);
+ }
+
// Return the generated environment variables
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
@@ -1753,9 +1836,9 @@ private function health_check()
$this->application->update(['status' => 'running']);
$this->application_deployment_queue->addLogEntry('New container is healthy.');
break;
- }
- if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
+ } elseif (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
$this->newVersionIsHealthy = false;
+ $this->application_deployment_queue->addLogEntry('New container is unhealthy.', type: 'error');
$this->query_logs();
break;
}
@@ -1957,7 +2040,12 @@ private function deploy_to_additional_destinations()
private function set_coolify_variables()
{
- $this->coolify_variables = "SOURCE_COMMIT={$this->commit} ";
+ $this->coolify_variables = '';
+
+ // Only include SOURCE_COMMIT in build context if enabled in settings
+ if ($this->application->settings->include_source_commit_in_build) {
+ $this->coolify_variables .= "SOURCE_COMMIT={$this->commit} ";
+ }
if ($this->pull_request_id === 0) {
$fqdn = $this->application->fqdn;
} else {
@@ -1979,7 +2067,6 @@ private function set_coolify_variables()
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
}
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
- $this->coolify_variables .= "COOLIFY_CONTAINER_NAME={$this->container_name} ";
}
private function check_git_if_build_needed()
@@ -2217,38 +2304,44 @@ private function generate_nixpacks_env_variables()
$this->env_nixpacks_args = collect([]);
if ($this->pull_request_id === 0) {
foreach ($this->application->nixpacks_environment_variables as $env) {
- if (! is_null($env->real_value)) {
+ if (! is_null($env->real_value) && $env->real_value !== '') {
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
}
}
} else {
foreach ($this->application->nixpacks_environment_variables_preview as $env) {
- if (! is_null($env->real_value)) {
+ if (! is_null($env->real_value) && $env->real_value !== '') {
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
}
}
}
// Add COOLIFY_* environment variables to Nixpacks build context
- $coolify_envs = $this->generate_coolify_env_variables();
+ $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
$coolify_envs->each(function ($value, $key) {
- $this->env_nixpacks_args->push("--env {$key}={$value}");
+ // Only add environment variables with non-null and non-empty values
+ if (! is_null($value) && $value !== '') {
+ $this->env_nixpacks_args->push("--env {$key}={$value}");
+ }
});
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
}
- private function generate_coolify_env_variables(): Collection
+ private function generate_coolify_env_variables(bool $forBuildTime = false): Collection
{
$coolify_envs = collect([]);
$local_branch = $this->branch;
if ($this->pull_request_id !== 0) {
- // Add SOURCE_COMMIT if not exists
- if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) {
- if (! is_null($this->commit)) {
- $coolify_envs->put('SOURCE_COMMIT', $this->commit);
- } else {
- $coolify_envs->put('SOURCE_COMMIT', 'unknown');
+ // Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time
+ // SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build
+ if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) {
+ if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) {
+ if (! is_null($this->commit)) {
+ $coolify_envs->put('SOURCE_COMMIT', $this->commit);
+ } else {
+ $coolify_envs->put('SOURCE_COMMIT', 'unknown');
+ }
}
}
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_FQDN')->isEmpty()) {
@@ -2273,20 +2366,26 @@ private function generate_coolify_env_variables(): Collection
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
}
- if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
- $coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
+ // Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache
+ if (! $forBuildTime) {
+ if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
+ $coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
+ }
}
}
add_coolify_default_environment_variables($this->application, $coolify_envs, $this->application->environment_variables_preview);
} else {
- // Add SOURCE_COMMIT if not exists
- if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
- if (! is_null($this->commit)) {
- $coolify_envs->put('SOURCE_COMMIT', $this->commit);
- } else {
- $coolify_envs->put('SOURCE_COMMIT', 'unknown');
+ // Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time
+ // SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build
+ if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) {
+ if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
+ if (! is_null($this->commit)) {
+ $coolify_envs->put('SOURCE_COMMIT', $this->commit);
+ } else {
+ $coolify_envs->put('SOURCE_COMMIT', 'unknown');
+ }
}
}
if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) {
@@ -2311,8 +2410,11 @@ private function generate_coolify_env_variables(): Collection
if ($this->application->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
}
- if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
- $coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
+ // Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache
+ if (! $forBuildTime) {
+ if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
+ $coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
+ }
}
}
@@ -2326,9 +2428,13 @@ private function generate_coolify_env_variables(): Collection
private function generate_env_variables()
{
$this->env_args = collect([]);
- $this->env_args->put('SOURCE_COMMIT', $this->commit);
- $coolify_envs = $this->generate_coolify_env_variables();
+ // Only include SOURCE_COMMIT in build args if enabled in settings
+ if ($this->application->settings->include_source_commit_in_build) {
+ $this->env_args->put('SOURCE_COMMIT', $this->commit);
+ }
+
+ $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
$coolify_envs->each(function ($value, $key) {
$this->env_args->put($key, $value);
});
@@ -2748,7 +2854,7 @@ private function build_image()
} else {
// Traditional build args approach - generate COOLIFY_ variables locally
// Generate COOLIFY_ variables locally for build args
- $coolify_envs = $this->generate_coolify_env_variables();
+ $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
$coolify_envs->each(function ($value, $key) {
$this->build_args->push("--build-arg '{$key}'");
});
@@ -3070,7 +3176,7 @@ private function graceful_shutdown_container(string $containerName)
try {
$timeout = isDev() ? 1 : 30;
$this->execute_remote_command(
- ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
+ ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
);
} catch (Exception $error) {
@@ -3107,6 +3213,18 @@ private function stop_running_container(bool $force = false)
$this->graceful_shutdown_container($this->container_name);
}
} catch (Exception $e) {
+ // If new version is healthy, this is just cleanup - don't fail the deployment
+ if ($this->newVersionIsHealthy || $force) {
+ $this->application_deployment_queue->addLogEntry(
+ "Warning: Could not remove old container: {$e->getMessage()}",
+ 'stderr',
+ hidden: true
+ );
+
+ return; // Don't re-throw - cleanup failures shouldn't fail successful deployments
+ }
+
+ // Only re-throw if deployment hasn't succeeded yet
throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e);
}
}
@@ -3294,7 +3412,9 @@ private function generate_build_secrets(Collection $variables)
private function generate_secrets_hash($variables)
{
if (! $this->secrets_hash_key) {
- $this->secrets_hash_key = bin2hex(random_bytes(32));
+ // Use APP_KEY as deterministic hash key to preserve Docker build cache
+ // Random keys would change every deployment, breaking cache even when secrets haven't changed
+ $this->secrets_hash_key = config('app.key');
}
if ($variables instanceof Collection) {
@@ -3337,100 +3457,121 @@ private function add_build_env_variables_to_dockerfile()
{
if ($this->dockerBuildkitSupported) {
// We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets
+ return;
+ }
+
+ // Skip ARG injection if disabled by user - preserves Docker build cache
+ if ($this->application->settings->inject_build_args_to_dockerfile === false) {
+ $this->application_deployment_queue->addLogEntry('Skipping Dockerfile ARG injection (disabled in settings).', hidden: true);
+
+ return;
+ }
+
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
+ 'hidden' => true,
+ 'save' => 'dockerfile',
+ 'ignore_errors' => true,
+ ]);
+ $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
+
+ // Find all FROM instruction positions
+ $fromLines = $this->findFromInstructionLines($dockerfile);
+
+ // If no FROM instructions found, skip ARG insertion
+ if (empty($fromLines)) {
+ return;
+ }
+
+ // Collect all ARG statements to insert
+ $argsToInsert = collect();
+
+ if ($this->pull_request_id === 0) {
+ // Only add environment variables that are available during build
+ $envs = $this->application->environment_variables()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true)
+ ->get();
+ foreach ($envs as $env) {
+ if (data_get($env, 'is_multiline') === true) {
+ $argsToInsert->push("ARG {$env->key}");
+ } else {
+ $argsToInsert->push("ARG {$env->key}={$env->real_value}");
+ }
+ }
+ // Add Coolify variables as ARGs
+ if ($this->coolify_variables) {
+ $coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
+ ->filter()
+ ->map(function ($var) {
+ return "ARG {$var}";
+ });
+ $argsToInsert = $argsToInsert->merge($coolify_vars);
+ }
} else {
- $this->execute_remote_command([
+ // Only add preview environment variables that are available during build
+ $envs = $this->application->environment_variables_preview()
+ ->where('key', 'not like', 'NIXPACKS_%')
+ ->where('is_buildtime', true)
+ ->get();
+ foreach ($envs as $env) {
+ if (data_get($env, 'is_multiline') === true) {
+ $argsToInsert->push("ARG {$env->key}");
+ } else {
+ $argsToInsert->push("ARG {$env->key}={$env->real_value}");
+ }
+ }
+ // Add Coolify variables as ARGs
+ if ($this->coolify_variables) {
+ $coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
+ ->filter()
+ ->map(function ($var) {
+ return "ARG {$var}";
+ });
+ $argsToInsert = $argsToInsert->merge($coolify_vars);
+ }
+ }
+
+ // Development logging to show what ARGs are being injected
+ if (isDev()) {
+ $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
+ $this->application_deployment_queue->addLogEntry('[DEBUG] Dockerfile ARG Injection');
+ $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
+ $this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToInsert->count());
+ foreach ($argsToInsert as $arg) {
+ // Only show ARG key, not the value (for security)
+ $argKey = str($arg)->after('ARG ')->before('=')->toString();
+ $this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}");
+ }
+ }
+
+ // Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers)
+ if ($argsToInsert->isNotEmpty()) {
+ foreach (array_reverse($fromLines) as $fromLineIndex) {
+ // Insert all ARGs after this FROM instruction
+ foreach ($argsToInsert->reverse() as $arg) {
+ $dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
+ }
+ }
+ $envs_mapped = $envs->mapWithKeys(function ($env) {
+ return [$env->key => $env->real_value];
+ });
+ $secrets_hash = $this->generate_secrets_hash($envs_mapped);
+ $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
+ }
+
+ $dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
+ $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
+ 'hidden' => true,
+ ],
+ [
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true,
- 'save' => 'dockerfile',
'ignore_errors' => true,
]);
- $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
-
- // Find all FROM instruction positions
- $fromLines = $this->findFromInstructionLines($dockerfile);
-
- // If no FROM instructions found, skip ARG insertion
- if (empty($fromLines)) {
- return;
- }
-
- // Collect all ARG statements to insert
- $argsToInsert = collect();
-
- if ($this->pull_request_id === 0) {
- // Only add environment variables that are available during build
- $envs = $this->application->environment_variables()
- ->where('key', 'not like', 'NIXPACKS_%')
- ->where('is_buildtime', true)
- ->get();
- foreach ($envs as $env) {
- if (data_get($env, 'is_multiline') === true) {
- $argsToInsert->push("ARG {$env->key}");
- } else {
- $argsToInsert->push("ARG {$env->key}={$env->real_value}");
- }
- }
- // Add Coolify variables as ARGs
- if ($this->coolify_variables) {
- $coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
- ->filter()
- ->map(function ($var) {
- return "ARG {$var}";
- });
- $argsToInsert = $argsToInsert->merge($coolify_vars);
- }
- } else {
- // Only add preview environment variables that are available during build
- $envs = $this->application->environment_variables_preview()
- ->where('key', 'not like', 'NIXPACKS_%')
- ->where('is_buildtime', true)
- ->get();
- foreach ($envs as $env) {
- if (data_get($env, 'is_multiline') === true) {
- $argsToInsert->push("ARG {$env->key}");
- } else {
- $argsToInsert->push("ARG {$env->key}={$env->real_value}");
- }
- }
- // Add Coolify variables as ARGs
- if ($this->coolify_variables) {
- $coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
- ->filter()
- ->map(function ($var) {
- return "ARG {$var}";
- });
- $argsToInsert = $argsToInsert->merge($coolify_vars);
- }
- }
-
- // Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers)
- if ($argsToInsert->isNotEmpty()) {
- foreach (array_reverse($fromLines) as $fromLineIndex) {
- // Insert all ARGs after this FROM instruction
- foreach ($argsToInsert->reverse() as $arg) {
- $dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
- }
- }
- $envs_mapped = $envs->mapWithKeys(function ($env) {
- return [$env->key => $env->real_value];
- });
- $secrets_hash = $this->generate_secrets_hash($envs_mapped);
- $argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
- }
-
- $dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
- $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true);
- $this->execute_remote_command(
- [
- executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
- 'hidden' => true,
- ],
- [
- executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
- 'hidden' => true,
- 'ignore_errors' => true,
- ]);
- }
}
private function modify_dockerfile_for_secrets($dockerfile_path)
@@ -3503,6 +3644,13 @@ private function modify_dockerfiles_for_compose($composeFile)
return;
}
+ // Skip ARG injection if disabled by user - preserves Docker build cache
+ if ($this->application->settings->inject_build_args_to_dockerfile === false) {
+ $this->application_deployment_queue->addLogEntry('Skipping Docker Compose Dockerfile ARG injection (disabled in settings).', hidden: true);
+
+ return;
+ }
+
// Generate env variables if not already done
// This populates $this->env_args with both user-defined and COOLIFY_* variables
if (! $this->env_args || $this->env_args->isEmpty()) {
@@ -3593,6 +3741,18 @@ private function modify_dockerfiles_for_compose($composeFile)
continue;
}
+ // Development logging to show what ARGs are being injected for Docker Compose
+ if (isDev()) {
+ $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
+ $this->application_deployment_queue->addLogEntry("[DEBUG] Docker Compose ARG Injection - Service: {$serviceName}");
+ $this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
+ $this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToAdd->count());
+ foreach ($argsToAdd as $arg) {
+ $argKey = str($arg)->after('ARG ')->toString();
+ $this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}");
+ }
+ }
+
$totalAdded = 0;
$offset = 0;
@@ -3797,13 +3957,17 @@ private function transitionToStatus(ApplicationDeploymentStatus $status): void
}
/**
- * Check if deployment is in a terminal state (FAILED or CANCELLED).
+ * Check if deployment is in a terminal state (FINISHED, FAILED or CANCELLED).
* Terminal states cannot be changed.
*/
private function isInTerminalState(): bool
{
$this->application_deployment_queue->refresh();
+ if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FINISHED->value) {
+ return true;
+ }
+
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
return true;
}
@@ -3843,6 +4007,15 @@ private function handleStatusTransition(ApplicationDeploymentStatus $status): vo
*/
private function handleSuccessfulDeployment(): void
{
+ // Reset restart count after successful deployment
+ // This is done here (not in Livewire) to avoid race conditions
+ // with GetContainersStatus reading old container restart counts
+ $this->application->update([
+ 'restart_count' => 0,
+ 'last_restart_at' => null,
+ 'last_restart_type' => null,
+ ]);
+
event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) {
diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php
index 4f2bfa68c..8da2426da 100644
--- a/app/Jobs/CheckForUpdatesJob.php
+++ b/app/Jobs/CheckForUpdatesJob.php
@@ -10,6 +10,7 @@
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -22,20 +23,60 @@ public function handle(): void
return;
}
$settings = instanceSettings();
- $response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json');
+ $response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url'));
if ($response->successful()) {
$versions = $response->json();
$latest_version = data_get($versions, 'coolify.v4.version');
$current_version = config('constants.coolify.version');
+ // Read existing cached version
+ $existingVersions = null;
+ $existingCoolifyVersion = null;
+ if (File::exists(base_path('versions.json'))) {
+ $existingVersions = json_decode(File::get(base_path('versions.json')), true);
+ $existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version');
+ }
+
+ // Determine the BEST version to use (CDN, cache, or current)
+ $bestVersion = $latest_version;
+
+ // Check if cache has newer version than CDN
+ if ($existingCoolifyVersion && version_compare($existingCoolifyVersion, $bestVersion, '>')) {
+ Log::warning('CDN served older Coolify version than cache', [
+ 'cdn_version' => $latest_version,
+ 'cached_version' => $existingCoolifyVersion,
+ 'current_version' => $current_version,
+ ]);
+ $bestVersion = $existingCoolifyVersion;
+ }
+
+ // CRITICAL: Never allow bestVersion to be older than currently running version
+ if (version_compare($bestVersion, $current_version, '<')) {
+ Log::warning('Version downgrade prevented in CheckForUpdatesJob', [
+ 'cdn_version' => $latest_version,
+ 'cached_version' => $existingCoolifyVersion,
+ 'current_version' => $current_version,
+ 'attempted_best' => $bestVersion,
+ 'using' => $current_version,
+ ]);
+ $bestVersion = $current_version;
+ }
+
+ // Use data_set() for safe mutation (fixes #3)
+ data_set($versions, 'coolify.v4.version', $bestVersion);
+ $latest_version = $bestVersion;
+
+ // ALWAYS write versions.json (for Sentinel, Helper, Traefik updates)
+ File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
+
+ // Invalidate cache to ensure fresh data is loaded
+ invalidate_versions_cache();
+
+ // Only mark new version available if Coolify version actually increased
if (version_compare($latest_version, $current_version, '>')) {
// New version available
$settings->update(['new_version_available' => true]);
- File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
-
- // Invalidate cache to ensure fresh data is loaded
- invalidate_versions_cache();
} else {
$settings->update(['new_version_available' => false]);
}
diff --git a/app/Jobs/CheckHelperImageJob.php b/app/Jobs/CheckHelperImageJob.php
index 6abb8a150..6d76da8eb 100644
--- a/app/Jobs/CheckHelperImageJob.php
+++ b/app/Jobs/CheckHelperImageJob.php
@@ -21,7 +21,7 @@ public function __construct() {}
public function handle(): void
{
try {
- $response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json');
+ $response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url'));
if ($response->successful()) {
$versions = $response->json();
$settings = instanceSettings();
diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php
index 88484bcce..92ec4cbd4 100644
--- a/app/Jobs/CheckTraefikVersionForServerJob.php
+++ b/app/Jobs/CheckTraefikVersionForServerJob.php
@@ -2,6 +2,7 @@
namespace App\Jobs;
+use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use App\Notifications\Server\TraefikVersionOutdated;
use Illuminate\Bus\Queueable;
@@ -38,6 +39,8 @@ public function handle(): void
$this->server->update(['detected_traefik_version' => $currentVersion]);
if (! $currentVersion) {
+ ProxyStatusChangedUI::dispatch($this->server->team_id);
+
return;
}
@@ -48,16 +51,22 @@ public function handle(): void
// Handle empty/null response from SSH command
if (empty(trim($imageTag))) {
+ ProxyStatusChangedUI::dispatch($this->server->team_id);
+
return;
}
if (str_contains(strtolower(trim($imageTag)), ':latest')) {
+ ProxyStatusChangedUI::dispatch($this->server->team_id);
+
return;
}
// Parse current version to extract major.minor.patch
$current = ltrim($currentVersion, 'v');
if (! preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches)) {
+ ProxyStatusChangedUI::dispatch($this->server->team_id);
+
return;
}
@@ -77,6 +86,8 @@ public function handle(): void
$this->server->update(['traefik_outdated_info' => null]);
}
+ ProxyStatusChangedUI::dispatch($this->server->team_id);
+
return;
}
@@ -96,6 +107,9 @@ public function handle(): void
// Fully up to date
$this->server->update(['traefik_outdated_info' => null]);
}
+
+ // Dispatch UI update event so warning state refreshes in real-time
+ ProxyStatusChangedUI::dispatch($this->server->team_id);
}
/**
diff --git a/app/Jobs/CleanupOrphanedPreviewContainersJob.php b/app/Jobs/CleanupOrphanedPreviewContainersJob.php
new file mode 100644
index 000000000..5d3bed457
--- /dev/null
+++ b/app/Jobs/CleanupOrphanedPreviewContainersJob.php
@@ -0,0 +1,214 @@
+expireAfter(600)->dontRelease()];
+ }
+
+ public function handle(): void
+ {
+ try {
+ $servers = $this->getServersToCheck();
+
+ foreach ($servers as $server) {
+ $this->cleanupOrphanedContainersOnServer($server);
+ }
+ } catch (\Throwable $e) {
+ Log::error('CleanupOrphanedPreviewContainersJob failed: '.$e->getMessage());
+ send_internal_notification('CleanupOrphanedPreviewContainersJob failed with error: '.$e->getMessage());
+ }
+ }
+
+ /**
+ * Get all functional servers to check for orphaned containers.
+ */
+ private function getServersToCheck(): \Illuminate\Support\Collection
+ {
+ $query = Server::whereRelation('settings', 'is_usable', true)
+ ->whereRelation('settings', 'is_reachable', true)
+ ->where('ip', '!=', '1.2.3.4');
+
+ if (isCloud()) {
+ $query = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true);
+ }
+
+ return $query->get()->filter(fn ($server) => $server->isFunctional());
+ }
+
+ /**
+ * Find and clean up orphaned PR containers on a specific server.
+ */
+ private function cleanupOrphanedContainersOnServer(Server $server): void
+ {
+ try {
+ $prContainers = $this->getPRContainersOnServer($server);
+
+ if ($prContainers->isEmpty()) {
+ return;
+ }
+
+ $orphanedCount = 0;
+ foreach ($prContainers as $container) {
+ if ($this->isOrphanedContainer($container)) {
+ $this->removeContainer($container, $server);
+ $orphanedCount++;
+ }
+ }
+
+ if ($orphanedCount > 0) {
+ Log::info("CleanupOrphanedPreviewContainersJob - Removed {$orphanedCount} orphaned PR containers", [
+ 'server' => $server->name,
+ ]);
+ }
+ } catch (\Throwable $e) {
+ Log::warning("CleanupOrphanedPreviewContainersJob - Error on server {$server->name}: {$e->getMessage()}");
+ }
+ }
+
+ /**
+ * Get all PR containers on a server (containers with pullRequestId > 0).
+ */
+ private function getPRContainersOnServer(Server $server): \Illuminate\Support\Collection
+ {
+ try {
+ $output = instant_remote_process([
+ "docker ps -a --filter 'label=coolify.pullRequestId' --format '{{json .}}'",
+ ], $server, false);
+
+ if (empty($output)) {
+ return collect();
+ }
+
+ return format_docker_command_output_to_json($output)
+ ->filter(function ($container) {
+ // Only include PR containers (pullRequestId > 0)
+ $prId = $this->extractPullRequestId($container);
+
+ return $prId !== null && $prId > 0;
+ });
+ } catch (\Throwable $e) {
+ Log::debug("Failed to get PR containers on server {$server->name}: {$e->getMessage()}");
+
+ return collect();
+ }
+ }
+
+ /**
+ * Extract pull request ID from container labels.
+ */
+ private function extractPullRequestId($container): ?int
+ {
+ $labels = data_get($container, 'Labels', '');
+ if (preg_match('/coolify\.pullRequestId=(\d+)/', $labels, $matches)) {
+ return (int) $matches[1];
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract application ID from container labels.
+ */
+ private function extractApplicationId($container): ?int
+ {
+ $labels = data_get($container, 'Labels', '');
+ if (preg_match('/coolify\.applicationId=(\d+)/', $labels, $matches)) {
+ return (int) $matches[1];
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if a container is orphaned (no corresponding ApplicationPreview record).
+ */
+ private function isOrphanedContainer($container): bool
+ {
+ $applicationId = $this->extractApplicationId($container);
+ $pullRequestId = $this->extractPullRequestId($container);
+
+ if ($applicationId === null || $pullRequestId === null) {
+ return false;
+ }
+
+ // Check if ApplicationPreview record exists (including soft-deleted)
+ $previewExists = ApplicationPreview::withTrashed()
+ ->where('application_id', $applicationId)
+ ->where('pull_request_id', $pullRequestId)
+ ->exists();
+
+ // If preview exists (even soft-deleted), container should be handled by DeleteResourceJob
+ // If preview doesn't exist at all, it's truly orphaned
+ return ! $previewExists;
+ }
+
+ /**
+ * Remove an orphaned container from the server.
+ */
+ private function removeContainer($container, Server $server): void
+ {
+ $containerName = data_get($container, 'Names');
+
+ if (empty($containerName)) {
+ Log::warning('CleanupOrphanedPreviewContainersJob - Cannot remove container: missing container name', [
+ 'container_data' => $container,
+ 'server' => $server->name,
+ ]);
+
+ return;
+ }
+
+ $applicationId = $this->extractApplicationId($container);
+ $pullRequestId = $this->extractPullRequestId($container);
+
+ Log::info('CleanupOrphanedPreviewContainersJob - Removing orphaned container', [
+ 'container' => $containerName,
+ 'application_id' => $applicationId,
+ 'pull_request_id' => $pullRequestId,
+ 'server' => $server->name,
+ ]);
+
+ $escapedContainerName = escapeshellarg($containerName);
+
+ try {
+ instant_remote_process(
+ ["docker rm -f {$escapedContainerName}"],
+ $server,
+ false
+ );
+ } catch (\Throwable $e) {
+ Log::warning("Failed to remove orphaned container {$containerName}: {$e->getMessage()}");
+ }
+ }
+}
diff --git a/app/Jobs/CoolifyTask.php b/app/Jobs/CoolifyTask.php
index d6dc6fa05..ce535e036 100755
--- a/app/Jobs/CoolifyTask.php
+++ b/app/Jobs/CoolifyTask.php
@@ -90,5 +90,22 @@ public function failed(?\Throwable $exception): void
'failed_at' => now()->toIso8601String(),
]);
$this->activity->save();
+
+ // Dispatch cleanup event on failure (same as on success)
+ 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));
+ }
+ Log::info('Cleanup event dispatched after job failure', [
+ 'event' => $this->call_event_on_finish,
+ ]);
+ } catch (\Throwable $e) {
+ Log::error('Error dispatching cleanup event on failure: '.$e->getMessage());
+ }
+ }
}
}
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 8766a1afc..a585baa69 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -121,7 +121,7 @@ public function handle(): void
$this->container_name = "{$this->database->name}-$serviceUuid";
$this->directory_name = $serviceName.'-'.$this->container_name;
$commands[] = "docker exec $this->container_name env | grep POSTGRES_";
- $envs = instant_remote_process($commands, $this->server);
+ $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
$envs = str($envs)->explode("\n");
$user = $envs->filter(function ($env) {
@@ -152,7 +152,7 @@ public function handle(): void
$this->container_name = "{$this->database->name}-$serviceUuid";
$this->directory_name = $serviceName.'-'.$this->container_name;
$commands[] = "docker exec $this->container_name env | grep MYSQL_";
- $envs = instant_remote_process($commands, $this->server);
+ $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
$envs = str($envs)->explode("\n");
$rootPassword = $envs->filter(function ($env) {
@@ -175,7 +175,7 @@ public function handle(): void
$this->container_name = "{$this->database->name}-$serviceUuid";
$this->directory_name = $serviceName.'-'.$this->container_name;
$commands[] = "docker exec $this->container_name env";
- $envs = instant_remote_process($commands, $this->server);
+ $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
$envs = str($envs)->explode("\n");
$rootPassword = $envs->filter(function ($env) {
return str($env)->startsWith('MARIADB_ROOT_PASSWORD=');
@@ -217,7 +217,7 @@ public function handle(): void
try {
$commands = [];
$commands[] = "docker exec $this->container_name env | grep MONGO_INITDB_";
- $envs = instant_remote_process($commands, $this->server);
+ $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
if (filled($envs)) {
$envs = str($envs)->explode("\n");
@@ -489,21 +489,26 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
$collectionsToExclude = collect();
}
$commands[] = 'mkdir -p '.$this->backup_dir;
+
+ // Validate and escape database name to prevent command injection
+ validateShellSafePath($databaseName, 'database name');
+ $escapedDatabaseName = escapeshellarg($databaseName);
+
if ($collectionsToExclude->count() === 0) {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
}
} else {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
}
}
}
- $this->backup_output = instant_remote_process($commands, $this->server);
+ $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
@@ -525,11 +530,14 @@ private function backup_standalone_postgresql(string $database): void
if ($this->backup->dump_all) {
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
} else {
- $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $database > $this->backup_location";
+ // Validate and escape database name to prevent command injection
+ validateShellSafePath($database, 'database name');
+ $escapedDatabase = escapeshellarg($database);
+ $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location";
}
$commands[] = $backupCommand;
- $this->backup_output = instant_remote_process($commands, $this->server);
+ $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
@@ -547,9 +555,12 @@ private function backup_standalone_mysql(string $database): void
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $database > $this->backup_location";
+ // Validate and escape database name to prevent command injection
+ validateShellSafePath($database, 'database name');
+ $escapedDatabase = escapeshellarg($database);
+ $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location";
}
- $this->backup_output = instant_remote_process($commands, $this->server);
+ $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
@@ -567,9 +578,12 @@ private function backup_standalone_mariadb(string $database): void
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $database > $this->backup_location";
+ // Validate and escape database name to prevent command injection
+ validateShellSafePath($database, 'database name');
+ $escapedDatabase = escapeshellarg($database);
+ $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location";
}
- $this->backup_output = instant_remote_process($commands, $this->server);
+ $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
@@ -600,7 +614,7 @@ private function add_to_error_output($output): void
private function calculate_size()
{
- return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false);
+ return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false, false, null, disableMultiplexing: true);
}
private function upload_to_s3(): void
@@ -623,9 +637,9 @@ private function upload_to_s3(): void
$fullImageName = $this->getFullImageName();
- $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false);
+ $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true);
if (filled($containerExists)) {
- instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false);
+ instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true);
}
if (isDev()) {
@@ -639,9 +653,15 @@ private function upload_to_s3(): void
} else {
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
}
- $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
+
+ // Escape S3 credentials to prevent command injection
+ $escapedEndpoint = escapeshellarg($endpoint);
+ $escapedKey = escapeshellarg($key);
+ $escapedSecret = escapeshellarg($secret);
+
+ $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
- instant_remote_process($commands, $this->server);
+ instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
$this->s3_uploaded = true;
} catch (\Throwable $e) {
@@ -650,7 +670,7 @@ private function upload_to_s3(): void
throw $e;
} finally {
$command = "docker rm -f backup-of-{$this->backup_log_uuid}";
- instant_remote_process([$command], $this->server);
+ instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true);
}
}
diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php
index c4358570e..825604910 100644
--- a/app/Jobs/DeleteResourceJob.php
+++ b/app/Jobs/DeleteResourceJob.php
@@ -191,7 +191,7 @@ private function stopPreviewContainers(array $containers, $server, int $timeout
$containerList = implode(' ', array_map('escapeshellarg', $containerNames));
$commands = [
- "docker stop --time=$timeout $containerList",
+ "docker stop -t $timeout $containerList",
"docker rm -f $containerList",
];
instant_remote_process(
diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php
index 9d44e08f9..e6c64ada7 100644
--- a/app/Jobs/PushServerUpdateJob.php
+++ b/app/Jobs/PushServerUpdateJob.php
@@ -300,8 +300,9 @@ private function aggregateMultiContainerStatuses()
}
// Use ContainerStatusAggregator service for state machine logic
+ // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded"
$aggregator = new ContainerStatusAggregator;
- $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
+ $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true);
// Update application status with aggregated result
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
@@ -360,8 +361,9 @@ private function aggregateServiceContainerStatuses()
// Use ContainerStatusAggregator service for state machine logic
// NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0
+ // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded"
$aggregator = new ContainerStatusAggregator;
- $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
+ $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true);
// Update service sub-resource status with aggregated result
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php
index e3e809c8d..2815c73bc 100644
--- a/app/Jobs/RestartProxyJob.php
+++ b/app/Jobs/RestartProxyJob.php
@@ -2,9 +2,12 @@
namespace App\Jobs;
-use App\Actions\Proxy\StartProxy;
-use App\Actions\Proxy\StopProxy;
+use App\Actions\Proxy\GetProxyConfiguration;
+use App\Actions\Proxy\SaveProxyConfiguration;
+use App\Enums\ProxyTypes;
+use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
+use App\Services\ProxyDashboardCacheService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -19,11 +22,13 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
public $tries = 1;
- public $timeout = 60;
+ public $timeout = 120;
+
+ public ?int $activity_id = null;
public function middleware(): array
{
- return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(60)->dontRelease()];
+ return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(120)->dontRelease()];
}
public function __construct(public Server $server) {}
@@ -31,15 +36,125 @@ public function __construct(public Server $server) {}
public function handle()
{
try {
- StopProxy::run($this->server, restarting: true);
-
+ // Set status to restarting
+ $this->server->proxy->status = 'restarting';
$this->server->proxy->force_stop = false;
$this->server->save();
- StartProxy::run($this->server, force: true, restarting: true);
+ // Build combined stop + start commands for a single activity
+ $commands = $this->buildRestartCommands();
+
+ // Create activity and dispatch immediately - returns Activity right away
+ // The remote_process runs asynchronously, so UI gets activity ID instantly
+ $activity = remote_process(
+ $commands,
+ $this->server,
+ callEventOnFinish: 'ProxyStatusChanged',
+ callEventData: $this->server->id
+ );
+
+ // Store activity ID and notify UI immediately with it
+ $this->activity_id = $activity->id;
+ ProxyStatusChangedUI::dispatch($this->server->team_id, $this->activity_id);
} catch (\Throwable $e) {
+ // Set error status
+ $this->server->proxy->status = 'error';
+ $this->server->save();
+
+ // Notify UI of error
+ ProxyStatusChangedUI::dispatch($this->server->team_id);
+
+ // Clear dashboard cache on error
+ ProxyDashboardCacheService::clearCache($this->server);
+
return handleError($e);
}
}
+
+ /**
+ * Build combined stop + start commands for proxy restart.
+ * This creates a single command sequence that shows all logs in one activity.
+ */
+ private function buildRestartCommands(): array
+ {
+ $proxyType = $this->server->proxyType();
+ $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
+ $proxy_path = $this->server->proxyPath();
+ $stopTimeout = 30;
+
+ // Get proxy configuration
+ $configuration = GetProxyConfiguration::run($this->server);
+ if (! $configuration) {
+ throw new \Exception('Configuration is not synced');
+ }
+ SaveProxyConfiguration::run($this->server, $configuration);
+ $docker_compose_yml_base64 = base64_encode($configuration);
+ $this->server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
+ $this->server->save();
+
+ $commands = collect([]);
+
+ // === STOP PHASE ===
+ $commands = $commands->merge([
+ "echo 'Stopping proxy...'",
+ "docker stop -t=$stopTimeout $containerName 2>/dev/null || true",
+ "docker rm -f $containerName 2>/dev/null || true",
+ '# Wait for container to be fully removed',
+ 'for i in {1..15}; do',
+ " if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then",
+ " echo 'Container removed successfully.'",
+ ' break',
+ ' fi',
+ ' echo "Waiting for container to be removed... ($i/15)"',
+ ' sleep 1',
+ ' # Force remove on each iteration in case it got stuck',
+ " docker rm -f $containerName 2>/dev/null || true",
+ 'done',
+ '# Final verification and force cleanup',
+ "if docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then",
+ " echo 'Container still exists after wait, forcing removal...'",
+ " docker rm -f $containerName 2>/dev/null || true",
+ ' sleep 2',
+ 'fi',
+ "echo 'Proxy stopped successfully.'",
+ ]);
+
+ // === START PHASE ===
+ if ($this->server->isSwarmManager()) {
+ $commands = $commands->merge([
+ "echo 'Starting proxy (Swarm mode)...'",
+ "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() && $proxyType === ProxyTypes::CADDY->value) {
+ $proxy_path = '/data/coolify/proxy/caddy';
+ }
+ $caddyfile = 'import /dynamic/*.caddy';
+ $commands = $commands->merge([
+ "echo 'Starting proxy...'",
+ "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',
+ ]);
+ // Ensure required networks exist BEFORE docker compose up
+ $commands = $commands->merge(ensureProxyNetworksExist($this->server));
+ $commands = $commands->merge([
+ "echo 'Starting coolify-proxy.'",
+ 'docker compose up -d --wait --remove-orphans',
+ "echo 'Successfully started coolify-proxy.'",
+ ]);
+ $commands = $commands->merge(connectProxyToNetworks($this->server));
+ }
+
+ return $commands->toArray();
+ }
}
diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php
index e55db5440..b21bc11a1 100644
--- a/app/Jobs/ScheduledTaskJob.php
+++ b/app/Jobs/ScheduledTaskJob.php
@@ -139,7 +139,9 @@ public function handle(): void
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) {
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'";
$exec = "docker exec {$containerName} {$cmd}";
- $this->task_output = instant_remote_process([$exec], $this->server, true);
+ // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently
+ // See: https://github.com/coollabsio/coolify/issues/6736
+ $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->task_log->update([
'status' => 'success',
'message' => $this->task_output,
diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php
index 45ab1dde8..a4619354d 100644
--- a/app/Jobs/ServerManagerJob.php
+++ b/app/Jobs/ServerManagerJob.php
@@ -111,34 +111,48 @@ private function processScheduledTasks(Collection $servers): void
private function processServerTasks(Server $server): void
{
+ // Get server timezone (used for all scheduled tasks)
+ $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
+ if (validate_timezone($serverTimezone) === false) {
+ $serverTimezone = config('app.timezone');
+ }
+
// Check if we should run sentinel-based checks
$lastSentinelUpdate = $server->sentinel_updated_at;
$waitTime = $server->waitBeforeDoingSshCheck();
- $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($waitTime));
+ $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->copy()->subSeconds($waitTime));
if ($sentinelOutOfSync) {
- // Dispatch jobs if Sentinel is out of sync
- if ($this->shouldRunNow($this->checkFrequency)) {
+ // Dispatch ServerCheckJob if Sentinel is out of sync
+ if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) {
ServerCheckJob::dispatch($server);
}
+ }
- // Dispatch ServerStorageCheckJob if due
- $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
+ $isSentinelEnabled = $server->isSentinelEnabled();
+ $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone);
+ // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
+
+ if ($shouldRestartSentinel) {
+ dispatch(function () use ($server) {
+ $server->restartContainer('coolify-sentinel');
+ });
+ }
+
+ // Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled)
+ // When Sentinel is active, PushServerUpdateJob handles storage checks with real-time data
+ if ($sentinelOutOfSync) {
+ $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *');
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
}
- $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency);
+ $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone);
if ($shouldRunStorageCheck) {
ServerStorageCheckJob::dispatch($server);
}
}
- $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
- if (validate_timezone($serverTimezone) === false) {
- $serverTimezone = config('app.timezone');
- }
-
// Dispatch ServerPatchCheckJob if due (weekly)
$shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone);
@@ -146,14 +160,10 @@ private function processServerTasks(Server $server): void
ServerPatchCheckJob::dispatch($server);
}
- // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
- $isSentinelEnabled = $server->isSentinelEnabled();
- $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone);
-
- if ($shouldRestartSentinel) {
- dispatch(function () use ($server) {
- $server->restartContainer('coolify-sentinel');
- });
+ // Sentinel update checks (hourly) - check for updates to Sentinel version
+ // No timezone needed for hourly - runs at top of every hour
+ if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) {
+ CheckAndStartSentinelJob::dispatch($server);
}
}
diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php
index ff5c2e4f5..b5e1929de 100644
--- a/app/Jobs/ValidateAndInstallServerJob.php
+++ b/app/Jobs/ValidateAndInstallServerJob.php
@@ -168,6 +168,9 @@ public function handle(): void
if (! $this->server->isBuildServer()) {
$proxyShouldRun = CheckProxy::run($this->server, true);
if ($proxyShouldRun) {
+ // Ensure networks exist BEFORE dispatching async proxy startup
+ // This prevents race condition where proxy tries to start before networks are created
+ instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
StartProxy::dispatch($this->server);
}
}
diff --git a/app/Listeners/MaintenanceModeDisabledNotification.php b/app/Listeners/MaintenanceModeDisabledNotification.php
deleted file mode 100644
index 6c3ab83d8..000000000
--- a/app/Listeners/MaintenanceModeDisabledNotification.php
+++ /dev/null
@@ -1,48 +0,0 @@
-files();
- $files = collect($files);
- $files = $files->sort();
- foreach ($files as $file) {
- $content = Storage::disk('webhooks-during-maintenance')->get($file);
- $data = json_decode($content, true);
- $symfonyRequest = new SymfonyRequest(
- $data['query'],
- $data['request'],
- $data['attributes'],
- $data['cookies'],
- $data['files'],
- $data['server'],
- $data['content']
- );
-
- foreach ($data['headers'] as $key => $value) {
- $symfonyRequest->headers->set($key, $value);
- }
- $request = Request::createFromBase($symfonyRequest);
- $endpoint = str($file)->after('_')->beforeLast('_')->value();
- $class = "App\Http\Controllers\Webhook\\".ucfirst(str($endpoint)->before('::')->value());
- $method = str($endpoint)->after('::')->value();
- try {
- $instance = new $class;
- $instance->$method($request);
- } catch (\Throwable $th) {
- } finally {
- Storage::disk('webhooks-during-maintenance')->delete($file);
- }
- }
- }
-}
diff --git a/app/Listeners/MaintenanceModeEnabledNotification.php b/app/Listeners/MaintenanceModeEnabledNotification.php
deleted file mode 100644
index 5aab248ea..000000000
--- a/app/Listeners/MaintenanceModeEnabledNotification.php
+++ /dev/null
@@ -1,21 +0,0 @@
-setupDynamicProxyConfiguration();
$server->proxy->force_stop = false;
$server->save();
+
+ // Check Traefik version after proxy is running
+ if ($server->proxyType() === ProxyTypes::TRAEFIK->value) {
+ $traefikVersions = get_traefik_versions();
+ if ($traefikVersions !== null) {
+ CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions);
+ } else {
+ Log::warning('Traefik version check skipped after proxy status change: versions.json data unavailable', [
+ 'server_id' => $server->id,
+ 'server_name' => $server->name,
+ ]);
+ }
+ }
}
if ($status === 'created') {
instant_remote_process([
diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php
index d01b55afb..bc310e715 100644
--- a/app/Livewire/ActivityMonitor.php
+++ b/app/Livewire/ActivityMonitor.php
@@ -10,7 +10,7 @@ class ActivityMonitor extends Component
{
public ?string $header = null;
- public $activityId;
+ public $activityId = null;
public $eventToDispatch = 'activityFinished';
@@ -49,9 +49,24 @@ public function newMonitorActivity($activityId, $eventToDispatch = 'activityFini
public function hydrateActivity()
{
+ if ($this->activityId === null) {
+ $this->activity = null;
+
+ return;
+ }
+
$this->activity = Activity::find($this->activityId);
}
+ public function updatedActivityId($value)
+ {
+ if ($value) {
+ $this->hydrateActivity();
+ $this->isPollingActive = true;
+ self::$eventDispatched = false;
+ }
+ }
+
public function polling()
{
$this->hydrateActivity();
diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php
index 57ecaa8a2..8c2be9ab6 100644
--- a/app/Livewire/Dashboard.php
+++ b/app/Livewire/Dashboard.php
@@ -18,9 +18,9 @@ class Dashboard extends Component
public function mount()
{
- $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get();
- $this->servers = Server::ownedByCurrentTeam()->get();
- $this->projects = Project::ownedByCurrentTeam()->get();
+ $this->privateKeys = PrivateKey::ownedByCurrentTeamCached();
+ $this->servers = Server::ownedByCurrentTeamCached();
+ $this->projects = Project::ownedByCurrentTeam()->with('environments')->get();
}
public function render()
diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php
index ac9cfd1c2..5c945ac01 100644
--- a/app/Livewire/DeploymentsIndicator.php
+++ b/app/Livewire/DeploymentsIndicator.php
@@ -14,7 +14,7 @@ class DeploymentsIndicator extends Component
#[Computed]
public function deployments()
{
- $servers = Server::ownedByCurrentTeam()->get();
+ $servers = Server::ownedByCurrentTeamCached();
return ApplicationDeploymentQueue::with(['application.environment.project'])
->whereIn('status', ['in_progress', 'queued'])
@@ -38,6 +38,12 @@ public function deploymentCount()
return $this->deployments->count();
}
+ #[Computed]
+ public function shouldReduceOpacity(): bool
+ {
+ return request()->routeIs('project.application.deployment.*');
+ }
+
public function toggleExpanded()
{
$this->expanded = ! $this->expanded;
diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php
index e97cceb0d..9508c2adc 100644
--- a/app/Livewire/NavbarDeleteTeam.php
+++ b/app/Livewire/NavbarDeleteTeam.php
@@ -2,10 +2,8 @@
namespace App\Livewire;
-use App\Models\InstanceSettings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class NavbarDeleteTeam extends Component
@@ -19,12 +17,8 @@ public function mount()
public function delete($password)
{
- if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
- return;
- }
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return;
}
$currentTeam = currentTeam();
diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php
index ed15ab258..cf7ef3e0b 100644
--- a/app/Livewire/Project/Application/Advanced.php
+++ b/app/Livewire/Project/Application/Advanced.php
@@ -37,6 +37,12 @@ class Advanced extends Component
#[Validate(['boolean'])]
public bool $disableBuildCache = false;
+ #[Validate(['boolean'])]
+ public bool $injectBuildArgsToDockerfile = true;
+
+ #[Validate(['boolean'])]
+ public bool $includeSourceCommitInBuild = false;
+
#[Validate(['boolean'])]
public bool $isLogDrainEnabled = false;
@@ -110,6 +116,8 @@ public function syncData(bool $toModel = false)
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
$this->application->settings->connect_to_docker_network = $this->isConnectToDockerNetworkEnabled;
$this->application->settings->disable_build_cache = $this->disableBuildCache;
+ $this->application->settings->inject_build_args_to_dockerfile = $this->injectBuildArgsToDockerfile;
+ $this->application->settings->include_source_commit_in_build = $this->includeSourceCommitInBuild;
$this->application->settings->save();
} else {
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
@@ -134,6 +142,8 @@ public function syncData(bool $toModel = false)
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
$this->disableBuildCache = $this->application->settings->disable_build_cache;
+ $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
+ $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
}
}
diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php
index cdac47d3d..8c0ee1a3f 100644
--- a/app/Livewire/Project/Application/Deployment/Show.php
+++ b/app/Livewire/Project/Application/Deployment/Show.php
@@ -18,12 +18,15 @@ class Show extends Component
public $isKeepAliveOn = true;
+ public bool $is_debug_enabled = false;
+
+ public bool $fullscreen = false;
+
+ private bool $deploymentFinishedDispatched = false;
+
public function getListeners()
{
- $teamId = auth()->user()->currentTeam()->id;
-
return [
- "echo-private:team.{$teamId},ServiceChecked" => '$refresh',
'refreshQueue',
];
}
@@ -56,9 +59,23 @@ public function mount()
$this->application_deployment_queue = $application_deployment_queue;
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
$this->deployment_uuid = $deploymentUuid;
+ $this->is_debug_enabled = $this->application->settings->is_debug_enabled;
$this->isKeepAliveOn();
}
+ public function toggleDebug()
+ {
+ try {
+ $this->authorize('update', $this->application);
+ $this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled;
+ $this->application->settings->save();
+ $this->is_debug_enabled = $this->application->settings->is_debug_enabled;
+ $this->application_deployment_queue->refresh();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
public function refreshQueue()
{
$this->application_deployment_queue->refresh();
@@ -75,10 +92,15 @@ private function isKeepAliveOn()
public function polling()
{
- $this->dispatch('deploymentFinished');
$this->application_deployment_queue->refresh();
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
$this->isKeepAliveOn();
+
+ // Dispatch event when deployment finishes to stop auto-scroll (only once)
+ if (! $this->isKeepAliveOn && ! $this->deploymentFinishedDispatched) {
+ $this->deploymentFinishedDispatched = true;
+ $this->dispatch('deploymentFinished');
+ }
}
public function getLogLinesProperty()
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 71ca9720e..c84de9d8d 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -521,7 +521,7 @@ public function instantSave()
}
}
- public function loadComposeFile($isInit = false, $showToast = true)
+ public function loadComposeFile($isInit = false, $showToast = true, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null)
{
try {
$this->authorize('update', $this->application);
@@ -530,7 +530,7 @@ public function loadComposeFile($isInit = false, $showToast = true)
return;
}
- ['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit);
+ ['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit, $restoreBaseDirectory, $restoreDockerComposeLocation);
if (is_null($this->parsedServices)) {
$showToast && $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
@@ -606,13 +606,6 @@ public function generateDomain(string $serviceName)
}
}
- public function updatedBaseDirectory()
- {
- if ($this->buildPack === 'dockercompose') {
- $this->loadComposeFile();
- }
- }
-
public function updatedIsStatic($value)
{
if ($value) {
@@ -786,11 +779,13 @@ public function submit($showToaster = true)
try {
$this->authorize('update', $this->application);
+ $this->resetErrorBag();
$this->validate();
$oldPortsExposes = $this->application->ports_exposes;
$oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
$oldDockerComposeLocation = $this->initialDockerComposeLocation;
+ $oldBaseDirectory = $this->application->base_directory;
// Process FQDN with intermediate variable to avoid Collection/string confusion
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
@@ -821,6 +816,42 @@ public function submit($showToaster = true)
return; // Stop if there are conflicts and user hasn't confirmed
}
+ // Normalize paths BEFORE validation
+ if ($this->baseDirectory && $this->baseDirectory !== '/') {
+ $this->baseDirectory = rtrim($this->baseDirectory, '/');
+ $this->application->base_directory = $this->baseDirectory;
+ }
+ if ($this->publishDirectory && $this->publishDirectory !== '/') {
+ $this->publishDirectory = rtrim($this->publishDirectory, '/');
+ $this->application->publish_directory = $this->publishDirectory;
+ }
+
+ // Validate docker compose file path BEFORE saving to database
+ // This prevents invalid paths from being persisted when validation fails
+ if ($this->buildPack === 'dockercompose' &&
+ ($oldDockerComposeLocation !== $this->dockerComposeLocation ||
+ $oldBaseDirectory !== $this->baseDirectory)) {
+ // Pass original values to loadComposeFile so it can restore them on failure
+ // The finally block in Application::loadComposeFile will save these original
+ // values if validation fails, preventing invalid paths from being persisted
+ $compose_return = $this->loadComposeFile(
+ isInit: false,
+ showToast: false,
+ restoreBaseDirectory: $oldBaseDirectory,
+ restoreDockerComposeLocation: $oldDockerComposeLocation
+ );
+ if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
+ // Validation failed - restore original values to component properties
+ $this->baseDirectory = $oldBaseDirectory;
+ $this->dockerComposeLocation = $oldDockerComposeLocation;
+ // The model was saved by loadComposeFile's finally block with original values
+ // Refresh to sync component with database state
+ $this->application->refresh();
+
+ return;
+ }
+ }
+
$this->application->save();
if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && ! $this->application->settings->is_container_label_readonly_enabled) {
$this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
@@ -828,13 +859,6 @@ public function submit($showToaster = true)
$this->application->save();
}
- if ($this->buildPack === 'dockercompose' && $oldDockerComposeLocation !== $this->dockerComposeLocation) {
- $compose_return = $this->loadComposeFile(showToast: false);
- if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
- return;
- }
- }
-
if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) {
$this->resetDefaultLabels();
}
@@ -855,14 +879,6 @@ public function submit($showToaster = true)
$this->application->ports_exposes = $port;
}
}
- if ($this->baseDirectory && $this->baseDirectory !== '/') {
- $this->baseDirectory = rtrim($this->baseDirectory, '/');
- $this->application->base_directory = $this->baseDirectory;
- }
- if ($this->publishDirectory && $this->publishDirectory !== '/') {
- $this->publishDirectory = rtrim($this->publishDirectory, '/');
- $this->application->publish_directory = $this->publishDirectory;
- }
if ($this->buildPack === 'dockercompose') {
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) {
@@ -1018,11 +1034,27 @@ public function getDockerComposeBuildCommandPreviewProperty(): string
// Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
// Actual deployment uses absolute path: /artifacts/{deployment_uuid}{base_directory}{docker_compose_location}
// Build-time env path references ApplicationDeploymentJob::BUILD_TIME_ENV_PATH as source of truth
- return injectDockerComposeFlags(
+ $command = injectDockerComposeFlags(
$this->dockerComposeCustomBuildCommand,
".{$normalizedBase}{$this->dockerComposeLocation}",
\App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH
);
+
+ // Inject build args if not using build secrets
+ if (! $this->application->settings->use_build_secrets) {
+ $buildTimeEnvs = $this->application->environment_variables()
+ ->where('is_buildtime', true)
+ ->get();
+
+ if ($buildTimeEnvs->isNotEmpty()) {
+ $buildArgs = generateDockerBuildArgs($buildTimeEnvs);
+ $buildArgsString = $buildArgs->implode(' ');
+
+ $command = injectDockerComposeBuildArgs($command, $buildArgsString);
+ }
+ }
+
+ return $command;
}
public function getDockerComposeStartCommandPreviewProperty(): string
diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php
index fc63c7f4b..a46b2f19c 100644
--- a/app/Livewire/Project/Application/Heading.php
+++ b/app/Livewire/Project/Application/Heading.php
@@ -100,19 +100,17 @@ public function deploy(bool $force_rebuild = false)
deployment_uuid: $this->deploymentUuid,
force_rebuild: $force_rebuild,
);
+ if ($result['status'] === 'queue_full') {
+ $this->dispatch('error', 'Deployment queue full', $result['message']);
+
+ return;
+ }
if ($result['status'] === 'skipped') {
$this->dispatch('error', 'Deployment skipped', $result['message']);
return;
}
- // Reset restart count on successful deployment
- $this->application->update([
- 'restart_count' => 0,
- 'last_restart_at' => null,
- 'last_restart_type' => null,
- ]);
-
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
@@ -151,19 +149,17 @@ public function restart()
deployment_uuid: $this->deploymentUuid,
restart_only: true,
);
+ if ($result['status'] === 'queue_full') {
+ $this->dispatch('error', 'Deployment queue full', $result['message']);
+
+ return;
+ }
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
return;
}
- // Reset restart count on manual restart
- $this->application->update([
- 'restart_count' => 0,
- 'last_restart_at' => now(),
- 'last_restart_type' => 'manual',
- ]);
-
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php
index e28c8142d..41f352c14 100644
--- a/app/Livewire/Project/Application/Previews.php
+++ b/app/Livewire/Project/Application/Previews.php
@@ -249,6 +249,11 @@ public function deploy(int $pull_request_id, ?string $pull_request_html_url = nu
pull_request_id: $pull_request_id,
git_type: $found->git_type ?? null,
);
+ if ($result['status'] === 'queue_full') {
+ $this->dispatch('error', 'Deployment queue full', $result['message']);
+
+ return;
+ }
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
@@ -278,7 +283,7 @@ private function stopContainers(array $containers, $server)
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
- "docker stop --time=30 $containerName",
+ "docker stop -t 30 $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php
index 942dfeb37..85ba2328e 100644
--- a/app/Livewire/Project/Application/PreviewsCompose.php
+++ b/app/Livewire/Project/Application/PreviewsCompose.php
@@ -96,8 +96,7 @@ public function generate()
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
- $preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn);
- $preview_fqdns[] = "$schema://$preview_fqdn";
+ $preview_fqdns[] = "$schema://$preview_fqdn{$port}";
}
$preview_fqdn = implode(',', $preview_fqdns);
diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php
index da67a5707..e53784db5 100644
--- a/app/Livewire/Project/Application/Rollback.php
+++ b/app/Livewire/Project/Application/Rollback.php
@@ -4,6 +4,7 @@
use App\Models\Application;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Validate;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -19,9 +20,30 @@ class Rollback extends Component
public array $parameters;
+ #[Validate(['integer', 'min:0', 'max:100'])]
+ public int $dockerImagesToKeep = 2;
+
+ public bool $serverRetentionDisabled = false;
+
public function mount()
{
$this->parameters = get_route_parameters();
+ $this->dockerImagesToKeep = $this->application->settings->docker_images_to_keep ?? 2;
+ $server = $this->application->destination->server;
+ $this->serverRetentionDisabled = $server->settings->disable_application_image_retention ?? false;
+ }
+
+ public function saveSettings()
+ {
+ try {
+ $this->authorize('update', $this->application);
+ $this->validate();
+ $this->application->settings->docker_images_to_keep = $this->dockerImagesToKeep;
+ $this->application->settings->save();
+ $this->dispatch('success', 'Settings saved.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
}
public function rollbackImage($commit)
@@ -30,7 +52,7 @@ public function rollbackImage($commit)
$deployment_uuid = new Cuid2;
- queue_application_deployment(
+ $result = queue_application_deployment(
application: $this->application,
deployment_uuid: $deployment_uuid,
commit: $commit,
@@ -38,6 +60,12 @@ public function rollbackImage($commit)
force_rebuild: false,
);
+ if ($result['status'] === 'queue_full') {
+ $this->dispatch('error', 'Deployment queue full', $result['message']);
+
+ return;
+ }
+
return redirect()->route('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
@@ -66,14 +94,12 @@ public function loadImages($showToast = false)
return str($item)->contains($image);
})->map(function ($item) {
$item = str($item)->explode('#');
- if ($item[1] === $this->current) {
- // $is_current = true;
- }
+ $is_current = $item[1] === $this->current;
return [
'tag' => $item[1],
'created_at' => $item[2],
- 'is_current' => $is_current ?? null,
+ 'is_current' => $is_current,
];
})->toArray();
}
diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php
index da543a049..d70c52411 100644
--- a/app/Livewire/Project/Database/BackupEdit.php
+++ b/app/Livewire/Project/Database/BackupEdit.php
@@ -2,12 +2,9 @@
namespace App\Livewire\Project\Database;
-use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -107,6 +104,25 @@ public function syncData(bool $toModel = false)
$this->backup->save_s3 = $this->saveS3;
$this->backup->disable_local_backup = $this->disableLocalBackup;
$this->backup->s3_storage_id = $this->s3StorageId;
+
+ // Validate databases_to_backup to prevent command injection
+ if (filled($this->databasesToBackup)) {
+ $databases = str($this->databasesToBackup)->explode(',');
+ foreach ($databases as $index => $db) {
+ $dbName = trim($db);
+ try {
+ validateShellSafePath($dbName, 'database name');
+ } catch (\Exception $e) {
+ // Provide specific error message indicating which database failed validation
+ $position = $index + 1;
+ throw new \Exception(
+ "Database #{$position} ('{$dbName}') validation failed: ".
+ $e->getMessage()
+ );
+ }
+ }
+ }
+
$this->backup->databases_to_backup = $this->databasesToBackup;
$this->backup->dump_all = $this->dumpAll;
$this->backup->timeout = $this->timeout;
@@ -135,12 +151,8 @@ public function delete($password)
{
$this->authorize('manageBackups', $this->backup->database);
- if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
- return;
- }
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return;
}
try {
diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php
index 0b6d8338b..44f903fcc 100644
--- a/app/Livewire/Project/Database/BackupExecutions.php
+++ b/app/Livewire/Project/Database/BackupExecutions.php
@@ -2,11 +2,9 @@
namespace App\Livewire\Project\Database;
-use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class BackupExecutions extends Component
@@ -69,12 +67,8 @@ public function cleanupDeleted()
public function deleteBackup($executionId, $password)
{
- if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
- return;
- }
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return;
}
$execution = $this->backup->executions()->where('id', $executionId)->first();
diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php
index 7d6ac3131..26feb1a5e 100644
--- a/app/Livewire/Project/Database/Import.php
+++ b/app/Livewire/Project/Database/Import.php
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Database;
+use App\Models\S3Storage;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
@@ -12,6 +13,92 @@ class Import extends Component
{
use AuthorizesRequests;
+ /**
+ * Validate that a string is safe for use as an S3 bucket name.
+ * Allows alphanumerics, dots, dashes, and underscores.
+ */
+ private function validateBucketName(string $bucket): bool
+ {
+ return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
+ }
+
+ /**
+ * Validate that a string is safe for use as an S3 path.
+ * Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
+ */
+ private function validateS3Path(string $path): bool
+ {
+ // Must not be empty
+ if (empty($path)) {
+ return false;
+ }
+
+ // Must not contain dangerous shell metacharacters or command injection patterns
+ $dangerousPatterns = [
+ '..', // Directory traversal
+ '$(', // Command substitution
+ '`', // Backtick command substitution
+ '|', // Pipe
+ ';', // Command separator
+ '&', // Background/AND
+ '>', // Redirect
+ '<', // Redirect
+ "\n", // Newline
+ "\r", // Carriage return
+ "\0", // Null byte
+ "'", // Single quote
+ '"', // Double quote
+ '\\', // Backslash
+ ];
+
+ foreach ($dangerousPatterns as $pattern) {
+ if (str_contains($path, $pattern)) {
+ return false;
+ }
+ }
+
+ // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
+ return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
+ }
+
+ /**
+ * Validate that a string is safe for use as a file path on the server.
+ */
+ private function validateServerPath(string $path): bool
+ {
+ // Must be an absolute path
+ if (! str_starts_with($path, '/')) {
+ return false;
+ }
+
+ // Must not contain dangerous shell metacharacters or command injection patterns
+ $dangerousPatterns = [
+ '..', // Directory traversal
+ '$(', // Command substitution
+ '`', // Backtick command substitution
+ '|', // Pipe
+ ';', // Command separator
+ '&', // Background/AND
+ '>', // Redirect
+ '<', // Redirect
+ "\n", // Newline
+ "\r", // Carriage return
+ "\0", // Null byte
+ "'", // Single quote
+ '"', // Double quote
+ '\\', // Backslash
+ ];
+
+ foreach ($dangerousPatterns as $pattern) {
+ if (str_contains($path, $pattern)) {
+ return false;
+ }
+ }
+
+ // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
+ return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
+ }
+
public bool $unsupported = false;
public $resource;
@@ -46,6 +133,8 @@ class Import extends Component
public string $customLocation = '';
+ public ?int $activityId = null;
+
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
@@ -54,22 +143,35 @@ class Import extends Component
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
+ // S3 Restore properties
+ public $availableS3Storages = [];
+
+ public ?int $s3StorageId = null;
+
+ public string $s3Path = '';
+
+ public ?int $s3FileSize = null;
+
public function getListeners()
{
$userId = Auth::id();
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
+ 'slideOverClosed' => 'resetActivityId',
];
}
+ public function resetActivityId()
+ {
+ $this->activityId = null;
+ }
+
public function mount()
{
- if (isDev()) {
- $this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz';
- }
$this->parameters = get_route_parameters();
$this->getContainers();
+ $this->loadAvailableS3Storages();
}
public function updatedDumpAll($value)
@@ -152,8 +254,16 @@ public function getContainers()
public function checkFile()
{
if (filled($this->customLocation)) {
+ // Validate the custom location to prevent command injection
+ if (! $this->validateServerPath($this->customLocation)) {
+ $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
+
+ return;
+ }
+
try {
- $result = instant_remote_process(["ls -l {$this->customLocation}"], $this->server, throwError: false);
+ $escapedPath = escapeshellarg($this->customLocation);
+ $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
if (blank($result)) {
$this->dispatch('error', 'The file does not exist or has been deleted.');
@@ -179,59 +289,35 @@ public function runImport()
try {
$this->importRunning = true;
$this->importCommands = [];
- if (filled($this->customLocation)) {
- $backupFileName = '/tmp/restore_'.$this->resource->uuid;
- $this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$backupFileName}";
- $tmpPath = $backupFileName;
- } else {
- $backupFileName = "upload/{$this->resource->uuid}/restore";
- $path = Storage::path($backupFileName);
- if (! Storage::exists($backupFileName)) {
- $this->dispatch('error', 'The file does not exist or has been deleted.');
+ $backupFileName = "upload/{$this->resource->uuid}/restore";
- return;
- }
+ // Check if an uploaded file exists first (takes priority over custom location)
+ if (Storage::exists($backupFileName)) {
+ $path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
+ } elseif (filled($this->customLocation)) {
+ // Validate the custom location to prevent command injection
+ if (! $this->validateServerPath($this->customLocation)) {
+ $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
+
+ return;
+ }
+ $tmpPath = '/tmp/restore_'.$this->resource->uuid;
+ $escapedCustomLocation = escapeshellarg($this->customLocation);
+ $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
+ } else {
+ $this->dispatch('error', 'The file does not exist or has been deleted.');
+
+ return;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
- switch ($this->resource->getMorphClass()) {
- case \App\Models\StandaloneMariadb::class:
- $restoreCommand = $this->mariadbRestoreCommand;
- if ($this->dumpAll) {
- $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
- } else {
- $restoreCommand .= " < {$tmpPath}";
- }
- break;
- case \App\Models\StandaloneMysql::class:
- $restoreCommand = $this->mysqlRestoreCommand;
- if ($this->dumpAll) {
- $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
- } else {
- $restoreCommand .= " < {$tmpPath}";
- }
- break;
- case \App\Models\StandalonePostgresql::class:
- $restoreCommand = $this->postgresqlRestoreCommand;
- if ($this->dumpAll) {
- $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
- } else {
- $restoreCommand .= " {$tmpPath}";
- }
- break;
- case \App\Models\StandaloneMongodb::class:
- $restoreCommand = $this->mongodbRestoreCommand;
- if ($this->dumpAll === false) {
- $restoreCommand .= "{$tmpPath}";
- }
- break;
- }
+ $restoreCommand = $this->buildRestoreCommand($tmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
@@ -248,7 +334,13 @@ public function runImport()
'container' => $this->container,
'serverId' => $this->server->id,
]);
+
+ // Track the activity ID
+ $this->activityId = $activity->id;
+
+ // Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
+ $this->dispatch('databaserestore');
}
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -257,4 +349,267 @@ public function runImport()
$this->importCommands = [];
}
}
+
+ public function loadAvailableS3Storages()
+ {
+ try {
+ $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
+ ->where('is_usable', true)
+ ->get();
+ } catch (\Throwable $e) {
+ $this->availableS3Storages = collect();
+ }
+ }
+
+ public function updatedS3Path($value)
+ {
+ // Reset validation state when path changes
+ $this->s3FileSize = null;
+
+ // Ensure path starts with a slash
+ if ($value !== null && $value !== '') {
+ $this->s3Path = str($value)->trim()->start('/')->value();
+ }
+ }
+
+ public function updatedS3StorageId()
+ {
+ // Reset validation state when storage changes
+ $this->s3FileSize = null;
+ }
+
+ public function checkS3File()
+ {
+ if (! $this->s3StorageId) {
+ $this->dispatch('error', 'Please select an S3 storage.');
+
+ return;
+ }
+
+ if (blank($this->s3Path)) {
+ $this->dispatch('error', 'Please provide an S3 path.');
+
+ return;
+ }
+
+ // Clean the path (remove leading slash if present)
+ $cleanPath = ltrim($this->s3Path, '/');
+
+ // Validate the S3 path early to prevent command injection in subsequent operations
+ if (! $this->validateS3Path($cleanPath)) {
+ $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
+
+ return;
+ }
+
+ try {
+ $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
+
+ // Validate bucket name early
+ if (! $this->validateBucketName($s3Storage->bucket)) {
+ $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
+
+ return;
+ }
+
+ // Test connection
+ $s3Storage->testConnection();
+
+ // Build S3 disk configuration
+ $disk = Storage::build([
+ 'driver' => 's3',
+ 'region' => $s3Storage->region,
+ 'key' => $s3Storage->key,
+ 'secret' => $s3Storage->secret,
+ 'bucket' => $s3Storage->bucket,
+ 'endpoint' => $s3Storage->endpoint,
+ 'use_path_style_endpoint' => true,
+ ]);
+
+ // Check if file exists
+ if (! $disk->exists($cleanPath)) {
+ $this->dispatch('error', 'File not found in S3. Please check the path.');
+
+ return;
+ }
+
+ // Get file size
+ $this->s3FileSize = $disk->size($cleanPath);
+
+ $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
+ } catch (\Throwable $e) {
+ $this->s3FileSize = null;
+
+ return handleError($e, $this);
+ }
+ }
+
+ public function restoreFromS3()
+ {
+ $this->authorize('update', $this->resource);
+
+ if (! $this->s3StorageId || blank($this->s3Path)) {
+ $this->dispatch('error', 'Please select S3 storage and provide a path first.');
+
+ return;
+ }
+
+ if (is_null($this->s3FileSize)) {
+ $this->dispatch('error', 'Please check the file first by clicking "Check File".');
+
+ return;
+ }
+
+ try {
+ $this->importRunning = true;
+
+ $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
+
+ $key = $s3Storage->key;
+ $secret = $s3Storage->secret;
+ $bucket = $s3Storage->bucket;
+ $endpoint = $s3Storage->endpoint;
+
+ // Validate bucket name to prevent command injection
+ if (! $this->validateBucketName($bucket)) {
+ $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
+
+ return;
+ }
+
+ // Clean the S3 path
+ $cleanPath = ltrim($this->s3Path, '/');
+
+ // Validate the S3 path to prevent command injection
+ if (! $this->validateS3Path($cleanPath)) {
+ $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
+
+ return;
+ }
+
+ // Get helper image
+ $helperImage = config('constants.coolify.helper_image');
+ $latestVersion = getHelperVersion();
+ $fullImageName = "{$helperImage}:{$latestVersion}";
+
+ // Get the database destination network
+ $destinationNetwork = $this->resource->destination->network ?? 'coolify';
+
+ // Generate unique names for this operation
+ $containerName = "s3-restore-{$this->resource->uuid}";
+ $helperTmpPath = '/tmp/'.basename($cleanPath);
+ $serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath);
+ $containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath);
+ $scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
+
+ // Prepare all commands in sequence
+ $commands = [];
+
+ // 1. Clean up any existing helper container and temp files from previous runs
+ $commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
+ $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
+ $commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
+
+ // 2. Start helper container on the database network
+ $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
+
+ // 3. Configure S3 access in helper container
+ $escapedEndpoint = escapeshellarg($endpoint);
+ $escapedKey = escapeshellarg($key);
+ $escapedSecret = escapeshellarg($secret);
+ $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
+
+ // 4. Check file exists in S3 (bucket and path already validated above)
+ $escapedBucket = escapeshellarg($bucket);
+ $escapedCleanPath = escapeshellarg($cleanPath);
+ $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
+ $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
+
+ // 5. Download from S3 to helper container (progress shown by default)
+ $escapedHelperTmpPath = escapeshellarg($helperTmpPath);
+ $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
+
+ // 6. Copy from helper to server, then immediately to database container
+ $commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
+ $commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
+
+ // 7. Cleanup helper container and server temp file immediately (no longer needed)
+ $commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
+ $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
+
+ // 8. Build and execute restore command inside database container
+ $restoreCommand = $this->buildRestoreCommand($containerTmpPath);
+
+ $restoreCommandBase64 = base64_encode($restoreCommand);
+ $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
+ $commands[] = "chmod +x {$scriptPath}";
+ $commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
+
+ // 9. Execute restore and cleanup temp files immediately after completion
+ $commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
+ $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
+
+ // Execute all commands with cleanup event (as safety net for edge cases)
+ $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
+ 'containerName' => $containerName,
+ 'serverTmpPath' => $serverTmpPath,
+ 'scriptPath' => $scriptPath,
+ 'containerTmpPath' => $containerTmpPath,
+ 'container' => $this->container,
+ 'serverId' => $this->server->id,
+ ]);
+
+ // Track the activity ID
+ $this->activityId = $activity->id;
+
+ // Dispatch activity to the monitor and open slide-over
+ $this->dispatch('activityMonitor', $activity->id);
+ $this->dispatch('databaserestore');
+ $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
+ } catch (\Throwable $e) {
+ $this->importRunning = false;
+
+ return handleError($e, $this);
+ }
+ }
+
+ public function buildRestoreCommand(string $tmpPath): string
+ {
+ switch ($this->resource->getMorphClass()) {
+ case \App\Models\StandaloneMariadb::class:
+ $restoreCommand = $this->mariadbRestoreCommand;
+ if ($this->dumpAll) {
+ $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
+ } else {
+ $restoreCommand .= " < {$tmpPath}";
+ }
+ break;
+ case \App\Models\StandaloneMysql::class:
+ $restoreCommand = $this->mysqlRestoreCommand;
+ if ($this->dumpAll) {
+ $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
+ } else {
+ $restoreCommand .= " < {$tmpPath}";
+ }
+ break;
+ case \App\Models\StandalonePostgresql::class:
+ $restoreCommand = $this->postgresqlRestoreCommand;
+ if ($this->dumpAll) {
+ $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
+ } else {
+ $restoreCommand .= " {$tmpPath}";
+ }
+ break;
+ case \App\Models\StandaloneMongodb::class:
+ $restoreCommand = $this->mongodbRestoreCommand;
+ if ($this->dumpAll === false) {
+ $restoreCommand .= "{$tmpPath}";
+ }
+ break;
+ default:
+ $restoreCommand = '';
+ }
+
+ return $restoreCommand;
+ }
}
diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php
index 3240aadd2..7ef2cdc4f 100644
--- a/app/Livewire/Project/Database/Postgresql/General.php
+++ b/app/Livewire/Project/Database/Postgresql/General.php
@@ -328,12 +328,15 @@ public function save_init_script($script)
$configuration_dir = database_configuration_dir().'/'.$container_name;
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
- $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
- $delete_command = "rm -f $old_file_path";
try {
+ // Validate and escape filename to prevent command injection
+ validateShellSafePath($oldScript['filename'], 'init script filename');
+ $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
+ $escapedOldPath = escapeshellarg($old_file_path);
+ $delete_command = "rm -f {$escapedOldPath}";
instant_remote_process([$delete_command], $this->server);
} catch (Exception $e) {
- $this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage());
+ $this->dispatch('error', $e->getMessage());
return;
}
@@ -370,13 +373,17 @@ public function delete_init_script($script)
if ($found) {
$container_name = $this->database->uuid;
$configuration_dir = database_configuration_dir().'/'.$container_name;
- $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
- $command = "rm -f $file_path";
try {
+ // Validate and escape filename to prevent command injection
+ validateShellSafePath($script['filename'], 'init script filename');
+ $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
+ $escapedPath = escapeshellarg($file_path);
+
+ $command = "rm -f {$escapedPath}";
instant_remote_process([$command], $this->server);
} catch (Exception $e) {
- $this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage());
+ $this->dispatch('error', $e->getMessage());
return;
}
@@ -405,6 +412,16 @@ public function save_new_init_script()
'new_filename' => 'required|string',
'new_content' => 'required|string',
]);
+
+ try {
+ // Validate filename to prevent command injection
+ validateShellSafePath($this->new_filename, 'init script filename');
+ } catch (Exception $e) {
+ $this->dispatch('error', $e->getMessage());
+
+ return;
+ }
+
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
if ($found) {
$this->dispatch('error', 'Filename already exists.');
diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php
index 0e4f15a5c..7aa8dfc49 100644
--- a/app/Livewire/Project/Index.php
+++ b/app/Livewire/Project/Index.php
@@ -17,9 +17,9 @@ class Index extends Component
public function mount()
{
- $this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
- $this->projects = Project::ownedByCurrentTeam()->get();
- $this->servers = Server::ownedByCurrentTeam()->count();
+ $this->private_keys = PrivateKey::ownedByCurrentTeamCached();
+ $this->projects = Project::ownedByCurrentTeamCached();
+ $this->servers = Server::ownedByCurrentTeamCached();
}
public function render()
diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php
index a88a62d88..18bb237af 100644
--- a/app/Livewire/Project/New/DockerCompose.php
+++ b/app/Livewire/Project/New/DockerCompose.php
@@ -74,6 +74,9 @@ public function submit()
}
$service->parse(isNew: true);
+ // Apply service-specific application prerequisites
+ applyServiceApplicationPrerequisites($service);
+
return redirect()->route('project.service.configuration', [
'service_uuid' => $service->uuid,
'environment_uuid' => $environment->uuid,
diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php
index 27ecacb99..40d2674e2 100644
--- a/app/Livewire/Project/New/GithubPrivateRepository.php
+++ b/app/Livewire/Project/New/GithubPrivateRepository.php
@@ -75,16 +75,6 @@ public function mount()
$this->github_apps = GithubApp::private();
}
- public function updatedBaseDirectory()
- {
- if ($this->base_directory) {
- $this->base_directory = rtrim($this->base_directory, '/');
- if (! str($this->base_directory)->startsWith('/')) {
- $this->base_directory = '/'.$this->base_directory;
- }
- }
- }
-
public function updatedBuildPack()
{
if ($this->build_pack === 'nixpacks') {
@@ -138,6 +128,7 @@ public function loadBranches()
$this->loadBranchByPage();
}
}
+ $this->branches = sortBranchesByPriority($this->branches);
$this->selected_branch_name = data_get($this->branches, '0.name', 'main');
}
diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php
index 89814ee7f..2fffff6b9 100644
--- a/app/Livewire/Project/New/PublicGitRepository.php
+++ b/app/Livewire/Project/New/PublicGitRepository.php
@@ -107,26 +107,6 @@ public function mount()
$this->query = request()->query();
}
- public function updatedBaseDirectory()
- {
- if ($this->base_directory) {
- $this->base_directory = rtrim($this->base_directory, '/');
- if (! str($this->base_directory)->startsWith('/')) {
- $this->base_directory = '/'.$this->base_directory;
- }
- }
- }
-
- public function updatedDockerComposeLocation()
- {
- if ($this->docker_compose_location) {
- $this->docker_compose_location = rtrim($this->docker_compose_location, '/');
- if (! str($this->docker_compose_location)->startsWith('/')) {
- $this->docker_compose_location = '/'.$this->docker_compose_location;
- }
- }
- }
-
public function updatedBuildPack()
{
if ($this->build_pack === 'nixpacks') {
diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php
index cdf95d2e4..1158fb3f7 100644
--- a/app/Livewire/Project/Resource/Create.php
+++ b/app/Livewire/Project/Resource/Create.php
@@ -81,7 +81,7 @@ public function mount()
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
- if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') {
+ if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($service_payload, 'connect_to_docker_network', true);
}
$service = Service::create($service_payload);
@@ -104,6 +104,9 @@ public function mount()
}
$service->parse(isNew: true);
+ // Apply service-specific application prerequisites
+ applyServiceApplicationPrerequisites($service);
+
return redirect()->route('project.service.configuration', [
'service_uuid' => $service->uuid,
'environment_uuid' => $environment->uuid,
diff --git a/app/Livewire/Project/Service/Database.php b/app/Livewire/Project/Service/Database.php
index 4bcf866d3..1e183c6bc 100644
--- a/app/Livewire/Project/Service/Database.php
+++ b/app/Livewire/Project/Service/Database.php
@@ -4,12 +4,9 @@
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
-use App\Models\InstanceSettings;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Database extends Component
@@ -96,12 +93,8 @@ public function delete($password)
try {
$this->authorize('delete', $this->database);
- if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
- return;
- }
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return;
}
$this->database->delete();
diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 2ce4374a0..079115bb6 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -3,7 +3,6 @@
namespace App\Livewire\Project\Service;
use App\Models\Application;
-use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@@ -16,8 +15,6 @@
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -62,7 +59,7 @@ public function mount()
$this->fs_path = $this->fileStorage->fs_path;
}
- $this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
+ $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
$this->syncData();
}
@@ -104,7 +101,8 @@ public function convertToDirectory()
public function loadStorageOnServer()
{
try {
- $this->authorize('update', $this->resource);
+ // Loading content is a read operation, so we use 'view' permission
+ $this->authorize('view', $this->resource);
$this->fileStorage->loadStorageOnServer();
$this->syncData();
@@ -140,12 +138,8 @@ public function delete($password)
{
$this->authorize('update', $this->resource);
- if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
- return;
- }
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return;
}
try {
diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php
index 259b9dbec..4302c05fb 100644
--- a/app/Livewire/Project/Service/ServiceApplicationView.php
+++ b/app/Livewire/Project/Service/ServiceApplicationView.php
@@ -2,12 +2,9 @@
namespace App\Livewire\Project\Service;
-use App\Models\InstanceSettings;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
-use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
@@ -82,6 +79,21 @@ public function instantSave()
}
}
+ public function instantSaveSettings()
+ {
+ try {
+ $this->authorize('update', $this->application);
+ // Save checkbox states without port validation
+ $this->application->is_gzip_enabled = $this->isGzipEnabled;
+ $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
+ $this->application->exclude_from_status = $this->excludeFromStatus;
+ $this->application->save();
+ $this->dispatch('success', 'Settings saved.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
public function instantSaveAdvanced()
{
try {
@@ -113,12 +125,8 @@ public function delete($password)
try {
$this->authorize('delete', $this->application);
- if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
- return;
- }
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return;
}
$this->application->delete();
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index db171db24..12d8bcbc3 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -67,7 +67,7 @@ public function refreshStoragesFromEvent()
public function refreshStorages()
{
$this->fileStorage = $this->resource->fileStorages()->get();
- $this->resource->refresh();
+ $this->resource->load('persistentStorages.resource');
}
public function getFilesProperty()
@@ -179,6 +179,10 @@ public function submitFileStorageDirectory()
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
+ // Validate paths to prevent command injection
+ validateShellSafePath($this->file_storage_directory_source, 'storage source path');
+ validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
+
\App\Models\LocalFileVolume::create([
'fs_path' => $this->file_storage_directory_source,
'mount_path' => $this->file_storage_directory_destination,
diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php
index 0ed1347f8..8bf3c7438 100644
--- a/app/Livewire/Project/Shared/Danger.php
+++ b/app/Livewire/Project/Shared/Danger.php
@@ -3,13 +3,10 @@
namespace App\Livewire\Project\Shared;
use App\Jobs\DeleteResourceJob;
-use App\Models\InstanceSettings;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -93,12 +90,8 @@ public function mount()
public function delete($password)
{
- if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
- return;
- }
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return;
}
if (! $this->resource) {
diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php
index 40291d2b0..ffd18b35c 100644
--- a/app/Livewire/Project/Shared/Destination.php
+++ b/app/Livewire/Project/Shared/Destination.php
@@ -5,12 +5,9 @@
use App\Actions\Application\StopApplicationOneServer;
use App\Actions\Docker\GetContainersStatus;
use App\Events\ApplicationStatusChanged;
-use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Illuminate\Support\Collection;
-use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -89,6 +86,11 @@ public function redeploy(int $network_id, int $server_id)
only_this_server: true,
no_questions_asked: true,
);
+ if ($result['status'] === 'queue_full') {
+ $this->dispatch('error', 'Deployment queue full', $result['message']);
+
+ return;
+ }
if ($result['status'] === 'skipped') {
$this->dispatch('success', 'Deployment skipped', $result['message']);
@@ -135,12 +137,8 @@ public function addServer(int $network_id, int $server_id)
public function removeServer(int $network_id, int $server_id, $password)
{
try {
- if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
- return;
- }
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return;
}
if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) {
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
index 5f5e12e0a..fa65e8bd2 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
@@ -2,8 +2,11 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
+use App\Models\Environment;
+use App\Models\Project;
use App\Traits\EnvironmentVariableAnalyzer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Computed;
use Livewire\Component;
class Add extends Component
@@ -56,6 +59,72 @@ public function mount()
$this->problematicVariables = self::getProblematicVariablesForFrontend();
}
+ #[Computed]
+ public function availableSharedVariables(): array
+ {
+ $team = currentTeam();
+ $result = [
+ 'team' => [],
+ 'project' => [],
+ 'environment' => [],
+ ];
+
+ // Early return if no team
+ if (! $team) {
+ return $result;
+ }
+
+ // Check if user can view team variables
+ try {
+ $this->authorize('view', $team);
+ $result['team'] = $team->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view team variables
+ }
+
+ // Get project variables if we have a project_uuid in route
+ $projectUuid = data_get($this->parameters, 'project_uuid');
+ if ($projectUuid) {
+ $project = Project::where('team_id', $team->id)
+ ->where('uuid', $projectUuid)
+ ->first();
+
+ if ($project) {
+ try {
+ $this->authorize('view', $project);
+ $result['project'] = $project->environment_variables()
+ ->pluck('key')
+ ->toArray();
+
+ // Get environment variables if we have an environment_uuid in route
+ $environmentUuid = data_get($this->parameters, 'environment_uuid');
+ if ($environmentUuid) {
+ $environment = $project->environments()
+ ->where('uuid', $environmentUuid)
+ ->first();
+
+ if ($environment) {
+ try {
+ $this->authorize('view', $environment);
+ $result['environment'] = $environment->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view environment variables
+ }
+ }
+ }
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view project variables
+ }
+ }
+ }
+
+ return $result;
+ }
+
public function submit()
{
$this->validate();
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index 3b8d244cc..2030f631e 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -2,11 +2,14 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
+use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
+use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Computed;
use Livewire\Component;
class Show extends Component
@@ -184,6 +187,7 @@ public function submit()
$this->serialize();
$this->syncData(true);
+ $this->syncData(false);
$this->dispatch('success', 'Environment variable updated.');
$this->dispatch('envsUpdated');
$this->dispatch('configurationChanged');
@@ -192,6 +196,72 @@ public function submit()
}
}
+ #[Computed]
+ public function availableSharedVariables(): array
+ {
+ $team = currentTeam();
+ $result = [
+ 'team' => [],
+ 'project' => [],
+ 'environment' => [],
+ ];
+
+ // Early return if no team
+ if (! $team) {
+ return $result;
+ }
+
+ // Check if user can view team variables
+ try {
+ $this->authorize('view', $team);
+ $result['team'] = $team->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view team variables
+ }
+
+ // Get project variables if we have a project_uuid in route
+ $projectUuid = data_get($this->parameters, 'project_uuid');
+ if ($projectUuid) {
+ $project = Project::where('team_id', $team->id)
+ ->where('uuid', $projectUuid)
+ ->first();
+
+ if ($project) {
+ try {
+ $this->authorize('view', $project);
+ $result['project'] = $project->environment_variables()
+ ->pluck('key')
+ ->toArray();
+
+ // Get environment variables if we have an environment_uuid in route
+ $environmentUuid = data_get($this->parameters, 'environment_uuid');
+ if ($environmentUuid) {
+ $environment = $project->environments()
+ ->where('uuid', $environmentUuid)
+ ->first();
+
+ if ($environment) {
+ try {
+ $this->authorize('view', $environment);
+ $result['environment'] = $environment->environment_variables()
+ ->pluck('key')
+ ->toArray();
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view environment variables
+ }
+ }
+ }
+ } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ // User not authorized to view project variables
+ }
+ }
+ }
+
+ return $result;
+ }
+
public function delete()
{
try {
diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php
index 304f7b411..f57563330 100644
--- a/app/Livewire/Project/Shared/GetLogs.php
+++ b/app/Livewire/Project/Shared/GetLogs.php
@@ -43,6 +43,10 @@ class GetLogs extends Component
public ?int $numberOfLines = 100;
+ public bool $expandByDefault = false;
+
+ public bool $collapsible = true;
+
public function mount()
{
if (! is_null($this->resource)) {
@@ -92,12 +96,33 @@ public function instantSave()
}
}
+ public function toggleTimestamps()
+ {
+ $previousValue = $this->showTimeStamps;
+ $this->showTimeStamps = ! $this->showTimeStamps;
+
+ try {
+ $this->instantSave();
+ $this->getLogs(true);
+ } catch (\Throwable $e) {
+ // Revert the flag to its previous value on failure
+ $this->showTimeStamps = $previousValue;
+
+ return handleError($e, $this);
+ }
+ }
+
+ public function toggleStreamLogs()
+ {
+ $this->streamLogs = ! $this->streamLogs;
+ }
+
public function getLogs($refresh = false)
{
if (! $this->server->isFunctional()) {
return;
}
- if (! $refresh && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) {
+ if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) {
return;
}
if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) {
diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php
index 47b3534a2..4ba961dfd 100644
--- a/app/Livewire/Project/Shared/ResourceOperations.php
+++ b/app/Livewire/Project/Shared/ResourceOperations.php
@@ -36,7 +36,7 @@ public function mount()
$parameters = get_route_parameters();
$this->projectUuid = data_get($parameters, 'project_uuid');
$this->environmentUuid = data_get($parameters, 'environment_uuid');
- $this->projects = Project::ownedByCurrentTeam()->get();
+ $this->projects = Project::ownedByCurrentTeamCached();
$this->servers = currentTeam()->servers->filter(fn ($server) => ! $server->isBuildServer());
}
diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php
index d7210c15d..2d6b76c25 100644
--- a/app/Livewire/Project/Shared/ScheduledTask/Add.php
+++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php
@@ -41,7 +41,7 @@ class Add extends Component
'command' => 'required|string',
'frequency' => 'required|string',
'container' => 'nullable|string',
- 'timeout' => 'required|integer|min:60|max:3600',
+ 'timeout' => 'required|integer|min:60|max:36000',
];
protected $validationAttributes = [
diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php
index 088de0a76..b1b34dd71 100644
--- a/app/Livewire/Project/Shared/ScheduledTask/Show.php
+++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php
@@ -40,7 +40,7 @@ class Show extends Component
#[Validate(['string', 'nullable'])]
public ?string $container = null;
- #[Validate(['integer', 'required', 'min:60', 'max:3600'])]
+ #[Validate(['integer', 'required', 'min:60', 'max:36000'])]
public $timeout = 300;
#[Locked]
@@ -72,7 +72,7 @@ public function mount(string $task_uuid, string $project_uuid, string $environme
} elseif ($service_uuid) {
$this->type = 'service';
$this->service_uuid = $service_uuid;
- $this->resource = Service::ownedByCurrentTeam()->where('uuid', $service_uuid)->firstOrFail();
+ $this->resource = Service::ownedByCurrentTeamCached()->where('uuid', $service_uuid)->firstOrFail();
}
$this->parameters = [
'environment_uuid' => $environment_uuid,
diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php
index 5970ec904..2091eca14 100644
--- a/app/Livewire/Project/Shared/Storages/Show.php
+++ b/app/Livewire/Project/Shared/Storages/Show.php
@@ -2,11 +2,8 @@
namespace App\Livewire\Project\Shared\Storages;
-use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Show extends Component
@@ -67,7 +64,7 @@ private function syncData(bool $toModel = false): void
public function mount()
{
$this->syncData(false);
- $this->isReadOnly = $this->storage->isReadOnlyVolume();
+ $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
}
public function submit()
@@ -84,12 +81,8 @@ public function delete($password)
{
$this->authorize('update', $this->resource);
- if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
- return;
- }
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return;
}
$this->storage->delete();
diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php
index de2deeed4..3c2abc84c 100644
--- a/app/Livewire/Project/Shared/Terminal.php
+++ b/app/Livewire/Project/Shared/Terminal.php
@@ -11,20 +11,6 @@ class Terminal extends Component
{
public bool $hasShell = true;
- public function getListeners()
- {
- $teamId = auth()->user()->currentTeam()->id;
-
- return [
- "echo-private:team.{$teamId},ApplicationStatusChanged" => 'closeTerminal',
- ];
- }
-
- public function closeTerminal()
- {
- $this->dispatch('reloadWindow');
- }
-
private function checkShellAvailability(Server $server, string $container): bool
{
$escapedContainer = escapeshellarg($container);
diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php
index 8d17bb557..dba1b4903 100644
--- a/app/Livewire/Server/Advanced.php
+++ b/app/Livewire/Server/Advanced.php
@@ -24,6 +24,9 @@ class Advanced extends Component
#[Validate(['integer', 'min:1'])]
public int $dynamicTimeout = 1;
+ #[Validate(['integer', 'min:1'])]
+ public int $deploymentQueueLimit = 25;
+
public function mount(string $server_uuid)
{
try {
@@ -43,12 +46,14 @@ public function syncData(bool $toModel = false)
$this->validate();
$this->server->settings->concurrent_builds = $this->concurrentBuilds;
$this->server->settings->dynamic_timeout = $this->dynamicTimeout;
+ $this->server->settings->deployment_queue_limit = $this->deploymentQueueLimit;
$this->server->settings->server_disk_usage_notification_threshold = $this->serverDiskUsageNotificationThreshold;
$this->server->settings->server_disk_usage_check_frequency = $this->serverDiskUsageCheckFrequency;
$this->server->settings->save();
} else {
$this->concurrentBuilds = $this->server->settings->concurrent_builds;
$this->dynamicTimeout = $this->server->settings->dynamic_timeout;
+ $this->deploymentQueueLimit = $this->server->settings->deployment_queue_limit;
$this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold;
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
}
diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php
index cf77664fe..5fd2ea4f7 100644
--- a/app/Livewire/Server/Create.php
+++ b/app/Livewire/Server/Create.php
@@ -17,7 +17,7 @@ class Create extends Component
public function mount()
{
- $this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
+ $this->private_keys = PrivateKey::ownedByCurrentTeamCached();
if (! isCloud()) {
$this->limit_reached = false;
diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php
index 8c2c54c99..27a6e7aca 100644
--- a/app/Livewire/Server/Delete.php
+++ b/app/Livewire/Server/Delete.php
@@ -3,11 +3,8 @@
namespace App\Livewire\Server;
use App\Actions\Server\DeleteServer;
-use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Delete extends Component
@@ -29,12 +26,8 @@ public function mount(string $server_uuid)
public function delete($password)
{
- if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
- return;
- }
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return;
}
try {
$this->authorize('delete', $this->server);
diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php
index 764e583cd..92094c950 100644
--- a/app/Livewire/Server/DockerCleanup.php
+++ b/app/Livewire/Server/DockerCleanup.php
@@ -31,6 +31,9 @@ class DockerCleanup extends Component
#[Validate('boolean')]
public bool $deleteUnusedNetworks = false;
+ #[Validate('boolean')]
+ public bool $disableApplicationImageRetention = false;
+
public function mount(string $server_uuid)
{
try {
@@ -52,6 +55,7 @@ public function syncData(bool $toModel = false)
$this->server->settings->docker_cleanup_threshold = $this->dockerCleanupThreshold;
$this->server->settings->delete_unused_volumes = $this->deleteUnusedVolumes;
$this->server->settings->delete_unused_networks = $this->deleteUnusedNetworks;
+ $this->server->settings->disable_application_image_retention = $this->disableApplicationImageRetention;
$this->server->settings->save();
} else {
$this->forceDockerCleanup = $this->server->settings->force_docker_cleanup;
@@ -59,6 +63,7 @@ public function syncData(bool $toModel = false)
$this->dockerCleanupThreshold = $this->server->settings->docker_cleanup_threshold;
$this->deleteUnusedVolumes = $this->server->settings->delete_unused_volumes;
$this->deleteUnusedNetworks = $this->server->settings->delete_unused_networks;
+ $this->disableApplicationImageRetention = $this->server->settings->disable_application_image_retention;
}
}
diff --git a/app/Livewire/Server/Index.php b/app/Livewire/Server/Index.php
index 74764960a..eb832d72f 100644
--- a/app/Livewire/Server/Index.php
+++ b/app/Livewire/Server/Index.php
@@ -12,7 +12,7 @@ class Index extends Component
public function mount()
{
- $this->servers = Server::ownedByCurrentTeam()->get();
+ $this->servers = Server::ownedByCurrentTeamCached();
}
public function render()
diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php
index 4e3481912..cd9cfcba6 100644
--- a/app/Livewire/Server/Navbar.php
+++ b/app/Livewire/Server/Navbar.php
@@ -6,7 +6,7 @@
use App\Actions\Proxy\StartProxy;
use App\Actions\Proxy\StopProxy;
use App\Enums\ProxyTypes;
-use App\Jobs\CheckTraefikVersionForServerJob;
+use App\Jobs\RestartProxyJob;
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -28,6 +28,10 @@ class Navbar extends Component
public ?string $proxyStatus = 'unknown';
+ public ?string $lastNotifiedStatus = null;
+
+ public bool $restartInitiated = false;
+
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
@@ -62,19 +66,19 @@ public function restart()
{
try {
$this->authorize('manageProxy', $this->server);
- StopProxy::run($this->server, restarting: true);
- $this->server->proxy->force_stop = false;
- $this->server->save();
-
- $activity = StartProxy::run($this->server, force: true, restarting: true);
- $this->dispatch('activityMonitor', $activity->id);
-
- // Check Traefik version after restart to provide immediate feedback
- if ($this->server->proxyType() === ProxyTypes::TRAEFIK->value) {
- CheckTraefikVersionForServerJob::dispatch($this->server, get_traefik_versions());
+ // Prevent duplicate restart calls
+ if ($this->restartInitiated) {
+ return;
}
+ $this->restartInitiated = true;
+
+ // Always use background job for all servers
+ RestartProxyJob::dispatch($this->server);
+
} catch (\Throwable $e) {
+ $this->restartInitiated = false;
+
return handleError($e, $this);
}
}
@@ -128,12 +132,27 @@ public function checkProxyStatus()
}
}
- public function showNotification()
+ public function showNotification($event = null)
{
$previousStatus = $this->proxyStatus;
$this->server->refresh();
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
+ // If event contains activityId, open activity monitor
+ if ($event && isset($event['activityId'])) {
+ $this->dispatch('activityMonitor', $event['activityId']);
+ }
+
+ // Reset restart flag when proxy reaches a stable state
+ if (in_array($this->proxyStatus, ['running', 'exited', 'error'])) {
+ $this->restartInitiated = false;
+ }
+
+ // Skip notification if we already notified about this status (prevents duplicates)
+ if ($this->lastNotifiedStatus === $this->proxyStatus) {
+ return;
+ }
+
switch ($this->proxyStatus) {
case 'running':
$this->loadProxyConfiguration();
@@ -141,6 +160,7 @@ public function showNotification()
// Don't show during normal start/restart flows (starting, restarting, stopping)
if (in_array($previousStatus, ['exited', 'stopped', 'unknown', null])) {
$this->dispatch('success', 'Proxy is running.');
+ $this->lastNotifiedStatus = $this->proxyStatus;
}
break;
case 'exited':
@@ -148,19 +168,30 @@ public function showNotification()
// Don't show during normal stop/restart flows (stopping, restarting)
if (in_array($previousStatus, ['running'])) {
$this->dispatch('info', 'Proxy has exited.');
+ $this->lastNotifiedStatus = $this->proxyStatus;
}
break;
case 'stopping':
- $this->dispatch('info', 'Proxy is stopping.');
+ // $this->dispatch('info', 'Proxy is stopping.');
+ $this->lastNotifiedStatus = $this->proxyStatus;
break;
case 'starting':
- $this->dispatch('info', 'Proxy is starting.');
+ // $this->dispatch('info', 'Proxy is starting.');
+ $this->lastNotifiedStatus = $this->proxyStatus;
+ break;
+ case 'restarting':
+ // $this->dispatch('info', 'Proxy is restarting.');
+ $this->lastNotifiedStatus = $this->proxyStatus;
+ break;
+ case 'error':
+ $this->dispatch('error', 'Proxy restart failed. Check logs.');
+ $this->lastNotifiedStatus = $this->proxyStatus;
break;
case 'unknown':
- $this->dispatch('info', 'Proxy status is unknown.');
+ // Don't notify for unknown status - too noisy
break;
default:
- $this->dispatch('info', 'Proxy status updated.');
+ // Don't notify for other statuses
break;
}
diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php
index c92f73f17..1a14baf89 100644
--- a/app/Livewire/Server/Proxy.php
+++ b/app/Livewire/Server/Proxy.php
@@ -79,20 +79,19 @@ protected function getTraefikVersions(): ?array
// Load from global cached helper (Redis + filesystem)
$versionsData = get_versions_data();
- $this->cachedVersionsFile = $versionsData;
-
if (! $versionsData) {
return null;
}
+ $this->cachedVersionsFile = $versionsData;
$traefikVersions = data_get($versionsData, 'traefik');
return is_array($traefikVersions) ? $traefikVersions : null;
}
- public function getConfigurationFilePathProperty()
+ public function getConfigurationFilePathProperty(): string
{
- return $this->server->proxyPath().'docker-compose.yml';
+ return rtrim($this->server->proxyPath(), '/').'/docker-compose.yml';
}
public function changeProxy()
diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
index f377bbeb9..c67591cf5 100644
--- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
+++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
@@ -25,13 +25,25 @@ public function delete(string $fileName)
$this->authorize('update', $this->server);
$proxy_path = $this->server->proxyPath();
$proxy_type = $this->server->proxyType();
+
+ // Decode filename: pipes are used to encode dots for Livewire property binding
+ // (e.g., 'my|service.yaml' -> 'my.service.yaml')
+ // This must happen BEFORE validation because validateShellSafePath() correctly
+ // rejects pipe characters as dangerous shell metacharacters
$file = str_replace('|', '.', $fileName);
+
+ // Validate filename to prevent command injection
+ validateShellSafePath($file, 'proxy configuration filename');
+
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
$this->dispatch('error', 'Cannot delete Caddyfile.');
return;
}
- instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $this->server);
+
+ $fullPath = "{$proxy_path}/dynamic/{$file}";
+ $escapedPath = escapeshellarg($fullPath);
+ instant_remote_process(["rm -f {$escapedPath}"], $this->server);
if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy();
}
diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
index eb2db1cbb..31a1dfc7e 100644
--- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
+++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
@@ -4,6 +4,7 @@
use App\Enums\ProxyTypes;
use App\Models\Server;
+use App\Rules\ValidProxyConfigFilename;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
@@ -38,9 +39,13 @@ public function addDynamicConfiguration()
try {
$this->authorize('update', $this->server);
$this->validate([
- 'fileName' => 'required',
+ 'fileName' => ['required', new ValidProxyConfigFilename],
'value' => 'required',
]);
+
+ // Additional security validation to prevent command injection
+ validateShellSafePath($this->fileName, 'proxy configuration filename');
+
if (data_get($this->parameters, 'server_uuid')) {
$this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first();
}
@@ -65,8 +70,10 @@ public function addDynamicConfiguration()
}
$proxy_path = $this->server->proxyPath();
$file = "{$proxy_path}/dynamic/{$this->fileName}";
+ $escapedFile = escapeshellarg($file);
+
if ($this->newFile) {
- $exists = instant_remote_process(["test -f $file && echo 1 || echo 0"], $this->server);
+ $exists = instant_remote_process(["test -f {$escapedFile} && echo 1 || echo 0"], $this->server);
if ($exists == 1) {
$this->dispatch('error', 'File already exists');
@@ -80,7 +87,7 @@ public function addDynamicConfiguration()
}
$base64_value = base64_encode($this->value);
instant_remote_process([
- "echo '{$base64_value}' | base64 -d | tee {$file} > /dev/null",
+ "echo '{$base64_value}' | base64 -d | tee {$escapedFile} > /dev/null",
], $this->server);
if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy();
diff --git a/app/Livewire/Server/Security/TerminalAccess.php b/app/Livewire/Server/Security/TerminalAccess.php
index 284eea7dd..310edcfe4 100644
--- a/app/Livewire/Server/Security/TerminalAccess.php
+++ b/app/Livewire/Server/Security/TerminalAccess.php
@@ -2,11 +2,8 @@
namespace App\Livewire\Server\Security;
-use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -44,13 +41,9 @@ public function toggleTerminal($password)
throw new \Exception('Only team administrators and owners can modify terminal access.');
}
- // Verify password unless two-step confirmation is disabled
- if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
- return;
- }
+ // Verify password
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return;
}
// Toggle the terminal setting
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 4626a9135..7a4a1c480 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -5,9 +5,12 @@
use App\Actions\Server\StartSentinel;
use App\Actions\Server\StopSentinel;
use App\Events\ServerReachabilityChanged;
+use App\Models\CloudProviderToken;
use App\Models\Server;
+use App\Services\HetznerService;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -73,6 +76,17 @@ class Show extends Component
public bool $isValidating = false;
+ // Hetzner linking properties
+ public Collection $availableHetznerTokens;
+
+ public ?int $selectedHetznerTokenId = null;
+
+ public ?array $matchedHetznerServer = null;
+
+ public ?string $hetznerSearchError = null;
+
+ public bool $hetznerNoMatchFound = false;
+
public function getListeners()
{
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
@@ -150,6 +164,9 @@ public function mount(string $server_uuid)
$this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->isValidating = $this->server->is_validating ?? false;
+ // Load Hetzner tokens for linking
+ $this->loadHetznerTokens();
+
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -465,6 +482,98 @@ public function submit()
}
}
+ public function loadHetznerTokens(): void
+ {
+ $this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam()
+ ->where('provider', 'hetzner')
+ ->get();
+ }
+
+ public function searchHetznerServer(): void
+ {
+ $this->hetznerSearchError = null;
+ $this->hetznerNoMatchFound = false;
+ $this->matchedHetznerServer = null;
+
+ if (! $this->selectedHetznerTokenId) {
+ $this->hetznerSearchError = 'Please select a Hetzner token.';
+
+ return;
+ }
+
+ try {
+ $this->authorize('update', $this->server);
+
+ $token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
+ if (! $token) {
+ $this->hetznerSearchError = 'Invalid token selected.';
+
+ return;
+ }
+
+ $hetznerService = new HetznerService($token->token);
+ $matched = $hetznerService->findServerByIp($this->server->ip);
+
+ if ($matched) {
+ $this->matchedHetznerServer = $matched;
+ } else {
+ $this->hetznerNoMatchFound = true;
+ }
+ } catch (\Throwable $e) {
+ $this->hetznerSearchError = 'Failed to search Hetzner servers: '.$e->getMessage();
+ }
+ }
+
+ public function linkToHetzner()
+ {
+ if (! $this->matchedHetznerServer) {
+ $this->dispatch('error', 'No Hetzner server selected.');
+
+ return;
+ }
+
+ try {
+ $this->authorize('update', $this->server);
+
+ $token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
+ if (! $token) {
+ $this->dispatch('error', 'Invalid token selected.');
+
+ return;
+ }
+
+ // Verify the server exists and is accessible with the token
+ $hetznerService = new HetznerService($token->token);
+ $serverData = $hetznerService->getServer($this->matchedHetznerServer['id']);
+
+ if (empty($serverData)) {
+ $this->dispatch('error', 'Could not find Hetzner server with ID: '.$this->matchedHetznerServer['id']);
+
+ return;
+ }
+
+ // Update the server with Hetzner details
+ $this->server->update([
+ 'cloud_provider_token_id' => $this->selectedHetznerTokenId,
+ 'hetzner_server_id' => $this->matchedHetznerServer['id'],
+ 'hetzner_server_status' => $serverData['status'] ?? null,
+ ]);
+
+ $this->hetznerServerStatus = $serverData['status'] ?? null;
+
+ // Clear the linking state
+ $this->matchedHetznerServer = null;
+ $this->selectedHetznerTokenId = null;
+ $this->hetznerNoMatchFound = false;
+ $this->hetznerSearchError = null;
+
+ $this->dispatch('success', 'Server successfully linked to Hetzner Cloud!');
+ $this->dispatch('refreshServerShow');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
public function render()
{
return view('livewire.server.show');
diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php
index c2dcd877b..1a5bd381b 100644
--- a/app/Livewire/Server/ValidateAndInstall.php
+++ b/app/Livewire/Server/ValidateAndInstall.php
@@ -206,6 +206,9 @@ public function validateDockerVersion()
if (! $proxyShouldRun) {
return;
}
+ // Ensure networks exist BEFORE dispatching async proxy startup
+ // This prevents race condition where proxy tries to start before networks are created
+ instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
StartProxy::dispatch($this->server);
} else {
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php
index be38ae1d8..b011d2dc1 100644
--- a/app/Livewire/Settings/Advanced.php
+++ b/app/Livewire/Settings/Advanced.php
@@ -5,8 +5,6 @@
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Rules\ValidIpOrCidr;
-use Auth;
-use Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -157,9 +155,7 @@ public function instantSave()
public function toggleTwoStepConfirmation($password): bool
{
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
+ if (! verifyPasswordConfirmation($password, $this)) {
return false;
}
diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php
index 96f13b173..7a96eabb2 100644
--- a/app/Livewire/Settings/Index.php
+++ b/app/Livewire/Settings/Index.php
@@ -44,6 +44,8 @@ class Index extends Component
public bool $forceSaveDomains = false;
+ public $buildActivityId = null;
+
public function render()
{
return view('livewire.settings.index');
@@ -151,4 +153,37 @@ public function submit()
return handleError($e, $this);
}
}
+
+ public function buildHelperImage()
+ {
+ try {
+ if (! isDev()) {
+ $this->dispatch('error', 'Building helper image is only available in development mode.');
+
+ return;
+ }
+
+ $version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
+ if (empty($version)) {
+ $this->dispatch('error', 'Please specify a version to build.');
+
+ return;
+ }
+
+ $buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile .";
+
+ $activity = remote_process(
+ command: [$buildCommand],
+ server: $this->server,
+ type: 'build-helper-image'
+ );
+
+ $this->buildActivityId = $activity->id;
+ $this->dispatch('activityMonitor', $activity->id);
+
+ $this->dispatch('success', "Building coolify-helper:{$version}...");
+ } catch (\Exception $e) {
+ return handleError($e, $this);
+ }
+ }
}
diff --git a/app/Livewire/SharedVariables/Environment/Index.php b/app/Livewire/SharedVariables/Environment/Index.php
index 3673a3882..6685c5c99 100644
--- a/app/Livewire/SharedVariables/Environment/Index.php
+++ b/app/Livewire/SharedVariables/Environment/Index.php
@@ -12,7 +12,7 @@ class Index extends Component
public function mount()
{
- $this->projects = Project::ownedByCurrentTeam()->get();
+ $this->projects = Project::ownedByCurrentTeamCached();
}
public function render()
diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php
index bee757a64..0bdc1503f 100644
--- a/app/Livewire/SharedVariables/Environment/Show.php
+++ b/app/Livewire/SharedVariables/Environment/Show.php
@@ -5,6 +5,7 @@
use App\Models\Application;
use App\Models\Project;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Show extends Component
@@ -19,7 +20,11 @@ class Show extends Component
public array $parameters;
- protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey', 'environmentVariableDeleted' => '$refresh'];
+ public string $view = 'normal';
+
+ public ?string $variables = null;
+
+ protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
public function saveKey($data)
{
@@ -39,6 +44,7 @@ public function saveKey($data)
'team_id' => currentTeam()->id,
]);
$this->environment->refresh();
+ $this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -49,6 +55,120 @@ public function mount()
$this->parameters = get_route_parameters();
$this->project = Project::ownedByCurrentTeam()->where('uuid', request()->route('project_uuid'))->firstOrFail();
$this->environment = $this->project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail();
+ $this->getDevView();
+ }
+
+ public function switch()
+ {
+ $this->authorize('view', $this->environment);
+ $this->view = $this->view === 'normal' ? 'dev' : 'normal';
+ $this->getDevView();
+ }
+
+ public function getDevView()
+ {
+ $this->variables = $this->formatEnvironmentVariables($this->environment->environment_variables->sortBy('key'));
+ }
+
+ private function formatEnvironmentVariables($variables)
+ {
+ return $variables->map(function ($item) {
+ if ($item->is_shown_once) {
+ return "$item->key=(Locked Secret, delete and add again to change)";
+ }
+ if ($item->is_multiline) {
+ return "$item->key=(Multiline environment variable, edit in normal view)";
+ }
+
+ return "$item->key=$item->value";
+ })->join("\n");
+ }
+
+ public function submit()
+ {
+ try {
+ $this->authorize('update', $this->environment);
+ $this->handleBulkSubmit();
+ $this->getDevView();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ } finally {
+ $this->refreshEnvs();
+ }
+ }
+
+ private function handleBulkSubmit()
+ {
+ $variables = parseEnvFormatToArray($this->variables);
+ $changesMade = false;
+
+ DB::transaction(function () use ($variables, &$changesMade) {
+ // Delete removed variables
+ $deletedCount = $this->deleteRemovedVariables($variables);
+ if ($deletedCount > 0) {
+ $changesMade = true;
+ }
+
+ // Update or create variables
+ $updatedCount = $this->updateOrCreateVariables($variables);
+ if ($updatedCount > 0) {
+ $changesMade = true;
+ }
+ });
+
+ // Only dispatch success after transaction has committed
+ if ($changesMade) {
+ $this->dispatch('success', 'Environment variables updated.');
+ }
+ }
+
+ private function deleteRemovedVariables($variables)
+ {
+ $variablesToDelete = $this->environment->environment_variables()->whereNotIn('key', array_keys($variables))->get();
+
+ if ($variablesToDelete->isEmpty()) {
+ return 0;
+ }
+
+ $this->environment->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
+
+ return $variablesToDelete->count();
+ }
+
+ private function updateOrCreateVariables($variables)
+ {
+ $count = 0;
+ foreach ($variables as $key => $value) {
+ $found = $this->environment->environment_variables()->where('key', $key)->first();
+
+ if ($found) {
+ if (! $found->is_shown_once && ! $found->is_multiline) {
+ if ($found->value !== $value) {
+ $found->value = $value;
+ $found->save();
+ $count++;
+ }
+ }
+ } else {
+ $this->environment->environment_variables()->create([
+ 'key' => $key,
+ 'value' => $value,
+ 'is_multiline' => false,
+ 'is_literal' => false,
+ 'type' => 'environment',
+ 'team_id' => currentTeam()->id,
+ ]);
+ $count++;
+ }
+ }
+
+ return $count;
+ }
+
+ public function refreshEnvs()
+ {
+ $this->environment->refresh();
+ $this->getDevView();
}
public function render()
diff --git a/app/Livewire/SharedVariables/Project/Index.php b/app/Livewire/SharedVariables/Project/Index.php
index 570da74d3..58929bade 100644
--- a/app/Livewire/SharedVariables/Project/Index.php
+++ b/app/Livewire/SharedVariables/Project/Index.php
@@ -12,7 +12,7 @@ class Index extends Component
public function mount()
{
- $this->projects = Project::ownedByCurrentTeam()->get();
+ $this->projects = Project::ownedByCurrentTeamCached();
}
public function render()
diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php
index 712a9960b..b205ea1ec 100644
--- a/app/Livewire/SharedVariables/Project/Show.php
+++ b/app/Livewire/SharedVariables/Project/Show.php
@@ -4,6 +4,7 @@
use App\Models\Project;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Show extends Component
@@ -12,7 +13,11 @@ class Show extends Component
public Project $project;
- protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh'];
+ public string $view = 'normal';
+
+ public ?string $variables = null;
+
+ protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
public function saveKey($data)
{
@@ -32,6 +37,7 @@ public function saveKey($data)
'team_id' => currentTeam()->id,
]);
$this->project->refresh();
+ $this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -46,6 +52,114 @@ public function mount()
return redirect()->route('dashboard');
}
$this->project = $project;
+ $this->getDevView();
+ }
+
+ public function switch()
+ {
+ $this->authorize('view', $this->project);
+ $this->view = $this->view === 'normal' ? 'dev' : 'normal';
+ $this->getDevView();
+ }
+
+ public function getDevView()
+ {
+ $this->variables = $this->formatEnvironmentVariables($this->project->environment_variables->sortBy('key'));
+ }
+
+ private function formatEnvironmentVariables($variables)
+ {
+ return $variables->map(function ($item) {
+ if ($item->is_shown_once) {
+ return "$item->key=(Locked Secret, delete and add again to change)";
+ }
+ if ($item->is_multiline) {
+ return "$item->key=(Multiline environment variable, edit in normal view)";
+ }
+
+ return "$item->key=$item->value";
+ })->join("\n");
+ }
+
+ public function submit()
+ {
+ try {
+ $this->authorize('update', $this->project);
+ $this->handleBulkSubmit();
+ $this->getDevView();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ } finally {
+ $this->refreshEnvs();
+ }
+ }
+
+ private function handleBulkSubmit()
+ {
+ $variables = parseEnvFormatToArray($this->variables);
+
+ $changesMade = DB::transaction(function () use ($variables) {
+ // Delete removed variables
+ $deletedCount = $this->deleteRemovedVariables($variables);
+
+ // Update or create variables
+ $updatedCount = $this->updateOrCreateVariables($variables);
+
+ return $deletedCount > 0 || $updatedCount > 0;
+ });
+
+ if ($changesMade) {
+ $this->dispatch('success', 'Environment variables updated.');
+ }
+ }
+
+ private function deleteRemovedVariables($variables)
+ {
+ $variablesToDelete = $this->project->environment_variables()->whereNotIn('key', array_keys($variables))->get();
+
+ if ($variablesToDelete->isEmpty()) {
+ return 0;
+ }
+
+ $this->project->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
+
+ return $variablesToDelete->count();
+ }
+
+ private function updateOrCreateVariables($variables)
+ {
+ $count = 0;
+ foreach ($variables as $key => $value) {
+ $found = $this->project->environment_variables()->where('key', $key)->first();
+
+ if ($found) {
+ if (! $found->is_shown_once && ! $found->is_multiline) {
+ if ($found->value !== $value) {
+ $found->value = $value;
+ $found->save();
+ $count++;
+ }
+ }
+ } else {
+ $this->project->environment_variables()->create([
+ 'key' => $key,
+ 'value' => $value,
+ 'is_multiline' => false,
+ 'is_literal' => false,
+ 'type' => 'project',
+ 'team_id' => currentTeam()->id,
+ ]);
+ $count++;
+ }
+ }
+
+ return $count;
+ }
+
+ public function refreshEnvs()
+ {
+ $this->project->refresh();
+ $this->getDevView();
}
public function render()
diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php
index 82473528c..e420686f0 100644
--- a/app/Livewire/SharedVariables/Team/Index.php
+++ b/app/Livewire/SharedVariables/Team/Index.php
@@ -4,6 +4,7 @@
use App\Models\Team;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Index extends Component
@@ -12,7 +13,11 @@ class Index extends Component
public Team $team;
- protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh'];
+ public string $view = 'normal';
+
+ public ?string $variables = null;
+
+ protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
public function saveKey($data)
{
@@ -32,6 +37,7 @@ public function saveKey($data)
'team_id' => currentTeam()->id,
]);
$this->team->refresh();
+ $this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -40,6 +46,119 @@ public function saveKey($data)
public function mount()
{
$this->team = currentTeam();
+ $this->getDevView();
+ }
+
+ public function switch()
+ {
+ $this->authorize('view', $this->team);
+ $this->view = $this->view === 'normal' ? 'dev' : 'normal';
+ $this->getDevView();
+ }
+
+ public function getDevView()
+ {
+ $this->variables = $this->formatEnvironmentVariables($this->team->environment_variables->sortBy('key'));
+ }
+
+ private function formatEnvironmentVariables($variables)
+ {
+ return $variables->map(function ($item) {
+ if ($item->is_shown_once) {
+ return "$item->key=(Locked Secret, delete and add again to change)";
+ }
+ if ($item->is_multiline) {
+ return "$item->key=(Multiline environment variable, edit in normal view)";
+ }
+
+ return "$item->key=$item->value";
+ })->join("\n");
+ }
+
+ public function submit()
+ {
+ try {
+ $this->authorize('update', $this->team);
+ $this->handleBulkSubmit();
+ $this->getDevView();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ } finally {
+ $this->refreshEnvs();
+ }
+ }
+
+ private function handleBulkSubmit()
+ {
+ $variables = parseEnvFormatToArray($this->variables);
+ $changesMade = false;
+
+ DB::transaction(function () use ($variables, &$changesMade) {
+ // Delete removed variables
+ $deletedCount = $this->deleteRemovedVariables($variables);
+ if ($deletedCount > 0) {
+ $changesMade = true;
+ }
+
+ // Update or create variables
+ $updatedCount = $this->updateOrCreateVariables($variables);
+ if ($updatedCount > 0) {
+ $changesMade = true;
+ }
+ });
+
+ if ($changesMade) {
+ $this->dispatch('success', 'Environment variables updated.');
+ }
+ }
+
+ private function deleteRemovedVariables($variables)
+ {
+ $variablesToDelete = $this->team->environment_variables()->whereNotIn('key', array_keys($variables))->get();
+
+ if ($variablesToDelete->isEmpty()) {
+ return 0;
+ }
+
+ $this->team->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
+
+ return $variablesToDelete->count();
+ }
+
+ private function updateOrCreateVariables($variables)
+ {
+ $count = 0;
+ foreach ($variables as $key => $value) {
+ $found = $this->team->environment_variables()->where('key', $key)->first();
+
+ if ($found) {
+ if (! $found->is_shown_once && ! $found->is_multiline) {
+ if ($found->value !== $value) {
+ $found->value = $value;
+ $found->save();
+ $count++;
+ }
+ }
+ } else {
+ $this->team->environment_variables()->create([
+ 'key' => $key,
+ 'value' => $value,
+ 'is_multiline' => false,
+ 'is_literal' => false,
+ 'type' => 'team',
+ 'team_id' => currentTeam()->id,
+ ]);
+ $count++;
+ }
+ }
+
+ return $count;
+ }
+
+ public function refreshEnvs()
+ {
+ $this->team->refresh();
+ $this->getDevView();
}
public function render()
diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php
index 4bd0b798a..0a38e6088 100644
--- a/app/Livewire/Source/Github/Change.php
+++ b/app/Livewire/Source/Github/Change.php
@@ -196,7 +196,7 @@ public function mount()
$github_app_uuid = request()->github_app_uuid;
$this->github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail();
$this->github_app->makeVisible(['client_secret', 'webhook_secret']);
- $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get();
+ $this->privateKeys = PrivateKey::ownedByCurrentTeamCached();
$this->applications = $this->github_app->applications;
$settings = instanceSettings();
diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php
index d97550693..d101d7b58 100644
--- a/app/Livewire/Storage/Form.php
+++ b/app/Livewire/Storage/Form.php
@@ -120,9 +120,16 @@ public function testConnection()
$this->storage->testConnection(shouldSave: true);
+ // Update component property to reflect the new validation status
+ $this->isUsable = $this->storage->is_usable;
+
return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.');
} catch (\Throwable $e) {
- $this->dispatch('error', 'Failed to create storage.', $e->getMessage());
+ // Refresh model and sync to get the latest state
+ $this->storage->refresh();
+ $this->isUsable = $this->storage->is_usable;
+
+ $this->dispatch('error', 'Failed to test connection.', $e->getMessage());
}
}
diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php
index bdea9a3b0..fdf3d0d28 100644
--- a/app/Livewire/Storage/Show.php
+++ b/app/Livewire/Storage/Show.php
@@ -3,10 +3,13 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Show extends Component
{
+ use AuthorizesRequests;
+
public $storage = null;
public function mount()
@@ -15,6 +18,7 @@ public function mount()
if (! $this->storage) {
abort(404);
}
+ $this->authorize('view', $this->storage);
}
public function render()
diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php
index 6d6915ae2..c8d44d42b 100644
--- a/app/Livewire/Team/AdminView.php
+++ b/app/Livewire/Team/AdminView.php
@@ -2,10 +2,7 @@
namespace App\Livewire\Team;
-use App\Models\InstanceSettings;
use App\Models\User;
-use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class AdminView extends Component
@@ -58,12 +55,8 @@ public function delete($id, $password)
return redirect()->route('dashboard');
}
- if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
- if (! Hash::check($password, Auth::user()->password)) {
- $this->addError('password', 'The provided password is incorrect.');
-
- return;
- }
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return;
}
if (! auth()->user()->isInstanceAdmin()) {
diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php
index e50085c64..36bee2a23 100644
--- a/app/Livewire/Upgrade.php
+++ b/app/Livewire/Upgrade.php
@@ -4,24 +4,31 @@
use App\Actions\Server\UpdateCoolify;
use App\Models\InstanceSettings;
+use App\Models\Server;
use Livewire\Component;
class Upgrade extends Component
{
- public bool $showProgress = false;
-
public bool $updateInProgress = false;
public bool $isUpgradeAvailable = false;
public string $latestVersion = '';
+ public string $currentVersion = '';
+
protected $listeners = ['updateAvailable' => 'checkUpdate'];
+ public function mount()
+ {
+ $this->currentVersion = config('constants.coolify.version');
+ }
+
public function checkUpdate()
{
try {
$this->latestVersion = get_latest_version_of_coolify();
+ $this->currentVersion = config('constants.coolify.version');
$this->isUpgradeAvailable = data_get(InstanceSettings::get(), 'new_version_available', false);
if (isDev()) {
$this->isUpgradeAvailable = true;
@@ -43,4 +50,71 @@ public function upgrade()
return handleError($e, $this);
}
}
+
+ public function getUpgradeStatus(): array
+ {
+ // Only root team members can view upgrade status
+ if (auth()->user()?->currentTeam()?->id !== 0) {
+ return ['status' => 'none'];
+ }
+
+ $server = Server::find(0);
+ if (! $server) {
+ return ['status' => 'none'];
+ }
+
+ $statusFile = '/data/coolify/source/.upgrade-status';
+
+ try {
+ $content = instant_remote_process(
+ ["cat {$statusFile} 2>/dev/null || echo ''"],
+ $server,
+ false
+ );
+ $content = trim($content ?? '');
+ } catch (\Throwable $e) {
+ return ['status' => 'none'];
+ }
+
+ if (empty($content)) {
+ return ['status' => 'none'];
+ }
+
+ $parts = explode('|', $content);
+ if (count($parts) < 3) {
+ return ['status' => 'none'];
+ }
+
+ [$step, $message, $timestamp] = $parts;
+
+ // Check if status is stale (older than 10 minutes)
+ try {
+ $statusTime = new \DateTime($timestamp);
+ $now = new \DateTime;
+ $diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60;
+
+ if ($diffMinutes > 10) {
+ return ['status' => 'none'];
+ }
+ } catch (\Throwable $e) {
+ return ['status' => 'none'];
+ }
+
+ if ($step === 'error') {
+ return [
+ 'status' => 'error',
+ 'step' => 0,
+ 'message' => $message,
+ ];
+ }
+
+ $stepInt = (int) $step;
+ $status = $stepInt >= 6 ? 'complete' : 'in_progress';
+
+ return [
+ 'status' => $status,
+ 'step' => $stepInt,
+ 'message' => $message,
+ ];
+ }
}
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 821c69bca..5006d0ff8 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -338,11 +338,25 @@ public static function ownedByCurrentTeamAPI(int $teamId)
return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name');
}
+ /**
+ * Get query builder for applications owned by current team.
+ * If you need all applications without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return Application::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
+ /**
+ * Get all applications owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return Application::ownedByCurrentTeam()->get();
+ });
+ }
+
public function getContainersToStop(Server $server, bool $previewDeployments = false): array
{
$containers = $previewDeployments
@@ -1035,7 +1049,7 @@ public function isLogDrainEnabled()
public function isConfigurationChanged(bool $save = false)
{
- $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets);
+ $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets.$this->settings->inject_build_args_to_dockerfile.$this->settings->include_source_commit_in_build);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
} else {
@@ -1500,10 +1514,10 @@ public function oldRawParser()
instant_remote_process($commands, $this->destination->server, false);
}
- public function parse(int $pull_request_id = 0, ?int $preview_id = null)
+ public function parse(int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null)
{
if ((int) $this->compose_parsing_version >= 3) {
- return applicationParser($this, $pull_request_id, $preview_id);
+ return applicationParser($this, $pull_request_id, $preview_id, $commit);
} elseif ($this->docker_compose_raw) {
return parseDockerComposeFile(resource: $this, isNew: false, pull_request_id: $pull_request_id, preview_id: $preview_id);
} else {
@@ -1511,9 +1525,11 @@ public function parse(int $pull_request_id = 0, ?int $preview_id = null)
}
}
- public function loadComposeFile($isInit = false)
+ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null)
{
- $initialDockerComposeLocation = $this->docker_compose_location;
+ // Use provided restore values or capture current values as fallback
+ $initialDockerComposeLocation = $restoreDockerComposeLocation ?? $this->docker_compose_location;
+ $initialBaseDirectory = $restoreBaseDirectory ?? $this->base_directory;
if ($isInit && $this->docker_compose_raw) {
return;
}
@@ -1580,6 +1596,7 @@ public function loadComposeFile($isInit = false)
throw new \RuntimeException($e->getMessage());
} finally {
$this->docker_compose_location = $initialDockerComposeLocation;
+ $this->base_directory = $initialBaseDirectory;
$this->save();
$commands = collect([
"rm -rf /tmp/{$uuid}",
diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php
index 721b22216..04ce6274a 100644
--- a/app/Models/ApplicationPreview.php
+++ b/app/Models/ApplicationPreview.php
@@ -145,11 +145,13 @@ public function generate_preview_fqdn_compose()
$template = $this->application->preview_url_template;
$host = $url->getHost();
$schema = $url->getScheme();
+ $portInt = $url->getPort();
+ $port = $portInt !== null ? ':'.$portInt : '';
$random = new Cuid2;
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
- $preview_fqdn = "$schema://$preview_fqdn";
+ $preview_fqdn = "$schema://$preview_fqdn{$port}";
$preview_domains[] = $preview_fqdn;
}
diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php
index 26cb937b3..f40977b3e 100644
--- a/app/Models/ApplicationSetting.php
+++ b/app/Models/ApplicationSetting.php
@@ -15,6 +15,8 @@ class ApplicationSetting extends Model
'is_container_label_escape_enabled' => 'boolean',
'is_container_label_readonly_enabled' => 'boolean',
'use_build_secrets' => 'boolean',
+ 'inject_build_args_to_dockerfile' => 'boolean',
+ 'include_source_commit_in_build' => 'boolean',
'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean',
'is_debug_enabled' => 'boolean',
@@ -23,6 +25,7 @@ class ApplicationSetting extends Model
'is_git_submodules_enabled' => 'boolean',
'is_git_lfs_enabled' => 'boolean',
'is_git_shallow_clone_enabled' => 'boolean',
+ 'docker_images_to_keep' => 'integer',
];
protected $guarded = [];
diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php
index 607040269..700ab0992 100644
--- a/app/Models/CloudProviderToken.php
+++ b/app/Models/CloudProviderToken.php
@@ -2,9 +2,7 @@
namespace App\Models;
-use Illuminate\Database\Eloquent\Model;
-
-class CloudProviderToken extends Model
+class CloudProviderToken extends BaseModel
{
protected $guarded = [];
diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php
index 80399a16b..895dc1c43 100644
--- a/app/Models/EnvironmentVariable.php
+++ b/app/Models/EnvironmentVariable.php
@@ -65,6 +65,8 @@ protected static function booted()
'value' => $environment_variable->value,
'is_multiline' => $environment_variable->is_multiline ?? false,
'is_literal' => $environment_variable->is_literal ?? false,
+ 'is_runtime' => $environment_variable->is_runtime ?? false,
+ 'is_buildtime' => $environment_variable->is_buildtime ?? false,
'resourceable_type' => Application::class,
'resourceable_id' => $environment_variable->resourceable_id,
'is_preview' => true,
@@ -190,11 +192,11 @@ private function get_real_environment_variables(?string $environment_variable =
return $environment_variable;
}
foreach ($sharedEnvsFound as $sharedEnv) {
- $type = str($sharedEnv)->match('/(.*?)\./');
+ $type = str($sharedEnv)->trim()->match('/(.*?)\./');
if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) {
continue;
}
- $variable = str($sharedEnv)->match('/\.(.*)/');
+ $variable = str($sharedEnv)->trim()->match('/\.(.*)/');
if ($type->value() === 'environment') {
$id = $resource->environment->id;
} elseif ($type->value() === 'project') {
@@ -231,7 +233,7 @@ private function set_environment_variables(?string $environment_variable = null)
$environment_variable = trim($environment_variable);
$type = str($environment_variable)->after('{{')->before('.')->value;
if (str($environment_variable)->startsWith('{{'.$type) && str($environment_variable)->endsWith('}}')) {
- return encrypt((string) str($environment_variable)->replace(' ', ''));
+ return encrypt($environment_variable);
}
return encrypt($environment_variable);
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index 376ea9c5e..9d7095cb5 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -61,9 +61,14 @@ public function loadStorageOnServer()
$path = $path->after('.');
$path = $workdir.$path;
}
- $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
+
+ // Validate and escape path to prevent command injection
+ validateShellSafePath($path, 'storage path');
+ $escapedPath = escapeshellarg($path);
+
+ $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
- $content = instant_remote_process(["cat $path"], $server, false);
+ $content = instant_remote_process(["cat {$escapedPath}"], $server, false);
// Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
$content = '[binary file]';
@@ -91,14 +96,19 @@ public function deleteStorageOnServer()
$path = $path->after('.');
$path = $workdir.$path;
}
- $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
- $isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server);
+
+ // Validate and escape path to prevent command injection
+ validateShellSafePath($path, 'storage path');
+ $escapedPath = escapeshellarg($path);
+
+ $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
+ $isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($path && $path != '/' && $path != '.' && $path != '..') {
if ($isFile === 'OK') {
- $commands->push("rm -rf $path > /dev/null 2>&1 || true");
+ $commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
} elseif ($isDir === 'OK') {
- $commands->push("rm -rf $path > /dev/null 2>&1 || true");
- $commands->push("rmdir $path > /dev/null 2>&1 || true");
+ $commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
+ $commands->push("rmdir {$escapedPath} > /dev/null 2>&1 || true");
}
}
if ($commands->count() > 0) {
@@ -135,10 +145,15 @@ public function saveStorageOnServer()
$path = $path->after('.');
$path = $workdir.$path;
}
- $isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
- $isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server);
+
+ // Validate and escape path to prevent command injection
+ validateShellSafePath($path, 'storage path');
+ $escapedPath = escapeshellarg($path);
+
+ $isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
+ $isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK' && $this->is_directory) {
- $content = instant_remote_process(["cat $path"], $server, false);
+ $content = instant_remote_process(["cat {$escapedPath}"], $server, false);
$this->is_directory = false;
$this->content = $content;
$this->save();
@@ -151,8 +166,8 @@ public function saveStorageOnServer()
throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file.
Please delete the directory on the server or mark it as directory.');
}
instant_remote_process([
- "rm -fr $path",
- "touch $path",
+ "rm -fr {$escapedPath}",
+ "touch {$escapedPath}",
], $server, false);
FileStorageChanged::dispatch(data_get($server, 'team_id'));
}
@@ -161,19 +176,19 @@ public function saveStorageOnServer()
$chown = data_get($this, 'chown');
if ($content) {
$content = base64_encode($content);
- $commands->push("echo '$content' | base64 -d | tee $path > /dev/null");
+ $commands->push("echo '$content' | base64 -d | tee {$escapedPath} > /dev/null");
} else {
- $commands->push("touch $path");
+ $commands->push("touch {$escapedPath}");
}
- $commands->push("chmod +x $path");
+ $commands->push("chmod +x {$escapedPath}");
if ($chown) {
- $commands->push("chown $chown $path");
+ $commands->push("chown $chown {$escapedPath}");
}
if ($chmod) {
- $commands->push("chmod $chmod $path");
+ $commands->push("chmod $chmod {$escapedPath}");
}
} elseif ($isDir === 'NOK' && $this->is_directory) {
- $commands->push("mkdir -p $path > /dev/null 2>&1 || true");
+ $commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true");
}
return instant_remote_process($commands, $server);
@@ -194,6 +209,23 @@ public function scopeWherePlainMountPath($query, $path)
return $query->get()->where('plain_mount_path', $path);
}
+ // Check if this volume belongs to a service resource
+ public function isServiceResource(): bool
+ {
+ return in_array($this->resource_type, [
+ 'App\Models\ServiceApplication',
+ 'App\Models\ServiceDatabase',
+ ]);
+ }
+
+ // Determine if this volume should be read-only in the UI
+ // File/directory mounts can be edited even for services
+ public function shouldBeReadOnlyInUI(): bool
+ {
+ // Check for explicit :ro flag in compose (existing logic)
+ return $this->isReadOnlyVolume();
+ }
+
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
@@ -224,22 +256,40 @@ public function isReadOnlyVolume(): bool
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
+ // Note: We match on mount_path (container path) only, since fs_path gets transformed
+ // from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
$parts = explode(':', $volume);
- // Check if this volume matches our fs_path and mount_path
+ // Check if this volume matches our mount_path
if (count($parts) >= 2) {
- $hostPath = $parts[0];
$containerPath = $parts[1];
$options = $parts[2] ?? null;
- // Match based on fs_path and mount_path
- if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) {
+ // Match based on mount_path
+ // Remove leading slash from mount_path if present for comparison
+ $mountPath = str($this->mount_path)->ltrim('/')->toString();
+ $containerPathClean = str($containerPath)->ltrim('/')->toString();
+
+ if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $options === 'ro';
}
}
+ } elseif (is_array($volume)) {
+ // Long-form syntax: { type: bind, source: ..., target: ..., read_only: true }
+ $containerPath = data_get($volume, 'target');
+ $readOnly = data_get($volume, 'read_only', false);
+
+ // Match based on mount_path
+ // Remove leading slash from mount_path if present for comparison
+ $mountPath = str($this->mount_path)->ltrim('/')->toString();
+ $containerPathClean = str($containerPath)->ltrim('/')->toString();
+
+ if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
+ return $readOnly === true;
+ }
}
}
diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php
index e7862478b..7126253ea 100644
--- a/app/Models/LocalPersistentVolume.php
+++ b/app/Models/LocalPersistentVolume.php
@@ -10,6 +10,11 @@ class LocalPersistentVolume extends Model
{
protected $guarded = [];
+ public function resource()
+ {
+ return $this->morphTo('resource');
+ }
+
public function application()
{
return $this->morphTo('resource');
@@ -50,6 +55,54 @@ protected function hostPath(): Attribute
);
}
+ // Check if this volume belongs to a service resource
+ public function isServiceResource(): bool
+ {
+ return in_array($this->resource_type, [
+ 'App\Models\ServiceApplication',
+ 'App\Models\ServiceDatabase',
+ ]);
+ }
+
+ // Check if this volume belongs to a dockercompose application
+ public function isDockerComposeResource(): bool
+ {
+ if ($this->resource_type !== 'App\Models\Application') {
+ return false;
+ }
+
+ // Only access relationship if already eager loaded to avoid N+1
+ if (! $this->relationLoaded('resource')) {
+ return false;
+ }
+
+ $application = $this->resource;
+ if (! $application) {
+ return false;
+ }
+
+ return data_get($application, 'build_pack') === 'dockercompose';
+ }
+
+ // Determine if this volume should be read-only in the UI
+ // Service volumes and dockercompose application volumes are read-only
+ // (users should edit compose file directly)
+ public function shouldBeReadOnlyInUI(): bool
+ {
+ // All service volumes should be read-only in UI
+ if ($this->isServiceResource()) {
+ return true;
+ }
+
+ // All dockercompose application volumes should be read-only in UI
+ if ($this->isDockerComposeResource()) {
+ return true;
+ }
+
+ // Check for explicit :ro flag in compose (existing logic)
+ return $this->isReadOnlyVolume();
+ }
+
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
@@ -85,6 +138,7 @@ public function isReadOnlyVolume(): bool
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
+ // Note: We match on mount_path (container path) only, since host paths get transformed
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
@@ -104,6 +158,19 @@ public function isReadOnlyVolume(): bool
return $options === 'ro';
}
}
+ } elseif (is_array($volume)) {
+ // Long-form syntax: { type: bind/volume, source: ..., target: ..., read_only: true }
+ $containerPath = data_get($volume, 'target');
+ $readOnly = data_get($volume, 'read_only', false);
+
+ // Match based on mount_path
+ // Remove leading slash from mount_path if present for comparison
+ $mountPath = str($this->mount_path)->ltrim('/')->toString();
+ $containerPathClean = str($containerPath)->ltrim('/')->toString();
+
+ if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
+ return $readOnly === true;
+ }
}
}
diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php
index 46531ed34..bb76d5ed6 100644
--- a/app/Models/PrivateKey.php
+++ b/app/Models/PrivateKey.php
@@ -80,6 +80,10 @@ public function getPublicKey()
return self::extractPublicKeyFromPrivate($this->private_key) ?? 'Error loading private key';
}
+ /**
+ * Get query builder for private keys owned by current team.
+ * If you need all private keys without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam(array $select = ['*'])
{
$teamId = currentTeam()->id;
@@ -88,6 +92,16 @@ public static function ownedByCurrentTeam(array $select = ['*'])
return self::whereTeamId($teamId)->select($selectArray->all());
}
+ /**
+ * Get all private keys owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return PrivateKey::ownedByCurrentTeam()->get();
+ });
+ }
+
public static function ownedAndOnlySShKeys(array $select = ['*'])
{
$teamId = currentTeam()->id;
diff --git a/app/Models/Project.php b/app/Models/Project.php
index a9bf76803..8b26672f0 100644
--- a/app/Models/Project.php
+++ b/app/Models/Project.php
@@ -30,11 +30,25 @@ class Project extends BaseModel
protected $guarded = [];
+ /**
+ * Get query builder for projects owned by current team.
+ * If you need all projects without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return Project::whereTeamId(currentTeam()->id)->orderByRaw('LOWER(name)');
}
+ /**
+ * Get all projects owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return Project::ownedByCurrentTeam()->get();
+ });
+ }
+
protected static function booted()
{
static::created(function ($project) {
diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php
index de27bbca6..3aae55966 100644
--- a/app/Models/S3Storage.php
+++ b/app/Models/S3Storage.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\HasSafeStringAttribute;
+use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Storage;
@@ -19,6 +20,28 @@ class S3Storage extends BaseModel
'secret' => 'encrypted',
];
+ /**
+ * Boot the model and register event listeners.
+ */
+ protected static function boot(): void
+ {
+ parent::boot();
+
+ // Trim whitespace from credentials before saving to prevent
+ // "Malformed Access Key Id" errors from accidental whitespace in pasted values.
+ // Note: We use the saving event instead of Attribute mutators because key/secret
+ // use Laravel's 'encrypted' cast. Attribute mutators fire before casts, which
+ // would cause issues with the encryption/decryption cycle.
+ static::saving(function (S3Storage $storage) {
+ if ($storage->key !== null) {
+ $storage->key = trim($storage->key);
+ }
+ if ($storage->secret !== null) {
+ $storage->secret = trim($storage->secret);
+ }
+ });
+ }
+
public static function ownedByCurrentTeam(array $select = ['*'])
{
$selectArray = collect($select)->concat(['id']);
@@ -41,6 +64,49 @@ public function awsUrl()
return "{$this->endpoint}/{$this->bucket}";
}
+ protected function path(): Attribute
+ {
+ return Attribute::make(
+ set: function (?string $value) {
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ return str($value)->trim()->start('/')->value();
+ }
+ );
+ }
+
+ /**
+ * Trim whitespace from endpoint to prevent malformed URLs.
+ */
+ protected function endpoint(): Attribute
+ {
+ return Attribute::make(
+ set: fn (?string $value) => $value ? trim($value) : null,
+ );
+ }
+
+ /**
+ * Trim whitespace from bucket name to prevent connection errors.
+ */
+ protected function bucket(): Attribute
+ {
+ return Attribute::make(
+ set: fn (?string $value) => $value ? trim($value) : null,
+ );
+ }
+
+ /**
+ * Trim whitespace from region to prevent connection errors.
+ */
+ protected function region(): Attribute
+ {
+ return Attribute::make(
+ set: fn (?string $value) => $value ? trim($value) : null,
+ );
+ }
+
public function testConnection(bool $shouldSave = false)
{
try {
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 8b153c8ac..82ee6721d 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -242,6 +242,10 @@ public static function isReachable()
return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true);
}
+ /**
+ * Get query builder for servers owned by current team.
+ * If you need all servers without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam(array $select = ['*'])
{
$teamId = currentTeam()->id;
@@ -250,6 +254,16 @@ public static function ownedByCurrentTeam(array $select = ['*'])
return Server::whereTeamId($teamId)->with('settings', 'swarmDockers', 'standaloneDockers')->select($selectArray->all())->orderBy('name');
}
+ /**
+ * Get all servers owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return Server::ownedByCurrentTeam()->get();
+ });
+ }
+
public static function isUsable()
{
return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_swarm_worker', false)->whereRelation('settings', 'is_build_server', false)->whereRelation('settings', 'force_disabled', false);
diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php
index 6da4dd4c6..0ad0fcf84 100644
--- a/app/Models/ServerSetting.php
+++ b/app/Models/ServerSetting.php
@@ -13,6 +13,7 @@
properties: [
'id' => ['type' => 'integer'],
'concurrent_builds' => ['type' => 'integer'],
+ 'deployment_queue_limit' => ['type' => 'integer'],
'dynamic_timeout' => ['type' => 'integer'],
'force_disabled' => ['type' => 'boolean'],
'force_server_cleanup' => ['type' => 'boolean'],
@@ -61,6 +62,7 @@ class ServerSetting extends Model
'is_reachable' => 'boolean',
'is_usable' => 'boolean',
'is_terminal_enabled' => 'boolean',
+ 'disable_application_image_retention' => 'boolean',
];
protected static function booted()
diff --git a/app/Models/Service.php b/app/Models/Service.php
index 2f8a64464..2daf9c39d 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -153,11 +153,25 @@ public function tags()
return $this->morphToMany(Tag::class, 'taggable');
}
+ /**
+ * Get query builder for services owned by current team.
+ * If you need all services without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return Service::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
+ /**
+ * Get all services owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return Service::ownedByCurrentTeam()->get();
+ });
+ }
+
public function deleteConfigurations()
{
$server = data_get($this, 'destination.server');
@@ -712,6 +726,84 @@ public function extraFields()
$fields->put('MinIO', $data->toArray());
break;
+ case $image->contains('garage'):
+ $data = collect([]);
+ $s3_api_url = $this->environment_variables()->where('key', 'GARAGE_S3_API_URL')->first();
+ $web_url = $this->environment_variables()->where('key', 'GARAGE_WEB_URL')->first();
+ $admin_url = $this->environment_variables()->where('key', 'GARAGE_ADMIN_URL')->first();
+ $admin_token = $this->environment_variables()->where('key', 'GARAGE_ADMIN_TOKEN')->first();
+ if (is_null($admin_token)) {
+ $admin_token = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GARAGE')->first();
+ }
+ $rpc_secret = $this->environment_variables()->where('key', 'GARAGE_RPC_SECRET')->first();
+ if (is_null($rpc_secret)) {
+ $rpc_secret = $this->environment_variables()->where('key', 'SERVICE_HEX_32_RPCSECRET')->first();
+ }
+ $metrics_token = $this->environment_variables()->where('key', 'GARAGE_METRICS_TOKEN')->first();
+ if (is_null($metrics_token)) {
+ $metrics_token = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GARAGEMETRICS')->first();
+ }
+
+ if ($s3_api_url) {
+ $data = $data->merge([
+ 'S3 API URL' => [
+ 'key' => data_get($s3_api_url, 'key'),
+ 'value' => data_get($s3_api_url, 'value'),
+ 'rules' => 'required|url',
+ ],
+ ]);
+ }
+ if ($web_url) {
+ $data = $data->merge([
+ 'Web URL' => [
+ 'key' => data_get($web_url, 'key'),
+ 'value' => data_get($web_url, 'value'),
+ 'rules' => 'required|url',
+ ],
+ ]);
+ }
+ if ($admin_url) {
+ $data = $data->merge([
+ 'Admin URL' => [
+ 'key' => data_get($admin_url, 'key'),
+ 'value' => data_get($admin_url, 'value'),
+ 'rules' => 'required|url',
+ ],
+ ]);
+ }
+ if ($admin_token) {
+ $data = $data->merge([
+ 'Admin Token' => [
+ 'key' => data_get($admin_token, 'key'),
+ 'value' => data_get($admin_token, 'value'),
+ 'rules' => 'required',
+ 'isPassword' => true,
+ ],
+ ]);
+ }
+ if ($rpc_secret) {
+ $data = $data->merge([
+ 'RPC Secret' => [
+ 'key' => data_get($rpc_secret, 'key'),
+ 'value' => data_get($rpc_secret, 'value'),
+ 'rules' => 'required',
+ 'isPassword' => true,
+ ],
+ ]);
+ }
+ if ($metrics_token) {
+ $data = $data->merge([
+ 'Metrics Token' => [
+ 'key' => data_get($metrics_token, 'key'),
+ 'value' => data_get($metrics_token, 'value'),
+ 'rules' => 'required',
+ 'isPassword' => true,
+ ],
+ ]);
+ }
+
+ $fields->put('Garage', $data->toArray());
+ break;
case $image->contains('weblate'):
$data = collect([]);
$admin_email = $this->environment_variables()->where('key', 'WEBLATE_ADMIN_EMAIL')->first();
diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php
index aef74b402..7b8b46812 100644
--- a/app/Models/ServiceApplication.php
+++ b/app/Models/ServiceApplication.php
@@ -37,11 +37,25 @@ public static function ownedByCurrentTeamAPI(int $teamId)
return ServiceApplication::whereRelation('service.environment.project.team', 'id', $teamId)->orderBy('name');
}
+ /**
+ * Get query builder for service applications owned by current team.
+ * If you need all service applications without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return ServiceApplication::whereRelation('service.environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
+ /**
+ * Get all service applications owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return ServiceApplication::ownedByCurrentTeam()->get();
+ });
+ }
+
public function isRunning()
{
return str($this->status)->contains('running');
diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php
index 3a249059c..f6a39cfe4 100644
--- a/app/Models/ServiceDatabase.php
+++ b/app/Models/ServiceDatabase.php
@@ -30,11 +30,25 @@ public static function ownedByCurrentTeamAPI(int $teamId)
return ServiceDatabase::whereRelation('service.environment.project.team', 'id', $teamId)->orderBy('name');
}
+ /**
+ * Get query builder for service databases owned by current team.
+ * If you need all service databases without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return ServiceDatabase::whereRelation('service.environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
+ /**
+ * Get all service databases owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return ServiceDatabase::ownedByCurrentTeam()->get();
+ });
+ }
+
public function restart()
{
$container_id = $this->name.'-'.$this->service->uuid;
diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php
index 6ac685618..f598ef2ea 100644
--- a/app/Models/StandaloneClickhouse.php
+++ b/app/Models/StandaloneClickhouse.php
@@ -44,11 +44,25 @@ protected static function booted()
});
}
+ /**
+ * Get query builder for ClickHouse databases owned by current team.
+ * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
+ /**
+ * Get all ClickHouse databases owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return StandaloneClickhouse::ownedByCurrentTeam()->get();
+ });
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php
index 2d004246c..47170056f 100644
--- a/app/Models/StandaloneDragonfly.php
+++ b/app/Models/StandaloneDragonfly.php
@@ -44,11 +44,25 @@ protected static function booted()
});
}
+ /**
+ * Get query builder for Dragonfly databases owned by current team.
+ * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
+ /**
+ * Get all Dragonfly databases owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return StandaloneDragonfly::ownedByCurrentTeam()->get();
+ });
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php
index 131e5bb3f..266110d0a 100644
--- a/app/Models/StandaloneKeydb.php
+++ b/app/Models/StandaloneKeydb.php
@@ -44,11 +44,25 @@ protected static function booted()
});
}
+ /**
+ * Get query builder for KeyDB databases owned by current team.
+ * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
+ /**
+ * Get all KeyDB databases owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return StandaloneKeydb::ownedByCurrentTeam()->get();
+ });
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php
index 675c7987f..aa7f2d31a 100644
--- a/app/Models/StandaloneMariadb.php
+++ b/app/Models/StandaloneMariadb.php
@@ -45,11 +45,25 @@ protected static function booted()
});
}
+ /**
+ * Get query builder for MariaDB databases owned by current team.
+ * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
+ /**
+ * Get all MariaDB databases owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return StandaloneMariadb::ownedByCurrentTeam()->get();
+ });
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php
index 7b70988f6..9046ab013 100644
--- a/app/Models/StandaloneMongodb.php
+++ b/app/Models/StandaloneMongodb.php
@@ -47,11 +47,25 @@ protected static function booted()
});
}
+ /**
+ * Get query builder for MongoDB databases owned by current team.
+ * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
+ /**
+ * Get all MongoDB databases owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return StandaloneMongodb::ownedByCurrentTeam()->get();
+ });
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php
index 6f79241af..719387b36 100644
--- a/app/Models/StandaloneMysql.php
+++ b/app/Models/StandaloneMysql.php
@@ -45,11 +45,25 @@ protected static function booted()
});
}
+ /**
+ * Get query builder for MySQL databases owned by current team.
+ * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
+ /**
+ * Get all MySQL databases owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return StandaloneMysql::ownedByCurrentTeam()->get();
+ });
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php
index 2dc5616a2..03080fd3d 100644
--- a/app/Models/StandalonePostgresql.php
+++ b/app/Models/StandalonePostgresql.php
@@ -45,11 +45,25 @@ protected static function booted()
});
}
+ /**
+ * Get query builder for PostgreSQL databases owned by current team.
+ * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
+ /**
+ * Get all PostgreSQL databases owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return StandalonePostgresql::ownedByCurrentTeam()->get();
+ });
+ }
+
public function workdir()
{
return database_configuration_dir()."/{$this->uuid}";
diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php
index c0223304a..6aca8af9a 100644
--- a/app/Models/StandaloneRedis.php
+++ b/app/Models/StandaloneRedis.php
@@ -46,11 +46,25 @@ protected static function booted()
});
}
+ /**
+ * Get query builder for Redis databases owned by current team.
+ * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead.
+ */
public static function ownedByCurrentTeam()
{
return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
+ /**
+ * Get all Redis databases owned by current team (cached for request duration).
+ */
+ public static function ownedByCurrentTeamCached()
+ {
+ return once(function () {
+ return StandaloneRedis::ownedByCurrentTeam()->get();
+ });
+ }
+
protected function serverStatus(): Attribute
{
return Attribute::make(
diff --git a/app/Models/User.php b/app/Models/User.php
index f04b6fa77..b790efcf1 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -443,4 +443,13 @@ public function hasEmailChangeRequest(): bool
&& $this->email_change_code_expires_at
&& Carbon::now()->lessThan($this->email_change_code_expires_at);
}
+
+ /**
+ * Check if the user has a password set.
+ * OAuth users are created without passwords.
+ */
+ public function hasPassword(): bool
+ {
+ return ! empty($this->password);
+ }
}
diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php
index 79bd0ae62..731006181 100644
--- a/app/Models/WebhookNotificationSettings.php
+++ b/app/Models/WebhookNotificationSettings.php
@@ -24,7 +24,8 @@ class WebhookNotificationSettings extends Model
'backup_failure_webhook_notifications',
'scheduled_task_success_webhook_notifications',
'scheduled_task_failure_webhook_notifications',
- 'docker_cleanup_webhook_notifications',
+ 'docker_cleanup_success_webhook_notifications',
+ 'docker_cleanup_failure_webhook_notifications',
'server_disk_usage_webhook_notifications',
'server_reachable_webhook_notifications',
'server_unreachable_webhook_notifications',
@@ -45,7 +46,8 @@ protected function casts(): array
'backup_failure_webhook_notifications' => 'boolean',
'scheduled_task_success_webhook_notifications' => 'boolean',
'scheduled_task_failure_webhook_notifications' => 'boolean',
- 'docker_cleanup_webhook_notifications' => 'boolean',
+ 'docker_cleanup_success_webhook_notifications' => 'boolean',
+ 'docker_cleanup_failure_webhook_notifications' => 'boolean',
'server_disk_usage_webhook_notifications' => 'boolean',
'server_reachable_webhook_notifications' => 'boolean',
'server_unreachable_webhook_notifications' => 'boolean',
diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php
index 234bc37ad..abd115550 100644
--- a/app/Notifications/Channels/EmailChannel.php
+++ b/app/Notifications/Channels/EmailChannel.php
@@ -43,21 +43,26 @@ public function send(SendsEmail $notifiable, Notification $notification): void
throw new Exception('No email recipients found');
}
- foreach ($recipients as $recipient) {
- // Check if the recipient is part of the team
- if (! $members->contains('email', $recipient)) {
- $emailSettings = $notifiable->emailNotificationSettings;
- data_set($emailSettings, 'smtp_password', '********');
- data_set($emailSettings, 'resend_api_key', '********');
- send_internal_notification(sprintf(
- "Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s",
- $recipient,
- $team,
- get_class($notification),
- get_class($notifiable),
- json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
- ));
- throw new Exception('Recipient is not part of the team');
+ // Skip team membership validation for test notifications
+ $isTestNotification = data_get($notification, 'isTestNotification', false);
+
+ if (! $isTestNotification) {
+ foreach ($recipients as $recipient) {
+ // Check if the recipient is part of the team
+ if (! $members->contains('email', $recipient)) {
+ $emailSettings = $notifiable->emailNotificationSettings;
+ data_set($emailSettings, 'smtp_password', '********');
+ data_set($emailSettings, 'resend_api_key', '********');
+ send_internal_notification(sprintf(
+ "Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s",
+ $recipient,
+ $team,
+ get_class($notification),
+ get_class($notifiable),
+ json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
+ ));
+ throw new Exception('Recipient is not part of the team');
+ }
}
}
diff --git a/app/Notifications/Server/TraefikVersionOutdated.php b/app/Notifications/Server/TraefikVersionOutdated.php
index 09ef4257d..c94cc1732 100644
--- a/app/Notifications/Server/TraefikVersionOutdated.php
+++ b/app/Notifications/Server/TraefikVersionOutdated.php
@@ -43,9 +43,19 @@ public function toMail($notifiable = null): MailMessage
$mail = new MailMessage;
$count = $this->servers->count();
+ // Transform servers to include URLs
+ $serversWithUrls = $this->servers->map(function ($server) {
+ return [
+ 'name' => $server->name,
+ 'uuid' => $server->uuid,
+ 'url' => base_url().'/server/'.$server->uuid.'/proxy',
+ 'outdatedInfo' => $server->outdatedInfo ?? [],
+ ];
+ });
+
$mail->subject("Coolify: Traefik proxy outdated on {$count} server(s)");
$mail->view('emails.traefik-version-outdated', [
- 'servers' => $this->servers,
+ 'servers' => $serversWithUrls,
'count' => $count,
]);
diff --git a/app/Notifications/Test.php b/app/Notifications/Test.php
index 60bc8a0ee..bbed22777 100644
--- a/app/Notifications/Test.php
+++ b/app/Notifications/Test.php
@@ -23,6 +23,8 @@ class Test extends Notification implements ShouldQueue
public $tries = 5;
+ public bool $isTestNotification = true;
+
public function __construct(public ?string $emails = null, public ?string $channel = null, public ?bool $ping = false)
{
$this->onQueue('high');
diff --git a/app/Notifications/TransactionalEmails/Test.php b/app/Notifications/TransactionalEmails/Test.php
index 3add70db2..2f7d70bbf 100644
--- a/app/Notifications/TransactionalEmails/Test.php
+++ b/app/Notifications/TransactionalEmails/Test.php
@@ -8,6 +8,8 @@
class Test extends CustomEmailNotification
{
+ public bool $isTestNotification = true;
+
public function __construct(public string $emails, public bool $isTransactionalEmail = true)
{
$this->onQueue('high');
diff --git a/app/Policies/InstanceSettingsPolicy.php b/app/Policies/InstanceSettingsPolicy.php
new file mode 100644
index 000000000..a04f07a28
--- /dev/null
+++ b/app/Policies/InstanceSettingsPolicy.php
@@ -0,0 +1,25 @@
+ \App\Policies\ApiTokenPolicy::class,
+ // Instance settings policy
+ \App\Models\InstanceSettings::class => \App\Policies\InstanceSettingsPolicy::class,
+
// Team policy
\App\Models\Team::class => \App\Policies\TeamPolicy::class,
diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php
index 2d9910add..9163d595c 100644
--- a/app/Providers/EventServiceProvider.php
+++ b/app/Providers/EventServiceProvider.php
@@ -2,10 +2,6 @@
namespace App\Providers;
-use App\Listeners\MaintenanceModeDisabledNotification;
-use App\Listeners\MaintenanceModeEnabledNotification;
-use Illuminate\Foundation\Events\MaintenanceModeDisabled;
-use Illuminate\Foundation\Events\MaintenanceModeEnabled;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use SocialiteProviders\Authentik\AuthentikExtendSocialite;
use SocialiteProviders\Azure\AzureExtendSocialite;
@@ -19,12 +15,6 @@
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
- MaintenanceModeEnabled::class => [
- MaintenanceModeEnabledNotification::class,
- ],
- MaintenanceModeDisabled::class => [
- MaintenanceModeDisabledNotification::class,
- ],
SocialiteWasCalled::class => [
AzureExtendSocialite::class.'@handle',
AuthentikExtendSocialite::class.'@handle',
diff --git a/app/Rules/ValidProxyConfigFilename.php b/app/Rules/ValidProxyConfigFilename.php
new file mode 100644
index 000000000..871cc6eeb
--- /dev/null
+++ b/app/Rules/ValidProxyConfigFilename.php
@@ -0,0 +1,73 @@
+ 255) {
+ $fail('The :attribute must not exceed 255 characters.');
+
+ return;
+ }
+
+ // Check for path separators (prevent path traversal)
+ if (str_contains($filename, '/') || str_contains($filename, '\\')) {
+ $fail('The :attribute cannot contain path separators.');
+
+ return;
+ }
+
+ // Check for hidden files (starting with dot)
+ if (str_starts_with($filename, '.')) {
+ $fail('The :attribute cannot start with a dot (hidden files not allowed).');
+
+ return;
+ }
+
+ // Check for valid characters only: alphanumeric, dashes, underscores, dots
+ if (! preg_match('/^[a-zA-Z0-9._-]+$/', $filename)) {
+ $fail('The :attribute may only contain letters, numbers, dashes, underscores, and dots.');
+
+ return;
+ }
+
+ // Check for reserved filenames (case-sensitive for coolify.yaml/yml, case-insensitive check not needed as Caddyfile is exact)
+ if (in_array($filename, self::RESERVED_FILENAMES, true)) {
+ $fail('The :attribute uses a reserved filename.');
+
+ return;
+ }
+ }
+}
diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php
index 4a17ecdd6..2be36d905 100644
--- a/app/Services/ContainerStatusAggregator.php
+++ b/app/Services/ContainerStatusAggregator.php
@@ -16,14 +16,23 @@
* UI components transform this to human-readable format (e.g., "Running (Healthy)").
*
* State Priority (highest to lowest):
- * 1. Restarting → degraded:unhealthy
- * 2. Crash Loop (exited with restarts) → degraded:unhealthy
- * 3. Mixed (running + exited) → degraded:unhealthy
- * 4. Running → running:healthy/unhealthy/unknown
- * 5. Dead/Removing → degraded:unhealthy
- * 6. Paused → paused:unknown
- * 7. Starting/Created → starting:unknown
- * 8. Exited → exited
+ * 1. Degraded (from sub-resources) → degraded:unhealthy
+ * 2. Restarting → degraded:unhealthy (or restarting:unknown if preserveRestarting=true)
+ * 3. Crash Loop (exited with restarts) → degraded:unhealthy
+ * 4. Mixed (running + exited) → degraded:unhealthy
+ * 5. Mixed (running + starting) → starting:unknown
+ * 6. Running → running:healthy/unhealthy/unknown
+ * 7. Dead/Removing → degraded:unhealthy
+ * 8. Paused → paused:unknown
+ * 9. Starting/Created → starting:unknown
+ * 10. Exited → exited
+ *
+ * The $preserveRestarting parameter controls whether "restarting" containers should be
+ * reported as "restarting:unknown" (true) or "degraded:unhealthy" (false, default).
+ * - Use preserveRestarting=true for individual sub-resources (ServiceApplication/ServiceDatabase)
+ * so they show "Restarting" in the UI.
+ * - Use preserveRestarting=false for overall Service status aggregation where any restarting
+ * container should mark the entire service as "Degraded".
*/
class ContainerStatusAggregator
{
@@ -32,9 +41,10 @@ class ContainerStatusAggregator
*
* @param Collection $containerStatuses Collection of status strings (e.g., "running (healthy)", "running:healthy")
* @param int $maxRestartCount Maximum restart count across containers (for crash loop detection)
+ * @param bool $preserveRestarting If true, "restarting" containers return "restarting:unknown" instead of "degraded:unhealthy"
* @return string Aggregated status in colon format (e.g., "running:healthy")
*/
- public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0): string
+ public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0, bool $preserveRestarting = false): string
{
// Validate maxRestartCount parameter
if ($maxRestartCount < 0) {
@@ -64,10 +74,16 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest
$hasStarting = false;
$hasPaused = false;
$hasDead = false;
+ $hasDegraded = false;
// Parse each status string and set flags
foreach ($containerStatuses as $status) {
- if (str($status)->contains('restarting')) {
+ if (str($status)->contains('degraded')) {
+ $hasDegraded = true;
+ if (str($status)->contains('unhealthy')) {
+ $hasUnhealthy = true;
+ }
+ } elseif (str($status)->contains('restarting')) {
$hasRestarting = true;
} elseif (str($status)->contains('running')) {
$hasRunning = true;
@@ -98,7 +114,9 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest
$hasStarting,
$hasPaused,
$hasDead,
- $maxRestartCount
+ $hasDegraded,
+ $maxRestartCount,
+ $preserveRestarting
);
}
@@ -107,9 +125,10 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest
*
* @param Collection $containers Collection of Docker container objects with State property
* @param int $maxRestartCount Maximum restart count across containers (for crash loop detection)
+ * @param bool $preserveRestarting If true, "restarting" containers return "restarting:unknown" instead of "degraded:unhealthy"
* @return string Aggregated status in colon format (e.g., "running:healthy")
*/
- public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0): string
+ public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0, bool $preserveRestarting = false): string
{
// Validate maxRestartCount parameter
if ($maxRestartCount < 0) {
@@ -175,7 +194,9 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC
$hasStarting,
$hasPaused,
$hasDead,
- $maxRestartCount
+ false, // $hasDegraded - not applicable for container objects, only for status strings
+ $maxRestartCount,
+ $preserveRestarting
);
}
@@ -190,7 +211,9 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC
* @param bool $hasStarting Has at least one starting/created container
* @param bool $hasPaused Has at least one paused container
* @param bool $hasDead Has at least one dead/removing container
+ * @param bool $hasDegraded Has at least one degraded container
* @param int $maxRestartCount Maximum restart count (for crash loop detection)
+ * @param bool $preserveRestarting If true, return "restarting:unknown" instead of "degraded:unhealthy" for restarting containers
* @return string Status in colon format (e.g., "running:healthy")
*/
private function resolveStatus(
@@ -202,24 +225,40 @@ private function resolveStatus(
bool $hasStarting,
bool $hasPaused,
bool $hasDead,
- int $maxRestartCount
+ bool $hasDegraded,
+ int $maxRestartCount,
+ bool $preserveRestarting = false
): string {
- // Priority 1: Restarting containers (degraded state)
- if ($hasRestarting) {
+ // Priority 1: Degraded containers from sub-resources (highest priority)
+ // If any service/application within a service stack is degraded, the entire stack is degraded
+ if ($hasDegraded) {
return 'degraded:unhealthy';
}
- // Priority 2: Crash loop detection (exited with restart count > 0)
+ // Priority 2: Restarting containers
+ // When preserveRestarting is true (for individual sub-resources), keep as "restarting"
+ // When false (for overall service status), mark as "degraded"
+ if ($hasRestarting) {
+ return $preserveRestarting ? 'restarting:unknown' : 'degraded:unhealthy';
+ }
+
+ // Priority 3: Crash loop detection (exited with restart count > 0)
if ($hasExited && $maxRestartCount > 0) {
return 'degraded:unhealthy';
}
- // Priority 3: Mixed state (some running, some exited = degraded)
+ // Priority 4: Mixed state (some running, some exited = degraded)
if ($hasRunning && $hasExited) {
return 'degraded:unhealthy';
}
- // Priority 4: Running containers (check health status)
+ // Priority 5: Mixed state (some running, some starting = still starting)
+ // If any component is still starting, the entire service stack is not fully ready
+ if ($hasRunning && $hasStarting) {
+ return 'starting:unknown';
+ }
+
+ // Priority 6: Running containers (check health status)
if ($hasRunning) {
if ($hasUnhealthy) {
return 'running:unhealthy';
@@ -230,22 +269,22 @@ private function resolveStatus(
}
}
- // Priority 5: Dead or removing containers
+ // Priority 7: Dead or removing containers
if ($hasDead) {
return 'degraded:unhealthy';
}
- // Priority 6: Paused containers
+ // Priority 8: Paused containers
if ($hasPaused) {
return 'paused:unknown';
}
- // Priority 7: Starting/created containers
+ // Priority 9: Starting/created containers
if ($hasStarting) {
return 'starting:unknown';
}
- // Priority 8: All containers exited (no restart count = truly stopped)
+ // Priority 10: All containers exited (no restart count = truly stopped)
return 'exited';
}
}
diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php
index dd4d6e631..1de7eb2b1 100644
--- a/app/Services/HetznerService.php
+++ b/app/Services/HetznerService.php
@@ -2,6 +2,7 @@
namespace App\Services;
+use App\Exceptions\RateLimitException;
use Illuminate\Support\Facades\Http;
class HetznerService
@@ -46,6 +47,19 @@ private function request(string $method, string $endpoint, array $data = [])
->{$method}($this->baseUrl.$endpoint, $data);
if (! $response->successful()) {
+ if ($response->status() === 429) {
+ $retryAfter = $response->header('Retry-After');
+ if ($retryAfter === null) {
+ $resetTime = $response->header('RateLimit-Reset');
+ $retryAfter = $resetTime ? max(0, (int) $resetTime - time()) : null;
+ }
+
+ throw new RateLimitException(
+ 'Rate limit exceeded. Please try again later.',
+ $retryAfter !== null ? (int) $retryAfter : null
+ );
+ }
+
throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error'));
}
@@ -147,4 +161,30 @@ public function deleteServer(int $serverId): void
{
$this->request('delete', "/servers/{$serverId}");
}
+
+ public function getServers(): array
+ {
+ return $this->requestPaginated('get', '/servers', 'servers');
+ }
+
+ public function findServerByIp(string $ip): ?array
+ {
+ $servers = $this->getServers();
+
+ foreach ($servers as $server) {
+ // Check IPv4
+ $ipv4 = data_get($server, 'public_net.ipv4.ip');
+ if ($ipv4 === $ip) {
+ return $server;
+ }
+
+ // Check IPv6 (Hetzner returns the full /64 block)
+ $ipv6 = data_get($server, 'public_net.ipv6.ip');
+ if ($ipv6 && str_starts_with($ip, rtrim($ipv6, '/'))) {
+ return $server;
+ }
+ }
+
+ return null;
+ }
}
diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php
index 58ae5f249..a60a47b93 100644
--- a/app/Traits/ExecuteRemoteCommand.php
+++ b/app/Traits/ExecuteRemoteCommand.php
@@ -140,9 +140,15 @@ public function execute_remote_command(...$commands)
// If we exhausted all retries and still failed
if (! $commandExecuted && $lastError) {
// Now we can set the status to FAILED since all retries have been exhausted
+ // But only if the deployment hasn't already been marked as FINISHED
if (isset($this->application_deployment_queue)) {
- $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value;
- $this->application_deployment_queue->save();
+ // Avoid clobbering a deployment that may have just been marked FINISHED
+ $this->application_deployment_queue->newQuery()
+ ->where('id', $this->application_deployment_queue->id)
+ ->where('status', '!=', ApplicationDeploymentStatus::FINISHED->value)
+ ->update([
+ 'status' => ApplicationDeploymentStatus::FAILED->value,
+ ]);
}
throw $lastError;
}
diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php
new file mode 100644
index 000000000..4a98e4a51
--- /dev/null
+++ b/app/View/Components/Forms/EnvVarInput.php
@@ -0,0 +1,94 @@
+canGate && $this->canResource && $this->autoDisable) {
+ $hasPermission = Gate::allows($this->canGate, $this->canResource);
+
+ if (! $hasPermission) {
+ $this->disabled = true;
+ }
+ }
+ }
+
+ public function render(): View|Closure|string
+ {
+ // Store original ID for wire:model binding (property name)
+ $this->modelBinding = $this->id;
+
+ if (is_null($this->id)) {
+ $this->id = new Cuid2;
+ // Don't create wire:model binding for auto-generated IDs
+ $this->modelBinding = 'null';
+ }
+ // Generate unique HTML ID by adding random suffix
+ // This prevents duplicate IDs when multiple forms are on the same page
+ if ($this->modelBinding && $this->modelBinding !== 'null') {
+ // Use original ID with random suffix for uniqueness
+ $uniqueSuffix = new Cuid2;
+ $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
+ } else {
+ $this->htmlId = (string) $this->id;
+ }
+
+ if (is_null($this->name)) {
+ $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
+ }
+
+ if ($this->type === 'password') {
+ $this->defaultClass = $this->defaultClass.' pr-[2.8rem]';
+ }
+
+ $this->scopeUrls = [
+ 'team' => route('shared-variables.team.index'),
+ 'project' => route('shared-variables.project.index'),
+ 'environment' => $this->projectUuid && $this->environmentUuid
+ ? route('shared-variables.environment.show', [
+ 'project_uuid' => $this->projectUuid,
+ 'environment_uuid' => $this->environmentUuid,
+ ])
+ : route('shared-variables.environment.index'),
+ 'default' => route('shared-variables.index'),
+ ];
+
+ return view('components.forms.env-var-input');
+ }
+}
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index 488653fb1..84bde5393 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -178,4 +178,5 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('use_build_server');
$request->offsetUnset('is_static');
$request->offsetUnset('force_domain_override');
+ $request->offsetUnset('autogenerate_domain');
}
diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php
index db7767c1e..03c53989c 100644
--- a/bootstrap/helpers/applications.php
+++ b/bootstrap/helpers/applications.php
@@ -28,6 +28,20 @@ function queue_application_deployment(Application $application, string $deployme
$destination_id = $destination->id;
}
+ // Check if the deployment queue is full for this server
+ $serverForQueueCheck = $server ?? Server::find($server_id);
+ $queue_limit = $serverForQueueCheck->settings->deployment_queue_limit ?? 25;
+ $queued_count = ApplicationDeploymentQueue::where('server_id', $server_id)
+ ->where('status', ApplicationDeploymentStatus::QUEUED->value)
+ ->count();
+
+ if ($queued_count >= $queue_limit) {
+ return [
+ 'status' => 'queue_full',
+ 'message' => 'Deployment queue is full. Please wait for existing deployments to complete.',
+ ];
+ }
+
// Check if there's already a deployment in progress or queued for this application and commit
$existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
->where('commit', $commit)
@@ -68,10 +82,16 @@ function queue_application_deployment(Application $application, string $deployme
]);
if ($no_questions_asked) {
+ $deployment->update([
+ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ]);
ApplicationDeploymentJob::dispatch(
application_deployment_queue_id: $deployment->id,
);
} elseif (next_queuable($server_id, $application_id, $commit, $pull_request_id)) {
+ $deployment->update([
+ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ]);
ApplicationDeploymentJob::dispatch(
application_deployment_queue_id: $deployment->id,
);
diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php
index f588b6c00..114c4bb98 100644
--- a/bootstrap/helpers/constants.php
+++ b/bootstrap/helpers/constants.php
@@ -48,6 +48,8 @@
'influxdb',
'clickhouse/clickhouse-server',
'timescaledb/timescaledb',
+ 'timescaledb', // Matches timescale/timescaledb
+ 'timescaledb-ha', // Matches timescale/timescaledb-ha
'pgvector/pgvector',
];
const SPECIFIC_SERVICES = [
@@ -56,6 +58,7 @@
'ghcr.io/coollabsio/minio',
'coollabsio/minio',
'svhd/logto',
+ 'dxflrs/garage',
];
// Based on /etc/os-release
@@ -67,4 +70,14 @@
'alpine',
];
+const NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK = [
+ 'pgadmin',
+ 'postgresus',
+];
+const NEEDS_TO_DISABLE_GZIP = [
+ 'beszel' => ['beszel'],
+];
+const NEEDS_TO_DISABLE_STRIPPREFIX = [
+ 'appwrite' => ['appwrite', 'appwrite-console', 'appwrite-realtime'],
+];
const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment'];
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index c4d77979f..a0f810480 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -312,6 +312,36 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
$LOGTO_ADMIN_ENDPOINT->value.':3002',
]);
break;
+ case $type?->contains('garage'):
+ $GARAGE_S3_API_URL = $variables->where('key', 'GARAGE_S3_API_URL')->first();
+ $GARAGE_WEB_URL = $variables->where('key', 'GARAGE_WEB_URL')->first();
+ $GARAGE_ADMIN_URL = $variables->where('key', 'GARAGE_ADMIN_URL')->first();
+
+ if (is_null($GARAGE_S3_API_URL) || is_null($GARAGE_WEB_URL) || is_null($GARAGE_ADMIN_URL)) {
+ return collect([]);
+ }
+
+ if (str($GARAGE_S3_API_URL->value ?? '')->isEmpty()) {
+ $GARAGE_S3_API_URL->update([
+ 'value' => generateUrl(server: $server, random: 's3-'.$uuid, forceHttps: true),
+ ]);
+ }
+ if (str($GARAGE_WEB_URL->value ?? '')->isEmpty()) {
+ $GARAGE_WEB_URL->update([
+ 'value' => generateUrl(server: $server, random: 'web-'.$uuid, forceHttps: true),
+ ]);
+ }
+ if (str($GARAGE_ADMIN_URL->value ?? '')->isEmpty()) {
+ $GARAGE_ADMIN_URL->update([
+ 'value' => generateUrl(server: $server, random: 'admin-'.$uuid, forceHttps: true),
+ ]);
+ }
+ $payload = collect([
+ $GARAGE_S3_API_URL->value.':3900',
+ $GARAGE_WEB_URL->value.':3902',
+ $GARAGE_ADMIN_URL->value.':3903',
+ ]);
+ break;
}
return $payload;
@@ -770,10 +800,26 @@ function isDatabaseImage(?string $image = null, ?array $serviceConfig = null)
}
$imageName = $image->before(':');
- // First check if it's a known database image
+ // Extract base image name (ignore registry prefix)
+ // Examples:
+ // docker.io/library/postgres -> postgres
+ // ghcr.io/postgrest/postgrest -> postgrest
+ // postgres -> postgres
+ // postgrest/postgrest -> postgrest
+ $baseImageName = $imageName;
+ if (str($imageName)->contains('/')) {
+ $baseImageName = str($imageName)->afterLast('/');
+ }
+
+ // Check if base image name exactly matches a known database image
$isKnownDatabase = false;
foreach (DATABASE_DOCKER_IMAGES as $database_docker_image) {
- if (str($imageName)->contains($database_docker_image)) {
+ // Extract base name from database pattern for comparison
+ $databaseBaseName = str($database_docker_image)->contains('/')
+ ? str($database_docker_image)->afterLast('/')
+ : $database_docker_image;
+
+ if ($baseImageName == $databaseBaseName) {
$isKnownDatabase = true;
break;
}
@@ -962,6 +1008,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
'--shm-size' => 'shm_size',
'--gpus' => 'gpus',
'--hostname' => 'hostname',
+ '--entrypoint' => 'entrypoint',
]);
foreach ($matches as $match) {
$option = $match[1];
@@ -982,6 +1029,38 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
$options[$option] = array_unique($options[$option]);
}
}
+ if ($option === '--entrypoint') {
+ $value = null;
+ // Match --entrypoint=value or --entrypoint value
+ // Handle quoted strings with escaped quotes: --entrypoint "python -c \"print('hi')\""
+ // Pattern matches: double-quoted (with escapes), single-quoted (with escapes), or unquoted values
+ if (preg_match(
+ '/--entrypoint(?:=|\s+)(?"(?:\\\\.|[^"])*"|\'(?:\\\\.|[^\'])*\'|[^\s]+)/',
+ $custom_docker_run_options,
+ $entrypoint_matches
+ )) {
+ $rawValue = $entrypoint_matches['raw'];
+ // Handle double-quoted strings: strip quotes and unescape special characters
+ if (str_starts_with($rawValue, '"') && str_ends_with($rawValue, '"')) {
+ $inner = substr($rawValue, 1, -1);
+ // Unescape backslash sequences: \" \$ \` \\
+ $value = preg_replace('/\\\\(["$`\\\\])/', '$1', $inner);
+ } elseif (str_starts_with($rawValue, "'") && str_ends_with($rawValue, "'")) {
+ // Handle single-quoted strings: just strip quotes (no unescaping per shell rules)
+ $value = substr($rawValue, 1, -1);
+ } else {
+ // Handle unquoted values
+ $value = $rawValue;
+ }
+ }
+
+ if ($value && trim($value) !== '') {
+ $options[$option][] = $value;
+ $options[$option] = array_values(array_unique($options[$option]));
+ }
+
+ continue;
+ }
if (isset($match[2]) && $match[2] !== '') {
$value = $match[2];
$options[$option][] = $value;
@@ -1022,6 +1101,12 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) {
$compose_options->put($mapping[$option], $value[0]);
}
+ } elseif ($option === '--entrypoint') {
+ if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) {
+ // Docker compose accepts entrypoint as either a string or an array
+ // Keep it as a string for simplicity - docker compose will handle it
+ $compose_options->put($mapping[$option], $value[0]);
+ }
} elseif ($option === '--gpus') {
$payload = [
'driver' => 'nvidia',
@@ -1337,3 +1422,62 @@ function injectDockerComposeFlags(string $command, string $composeFilePath, stri
// Replace only first occurrence to avoid modifying comments/strings/chained commands
return preg_replace('/docker\s+compose/', $dockerComposeReplacement, $command, 1);
}
+
+/**
+ * Inject build arguments right after build-related subcommands in docker/docker compose commands.
+ * This ensures build args are only applied to build operations, not to push, pull, up, etc.
+ *
+ * Supports:
+ * - docker compose build
+ * - docker buildx build
+ * - docker builder build
+ * - docker build (legacy)
+ *
+ * Examples:
+ * - Input: "docker compose -f file.yml build"
+ * Output: "docker compose -f file.yml build --build-arg X --build-arg Y"
+ *
+ * - Input: "docker buildx build --platform linux/amd64"
+ * Output: "docker buildx build --build-arg X --build-arg Y --platform linux/amd64"
+ *
+ * - Input: "docker builder build --tag myimage:latest"
+ * Output: "docker builder build --build-arg X --build-arg Y --tag myimage:latest"
+ *
+ * - Input: "docker compose build && docker compose push"
+ * Output: "docker compose build --build-arg X --build-arg Y && docker compose push"
+ *
+ * - Input: "docker compose push"
+ * Output: "docker compose push" (unchanged - no build command found)
+ *
+ * @param string $command The docker command
+ * @param string $buildArgsString The build arguments to inject (e.g., "--build-arg X --build-arg Y")
+ * @return string The modified command with build args injected after build subcommand
+ */
+function injectDockerComposeBuildArgs(string $command, string $buildArgsString): string
+{
+ // Early return if no build args to inject
+ if (empty(trim($buildArgsString))) {
+ return $command;
+ }
+
+ // Match build-related commands:
+ // - ' builder build' (docker builder build)
+ // - ' buildx build' (docker buildx build)
+ // - ' build' (docker compose build, docker build)
+ // Followed by either:
+ // - whitespace (allowing service names, flags, or any valid arguments)
+ // - end of string ($)
+ // This regex ensures we match build subcommands, not "build" in other contexts
+ // IMPORTANT: Order matters - check longer patterns first (builder build, buildx build) before ' build'
+ $pattern = '/( builder build| buildx build| build)(?=\s|$)/';
+
+ // Replace the first occurrence of build command with build command + build-args
+ $modifiedCommand = preg_replace(
+ $pattern,
+ '$1 '.$buildArgsString,
+ $command,
+ 1 // Only replace first occurrence
+ );
+
+ return $modifiedCommand ?? $command;
+}
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index dfcc3e190..43ba58e59 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -358,7 +358,7 @@ function parseDockerVolumeString(string $volumeString): array
];
}
-function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection
+function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null): Collection
{
$uuid = data_get($resource, 'uuid');
$compose = data_get($resource, 'docker_compose_raw');
@@ -1145,11 +1145,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$template = $resource->preview_url_template;
$host = $url->getHost();
$schema = $url->getScheme();
+ $portInt = $url->getPort();
+ $port = $portInt !== null ? ':'.$portInt : '';
$random = new Cuid2;
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn);
- $preview_fqdn = "$schema://$preview_fqdn";
+ $preview_fqdn = "$schema://$preview_fqdn{$port}";
$preview->fqdn = $preview_fqdn;
$preview->save();
@@ -1324,6 +1326,20 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
->values();
$payload['env_file'] = $envFiles;
+
+ // Inject commit-based image tag for services with build directive (for rollback support)
+ // Only inject if service has build but no explicit image defined
+ $hasBuild = data_get($service, 'build') !== null;
+ $hasImage = data_get($service, 'image') !== null;
+ if ($hasBuild && ! $hasImage && $commit) {
+ $imageTag = str($commit)->substr(0, 128)->value();
+ if ($isPullRequest) {
+ $imageTag = "pr-{$pullRequestId}";
+ }
+ $imageRepo = "{$uuid}_{$serviceName}";
+ $payload['image'] = "{$imageRepo}:{$imageTag}";
+ }
+
if ($isPullRequest) {
$serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
}
@@ -1644,9 +1660,16 @@ function serviceParser(Service $resource): Collection
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
- $fqdn = "$fqdn$path";
- $url = "$url$path";
- $fqdnValueForEnv = "$fqdnValueForEnv$path";
+ // Only add path if it's not already present (prevents duplication on subsequent parse() calls)
+ if (! str($fqdn)->endsWith($path)) {
+ $fqdn = "$fqdn$path";
+ }
+ if (! str($url)->endsWith($path)) {
+ $url = "$url$path";
+ }
+ if (! str($fqdnValueForEnv)->endsWith($path)) {
+ $fqdnValueForEnv = "$fqdnValueForEnv$path";
+ }
}
}
diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php
index 08fad4958..ac52c0af8 100644
--- a/bootstrap/helpers/proxy.php
+++ b/bootstrap/helpers/proxy.php
@@ -6,6 +6,20 @@
use App\Models\Server;
use Symfony\Component\Yaml\Yaml;
+/**
+ * Check if a network name is a Docker predefined system network.
+ * These networks cannot be created, modified, or managed by docker network commands.
+ *
+ * @param string $network Network name to check
+ * @return bool True if it's a predefined network that should be skipped
+ */
+function isDockerPredefinedNetwork(string $network): bool
+{
+ // Only filter 'default' and 'host' to match existing codebase patterns
+ // See: bootstrap/helpers/parsers.php:891, bootstrap/helpers/shared.php:689,748
+ return in_array($network, ['default', 'host'], true);
+}
+
function collectProxyDockerNetworksByServer(Server $server)
{
if (! $server->isFunctional()) {
@@ -66,8 +80,12 @@ function collectDockerNetworksByServer(Server $server)
$networks->push($network);
$allNetworks->push($network);
}
- $networks = collect($networks)->flatten()->unique();
- $allNetworks = $allNetworks->flatten()->unique();
+ $networks = collect($networks)->flatten()->unique()->filter(function ($network) {
+ return ! isDockerPredefinedNetwork($network);
+ });
+ $allNetworks = $allNetworks->flatten()->unique()->filter(function ($network) {
+ return ! isDockerPredefinedNetwork($network);
+ });
if ($server->isSwarm()) {
if ($networks->count() === 0) {
$networks = collect(['coolify-overlay']);
@@ -108,6 +126,37 @@ function connectProxyToNetworks(Server $server)
return $commands->flatten();
}
+
+/**
+ * Ensures all required networks exist before docker compose up.
+ * This must be called BEFORE docker compose up since the compose file declares networks as external.
+ *
+ * @param Server $server The server to ensure networks on
+ * @return \Illuminate\Support\Collection Commands to create networks if they don't exist
+ */
+function ensureProxyNetworksExist(Server $server)
+{
+ ['allNetworks' => $networks] = collectDockerNetworksByServer($server);
+
+ if ($server->isSwarm()) {
+ $commands = $networks->map(function ($network) {
+ return [
+ "echo 'Ensuring network $network exists...'",
+ "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
+ ];
+ });
+ } else {
+ $commands = $networks->map(function ($network) {
+ return [
+ "echo 'Ensuring network $network exists...'",
+ "docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
+ ];
+ });
+ }
+
+ return $commands->flatten();
+}
+
function extractCustomProxyCommands(Server $server, string $existing_config): array
{
$custom_commands = [];
@@ -188,8 +237,8 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
$array_of_networks = collect([]);
$filtered_networks = collect([]);
$networks->map(function ($network) use ($array_of_networks, $filtered_networks) {
- if ($network === 'host') {
- return; // network-scoped alias is supported only for containers in user defined networks
+ if (isDockerPredefinedNetwork($network)) {
+ return; // Predefined networks cannot be used in network configuration
}
$array_of_networks[$network] = [
diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php
index 3218bf878..bdfbaba48 100644
--- a/bootstrap/helpers/remoteProcess.php
+++ b/bootstrap/helpers/remoteProcess.php
@@ -118,7 +118,7 @@ function () use ($server, $command_string) {
);
}
-function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
+function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false, ?int $timeout = null, bool $disableMultiplexing = false): ?string
{
$command = $command instanceof Collection ? $command->toArray() : $command;
@@ -126,11 +126,12 @@ function instant_remote_process(Collection|array $command, Server $server, bool
$command = parseCommandsByLineForSudo(collect($command), $server);
}
$command_string = implode("\n", $command);
+ $effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout');
return \App\Helpers\SshRetryHandler::retry(
- function () use ($server, $command_string) {
- $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string);
- $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand);
+ function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) {
+ $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing);
+ $process = Process::timeout($effectiveTimeout)->run($sshCommand);
$output = trim($process->output());
$exitCode = $process->exitCode();
diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php
index 3fff2c090..3d2b61b86 100644
--- a/bootstrap/helpers/services.php
+++ b/bootstrap/helpers/services.php
@@ -4,6 +4,7 @@
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Stringable;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
@@ -339,3 +340,54 @@ function parseServiceEnvironmentVariable(string $key): array
'has_port' => $hasPort,
];
}
+
+/**
+ * Apply service-specific application prerequisites after service parse.
+ *
+ * This function configures application-level settings that are required for
+ * specific one-click services to work correctly (e.g., disabling gzip for Beszel,
+ * disabling strip prefix for Appwrite services).
+ *
+ * Must be called AFTER $service->parse() since it requires applications to exist.
+ *
+ * @param Service $service The service to apply prerequisites to
+ */
+function applyServiceApplicationPrerequisites(Service $service): void
+{
+ try {
+ // Extract service name from service name (format: "servicename-uuid")
+ $serviceName = str($service->name)->beforeLast('-')->value();
+
+ // Apply gzip disabling if needed
+ if (array_key_exists($serviceName, NEEDS_TO_DISABLE_GZIP)) {
+ $applicationNames = NEEDS_TO_DISABLE_GZIP[$serviceName];
+ foreach ($applicationNames as $applicationName) {
+ $application = $service->applications()->whereName($applicationName)->first();
+ if ($application) {
+ $application->is_gzip_enabled = false;
+ $application->save();
+ }
+ }
+ }
+
+ // Apply stripprefix disabling if needed
+ if (array_key_exists($serviceName, NEEDS_TO_DISABLE_STRIPPREFIX)) {
+ $applicationNames = NEEDS_TO_DISABLE_STRIPPREFIX[$serviceName];
+ foreach ($applicationNames as $applicationName) {
+ $application = $service->applications()->whereName($applicationName)->first();
+ if ($application) {
+ $application->is_stripprefix_enabled = false;
+ $application->save();
+ }
+ }
+ }
+ } catch (\Throwable $e) {
+ // Log error but don't throw - prerequisites are nice-to-have, not critical
+ Log::error('Failed to apply service application prerequisites', [
+ 'service_id' => $service->id,
+ 'service_name' => $service->name,
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+ }
+}
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 8a278476e..670716164 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -33,6 +33,7 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
+use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\RateLimiter;
@@ -230,7 +231,7 @@ function get_route_parameters(): array
function get_latest_sentinel_version(): string
{
try {
- $response = Http::get('https://cdn.coollabs.io/coolify/versions.json');
+ $response = Http::get(config('constants.coolify.versions_url'));
$versions = $response->json();
return data_get($versions, 'coolify.sentinel.version');
@@ -300,6 +301,24 @@ function generate_application_name(string $git_repository, string $git_branch, ?
return Str::kebab("$git_repository:$git_branch-$cuid");
}
+/**
+ * Sort branches by priority: main first, master second, then alphabetically.
+ *
+ * @param Collection $branches Collection of branch objects with 'name' key
+ */
+function sortBranchesByPriority(Collection $branches): Collection
+{
+ return $branches->sortBy(function ($branch) {
+ $name = data_get($branch, 'name');
+
+ return match ($name) {
+ 'main' => '0_main',
+ 'master' => '1_master',
+ default => '2_'.$name,
+ };
+ })->values();
+}
+
function base_ip(): string
{
if (isDev()) {
@@ -3154,6 +3173,118 @@ function generateDockerComposeServiceName(mixed $services, int $pullRequestId =
return $collection;
}
+function formatBytes(?int $bytes, int $precision = 2): string
+{
+ if ($bytes === null || $bytes === 0) {
+ return '0 B';
+ }
+
+ // Handle negative numbers
+ if ($bytes < 0) {
+ return '0 B';
+ }
+
+ $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
+ $base = 1024;
+ $exponent = floor(log($bytes) / log($base));
+ $exponent = min($exponent, count($units) - 1);
+
+ $value = $bytes / pow($base, $exponent);
+
+ return round($value, $precision).' '.$units[$exponent];
+}
+
+/**
+ * Validates that a file path is safely within the /tmp/ directory.
+ * Protects against path traversal attacks by resolving the real path
+ * and verifying it stays within /tmp/.
+ *
+ * Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled.
+ */
+function isSafeTmpPath(?string $path): bool
+{
+ if (blank($path)) {
+ return false;
+ }
+
+ // URL decode to catch encoded traversal attempts
+ $decodedPath = urldecode($path);
+
+ // Minimum length check - /tmp/x is 6 chars
+ if (strlen($decodedPath) < 6) {
+ return false;
+ }
+
+ // Must start with /tmp/
+ if (! str($decodedPath)->startsWith('/tmp/')) {
+ return false;
+ }
+
+ // Quick check for obvious traversal attempts
+ if (str($decodedPath)->contains('..')) {
+ return false;
+ }
+
+ // Check for null bytes (directory traversal technique)
+ if (str($decodedPath)->contains("\0")) {
+ return false;
+ }
+
+ // Remove any trailing slashes for consistent validation
+ $normalizedPath = rtrim($decodedPath, '/');
+
+ // Normalize the path by removing redundant separators and resolving . and ..
+ // We'll do this manually since realpath() requires the path to exist
+ $parts = explode('/', $normalizedPath);
+ $resolvedParts = [];
+
+ foreach ($parts as $part) {
+ if ($part === '' || $part === '.') {
+ // Skip empty parts (from //) and current directory references
+ continue;
+ } elseif ($part === '..') {
+ // Parent directory - this should have been caught earlier but double-check
+ return false;
+ } else {
+ $resolvedParts[] = $part;
+ }
+ }
+
+ $resolvedPath = '/'.implode('/', $resolvedParts);
+
+ // Final check: resolved path must start with /tmp/
+ // And must have at least one component after /tmp/
+ if (! str($resolvedPath)->startsWith('/tmp/') || $resolvedPath === '/tmp') {
+ return false;
+ }
+
+ // Resolve the canonical /tmp path (handles symlinks like /tmp -> /private/tmp on macOS)
+ $canonicalTmpPath = realpath('/tmp');
+ if ($canonicalTmpPath === false) {
+ // If /tmp doesn't exist, something is very wrong, but allow non-existing paths
+ $canonicalTmpPath = '/tmp';
+ }
+
+ // Calculate dirname once to avoid redundant calls
+ $dirPath = dirname($resolvedPath);
+
+ // If the directory exists, resolve it via realpath to catch symlink attacks
+ if (is_dir($dirPath)) {
+ // For existing paths, resolve to absolute path to catch symlinks
+ $realDir = realpath($dirPath);
+ if ($realDir === false) {
+ return false;
+ }
+
+ // Check if the real directory is within /tmp (or its canonical path)
+ if (! str($realDir)->startsWith('/tmp') && ! str($realDir)->startsWith($canonicalTmpPath)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
/**
* Transform colon-delimited status format to human-readable parentheses format.
*
@@ -3196,3 +3327,57 @@ function formatContainerStatus(string $status): string
return str($status)->headline()->value();
}
}
+
+/**
+ * Check if password confirmation should be skipped.
+ * Returns true if:
+ * - Two-step confirmation is globally disabled
+ * - User has no password (OAuth users)
+ *
+ * Used by modal-confirmation.blade.php to determine if password step should be shown.
+ *
+ * @return bool True if password confirmation should be skipped
+ */
+function shouldSkipPasswordConfirmation(): bool
+{
+ // Skip if two-step confirmation is globally disabled
+ if (data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
+ return true;
+ }
+
+ // Skip if user has no password (OAuth users)
+ if (! Auth::user()?->hasPassword()) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Verify password for two-step confirmation.
+ * Skips verification if:
+ * - Two-step confirmation is globally disabled
+ * - User has no password (OAuth users)
+ *
+ * @param mixed $password The password to verify (may be array if skipped by frontend)
+ * @param \Livewire\Component|null $component Optional Livewire component to add errors to
+ * @return bool True if verification passed (or skipped), false if password is incorrect
+ */
+function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $component = null): bool
+{
+ // Skip if password confirmation should be skipped
+ if (shouldSkipPasswordConfirmation()) {
+ return true;
+ }
+
+ // Verify the password
+ if (! Hash::check($password, Auth::user()->password)) {
+ if ($component) {
+ $component->addError('password', 'The provided password is incorrect.');
+ }
+
+ return false;
+ }
+
+ return true;
+}
diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php
index 48c3a62c3..1a0ae0fbd 100644
--- a/bootstrap/helpers/subscriptions.php
+++ b/bootstrap/helpers/subscriptions.php
@@ -5,39 +5,44 @@
function isSubscriptionActive()
{
- if (! isCloud()) {
- return false;
- }
- $team = currentTeam();
- if (! $team) {
- return false;
- }
- $subscription = $team?->subscription;
+ return once(function () {
+ if (! isCloud()) {
+ return false;
+ }
+ $team = currentTeam();
+ if (! $team) {
+ return false;
+ }
+ $subscription = $team?->subscription;
- if (is_null($subscription)) {
- return false;
- }
- if (isStripe()) {
- return $subscription->stripe_invoice_paid === true;
- }
+ if (is_null($subscription)) {
+ return false;
+ }
+ if (isStripe()) {
+ return $subscription->stripe_invoice_paid === true;
+ }
- return false;
+ return false;
+ });
}
+
function isSubscriptionOnGracePeriod()
{
- $team = currentTeam();
- if (! $team) {
- return false;
- }
- $subscription = $team?->subscription;
- if (! $subscription) {
- return false;
- }
- if (isStripe()) {
- return $subscription->stripe_cancel_at_period_end;
- }
+ return once(function () {
+ $team = currentTeam();
+ if (! $team) {
+ return false;
+ }
+ $subscription = $team?->subscription;
+ if (! $subscription) {
+ return false;
+ }
+ if (isStripe()) {
+ return $subscription->stripe_cancel_at_period_end;
+ }
- return false;
+ return false;
+ });
}
function subscriptionProvider()
{
diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php
index f7336beeb..b8ef84687 100644
--- a/bootstrap/helpers/sudo.php
+++ b/bootstrap/helpers/sudo.php
@@ -23,24 +23,56 @@ function shouldChangeOwnership(string $path): bool
function parseCommandsByLineForSudo(Collection $commands, Server $server): array
{
$commands = $commands->map(function ($line) {
- if (
- ! str(trim($line))->startsWith([
- 'cd',
- 'command',
- 'echo',
- 'true',
- 'if',
- 'fi',
- ])
- ) {
- return "sudo $line";
+ $trimmedLine = trim($line);
+
+ // All bash keywords that should not receive sudo prefix
+ // Using word boundary matching to avoid prefix collisions (e.g., 'do' vs 'docker', 'if' vs 'ifconfig', 'fi' vs 'find')
+ $bashKeywords = [
+ 'cd',
+ 'command',
+ 'declare',
+ 'echo',
+ 'export',
+ 'local',
+ 'readonly',
+ 'return',
+ 'true',
+ 'if',
+ 'fi',
+ 'for',
+ 'done',
+ 'while',
+ 'until',
+ 'case',
+ 'esac',
+ 'select',
+ 'then',
+ 'else',
+ 'elif',
+ 'break',
+ 'continue',
+ 'do',
+ ];
+
+ // Special case: comments (no collision risk with '#')
+ if (str_starts_with($trimmedLine, '#')) {
+ return $line;
}
- if (str(trim($line))->startsWith('if')) {
- return str_replace('if', 'if sudo', $line);
+ // Check all keywords with word boundary matching
+ // Match keyword followed by space, semicolon, or end of line
+ foreach ($bashKeywords as $keyword) {
+ if (preg_match('/^'.preg_quote($keyword, '/').'(\s|;|$)/', $trimmedLine)) {
+ // Special handling for 'if' - insert sudo after 'if '
+ if ($keyword === 'if') {
+ return preg_replace('/^(\s*)if\s+/', '$1if sudo ', $line);
+ }
+
+ return $line;
+ }
}
- return $line;
+ return "sudo $line";
});
$commands = $commands->map(function ($line) use ($server) {
diff --git a/config/constants.php b/config/constants.php
index 161d1e2fd..d9734c48e 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,7 +2,7 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.446',
+ 'version' => '4.0.0-beta.455',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
@@ -12,6 +12,9 @@
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'),
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
+ 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
+ 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
+ 'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
'releases_url' => 'https://cdn.coolify.io/releases.json',
],
diff --git a/config/filesystems.php b/config/filesystems.php
index c2df26c84..ba0921a79 100644
--- a/config/filesystems.php
+++ b/config/filesystems.php
@@ -35,13 +35,6 @@
'throw' => false,
],
- 'webhooks-during-maintenance' => [
- 'driver' => 'local',
- 'root' => storage_path('app/webhooks-during-maintenance'),
- 'visibility' => 'private',
- 'throw' => false,
- ],
-
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
diff --git a/config/session.php b/config/session.php
index 44ca7ded9..c7b176a5a 100644
--- a/config/session.php
+++ b/config/session.php
@@ -18,7 +18,7 @@
|
*/
- 'driver' => env('SESSION_DRIVER', 'database'),
+ 'driver' => env('SESSION_DRIVER', 'redis'),
/*
|--------------------------------------------------------------------------
diff --git a/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php
index 2c92b0e19..a9c59cbc3 100644
--- a/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php
+++ b/database/migrations/2025_10_08_181125_create_cloud_provider_tokens_table.php
@@ -11,16 +11,18 @@
*/
public function up(): void
{
- Schema::create('cloud_provider_tokens', function (Blueprint $table) {
- $table->id();
- $table->foreignId('team_id')->constrained()->onDelete('cascade');
- $table->string('provider');
- $table->text('token');
- $table->string('name')->nullable();
- $table->timestamps();
+ if (! Schema::hasTable('cloud_provider_tokens')) {
+ Schema::create('cloud_provider_tokens', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('team_id')->constrained()->onDelete('cascade');
+ $table->string('provider');
+ $table->text('token');
+ $table->string('name')->nullable();
+ $table->timestamps();
- $table->index(['team_id', 'provider']);
- });
+ $table->index(['team_id', 'provider']);
+ });
+ }
}
/**
diff --git a/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php
index b1c9ec48b..b5cae7d32 100644
--- a/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php
+++ b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php
@@ -11,9 +11,11 @@
*/
public function up(): void
{
- Schema::table('servers', function (Blueprint $table) {
- $table->bigInteger('hetzner_server_id')->nullable()->after('id');
- });
+ if (! Schema::hasColumn('servers', 'hetzner_server_id')) {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->bigInteger('hetzner_server_id')->nullable()->after('id');
+ });
+ }
}
/**
@@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
- Schema::table('servers', function (Blueprint $table) {
- $table->dropColumn('hetzner_server_id');
- });
+ if (Schema::hasColumn('servers', 'hetzner_server_id')) {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->dropColumn('hetzner_server_id');
+ });
+ }
}
};
diff --git a/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php
index a25a4ce83..9f23a7ee9 100644
--- a/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php
+++ b/database/migrations/2025_10_09_095905_add_cloud_provider_token_id_to_servers_table.php
@@ -11,9 +11,11 @@
*/
public function up(): void
{
- Schema::table('servers', function (Blueprint $table) {
- $table->foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null');
- });
+ if (! Schema::hasColumn('servers', 'cloud_provider_token_id')) {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null');
+ });
+ }
}
/**
@@ -21,9 +23,11 @@ public function up(): void
*/
public function down(): void
{
- Schema::table('servers', function (Blueprint $table) {
- $table->dropForeign(['cloud_provider_token_id']);
- $table->dropColumn('cloud_provider_token_id');
- });
+ if (Schema::hasColumn('servers', 'cloud_provider_token_id')) {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->dropForeign(['cloud_provider_token_id']);
+ $table->dropColumn('cloud_provider_token_id');
+ });
+ }
}
};
diff --git a/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php b/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php
index d94c9c76f..54a0a37ba 100644
--- a/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php
+++ b/database/migrations/2025_10_09_113602_add_hetzner_server_status_to_servers_table.php
@@ -11,9 +11,11 @@
*/
public function up(): void
{
- Schema::table('servers', function (Blueprint $table) {
- $table->string('hetzner_server_status')->nullable()->after('hetzner_server_id');
- });
+ if (! Schema::hasColumn('servers', 'hetzner_server_status')) {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->string('hetzner_server_status')->nullable()->after('hetzner_server_id');
+ });
+ }
}
/**
@@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
- Schema::table('servers', function (Blueprint $table) {
- $table->dropColumn('hetzner_server_status');
- });
+ if (Schema::hasColumn('servers', 'hetzner_server_status')) {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->dropColumn('hetzner_server_status');
+ });
+ }
}
};
diff --git a/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php b/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php
index ddb655d2c..b1309713d 100644
--- a/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php
+++ b/database/migrations/2025_10_09_125036_add_is_validating_to_servers_table.php
@@ -11,9 +11,11 @@
*/
public function up(): void
{
- Schema::table('servers', function (Blueprint $table) {
- $table->boolean('is_validating')->default(false)->after('hetzner_server_status');
- });
+ if (! Schema::hasColumn('servers', 'is_validating')) {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->boolean('is_validating')->default(false)->after('hetzner_server_status');
+ });
+ }
}
/**
@@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
- Schema::table('servers', function (Blueprint $table) {
- $table->dropColumn('is_validating');
- });
+ if (Schema::hasColumn('servers', 'is_validating')) {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->dropColumn('is_validating');
+ });
+ }
}
};
diff --git a/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php b/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php
deleted file mode 100644
index de2707557..000000000
--- a/database/migrations/2025_10_10_120001_populate_webhook_notification_settings_for_existing_teams.php
+++ /dev/null
@@ -1,47 +0,0 @@
-get();
-
- foreach ($teams as $team) {
- DB::table('webhook_notification_settings')->updateOrInsert(
- ['team_id' => $team->id],
- [
- 'webhook_enabled' => false,
- 'webhook_url' => null,
- 'deployment_success_webhook_notifications' => false,
- 'deployment_failure_webhook_notifications' => true,
- 'status_change_webhook_notifications' => false,
- 'backup_success_webhook_notifications' => false,
- 'backup_failure_webhook_notifications' => true,
- 'scheduled_task_success_webhook_notifications' => false,
- 'scheduled_task_failure_webhook_notifications' => true,
- 'docker_cleanup_success_webhook_notifications' => false,
- 'docker_cleanup_failure_webhook_notifications' => true,
- 'server_disk_usage_webhook_notifications' => true,
- 'server_reachable_webhook_notifications' => false,
- 'server_unreachable_webhook_notifications' => true,
- 'server_patch_webhook_notifications' => false,
- ]
- );
- }
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- // We don't need to do anything in down() since the webhook_notification_settings
- // table will be dropped by the create migration's down() method
- }
-};
diff --git a/database/migrations/2025_10_10_120002_create_cloud_init_scripts_table.php b/database/migrations/2025_10_10_120002_create_cloud_init_scripts_table.php
deleted file mode 100644
index ae63dc53a..000000000
--- a/database/migrations/2025_10_10_120002_create_cloud_init_scripts_table.php
+++ /dev/null
@@ -1,36 +0,0 @@
-id();
- $table->foreignId('team_id')->constrained()->onDelete('cascade');
- $table->string('name');
- $table->text('script'); // Encrypted in the model
- $table->timestamps();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::dropIfExists('cloud_init_scripts');
- }
-};
diff --git a/database/migrations/2025_10_10_120002_create_webhook_notification_settings_table.php b/database/migrations/2025_10_10_120002_create_webhook_notification_settings_table.php
deleted file mode 100644
index c0689f81e..000000000
--- a/database/migrations/2025_10_10_120002_create_webhook_notification_settings_table.php
+++ /dev/null
@@ -1,52 +0,0 @@
-id();
- $table->foreignId('team_id')->constrained()->cascadeOnDelete();
-
- $table->boolean('webhook_enabled')->default(false);
- $table->text('webhook_url')->nullable();
-
- $table->boolean('deployment_success_webhook_notifications')->default(false);
- $table->boolean('deployment_failure_webhook_notifications')->default(true);
- $table->boolean('status_change_webhook_notifications')->default(false);
- $table->boolean('backup_success_webhook_notifications')->default(false);
- $table->boolean('backup_failure_webhook_notifications')->default(true);
- $table->boolean('scheduled_task_success_webhook_notifications')->default(false);
- $table->boolean('scheduled_task_failure_webhook_notifications')->default(true);
- $table->boolean('docker_cleanup_success_webhook_notifications')->default(false);
- $table->boolean('docker_cleanup_failure_webhook_notifications')->default(true);
- $table->boolean('server_disk_usage_webhook_notifications')->default(true);
- $table->boolean('server_reachable_webhook_notifications')->default(false);
- $table->boolean('server_unreachable_webhook_notifications')->default(true);
- $table->boolean('server_patch_webhook_notifications')->default(false);
-
- $table->unique(['team_id']);
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::dropIfExists('webhook_notification_settings');
- }
-};
diff --git a/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php b/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php
index 56ed2239a..f968d2926 100644
--- a/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php
+++ b/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php
@@ -11,9 +11,11 @@
*/
public function up(): void
{
- Schema::table('instance_settings', function (Blueprint $table) {
- $table->string('dev_helper_version')->nullable();
- });
+ if (! Schema::hasColumn('instance_settings', 'dev_helper_version')) {
+ Schema::table('instance_settings', function (Blueprint $table) {
+ $table->string('dev_helper_version')->nullable();
+ });
+ }
}
/**
@@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
- Schema::table('instance_settings', function (Blueprint $table) {
- $table->dropColumn('dev_helper_version');
- });
+ if (Schema::hasColumn('instance_settings', 'dev_helper_version')) {
+ Schema::table('instance_settings', function (Blueprint $table) {
+ $table->dropColumn('dev_helper_version');
+ });
+ }
}
};
diff --git a/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php b/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php
index 067861e16..59223a506 100644
--- a/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php
+++ b/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php
@@ -11,9 +11,11 @@
*/
public function up(): void
{
- Schema::table('scheduled_tasks', function (Blueprint $table) {
- $table->integer('timeout')->default(300)->after('frequency');
- });
+ if (! Schema::hasColumn('scheduled_tasks', 'timeout')) {
+ Schema::table('scheduled_tasks', function (Blueprint $table) {
+ $table->integer('timeout')->default(300)->after('frequency');
+ });
+ }
}
/**
@@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
- Schema::table('scheduled_tasks', function (Blueprint $table) {
- $table->dropColumn('timeout');
- });
+ if (Schema::hasColumn('scheduled_tasks', 'timeout')) {
+ Schema::table('scheduled_tasks', function (Blueprint $table) {
+ $table->dropColumn('timeout');
+ });
+ }
}
};
diff --git a/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php b/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php
index 14fdd5998..ff45b1fcf 100644
--- a/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php
+++ b/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php
@@ -11,12 +11,29 @@
*/
public function up(): void
{
- Schema::table('scheduled_task_executions', function (Blueprint $table) {
- $table->timestamp('started_at')->nullable()->after('scheduled_task_id');
- $table->integer('retry_count')->default(0)->after('status');
- $table->decimal('duration', 10, 2)->nullable()->after('retry_count')->comment('Duration in seconds');
- $table->text('error_details')->nullable()->after('message');
- });
+ if (! Schema::hasColumn('scheduled_task_executions', 'started_at')) {
+ Schema::table('scheduled_task_executions', function (Blueprint $table) {
+ $table->timestamp('started_at')->nullable()->after('scheduled_task_id');
+ });
+ }
+
+ if (! Schema::hasColumn('scheduled_task_executions', 'retry_count')) {
+ Schema::table('scheduled_task_executions', function (Blueprint $table) {
+ $table->integer('retry_count')->default(0)->after('status');
+ });
+ }
+
+ if (! Schema::hasColumn('scheduled_task_executions', 'duration')) {
+ Schema::table('scheduled_task_executions', function (Blueprint $table) {
+ $table->decimal('duration', 10, 2)->nullable()->after('retry_count')->comment('Duration in seconds');
+ });
+ }
+
+ if (! Schema::hasColumn('scheduled_task_executions', 'error_details')) {
+ Schema::table('scheduled_task_executions', function (Blueprint $table) {
+ $table->text('error_details')->nullable()->after('message');
+ });
+ }
}
/**
@@ -24,8 +41,13 @@ public function up(): void
*/
public function down(): void
{
- Schema::table('scheduled_task_executions', function (Blueprint $table) {
- $table->dropColumn(['started_at', 'retry_count', 'duration', 'error_details']);
- });
+ $columns = ['started_at', 'retry_count', 'duration', 'error_details'];
+ foreach ($columns as $column) {
+ if (Schema::hasColumn('scheduled_task_executions', $column)) {
+ Schema::table('scheduled_task_executions', function (Blueprint $table) use ($column) {
+ $table->dropColumn($column);
+ });
+ }
+ }
}
};
diff --git a/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php b/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php
index 329ac7af9..b9dfd4d9d 100644
--- a/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php
+++ b/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php
@@ -11,11 +11,23 @@
*/
public function up(): void
{
- Schema::table('applications', function (Blueprint $table) {
- $table->integer('restart_count')->default(0)->after('status');
- $table->timestamp('last_restart_at')->nullable()->after('restart_count');
- $table->string('last_restart_type', 10)->nullable()->after('last_restart_at');
- });
+ if (! Schema::hasColumn('applications', 'restart_count')) {
+ Schema::table('applications', function (Blueprint $table) {
+ $table->integer('restart_count')->default(0)->after('status');
+ });
+ }
+
+ if (! Schema::hasColumn('applications', 'last_restart_at')) {
+ Schema::table('applications', function (Blueprint $table) {
+ $table->timestamp('last_restart_at')->nullable()->after('restart_count');
+ });
+ }
+
+ if (! Schema::hasColumn('applications', 'last_restart_type')) {
+ Schema::table('applications', function (Blueprint $table) {
+ $table->string('last_restart_type', 10)->nullable()->after('last_restart_at');
+ });
+ }
}
/**
@@ -23,8 +35,13 @@ public function up(): void
*/
public function down(): void
{
- Schema::table('applications', function (Blueprint $table) {
- $table->dropColumn(['restart_count', 'last_restart_at', 'last_restart_type']);
- });
+ $columns = ['restart_count', 'last_restart_at', 'last_restart_type'];
+ foreach ($columns as $column) {
+ if (Schema::hasColumn('applications', $column)) {
+ Schema::table('applications', function (Blueprint $table) use ($column) {
+ $table->dropColumn($column);
+ });
+ }
+ }
}
};
diff --git a/database/migrations/2025_11_12_130931_add_traefik_version_tracking_to_servers_table.php b/database/migrations/2025_11_12_130931_add_traefik_version_tracking_to_servers_table.php
index 3bab33368..290423526 100644
--- a/database/migrations/2025_11_12_130931_add_traefik_version_tracking_to_servers_table.php
+++ b/database/migrations/2025_11_12_130931_add_traefik_version_tracking_to_servers_table.php
@@ -11,9 +11,11 @@
*/
public function up(): void
{
- Schema::table('servers', function (Blueprint $table) {
- $table->string('detected_traefik_version')->nullable();
- });
+ if (! Schema::hasColumn('servers', 'detected_traefik_version')) {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->string('detected_traefik_version')->nullable();
+ });
+ }
}
/**
@@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
- Schema::table('servers', function (Blueprint $table) {
- $table->dropColumn('detected_traefik_version');
- });
+ if (Schema::hasColumn('servers', 'detected_traefik_version')) {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->dropColumn('detected_traefik_version');
+ });
+ }
}
};
diff --git a/database/migrations/2025_11_12_131252_add_traefik_outdated_to_email_notification_settings.php b/database/migrations/2025_11_12_131252_add_traefik_outdated_to_email_notification_settings.php
index ac509dc71..61a9c80b1 100644
--- a/database/migrations/2025_11_12_131252_add_traefik_outdated_to_email_notification_settings.php
+++ b/database/migrations/2025_11_12_131252_add_traefik_outdated_to_email_notification_settings.php
@@ -11,9 +11,11 @@
*/
public function up(): void
{
- Schema::table('email_notification_settings', function (Blueprint $table) {
- $table->boolean('traefik_outdated_email_notifications')->default(true);
- });
+ if (! Schema::hasColumn('email_notification_settings', 'traefik_outdated_email_notifications')) {
+ Schema::table('email_notification_settings', function (Blueprint $table) {
+ $table->boolean('traefik_outdated_email_notifications')->default(true);
+ });
+ }
}
/**
@@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
- Schema::table('email_notification_settings', function (Blueprint $table) {
- $table->dropColumn('traefik_outdated_email_notifications');
- });
+ if (Schema::hasColumn('email_notification_settings', 'traefik_outdated_email_notifications')) {
+ Schema::table('email_notification_settings', function (Blueprint $table) {
+ $table->dropColumn('traefik_outdated_email_notifications');
+ });
+ }
}
};
diff --git a/database/migrations/2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings.php b/database/migrations/2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings.php
index b7d69e634..3ceb07da8 100644
--- a/database/migrations/2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings.php
+++ b/database/migrations/2025_11_12_133400_add_traefik_outdated_thread_id_to_telegram_notification_settings.php
@@ -11,9 +11,11 @@
*/
public function up(): void
{
- Schema::table('telegram_notification_settings', function (Blueprint $table) {
- $table->text('telegram_notifications_traefik_outdated_thread_id')->nullable();
- });
+ if (! Schema::hasColumn('telegram_notification_settings', 'telegram_notifications_traefik_outdated_thread_id')) {
+ Schema::table('telegram_notification_settings', function (Blueprint $table) {
+ $table->text('telegram_notifications_traefik_outdated_thread_id')->nullable();
+ });
+ }
}
/**
@@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
- Schema::table('telegram_notification_settings', function (Blueprint $table) {
- $table->dropColumn('telegram_notifications_traefik_outdated_thread_id');
- });
+ if (Schema::hasColumn('telegram_notification_settings', 'telegram_notifications_traefik_outdated_thread_id')) {
+ Schema::table('telegram_notification_settings', function (Blueprint $table) {
+ $table->dropColumn('telegram_notifications_traefik_outdated_thread_id');
+ });
+ }
}
};
diff --git a/database/migrations/2025_11_14_114632_add_traefik_outdated_info_to_servers_table.php b/database/migrations/2025_11_14_114632_add_traefik_outdated_info_to_servers_table.php
index 99e10707d..12fca4190 100644
--- a/database/migrations/2025_11_14_114632_add_traefik_outdated_info_to_servers_table.php
+++ b/database/migrations/2025_11_14_114632_add_traefik_outdated_info_to_servers_table.php
@@ -11,9 +11,11 @@
*/
public function up(): void
{
- Schema::table('servers', function (Blueprint $table) {
- $table->json('traefik_outdated_info')->nullable();
- });
+ if (! Schema::hasColumn('servers', 'traefik_outdated_info')) {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->json('traefik_outdated_info')->nullable();
+ });
+ }
}
/**
@@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
- Schema::table('servers', function (Blueprint $table) {
- $table->dropColumn('traefik_outdated_info');
- });
+ if (Schema::hasColumn('servers', 'traefik_outdated_info')) {
+ Schema::table('servers', function (Blueprint $table) {
+ $table->dropColumn('traefik_outdated_info');
+ });
+ }
}
};
diff --git a/database/migrations/2025_11_16_000001_create_webhook_notification_settings_table.php b/database/migrations/2025_11_16_000001_create_webhook_notification_settings_table.php
new file mode 100644
index 000000000..df620bd6e
--- /dev/null
+++ b/database/migrations/2025_11_16_000001_create_webhook_notification_settings_table.php
@@ -0,0 +1,89 @@
+id();
+ $table->foreignId('team_id')->constrained()->cascadeOnDelete();
+
+ $table->boolean('webhook_enabled')->default(false);
+ $table->text('webhook_url')->nullable();
+
+ $table->boolean('deployment_success_webhook_notifications')->default(false);
+ $table->boolean('deployment_failure_webhook_notifications')->default(true);
+ $table->boolean('status_change_webhook_notifications')->default(false);
+ $table->boolean('backup_success_webhook_notifications')->default(false);
+ $table->boolean('backup_failure_webhook_notifications')->default(true);
+ $table->boolean('scheduled_task_success_webhook_notifications')->default(false);
+ $table->boolean('scheduled_task_failure_webhook_notifications')->default(true);
+ $table->boolean('docker_cleanup_success_webhook_notifications')->default(false);
+ $table->boolean('docker_cleanup_failure_webhook_notifications')->default(true);
+ $table->boolean('server_disk_usage_webhook_notifications')->default(true);
+ $table->boolean('server_reachable_webhook_notifications')->default(false);
+ $table->boolean('server_unreachable_webhook_notifications')->default(true);
+ $table->boolean('server_patch_webhook_notifications')->default(false);
+ $table->boolean('traefik_outdated_webhook_notifications')->default(true);
+
+ $table->unique(['team_id']);
+ });
+ }
+
+ // Populate webhook notification settings for existing teams (only if they don't already have settings)
+ DB::table('teams')->chunkById(100, function ($teams) {
+ foreach ($teams as $team) {
+ try {
+ // Check if settings already exist for this team
+ $exists = DB::table('webhook_notification_settings')
+ ->where('team_id', $team->id)
+ ->exists();
+
+ if (! $exists) {
+ // Only insert if no settings exist - don't overwrite existing preferences
+ DB::table('webhook_notification_settings')->insert([
+ 'team_id' => $team->id,
+ 'webhook_enabled' => false,
+ 'webhook_url' => null,
+ 'deployment_success_webhook_notifications' => false,
+ 'deployment_failure_webhook_notifications' => true,
+ 'status_change_webhook_notifications' => false,
+ 'backup_success_webhook_notifications' => false,
+ 'backup_failure_webhook_notifications' => true,
+ 'scheduled_task_success_webhook_notifications' => false,
+ 'scheduled_task_failure_webhook_notifications' => true,
+ 'docker_cleanup_success_webhook_notifications' => false,
+ 'docker_cleanup_failure_webhook_notifications' => true,
+ 'server_disk_usage_webhook_notifications' => true,
+ 'server_reachable_webhook_notifications' => false,
+ 'server_unreachable_webhook_notifications' => true,
+ 'server_patch_webhook_notifications' => false,
+ 'traefik_outdated_webhook_notifications' => true,
+ ]);
+ }
+ } catch (\Throwable $e) {
+ Log::error('Error creating webhook notification settings for team '.$team->id.': '.$e->getMessage());
+ }
+ }
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('webhook_notification_settings');
+ }
+};
diff --git a/database/migrations/2025_11_16_000002_create_cloud_init_scripts_table.php b/database/migrations/2025_11_16_000002_create_cloud_init_scripts_table.php
new file mode 100644
index 000000000..11c5b99a3
--- /dev/null
+++ b/database/migrations/2025_11_16_000002_create_cloud_init_scripts_table.php
@@ -0,0 +1,33 @@
+id();
+ $table->foreignId('team_id')->constrained()->cascadeOnDelete();
+ $table->string('name');
+ $table->text('script'); // Encrypted in the model
+ $table->timestamps();
+ });
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('cloud_init_scripts');
+ }
+};
diff --git a/database/migrations/2025_11_17_092707_add_traefik_outdated_to_notification_settings.php b/database/migrations/2025_11_17_092707_add_traefik_outdated_to_notification_settings.php
index b5cad28b0..a0806ae9f 100644
--- a/database/migrations/2025_11_17_092707_add_traefik_outdated_to_notification_settings.php
+++ b/database/migrations/2025_11_17_092707_add_traefik_outdated_to_notification_settings.php
@@ -19,9 +19,13 @@ public function up(): void
$table->boolean('traefik_outdated_slack_notifications')->default(true);
});
- Schema::table('webhook_notification_settings', function (Blueprint $table) {
- $table->boolean('traefik_outdated_webhook_notifications')->default(true);
- });
+ // Only add if table exists and column doesn't exist
+ if (Schema::hasTable('webhook_notification_settings') &&
+ ! Schema::hasColumn('webhook_notification_settings', 'traefik_outdated_webhook_notifications')) {
+ Schema::table('webhook_notification_settings', function (Blueprint $table) {
+ $table->boolean('traefik_outdated_webhook_notifications')->default(true);
+ });
+ }
Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_telegram_notifications')->default(true);
@@ -45,9 +49,13 @@ public function down(): void
$table->dropColumn('traefik_outdated_slack_notifications');
});
- Schema::table('webhook_notification_settings', function (Blueprint $table) {
- $table->dropColumn('traefik_outdated_webhook_notifications');
- });
+ // Only drop if table and column exist
+ if (Schema::hasTable('webhook_notification_settings') &&
+ Schema::hasColumn('webhook_notification_settings', 'traefik_outdated_webhook_notifications')) {
+ Schema::table('webhook_notification_settings', function (Blueprint $table) {
+ $table->dropColumn('traefik_outdated_webhook_notifications');
+ });
+ }
Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_telegram_notifications');
diff --git a/database/migrations/2025_11_26_124200_add_build_cache_settings_to_application_settings.php b/database/migrations/2025_11_26_124200_add_build_cache_settings_to_application_settings.php
new file mode 100644
index 000000000..f38c9c2a8
--- /dev/null
+++ b/database/migrations/2025_11_26_124200_add_build_cache_settings_to_application_settings.php
@@ -0,0 +1,44 @@
+boolean('inject_build_args_to_dockerfile')->default(true)->after('use_build_secrets');
+ });
+ }
+
+ if (! Schema::hasColumn('application_settings', 'include_source_commit_in_build')) {
+ Schema::table('application_settings', function (Blueprint $table) {
+ $table->boolean('include_source_commit_in_build')->default(false)->after('inject_build_args_to_dockerfile');
+ });
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ if (Schema::hasColumn('application_settings', 'inject_build_args_to_dockerfile')) {
+ Schema::table('application_settings', function (Blueprint $table) {
+ $table->dropColumn('inject_build_args_to_dockerfile');
+ });
+ }
+
+ if (Schema::hasColumn('application_settings', 'include_source_commit_in_build')) {
+ Schema::table('application_settings', function (Blueprint $table) {
+ $table->dropColumn('include_source_commit_in_build');
+ });
+ }
+ }
+};
diff --git a/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php b/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php
new file mode 100644
index 000000000..88c236239
--- /dev/null
+++ b/database/migrations/2025_12_04_134435_add_deployment_queue_limit_to_server_settings.php
@@ -0,0 +1,32 @@
+integer('deployment_queue_limit')->default(25)->after('concurrent_builds');
+ });
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ if (Schema::hasColumn('server_settings', 'deployment_queue_limit')) {
+ Schema::table('server_settings', function (Blueprint $table) {
+ $table->dropColumn('deployment_queue_limit');
+ });
+ }
+ }
+};
diff --git a/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php b/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php
new file mode 100644
index 000000000..3cc027466
--- /dev/null
+++ b/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php
@@ -0,0 +1,26 @@
+integer('docker_images_to_keep')->default(2);
+ });
+ }
+ }
+
+ public function down(): void
+ {
+ if (Schema::hasColumn('application_settings', 'docker_images_to_keep')) {
+ Schema::table('application_settings', function (Blueprint $table) {
+ $table->dropColumn('docker_images_to_keep');
+ });
+ }
+ }
+};
diff --git a/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php b/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php
new file mode 100644
index 000000000..dc70cc9f0
--- /dev/null
+++ b/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php
@@ -0,0 +1,26 @@
+boolean('disable_application_image_retention')->default(false);
+ });
+ }
+ }
+
+ public function down(): void
+ {
+ if (Schema::hasColumn('server_settings', 'disable_application_image_retention')) {
+ Schema::table('server_settings', function (Blueprint $table) {
+ $table->dropColumn('disable_application_image_retention');
+ });
+ }
+ }
+};
diff --git a/database/migrations/2025_12_08_135600_add_performance_indexes.php b/database/migrations/2025_12_08_135600_add_performance_indexes.php
new file mode 100644
index 000000000..680c4b4f7
--- /dev/null
+++ b/database/migrations/2025_12_08_135600_add_performance_indexes.php
@@ -0,0 +1,49 @@
+indexes as [$table, $columns, $indexName]) {
+ if (! $this->indexExists($indexName)) {
+ $columnList = implode(', ', array_map(fn ($col) => "\"$col\"", $columns));
+ DB::statement("CREATE INDEX \"{$indexName}\" ON \"{$table}\" ({$columnList})");
+ }
+ }
+ }
+
+ public function down(): void
+ {
+ foreach ($this->indexes as [, , $indexName]) {
+ DB::statement("DROP INDEX IF EXISTS \"{$indexName}\"");
+ }
+ }
+
+ private function indexExists(string $indexName): bool
+ {
+ $result = DB::selectOne(
+ 'SELECT 1 FROM pg_indexes WHERE indexname = ?',
+ [$indexName]
+ );
+
+ return $result !== null;
+ }
+};
diff --git a/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php b/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php
new file mode 100644
index 000000000..56f44794d
--- /dev/null
+++ b/database/migrations/2025_12_10_135600_add_uuid_to_cloud_provider_tokens.php
@@ -0,0 +1,50 @@
+string('uuid')->nullable()->unique()->after('id');
+ });
+
+ // Generate UUIDs for existing records using chunked processing
+ DB::table('cloud_provider_tokens')
+ ->whereNull('uuid')
+ ->chunkById(500, function ($tokens) {
+ foreach ($tokens as $token) {
+ DB::table('cloud_provider_tokens')
+ ->where('id', $token->id)
+ ->update(['uuid' => (string) new Cuid2]);
+ }
+ });
+
+ // Make uuid non-nullable after filling in values
+ Schema::table('cloud_provider_tokens', function (Blueprint $table) {
+ $table->string('uuid')->nullable(false)->change();
+ });
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ if (Schema::hasColumn('cloud_provider_tokens', 'uuid')) {
+ Schema::table('cloud_provider_tokens', function (Blueprint $table) {
+ $table->dropColumn('uuid');
+ });
+ }
+ }
+};
diff --git a/database/migrations/2025_12_15_143052_trim_s3_storage_credentials.php b/database/migrations/2025_12_15_143052_trim_s3_storage_credentials.php
new file mode 100644
index 000000000..bb59d7358
--- /dev/null
+++ b/database/migrations/2025_12_15_143052_trim_s3_storage_credentials.php
@@ -0,0 +1,112 @@
+select(['id', 'key', 'secret', 'endpoint', 'bucket', 'region'])
+ ->orderBy('id')
+ ->chunk(100, function ($storages) {
+ foreach ($storages as $storage) {
+ try {
+ DB::transaction(function () use ($storage) {
+ $updates = [];
+
+ // Trim endpoint (not encrypted)
+ if ($storage->endpoint !== null) {
+ $trimmedEndpoint = trim($storage->endpoint);
+ if ($trimmedEndpoint !== $storage->endpoint) {
+ $updates['endpoint'] = $trimmedEndpoint;
+ }
+ }
+
+ // Trim bucket (not encrypted)
+ if ($storage->bucket !== null) {
+ $trimmedBucket = trim($storage->bucket);
+ if ($trimmedBucket !== $storage->bucket) {
+ $updates['bucket'] = $trimmedBucket;
+ }
+ }
+
+ // Trim region (not encrypted)
+ if ($storage->region !== null) {
+ $trimmedRegion = trim($storage->region);
+ if ($trimmedRegion !== $storage->region) {
+ $updates['region'] = $trimmedRegion;
+ }
+ }
+
+ // Trim key (encrypted) - verify re-encryption works before saving
+ if ($storage->key !== null) {
+ try {
+ $decryptedKey = Crypt::decryptString($storage->key);
+ $trimmedKey = trim($decryptedKey);
+ if ($trimmedKey !== $decryptedKey) {
+ $encryptedKey = Crypt::encryptString($trimmedKey);
+ // Verify the new encryption is valid
+ if (Crypt::decryptString($encryptedKey) === $trimmedKey) {
+ $updates['key'] = $encryptedKey;
+ } else {
+ Log::warning("S3 storage ID {$storage->id}: Re-encryption verification failed for key, skipping");
+ }
+ }
+ } catch (\Throwable $e) {
+ Log::warning("Could not decrypt S3 storage key for ID {$storage->id}: ".$e->getMessage());
+ }
+ }
+
+ // Trim secret (encrypted) - verify re-encryption works before saving
+ if ($storage->secret !== null) {
+ try {
+ $decryptedSecret = Crypt::decryptString($storage->secret);
+ $trimmedSecret = trim($decryptedSecret);
+ if ($trimmedSecret !== $decryptedSecret) {
+ $encryptedSecret = Crypt::encryptString($trimmedSecret);
+ // Verify the new encryption is valid
+ if (Crypt::decryptString($encryptedSecret) === $trimmedSecret) {
+ $updates['secret'] = $encryptedSecret;
+ } else {
+ Log::warning("S3 storage ID {$storage->id}: Re-encryption verification failed for secret, skipping");
+ }
+ }
+ } catch (\Throwable $e) {
+ Log::warning("Could not decrypt S3 storage secret for ID {$storage->id}: ".$e->getMessage());
+ }
+ }
+
+ if (! empty($updates)) {
+ DB::table('s3_storages')->where('id', $storage->id)->update($updates);
+ Log::info("Trimmed whitespace from S3 storage credentials for ID {$storage->id}", [
+ 'fields_updated' => array_keys($updates),
+ ]);
+ }
+ });
+ } catch (\Throwable $e) {
+ Log::error("Failed to process S3 storage ID {$storage->id}: ".$e->getMessage());
+ // Continue with next record instead of failing entire migration
+ }
+ }
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ // Cannot reverse trimming operation
+ }
+};
diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php
index f012c1534..f5a00fe15 100644
--- a/database/seeders/ApplicationSeeder.php
+++ b/database/seeders/ApplicationSeeder.php
@@ -15,6 +15,7 @@ class ApplicationSeeder extends Seeder
public function run(): void
{
Application::create([
+ 'uuid' => 'docker-compose',
'name' => 'Docker Compose Example',
'repository_project_id' => 603035348,
'git_repository' => 'coollabsio/coolify-examples',
@@ -30,6 +31,7 @@ public function run(): void
'source_type' => GithubApp::class,
]);
Application::create([
+ 'uuid' => 'nodejs',
'name' => 'NodeJS Fastify Example',
'fqdn' => 'http://nodejs.127.0.0.1.sslip.io',
'repository_project_id' => 603035348,
@@ -45,6 +47,7 @@ public function run(): void
'source_type' => GithubApp::class,
]);
Application::create([
+ 'uuid' => 'dockerfile',
'name' => 'Dockerfile Example',
'fqdn' => 'http://dockerfile.127.0.0.1.sslip.io',
'repository_project_id' => 603035348,
@@ -60,6 +63,7 @@ public function run(): void
'source_type' => GithubApp::class,
]);
Application::create([
+ 'uuid' => 'dockerfile-pure',
'name' => 'Pure Dockerfile Example',
'fqdn' => 'http://pure-dockerfile.127.0.0.1.sslip.io',
'git_repository' => 'coollabsio/coolify',
@@ -75,6 +79,23 @@ public function run(): void
'dockerfile' => 'FROM nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+',
+ ]);
+ Application::create([
+ 'uuid' => 'crashloop',
+ 'name' => 'Crash Loop Example',
+ 'git_repository' => 'coollabsio/coolify',
+ 'git_branch' => 'v4.x',
+ 'git_commit_sha' => 'HEAD',
+ 'build_pack' => 'dockerfile',
+ 'ports_exposes' => '80',
+ 'environment_id' => 1,
+ 'destination_id' => 0,
+ 'destination_type' => StandaloneDocker::class,
+ 'source_id' => 0,
+ 'source_type' => GithubApp::class,
+ 'dockerfile' => 'FROM alpine
+CMD ["sh", "-c", "echo Crashing in 5 seconds... && sleep 5 && exit 1"]
',
]);
}
diff --git a/database/seeders/GithubAppSeeder.php b/database/seeders/GithubAppSeeder.php
index b34c00473..10e23c36a 100644
--- a/database/seeders/GithubAppSeeder.php
+++ b/database/seeders/GithubAppSeeder.php
@@ -14,6 +14,7 @@ public function run(): void
{
GithubApp::create([
'id' => 0,
+ 'uuid' => 'github-public',
'name' => 'Public GitHub',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
@@ -22,7 +23,7 @@ public function run(): void
]);
GithubApp::create([
'name' => 'coolify-laravel-dev-public',
- 'uuid' => '69420',
+ 'uuid' => 'github-app',
'organization' => 'coollabsio',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
diff --git a/database/seeders/GitlabAppSeeder.php b/database/seeders/GitlabAppSeeder.php
index ec2b7ec5e..5dfb59902 100644
--- a/database/seeders/GitlabAppSeeder.php
+++ b/database/seeders/GitlabAppSeeder.php
@@ -14,6 +14,7 @@ public function run(): void
{
GitlabApp::create([
'id' => 1,
+ 'uuid' => 'gitlab-public',
'name' => 'Public GitLab',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
diff --git a/database/seeders/PrivateKeySeeder.php b/database/seeders/PrivateKeySeeder.php
index 6b44d0867..0aa4153b3 100644
--- a/database/seeders/PrivateKeySeeder.php
+++ b/database/seeders/PrivateKeySeeder.php
@@ -13,6 +13,7 @@ class PrivateKeySeeder extends Seeder
public function run(): void
{
PrivateKey::create([
+ 'uuid' => 'ssh',
'team_id' => 0,
'name' => 'Testing Host Key',
'description' => 'This is a test docker container',
@@ -27,6 +28,7 @@ public function run(): void
]);
PrivateKey::create([
+ 'uuid' => 'github-key',
'team_id' => 0,
'name' => 'development-github-app',
'description' => 'This is the key for using the development GitHub app',
diff --git a/database/seeders/ProjectSeeder.php b/database/seeders/ProjectSeeder.php
index 33cd8cd06..ab8e54051 100644
--- a/database/seeders/ProjectSeeder.php
+++ b/database/seeders/ProjectSeeder.php
@@ -9,10 +9,14 @@ class ProjectSeeder extends Seeder
{
public function run(): void
{
- Project::create([
+ $project = Project::create([
+ 'uuid' => 'project',
'name' => 'My first project',
'description' => 'This is a test project in development',
'team_id' => 0,
]);
+
+ // Update the auto-created environment with a deterministic UUID
+ $project->environments()->first()->update(['uuid' => 'production']);
}
}
diff --git a/database/seeders/S3StorageSeeder.php b/database/seeders/S3StorageSeeder.php
index 9fa531447..b38df6ad5 100644
--- a/database/seeders/S3StorageSeeder.php
+++ b/database/seeders/S3StorageSeeder.php
@@ -13,6 +13,7 @@ class S3StorageSeeder extends Seeder
public function run(): void
{
S3Storage::create([
+ 'uuid' => 'minio',
'name' => 'Local MinIO',
'description' => 'Local MinIO S3 Storage',
'key' => 'minioadmin',
diff --git a/database/seeders/ServerSeeder.php b/database/seeders/ServerSeeder.php
index d32843107..2d8746691 100644
--- a/database/seeders/ServerSeeder.php
+++ b/database/seeders/ServerSeeder.php
@@ -13,6 +13,7 @@ public function run(): void
{
Server::create([
'id' => 0,
+ 'uuid' => 'localhost',
'name' => 'localhost',
'description' => 'This is a test docker container in development mode',
'ip' => 'coolify-testing-host',
diff --git a/database/seeders/StandaloneDockerSeeder.php b/database/seeders/StandaloneDockerSeeder.php
index a466de56b..e31c62d9f 100644
--- a/database/seeders/StandaloneDockerSeeder.php
+++ b/database/seeders/StandaloneDockerSeeder.php
@@ -15,6 +15,7 @@ public function run(): void
if (StandaloneDocker::find(0) == null) {
StandaloneDocker::create([
'id' => 0,
+ 'uuid' => 'docker',
'name' => 'Standalone Docker 1',
'network' => 'coolify',
'server_id' => 0,
diff --git a/database/seeders/StandalonePostgresqlSeeder.php b/database/seeders/StandalonePostgresqlSeeder.php
index 1fc96a610..59ee6fd42 100644
--- a/database/seeders/StandalonePostgresqlSeeder.php
+++ b/database/seeders/StandalonePostgresqlSeeder.php
@@ -11,6 +11,7 @@ class StandalonePostgresqlSeeder extends Seeder
public function run(): void
{
StandalonePostgresql::create([
+ 'uuid' => 'postgresql',
'name' => 'Local PostgreSQL',
'description' => 'Local PostgreSQL for testing',
'postgres_password' => 'postgres',
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index b90f126a2..46e0e88e5 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -11,7 +11,6 @@ services:
- /data/coolify/databases:/var/www/html/storage/app/databases
- /data/coolify/services:/var/www/html/storage/app/services
- /data/coolify/backups:/var/www/html/storage/app/backups
- - /data/coolify/webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance
environment:
- APP_ENV=${APP_ENV:-production}
- PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M}
diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml
index cd4a307aa..3116a4185 100644
--- a/docker-compose.windows.yml
+++ b/docker-compose.windows.yml
@@ -25,7 +25,6 @@ services:
- ./databases:/var/www/html/storage/app/databases
- ./services:/var/www/html/storage/app/services
- ./backups:/var/www/html/storage/app/backups
- - ./webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance
env_file:
- .env
environment:
@@ -75,13 +74,7 @@ services:
POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: "${DB_DATABASE:-coolify}"
healthcheck:
- test:
- [
- "CMD-SHELL",
- "pg_isready -U ${DB_USERNAME}",
- "-d",
- "${DB_DATABASE:-coolify}"
- ]
+ test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ]
interval: 5s
retries: 10
timeout: 2s
@@ -121,7 +114,7 @@ services:
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
healthcheck:
- test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"]
+ test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s
retries: 10
timeout: 2s
diff --git a/openapi.json b/openapi.json
index dd3c6783a..fe8ca863e 100644
--- a/openapi.json
+++ b/openapi.json
@@ -361,6 +361,11 @@
"force_domain_override": {
"type": "boolean",
"description": "Force domain usage even if conflicts are detected. Default is false."
+ },
+ "autogenerate_domain": {
+ "type": "boolean",
+ "default": true,
+ "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
}
},
"type": "object"
@@ -771,6 +776,11 @@
"force_domain_override": {
"type": "boolean",
"description": "Force domain usage even if conflicts are detected. Default is false."
+ },
+ "autogenerate_domain": {
+ "type": "boolean",
+ "default": true,
+ "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
}
},
"type": "object"
@@ -1181,6 +1191,11 @@
"force_domain_override": {
"type": "boolean",
"description": "Force domain usage even if conflicts are detected. Default is false."
+ },
+ "autogenerate_domain": {
+ "type": "boolean",
+ "default": true,
+ "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
}
},
"type": "object"
@@ -1520,6 +1535,11 @@
"force_domain_override": {
"type": "boolean",
"description": "Force domain usage even if conflicts are detected. Default is false."
+ },
+ "autogenerate_domain": {
+ "type": "boolean",
+ "default": true,
+ "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
}
},
"type": "object"
@@ -1842,6 +1862,11 @@
"force_domain_override": {
"type": "boolean",
"description": "Force domain usage even if conflicts are detected. Default is false."
+ },
+ "autogenerate_domain": {
+ "type": "boolean",
+ "default": true,
+ "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
}
},
"type": "object"
@@ -3275,6 +3300,387 @@
]
}
},
+ "\/cloud-tokens": {
+ "get": {
+ "tags": [
+ "Cloud Tokens"
+ ],
+ "summary": "List Cloud Provider Tokens",
+ "description": "List all cloud provider tokens for the authenticated team.",
+ "operationId": "list-cloud-tokens",
+ "responses": {
+ "200": {
+ "description": "Get all cloud provider tokens.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "uuid": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string",
+ "enum": [
+ "hetzner",
+ "digitalocean"
+ ]
+ },
+ "team_id": {
+ "type": "integer"
+ },
+ "servers_count": {
+ "type": "integer"
+ },
+ "created_at": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Cloud Tokens"
+ ],
+ "summary": "Create Cloud Provider Token",
+ "description": "Create a new cloud provider token. The token will be validated before being stored.",
+ "operationId": "create-cloud-token",
+ "requestBody": {
+ "description": "Cloud provider token details",
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "provider",
+ "token",
+ "name"
+ ],
+ "properties": {
+ "provider": {
+ "type": "string",
+ "enum": [
+ "hetzner",
+ "digitalocean"
+ ],
+ "example": "hetzner",
+ "description": "The cloud provider."
+ },
+ "token": {
+ "type": "string",
+ "example": "your-api-token-here",
+ "description": "The API token for the cloud provider."
+ },
+ "name": {
+ "type": "string",
+ "example": "My Hetzner Token",
+ "description": "A friendly name for the token."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Cloud provider token created.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "uuid": {
+ "type": "string",
+ "example": "og888os",
+ "description": "The UUID of the token."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/cloud-tokens\/{uuid}": {
+ "get": {
+ "tags": [
+ "Cloud Tokens"
+ ],
+ "summary": "Get Cloud Provider Token",
+ "description": "Get cloud provider token by UUID.",
+ "operationId": "get-cloud-token-by-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "Token UUID",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Get cloud provider token by UUID",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "uuid": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "provider": {
+ "type": "string"
+ },
+ "team_id": {
+ "type": "integer"
+ },
+ "servers_count": {
+ "type": "integer"
+ },
+ "created_at": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "delete": {
+ "tags": [
+ "Cloud Tokens"
+ ],
+ "summary": "Delete Cloud Provider Token",
+ "description": "Delete cloud provider token by UUID. Cannot delete if token is used by any servers.",
+ "operationId": "delete-cloud-token-by-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the cloud provider token.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "format": "uuid"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Cloud provider token deleted.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Cloud provider token deleted."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "patch": {
+ "tags": [
+ "Cloud Tokens"
+ ],
+ "summary": "Update Cloud Provider Token",
+ "description": "Update cloud provider token name.",
+ "operationId": "update-cloud-token-by-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "Token UUID",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Cloud provider token updated.",
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The friendly name for the token."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Cloud provider token updated.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "uuid": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/cloud-tokens\/{uuid}\/validate": {
+ "post": {
+ "tags": [
+ "Cloud Tokens"
+ ],
+ "summary": "Validate Cloud Provider Token",
+ "description": "Validate a cloud provider token against the provider API.",
+ "operationId": "validate-cloud-token-by-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "Token UUID",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Token validation result.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "valid": {
+ "type": "boolean",
+ "example": true
+ },
+ "message": {
+ "type": "string",
+ "example": "Token is valid."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/databases": {
"get": {
"tags": [
@@ -6314,6 +6720,486 @@
]
}
},
+ "\/hetzner\/locations": {
+ "get": {
+ "tags": [
+ "Hetzner"
+ ],
+ "summary": "Get Hetzner Locations",
+ "description": "Get all available Hetzner datacenter locations.",
+ "operationId": "get-hetzner-locations",
+ "parameters": [
+ {
+ "name": "cloud_provider_token_uuid",
+ "in": "query",
+ "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "cloud_provider_token_id",
+ "in": "query",
+ "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
+ "required": false,
+ "deprecated": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of Hetzner locations.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "country": {
+ "type": "string"
+ },
+ "city": {
+ "type": "string"
+ },
+ "latitude": {
+ "type": "number"
+ },
+ "longitude": {
+ "type": "number"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/hetzner\/server-types": {
+ "get": {
+ "tags": [
+ "Hetzner"
+ ],
+ "summary": "Get Hetzner Server Types",
+ "description": "Get all available Hetzner server types (instance sizes).",
+ "operationId": "get-hetzner-server-types",
+ "parameters": [
+ {
+ "name": "cloud_provider_token_uuid",
+ "in": "query",
+ "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "cloud_provider_token_id",
+ "in": "query",
+ "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
+ "required": false,
+ "deprecated": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of Hetzner server types.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "cores": {
+ "type": "integer"
+ },
+ "memory": {
+ "type": "number"
+ },
+ "disk": {
+ "type": "integer"
+ },
+ "prices": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "location": {
+ "type": "string",
+ "description": "Datacenter location name"
+ },
+ "price_hourly": {
+ "type": "object",
+ "properties": {
+ "net": {
+ "type": "string"
+ },
+ "gross": {
+ "type": "string"
+ }
+ }
+ },
+ "price_monthly": {
+ "type": "object",
+ "properties": {
+ "net": {
+ "type": "string"
+ },
+ "gross": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/hetzner\/images": {
+ "get": {
+ "tags": [
+ "Hetzner"
+ ],
+ "summary": "Get Hetzner Images",
+ "description": "Get all available Hetzner system images (operating systems).",
+ "operationId": "get-hetzner-images",
+ "parameters": [
+ {
+ "name": "cloud_provider_token_uuid",
+ "in": "query",
+ "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "cloud_provider_token_id",
+ "in": "query",
+ "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
+ "required": false,
+ "deprecated": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of Hetzner images.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ },
+ "os_flavor": {
+ "type": "string"
+ },
+ "os_version": {
+ "type": "string"
+ },
+ "architecture": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/hetzner\/ssh-keys": {
+ "get": {
+ "tags": [
+ "Hetzner"
+ ],
+ "summary": "Get Hetzner SSH Keys",
+ "description": "Get all SSH keys stored in the Hetzner account.",
+ "operationId": "get-hetzner-ssh-keys",
+ "parameters": [
+ {
+ "name": "cloud_provider_token_uuid",
+ "in": "query",
+ "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "cloud_provider_token_id",
+ "in": "query",
+ "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
+ "required": false,
+ "deprecated": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of Hetzner SSH keys.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "fingerprint": {
+ "type": "string"
+ },
+ "public_key": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/servers\/hetzner": {
+ "post": {
+ "tags": [
+ "Hetzner"
+ ],
+ "summary": "Create Hetzner Server",
+ "description": "Create a new server on Hetzner and register it in Coolify.",
+ "operationId": "create-hetzner-server",
+ "requestBody": {
+ "description": "Hetzner server creation parameters",
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "location",
+ "server_type",
+ "image",
+ "private_key_uuid"
+ ],
+ "properties": {
+ "cloud_provider_token_uuid": {
+ "type": "string",
+ "example": "abc123",
+ "description": "Cloud provider token UUID. Required if cloud_provider_token_id is not provided."
+ },
+ "cloud_provider_token_id": {
+ "type": "string",
+ "example": "abc123",
+ "description": "Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.",
+ "deprecated": true
+ },
+ "location": {
+ "type": "string",
+ "example": "nbg1",
+ "description": "Hetzner location name"
+ },
+ "server_type": {
+ "type": "string",
+ "example": "cx11",
+ "description": "Hetzner server type name"
+ },
+ "image": {
+ "type": "integer",
+ "example": 15512617,
+ "description": "Hetzner image ID"
+ },
+ "name": {
+ "type": "string",
+ "example": "my-server",
+ "description": "Server name (auto-generated if not provided)"
+ },
+ "private_key_uuid": {
+ "type": "string",
+ "example": "xyz789",
+ "description": "Private key UUID"
+ },
+ "enable_ipv4": {
+ "type": "boolean",
+ "example": true,
+ "description": "Enable IPv4 (default: true)"
+ },
+ "enable_ipv6": {
+ "type": "boolean",
+ "example": true,
+ "description": "Enable IPv6 (default: true)"
+ },
+ "hetzner_ssh_key_ids": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ },
+ "description": "Additional Hetzner SSH key IDs"
+ },
+ "cloud_init_script": {
+ "type": "string",
+ "description": "Cloud-init YAML script (optional)"
+ },
+ "instant_validate": {
+ "type": "boolean",
+ "example": false,
+ "description": "Validate server immediately after creation"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Hetzner server created.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "uuid": {
+ "type": "string",
+ "example": "og888os",
+ "description": "The UUID of the server."
+ },
+ "hetzner_server_id": {
+ "type": "integer",
+ "description": "The Hetzner server ID."
+ },
+ "ip": {
+ "type": "string",
+ "description": "The server IP address."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ },
+ "429": {
+ "$ref": "#\/components\/responses\/429"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/version": {
"get": {
"summary": "Version",
@@ -9816,6 +10702,9 @@
"concurrent_builds": {
"type": "integer"
},
+ "deployment_queue_limit": {
+ "type": "integer"
+ },
"dynamic_timeout": {
"type": "integer"
},
@@ -10174,6 +11063,31 @@
}
}
}
+ },
+ "429": {
+ "description": "Rate limit exceeded.",
+ "headers": {
+ "Retry-After": {
+ "description": "Number of seconds to wait before retrying.",
+ "schema": {
+ "type": "integer",
+ "example": 60
+ }
+ }
+ },
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Rate limit exceeded. Please try again later."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
}
},
"securitySchemes": {
@@ -10189,6 +11103,10 @@
"name": "Applications",
"description": "Applications"
},
+ {
+ "name": "Cloud Tokens",
+ "description": "Cloud Tokens"
+ },
{
"name": "Databases",
"description": "Databases"
@@ -10201,6 +11119,10 @@
"name": "GitHub Apps",
"description": "GitHub Apps"
},
+ {
+ "name": "Hetzner",
+ "description": "Hetzner"
+ },
{
"name": "Projects",
"description": "Projects"
diff --git a/openapi.yaml b/openapi.yaml
index 754b7ec6f..a7faa8c72 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -265,6 +265,10 @@ paths:
force_domain_override:
type: boolean
description: 'Force domain usage even if conflicts are detected. Default is false.'
+ autogenerate_domain:
+ type: boolean
+ default: true
+ description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object
responses:
'201':
@@ -531,6 +535,10 @@ paths:
force_domain_override:
type: boolean
description: 'Force domain usage even if conflicts are detected. Default is false.'
+ autogenerate_domain:
+ type: boolean
+ default: true
+ description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object
responses:
'201':
@@ -797,6 +805,10 @@ paths:
force_domain_override:
type: boolean
description: 'Force domain usage even if conflicts are detected. Default is false.'
+ autogenerate_domain:
+ type: boolean
+ default: true
+ description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object
responses:
'201':
@@ -1010,6 +1022,10 @@ paths:
force_domain_override:
type: boolean
description: 'Force domain usage even if conflicts are detected. Default is false.'
+ autogenerate_domain:
+ type: boolean
+ default: true
+ description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object
responses:
'201':
@@ -1214,6 +1230,10 @@ paths:
force_domain_override:
type: boolean
description: 'Force domain usage even if conflicts are detected. Default is false.'
+ autogenerate_domain:
+ type: boolean
+ default: true
+ description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Default: true."
type: object
responses:
'201':
@@ -2075,6 +2095,224 @@ paths:
security:
-
bearerAuth: []
+ /cloud-tokens:
+ get:
+ tags:
+ - 'Cloud Tokens'
+ summary: 'List Cloud Provider Tokens'
+ description: 'List all cloud provider tokens for the authenticated team.'
+ operationId: list-cloud-tokens
+ responses:
+ '200':
+ description: 'Get all cloud provider tokens.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ properties: { uuid: { type: string }, name: { type: string }, provider: { type: string, enum: [hetzner, digitalocean] }, team_id: { type: integer }, servers_count: { type: integer }, created_at: { type: string }, updated_at: { type: string } }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ security:
+ -
+ bearerAuth: []
+ post:
+ tags:
+ - 'Cloud Tokens'
+ summary: 'Create Cloud Provider Token'
+ description: 'Create a new cloud provider token. The token will be validated before being stored.'
+ operationId: create-cloud-token
+ requestBody:
+ description: 'Cloud provider token details'
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - provider
+ - token
+ - name
+ properties:
+ provider:
+ type: string
+ enum: [hetzner, digitalocean]
+ example: hetzner
+ description: 'The cloud provider.'
+ token:
+ type: string
+ example: your-api-token-here
+ description: 'The API token for the cloud provider.'
+ name:
+ type: string
+ example: 'My Hetzner Token'
+ description: 'A friendly name for the token.'
+ type: object
+ responses:
+ '201':
+ description: 'Cloud provider token created.'
+ content:
+ application/json:
+ schema:
+ properties:
+ uuid: { type: string, example: og888os, description: 'The UUID of the token.' }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/cloud-tokens/{uuid}':
+ get:
+ tags:
+ - 'Cloud Tokens'
+ summary: 'Get Cloud Provider Token'
+ description: 'Get cloud provider token by UUID.'
+ operationId: get-cloud-token-by-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'Token UUID'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Get cloud provider token by UUID'
+ content:
+ application/json:
+ schema:
+ properties:
+ uuid: { type: string }
+ name: { type: string }
+ provider: { type: string }
+ team_id: { type: integer }
+ servers_count: { type: integer }
+ created_at: { type: string }
+ updated_at: { type: string }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ delete:
+ tags:
+ - 'Cloud Tokens'
+ summary: 'Delete Cloud Provider Token'
+ description: 'Delete cloud provider token by UUID. Cannot delete if token is used by any servers.'
+ operationId: delete-cloud-token-by-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the cloud provider token.'
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ '200':
+ description: 'Cloud provider token deleted.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'Cloud provider token deleted.' }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ patch:
+ tags:
+ - 'Cloud Tokens'
+ summary: 'Update Cloud Provider Token'
+ description: 'Update cloud provider token name.'
+ operationId: update-cloud-token-by-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'Token UUID'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ description: 'Cloud provider token updated.'
+ required: true
+ content:
+ application/json:
+ schema:
+ properties:
+ name:
+ type: string
+ description: 'The friendly name for the token.'
+ type: object
+ responses:
+ '200':
+ description: 'Cloud provider token updated.'
+ content:
+ application/json:
+ schema:
+ properties:
+ uuid: { type: string }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/cloud-tokens/{uuid}/validate':
+ post:
+ tags:
+ - 'Cloud Tokens'
+ summary: 'Validate Cloud Provider Token'
+ description: 'Validate a cloud provider token against the provider API.'
+ operationId: validate-cloud-token-by-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'Token UUID'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Token validation result.'
+ content:
+ application/json:
+ schema:
+ properties:
+ valid: { type: boolean, example: true }
+ message: { type: string, example: 'Token is valid.' }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
/databases:
get:
tags:
@@ -4084,6 +4322,258 @@ paths:
security:
-
bearerAuth: []
+ /hetzner/locations:
+ get:
+ tags:
+ - Hetzner
+ summary: 'Get Hetzner Locations'
+ description: 'Get all available Hetzner datacenter locations.'
+ operationId: get-hetzner-locations
+ parameters:
+ -
+ name: cloud_provider_token_uuid
+ in: query
+ description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'
+ required: false
+ schema:
+ type: string
+ -
+ name: cloud_provider_token_id
+ in: query
+ description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
+ required: false
+ deprecated: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'List of Hetzner locations.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ properties: { id: { type: integer }, name: { type: string }, description: { type: string }, country: { type: string }, city: { type: string }, latitude: { type: number }, longitude: { type: number } }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ /hetzner/server-types:
+ get:
+ tags:
+ - Hetzner
+ summary: 'Get Hetzner Server Types'
+ description: 'Get all available Hetzner server types (instance sizes).'
+ operationId: get-hetzner-server-types
+ parameters:
+ -
+ name: cloud_provider_token_uuid
+ in: query
+ description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'
+ required: false
+ schema:
+ type: string
+ -
+ name: cloud_provider_token_id
+ in: query
+ description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
+ required: false
+ deprecated: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'List of Hetzner server types.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ properties: { id: { type: integer }, name: { type: string }, description: { type: string }, cores: { type: integer }, memory: { type: number }, disk: { type: integer }, prices: { type: array, items: { type: object, properties: { location: { type: string, description: 'Datacenter location name' }, price_hourly: { type: object, properties: { net: { type: string }, gross: { type: string } } }, price_monthly: { type: object, properties: { net: { type: string }, gross: { type: string } } } } } } }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ /hetzner/images:
+ get:
+ tags:
+ - Hetzner
+ summary: 'Get Hetzner Images'
+ description: 'Get all available Hetzner system images (operating systems).'
+ operationId: get-hetzner-images
+ parameters:
+ -
+ name: cloud_provider_token_uuid
+ in: query
+ description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'
+ required: false
+ schema:
+ type: string
+ -
+ name: cloud_provider_token_id
+ in: query
+ description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
+ required: false
+ deprecated: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'List of Hetzner images.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ properties: { id: { type: integer }, name: { type: string }, description: { type: string }, type: { type: string }, os_flavor: { type: string }, os_version: { type: string }, architecture: { type: string } }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ /hetzner/ssh-keys:
+ get:
+ tags:
+ - Hetzner
+ summary: 'Get Hetzner SSH Keys'
+ description: 'Get all SSH keys stored in the Hetzner account.'
+ operationId: get-hetzner-ssh-keys
+ parameters:
+ -
+ name: cloud_provider_token_uuid
+ in: query
+ description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'
+ required: false
+ schema:
+ type: string
+ -
+ name: cloud_provider_token_id
+ in: query
+ description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
+ required: false
+ deprecated: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'List of Hetzner SSH keys.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ properties: { id: { type: integer }, name: { type: string }, fingerprint: { type: string }, public_key: { type: string } }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ /servers/hetzner:
+ post:
+ tags:
+ - Hetzner
+ summary: 'Create Hetzner Server'
+ description: 'Create a new server on Hetzner and register it in Coolify.'
+ operationId: create-hetzner-server
+ requestBody:
+ description: 'Hetzner server creation parameters'
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - location
+ - server_type
+ - image
+ - private_key_uuid
+ properties:
+ cloud_provider_token_uuid:
+ type: string
+ example: abc123
+ description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'
+ cloud_provider_token_id:
+ type: string
+ example: abc123
+ description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.'
+ deprecated: true
+ location:
+ type: string
+ example: nbg1
+ description: 'Hetzner location name'
+ server_type:
+ type: string
+ example: cx11
+ description: 'Hetzner server type name'
+ image:
+ type: integer
+ example: 15512617
+ description: 'Hetzner image ID'
+ name:
+ type: string
+ example: my-server
+ description: 'Server name (auto-generated if not provided)'
+ private_key_uuid:
+ type: string
+ example: xyz789
+ description: 'Private key UUID'
+ enable_ipv4:
+ type: boolean
+ example: true
+ description: 'Enable IPv4 (default: true)'
+ enable_ipv6:
+ type: boolean
+ example: true
+ description: 'Enable IPv6 (default: true)'
+ hetzner_ssh_key_ids:
+ type: array
+ items: { type: integer }
+ description: 'Additional Hetzner SSH key IDs'
+ cloud_init_script:
+ type: string
+ description: 'Cloud-init YAML script (optional)'
+ instant_validate:
+ type: boolean
+ example: false
+ description: 'Validate server immediately after creation'
+ type: object
+ responses:
+ '201':
+ description: 'Hetzner server created.'
+ content:
+ application/json:
+ schema:
+ properties:
+ uuid: { type: string, example: og888os, description: 'The UUID of the server.' }
+ hetzner_server_id: { type: integer, description: 'The Hetzner server ID.' }
+ ip: { type: string, description: 'The server IP address.' }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ '429':
+ $ref: '#/components/responses/429'
+ security:
+ -
+ bearerAuth: []
/version:
get:
summary: Version
@@ -6312,6 +6802,8 @@ components:
type: integer
concurrent_builds:
type: integer
+ deployment_queue_limit:
+ type: integer
dynamic_timeout:
type: integer
force_disabled:
@@ -6556,6 +7048,22 @@ components:
type: array
items: { type: string }
type: object
+ '429':
+ description: 'Rate limit exceeded.'
+ headers:
+ Retry-After:
+ description: 'Number of seconds to wait before retrying.'
+ schema:
+ type: integer
+ example: 60
+ content:
+ application/json:
+ schema:
+ properties:
+ message:
+ type: string
+ example: 'Rate limit exceeded. Please try again later.'
+ type: object
securitySchemes:
bearerAuth:
type: http
@@ -6565,6 +7073,9 @@ tags:
-
name: Applications
description: Applications
+ -
+ name: 'Cloud Tokens'
+ description: 'Cloud Tokens'
-
name: Databases
description: Databases
@@ -6574,6 +7085,9 @@ tags:
-
name: 'GitHub Apps'
description: 'GitHub Apps'
+ -
+ name: Hetzner
+ description: Hetzner
-
name: Projects
description: Projects
diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml
index b90f126a2..46e0e88e5 100644
--- a/other/nightly/docker-compose.prod.yml
+++ b/other/nightly/docker-compose.prod.yml
@@ -11,7 +11,6 @@ services:
- /data/coolify/databases:/var/www/html/storage/app/databases
- /data/coolify/services:/var/www/html/storage/app/services
- /data/coolify/backups:/var/www/html/storage/app/backups
- - /data/coolify/webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance
environment:
- APP_ENV=${APP_ENV:-production}
- PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M}
diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml
index 09ce3ead3..6306ab381 100644
--- a/other/nightly/docker-compose.windows.yml
+++ b/other/nightly/docker-compose.windows.yml
@@ -25,7 +25,6 @@ services:
- ./databases:/var/www/html/storage/app/databases
- ./services:/var/www/html/storage/app/services
- ./backups:/var/www/html/storage/app/backups
- - ./webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance
env_file:
- .env
environment:
@@ -75,13 +74,7 @@ services:
POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: "${DB_DATABASE:-coolify}"
healthcheck:
- test:
- [
- "CMD-SHELL",
- "pg_isready -U ${DB_USERNAME}",
- "-d",
- "${DB_DATABASE:-coolify}"
- ]
+ test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ]
interval: 5s
retries: 10
timeout: 2s
@@ -121,7 +114,7 @@ services:
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
healthcheck:
- test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"]
+ test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
interval: 5s
retries: 10
timeout: 2s
diff --git a/other/nightly/install.sh b/other/nightly/install.sh
index bcd37e71f..b037fe382 100755
--- a/other/nightly/install.sh
+++ b/other/nightly/install.sh
@@ -223,7 +223,7 @@ if [ "$WARNING_SPACE" = true ]; then
sleep 5
fi
-mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,sentinel}
+mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel}
mkdir -p /data/coolify/ssh/{keys,mux}
mkdir -p /data/coolify/proxy/dynamic
@@ -288,9 +288,9 @@ if [ "$OS_TYPE" = 'amzn' ]; then
dnf install -y findutils >/dev/null
fi
-LATEST_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $2}' | tr -d ',')
-LATEST_HELPER_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $6}' | tr -d ',')
-LATEST_REALTIME_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $8}' | tr -d ',')
+LATEST_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $2}' | tr -d ',')
+LATEST_HELPER_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $6}' | tr -d ',')
+LATEST_REALTIME_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $8}' | tr -d ',')
if [ -z "$LATEST_HELPER_VERSION" ]; then
LATEST_HELPER_VERSION=latest
@@ -705,10 +705,10 @@ else
fi
echo -e "5. Download required files from CDN. "
-curl -fsSL $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml
-curl -fsSL $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
-curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production
-curl -fsSL $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh
+curl -fsSL -L $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml
+curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
+curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production
+curl -fsSL -L $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh
echo -e "6. Setting up environment variable file"
diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh
index 14eede4ee..0d3896647 100644
--- a/other/nightly/upgrade.sh
+++ b/other/nightly/upgrade.sh
@@ -64,9 +64,45 @@ if [ -f /root/.docker/config.json ]; then
DOCKER_CONFIG_MOUNT="-v /root/.docker/config.json:/root/.docker/config.json"
fi
-if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
- echo "docker-compose.custom.yml detected." >>"$LOGFILE"
- docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
-else
- docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --force-recreate --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
-fi
+# Pull all required images before stopping containers
+# This ensures we don't take down the system if image pull fails (rate limits, network issues, etc.)
+echo "Pulling required Docker images..." >>"$LOGFILE"
+docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE}" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify image. Aborting upgrade." >>"$LOGFILE"; exit 1; }
+docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify helper image. Aborting upgrade." >>"$LOGFILE"; exit 1; }
+docker pull postgres:15-alpine >>"$LOGFILE" 2>&1 || { echo "Failed to pull PostgreSQL image. Aborting upgrade." >>"$LOGFILE"; exit 1; }
+docker pull redis:7-alpine >>"$LOGFILE" 2>&1 || { echo "Failed to pull Redis image. Aborting upgrade." >>"$LOGFILE"; exit 1; }
+# Pull realtime image - version is hardcoded in docker-compose.prod.yml, extract it or use a known version
+docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify realtime image. Aborting upgrade." >>"$LOGFILE"; exit 1; }
+echo "All images pulled successfully." >>"$LOGFILE"
+
+# Stop and remove existing Coolify containers to prevent conflicts
+# This handles both old installations (project "source") and new ones (project "coolify")
+# Use nohup to ensure the script continues even if SSH connection is lost
+echo "Starting container restart sequence (detached)..." >>"$LOGFILE"
+
+nohup bash -c "
+ LOGFILE='$LOGFILE'
+ DOCKER_CONFIG_MOUNT='$DOCKER_CONFIG_MOUNT'
+ REGISTRY_URL='$REGISTRY_URL'
+ LATEST_HELPER_VERSION='$LATEST_HELPER_VERSION'
+ LATEST_IMAGE='$LATEST_IMAGE'
+
+ # Stop and remove containers
+ echo 'Stopping existing Coolify containers...' >>\"\$LOGFILE\"
+ for container in coolify coolify-db coolify-redis coolify-realtime; do
+ if docker ps -a --format '{{.Names}}' | grep -q \"^\${container}\$\"; then
+ docker stop \"\$container\" >>\"\$LOGFILE\" 2>&1 || true
+ docker rm \"\$container\" >>\"\$LOGFILE\" 2>&1 || true
+ echo \" - Removed container: \$container\" >>\"\$LOGFILE\"
+ fi
+ done
+
+ # Start new containers
+ if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
+ echo 'docker-compose.custom.yml detected.' >>\"\$LOGFILE\"
+ docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
+ else
+ docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
+ fi
+ echo 'Upgrade completed.' >>\"\$LOGFILE\"
+" >>"$LOGFILE" 2>&1 &
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index bb9b51ab1..94c23ede4 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.445"
+ "version": "4.0.0-beta.455"
},
"nightly": {
- "version": "4.0.0-beta.446"
+ "version": "4.0.0-beta.456"
},
"helper": {
"version": "1.0.12"
@@ -13,7 +13,17 @@
"version": "1.0.10"
},
"sentinel": {
- "version": "0.0.16"
+ "version": "0.0.18"
}
+ },
+ "traefik": {
+ "v3.6": "3.6.1",
+ "v3.5": "3.5.6",
+ "v3.4": "3.4.5",
+ "v3.3": "3.3.7",
+ "v3.2": "3.2.5",
+ "v3.1": "3.1.7",
+ "v3.0": "3.0.4",
+ "v2.11": "2.11.31"
}
}
\ No newline at end of file
diff --git a/public/svgs/fizzy.png b/public/svgs/fizzy.png
new file mode 100644
index 000000000..44efbd781
Binary files /dev/null and b/public/svgs/fizzy.png differ
diff --git a/public/svgs/garage.svg b/public/svgs/garage.svg
new file mode 100644
index 000000000..18aedeaaf
--- /dev/null
+++ b/public/svgs/garage.svg
@@ -0,0 +1,118 @@
+
+
diff --git a/public/svgs/rustfs.png b/public/svgs/rustfs.png
new file mode 100644
index 000000000..927b8c5c4
Binary files /dev/null and b/public/svgs/rustfs.png differ
diff --git a/public/svgs/rustfs.svg b/public/svgs/rustfs.svg
new file mode 100644
index 000000000..18e9b8418
--- /dev/null
+++ b/public/svgs/rustfs.svg
@@ -0,0 +1,15 @@
+
diff --git a/public/svgs/unsend.svg b/public/svgs/unsend.svg
deleted file mode 100644
index f5ff6fabc..000000000
--- a/public/svgs/unsend.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
diff --git a/public/svgs/usesend.svg b/public/svgs/usesend.svg
new file mode 100644
index 000000000..067a3f569
--- /dev/null
+++ b/public/svgs/usesend.svg
@@ -0,0 +1,43 @@
+
diff --git a/resources/css/app.css b/resources/css/app.css
index 70759e542..30371d307 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -18,6 +18,16 @@ @theme {
--color-base: #101010;
--color-warning: #fcd452;
+ --color-warning-50: #fefce8;
+ --color-warning-100: #fef9c3;
+ --color-warning-200: #fef08a;
+ --color-warning-300: #fde047;
+ --color-warning-400: #fcd452;
+ --color-warning-500: #facc15;
+ --color-warning-600: #ca8a04;
+ --color-warning-700: #a16207;
+ --color-warning-800: #854d0e;
+ --color-warning-900: #713f12;
--color-success: #22C55E;
--color-error: #dc2626;
--color-coollabs-50: #f5f0ff;
@@ -175,4 +185,15 @@ .input[type="password"] {
.lds-heart {
animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
+}
+
+.log-highlight {
+ background-color: rgba(234, 179, 8, 0.4);
+ border-radius: 2px;
+ box-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+}
+
+.dark .log-highlight {
+ background-color: rgba(234, 179, 8, 0.3);
}
\ No newline at end of file
diff --git a/resources/css/utilities.css b/resources/css/utilities.css
index 2899ea1e5..abb177835 100644
--- a/resources/css/utilities.css
+++ b/resources/css/utilities.css
@@ -49,7 +49,7 @@ @utility input-sticky {
}
@utility input-sticky-active {
- @apply text-black border-2 border-coollabs dark:text-white focus:bg-neutral-200 dark:focus:bg-coolgray-400 focus:border-coollabs;
+ @apply text-black border-2 border-coollabs dark:border-warning dark:text-white focus:bg-neutral-200 dark:focus:bg-coolgray-400 focus:border-coollabs dark:focus:border-warning;
}
/* Focus */
@@ -154,7 +154,7 @@ @utility badge {
}
@utility badge-dashboard {
- @apply absolute top-0 right-0 w-2.5 h-2.5 rounded-bl-full text-xs font-bold leading-none border border-neutral-200 dark:border-black;
+ @apply absolute top-1 right-1 w-2.5 h-2.5 rounded-full text-xs font-bold leading-none border border-neutral-200 dark:border-black;
}
@utility badge-success {
@@ -229,6 +229,10 @@ @utility box-without-bg-without-border {
@apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem];
}
+@utility coolbox {
+ @apply relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem];
+}
+
@utility on-box {
@apply rounded-sm hover:bg-neutral-300 dark:hover:bg-coolgray-500/20;
}
diff --git a/resources/js/terminal.js b/resources/js/terminal.js
index b49aad9cf..6707bec98 100644
--- a/resources/js/terminal.js
+++ b/resources/js/terminal.js
@@ -33,6 +33,9 @@ export function initializeTerminalComponent() {
// Resize handling
resizeObserver: null,
resizeTimeout: null,
+ // Visibility handling - prevent disconnects when tab loses focus
+ isDocumentVisible: true,
+ wasConnectedBeforeHidden: false,
init() {
this.setupTerminal();
@@ -92,6 +95,11 @@ export function initializeTerminalComponent() {
}, { once: true });
});
+ // Handle visibility changes to prevent disconnects when tab loses focus
+ document.addEventListener('visibilitychange', () => {
+ this.handleVisibilityChange();
+ });
+
window.onresize = () => {
this.resizeTerminal()
};
@@ -451,6 +459,11 @@ export function initializeTerminalComponent() {
},
keepAlive() {
+ // Skip keepalive when document is hidden to prevent unnecessary disconnects
+ if (!this.isDocumentVisible) {
+ return;
+ }
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true });
} else if (this.connectionState === 'disconnected') {
@@ -459,6 +472,35 @@ export function initializeTerminalComponent() {
}
},
+ handleVisibilityChange() {
+ const wasVisible = this.isDocumentVisible;
+ this.isDocumentVisible = !document.hidden;
+
+ if (!this.isDocumentVisible) {
+ // Tab is now hidden - pause heartbeat monitoring to prevent false disconnects
+ this.wasConnectedBeforeHidden = this.connectionState === 'connected';
+ if (this.pingTimeoutId) {
+ clearTimeout(this.pingTimeoutId);
+ this.pingTimeoutId = null;
+ }
+ console.log('[Terminal] Tab hidden, pausing heartbeat monitoring');
+ } else if (wasVisible === false) {
+ // Tab is now visible again
+ console.log('[Terminal] Tab visible, resuming connection management');
+
+ if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
+ // Send immediate ping to verify connection is still alive
+ this.heartbeatMissed = 0;
+ this.sendMessage({ ping: true });
+ this.resetPingTimeout();
+ } else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') {
+ // Was connected before but now disconnected - attempt reconnection
+ this.reconnectAttempts = 0;
+ this.initializeWebSocket();
+ }
+ }
+ },
+
resetPingTimeout() {
if (this.pingTimeoutId) {
clearTimeout(this.pingTimeoutId);
diff --git a/resources/views/components/callout.blade.php b/resources/views/components/callout.blade.php
index 67da3ba7f..ec99729ef 100644
--- a/resources/views/components/callout.blade.php
+++ b/resources/views/components/callout.blade.php
@@ -2,7 +2,7 @@
@php
$icons = [
- 'warning' => '',
+ 'warning' => '',
'danger' => '',
@@ -13,10 +13,10 @@
$colors = [
'warning' => [
- 'bg' => 'bg-yellow-50 dark:bg-yellow-900/30',
- 'border' => 'border-yellow-300 dark:border-yellow-800',
- 'title' => 'text-yellow-800 dark:text-yellow-300',
- 'text' => 'text-yellow-700 dark:text-yellow-200'
+ 'bg' => 'bg-warning-50 dark:bg-warning-900/30',
+ 'border' => 'border-warning-300 dark:border-warning-800',
+ 'title' => 'text-warning-800 dark:text-warning-300',
+ 'text' => 'text-warning-700 dark:text-warning-200'
],
'danger' => [
'bg' => 'bg-red-50 dark:bg-red-900/30',
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php
new file mode 100644
index 000000000..2466a57f9
--- /dev/null
+++ b/resources/views/components/forms/env-var-input.blade.php
@@ -0,0 +1,274 @@
+
Please enter your password to confirm this destructive action.
diff --git a/resources/views/components/resource-view.blade.php b/resources/views/components/resource-view.blade.php
index ff8e99074..3d216d8da 100644
--- a/resources/views/components/resource-view.blade.php
+++ b/resources/views/components/resource-view.blade.php
@@ -1,27 +1,22 @@
-
\ No newline at end of file
diff --git a/resources/views/livewire/destination/index.blade.php b/resources/views/livewire/destination/index.blade.php
index 0e6f3a005..b86998c48 100644
--- a/resources/views/livewire/destination/index.blade.php
+++ b/resources/views/livewire/destination/index.blade.php
@@ -17,7 +17,7 @@
@forelse ($servers as $server)
@forelse ($server->destinations() as $destination)
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
-