diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 000000000..24c099119
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,2 @@
+reviews:
+ review_status: false
diff --git a/.cursor/rules/frontend-patterns.mdc b/.cursor/rules/frontend-patterns.mdc
index 663490d3b..4730160b2 100644
--- a/.cursor/rules/frontend-patterns.mdc
+++ b/.cursor/rules/frontend-patterns.mdc
@@ -267,18 +267,365 @@ For complete documentation, see **[form-components.mdc](mdc:.cursor/rules/form-c
## Form Handling Patterns
+### Livewire Component Data Synchronization Pattern
+
+**IMPORTANT**: All Livewire components must use the **manual `syncData()` pattern** for synchronizing component properties with Eloquent models.
+
+#### Property Naming Convention
+- **Component properties**: Use camelCase (e.g., `$gitRepository`, `$isStatic`)
+- **Database columns**: Use snake_case (e.g., `git_repository`, `is_static`)
+- **View bindings**: Use camelCase matching component properties (e.g., `id="gitRepository"`)
+
+#### The syncData() Method Pattern
+
+```php
+use Livewire\Attributes\Validate;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+
+class MyComponent extends Component
+{
+ use AuthorizesRequests;
+
+ public Application $application;
+
+ // Properties with validation attributes
+ #[Validate(['required'])]
+ public string $name;
+
+ #[Validate(['string', 'nullable'])]
+ public ?string $description = null;
+
+ #[Validate(['boolean', 'required'])]
+ public bool $isStatic = false;
+
+ public function mount()
+ {
+ $this->authorize('view', $this->application);
+ $this->syncData(); // Load from model
+ }
+
+ public function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ $this->validate();
+
+ // Sync TO model (camelCase → snake_case)
+ $this->application->name = $this->name;
+ $this->application->description = $this->description;
+ $this->application->is_static = $this->isStatic;
+
+ $this->application->save();
+ } else {
+ // Sync FROM model (snake_case → camelCase)
+ $this->name = $this->application->name;
+ $this->description = $this->application->description;
+ $this->isStatic = $this->application->is_static;
+ }
+ }
+
+ public function submit()
+ {
+ $this->authorize('update', $this->application);
+ $this->syncData(toModel: true); // Save to model
+ $this->dispatch('success', 'Saved successfully.');
+ }
+}
+```
+
+#### Validation with #[Validate] Attributes
+
+All component properties should have `#[Validate]` attributes:
+
+```php
+// Boolean properties
+#[Validate(['boolean'])]
+public bool $isEnabled = false;
+
+// Required strings
+#[Validate(['string', 'required'])]
+public string $name;
+
+// Nullable strings
+#[Validate(['string', 'nullable'])]
+public ?string $description = null;
+
+// With constraints
+#[Validate(['integer', 'min:1'])]
+public int $timeout;
+```
+
+#### Benefits of syncData() Pattern
+
+- **Explicit Control**: Clear visibility of what's being synchronized
+- **Type Safety**: #[Validate] attributes provide compile-time validation info
+- **Easy Debugging**: Single method to check for data flow issues
+- **Maintainability**: All sync logic in one place
+- **Flexibility**: Can add custom logic (encoding, transformations, etc.)
+
+#### Creating New Form Components with syncData()
+
+#### Step-by-Step Component Creation Guide
+
+**Step 1: Define properties in camelCase with #[Validate] attributes**
+```php
+use Livewire\Attributes\Validate;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Component;
+
+class MyFormComponent extends Component
+{
+ use AuthorizesRequests;
+
+ // The model we're syncing with
+ public Application $application;
+
+ // Component properties in camelCase with validation
+ #[Validate(['string', 'required'])]
+ public string $name;
+
+ #[Validate(['string', 'nullable'])]
+ public ?string $gitRepository = null;
+
+ #[Validate(['string', 'nullable'])]
+ public ?string $installCommand = null;
+
+ #[Validate(['boolean'])]
+ public bool $isStatic = false;
+}
+```
+
+**Step 2: Implement syncData() method**
+```php
+public function syncData(bool $toModel = false): void
+{
+ if ($toModel) {
+ $this->validate();
+
+ // Sync TO model (component camelCase → database snake_case)
+ $this->application->name = $this->name;
+ $this->application->git_repository = $this->gitRepository;
+ $this->application->install_command = $this->installCommand;
+ $this->application->is_static = $this->isStatic;
+
+ $this->application->save();
+ } else {
+ // Sync FROM model (database snake_case → component camelCase)
+ $this->name = $this->application->name;
+ $this->gitRepository = $this->application->git_repository;
+ $this->installCommand = $this->application->install_command;
+ $this->isStatic = $this->application->is_static;
+ }
+}
+```
+
+**Step 3: Implement mount() to load initial data**
+```php
+public function mount()
+{
+ $this->authorize('view', $this->application);
+ $this->syncData(); // Load data from model to component properties
+}
+```
+
+**Step 4: Implement action methods with authorization**
+```php
+public function instantSave()
+{
+ try {
+ $this->authorize('update', $this->application);
+ $this->syncData(toModel: true); // Save component properties to model
+ $this->dispatch('success', 'Settings saved.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+}
+
+public function submit()
+{
+ try {
+ $this->authorize('update', $this->application);
+ $this->syncData(toModel: true); // Save component properties to model
+ $this->dispatch('success', 'Changes saved successfully.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+}
+```
+
+**Step 5: Create Blade view with camelCase bindings**
+```blade
+
+
+
+```
+
+**Key Points**:
+- Use `wire:model="camelCase"` and `id="camelCase"` in Blade views
+- Component properties are camelCase, database columns are snake_case
+- Always include authorization checks (`authorize()`, `canGate`, `canResource`)
+- Use `instantSave` for checkboxes that save immediately without form submission
+
+#### Special Patterns
+
+**Pattern 1: Related Models (e.g., Application → Settings)**
+```php
+public function syncData(bool $toModel = false): void
+{
+ if ($toModel) {
+ $this->validate();
+
+ // Sync main model
+ $this->application->name = $this->name;
+ $this->application->save();
+
+ // Sync related model
+ $this->application->settings->is_static = $this->isStatic;
+ $this->application->settings->save();
+ } else {
+ // From main model
+ $this->name = $this->application->name;
+
+ // From related model
+ $this->isStatic = $this->application->settings->is_static;
+ }
+}
+```
+
+**Pattern 2: Custom Encoding/Decoding**
+```php
+public function syncData(bool $toModel = false): void
+{
+ if ($toModel) {
+ $this->validate();
+
+ // Encode before saving
+ $this->application->custom_labels = base64_encode($this->customLabels);
+ $this->application->save();
+ } else {
+ // Decode when loading
+ $this->customLabels = $this->application->parseContainerLabels();
+ }
+}
+```
+
+**Pattern 3: Error Rollback**
+```php
+public function submit()
+{
+ $this->authorize('update', $this->resource);
+ $original = $this->model->getOriginal();
+
+ try {
+ $this->syncData(toModel: true);
+ $this->dispatch('success', 'Saved successfully.');
+ } catch (\Throwable $e) {
+ // Rollback on error
+ $this->model->setRawAttributes($original);
+ $this->model->save();
+ $this->syncData(); // Reload from model
+ return handleError($e, $this);
+ }
+}
+```
+
+#### Property Type Patterns
+
+**Required Strings**
+```php
+#[Validate(['string', 'required'])]
+public string $name; // No ?, no default, always has value
+```
+
+**Nullable Strings**
+```php
+#[Validate(['string', 'nullable'])]
+public ?string $description = null; // ?, = null, can be empty
+```
+
+**Booleans**
+```php
+#[Validate(['boolean'])]
+public bool $isEnabled = false; // Always has default value
+```
+
+**Integers with Constraints**
+```php
+#[Validate(['integer', 'min:1'])]
+public int $timeout; // Required
+
+#[Validate(['integer', 'min:1', 'nullable'])]
+public ?int $port = null; // Nullable
+```
+
+#### Testing Checklist
+
+After creating a new component with syncData(), verify:
+
+- [ ] All checkboxes save correctly (especially `instantSave` ones)
+- [ ] All form inputs persist to database
+- [ ] Custom encoded fields (like labels) display correctly if applicable
+- [ ] Form validation works for all fields
+- [ ] No console errors in browser
+- [ ] Authorization checks work (`@can` directives and `authorize()` calls)
+- [ ] Error rollback works if exceptions occur
+- [ ] Related models save correctly if applicable (e.g., Application + ApplicationSetting)
+
+#### Common Pitfalls to Avoid
+
+1. **snake_case in component properties**: Always use camelCase for component properties (e.g., `$gitRepository` not `$git_repository`)
+2. **Missing #[Validate] attributes**: Every property should have validation attributes for type safety
+3. **Forgetting to call syncData()**: Must call `syncData()` in `mount()` to load initial data
+4. **Missing authorization**: Always use `authorize()` in methods and `canGate`/`canResource` in views
+5. **View binding mismatch**: Use camelCase in Blade (e.g., `id="gitRepository"` not `id="git_repository"`)
+6. **wire:model vs wire:model.live**: Use `.live` for `instantSave` checkboxes to avoid timing issues
+7. **Validation sync**: If using `rules()` method, keep it in sync with `#[Validate]` attributes
+8. **Related models**: Don't forget to save both main and related models in syncData() method
+
### Livewire Forms
```php
class ServerCreateForm extends Component
{
public $name;
public $ip;
-
+
protected $rules = [
'name' => 'required|min:3',
'ip' => 'required|ip',
];
-
+
public function save()
{
$this->validate();
diff --git a/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml b/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml
index d00853964..365842254 100644
--- a/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml
+++ b/.github/workflows/chore-lock-closed-issues-discussions-and-prs.yml
@@ -4,6 +4,11 @@ on:
schedule:
- cron: '0 1 * * *'
+permissions:
+ issues: write
+ discussions: write
+ pull-requests: write
+
jobs:
lock-threads:
runs-on: ubuntu-latest
@@ -13,5 +18,5 @@ jobs:
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '30'
- pr-inactive-days: '30'
discussion-inactive-days: '30'
+ pr-inactive-days: '30'
diff --git a/.github/workflows/chore-manage-stale-issues-and-prs.yml b/.github/workflows/chore-manage-stale-issues-and-prs.yml
index 58a2b7d7e..d61005549 100644
--- a/.github/workflows/chore-manage-stale-issues-and-prs.yml
+++ b/.github/workflows/chore-manage-stale-issues-and-prs.yml
@@ -4,6 +4,10 @@ on:
schedule:
- cron: '0 2 * * *'
+permissions:
+ issues: write
+ pull-requests: write
+
jobs:
manage-stale:
runs-on: ubuntu-latest
diff --git a/.github/workflows/chore-pr-comments.yml b/.github/workflows/chore-pr-comments.yml
index 8836c6632..1d94bec81 100644
--- a/.github/workflows/chore-pr-comments.yml
+++ b/.github/workflows/chore-pr-comments.yml
@@ -3,20 +3,13 @@ on:
pull_request_target:
types:
- labeled
+
+permissions:
+ pull-requests: write
+
jobs:
add-comment:
runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- contents: read
- actions: none
- checks: none
- deployments: none
- issues: none
- packages: none
- repository-projects: none
- security-events: none
- statuses: none
strategy:
matrix:
include:
diff --git a/.github/workflows/chore-remove-labels-and-assignees-on-close.yml b/.github/workflows/chore-remove-labels-and-assignees-on-close.yml
index 194984ddc..8ac199a08 100644
--- a/.github/workflows/chore-remove-labels-and-assignees-on-close.yml
+++ b/.github/workflows/chore-remove-labels-and-assignees-on-close.yml
@@ -8,6 +8,10 @@ on:
pull_request_target:
types: [closed]
+permissions:
+ issues: write
+ pull-requests: write
+
jobs:
remove-labels-and-assignees:
runs-on: ubuntu-latest
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
deleted file mode 100644
index a2c92df59..000000000
--- a/.github/workflows/claude-code-review.yml
+++ /dev/null
@@ -1,79 +0,0 @@
-name: Claude Code Review
-
-on:
- pull_request:
- types: [opened, synchronize]
- # Optional: Only run on specific file changes
- # paths:
- # - "src/**/*.ts"
- # - "src/**/*.tsx"
- # - "src/**/*.js"
- # - "src/**/*.jsx"
-
-jobs:
- claude-review:
- if: false
- # Optional: Filter by PR author
- # if: |
- # github.event.pull_request.user.login == 'external-contributor' ||
- # github.event.pull_request.user.login == 'new-developer' ||
- # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
-
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pull-requests: read
- issues: read
- id-token: write
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
- with:
- fetch-depth: 1
-
- - name: Run Claude Code Review
- id: claude-review
- uses: anthropics/claude-code-action@beta
- with:
- claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
-
- # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
- # model: "claude-opus-4-1-20250805"
-
- # Direct prompt for automated review (no @claude mention needed)
- direct_prompt: |
- Please review this pull request and provide feedback on:
- - Code quality and best practices
- - Potential bugs or issues
- - Performance considerations
- - Security concerns
- - Test coverage
-
- Be constructive and helpful in your feedback.
-
- # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
- # use_sticky_comment: true
-
- # Optional: Customize review based on file types
- # direct_prompt: |
- # Review this PR focusing on:
- # - For TypeScript files: Type safety and proper interface usage
- # - For API endpoints: Security, input validation, and error handling
- # - For React components: Performance, accessibility, and best practices
- # - For tests: Coverage, edge cases, and test quality
-
- # Optional: Different prompts for different authors
- # direct_prompt: |
- # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
- # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
- # 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
-
- # Optional: Add specific tools for running tests or linting
- # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
-
- # Optional: Skip review for certain conditions
- # if: |
- # !contains(github.event.pull_request.title, '[skip-review]') &&
- # !contains(github.event.pull_request.title, '[WIP]')
-
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
deleted file mode 100644
index 9daf0e90e..000000000
--- a/.github/workflows/claude.yml
+++ /dev/null
@@ -1,65 +0,0 @@
-name: Claude Code
-
-on:
- issue_comment:
- types: [created]
- pull_request_review_comment:
- types: [created]
- issues:
- types: [opened, assigned]
- pull_request_review:
- types: [submitted]
-
-jobs:
- claude:
- if: |
- (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
- (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
- (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
- (github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'Claude') ||
- (github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'Claude') ||
- (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pull-requests: read
- issues: read
- id-token: write
- actions: read # Required for Claude to read CI results on PRs
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
- with:
- fetch-depth: 1
-
- - name: Run Claude Code
- id: claude
- uses: anthropics/claude-code-action@v1
- with:
- anthropic_api_key: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
-
- # This is an optional setting that allows Claude to read CI results on PRs
- additional_permissions: |
- actions: read
-
- # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
- # model: "claude-opus-4-1-20250805"
-
- # Optional: Customize the trigger phrase (default: @claude)
- # trigger_phrase: "/claude"
-
- # Optional: Trigger when specific user is assigned to an issue
- # assignee_trigger: "claude-bot"
-
- # Optional: Allow Claude to run specific commands
- # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
-
- # Optional: Add custom instructions for Claude to customize its behavior for your project
- # custom_instructions: |
- # Follow our coding standards
- # Ensure all new code has tests
- # Use TypeScript for new files
-
- # Optional: Custom environment variables for Claude
- # claude_env: |
- # NODE_ENV: test
diff --git a/.github/workflows/cleanup-ghcr-untagged.yml b/.github/workflows/cleanup-ghcr-untagged.yml
index 394fba68f..a86cedcb0 100644
--- a/.github/workflows/cleanup-ghcr-untagged.yml
+++ b/.github/workflows/cleanup-ghcr-untagged.yml
@@ -1,17 +1,14 @@
name: Cleanup Untagged GHCR Images
on:
- workflow_dispatch: # Manual trigger only
+ workflow_dispatch:
-env:
- GITHUB_REGISTRY: ghcr.io
+permissions:
+ packages: write
jobs:
cleanup-all-packages:
runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
strategy:
matrix:
package: ['coolify', 'coolify-helper', 'coolify-realtime', 'coolify-testing-host']
diff --git a/.github/workflows/coolify-helper-next.yml b/.github/workflows/coolify-helper-next.yml
index a4a2a21f6..fec54d54a 100644
--- a/.github/workflows/coolify-helper-next.yml
+++ b/.github/workflows/coolify-helper-next.yml
@@ -7,19 +7,31 @@ on:
- .github/workflows/coolify-helper-next.yml
- docker/coolify-helper/Dockerfile
+permissions:
+ contents: read
+ packages: write
+
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
jobs:
- amd64:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
+ build-push:
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ platform: linux/amd64
+ runner: ubuntu-24.04
+ - arch: aarch64
+ platform: linux/aarch64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -40,66 +52,27 @@ jobs:
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- - name: Build and Push Image
+ - name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
- platforms: linux/amd64
+ platforms: ${{ matrix.platform }}
push: true
tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
- labels: |
- coolify.managed=true
- aarch64:
- runs-on: [ self-hosted, arm64 ]
- permissions:
- contents: read
- packages: write
- steps:
- - uses: actions/checkout@v4
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Get Version
- id: version
- run: |
- echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
-
- - name: Build and Push Image
- uses: docker/build-push-action@v6
- with:
- context: .
- file: docker/coolify-helper/Dockerfile
- platforms: linux/aarch64
- push: true
- tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- needs: [ amd64, aarch64 ]
+ runs-on: ubuntu-24.04
+ needs: build-push
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
+
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
@@ -124,14 +97,16 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
- --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
- --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml
index 56c3eaa17..0c9996ec8 100644
--- a/.github/workflows/coolify-helper.yml
+++ b/.github/workflows/coolify-helper.yml
@@ -7,19 +7,31 @@ on:
- .github/workflows/coolify-helper.yml
- docker/coolify-helper/Dockerfile
+permissions:
+ contents: read
+ packages: write
+
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
jobs:
- amd64:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
+ build-push:
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ platform: linux/amd64
+ runner: ubuntu-24.04
+ - arch: aarch64
+ platform: linux/aarch64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -40,65 +52,25 @@ jobs:
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
- - name: Build and Push Image
+ - name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-helper/Dockerfile
- platforms: linux/amd64
+ platforms: ${{ matrix.platform }}
push: true
tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
- labels: |
- coolify.managed=true
- aarch64:
- runs-on: [ self-hosted, arm64 ]
- permissions:
- contents: read
- packages: write
- steps:
- - uses: actions/checkout@v4
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Get Version
- id: version
- run: |
- echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
-
- - name: Build and Push Image
- uses: docker/build-push-action@v6
- with:
- context: .
- file: docker/coolify-helper/Dockerfile
- platforms: linux/aarch64
- push: true
- tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- needs: [ amd64, aarch64 ]
+ runs-on: ubuntu-24.04
+ needs: build-push
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- uses: docker/setup-buildx-action@v3
@@ -124,14 +96,16 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
- --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
- --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
diff --git a/.github/workflows/coolify-production-build.yml b/.github/workflows/coolify-production-build.yml
index cd1f002b8..21871b103 100644
--- a/.github/workflows/coolify-production-build.yml
+++ b/.github/workflows/coolify-production-build.yml
@@ -14,16 +14,31 @@ on:
- templates/**
- CHANGELOG.md
+permissions:
+ contents: read
+ packages: write
+
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
- amd64:
- runs-on: ubuntu-latest
+ build-push:
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ platform: linux/amd64
+ runner: ubuntu-24.04
+ - arch: aarch64
+ platform: linux/aarch64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -44,60 +59,24 @@ jobs:
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
- - name: Build and Push Image
+ - name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
- platforms: linux/amd64
+ platforms: ${{ matrix.platform }}
push: true
tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
-
- aarch64:
- runs-on: [self-hosted, arm64]
- steps:
- - uses: actions/checkout@v4
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Get Version
- id: version
- run: |
- echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
-
- - name: Build and Push Image
- uses: docker/build-push-action@v6
- with:
- context: .
- file: docker/production/Dockerfile
- platforms: linux/aarch64
- push: true
- tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
merge-manifest:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- needs: [amd64, aarch64]
+ runs-on: ubuntu-24.04
+ needs: build-push
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- uses: docker/setup-buildx-action@v3
@@ -123,14 +102,16 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
- --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
- --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
diff --git a/.github/workflows/coolify-realtime-next.yml b/.github/workflows/coolify-realtime-next.yml
index ad590146b..7ab4dcc42 100644
--- a/.github/workflows/coolify-realtime-next.yml
+++ b/.github/workflows/coolify-realtime-next.yml
@@ -11,19 +11,31 @@ on:
- docker/coolify-realtime/package-lock.json
- docker/coolify-realtime/soketi-entrypoint.sh
+permissions:
+ contents: read
+ packages: write
+
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
jobs:
- amd64:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
+ build-push:
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ platform: linux/amd64
+ runner: ubuntu-24.04
+ - arch: aarch64
+ platform: linux/aarch64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -44,67 +56,26 @@ jobs:
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- - name: Build and Push Image
+ - name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
- platforms: linux/amd64
+ platforms: ${{ matrix.platform }}
push: true
tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
- labels: |
- coolify.managed=true
-
- aarch64:
- runs-on: [ self-hosted, arm64 ]
- permissions:
- contents: read
- packages: write
- steps:
- - uses: actions/checkout@v4
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Get Version
- id: version
- run: |
- echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
-
- - name: Build and Push Image
- uses: docker/build-push-action@v6
- with:
- context: .
- file: docker/coolify-realtime/Dockerfile
- platforms: linux/aarch64
- push: true
- tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- needs: [ amd64, aarch64 ]
+ runs-on: ubuntu-24.04
+ needs: build-push
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- uses: docker/setup-buildx-action@v3
@@ -130,14 +101,16 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
- --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
- --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
diff --git a/.github/workflows/coolify-realtime.yml b/.github/workflows/coolify-realtime.yml
index d00621cc2..5efe445c5 100644
--- a/.github/workflows/coolify-realtime.yml
+++ b/.github/workflows/coolify-realtime.yml
@@ -11,19 +11,31 @@ on:
- docker/coolify-realtime/package-lock.json
- docker/coolify-realtime/soketi-entrypoint.sh
+permissions:
+ contents: read
+ packages: write
+
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
jobs:
- amd64:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
+ build-push:
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ platform: linux/amd64
+ runner: ubuntu-24.04
+ - arch: aarch64
+ platform: linux/aarch64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -44,67 +56,26 @@ jobs:
run: |
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
- - name: Build and Push Image
+ - name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/coolify-realtime/Dockerfile
- platforms: linux/amd64
+ platforms: ${{ matrix.platform }}
push: true
tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
- labels: |
- coolify.managed=true
-
- aarch64:
- runs-on: [ self-hosted, arm64 ]
- permissions:
- contents: read
- packages: write
- steps:
- - uses: actions/checkout@v4
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Get Version
- id: version
- run: |
- echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
-
- - name: Build and Push Image
- uses: docker/build-push-action@v6
- with:
- context: .
- file: docker/coolify-realtime/Dockerfile
- platforms: linux/aarch64
- push: true
- tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- needs: [ amd64, aarch64 ]
+ runs-on: ubuntu-24.04
+ needs: build-push
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- uses: docker/setup-buildx-action@v3
@@ -130,14 +101,16 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
- --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
- --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml
index df737c9c3..67b7b03e8 100644
--- a/.github/workflows/coolify-staging-build.yml
+++ b/.github/workflows/coolify-staging-build.yml
@@ -17,6 +17,10 @@ on:
- templates/**
- CHANGELOG.md
+permissions:
+ contents: read
+ packages: write
+
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
@@ -34,11 +38,10 @@ jobs:
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
- permissions:
- contents: read
- packages: write
steps:
- uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
@@ -82,11 +85,10 @@ jobs:
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
- permissions:
- contents: read
- packages: write
steps:
- uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
diff --git a/.github/workflows/coolify-testing-host.yml b/.github/workflows/coolify-testing-host.yml
index 95a228114..24133887a 100644
--- a/.github/workflows/coolify-testing-host.yml
+++ b/.github/workflows/coolify-testing-host.yml
@@ -7,19 +7,31 @@ on:
- .github/workflows/coolify-testing-host.yml
- docker/testing-host/Dockerfile
+permissions:
+ contents: read
+ packages: write
+
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-testing-host"
jobs:
- amd64:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
+ build-push:
+ strategy:
+ matrix:
+ include:
+ - arch: amd64
+ platform: linux/amd64
+ runner: ubuntu-24.04
+ - arch: aarch64
+ platform: linux/aarch64
+ runner: ubuntu-24.04-arm
+ runs-on: ${{ matrix.runner }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@@ -35,62 +47,26 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- - name: Build and Push Image
+ - name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/testing-host/Dockerfile
- platforms: linux/amd64
+ platforms: ${{ matrix.platform }}
push: true
tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- labels: |
- coolify.managed=true
-
- aarch64:
- runs-on: [ self-hosted, arm64 ]
- permissions:
- contents: read
- packages: write
- steps:
- - uses: actions/checkout@v4
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Build and Push Image
- uses: docker/build-push-action@v6
- with:
- context: .
- file: docker/testing-host/Dockerfile
- platforms: linux/aarch64
- push: true
- tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
labels: |
coolify.managed=true
merge-manifest:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- packages: write
- needs: [ amd64, aarch64 ]
+ runs-on: ubuntu-24.04
+ needs: build-push
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
- uses: docker/setup-buildx-action@v3
@@ -111,13 +87,15 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
- --append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
+ ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
- --append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
+ ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- uses: sarisia/actions-status-discord@v1
diff --git a/.gitignore b/.gitignore
index 65b7faa1b..935ea548e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,4 @@ scripts/load-test/*
docker/coolify-realtime/node_modules
.DS_Store
CHANGELOG.md
+/.workspaces
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6696cfba0..2980c7401 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,39 @@ # 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
@@ -13,6 +46,12 @@ ### 🚀 Features
- 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
@@ -50,6 +89,13 @@ ### 🐛 Bug Fixes
- *(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
@@ -64,6 +110,10 @@ ### 🚜 Refactor
- 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
@@ -74,6 +124,7 @@ ### 📚 Documentation
- Update changelog
- Update changelog
- Update changelog
+- Add service & database deployment logging plan
### 🧪 Testing
@@ -85,6 +136,8 @@ ### ⚙️ 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
diff --git a/README.md b/README.md
index f159cde89..456a1268e 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,6 @@ ## Big Sponsors
* [Dade2](https://dade2.net/?ref=coolify.io) - IT Consulting, Cloud Solutions & System Integration
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
-* [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php
index 38f6d7bc8..7fdfe9aeb 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 --timeout=10 $container_name 2>/dev/null || true";
+ $this->commands[] = "docker stop --time=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 300221d24..d1bb119af 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 --timeout=10 $container_name 2>/dev/null || true";
+ $this->commands[] = "docker stop --time=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 3a2ceebb3..128469e24 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 --timeout=10 $container_name 2>/dev/null || true";
+ $this->commands[] = "docker stop --time=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 8a936c8ae..29dd7b8fe 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 --timeout=10 $container_name 2>/dev/null || true";
+ $this->commands[] = "docker stop --time=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 19699d684..5982b68be 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 --timeout=10 $container_name 2>/dev/null || true";
+ $this->commands[] = "docker stop --time=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 25546fa9d..c1df8d6db 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 --timeout=10 $container_name 2>/dev/null || true";
+ $this->commands[] = "docker stop --time=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 ac011acbe..1ae0d56a0 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 --timeout=10 $container_name 2>/dev/null || true";
+ $this->commands[] = "docker stop --time=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) {
@@ -231,8 +231,6 @@ public function handle(StandalonePostgresql $database)
}
$this->commands[] = "echo 'Database started.'";
- ray($this->commands);
-
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index 8a7ae42a4..4c99a0213 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 --timeout=10 $container_name 2>/dev/null || true";
+ $this->commands[] = "docker stop --time=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 63f5b1979..5c881e743 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 --timeout=$timeout $containerName",
+ "docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php
index 392562167..6bf094c32 100644
--- a/app/Actions/Server/CleanupDocker.php
+++ b/app/Actions/Server/CleanupDocker.php
@@ -20,7 +20,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
$realtimeImageWithoutPrefix = 'coollabsio/coolify-realtime';
$realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion";
- $helperImageVersion = data_get($settings, 'helper_version');
+ $helperImageVersion = getHelperVersion();
$helperImage = config('constants.coolify.helper_image');
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php
index dfef6a566..50011c74f 100644
--- a/app/Actions/Service/StartService.php
+++ b/app/Actions/Service/StartService.php
@@ -22,6 +22,10 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
$service->isConfigurationChanged(save: true);
$commands[] = 'cd '.$service->workdir();
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
+ // Ensure .env file exists before docker compose tries to load it
+ // This is defensive programming - saveComposeConfigs() already creates it,
+ // but we guarantee it here in case of any edge cases or manual deployments
+ $commands[] = 'touch .env';
if ($pullLatestImages) {
$commands[] = "echo 'Pulling images.'";
$commands[] = 'docker compose pull';
diff --git a/app/Console/Commands/Cloud/RestoreDatabase.php b/app/Console/Commands/Cloud/RestoreDatabase.php
new file mode 100644
index 000000000..7c6c0d4c6
--- /dev/null
+++ b/app/Console/Commands/Cloud/RestoreDatabase.php
@@ -0,0 +1,219 @@
+debug = $this->option('debug');
+
+ if (! $this->isDevelopment()) {
+ $this->error('This command can only be run in development mode.');
+
+ return 1;
+ }
+
+ $filePath = $this->argument('file');
+
+ if (! file_exists($filePath)) {
+ $this->error("File not found: {$filePath}");
+
+ return 1;
+ }
+
+ if (! is_readable($filePath)) {
+ $this->error("File is not readable: {$filePath}");
+
+ return 1;
+ }
+
+ try {
+ $this->info('Starting database restoration...');
+
+ $database = config('database.connections.pgsql.database');
+ $host = config('database.connections.pgsql.host');
+ $port = config('database.connections.pgsql.port');
+ $username = config('database.connections.pgsql.username');
+ $password = config('database.connections.pgsql.password');
+
+ if (! $database || ! $username) {
+ $this->error('Database configuration is incomplete.');
+
+ return 1;
+ }
+
+ $this->info("Restoring to database: {$database}");
+
+ // Drop all tables
+ if (! $this->dropAllTables($database, $host, $port, $username, $password)) {
+ return 1;
+ }
+
+ // Restore the database dump
+ if (! $this->restoreDatabaseDump($filePath, $database, $host, $port, $username, $password)) {
+ return 1;
+ }
+
+ $this->info('Database restoration completed successfully!');
+
+ return 0;
+ } catch (\Exception $e) {
+ $this->error("An error occurred: {$e->getMessage()}");
+
+ return 1;
+ }
+ }
+
+ private function dropAllTables(string $database, string $host, string $port, string $username, string $password): bool
+ {
+ $this->info('Dropping all tables...');
+
+ // SQL to drop all tables
+ $dropTablesSQL = <<<'SQL'
+ DO $$ DECLARE
+ r RECORD;
+ BEGIN
+ FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
+ EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
+ END LOOP;
+ END $$;
+ SQL;
+
+ // Build the psql command to drop all tables
+ $command = sprintf(
+ 'PGPASSWORD=%s psql -h %s -p %s -U %s -d %s -c %s',
+ escapeshellarg($password),
+ escapeshellarg($host),
+ escapeshellarg($port),
+ escapeshellarg($username),
+ escapeshellarg($database),
+ escapeshellarg($dropTablesSQL)
+ );
+
+ if ($this->debug) {
+ $this->line('Executing drop command: ');
+ $this->line($command);
+ }
+
+ $output = shell_exec($command.' 2>&1');
+
+ if ($this->debug) {
+ $this->line("Output: {$output}");
+ }
+
+ $this->info('All tables dropped successfully.');
+
+ return true;
+ }
+
+ private function restoreDatabaseDump(string $filePath, string $database, string $host, string $port, string $username, string $password): bool
+ {
+ $this->info('Restoring database from dump file...');
+
+ // Handle gzipped files by decompressing first
+ $actualFile = $filePath;
+ if (str_ends_with($filePath, '.gz')) {
+ $actualFile = rtrim($filePath, '.gz');
+ $this->info('Decompressing gzipped dump file...');
+
+ $decompressCommand = sprintf(
+ 'gunzip -c %s > %s',
+ escapeshellarg($filePath),
+ escapeshellarg($actualFile)
+ );
+
+ if ($this->debug) {
+ $this->line('Executing decompress command: ');
+ $this->line($decompressCommand);
+ }
+
+ $decompressOutput = shell_exec($decompressCommand.' 2>&1');
+ if ($this->debug && $decompressOutput) {
+ $this->line("Decompress output: {$decompressOutput}");
+ }
+ }
+
+ // Use pg_restore for custom format dumps
+ $command = sprintf(
+ 'PGPASSWORD=%s pg_restore -h %s -p %s -U %s -d %s -v %s',
+ escapeshellarg($password),
+ escapeshellarg($host),
+ escapeshellarg($port),
+ escapeshellarg($username),
+ escapeshellarg($database),
+ escapeshellarg($actualFile)
+ );
+
+ if ($this->debug) {
+ $this->line('Executing restore command: ');
+ $this->line($command);
+ }
+
+ // Execute the restore command
+ $process = proc_open(
+ $command,
+ [
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ],
+ $pipes
+ );
+
+ if (! is_resource($process)) {
+ $this->error('Failed to start restoration process.');
+
+ return false;
+ }
+
+ $output = stream_get_contents($pipes[1]);
+ $error = stream_get_contents($pipes[2]);
+ $exitCode = proc_close($process);
+
+ // Clean up decompressed file if we created one
+ if ($actualFile !== $filePath && file_exists($actualFile)) {
+ unlink($actualFile);
+ }
+
+ if ($this->debug) {
+ if ($output) {
+ $this->line('Output: ');
+ $this->line($output);
+ }
+ if ($error) {
+ $this->line('Error output: ');
+ $this->line($error);
+ }
+ $this->line("Exit code: {$exitCode}");
+ }
+
+ if ($exitCode !== 0) {
+ $this->error("Restoration failed with exit code: {$exitCode}");
+ if ($error) {
+ $this->error('Error details:');
+ $this->error($error);
+ }
+
+ return false;
+ }
+
+ if ($output && ! $this->debug) {
+ $this->line($output);
+ }
+
+ return true;
+ }
+
+ private function isDevelopment(): bool
+ {
+ return app()->environment(['local', 'development', 'dev']);
+ }
+}
diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php
index b0cd24715..e634feadb 100644
--- a/app/Console/Commands/SyncBunny.php
+++ b/app/Console/Commands/SyncBunny.php
@@ -26,9 +26,9 @@ class SyncBunny extends Command
protected $description = 'Sync files to BunnyCDN';
/**
- * Fetch GitHub releases and sync to CDN
+ * Fetch GitHub releases and sync to GitHub repository
*/
- private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn)
+ private function syncReleasesToGitHubRepo(): bool
{
$this->info('Fetching releases from GitHub...');
try {
@@ -37,33 +37,122 @@ private function syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny
'per_page' => 30, // Fetch more releases for better changelog
]);
- if ($response->successful()) {
- $releases = $response->json();
-
- // Save releases to a temporary file
- $releases_file = "$parent_dir/releases.json";
- file_put_contents($releases_file, json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
-
- // Upload to CDN
- Http::pool(fn (Pool $pool) => [
- $pool->storage(fileName: $releases_file)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/releases.json"),
- $pool->purge("$bunny_cdn/coolify/releases.json"),
- ]);
-
- // Clean up temporary file
- unlink($releases_file);
-
- $this->info('releases.json uploaded & purged...');
- $this->info('Total releases synced: '.count($releases));
-
- return true;
- } else {
+ if (! $response->successful()) {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
+
+ $releases = $response->json();
+ $timestamp = time();
+ $tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp;
+ $branchName = 'update-releases-'.$timestamp;
+
+ // 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...');
+ 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 releases.json
+ $this->info('Writing releases.json...');
+ $releasesPath = "$tmpDir/json/releases.json";
+ $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.');
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ // Stage and commit
+ $this->info('Committing changes...');
+ 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));
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return false;
+ }
+
+ $this->info('Checking for changes...');
+ $statusOutput = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 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('Releases are already up to date. No changes to commit.');
+ exec('rm -rf '.escapeshellarg($tmpDir));
+
+ return true;
+ }
+
+ $commitMessage = 'Update releases.json with latest releases - '.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...');
+ 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 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';
+ 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 Output: '.implode("\n", $output));
+ }
+ $this->info('Total releases synced: '.count($releases));
+
+ return true;
} catch (\Throwable $e) {
- $this->error('Error fetching releases: '.$e->getMessage());
+ $this->error('Error syncing releases: '.$e->getMessage());
return false;
}
@@ -174,11 +263,7 @@ public function handle()
return;
}
- // First sync GitHub releases
- $this->info('Syncing GitHub releases first...');
- $this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn);
-
- // Then sync versions.json
+ // Sync 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"),
@@ -187,14 +272,14 @@ public function handle()
return;
} elseif ($only_github_releases) {
- $this->info('About to sync GitHub releases to BunnyCDN.');
+ $this->info('About to sync GitHub releases to GitHub repository.');
$confirmed = confirm('Are you sure you want to sync GitHub releases?');
if (! $confirmed) {
return;
}
- // Use the reusable function
- $this->syncGitHubReleases($parent_dir, $bunny_cdn_storage_name, $bunny_cdn_path, $bunny_cdn);
+ // Sync releases to GitHub repository
+ $this->syncReleasesToGitHubRepo();
return;
}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index 73690a05b..0d38b7363 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -1619,6 +1619,18 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(['message' => 'Server has multiple destinations and you do not set destination_uuid.'], 400);
}
$destination = $destinations->first();
+ if ($destinations->count() > 1 && $request->has('destination_uuid')) {
+ $destination = $destinations->where('uuid', $request->destination_uuid)->first();
+ if (! $destination) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => [
+ 'destination_uuid' => 'Provided destination_uuid does not belong to the specified server.',
+ ],
+ ], 422);
+ }
+ }
+
if ($request->has('public_port') && $request->is_public) {
if (isPublicPortAlreadyUsed($server, $request->public_port)) {
return response()->json(['message' => 'Public port already used by another database.'], 400);
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 971c1d806..0fd007e9a 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -1780,9 +1780,8 @@ private function create_workdir()
private function prepare_builder_image(bool $firstTry = true)
{
$this->checkForCancellation();
- $settings = instanceSettings();
$helperImage = config('constants.coolify.helper_image');
- $helperImage = "{$helperImage}:{$settings->helper_version}";
+ $helperImage = "{$helperImage}:".getHelperVersion();
// Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
@@ -2322,8 +2321,8 @@ private function generate_compose_file()
$this->application->parseHealthcheckFromDockerfile($this->saved_outputs->get('dockerfile_from_repo'));
}
$custom_network_aliases = [];
- if (is_array($this->application->custom_network_aliases) && count($this->application->custom_network_aliases) > 0) {
- $custom_network_aliases = $this->application->custom_network_aliases;
+ if (! empty($this->application->custom_network_aliases_array)) {
+ $custom_network_aliases = $this->application->custom_network_aliases_array;
}
$docker_compose = [
'services' => [
@@ -3030,6 +3029,12 @@ private function stop_running_container(bool $force = false)
private function start_by_compose_file()
{
+ // Ensure .env file exists before docker compose tries to load it (defensive programming)
+ $this->execute_remote_command(
+ ["touch {$this->workdir}/.env", 'hidden' => true],
+ ["touch {$this->configuration_dir}/.env", 'hidden' => true],
+ );
+
if ($this->application->build_pack === 'dockerimage') {
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
$this->execute_remote_command(
@@ -3227,6 +3232,20 @@ private function generate_secrets_hash($variables)
return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key);
}
+ protected function findFromInstructionLines($dockerfile): array
+ {
+ $fromLines = [];
+ foreach ($dockerfile as $index => $line) {
+ $trimmedLine = trim($line);
+ // Check if line starts with FROM (case-insensitive)
+ if (preg_match('/^FROM\s+/i', $trimmedLine)) {
+ $fromLines[] = $index;
+ }
+ }
+
+ return $fromLines;
+ }
+
private function add_build_env_variables_to_dockerfile()
{
if ($this->dockerBuildkitSupported) {
@@ -3239,6 +3258,18 @@ private function add_build_env_variables_to_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()
@@ -3247,9 +3278,9 @@ private function add_build_env_variables_to_dockerfile()
->get();
foreach ($envs as $env) {
if (data_get($env, 'is_multiline') === true) {
- $dockerfile->splice(1, 0, ["ARG {$env->key}"]);
+ $argsToInsert->push("ARG {$env->key}");
} else {
- $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
+ $argsToInsert->push("ARG {$env->key}={$env->real_value}");
}
}
// Add Coolify variables as ARGs
@@ -3259,9 +3290,7 @@ private function add_build_env_variables_to_dockerfile()
->map(function ($var) {
return "ARG {$var}";
});
- foreach ($coolify_vars as $arg) {
- $dockerfile->splice(1, 0, [$arg]);
- }
+ $argsToInsert = $argsToInsert->merge($coolify_vars);
}
} else {
// Only add preview environment variables that are available during build
@@ -3271,9 +3300,9 @@ private function add_build_env_variables_to_dockerfile()
->get();
foreach ($envs as $env) {
if (data_get($env, 'is_multiline') === true) {
- $dockerfile->splice(1, 0, ["ARG {$env->key}"]);
+ $argsToInsert->push("ARG {$env->key}");
} else {
- $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
+ $argsToInsert->push("ARG {$env->key}={$env->real_value}");
}
}
// Add Coolify variables as ARGs
@@ -3283,15 +3312,23 @@ private function add_build_env_variables_to_dockerfile()
->map(function ($var) {
return "ARG {$var}";
});
- foreach ($coolify_vars as $arg) {
- $dockerfile->splice(1, 0, [$arg]);
- }
+ $argsToInsert = $argsToInsert->merge($coolify_vars);
}
}
- if ($envs->isNotEmpty()) {
- $secrets_hash = $this->generate_secrets_hash($envs);
- $dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]);
+ // 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"));
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 11da6fac1..45586f0d0 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -653,9 +653,8 @@ private function upload_to_s3(): void
private function getFullImageName(): string
{
- $settings = instanceSettings();
$helperImage = config('constants.coolify.helper_image');
- $latestVersion = $settings->helper_version;
+ $latestVersion = getHelperVersion();
return "{$helperImage}:{$latestVersion}";
}
diff --git a/app/Jobs/PullHelperImageJob.php b/app/Jobs/PullHelperImageJob.php
index b92886d38..7cdf1b81a 100644
--- a/app/Jobs/PullHelperImageJob.php
+++ b/app/Jobs/PullHelperImageJob.php
@@ -24,7 +24,7 @@ public function __construct(public Server $server)
public function handle(): void
{
$helperImage = config('constants.coolify.helper_image');
- $latest_version = instanceSettings()->helper_version;
+ $latest_version = getHelperVersion();
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
}
}
diff --git a/app/Livewire/Concerns/SynchronizesModelData.php b/app/Livewire/Concerns/SynchronizesModelData.php
deleted file mode 100644
index f8218c715..000000000
--- a/app/Livewire/Concerns/SynchronizesModelData.php
+++ /dev/null
@@ -1,35 +0,0 @@
- Array mapping property names to model keys (e.g., ['content' => 'fileStorage.content'])
- */
- abstract protected function getModelBindings(): array;
-
- /**
- * Synchronize component properties TO the model.
- * Copies values from component properties to the model.
- */
- protected function syncToModel(): void
- {
- foreach ($this->getModelBindings() as $property => $modelKey) {
- data_set($this, $modelKey, $this->{$property});
- }
- }
-
- /**
- * Synchronize component properties FROM the model.
- * Copies values from the model to component properties.
- */
- protected function syncFromModel(): void
- {
- foreach ($this->getModelBindings() as $property => $modelKey) {
- $this->{$property} = data_get($this, $modelKey);
- }
- }
-}
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 8e8add430..a83e6f70a 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -3,11 +3,11 @@
namespace App\Livewire\Project\Application;
use App\Actions\Application\GenerateConfig;
-use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\Application;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
+use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@@ -15,7 +15,6 @@
class General extends Component
{
use AuthorizesRequests;
- use SynchronizesModelData;
public string $applicationId;
@@ -23,94 +22,136 @@ class General extends Component
public Collection $services;
+ #[Validate('required|regex:/^[a-zA-Z0-9\s\-_.\/:()]+$/')]
public string $name;
+ #[Validate(['string', 'nullable'])]
public ?string $description = null;
+ #[Validate(['nullable'])]
public ?string $fqdn = null;
- public string $git_repository;
+ #[Validate(['required'])]
+ public string $gitRepository;
- public string $git_branch;
+ #[Validate(['required'])]
+ public string $gitBranch;
- public ?string $git_commit_sha = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $gitCommitSha = null;
- public ?string $install_command = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $installCommand = null;
- public ?string $build_command = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $buildCommand = null;
- public ?string $start_command = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $startCommand = null;
- public string $build_pack;
+ #[Validate(['required'])]
+ public string $buildPack;
- public string $static_image;
+ #[Validate(['required'])]
+ public string $staticImage;
- public string $base_directory;
+ #[Validate(['required'])]
+ public string $baseDirectory;
- public ?string $publish_directory = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $publishDirectory = null;
- public ?string $ports_exposes = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $portsExposes = null;
- public ?string $ports_mappings = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $portsMappings = null;
- public ?string $custom_network_aliases = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $customNetworkAliases = null;
+ #[Validate(['string', 'nullable'])]
public ?string $dockerfile = null;
- public ?string $dockerfile_location = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $dockerfileLocation = null;
- public ?string $dockerfile_target_build = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $dockerfileTargetBuild = null;
- public ?string $docker_registry_image_name = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $dockerRegistryImageName = null;
- public ?string $docker_registry_image_tag = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $dockerRegistryImageTag = null;
- public ?string $docker_compose_location = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $dockerComposeLocation = null;
- public ?string $docker_compose = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $dockerCompose = null;
- public ?string $docker_compose_raw = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $dockerComposeRaw = null;
- public ?string $docker_compose_custom_start_command = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $dockerComposeCustomStartCommand = null;
- public ?string $docker_compose_custom_build_command = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $dockerComposeCustomBuildCommand = null;
- public ?string $custom_labels = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $customDockerRunOptions = null;
- public ?string $custom_docker_run_options = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $preDeploymentCommand = null;
- public ?string $pre_deployment_command = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $preDeploymentCommandContainer = null;
- public ?string $pre_deployment_command_container = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $postDeploymentCommand = null;
- public ?string $post_deployment_command = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $postDeploymentCommandContainer = null;
- public ?string $post_deployment_command_container = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $customNginxConfiguration = null;
- public ?string $custom_nginx_configuration = null;
+ #[Validate(['boolean', 'required'])]
+ public bool $isStatic = false;
- public bool $is_static = false;
+ #[Validate(['boolean', 'required'])]
+ public bool $isSpa = false;
- public bool $is_spa = false;
+ #[Validate(['boolean', 'required'])]
+ public bool $isBuildServerEnabled = false;
- public bool $is_build_server_enabled = false;
+ #[Validate(['boolean', 'required'])]
+ public bool $isPreserveRepositoryEnabled = false;
- public bool $is_preserve_repository_enabled = false;
+ #[Validate(['boolean', 'required'])]
+ public bool $isContainerLabelEscapeEnabled = true;
- public bool $is_container_label_escape_enabled = true;
+ #[Validate(['boolean', 'required'])]
+ public bool $isContainerLabelReadonlyEnabled = false;
- public bool $is_container_label_readonly_enabled = false;
+ #[Validate(['boolean', 'required'])]
+ public bool $isHttpBasicAuthEnabled = false;
- public bool $is_http_basic_auth_enabled = false;
+ #[Validate(['string', 'nullable'])]
+ public ?string $httpBasicAuthUsername = null;
- public ?string $http_basic_auth_username = null;
+ #[Validate(['string', 'nullable'])]
+ public ?string $httpBasicAuthPassword = null;
- public ?string $http_basic_auth_password = null;
-
- public ?string $watch_paths = null;
+ #[Validate(['nullable'])]
+ public ?string $watchPaths = null;
+ #[Validate(['string', 'required'])]
public string $redirect;
+ #[Validate(['nullable'])]
public $customLabels;
public bool $labelsChanged = false;
@@ -141,46 +182,46 @@ protected function rules(): array
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable',
- 'git_repository' => 'required',
- 'git_branch' => 'required',
- 'git_commit_sha' => 'nullable',
- 'install_command' => 'nullable',
- 'build_command' => 'nullable',
- 'start_command' => 'nullable',
- 'build_pack' => 'required',
- 'static_image' => 'required',
- 'base_directory' => 'required',
- 'publish_directory' => 'nullable',
- 'ports_exposes' => 'required',
- 'ports_mappings' => 'nullable',
- 'custom_network_aliases' => 'nullable',
+ 'gitRepository' => 'required',
+ 'gitBranch' => 'required',
+ 'gitCommitSha' => 'nullable',
+ 'installCommand' => 'nullable',
+ 'buildCommand' => 'nullable',
+ 'startCommand' => 'nullable',
+ 'buildPack' => 'required',
+ 'staticImage' => 'required',
+ 'baseDirectory' => 'required',
+ 'publishDirectory' => 'nullable',
+ 'portsExposes' => 'required',
+ 'portsMappings' => 'nullable',
+ 'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable',
- 'docker_registry_image_name' => 'nullable',
- 'docker_registry_image_tag' => 'nullable',
- 'dockerfile_location' => 'nullable',
- 'docker_compose_location' => 'nullable',
- 'docker_compose' => 'nullable',
- 'docker_compose_raw' => 'nullable',
- 'dockerfile_target_build' => 'nullable',
- 'docker_compose_custom_start_command' => 'nullable',
- 'docker_compose_custom_build_command' => 'nullable',
- 'custom_labels' => 'nullable',
- 'custom_docker_run_options' => 'nullable',
- 'pre_deployment_command' => 'nullable',
- 'pre_deployment_command_container' => 'nullable',
- 'post_deployment_command' => 'nullable',
- 'post_deployment_command_container' => 'nullable',
- 'custom_nginx_configuration' => 'nullable',
- 'is_static' => 'boolean|required',
- 'is_spa' => 'boolean|required',
- 'is_build_server_enabled' => 'boolean|required',
- 'is_container_label_escape_enabled' => 'boolean|required',
- 'is_container_label_readonly_enabled' => 'boolean|required',
- 'is_preserve_repository_enabled' => 'boolean|required',
- 'is_http_basic_auth_enabled' => 'boolean|required',
- 'http_basic_auth_username' => 'string|nullable',
- 'http_basic_auth_password' => 'string|nullable',
- 'watch_paths' => 'nullable',
+ 'dockerRegistryImageName' => 'nullable',
+ 'dockerRegistryImageTag' => 'nullable',
+ 'dockerfileLocation' => 'nullable',
+ 'dockerComposeLocation' => 'nullable',
+ 'dockerCompose' => 'nullable',
+ 'dockerComposeRaw' => 'nullable',
+ 'dockerfileTargetBuild' => 'nullable',
+ 'dockerComposeCustomStartCommand' => 'nullable',
+ 'dockerComposeCustomBuildCommand' => 'nullable',
+ 'customLabels' => 'nullable',
+ 'customDockerRunOptions' => 'nullable',
+ 'preDeploymentCommand' => 'nullable',
+ 'preDeploymentCommandContainer' => 'nullable',
+ 'postDeploymentCommand' => 'nullable',
+ 'postDeploymentCommandContainer' => 'nullable',
+ 'customNginxConfiguration' => 'nullable',
+ 'isStatic' => 'boolean|required',
+ 'isSpa' => 'boolean|required',
+ 'isBuildServerEnabled' => 'boolean|required',
+ 'isContainerLabelEscapeEnabled' => 'boolean|required',
+ 'isContainerLabelReadonlyEnabled' => 'boolean|required',
+ 'isPreserveRepositoryEnabled' => 'boolean|required',
+ 'isHttpBasicAuthEnabled' => 'boolean|required',
+ 'httpBasicAuthUsername' => 'string|nullable',
+ 'httpBasicAuthPassword' => 'string|nullable',
+ 'watchPaths' => 'nullable',
'redirect' => 'string|required',
];
}
@@ -193,26 +234,26 @@ protected function messages(): array
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
- 'git_repository.required' => 'The Git Repository field is required.',
- 'git_branch.required' => 'The Git Branch field is required.',
- 'build_pack.required' => 'The Build Pack field is required.',
- 'static_image.required' => 'The Static Image field is required.',
- 'base_directory.required' => 'The Base Directory field is required.',
- 'ports_exposes.required' => 'The Exposed Ports field is required.',
- 'is_static.required' => 'The Static setting is required.',
- 'is_static.boolean' => 'The Static setting must be true or false.',
- 'is_spa.required' => 'The SPA setting is required.',
- 'is_spa.boolean' => 'The SPA setting must be true or false.',
- 'is_build_server_enabled.required' => 'The Build Server setting is required.',
- 'is_build_server_enabled.boolean' => 'The Build Server setting must be true or false.',
- 'is_container_label_escape_enabled.required' => 'The Container Label Escape setting is required.',
- 'is_container_label_escape_enabled.boolean' => 'The Container Label Escape setting must be true or false.',
- 'is_container_label_readonly_enabled.required' => 'The Container Label Readonly setting is required.',
- 'is_container_label_readonly_enabled.boolean' => 'The Container Label Readonly setting must be true or false.',
- 'is_preserve_repository_enabled.required' => 'The Preserve Repository setting is required.',
- 'is_preserve_repository_enabled.boolean' => 'The Preserve Repository setting must be true or false.',
- 'is_http_basic_auth_enabled.required' => 'The HTTP Basic Auth setting is required.',
- 'is_http_basic_auth_enabled.boolean' => 'The HTTP Basic Auth setting must be true or false.',
+ 'gitRepository.required' => 'The Git Repository field is required.',
+ 'gitBranch.required' => 'The Git Branch field is required.',
+ 'buildPack.required' => 'The Build Pack field is required.',
+ 'staticImage.required' => 'The Static Image field is required.',
+ 'baseDirectory.required' => 'The Base Directory field is required.',
+ 'portsExposes.required' => 'The Exposed Ports field is required.',
+ 'isStatic.required' => 'The Static setting is required.',
+ 'isStatic.boolean' => 'The Static setting must be true or false.',
+ 'isSpa.required' => 'The SPA setting is required.',
+ 'isSpa.boolean' => 'The SPA setting must be true or false.',
+ 'isBuildServerEnabled.required' => 'The Build Server setting is required.',
+ 'isBuildServerEnabled.boolean' => 'The Build Server setting must be true or false.',
+ 'isContainerLabelEscapeEnabled.required' => 'The Container Label Escape setting is required.',
+ 'isContainerLabelEscapeEnabled.boolean' => 'The Container Label Escape setting must be true or false.',
+ 'isContainerLabelReadonlyEnabled.required' => 'The Container Label Readonly setting is required.',
+ 'isContainerLabelReadonlyEnabled.boolean' => 'The Container Label Readonly setting must be true or false.',
+ 'isPreserveRepositoryEnabled.required' => 'The Preserve Repository setting is required.',
+ 'isPreserveRepositoryEnabled.boolean' => 'The Preserve Repository setting must be true or false.',
+ 'isHttpBasicAuthEnabled.required' => 'The HTTP Basic Auth setting is required.',
+ 'isHttpBasicAuthEnabled.boolean' => 'The HTTP Basic Auth setting must be true or false.',
'redirect.required' => 'The Redirect setting is required.',
'redirect.string' => 'The Redirect setting must be a string.',
]
@@ -220,43 +261,43 @@ protected function messages(): array
}
protected $validationAttributes = [
- 'application.name' => 'name',
- 'application.description' => 'description',
- 'application.fqdn' => 'FQDN',
- 'application.git_repository' => 'Git repository',
- 'application.git_branch' => 'Git branch',
- 'application.git_commit_sha' => 'Git commit SHA',
- 'application.install_command' => 'Install command',
- 'application.build_command' => 'Build command',
- 'application.start_command' => 'Start command',
- 'application.build_pack' => 'Build pack',
- 'application.static_image' => 'Static image',
- 'application.base_directory' => 'Base directory',
- 'application.publish_directory' => 'Publish directory',
- 'application.ports_exposes' => 'Ports exposes',
- 'application.ports_mappings' => 'Ports mappings',
- 'application.dockerfile' => 'Dockerfile',
- 'application.docker_registry_image_name' => 'Docker registry image name',
- 'application.docker_registry_image_tag' => 'Docker registry image tag',
- 'application.dockerfile_location' => 'Dockerfile location',
- 'application.docker_compose_location' => 'Docker compose location',
- 'application.docker_compose' => 'Docker compose',
- 'application.docker_compose_raw' => 'Docker compose raw',
- 'application.custom_labels' => 'Custom labels',
- 'application.dockerfile_target_build' => 'Dockerfile target build',
- 'application.custom_docker_run_options' => 'Custom docker run commands',
- 'application.custom_network_aliases' => 'Custom docker network aliases',
- 'application.docker_compose_custom_start_command' => 'Docker compose custom start command',
- 'application.docker_compose_custom_build_command' => 'Docker compose custom build command',
- 'application.custom_nginx_configuration' => 'Custom Nginx configuration',
- 'application.settings.is_static' => 'Is static',
- 'application.settings.is_spa' => 'Is SPA',
- 'application.settings.is_build_server_enabled' => 'Is build server enabled',
- 'application.settings.is_container_label_escape_enabled' => 'Is container label escape enabled',
- 'application.settings.is_container_label_readonly_enabled' => 'Is container label readonly',
- 'application.settings.is_preserve_repository_enabled' => 'Is preserve repository enabled',
- 'application.watch_paths' => 'Watch paths',
- 'application.redirect' => 'Redirect',
+ 'name' => 'name',
+ 'description' => 'description',
+ 'fqdn' => 'FQDN',
+ 'gitRepository' => 'Git repository',
+ 'gitBranch' => 'Git branch',
+ 'gitCommitSha' => 'Git commit SHA',
+ 'installCommand' => 'Install command',
+ 'buildCommand' => 'Build command',
+ 'startCommand' => 'Start command',
+ 'buildPack' => 'Build pack',
+ 'staticImage' => 'Static image',
+ 'baseDirectory' => 'Base directory',
+ 'publishDirectory' => 'Publish directory',
+ 'portsExposes' => 'Ports exposes',
+ 'portsMappings' => 'Ports mappings',
+ 'dockerfile' => 'Dockerfile',
+ 'dockerRegistryImageName' => 'Docker registry image name',
+ 'dockerRegistryImageTag' => 'Docker registry image tag',
+ 'dockerfileLocation' => 'Dockerfile location',
+ 'dockerComposeLocation' => 'Docker compose location',
+ 'dockerCompose' => 'Docker compose',
+ 'dockerComposeRaw' => 'Docker compose raw',
+ 'customLabels' => 'Custom labels',
+ 'dockerfileTargetBuild' => 'Dockerfile target build',
+ 'customDockerRunOptions' => 'Custom docker run commands',
+ 'customNetworkAliases' => 'Custom docker network aliases',
+ 'dockerComposeCustomStartCommand' => 'Docker compose custom start command',
+ 'dockerComposeCustomBuildCommand' => 'Docker compose custom build command',
+ 'customNginxConfiguration' => 'Custom Nginx configuration',
+ 'isStatic' => 'Is static',
+ 'isSpa' => 'Is SPA',
+ 'isBuildServerEnabled' => 'Is build server enabled',
+ 'isContainerLabelEscapeEnabled' => 'Is container label escape enabled',
+ 'isContainerLabelReadonlyEnabled' => 'Is container label readonly',
+ 'isPreserveRepositoryEnabled' => 'Is preserve repository enabled',
+ 'watchPaths' => 'Watch paths',
+ 'redirect' => 'Redirect',
];
public function mount()
@@ -266,14 +307,14 @@ public function mount()
if (is_null($this->parsedServices) || empty($this->parsedServices)) {
$this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
// Still sync data even if parse fails, so form fields are populated
- $this->syncFromModel();
+ $this->syncData();
return;
}
} catch (\Throwable $e) {
$this->dispatch('error', $e->getMessage());
// Still sync data even on error, so form fields are populated
- $this->syncFromModel();
+ $this->syncData();
}
if ($this->application->build_pack === 'dockercompose') {
// Only update if user has permission
@@ -325,57 +366,114 @@ public function mount()
// Sync data from model to properties at the END, after all business logic
// This ensures any modifications to $this->application during mount() are reflected in properties
- $this->syncFromModel();
+ $this->syncData();
}
- protected function getModelBindings(): array
+ public function syncData(bool $toModel = false): void
{
- return [
- 'name' => 'application.name',
- 'description' => 'application.description',
- 'fqdn' => 'application.fqdn',
- 'git_repository' => 'application.git_repository',
- 'git_branch' => 'application.git_branch',
- 'git_commit_sha' => 'application.git_commit_sha',
- 'install_command' => 'application.install_command',
- 'build_command' => 'application.build_command',
- 'start_command' => 'application.start_command',
- 'build_pack' => 'application.build_pack',
- 'static_image' => 'application.static_image',
- 'base_directory' => 'application.base_directory',
- 'publish_directory' => 'application.publish_directory',
- 'ports_exposes' => 'application.ports_exposes',
- 'ports_mappings' => 'application.ports_mappings',
- 'custom_network_aliases' => 'application.custom_network_aliases',
- 'dockerfile' => 'application.dockerfile',
- 'dockerfile_location' => 'application.dockerfile_location',
- 'dockerfile_target_build' => 'application.dockerfile_target_build',
- 'docker_registry_image_name' => 'application.docker_registry_image_name',
- 'docker_registry_image_tag' => 'application.docker_registry_image_tag',
- 'docker_compose_location' => 'application.docker_compose_location',
- 'docker_compose' => 'application.docker_compose',
- 'docker_compose_raw' => 'application.docker_compose_raw',
- 'docker_compose_custom_start_command' => 'application.docker_compose_custom_start_command',
- 'docker_compose_custom_build_command' => 'application.docker_compose_custom_build_command',
- 'custom_labels' => 'application.custom_labels',
- 'custom_docker_run_options' => 'application.custom_docker_run_options',
- 'pre_deployment_command' => 'application.pre_deployment_command',
- 'pre_deployment_command_container' => 'application.pre_deployment_command_container',
- 'post_deployment_command' => 'application.post_deployment_command',
- 'post_deployment_command_container' => 'application.post_deployment_command_container',
- 'custom_nginx_configuration' => 'application.custom_nginx_configuration',
- 'is_static' => 'application.settings.is_static',
- 'is_spa' => 'application.settings.is_spa',
- 'is_build_server_enabled' => 'application.settings.is_build_server_enabled',
- 'is_preserve_repository_enabled' => 'application.settings.is_preserve_repository_enabled',
- 'is_container_label_escape_enabled' => 'application.settings.is_container_label_escape_enabled',
- 'is_container_label_readonly_enabled' => 'application.settings.is_container_label_readonly_enabled',
- 'is_http_basic_auth_enabled' => 'application.is_http_basic_auth_enabled',
- 'http_basic_auth_username' => 'application.http_basic_auth_username',
- 'http_basic_auth_password' => 'application.http_basic_auth_password',
- 'watch_paths' => 'application.watch_paths',
- 'redirect' => 'application.redirect',
- ];
+ if ($toModel) {
+ $this->validate();
+
+ // Application properties
+ $this->application->name = $this->name;
+ $this->application->description = $this->description;
+ $this->application->fqdn = $this->fqdn;
+ $this->application->git_repository = $this->gitRepository;
+ $this->application->git_branch = $this->gitBranch;
+ $this->application->git_commit_sha = $this->gitCommitSha;
+ $this->application->install_command = $this->installCommand;
+ $this->application->build_command = $this->buildCommand;
+ $this->application->start_command = $this->startCommand;
+ $this->application->build_pack = $this->buildPack;
+ $this->application->static_image = $this->staticImage;
+ $this->application->base_directory = $this->baseDirectory;
+ $this->application->publish_directory = $this->publishDirectory;
+ $this->application->ports_exposes = $this->portsExposes;
+ $this->application->ports_mappings = $this->portsMappings;
+ $this->application->custom_network_aliases = $this->customNetworkAliases;
+ $this->application->dockerfile = $this->dockerfile;
+ $this->application->dockerfile_location = $this->dockerfileLocation;
+ $this->application->dockerfile_target_build = $this->dockerfileTargetBuild;
+ $this->application->docker_registry_image_name = $this->dockerRegistryImageName;
+ $this->application->docker_registry_image_tag = $this->dockerRegistryImageTag;
+ $this->application->docker_compose_location = $this->dockerComposeLocation;
+ $this->application->docker_compose = $this->dockerCompose;
+ $this->application->docker_compose_raw = $this->dockerComposeRaw;
+ $this->application->docker_compose_custom_start_command = $this->dockerComposeCustomStartCommand;
+ $this->application->docker_compose_custom_build_command = $this->dockerComposeCustomBuildCommand;
+ $this->application->custom_labels = is_null($this->customLabels)
+ ? null
+ : base64_encode($this->customLabels);
+ $this->application->custom_docker_run_options = $this->customDockerRunOptions;
+ $this->application->pre_deployment_command = $this->preDeploymentCommand;
+ $this->application->pre_deployment_command_container = $this->preDeploymentCommandContainer;
+ $this->application->post_deployment_command = $this->postDeploymentCommand;
+ $this->application->post_deployment_command_container = $this->postDeploymentCommandContainer;
+ $this->application->custom_nginx_configuration = $this->customNginxConfiguration;
+ $this->application->is_http_basic_auth_enabled = $this->isHttpBasicAuthEnabled;
+ $this->application->http_basic_auth_username = $this->httpBasicAuthUsername;
+ $this->application->http_basic_auth_password = $this->httpBasicAuthPassword;
+ $this->application->watch_paths = $this->watchPaths;
+ $this->application->redirect = $this->redirect;
+
+ // Application settings properties
+ $this->application->settings->is_static = $this->isStatic;
+ $this->application->settings->is_spa = $this->isSpa;
+ $this->application->settings->is_build_server_enabled = $this->isBuildServerEnabled;
+ $this->application->settings->is_preserve_repository_enabled = $this->isPreserveRepositoryEnabled;
+ $this->application->settings->is_container_label_escape_enabled = $this->isContainerLabelEscapeEnabled;
+ $this->application->settings->is_container_label_readonly_enabled = $this->isContainerLabelReadonlyEnabled;
+
+ $this->application->settings->save();
+ } else {
+ // From model to properties
+ $this->name = $this->application->name;
+ $this->description = $this->application->description;
+ $this->fqdn = $this->application->fqdn;
+ $this->gitRepository = $this->application->git_repository;
+ $this->gitBranch = $this->application->git_branch;
+ $this->gitCommitSha = $this->application->git_commit_sha;
+ $this->installCommand = $this->application->install_command;
+ $this->buildCommand = $this->application->build_command;
+ $this->startCommand = $this->application->start_command;
+ $this->buildPack = $this->application->build_pack;
+ $this->staticImage = $this->application->static_image;
+ $this->baseDirectory = $this->application->base_directory;
+ $this->publishDirectory = $this->application->publish_directory;
+ $this->portsExposes = $this->application->ports_exposes;
+ $this->portsMappings = $this->application->ports_mappings;
+ $this->customNetworkAliases = $this->application->custom_network_aliases;
+ $this->dockerfile = $this->application->dockerfile;
+ $this->dockerfileLocation = $this->application->dockerfile_location;
+ $this->dockerfileTargetBuild = $this->application->dockerfile_target_build;
+ $this->dockerRegistryImageName = $this->application->docker_registry_image_name;
+ $this->dockerRegistryImageTag = $this->application->docker_registry_image_tag;
+ $this->dockerComposeLocation = $this->application->docker_compose_location;
+ $this->dockerCompose = $this->application->docker_compose;
+ $this->dockerComposeRaw = $this->application->docker_compose_raw;
+ $this->dockerComposeCustomStartCommand = $this->application->docker_compose_custom_start_command;
+ $this->dockerComposeCustomBuildCommand = $this->application->docker_compose_custom_build_command;
+ $this->customLabels = $this->application->parseContainerLabels();
+ $this->customDockerRunOptions = $this->application->custom_docker_run_options;
+ $this->preDeploymentCommand = $this->application->pre_deployment_command;
+ $this->preDeploymentCommandContainer = $this->application->pre_deployment_command_container;
+ $this->postDeploymentCommand = $this->application->post_deployment_command;
+ $this->postDeploymentCommandContainer = $this->application->post_deployment_command_container;
+ $this->customNginxConfiguration = $this->application->custom_nginx_configuration;
+ $this->isHttpBasicAuthEnabled = $this->application->is_http_basic_auth_enabled;
+ $this->httpBasicAuthUsername = $this->application->http_basic_auth_username;
+ $this->httpBasicAuthPassword = $this->application->http_basic_auth_password;
+ $this->watchPaths = $this->application->watch_paths;
+ $this->redirect = $this->application->redirect;
+
+ // Application settings properties
+ $this->isStatic = $this->application->settings->is_static;
+ $this->isSpa = $this->application->settings->is_spa;
+ $this->isBuildServerEnabled = $this->application->settings->is_build_server_enabled;
+ $this->isPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
+ $this->isContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
+ $this->isContainerLabelReadonlyEnabled = $this->application->settings->is_container_label_readonly_enabled;
+ }
}
public function instantSave()
@@ -386,33 +484,36 @@ public function instantSave()
$oldPortsExposes = $this->application->ports_exposes;
$oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
$oldIsPreserveRepositoryEnabled = $this->application->settings->is_preserve_repository_enabled;
+ $oldIsSpa = $this->application->settings->is_spa;
+ $oldIsHttpBasicAuthEnabled = $this->application->is_http_basic_auth_enabled;
- $this->syncToModel();
+ $this->syncData(toModel: true);
- if ($this->application->settings->isDirty('is_spa')) {
- $this->generateNginxConfiguration($this->application->settings->is_spa ? 'spa' : 'static');
+ if ($oldIsSpa !== $this->isSpa) {
+ $this->generateNginxConfiguration($this->isSpa ? 'spa' : 'static');
}
- if ($this->application->isDirty('is_http_basic_auth_enabled')) {
+ if ($oldIsHttpBasicAuthEnabled !== $this->isHttpBasicAuthEnabled) {
$this->application->save();
}
- $this->application->settings->save();
+
$this->dispatch('success', 'Settings saved.');
$this->application->refresh();
- $this->syncFromModel();
+
+ $this->syncData();
// If port_exposes changed, reset default labels
- if ($oldPortsExposes !== $this->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) {
+ if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) {
$this->resetDefaultLabels(false);
}
- if ($oldIsPreserveRepositoryEnabled !== $this->is_preserve_repository_enabled) {
- if ($this->is_preserve_repository_enabled === false) {
+ if ($oldIsPreserveRepositoryEnabled !== $this->isPreserveRepositoryEnabled) {
+ if ($this->isPreserveRepositoryEnabled === false) {
$this->application->fileStorages->each(function ($storage) {
- $storage->is_based_on_git = $this->is_preserve_repository_enabled;
+ $storage->is_based_on_git = $this->isPreserveRepositoryEnabled;
$storage->save();
});
}
}
- if ($this->is_container_label_readonly_enabled) {
+ if ($this->isContainerLabelReadonlyEnabled) {
$this->resetDefaultLabels(false);
}
} catch (\Throwable $e) {
@@ -441,7 +542,7 @@ public function loadComposeFile($isInit = false, $showToast = true)
// Sync the docker_compose_raw from the model to the component property
// This ensures the Monaco editor displays the loaded compose file
- $this->syncFromModel();
+ $this->syncData();
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots and dashes to use underscores for HTML form binding
@@ -507,7 +608,7 @@ public function generateDomain(string $serviceName)
public function updatedBaseDirectory()
{
- if ($this->build_pack === 'dockercompose') {
+ if ($this->buildPack === 'dockercompose') {
$this->loadComposeFile();
}
}
@@ -527,24 +628,24 @@ public function updatedBuildPack()
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User doesn't have permission, revert the change and return
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
return;
}
// Sync property to model before checking/modifying
- $this->syncToModel();
+ $this->syncData(toModel: true);
- if ($this->build_pack !== 'nixpacks') {
- $this->is_static = false;
+ if ($this->buildPack !== 'nixpacks') {
+ $this->isStatic = false;
$this->application->settings->is_static = false;
$this->application->settings->save();
} else {
- $this->ports_exposes = 3000;
- $this->application->ports_exposes = 3000;
+ $this->portsExposes = '3000';
+ $this->application->ports_exposes = '3000';
$this->resetDefaultLabels(false);
}
- if ($this->build_pack === 'dockercompose') {
+ if ($this->buildPack === 'dockercompose') {
// Only update if user has permission
try {
$this->authorize('update', $this->application);
@@ -567,9 +668,9 @@ public function updatedBuildPack()
$this->application->environment_variables_preview()->where('key', 'LIKE', 'SERVICE_URL_%')->delete();
}
}
- if ($this->build_pack === 'static') {
- $this->ports_exposes = 80;
- $this->application->ports_exposes = 80;
+ if ($this->buildPack === 'static') {
+ $this->portsExposes = '80';
+ $this->application->ports_exposes = '80';
$this->resetDefaultLabels(false);
$this->generateNginxConfiguration();
}
@@ -586,10 +687,10 @@ public function getWildcardDomain()
if ($server) {
$fqdn = generateUrl(server: $server, random: $this->application->uuid);
$this->fqdn = $fqdn;
- $this->syncToModel();
+ $this->syncData(toModel: true);
$this->application->save();
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
$this->resetDefaultLabels();
$this->dispatch('success', 'Wildcard domain generated.');
}
@@ -603,11 +704,11 @@ public function generateNginxConfiguration($type = 'static')
try {
$this->authorize('update', $this->application);
- $this->custom_nginx_configuration = defaultNginxConfiguration($type);
- $this->syncToModel();
+ $this->customNginxConfiguration = defaultNginxConfiguration($type);
+ $this->syncData(toModel: true);
$this->application->save();
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
$this->dispatch('success', 'Nginx configuration generated.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -617,16 +718,15 @@ public function generateNginxConfiguration($type = 'static')
public function resetDefaultLabels($manualReset = false)
{
try {
- if (! $this->is_container_label_readonly_enabled && ! $manualReset) {
+ if (! $this->isContainerLabelReadonlyEnabled && ! $manualReset) {
return;
}
$this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
- $this->custom_labels = base64_encode($this->customLabels);
- $this->syncToModel();
+ $this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
$this->application->refresh();
- $this->syncFromModel();
- if ($this->build_pack === 'dockercompose') {
+ $this->syncData();
+ if ($this->buildPack === 'dockercompose') {
$this->loadComposeFile(showToast: false);
}
$this->dispatch('configurationChanged');
@@ -722,7 +822,7 @@ public function submit($showToaster = true)
$this->dispatch('warning', __('warning.sslipdomain'));
}
- $this->syncToModel();
+ $this->syncData(toModel: true);
if ($this->application->isDirty('redirect')) {
$this->setRedirect();
@@ -742,42 +842,42 @@ public function submit($showToaster = true)
$this->application->save();
}
- if ($this->build_pack === 'dockercompose' && $oldDockerComposeLocation !== $this->docker_compose_location) {
+ 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->ports_exposes || $oldIsContainerLabelEscapeEnabled !== $this->is_container_label_escape_enabled) {
+ if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) {
$this->resetDefaultLabels();
}
- if ($this->build_pack === 'dockerimage') {
+ if ($this->buildPack === 'dockerimage') {
$this->validate([
- 'docker_registry_image_name' => 'required',
+ 'dockerRegistryImageName' => 'required',
]);
}
- if ($this->custom_docker_run_options) {
- $this->custom_docker_run_options = str($this->custom_docker_run_options)->trim()->toString();
- $this->application->custom_docker_run_options = $this->custom_docker_run_options;
+ if ($this->customDockerRunOptions) {
+ $this->customDockerRunOptions = str($this->customDockerRunOptions)->trim()->toString();
+ $this->application->custom_docker_run_options = $this->customDockerRunOptions;
}
if ($this->dockerfile) {
$port = get_port_from_dockerfile($this->dockerfile);
- if ($port && ! $this->ports_exposes) {
- $this->ports_exposes = $port;
+ if ($port && ! $this->portsExposes) {
+ $this->portsExposes = $port;
$this->application->ports_exposes = $port;
}
}
- if ($this->base_directory && $this->base_directory !== '/') {
- $this->base_directory = rtrim($this->base_directory, '/');
- $this->application->base_directory = $this->base_directory;
+ if ($this->baseDirectory && $this->baseDirectory !== '/') {
+ $this->baseDirectory = rtrim($this->baseDirectory, '/');
+ $this->application->base_directory = $this->baseDirectory;
}
- if ($this->publish_directory && $this->publish_directory !== '/') {
- $this->publish_directory = rtrim($this->publish_directory, '/');
- $this->application->publish_directory = $this->publish_directory;
+ if ($this->publishDirectory && $this->publishDirectory !== '/') {
+ $this->publishDirectory = rtrim($this->publishDirectory, '/');
+ $this->application->publish_directory = $this->publishDirectory;
}
- if ($this->build_pack === 'dockercompose') {
+ if ($this->buildPack === 'dockercompose') {
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) {
@@ -809,11 +909,11 @@ public function submit($showToaster = true)
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
$showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!');
} catch (\Throwable $e) {
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
return handleError($e, $this);
} finally {
diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php
index f759dd71e..a9a7de878 100644
--- a/app/Livewire/Project/Service/EditDomain.php
+++ b/app/Livewire/Project/Service/EditDomain.php
@@ -2,14 +2,16 @@
namespace App\Livewire\Project\Service;
-use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\ServiceApplication;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class EditDomain extends Component
{
- use SynchronizesModelData;
+ use AuthorizesRequests;
+
public $applicationId;
public ServiceApplication $application;
@@ -20,6 +22,13 @@ class EditDomain extends Component
public $forceSaveDomains = false;
+ public $showPortWarningModal = false;
+
+ public $forceRemovePort = false;
+
+ public $requiredPort = null;
+
+ #[Validate(['nullable'])]
public ?string $fqdn = null;
protected $rules = [
@@ -28,16 +37,25 @@ class EditDomain extends Component
public function mount()
{
- $this->application = ServiceApplication::query()->findOrFail($this->applicationId);
+ $this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
$this->authorize('view', $this->application);
- $this->syncFromModel();
+ $this->requiredPort = $this->application->service->getRequiredPort();
+ $this->syncData();
}
- protected function getModelBindings(): array
+ public function syncData(bool $toModel = false): void
{
- return [
- 'fqdn' => 'application.fqdn',
- ];
+ if ($toModel) {
+ $this->validate();
+
+ // Sync to model
+ $this->application->fqdn = $this->fqdn;
+
+ $this->application->save();
+ } else {
+ // Sync from model
+ $this->fqdn = $this->application->fqdn;
+ }
}
public function confirmDomainUsage()
@@ -47,6 +65,19 @@ public function confirmDomainUsage()
$this->submit();
}
+ public function confirmRemovePort()
+ {
+ $this->forceRemovePort = true;
+ $this->showPortWarningModal = false;
+ $this->submit();
+ }
+
+ public function cancelRemovePort()
+ {
+ $this->showPortWarningModal = false;
+ $this->syncData(); // Reset to original FQDN
+ }
+
public function submit()
{
try {
@@ -64,8 +95,8 @@ public function submit()
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
- // Sync to model for domain conflict check
- $this->syncToModel();
+ // Sync to model for domain conflict check (without validation)
+ $this->application->fqdn = $this->fqdn;
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
@@ -80,10 +111,45 @@ public function submit()
$this->forceSaveDomains = false;
}
+ // Check for required port
+ if (! $this->forceRemovePort) {
+ $service = $this->application->service;
+ $requiredPort = $service->getRequiredPort();
+
+ if ($requiredPort !== null) {
+ // Check if all FQDNs have a port
+ $fqdns = str($this->fqdn)->trim()->explode(',');
+ $missingPort = false;
+
+ foreach ($fqdns as $fqdn) {
+ $fqdn = trim($fqdn);
+ if (empty($fqdn)) {
+ continue;
+ }
+
+ $port = ServiceApplication::extractPortFromUrl($fqdn);
+ if ($port === null) {
+ $missingPort = true;
+ break;
+ }
+ }
+
+ if ($missingPort) {
+ $this->requiredPort = $requiredPort;
+ $this->showPortWarningModal = true;
+
+ return;
+ }
+ }
+ } else {
+ // Reset the force flag after using it
+ $this->forceRemovePort = false;
+ }
+
$this->validate();
$this->application->save();
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
updateCompose($this->application);
if (str($this->application->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED. Only use multiple domains if you know what you are doing.');
@@ -96,7 +162,7 @@ public function submit()
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
- $this->syncFromModel();
+ $this->syncData();
}
return handleError($e, $this);
diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 40539b13e..2ce4374a0 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -2,7 +2,6 @@
namespace App\Livewire\Project\Service;
-use App\Livewire\Concerns\SynchronizesModelData;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
@@ -19,11 +18,12 @@
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
+use Livewire\Attributes\Validate;
use Livewire\Component;
class FileStorage extends Component
{
- use AuthorizesRequests, SynchronizesModelData;
+ use AuthorizesRequests;
public LocalFileVolume $fileStorage;
@@ -37,8 +37,10 @@ class FileStorage extends Component
public bool $isReadOnly = false;
+ #[Validate(['nullable'])]
public ?string $content = null;
+ #[Validate(['required', 'boolean'])]
public bool $isBasedOnGit = false;
protected $rules = [
@@ -61,15 +63,24 @@ public function mount()
}
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
- $this->syncFromModel();
+ $this->syncData();
}
- protected function getModelBindings(): array
+ public function syncData(bool $toModel = false): void
{
- return [
- 'content' => 'fileStorage.content',
- 'isBasedOnGit' => 'fileStorage.is_based_on_git',
- ];
+ if ($toModel) {
+ $this->validate();
+
+ // Sync to model
+ $this->fileStorage->content = $this->content;
+ $this->fileStorage->is_based_on_git = $this->isBasedOnGit;
+
+ $this->fileStorage->save();
+ } else {
+ // Sync from model
+ $this->content = $this->fileStorage->content;
+ $this->isBasedOnGit = $this->fileStorage->is_based_on_git;
+ }
}
public function convertToDirectory()
@@ -96,7 +107,7 @@ public function loadStorageOnServer()
$this->authorize('update', $this->resource);
$this->fileStorage->loadStorageOnServer();
- $this->syncFromModel();
+ $this->syncData();
$this->dispatch('success', 'File storage loaded from server.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -165,14 +176,16 @@ public function submit()
if ($this->fileStorage->is_directory) {
$this->content = null;
}
- $this->syncToModel();
+ // Sync component properties to model
+ $this->fileStorage->content = $this->content;
+ $this->fileStorage->is_based_on_git = $this->isBasedOnGit;
$this->fileStorage->save();
$this->fileStorage->saveStorageOnServer();
$this->dispatch('success', 'File updated.');
} catch (\Throwable $e) {
$this->fileStorage->setRawAttributes($original);
$this->fileStorage->save();
- $this->syncFromModel();
+ $this->syncData();
return handleError($e, $this);
}
diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php
index 20358218f..1d8d8b247 100644
--- a/app/Livewire/Project/Service/ServiceApplicationView.php
+++ b/app/Livewire/Project/Service/ServiceApplicationView.php
@@ -2,20 +2,19 @@
namespace App\Livewire\Project\Service;
-use App\Livewire\Concerns\SynchronizesModelData;
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;
class ServiceApplicationView extends Component
{
use AuthorizesRequests;
- use SynchronizesModelData;
public ServiceApplication $application;
@@ -31,20 +30,34 @@ class ServiceApplicationView extends Component
public $forceSaveDomains = false;
+ public $showPortWarningModal = false;
+
+ public $forceRemovePort = false;
+
+ public $requiredPort = null;
+
+ #[Validate(['nullable'])]
public ?string $humanName = null;
+ #[Validate(['nullable'])]
public ?string $description = null;
+ #[Validate(['nullable'])]
public ?string $fqdn = null;
+ #[Validate(['string', 'nullable'])]
public ?string $image = null;
+ #[Validate(['required', 'boolean'])]
public bool $excludeFromStatus = false;
+ #[Validate(['nullable', 'boolean'])]
public bool $isLogDrainEnabled = false;
+ #[Validate(['nullable', 'boolean'])]
public bool $isGzipEnabled = false;
+ #[Validate(['nullable', 'boolean'])]
public bool $isStripprefixEnabled = false;
protected $rules = [
@@ -79,7 +92,15 @@ public function instantSaveAdvanced()
return;
}
- $this->syncToModel();
+ // Sync component properties to model
+ $this->application->human_name = $this->humanName;
+ $this->application->description = $this->description;
+ $this->application->fqdn = $this->fqdn;
+ $this->application->image = $this->image;
+ $this->application->exclude_from_status = $this->excludeFromStatus;
+ $this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
+ $this->application->is_gzip_enabled = $this->isGzipEnabled;
+ $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
$this->application->save();
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (\Throwable $e) {
@@ -114,24 +135,53 @@ public function mount()
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
- $this->syncFromModel();
+ $this->requiredPort = $this->application->service->getRequiredPort();
+ $this->syncData();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
- protected function getModelBindings(): array
+ public function confirmRemovePort()
{
- return [
- 'humanName' => 'application.human_name',
- 'description' => 'application.description',
- 'fqdn' => 'application.fqdn',
- 'image' => 'application.image',
- 'excludeFromStatus' => 'application.exclude_from_status',
- 'isLogDrainEnabled' => 'application.is_log_drain_enabled',
- 'isGzipEnabled' => 'application.is_gzip_enabled',
- 'isStripprefixEnabled' => 'application.is_stripprefix_enabled',
- ];
+ $this->forceRemovePort = true;
+ $this->showPortWarningModal = false;
+ $this->submit();
+ }
+
+ public function cancelRemovePort()
+ {
+ $this->showPortWarningModal = false;
+ $this->syncData(); // Reset to original FQDN
+ }
+
+ public function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ $this->validate();
+
+ // Sync to model
+ $this->application->human_name = $this->humanName;
+ $this->application->description = $this->description;
+ $this->application->fqdn = $this->fqdn;
+ $this->application->image = $this->image;
+ $this->application->exclude_from_status = $this->excludeFromStatus;
+ $this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
+ $this->application->is_gzip_enabled = $this->isGzipEnabled;
+ $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
+
+ $this->application->save();
+ } else {
+ // Sync from model
+ $this->humanName = $this->application->human_name;
+ $this->description = $this->application->description;
+ $this->fqdn = $this->application->fqdn;
+ $this->image = $this->application->image;
+ $this->excludeFromStatus = data_get($this->application, 'exclude_from_status', false);
+ $this->isLogDrainEnabled = data_get($this->application, 'is_log_drain_enabled', false);
+ $this->isGzipEnabled = data_get($this->application, 'is_gzip_enabled', true);
+ $this->isStripprefixEnabled = data_get($this->application, 'is_stripprefix_enabled', true);
+ }
}
public function convertToDatabase()
@@ -193,8 +243,15 @@ public function submit()
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
}
- // Sync to model for domain conflict check
- $this->syncToModel();
+ // Sync to model for domain conflict check (without validation)
+ $this->application->human_name = $this->humanName;
+ $this->application->description = $this->description;
+ $this->application->fqdn = $this->fqdn;
+ $this->application->image = $this->image;
+ $this->application->exclude_from_status = $this->excludeFromStatus;
+ $this->application->is_log_drain_enabled = $this->isLogDrainEnabled;
+ $this->application->is_gzip_enabled = $this->isGzipEnabled;
+ $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
// Check for domain conflicts if not forcing save
if (! $this->forceSaveDomains) {
$result = checkDomainUsage(resource: $this->application);
@@ -209,10 +266,45 @@ public function submit()
$this->forceSaveDomains = false;
}
+ // Check for required port
+ if (! $this->forceRemovePort) {
+ $service = $this->application->service;
+ $requiredPort = $service->getRequiredPort();
+
+ if ($requiredPort !== null) {
+ // Check if all FQDNs have a port
+ $fqdns = str($this->fqdn)->trim()->explode(',');
+ $missingPort = false;
+
+ foreach ($fqdns as $fqdn) {
+ $fqdn = trim($fqdn);
+ if (empty($fqdn)) {
+ continue;
+ }
+
+ $port = ServiceApplication::extractPortFromUrl($fqdn);
+ if ($port === null) {
+ $missingPort = true;
+ break;
+ }
+ }
+
+ if ($missingPort) {
+ $this->requiredPort = $requiredPort;
+ $this->showPortWarningModal = true;
+
+ return;
+ }
+ }
+ } else {
+ // Reset the force flag after using it
+ $this->forceRemovePort = false;
+ }
+
$this->validate();
$this->application->save();
$this->application->refresh();
- $this->syncFromModel();
+ $this->syncData();
updateCompose($this->application);
if (str($this->application->fqdn)->contains(',')) {
$this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED. Only use multiple domains if you know what you are doing.');
@@ -224,7 +316,7 @@ public function submit()
$originalFqdn = $this->application->getOriginal('fqdn');
if ($originalFqdn !== $this->application->fqdn) {
$this->application->fqdn = $originalFqdn;
- $this->syncFromModel();
+ $this->syncData();
}
return handleError($e, $this);
diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php
index 85cd21a7f..8a7b6e090 100644
--- a/app/Livewire/Project/Service/StackForm.php
+++ b/app/Livewire/Project/Service/StackForm.php
@@ -5,6 +5,7 @@
use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
use Livewire\Component;
class StackForm extends Component
@@ -22,7 +23,7 @@ class StackForm extends Component
public string $dockerComposeRaw;
- public string $dockerCompose;
+ public ?string $dockerCompose = null;
public ?bool $connectToDockerNetwork = null;
@@ -30,7 +31,7 @@ protected function rules(): array
{
$baseRules = [
'dockerComposeRaw' => 'required',
- 'dockerCompose' => 'required',
+ 'dockerCompose' => 'nullable',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'connectToDockerNetwork' => 'nullable',
@@ -140,18 +141,26 @@ public function submit($notify = true)
$this->validate();
$this->syncData(true);
- // Validate for command injection BEFORE saving to database
+ // Validate for command injection BEFORE any database operations
validateDockerComposeForInjection($this->service->docker_compose_raw);
- $this->service->save();
- $this->service->saveExtraFields($this->fields);
- $this->service->parse();
- $this->service->refresh();
- $this->service->saveComposeConfigs();
+ // Use transaction to ensure atomicity - if parse fails, save is rolled back
+ DB::transaction(function () {
+ $this->service->save();
+ $this->service->saveExtraFields($this->fields);
+ $this->service->parse();
+ $this->service->refresh();
+ $this->service->saveComposeConfigs();
+ });
+
$this->dispatch('refreshEnvs');
$this->dispatch('refreshServices');
$notify && $this->dispatch('success', 'Service saved.');
} catch (\Throwable $e) {
+ // On error, refresh from database to restore clean state
+ $this->service->refresh();
+ $this->syncData(false);
+
return handleError($e, $this);
} finally {
if (is_null($this->service->config_hash)) {
diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php
index c8029761d..05f786690 100644
--- a/app/Livewire/Project/Shared/HealthChecks.php
+++ b/app/Livewire/Project/Shared/HealthChecks.php
@@ -2,42 +2,54 @@
namespace App\Livewire\Project\Shared;
-use App\Livewire\Concerns\SynchronizesModelData;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Livewire\Attributes\Validate;
use Livewire\Component;
class HealthChecks extends Component
{
use AuthorizesRequests;
- use SynchronizesModelData;
public $resource;
// Explicit properties
+ #[Validate(['boolean'])]
public bool $healthCheckEnabled = false;
+ #[Validate(['string'])]
public string $healthCheckMethod;
+ #[Validate(['string'])]
public string $healthCheckScheme;
+ #[Validate(['string'])]
public string $healthCheckHost;
+ #[Validate(['nullable', 'string'])]
public ?string $healthCheckPort = null;
+ #[Validate(['string'])]
public string $healthCheckPath;
+ #[Validate(['integer'])]
public int $healthCheckReturnCode;
+ #[Validate(['nullable', 'string'])]
public ?string $healthCheckResponseText = null;
+ #[Validate(['integer', 'min:1'])]
public int $healthCheckInterval;
+ #[Validate(['integer', 'min:1'])]
public int $healthCheckTimeout;
+ #[Validate(['integer', 'min:1'])]
public int $healthCheckRetries;
+ #[Validate(['integer'])]
public int $healthCheckStartPeriod;
+ #[Validate(['boolean'])]
public bool $customHealthcheckFound = false;
protected $rules = [
@@ -56,36 +68,69 @@ class HealthChecks extends Component
'customHealthcheckFound' => 'boolean',
];
- protected function getModelBindings(): array
- {
- return [
- 'healthCheckEnabled' => 'resource.health_check_enabled',
- 'healthCheckMethod' => 'resource.health_check_method',
- 'healthCheckScheme' => 'resource.health_check_scheme',
- 'healthCheckHost' => 'resource.health_check_host',
- 'healthCheckPort' => 'resource.health_check_port',
- 'healthCheckPath' => 'resource.health_check_path',
- 'healthCheckReturnCode' => 'resource.health_check_return_code',
- 'healthCheckResponseText' => 'resource.health_check_response_text',
- 'healthCheckInterval' => 'resource.health_check_interval',
- 'healthCheckTimeout' => 'resource.health_check_timeout',
- 'healthCheckRetries' => 'resource.health_check_retries',
- 'healthCheckStartPeriod' => 'resource.health_check_start_period',
- 'customHealthcheckFound' => 'resource.custom_healthcheck_found',
- ];
- }
-
public function mount()
{
$this->authorize('view', $this->resource);
- $this->syncFromModel();
+ $this->syncData();
+ }
+
+ public function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ $this->validate();
+
+ // Sync to model
+ $this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_method = $this->healthCheckMethod;
+ $this->resource->health_check_scheme = $this->healthCheckScheme;
+ $this->resource->health_check_host = $this->healthCheckHost;
+ $this->resource->health_check_port = $this->healthCheckPort;
+ $this->resource->health_check_path = $this->healthCheckPath;
+ $this->resource->health_check_return_code = $this->healthCheckReturnCode;
+ $this->resource->health_check_response_text = $this->healthCheckResponseText;
+ $this->resource->health_check_interval = $this->healthCheckInterval;
+ $this->resource->health_check_timeout = $this->healthCheckTimeout;
+ $this->resource->health_check_retries = $this->healthCheckRetries;
+ $this->resource->health_check_start_period = $this->healthCheckStartPeriod;
+ $this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
+
+ $this->resource->save();
+ } else {
+ // Sync from model
+ $this->healthCheckEnabled = $this->resource->health_check_enabled;
+ $this->healthCheckMethod = $this->resource->health_check_method;
+ $this->healthCheckScheme = $this->resource->health_check_scheme;
+ $this->healthCheckHost = $this->resource->health_check_host;
+ $this->healthCheckPort = $this->resource->health_check_port;
+ $this->healthCheckPath = $this->resource->health_check_path;
+ $this->healthCheckReturnCode = $this->resource->health_check_return_code;
+ $this->healthCheckResponseText = $this->resource->health_check_response_text;
+ $this->healthCheckInterval = $this->resource->health_check_interval;
+ $this->healthCheckTimeout = $this->resource->health_check_timeout;
+ $this->healthCheckRetries = $this->resource->health_check_retries;
+ $this->healthCheckStartPeriod = $this->resource->health_check_start_period;
+ $this->customHealthcheckFound = $this->resource->custom_healthcheck_found;
+ }
}
public function instantSave()
{
$this->authorize('update', $this->resource);
- $this->syncToModel();
+ // Sync component properties to model
+ $this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_method = $this->healthCheckMethod;
+ $this->resource->health_check_scheme = $this->healthCheckScheme;
+ $this->resource->health_check_host = $this->healthCheckHost;
+ $this->resource->health_check_port = $this->healthCheckPort;
+ $this->resource->health_check_path = $this->healthCheckPath;
+ $this->resource->health_check_return_code = $this->healthCheckReturnCode;
+ $this->resource->health_check_response_text = $this->healthCheckResponseText;
+ $this->resource->health_check_interval = $this->healthCheckInterval;
+ $this->resource->health_check_timeout = $this->healthCheckTimeout;
+ $this->resource->health_check_retries = $this->healthCheckRetries;
+ $this->resource->health_check_start_period = $this->healthCheckStartPeriod;
+ $this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
}
@@ -96,7 +141,20 @@ public function submit()
$this->authorize('update', $this->resource);
$this->validate();
- $this->syncToModel();
+ // Sync component properties to model
+ $this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_method = $this->healthCheckMethod;
+ $this->resource->health_check_scheme = $this->healthCheckScheme;
+ $this->resource->health_check_host = $this->healthCheckHost;
+ $this->resource->health_check_port = $this->healthCheckPort;
+ $this->resource->health_check_path = $this->healthCheckPath;
+ $this->resource->health_check_return_code = $this->healthCheckReturnCode;
+ $this->resource->health_check_response_text = $this->healthCheckResponseText;
+ $this->resource->health_check_interval = $this->healthCheckInterval;
+ $this->resource->health_check_timeout = $this->healthCheckTimeout;
+ $this->resource->health_check_retries = $this->healthCheckRetries;
+ $this->resource->health_check_start_period = $this->healthCheckStartPeriod;
+ $this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
} catch (\Throwable $e) {
@@ -111,7 +169,20 @@ public function toggleHealthcheck()
$wasEnabled = $this->healthCheckEnabled;
$this->healthCheckEnabled = ! $this->healthCheckEnabled;
- $this->syncToModel();
+ // Sync component properties to model
+ $this->resource->health_check_enabled = $this->healthCheckEnabled;
+ $this->resource->health_check_method = $this->healthCheckMethod;
+ $this->resource->health_check_scheme = $this->healthCheckScheme;
+ $this->resource->health_check_host = $this->healthCheckHost;
+ $this->resource->health_check_port = $this->healthCheckPort;
+ $this->resource->health_check_path = $this->healthCheckPath;
+ $this->resource->health_check_return_code = $this->healthCheckReturnCode;
+ $this->resource->health_check_response_text = $this->healthCheckResponseText;
+ $this->resource->health_check_interval = $this->healthCheckInterval;
+ $this->resource->health_check_timeout = $this->healthCheckTimeout;
+ $this->resource->health_check_retries = $this->healthCheckRetries;
+ $this->resource->health_check_start_period = $this->healthCheckStartPeriod;
+ $this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
if ($this->healthCheckEnabled && ! $wasEnabled && $this->resource->isRunning()) {
diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php
index 13d690352..96f13b173 100644
--- a/app/Livewire/Settings/Index.php
+++ b/app/Livewire/Settings/Index.php
@@ -35,6 +35,9 @@ class Index extends Component
#[Validate('required|string|timezone')]
public string $instance_timezone;
+ #[Validate('nullable|string|max:50')]
+ public ?string $dev_helper_version = null;
+
public array $domainConflicts = [];
public bool $showDomainConflictModal = false;
@@ -60,6 +63,7 @@ public function mount()
$this->public_ipv4 = $this->settings->public_ipv4;
$this->public_ipv6 = $this->settings->public_ipv6;
$this->instance_timezone = $this->settings->instance_timezone;
+ $this->dev_helper_version = $this->settings->dev_helper_version;
}
#[Computed]
@@ -81,6 +85,7 @@ public function instantSave($isSave = true)
$this->settings->public_ipv4 = $this->public_ipv4;
$this->settings->public_ipv6 = $this->public_ipv6;
$this->settings->instance_timezone = $this->instance_timezone;
+ $this->settings->dev_helper_version = $this->dev_helper_version;
if ($isSave) {
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 32459f752..615e35f68 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -120,7 +120,6 @@ class Application extends BaseModel
protected $appends = ['server_status'];
protected $casts = [
- 'custom_network_aliases' => 'array',
'http_basic_auth_password' => 'encrypted',
];
@@ -253,6 +252,30 @@ public function customNetworkAliases(): Attribute
return null;
}
+ if (is_string($value) && $this->isJson($value)) {
+ $decoded = json_decode($value, true);
+
+ // Return as comma-separated string, not array
+ return is_array($decoded) ? implode(',', $decoded) : $value;
+ }
+
+ return $value;
+ }
+ );
+ }
+
+ /**
+ * Get custom_network_aliases as an array
+ */
+ public function customNetworkAliasesArray(): Attribute
+ {
+ return Attribute::make(
+ get: function () {
+ $value = $this->getRawOriginal('custom_network_aliases');
+ if (is_null($value)) {
+ return null;
+ }
+
if (is_string($value) && $this->isJson($value)) {
return json_decode($value, true);
}
@@ -957,7 +980,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->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->custom_labels.$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);
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 {
diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php
index 4b03c69e1..26cb937b3 100644
--- a/app/Models/ApplicationSetting.php
+++ b/app/Models/ApplicationSetting.php
@@ -7,8 +7,14 @@
class ApplicationSetting extends Model
{
- protected $cast = [
+ protected $casts = [
'is_static' => 'boolean',
+ 'is_spa' => 'boolean',
+ 'is_build_server_enabled' => 'boolean',
+ 'is_preserve_repository_enabled' => 'boolean',
+ 'is_container_label_escape_enabled' => 'boolean',
+ 'is_container_label_readonly_enabled' => 'boolean',
+ 'use_build_secrets' => 'boolean',
'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean',
'is_debug_enabled' => 'boolean',
diff --git a/app/Models/Service.php b/app/Models/Service.php
index c4b8623e0..ef755d105 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -1184,6 +1184,31 @@ public function documentation()
return data_get($service, 'documentation', config('constants.urls.docs'));
}
+ /**
+ * Get the required port for this service from the template definition.
+ */
+ public function getRequiredPort(): ?int
+ {
+ try {
+ $services = get_service_templates();
+ $serviceName = str($this->name)->beforeLast('-')->value();
+ $service = data_get($services, $serviceName, []);
+ $port = data_get($service, 'port');
+
+ return $port ? (int) $port : null;
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ /**
+ * Check if this service requires a port to function correctly.
+ */
+ public function requiresPort(): bool
+ {
+ return $this->getRequiredPort() !== null;
+ }
+
public function applications()
{
return $this->hasMany(ServiceApplication::class);
@@ -1262,6 +1287,11 @@ public function workdir()
public function saveComposeConfigs()
{
+ // Guard against null or empty docker_compose
+ if (! $this->docker_compose) {
+ return;
+ }
+
$workdir = $this->workdir();
instant_remote_process([
diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php
index 5cafc9042..49bd56206 100644
--- a/app/Models/ServiceApplication.php
+++ b/app/Models/ServiceApplication.php
@@ -118,6 +118,53 @@ public function fqdns(): Attribute
);
}
+ /**
+ * Extract port number from a given FQDN URL.
+ * Returns null if no port is specified.
+ */
+ public static function extractPortFromUrl(string $url): ?int
+ {
+ try {
+ // Ensure URL has a scheme for proper parsing
+ if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) {
+ $url = 'http://'.$url;
+ }
+
+ $parsed = parse_url($url);
+ $port = $parsed['port'] ?? null;
+
+ return $port ? (int) $port : null;
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ /**
+ * Check if all FQDNs have a port specified.
+ */
+ public function allFqdnsHavePort(): bool
+ {
+ if (is_null($this->fqdn) || $this->fqdn === '') {
+ return false;
+ }
+
+ $fqdns = explode(',', $this->fqdn);
+
+ foreach ($fqdns as $fqdn) {
+ $fqdn = trim($fqdn);
+ if (empty($fqdn)) {
+ continue;
+ }
+
+ $port = self::extractPortFromUrl($fqdn);
+ if ($port === null) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
public function getFilesFromServer(bool $isInit = false)
{
getFilesystemVolumesFromServer($this, $isInit);
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index 5d0f9a2a7..488653fb1 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -97,6 +97,7 @@ function sharedDataApplications()
'start_command' => 'string|nullable',
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
+ 'custom_network_aliases' => 'string|nullable',
'base_directory' => 'string|nullable',
'publish_directory' => 'string|nullable',
'health_check_enabled' => 'boolean',
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index d6c9b5bdf..5bccb50f1 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -1073,6 +1073,9 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
}
$yaml_compose = Yaml::parse($compose);
foreach ($yaml_compose['services'] as $service_name => $service) {
+ if (! isset($service['volumes'])) {
+ continue;
+ }
foreach ($service['volumes'] as $volume_name => $volume) {
if (data_get($volume, 'type') === 'bind' && data_get($volume, 'content')) {
unset($yaml_compose['services'][$service_name]['volumes'][$volume_name]['content']);
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index 01ae50f6b..9b17e6810 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -59,11 +59,13 @@ function validateDockerComposeForInjection(string $composeYaml): void
if (isset($volume['source'])) {
$source = $volume['source'];
if (is_string($source)) {
- // Allow simple env vars and env vars with defaults (validated in parseDockerVolumeString)
+ // Allow env vars and env vars with defaults (validated in parseDockerVolumeString)
+ // Also allow env vars followed by safe path concatenation (e.g., ${VAR}/path)
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $source);
+ $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $source);
- if (! $isSimpleEnvVar && ! $isEnvVarWithDefault) {
+ if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($source, 'volume source');
} catch (\Exception $e) {
@@ -310,15 +312,17 @@ function parseDockerVolumeString(string $volumeString): array
// Validate source path for command injection attempts
// We validate the final source value after environment variable processing
if ($source !== null) {
- // Allow simple environment variables like ${VAR_NAME} or ${VAR}
- // but validate everything else for shell metacharacters
+ // Allow environment variables like ${VAR_NAME} or ${VAR}
+ // Also allow env vars followed by safe path concatenation (e.g., ${VAR}/path)
$sourceStr = is_string($source) ? $source : $source;
// Skip validation for simple environment variable references
- // Pattern: ${WORD_CHARS} with no special characters inside
+ // Pattern 1: ${WORD_CHARS} with no special characters inside
+ // Pattern 2: ${WORD_CHARS}/path/to/file (env var with path concatenation)
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr);
+ $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceStr);
- if (! $isSimpleEnvVar) {
+ if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceStr, 'volume source');
} catch (\Exception $e) {
@@ -711,9 +715,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
$sourceValue = $source->value();
- // Allow simple environment variable references
+ // Allow environment variable references and env vars with path concatenation
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
- if (! $isSimpleEnvVar) {
+ $isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue);
+ $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue);
+
+ if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
@@ -1164,13 +1171,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$environment = $environment->filter(function ($value, $key) {
return ! str($key)->startsWith('SERVICE_FQDN_');
})->map(function ($value, $key) use ($resource) {
- // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
- if (str($value)->isEmpty()) {
- if ($resource->environment_variables()->where('key', $key)->exists()) {
- $value = $resource->environment_variables()->where('key', $key)->first()->value;
- } else {
- $value = null;
+ // Preserve empty strings and null values with correct Docker Compose semantics:
+ // - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy")
+ // - Null: Variable is unset/removed from container environment (may inherit from host)
+ if ($value === null) {
+ // User explicitly wants variable unset - respect that
+ // NEVER override from database - null means "inherit from environment"
+ // Keep as null (will be excluded from container environment)
+ } elseif ($value === '') {
+ // Empty string - allow database override for backward compatibility
+ $dbEnv = $resource->environment_variables()->where('key', $key)->first();
+ // Only use database override if it exists AND has a non-empty value
+ if ($dbEnv && str($dbEnv->value)->isNotEmpty()) {
+ $value = $dbEnv->value;
}
+ // Otherwise keep empty string as-is
}
return $value;
@@ -1285,6 +1300,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($depends_on->count() > 0) {
$payload['depends_on'] = $depends_on;
}
+ // Auto-inject .env file so Coolify environment variables are available inside containers
+ // This makes Applications behave consistently with manual .env file usage
+ $payload['env_file'] = ['.env'];
if ($isPullRequest) {
$serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
}
@@ -1299,6 +1317,18 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
return array_search($key, $customOrder);
});
+ // Remove empty top-level sections (volumes, networks, configs, secrets)
+ // Keep only non-empty sections to match Docker Compose best practices
+ $topLevel = $topLevel->filter(function ($value, $key) {
+ // Always keep 'services' section
+ if ($key === 'services') {
+ return true;
+ }
+
+ // Keep section only if it has content
+ return $value instanceof Collection ? $value->isNotEmpty() : ! empty($value);
+ });
+
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
$resource->docker_compose = $cleanedCompose;
@@ -1589,21 +1619,22 @@ function serviceParser(Service $resource): Collection
]);
}
if (substr_count(str($key)->value(), '_') === 3) {
- $newKey = str($key)->beforeLast('_');
+ // For port-specific variables (e.g., SERVICE_FQDN_UMAMI_3000),
+ // keep the port suffix in the key and use the URL with port
$resource->environment_variables()->updateOrCreate([
- 'key' => $newKey->value(),
+ 'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
- 'value' => $fqdn,
+ 'value' => $fqdnWithPort,
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
- 'key' => $newKey->value(),
+ 'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
- 'value' => $url,
+ 'value' => $urlWithPort,
'is_preview' => false,
]);
}
@@ -1791,9 +1822,12 @@ function serviceParser(Service $resource): Collection
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
$sourceValue = $source->value();
- // Allow simple environment variable references
+ // Allow environment variable references and env vars with path concatenation
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
- if (! $isSimpleEnvVar) {
+ $isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue);
+ $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue);
+
+ if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (\Exception $e) {
@@ -2122,13 +2156,21 @@ function serviceParser(Service $resource): Collection
$environment = $environment->filter(function ($value, $key) {
return ! str($key)->startsWith('SERVICE_FQDN_');
})->map(function ($value, $key) use ($resource) {
- // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used
- if (str($value)->isEmpty()) {
- if ($resource->environment_variables()->where('key', $key)->exists()) {
- $value = $resource->environment_variables()->where('key', $key)->first()->value;
- } else {
- $value = null;
+ // Preserve empty strings and null values with correct Docker Compose semantics:
+ // - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy")
+ // - Null: Variable is unset/removed from container environment (may inherit from host)
+ if ($value === null) {
+ // User explicitly wants variable unset - respect that
+ // NEVER override from database - null means "inherit from environment"
+ // Keep as null (will be excluded from container environment)
+ } elseif ($value === '') {
+ // Empty string - allow database override for backward compatibility
+ $dbEnv = $resource->environment_variables()->where('key', $key)->first();
+ // Only use database override if it exists AND has a non-empty value
+ if ($dbEnv && str($dbEnv->value)->isNotEmpty()) {
+ $value = $dbEnv->value;
}
+ // Otherwise keep empty string as-is
}
return $value;
@@ -2240,6 +2282,9 @@ function serviceParser(Service $resource): Collection
if ($depends_on->count() > 0) {
$payload['depends_on'] = $depends_on;
}
+ // Auto-inject .env file so Coolify environment variables are available inside containers
+ // This makes Services behave consistently with Applications
+ $payload['env_file'] = ['.env'];
$parsedServices->put($serviceName, $payload);
}
@@ -2251,6 +2296,18 @@ function serviceParser(Service $resource): Collection
return array_search($key, $customOrder);
});
+ // Remove empty top-level sections (volumes, networks, configs, secrets)
+ // Keep only non-empty sections to match Docker Compose best practices
+ $topLevel = $topLevel->filter(function ($value, $key) {
+ // Always keep 'services' section
+ if ($key === 'services') {
+ return true;
+ }
+
+ // Keep section only if it has content
+ return $value instanceof Collection ? $value->isNotEmpty() : ! empty($value);
+ });
+
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
$resource->docker_compose = $cleanedCompose;
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 0f5b6f553..effde712a 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -2879,6 +2879,18 @@ function instanceSettings()
return InstanceSettings::get();
}
+function getHelperVersion(): string
+{
+ $settings = instanceSettings();
+
+ // In development mode, use the dev_helper_version if set, otherwise fallback to config
+ if (isDev() && ! empty($settings->dev_helper_version)) {
+ return $settings->dev_helper_version;
+ }
+
+ return config('constants.coolify.helper_version');
+}
+
function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id)
{
$server = Server::find($server_id)->where('team_id', $team_id)->first();
diff --git a/config/constants.php b/config/constants.php
index 503fe3808..770e00ffe 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,8 +2,8 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.439',
- 'helper_version' => '1.0.11',
+ 'version' => '4.0.0-beta.443',
+ 'helper_version' => '1.0.12',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
@@ -12,7 +12,7 @@
'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),
- 'releases_url' => 'https://cdn.coollabs.io/coolify/releases.json',
+ 'releases_url' => 'https://cdn.coolify.io/releases.json',
],
'urls' => [
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
new file mode 100644
index 000000000..56ed2239a
--- /dev/null
+++ b/database/migrations/2025_11_02_161923_add_dev_helper_version_to_instance_settings.php
@@ -0,0 +1,28 @@
+string('dev_helper_version')->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('instance_settings', function (Blueprint $table) {
+ $table->dropColumn('dev_helper_version');
+ });
+ }
+};
diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile
index 212703798..14879eb96 100644
--- a/docker/coolify-helper/Dockerfile
+++ b/docker/coolify-helper/Dockerfile
@@ -10,7 +10,7 @@ ARG DOCKER_BUILDX_VERSION=0.25.0
# https://github.com/buildpacks/pack/releases
ARG PACK_VERSION=0.38.2
# https://github.com/railwayapp/nixpacks/releases
-ARG NIXPACKS_VERSION=1.40.0
+ARG NIXPACKS_VERSION=1.41.0
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
diff --git a/jean.json b/jean.json
new file mode 100644
index 000000000..4e5c788ed
--- /dev/null
+++ b/jean.json
@@ -0,0 +1,5 @@
+{
+ "scripts": {
+ "setup": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json"
+ }
+}
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 2e5cc5e84..0d9519bf8 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.435"
+ "version": "4.0.0-beta.443"
},
"nightly": {
- "version": "4.0.0-beta.436"
+ "version": "4.0.0-beta.444"
},
"helper": {
"version": "1.0.11"
diff --git a/package-lock.json b/package-lock.json
index 9e8fe7328..f8ef518d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -916,7 +916,8 @@
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.10",
@@ -1431,8 +1432,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
@@ -1595,6 +1595,7 @@
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
@@ -1609,6 +1610,7 @@
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -1627,6 +1629,7 @@
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10.0.0"
}
@@ -2388,7 +2391,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -2465,7 +2467,6 @@
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"tweetnacl": "^1.0.3"
}
@@ -2550,6 +2551,7 @@
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
@@ -2566,6 +2568,7 @@
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -2584,6 +2587,7 @@
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
@@ -2598,6 +2602,7 @@
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -2646,8 +2651,7 @@
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz",
"integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -2664,11 +2668,11 @@
}
},
"node_modules/tar": {
- "version": "7.5.1",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz",
- "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==",
+ "version": "7.5.2",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
+ "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==",
"dev": true,
- "license": "ISC",
+ "license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
@@ -2716,7 +2720,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -2816,7 +2819,6 @@
"integrity": "sha512-rjOV2ecxMd5SiAmof2xzh2WxntRcigkX/He4YFJ6WdRvVUrbt6DxC1Iujh10XLl8xCDRDtGKMeO3D+pRQ1PP9w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.16",
"@vue/compiler-sfc": "3.5.16",
@@ -2839,6 +2841,7 @@
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -2860,6 +2863,7 @@
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"dev": true,
+ "peer": true,
"engines": {
"node": ">=0.4.0"
}
diff --git a/resources/css/app.css b/resources/css/app.css
index fa1e61cb2..70759e542 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -82,7 +82,7 @@ @keyframes lds-heart {
*/
html,
body {
- @apply w-full min-h-full bg-neutral-50 dark:bg-base dark:text-neutral-400;
+ @apply w-full min-h-full bg-gray-50 dark:bg-base dark:text-neutral-400;
}
body {
diff --git a/resources/css/utilities.css b/resources/css/utilities.css
index f819280d5..5d8a6bfa1 100644
--- a/resources/css/utilities.css
+++ b/resources/css/utilities.css
@@ -152,7 +152,7 @@ @utility custom-modal {
}
@utility navbar-main {
- @apply flex flex-col gap-4 justify-items-start pb-2 border-b-2 border-solid h-fit md:flex-row sm:justify-between dark:border-coolgray-200 border-neutral-200 md:items-center;
+ @apply flex flex-col gap-4 justify-items-start pb-2 border-b-2 border-solid h-fit md:flex-row sm:justify-between dark:border-coolgray-200 border-neutral-200 md:items-center text-neutral-700 dark:text-neutral-400;
}
@utility loading {
diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php
index f85dc268e..ede49117a 100644
--- a/resources/views/auth/login.blade.php
+++ b/resources/views/auth/login.blade.php
@@ -61,7 +61,7 @@ class="text-sm dark:text-neutral-400 hover:text-coollabs dark:hover:text-warning
-
+
Don't have an account?
@@ -82,7 +82,7 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d
- or
+ or
continue with
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php
index 3db943726..cdfa52a98 100644
--- a/resources/views/auth/register.blade.php
+++ b/resources/views/auth/register.blade.php
@@ -33,7 +33,8 @@ function getOldOrLocal($key, $localValue)
Root User Setup
-
This user will be the root user with full admin access.
+
This user will be the root user with full
+ admin access.
@@ -58,13 +59,16 @@ function getOldOrLocal($key, $localValue)
-
+
- Your password should be min 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one symbol.
+ Your password should be min 8 characters long and contain at least one uppercase letter,
+ one lowercase letter, one number, and one symbol.
-
+
Create Account
@@ -74,17 +78,18 @@ function getOldOrLocal($key, $localValue)
-
+
Already have an account?
-
+
{{ __('auth.already_registered') }}
-
+
\ No newline at end of file
diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php
index a4a07ebd6..3e0c237b4 100644
--- a/resources/views/auth/reset-password.blade.php
+++ b/resources/views/auth/reset-password.blade.php
@@ -47,16 +47,19 @@
label="{{ __('input.email') }}" />
-
+
-
+
- Your password should be min 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one symbol.
+ Your password should be min 8 characters long and contain at least one uppercase letter,
+ one lowercase letter, one number, and one symbol.
-
+
{{ __('auth.reset_password') }}
@@ -66,17 +69,18 @@
-
+
Remember your password?
-
+
Back to Login
-
+
\ No newline at end of file
diff --git a/resources/views/auth/two-factor-challenge.blade.php b/resources/views/auth/two-factor-challenge.blade.php
index d4531cbe8..05dbcc90c 100644
--- a/resources/views/auth/two-factor-challenge.blade.php
+++ b/resources/views/auth/two-factor-challenge.blade.php
@@ -120,7 +120,7 @@ class="mt-2 text-sm dark:text-neutral-400 hover:text-black dark:hover:text-white
-
+
Need help?
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
index 0a7909761..0ef021458 100644
--- a/resources/views/layouts/app.blade.php
+++ b/resources/views/layouts/app.blade.php
@@ -17,7 +17,8 @@
localStorage.setItem('pageWidth', 'full');
}
}
- }" x-cloak class="mx-auto" :class="pageWidth === 'full' ? '' : 'max-w-7xl'">
+ }" x-cloak class="mx-auto dark:text-inherit text-black"
+ :class="pageWidth === 'full' ? '' : 'max-w-7xl'">
-
+
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index a4c72a5d8..7bb366cd4 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -2,17 +2,19 @@
+
-
+
@@ -73,102 +75,102 @@
@section('body')
-
-
-
-
+ }, timeout);
+ return;
+ } else {
+ window.location.reload();
+ }
+ })
+ window.Livewire.on('info', (message) => {
+ if (typeof message === 'string') {
+ window.toast('Info', {
+ type: 'info',
+ description: message,
+ })
+ return;
+ }
+ if (message.length == 1) {
+ window.toast('Info', {
+ type: 'info',
+ description: message[0],
+ })
+ } else if (message.length == 2) {
+ window.toast(message[0], {
+ type: 'info',
+ description: message[1],
+ })
+ }
+ })
+ window.Livewire.on('error', (message) => {
+ if (typeof message === 'string') {
+ window.toast('Error', {
+ type: 'danger',
+ description: message,
+ })
+ return;
+ }
+ if (message.length == 1) {
+ window.toast('Error', {
+ type: 'danger',
+ description: message[0],
+ })
+ } else if (message.length == 2) {
+ window.toast(message[0], {
+ type: 'danger',
+ description: message[1],
+ })
+ }
+ })
+ window.Livewire.on('warning', (message) => {
+ if (typeof message === 'string') {
+ window.toast('Warning', {
+ type: 'warning',
+ description: message,
+ })
+ return;
+ }
+ if (message.length == 1) {
+ window.toast('Warning', {
+ type: 'warning',
+ description: message[0],
+ })
+ } else if (message.length == 2) {
+ window.toast(message[0], {
+ type: 'warning',
+ description: message[1],
+ })
+ }
+ })
+ window.Livewire.on('success', (message) => {
+ if (typeof message === 'string') {
+ window.toast('Success', {
+ type: 'success',
+ description: message,
+ })
+ return;
+ }
+ if (message.length == 1) {
+ window.toast('Success', {
+ type: 'success',
+ description: message[0],
+ })
+ } else if (message.length == 2) {
+ window.toast(message[0], {
+ type: 'success',
+ description: message[1],
+ })
+ }
+ })
+ });
+
+
@show
-