diff --git a/.coderabbit.yaml b/.coderabbit.yaml
deleted file mode 100644
index 24c099119..000000000
--- a/.coderabbit.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-reviews:
- review_status: false
diff --git a/.github/workflows/coolify-helper-next.yml b/.github/workflows/coolify-helper-next.yml
index ba8a69d28..fec54d54a 100644
--- a/.github/workflows/coolify-helper-next.yml
+++ b/.github/workflows/coolify-helper-next.yml
@@ -17,8 +17,17 @@ env:
IMAGE_NAME: "coollabsio/coolify-helper"
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@v5
with:
@@ -43,60 +52,22 @@ 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 ]
- steps:
- - uses: actions/checkout@v5
- with:
- persist-credentials: false
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Get Version
- id: version
- run: |
- echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
-
- - name: Build and Push Image
- 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
- needs: [ amd64, aarch64 ]
+ runs-on: ubuntu-24.04
+ needs: build-push
steps:
- uses: actions/checkout@v5
with:
@@ -126,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 738a3480c..0c9996ec8 100644
--- a/.github/workflows/coolify-helper.yml
+++ b/.github/workflows/coolify-helper.yml
@@ -17,8 +17,17 @@ env:
IMAGE_NAME: "coollabsio/coolify-helper"
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@v5
with:
@@ -43,59 +52,21 @@ 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 ]
- steps:
- - uses: actions/checkout@v5
- with:
- persist-credentials: false
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Get Version
- id: version
- run: |
- echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
-
- - name: Build and Push Image
- 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
- needs: [ amd64, aarch64 ]
+ runs-on: ubuntu-24.04
+ needs: build-push
steps:
- uses: actions/checkout@v5
with:
@@ -125,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 b6cfd34ae..21871b103 100644
--- a/.github/workflows/coolify-production-build.yml
+++ b/.github/workflows/coolify-production-build.yml
@@ -24,8 +24,17 @@ env:
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@v5
with:
@@ -50,57 +59,20 @@ 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@v5
- with:
- persist-credentials: false
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Get Version
- id: version
- run: |
- echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
-
- - name: Build and Push Image
- 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
- needs: [amd64, aarch64]
+ runs-on: ubuntu-24.04
+ needs: build-push
steps:
- uses: actions/checkout@v5
with:
@@ -130,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 7a6071bde..7ab4dcc42 100644
--- a/.github/workflows/coolify-realtime-next.yml
+++ b/.github/workflows/coolify-realtime-next.yml
@@ -21,8 +21,17 @@ env:
IMAGE_NAME: "coollabsio/coolify-realtime"
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@v5
with:
@@ -47,62 +56,22 @@ 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 ]
- steps:
- - uses: actions/checkout@v5
- with:
- persist-credentials: false
-
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Get Version
- id: version
- run: |
- echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
-
- - name: Build and Push Image
- 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
- needs: [ amd64, aarch64 ]
+ runs-on: ubuntu-24.04
+ needs: build-push
steps:
- uses: actions/checkout@v5
with:
@@ -132,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 1074af3ee..5efe445c5 100644
--- a/.github/workflows/coolify-realtime.yml
+++ b/.github/workflows/coolify-realtime.yml
@@ -21,8 +21,17 @@ env:
IMAGE_NAME: "coollabsio/coolify-realtime"
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@v5
with:
@@ -47,61 +56,22 @@ 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 ]
- steps:
- - uses: actions/checkout@v5
- with:
- persist-credentials: false
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Get Version
- id: version
- run: |
- echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
-
- - name: Build and Push Image
- 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
- needs: [ amd64, aarch64 ]
+ runs-on: ubuntu-24.04
+ needs: build-push
steps:
- uses: actions/checkout@v5
with:
@@ -131,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-testing-host.yml b/.github/workflows/coolify-testing-host.yml
index c4aecd85e..24133887a 100644
--- a/.github/workflows/coolify-testing-host.yml
+++ b/.github/workflows/coolify-testing-host.yml
@@ -17,8 +17,17 @@ env:
IMAGE_NAME: "coollabsio/coolify-testing-host"
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@v5
with:
@@ -38,56 +47,22 @@ 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 ]
- steps:
- - uses: actions/checkout@v5
- with:
- persist-credentials: false
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Build and Push Image
- 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
- needs: [ amd64, aarch64 ]
+ runs-on: ubuntu-24.04
+ needs: build-push
steps:
- uses: actions/checkout@v5
with:
@@ -112,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/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml
index f62b41736..935a88721 100644
--- a/.github/workflows/generate-changelog.yml
+++ b/.github/workflows/generate-changelog.yml
@@ -16,7 +16,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
- persist-credentials: false
fetch-depth: 0
- name: Generate changelog
diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php
index f5d5f82b6..c7f4055f0 100644
--- a/app/Actions/Docker/GetContainersStatus.php
+++ b/app/Actions/Docker/GetContainersStatus.php
@@ -10,6 +10,7 @@
use App\Models\ServiceDatabase;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
use Lorisleiva\Actions\Concerns\AsAction;
class GetContainersStatus
@@ -28,6 +29,8 @@ class GetContainersStatus
protected ?Collection $applicationContainerStatuses;
+ protected ?Collection $applicationContainerRestartCounts;
+
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{
$this->containers = $containers;
@@ -136,6 +139,18 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
+
+ // Track restart counts for applications
+ $restartCount = data_get($container, 'RestartCount', 0);
+ if (! isset($this->applicationContainerRestartCounts)) {
+ $this->applicationContainerRestartCounts = collect();
+ }
+ if (! $this->applicationContainerRestartCounts->has($applicationId)) {
+ $this->applicationContainerRestartCounts->put($applicationId, collect());
+ }
+ if ($containerName) {
+ $this->applicationContainerRestartCounts->get($applicationId)->put($containerName, $restartCount);
+ }
} else {
// Notify user that this container should not be there.
}
@@ -291,7 +306,24 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
continue;
}
- $application->update(['status' => 'exited']);
+ // If container was recently restarting (crash loop), keep it as degraded for a grace period
+ // This prevents false "exited" status during the brief moment between container removal and recreation
+ $recentlyRestarted = $application->restart_count > 0 &&
+ $application->last_restart_at &&
+ $application->last_restart_at->greaterThan(now()->subSeconds(30));
+
+ if ($recentlyRestarted) {
+ // Keep it as degraded if it was recently in a crash loop
+ $application->update(['status' => 'degraded (unhealthy)']);
+ } else {
+ // Reset restart count when application exits completely
+ $application->update([
+ 'status' => 'exited',
+ 'restart_count' => 0,
+ 'last_restart_at' => null,
+ 'last_restart_type' => null,
+ ]);
+ }
}
$notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews);
foreach ($notRunningApplicationPreviews as $previewId) {
@@ -340,22 +372,56 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
continue;
}
- $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
- if ($aggregatedStatus) {
- $statusFromDb = $application->status;
- if ($statusFromDb !== $aggregatedStatus) {
- $application->update(['status' => $aggregatedStatus]);
- } else {
- $application->update(['last_online_at' => now()]);
- }
+ // Track restart counts first
+ $maxRestartCount = 0;
+ if (isset($this->applicationContainerRestartCounts) && $this->applicationContainerRestartCounts->has($applicationId)) {
+ $containerRestartCounts = $this->applicationContainerRestartCounts->get($applicationId);
+ $maxRestartCount = $containerRestartCounts->max() ?? 0;
}
+
+ // Wrap all database updates in a transaction to ensure consistency
+ DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
+ $previousRestartCount = $application->restart_count ?? 0;
+
+ if ($maxRestartCount > $previousRestartCount) {
+ // Restart count increased - this is a crash restart
+ $application->update([
+ 'restart_count' => $maxRestartCount,
+ 'last_restart_at' => now(),
+ 'last_restart_type' => 'crash',
+ ]);
+
+ // Send notification
+ $containerName = $application->name;
+ $projectUuid = data_get($application, 'environment.project.uuid');
+ $environmentName = data_get($application, 'environment.name');
+ $applicationUuid = data_get($application, 'uuid');
+
+ if ($projectUuid && $applicationUuid && $environmentName) {
+ $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
+ } else {
+ $url = null;
+ }
+ }
+
+ // Aggregate status after tracking restart counts
+ $aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount);
+ if ($aggregatedStatus) {
+ $statusFromDb = $application->status;
+ if ($statusFromDb !== $aggregatedStatus) {
+ $application->update(['status' => $aggregatedStatus]);
+ } else {
+ $application->update(['last_online_at' => now()]);
+ }
+ }
+ });
}
}
ServiceChecked::dispatch($this->server->team->id);
}
- private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
+ private function aggregateApplicationStatus($application, Collection $containerStatuses, int $maxRestartCount = 0): ?string
{
// Parse docker compose to check for excluded containers
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
@@ -413,6 +479,11 @@ private function aggregateApplicationStatus($application, Collection $containerS
return 'degraded (unhealthy)';
}
+ // If container is exited but has restart count > 0, it's in a crash loop
+ if ($hasExited && $maxRestartCount > 0) {
+ return 'degraded (unhealthy)';
+ }
+
if ($hasRunning && $hasExited) {
return 'degraded (unhealthy)';
}
@@ -421,7 +492,7 @@ private function aggregateApplicationStatus($application, Collection $containerS
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
}
- // All containers are exited
+ // All containers are exited with no restart count - truly stopped
return 'exited (unhealthy)';
}
}
diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php
index dfef6a566..6b5e1d4ac 100644
--- a/app/Actions/Service/StartService.php
+++ b/app/Actions/Service/StartService.php
@@ -20,18 +20,23 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
}
$service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
- $commands[] = 'cd '.$service->workdir();
- $commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
+ $workdir = $service->workdir();
+ // $commands[] = "cd {$workdir}";
+ $commands[] = "echo 'Saved configuration files to {$workdir}.'";
+ // Ensure .env exists in the correct directory 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 {$workdir}/.env";
if ($pullLatestImages) {
$commands[] = "echo 'Pulling images.'";
- $commands[] = 'docker compose pull';
+ $commands[] = "docker compose --project-directory {$workdir} pull";
}
if ($service->networks()->count() > 0) {
$commands[] = "echo 'Creating Docker network.'";
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
}
$commands[] = 'echo Starting service.';
- $commands[] = 'docker compose up -d --remove-orphans --force-recreate --build';
+ $commands[] = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --remove-orphans --force-recreate --build";
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
if (data_get($service, 'connect_to_docker_network')) {
$compose = data_get($service, 'docker_compose', []);
diff --git a/app/Console/Commands/CleanupRedis.php b/app/Console/Commands/CleanupRedis.php
index f6a2de75b..abf8010c0 100644
--- a/app/Console/Commands/CleanupRedis.php
+++ b/app/Console/Commands/CleanupRedis.php
@@ -7,7 +7,7 @@
class CleanupRedis extends Command
{
- protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks}';
+ protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks} {--restart : Aggressive cleanup mode for system restart (marks all processing jobs as failed)}';
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)';
@@ -63,6 +63,14 @@ public function handle()
$deletedCount += $locksCleaned;
}
+ // Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
+ $isRestart = $this->option('restart');
+ if ($isRestart || $this->option('clear-locks')) {
+ $this->info($isRestart ? 'Cleaning up stuck jobs (RESTART MODE - aggressive)...' : 'Checking for stuck jobs (runtime mode - conservative)...');
+ $jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart);
+ $deletedCount += $jobsCleaned;
+ }
+
if ($dryRun) {
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
} else {
@@ -332,4 +340,130 @@ private function cleanupCacheLocks(bool $dryRun): int
return $cleanedCount;
}
+
+ /**
+ * Clean up stuck jobs based on mode (restart vs runtime).
+ *
+ * @param mixed $redis Redis connection
+ * @param string $prefix Horizon prefix
+ * @param bool $dryRun Dry run mode
+ * @param bool $isRestart Restart mode (aggressive) vs runtime mode (conservative)
+ * @return int Number of jobs cleaned
+ */
+ private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $isRestart): int
+ {
+ $cleanedCount = 0;
+ $now = time();
+
+ // Get all keys with the horizon prefix
+ $cursor = 0;
+ $keys = [];
+ do {
+ $result = $redis->scan($cursor, ['match' => '*', 'count' => 100]);
+
+ // Guard against scan() returning false
+ if ($result === false) {
+ $this->error('Redis scan failed, stopping key retrieval');
+ break;
+ }
+
+ $cursor = $result[0];
+ $keys = array_merge($keys, $result[1]);
+ } while ($cursor !== 0);
+
+ foreach ($keys as $key) {
+ $keyWithoutPrefix = str_replace($prefix, '', $key);
+ $type = $redis->command('type', [$keyWithoutPrefix]);
+
+ // Only process hash-type keys (individual jobs)
+ if ($type !== 5) {
+ continue;
+ }
+
+ $data = $redis->command('hgetall', [$keyWithoutPrefix]);
+ $status = data_get($data, 'status');
+ $payload = data_get($data, 'payload');
+
+ // Only process jobs in "processing" or "reserved" state
+ if (! in_array($status, ['processing', 'reserved'])) {
+ continue;
+ }
+
+ // Parse job payload to get job class and started time
+ $payloadData = json_decode($payload, true);
+
+ // Check for JSON decode errors
+ if ($payloadData === null || json_last_error() !== JSON_ERROR_NONE) {
+ $errorMsg = json_last_error_msg();
+ $truncatedPayload = is_string($payload) ? substr($payload, 0, 200) : 'non-string payload';
+ $this->error("Failed to decode job payload for {$keyWithoutPrefix}: {$errorMsg}. Payload: {$truncatedPayload}");
+
+ continue;
+ }
+
+ $jobClass = data_get($payloadData, 'displayName', 'Unknown');
+
+ // Prefer reserved_at (when job started processing), fallback to created_at
+ $reservedAt = (int) data_get($data, 'reserved_at', 0);
+ $createdAt = (int) data_get($data, 'created_at', 0);
+ $startTime = $reservedAt ?: $createdAt;
+
+ // If we can't determine when the job started, skip it
+ if (! $startTime) {
+ continue;
+ }
+
+ // Calculate how long the job has been processing
+ $processingTime = $now - $startTime;
+
+ $shouldFail = false;
+ $reason = '';
+
+ if ($isRestart) {
+ // RESTART MODE: Mark ALL processing/reserved jobs as failed
+ // Safe because all workers are dead on restart
+ $shouldFail = true;
+ $reason = 'System restart - all workers terminated';
+ } else {
+ // RUNTIME MODE: Only mark truly stuck jobs as failed
+ // Be conservative to avoid killing legitimate long-running jobs
+
+ // Skip ApplicationDeploymentJob entirely (has dynamic_timeout, can run 2+ hours)
+ if (str_contains($jobClass, 'ApplicationDeploymentJob')) {
+ continue;
+ }
+
+ // Skip DatabaseBackupJob (large backups can take hours)
+ if (str_contains($jobClass, 'DatabaseBackupJob')) {
+ continue;
+ }
+
+ // For other jobs, only fail if processing > 12 hours
+ if ($processingTime > 43200) { // 12 hours
+ $shouldFail = true;
+ $reason = 'Processing for more than 12 hours';
+ }
+ }
+
+ if ($shouldFail) {
+ if ($dryRun) {
+ $this->warn(" Would mark as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1)." min) - {$reason}");
+ } else {
+ // Mark job as failed
+ $redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
+ $redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
+ $redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]);
+
+ $this->info(" ✓ Marked as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1).' min) - '.$reason);
+ }
+ $cleanedCount++;
+ }
+ }
+
+ if ($cleanedCount === 0) {
+ $this->info($isRestart ? ' No jobs to clean up' : ' No stuck jobs found (all jobs running normally)');
+ }
+
+ return $cleanedCount;
+ }
}
diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php
index 0b13462ef..165a3ae21 100644
--- a/app/Console/Commands/CleanupStuckedResources.php
+++ b/app/Console/Commands/CleanupStuckedResources.php
@@ -222,9 +222,14 @@ private function cleanup_stucked_resources()
try {
$scheduled_backups = ScheduledDatabaseBackup::all();
foreach ($scheduled_backups as $scheduled_backup) {
- if (! $scheduled_backup->server()) {
- echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
- $scheduled_backup->delete();
+ try {
+ $server = $scheduled_backup->server();
+ if (! $server) {
+ echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
+ $scheduled_backup->delete();
+ }
+ } catch (\Throwable $e) {
+ echo "Error checking server for scheduledbackup {$scheduled_backup->id}: {$e->getMessage()}\n";
}
}
} catch (\Throwable $e) {
@@ -416,7 +421,7 @@ private function cleanup_stucked_resources()
foreach ($serviceApplications as $service) {
if (! data_get($service, 'service')) {
echo 'ServiceApplication without service: '.$service->name.'\n';
- DeleteResourceJob::dispatch($service);
+ $service->forceDelete();
continue;
}
@@ -429,7 +434,7 @@ private function cleanup_stucked_resources()
foreach ($serviceDatabases as $service) {
if (! data_get($service, 'service')) {
echo 'ServiceDatabase without service: '.$service->name.'\n';
- DeleteResourceJob::dispatch($service);
+ $service->forceDelete();
continue;
}
diff --git a/app/Console/Commands/Dev.php b/app/Console/Commands/Dev.php
index 8f26d78ff..acc6dc2f9 100644
--- a/app/Console/Commands/Dev.php
+++ b/app/Console/Commands/Dev.php
@@ -4,6 +4,9 @@
use App\Jobs\CheckHelperImageJob;
use App\Models\InstanceSettings;
+use App\Models\ScheduledDatabaseBackupExecution;
+use App\Models\ScheduledTaskExecution;
+use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
@@ -45,6 +48,44 @@ public function init()
} else {
echo "Instance already initialized.\n";
}
+
+ // Clean up stuck jobs and stale locks on development startup
+ try {
+ echo "Cleaning up Redis (stuck jobs and stale locks)...\n";
+ Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
+ echo "Redis cleanup completed.\n";
+ } catch (\Throwable $e) {
+ echo "Error in cleanup:redis: {$e->getMessage()}\n";
+ }
+
+ try {
+ $updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
+ 'status' => 'failed',
+ 'message' => 'Marked as failed during Coolify startup - job was interrupted',
+ 'finished_at' => Carbon::now(),
+ ]);
+
+ if ($updatedTaskCount > 0) {
+ echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
+ }
+ } catch (\Throwable $e) {
+ echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
+ }
+
+ try {
+ $updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
+ 'status' => 'failed',
+ 'message' => 'Marked as failed during Coolify startup - job was interrupted',
+ 'finished_at' => Carbon::now(),
+ ]);
+
+ if ($updatedBackupCount > 0) {
+ echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
+ }
+ } catch (\Throwable $e) {
+ echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
+ }
+
CheckHelperImageJob::dispatch();
}
}
diff --git a/app/Console/Commands/Emails.php b/app/Console/Commands/Emails.php
index a022d54dc..43ba06804 100644
--- a/app/Console/Commands/Emails.php
+++ b/app/Console/Commands/Emails.php
@@ -167,7 +167,7 @@ public function handle()
]);
}
$output = 'Because of an error, the backup of the database '.$db->name.' failed.';
- $this->mail = (new BackupFailed($backup, $db, $output))->toMail();
+ $this->mail = (new BackupFailed($backup, $db, $output, $backup->database_name ?? 'unknown'))->toMail();
$this->sendEmail();
break;
case 'backup-success':
diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php
index 4bc818f0a..66cb77838 100644
--- a/app/Console/Commands/Init.php
+++ b/app/Console/Commands/Init.php
@@ -10,9 +10,12 @@
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
+use App\Models\ScheduledDatabaseBackupExecution;
+use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Models\User;
+use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
@@ -73,7 +76,7 @@ public function handle()
$this->cleanupUnusedNetworkFromCoolifyProxy();
try {
- $this->call('cleanup:redis', ['--clear-locks' => true]);
+ $this->call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
} catch (\Throwable $e) {
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
}
@@ -86,6 +89,7 @@ public function handle()
$this->call('cleanup:stucked-resources');
} catch (\Throwable $e) {
echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n";
+ echo "Continuing with initialization - cleanup errors will not prevent Coolify from starting\n";
}
try {
$updatedCount = ApplicationDeploymentQueue::whereIn('status', [
@@ -102,6 +106,34 @@ public function handle()
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
}
+ try {
+ $updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
+ 'status' => 'failed',
+ 'message' => 'Marked as failed during Coolify startup - job was interrupted',
+ 'finished_at' => Carbon::now(),
+ ]);
+
+ if ($updatedTaskCount > 0) {
+ echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
+ }
+ } catch (\Throwable $e) {
+ echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
+ }
+
+ try {
+ $updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
+ 'status' => 'failed',
+ 'message' => 'Marked as failed during Coolify startup - job was interrupted',
+ 'finished_at' => Carbon::now(),
+ ]);
+
+ if ($updatedBackupCount > 0) {
+ echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
+ }
+ } catch (\Throwable $e) {
+ echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
+ }
+
try {
$localhost = $this->servers->where('id', 0)->first();
if ($localhost) {
diff --git a/app/Exceptions/DeploymentException.php b/app/Exceptions/DeploymentException.php
new file mode 100644
index 000000000..01e0a8235
--- /dev/null
+++ b/app/Exceptions/DeploymentException.php
@@ -0,0 +1,32 @@
+getMessage(), $exception->getCode(), $exception);
+ }
+}
diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php
index 3d731223d..71de48bcd 100644
--- a/app/Exceptions/Handler.php
+++ b/app/Exceptions/Handler.php
@@ -30,6 +30,7 @@ class Handler extends ExceptionHandler
protected $dontReport = [
ProcessException::class,
NonReportableException::class,
+ DeploymentException::class,
];
/**
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index 5ba9c08e7..a1fcaa7f5 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -246,6 +246,40 @@ public function manual(Request $request)
if ($action === 'closed') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
+ // Cancel any active deployments for this PR immediately
+ $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
+ ->where('pull_request_id', $pull_request_id)
+ ->whereIn('status', [
+ \App\Enums\ApplicationDeploymentStatus::QUEUED->value,
+ \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ])
+ ->first();
+
+ if ($activeDeployment) {
+ try {
+ // Mark deployment as cancelled
+ $activeDeployment->update([
+ 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
+ ]);
+
+ // Add cancellation log entry
+ $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
+
+ // Check if helper container exists and kill it
+ $deployment_uuid = $activeDeployment->deployment_uuid;
+ $server = $application->destination->server;
+ $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
+ $containerExists = instant_remote_process([$checkCommand], $server);
+
+ if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
+ instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
+ $activeDeployment->addLogEntry('Deployment container stopped.');
+ }
+ } catch (\Throwable $e) {
+ // Silently handle errors during deployment cancellation
+ }
+ }
+
DeleteResourceJob::dispatch($found);
$return_payloads->push([
'application' => $application->name,
@@ -481,6 +515,42 @@ public function normal(Request $request)
if ($action === 'closed' || $action === 'close') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
+ // Cancel any active deployments for this PR immediately
+ $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
+ ->where('pull_request_id', $pull_request_id)
+ ->whereIn('status', [
+ \App\Enums\ApplicationDeploymentStatus::QUEUED->value,
+ \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ])
+ ->first();
+
+ if ($activeDeployment) {
+ try {
+ // Mark deployment as cancelled
+ $activeDeployment->update([
+ 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
+ ]);
+
+ // Add cancellation log entry
+ $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
+
+ // Check if helper container exists and kill it
+ $deployment_uuid = $activeDeployment->deployment_uuid;
+ $server = $application->destination->server;
+ $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
+ $containerExists = instant_remote_process([$checkCommand], $server);
+
+ if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
+ instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
+ $activeDeployment->addLogEntry('Deployment container stopped.');
+ }
+
+ } catch (\Throwable $e) {
+ // Silently handle errors during deployment cancellation
+ }
+ }
+
+ // Clean up any deployed containers
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
if ($containers->isNotEmpty()) {
$containers->each(function ($container) use ($application) {
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index ea8cdff95..349fccb50 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -7,6 +7,7 @@
use App\Enums\ProcessStatus;
use App\Events\ApplicationConfigurationChanged;
use App\Events\ServiceStatusChanged;
+use App\Exceptions\DeploymentException;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
@@ -31,7 +32,6 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
-use RuntimeException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Throwable;
@@ -341,20 +341,42 @@ public function handle(): void
$this->fail($e);
throw $e;
} finally {
- $this->application_deployment_queue->update([
- 'finished_at' => Carbon::now()->toImmutable(),
- ]);
-
- if ($this->use_build_server) {
- $this->server = $this->build_server;
- } else {
- $this->write_deployment_configurations();
+ // Wrap cleanup operations in try-catch to prevent exceptions from interfering
+ // with Laravel's job failure handling and status updates
+ try {
+ $this->application_deployment_queue->update([
+ 'finished_at' => Carbon::now()->toImmutable(),
+ ]);
+ } catch (Exception $e) {
+ // Log but don't fail - finished_at is not critical
+ \Log::warning('Failed to update finished_at for deployment '.$this->deployment_uuid.': '.$e->getMessage());
}
- $this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
- $this->graceful_shutdown_container($this->deployment_uuid);
+ try {
+ if ($this->use_build_server) {
+ $this->server = $this->build_server;
+ } else {
+ $this->write_deployment_configurations();
+ }
+ } catch (Exception $e) {
+ // Log but don't fail - configuration writing errors shouldn't prevent status updates
+ $this->application_deployment_queue->addLogEntry('Warning: Failed to write deployment configurations: '.$e->getMessage(), 'stderr');
+ }
- ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
+ try {
+ $this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
+ $this->graceful_shutdown_container($this->deployment_uuid);
+ } catch (Exception $e) {
+ // Log but don't fail - container cleanup errors are expected when container is already gone
+ \Log::warning('Failed to shutdown container '.$this->deployment_uuid.': '.$e->getMessage());
+ }
+
+ try {
+ ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
+ } catch (Exception $e) {
+ // Log but don't fail - event dispatch errors shouldn't prevent status updates
+ \Log::warning('Failed to dispatch ServiceStatusChanged for deployment '.$this->deployment_uuid.': '.$e->getMessage());
+ }
}
}
@@ -954,7 +976,7 @@ private function push_to_docker_registry()
} catch (Exception $e) {
$this->application_deployment_queue->addLogEntry('Failed to push image to docker registry. Please check debug logs for more information.');
if ($forceFail) {
- throw new RuntimeException($e->getMessage(), 69420);
+ throw new DeploymentException($e->getMessage(), 69420);
}
}
}
@@ -1146,6 +1168,18 @@ private function generate_runtime_environment_variables()
foreach ($runtime_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
}
+
+ // Check for PORT environment variable mismatch with ports_exposes
+ if ($this->build_pack !== 'dockercompose') {
+ $detectedPort = $this->application->detectPortFromEnvironment(false);
+ if ($detectedPort && ! empty($ports) && ! in_array($detectedPort, $ports)) {
+ $this->application_deployment_queue->addLogEntry(
+ "Warning: PORT environment variable ({$detectedPort}) does not match configured ports_exposes: ".implode(',', $ports).'. It could case "bad gateway" or "no server" errors. Check the "General" page to fix it.',
+ 'stderr'
+ );
+ }
+ }
+
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
@@ -1789,7 +1823,7 @@ private function prepare_builder_image(bool $firstTry = true)
$env_flags = $this->generate_docker_env_flags_for_secrets();
if ($this->use_build_server) {
if ($this->dockerConfigFileExists === 'NOK') {
- throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
+ throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
}
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else {
@@ -2055,7 +2089,7 @@ private function generate_nixpacks_confs()
if ($this->saved_outputs->get('nixpacks_type')) {
$this->nixpacks_type = $this->saved_outputs->get('nixpacks_type');
if (str($this->nixpacks_type)->isEmpty()) {
- throw new RuntimeException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers');
+ throw new DeploymentException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers');
}
}
@@ -3029,6 +3063,11 @@ 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->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(
@@ -3637,7 +3676,7 @@ private function run_pre_deployment_command()
return;
}
}
- throw new RuntimeException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
+ throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
}
private function run_post_deployment_command()
@@ -3673,7 +3712,7 @@ private function run_post_deployment_command()
return;
}
}
- throw new RuntimeException('Post-deployment command: Could not find a valid container. Is the container name correct?');
+ throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
}
/**
@@ -3684,7 +3723,7 @@ private function checkForCancellation(): void
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
- throw new \RuntimeException('Deployment cancelled by user', 69420);
+ throw new DeploymentException('Deployment cancelled by user', 69420);
}
}
@@ -3717,7 +3756,7 @@ private function isInTerminalState(): bool
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
- throw new \RuntimeException('Deployment cancelled by user', 69420);
+ throw new DeploymentException('Deployment cancelled by user', 69420);
}
return false;
@@ -3798,10 +3837,8 @@ private function failDeployment(): void
public function failed(Throwable $exception): void
{
$this->failDeployment();
- $this->application_deployment_queue->addLogEntry('Oops something is not okay, are you okay? 😢', 'stderr');
- if (str($exception->getMessage())->isNotEmpty()) {
- $this->application_deployment_queue->addLogEntry($exception->getMessage(), 'stderr');
- }
+ $errorMessage = $exception->getMessage() ?: 'Unknown error occurred';
+ $this->application_deployment_queue->addLogEntry("Deployment failed: {$errorMessage}", 'stderr');
if ($this->application->build_pack !== 'dockercompose') {
$code = $exception->getCode();
diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php
index c82a27ce9..f6f5e8b5b 100644
--- a/app/Jobs/CleanupHelperContainersJob.php
+++ b/app/Jobs/CleanupHelperContainersJob.php
@@ -2,6 +2,8 @@
namespace App\Jobs;
+use App\Enums\ApplicationDeploymentStatus;
+use App\Models\ApplicationDeploymentQueue;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -20,10 +22,51 @@ public function __construct(public Server $server) {}
public function handle(): void
{
try {
+ // Get all active deployments on this server
+ $activeDeployments = ApplicationDeploymentQueue::where('server_id', $this->server->id)
+ ->whereIn('status', [
+ ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ApplicationDeploymentStatus::QUEUED->value,
+ ])
+ ->pluck('deployment_uuid')
+ ->toArray();
+
+ \Log::info('CleanupHelperContainersJob - Active deployments', [
+ 'server' => $this->server->name,
+ 'active_deployment_uuids' => $activeDeployments,
+ ]);
+
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
- $containerIds = collect(json_decode($containers))->pluck('ID');
- if ($containerIds->count() > 0) {
- foreach ($containerIds as $containerId) {
+ $helperContainers = collect(json_decode($containers));
+
+ if ($helperContainers->count() > 0) {
+ foreach ($helperContainers as $container) {
+ $containerId = data_get($container, 'ID');
+ $containerName = data_get($container, 'Names');
+
+ // Check if this container belongs to an active deployment
+ $isActiveDeployment = false;
+ foreach ($activeDeployments as $deploymentUuid) {
+ if (str_contains($containerName, $deploymentUuid)) {
+ $isActiveDeployment = true;
+ break;
+ }
+ }
+
+ if ($isActiveDeployment) {
+ \Log::info('CleanupHelperContainersJob - Skipping active deployment container', [
+ 'container' => $containerName,
+ 'id' => $containerId,
+ ]);
+
+ continue;
+ }
+
+ \Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [
+ 'container' => $containerName,
+ 'id' => $containerId,
+ ]);
+
instant_remote_process_with_timeout(['docker container rm -f '.$containerId], $this->server, false);
}
}
diff --git a/app/Jobs/CoolifyTask.php b/app/Jobs/CoolifyTask.php
index 49a5ba8dd..d6dc6fa05 100755
--- a/app/Jobs/CoolifyTask.php
+++ b/app/Jobs/CoolifyTask.php
@@ -3,18 +3,35 @@
namespace App\Jobs;
use App\Actions\CoolifyTask\RunRemoteProcess;
+use App\Enums\ProcessStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
use Spatie\Activitylog\Models\Activity;
class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+ /**
+ * The number of times the job may be attempted.
+ */
+ public $tries = 3;
+
+ /**
+ * The maximum number of unhandled exceptions to allow before failing.
+ */
+ public $maxExceptions = 1;
+
+ /**
+ * The number of seconds the job can run before timing out.
+ */
+ public $timeout = 600;
+
/**
* Create a new job instance.
*/
@@ -42,4 +59,36 @@ public function handle(): void
$remote_process();
}
+
+ /**
+ * Calculate the number of seconds to wait before retrying the job.
+ */
+ public function backoff(): array
+ {
+ return [30, 90, 180]; // 30s, 90s, 180s between retries
+ }
+
+ /**
+ * Handle a job failure.
+ */
+ public function failed(?\Throwable $exception): void
+ {
+ Log::channel('scheduled-errors')->error('CoolifyTask permanently failed', [
+ 'job' => 'CoolifyTask',
+ 'activity_id' => $this->activity->id,
+ 'server_uuid' => $this->activity->getExtraProperty('server_uuid'),
+ 'command_preview' => substr($this->activity->getExtraProperty('command') ?? '', 0, 200),
+ 'error' => $exception?->getMessage(),
+ 'total_attempts' => $this->attempts(),
+ 'trace' => $exception?->getTraceAsString(),
+ ]);
+
+ // Update activity status to reflect permanent failure
+ $this->activity->properties = $this->activity->properties->merge([
+ 'status' => ProcessStatus::ERROR->value,
+ 'error' => $exception?->getMessage() ?? 'Job permanently failed',
+ 'failed_at' => now()->toIso8601String(),
+ ]);
+ $this->activity->save();
+ }
}
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 45586f0d0..8766a1afc 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -23,6 +23,7 @@
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
use Visus\Cuid2\Cuid2;
@@ -31,6 +32,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+ public $maxExceptions = 1;
+
public ?Team $team = null;
public Server $server;
@@ -74,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public ScheduledDatabaseBackup $backup)
{
$this->onQueue('high');
- $this->timeout = $backup->timeout;
+ $this->timeout = $backup->timeout ?? 3600;
}
public function handle(): void
@@ -661,15 +664,34 @@ private function getFullImageName(): string
public function failed(?Throwable $exception): void
{
+ Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [
+ 'job' => 'DatabaseBackupJob',
+ 'backup_id' => $this->backup->uuid,
+ 'database' => $this->database?->name ?? 'unknown',
+ 'database_type' => get_class($this->database ?? new \stdClass),
+ 'server' => $this->server?->name ?? 'unknown',
+ 'total_attempts' => $this->attempts(),
+ 'error' => $exception?->getMessage(),
+ 'trace' => $exception?->getTraceAsString(),
+ ]);
+
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
if ($log) {
$log->update([
'status' => 'failed',
- 'message' => 'Job failed: '.($exception?->getMessage() ?? 'Unknown error'),
+ 'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
'size' => 0,
'filename' => null,
+ 'finished_at' => Carbon::now(),
]);
}
+
+ // Notify team about permanent failure
+ if ($this->team) {
+ $databaseName = $log?->database_name ?? 'unknown';
+ $output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
+ $this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
+ }
}
}
diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php
index b9fbebcc9..c4358570e 100644
--- a/app/Jobs/DeleteResourceJob.php
+++ b/app/Jobs/DeleteResourceJob.php
@@ -124,16 +124,54 @@ private function deleteApplicationPreview()
$this->resource->delete();
}
+ // Cancel any active deployments for this PR (same logic as API cancel_deployment)
+ $activeDeployments = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
+ ->where('pull_request_id', $pull_request_id)
+ ->whereIn('status', [
+ \App\Enums\ApplicationDeploymentStatus::QUEUED->value,
+ \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
+ ])
+ ->get();
+
+ foreach ($activeDeployments as $activeDeployment) {
+ try {
+ // Mark deployment as cancelled
+ $activeDeployment->update([
+ 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
+ ]);
+
+ // Add cancellation log entry
+ $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
+
+ // Check if helper container exists and kill it
+ $deployment_uuid = $activeDeployment->deployment_uuid;
+ $escapedDeploymentUuid = escapeshellarg($deployment_uuid);
+ $checkCommand = "docker ps -a --filter name={$escapedDeploymentUuid} --format '{{.Names}}'";
+ $containerExists = instant_remote_process([$checkCommand], $server);
+
+ if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
+ instant_remote_process(["docker rm -f {$escapedDeploymentUuid}"], $server);
+ $activeDeployment->addLogEntry('Deployment container stopped.');
+ } else {
+ $activeDeployment->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
+ }
+
+ } catch (\Throwable $e) {
+ // Silently handle errors during deployment cancellation
+ }
+ }
+
try {
if ($server->isSwarm()) {
- instant_remote_process(["docker stack rm {$application->uuid}-{$pull_request_id}"], $server);
+ $escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
+ instant_remote_process(["docker stack rm {$escapedStackName}"], $server);
} else {
$containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray();
$this->stopPreviewContainers($containers, $server);
}
} catch (\Throwable $e) {
// Log the error but don't fail the job
- ray('Error stopping preview containers: '.$e->getMessage());
+ \Log::warning('Error stopping preview containers for application '.$application->uuid.', PR #'.$pull_request_id.': '.$e->getMessage());
}
// Finally, force delete to trigger resource cleanup
@@ -156,7 +194,6 @@ private function stopPreviewContainers(array $containers, $server, int $timeout
"docker stop --time=$timeout $containerList",
"docker rm -f $containerList",
];
-
instant_remote_process(
command: $commands,
server: $server,
diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php
index 9937444b8..75ff883c2 100644
--- a/app/Jobs/ScheduledJobManager.php
+++ b/app/Jobs/ScheduledJobManager.php
@@ -52,7 +52,7 @@ public function middleware(): array
{
return [
(new WithoutOverlapping('scheduled-job-manager'))
- ->expireAfter(60) // Lock expires after 1 minute to prevent stale locks
+ ->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
->dontRelease(), // Don't re-queue on lock conflict
];
}
diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php
index 609595356..e55db5440 100644
--- a/app/Jobs/ScheduledTaskJob.php
+++ b/app/Jobs/ScheduledTaskJob.php
@@ -18,14 +18,30 @@
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
class ScheduledTaskJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+ /**
+ * The number of times the job may be attempted.
+ */
+ public $tries = 3;
+
+ /**
+ * The maximum number of unhandled exceptions to allow before failing.
+ */
+ public $maxExceptions = 1;
+
+ /**
+ * The number of seconds the job can run before timing out.
+ */
+ public $timeout = 300;
+
public Team $team;
- public Server $server;
+ public ?Server $server = null;
public ScheduledTask $task;
@@ -33,6 +49,11 @@ class ScheduledTaskJob implements ShouldQueue
public ?ScheduledTaskExecution $task_log = null;
+ /**
+ * Store execution ID to survive job serialization for timeout handling.
+ */
+ protected ?int $executionId = null;
+
public string $task_status = 'failed';
public ?string $task_output = null;
@@ -55,6 +76,9 @@ public function __construct($task)
}
$this->team = Team::findOrFail($task->team_id);
$this->server_timezone = $this->getServerTimezone();
+
+ // Set timeout from task configuration
+ $this->timeout = $this->task->timeout ?? 300;
}
private function getServerTimezone(): string
@@ -70,11 +94,18 @@ private function getServerTimezone(): string
public function handle(): void
{
+ $startTime = Carbon::now();
+
try {
$this->task_log = ScheduledTaskExecution::create([
'scheduled_task_id' => $this->task->id,
+ 'started_at' => $startTime,
+ 'retry_count' => $this->attempts() - 1,
]);
+ // Store execution ID for timeout handling
+ $this->executionId = $this->task_log->id;
+
$this->server = $this->resource->destination->server;
if ($this->resource->type() === 'application') {
@@ -129,15 +160,101 @@ public function handle(): void
'message' => $this->task_output ?? $e->getMessage(),
]);
}
- $this->team?->notify(new TaskFailed($this->task, $e->getMessage()));
+
+ // Log the error to the scheduled-errors channel
+ Log::channel('scheduled-errors')->error('ScheduledTask execution failed', [
+ 'job' => 'ScheduledTaskJob',
+ 'task_id' => $this->task->uuid,
+ 'task_name' => $this->task->name,
+ 'server' => $this->server?->name ?? 'unknown',
+ 'attempt' => $this->attempts(),
+ 'error' => $e->getMessage(),
+ ]);
+
+ // Only notify and throw on final failure
+
+ // Re-throw to trigger Laravel's retry mechanism with backoff
throw $e;
} finally {
ScheduledTaskDone::dispatch($this->team->id);
if ($this->task_log) {
+ $finishedAt = Carbon::now();
+ $duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
+
$this->task_log->update([
- 'finished_at' => Carbon::now()->toImmutable(),
+ 'finished_at' => $finishedAt->toImmutable(),
+ 'duration' => $duration,
]);
}
}
}
+
+ /**
+ * Calculate the number of seconds to wait before retrying the job.
+ */
+ public function backoff(): array
+ {
+ return [30, 60, 120]; // 30s, 60s, 120s between retries
+ }
+
+ /**
+ * Handle a job failure.
+ */
+ public function failed(?\Throwable $exception): void
+ {
+ Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
+ 'job' => 'ScheduledTaskJob',
+ 'task_id' => $this->task->uuid,
+ 'task_name' => $this->task->name,
+ 'server' => $this->server?->name ?? 'unknown',
+ 'total_attempts' => $this->attempts(),
+ 'error' => $exception?->getMessage(),
+ 'trace' => $exception?->getTraceAsString(),
+ ]);
+
+ // Reload execution log from database
+ // When a job times out, failed() is called in a fresh process with the original
+ // queue payload, so $executionId will be null. We need to query for the latest execution.
+ $execution = null;
+
+ // Try to find execution using stored ID first (works for non-timeout failures)
+ if ($this->executionId) {
+ $execution = ScheduledTaskExecution::find($this->executionId);
+ }
+
+ // If no stored ID or not found, query for the most recent execution log for this task
+ if (! $execution) {
+ $execution = ScheduledTaskExecution::query()
+ ->where('scheduled_task_id', $this->task->id)
+ ->orderBy('created_at', 'desc')
+ ->first();
+ }
+
+ // Last resort: check task_log property
+ if (! $execution && $this->task_log) {
+ $execution = $this->task_log;
+ }
+
+ if ($execution) {
+ $errorMessage = 'Job permanently failed after '.$this->attempts().' attempts';
+ if ($exception) {
+ $errorMessage .= ': '.$exception->getMessage();
+ }
+
+ $execution->update([
+ 'status' => 'failed',
+ 'message' => $errorMessage,
+ 'error_details' => $exception?->getTraceAsString(),
+ 'finished_at' => Carbon::now()->toImmutable(),
+ ]);
+ } else {
+ Log::channel('scheduled-errors')->warning('Could not find execution log to update', [
+ 'execution_id' => $this->executionId,
+ 'task_id' => $this->task->uuid,
+ ]);
+ }
+
+ // Notify team about permanent failure
+ $this->team?->notify(new TaskFailed($this->task, $exception?->getMessage() ?? 'Unknown error'));
+ }
}
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index a83e6f70a..ce7d6b1b7 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -1000,4 +1000,23 @@ private function updateServiceEnvironmentVariables()
}
}
}
+
+ public function getDetectedPortInfoProperty(): ?array
+ {
+ $detectedPort = $this->application->detectPortFromEnvironment();
+
+ if (! $detectedPort) {
+ return null;
+ }
+
+ $portsExposesArray = $this->application->ports_exposes_array;
+ $isMatch = in_array($detectedPort, $portsExposesArray);
+ $isEmpty = empty($portsExposesArray);
+
+ return [
+ 'port' => $detectedPort,
+ 'matches' => $isMatch,
+ 'isEmpty' => $isEmpty,
+ ];
+ }
}
diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php
index 5231438e5..fc63c7f4b 100644
--- a/app/Livewire/Project/Application/Heading.php
+++ b/app/Livewire/Project/Application/Heading.php
@@ -101,11 +101,18 @@ public function deploy(bool $force_rebuild = false)
force_rebuild: $force_rebuild,
);
if ($result['status'] === 'skipped') {
- $this->dispatch('success', 'Deployment skipped', $result['message']);
+ $this->dispatch('error', 'Deployment skipped', $result['message']);
return;
}
+ // Reset restart count on successful deployment
+ $this->application->update([
+ 'restart_count' => 0,
+ 'last_restart_at' => null,
+ 'last_restart_type' => null,
+ ]);
+
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
@@ -137,6 +144,7 @@ public function restart()
return;
}
+
$this->setDeploymentUuid();
$result = queue_application_deployment(
application: $this->application,
@@ -149,6 +157,13 @@ public function restart()
return;
}
+ // Reset restart count on manual restart
+ $this->application->update([
+ 'restart_count' => 0,
+ 'last_restart_at' => now(),
+ 'last_restart_type' => 'manual',
+ ]);
+
return $this->redirectRoute('project.application.deployment.show', [
'project_uuid' => $this->parameters['project_uuid'],
'application_uuid' => $this->parameters['application_uuid'],
diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php
index 7deaa82a9..da543a049 100644
--- a/app/Livewire/Project/Database/BackupEdit.php
+++ b/app/Livewire/Project/Database/BackupEdit.php
@@ -79,7 +79,7 @@ class BackupEdit extends Component
#[Validate(['required', 'boolean'])]
public bool $dumpAll = false;
- #[Validate(['required', 'int', 'min:1', 'max:36000'])]
+ #[Validate(['required', 'int', 'min:60', 'max:36000'])]
public int $timeout = 3600;
public function mount()
diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php
index a9a7de878..7158b6e40 100644
--- a/app/Livewire/Project/Service/EditDomain.php
+++ b/app/Livewire/Project/Service/EditDomain.php
@@ -39,7 +39,7 @@ public function mount()
{
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
$this->authorize('view', $this->application);
- $this->requiredPort = $this->application->service->getRequiredPort();
+ $this->requiredPort = $this->application->getRequiredPort();
$this->syncData();
}
@@ -113,8 +113,7 @@ public function submit()
// Check for required port
if (! $this->forceRemovePort) {
- $service = $this->application->service;
- $requiredPort = $service->getRequiredPort();
+ $requiredPort = $this->application->getRequiredPort();
if ($requiredPort !== null) {
// Check if all FQDNs have a port
diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php
index 1d8d8b247..259b9dbec 100644
--- a/app/Livewire/Project/Service/ServiceApplicationView.php
+++ b/app/Livewire/Project/Service/ServiceApplicationView.php
@@ -135,7 +135,7 @@ public function mount()
try {
$this->parameters = get_route_parameters();
$this->authorize('view', $this->application);
- $this->requiredPort = $this->application->service->getRequiredPort();
+ $this->requiredPort = $this->application->getRequiredPort();
$this->syncData();
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -268,8 +268,7 @@ public function submit()
// Check for required port
if (! $this->forceRemovePort) {
- $service = $this->application->service;
- $requiredPort = $service->getRequiredPort();
+ $requiredPort = $this->application->getRequiredPort();
if ($requiredPort !== null) {
// Check if all FQDNs have a port
diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php
index 85cd21a7f..72ae6915a 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,27 @@ 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();
+ // 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();
+ });
+ // Refresh and write files after a successful commit
$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/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php
index e4b666532..d7210c15d 100644
--- a/app/Livewire/Project/Shared/ScheduledTask/Add.php
+++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php
@@ -34,11 +34,14 @@ class Add extends Component
public ?string $container = '';
+ public int $timeout = 300;
+
protected $rules = [
'name' => 'required|string',
'command' => 'required|string',
'frequency' => 'required|string',
'container' => 'nullable|string',
+ 'timeout' => 'required|integer|min:60|max:3600',
];
protected $validationAttributes = [
@@ -46,6 +49,7 @@ class Add extends Component
'command' => 'command',
'frequency' => 'frequency',
'container' => 'container',
+ 'timeout' => 'timeout',
];
public function mount()
@@ -103,6 +107,7 @@ public function saveScheduledTask()
$task->command = $this->command;
$task->frequency = $this->frequency;
$task->container = $this->container;
+ $task->timeout = $this->timeout;
$task->team_id = currentTeam()->id;
switch ($this->type) {
@@ -130,5 +135,6 @@ public function clear()
$this->command = '';
$this->frequency = '';
$this->container = '';
+ $this->timeout = 300;
}
}
diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php
index c8d07ae36..088de0a76 100644
--- a/app/Livewire/Project/Shared/ScheduledTask/Show.php
+++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php
@@ -40,6 +40,9 @@ class Show extends Component
#[Validate(['string', 'nullable'])]
public ?string $container = null;
+ #[Validate(['integer', 'required', 'min:60', 'max:3600'])]
+ public $timeout = 300;
+
#[Locked]
public ?string $application_uuid;
@@ -99,6 +102,7 @@ public function syncData(bool $toModel = false)
$this->task->command = str($this->command)->trim()->value();
$this->task->frequency = str($this->frequency)->trim()->value();
$this->task->container = str($this->container)->trim()->value();
+ $this->task->timeout = (int) $this->timeout;
$this->task->save();
} else {
$this->isEnabled = $this->task->enabled;
@@ -106,6 +110,7 @@ public function syncData(bool $toModel = false)
$this->command = $this->task->command;
$this->frequency = $this->task->frequency;
$this->container = $this->task->container;
+ $this->timeout = $this->task->timeout ?? 300;
}
}
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 615e35f68..5e2aaa347 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -121,6 +121,8 @@ class Application extends BaseModel
protected $casts = [
'http_basic_auth_password' => 'encrypted',
+ 'restart_count' => 'integer',
+ 'last_restart_at' => 'datetime',
];
protected static function booted()
@@ -772,6 +774,24 @@ public function main_port()
return $this->settings->is_static ? [80] : $this->ports_exposes_array;
}
+ public function detectPortFromEnvironment(?bool $isPreview = false): ?int
+ {
+ $envVars = $isPreview
+ ? $this->environment_variables_preview
+ : $this->environment_variables;
+
+ $portVar = $envVars->firstWhere('key', 'PORT');
+
+ if ($portVar && $portVar->real_value) {
+ $portValue = trim($portVar->real_value);
+ if (is_numeric($portValue)) {
+ return (int) $portValue;
+ }
+ }
+
+ return null;
+ }
+
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php
index 06903ffb6..bada0b7a5 100644
--- a/app/Models/ScheduledTask.php
+++ b/app/Models/ScheduledTask.php
@@ -12,6 +12,14 @@ class ScheduledTask extends BaseModel
protected $guarded = [];
+ protected function casts(): array
+ {
+ return [
+ 'enabled' => 'boolean',
+ 'timeout' => 'integer',
+ ];
+ }
+
public function service()
{
return $this->belongsTo(Service::class);
diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php
index de13fefb0..02fd6917a 100644
--- a/app/Models/ScheduledTaskExecution.php
+++ b/app/Models/ScheduledTaskExecution.php
@@ -8,6 +8,16 @@ class ScheduledTaskExecution extends BaseModel
{
protected $guarded = [];
+ protected function casts(): array
+ {
+ return [
+ 'started_at' => 'datetime',
+ 'finished_at' => 'datetime',
+ 'retry_count' => 'integer',
+ 'duration' => 'decimal:2',
+ ];
+ }
+
public function scheduledTask(): BelongsTo
{
return $this->belongsTo(ScheduledTask::class);
diff --git a/app/Models/Service.php b/app/Models/Service.php
index 12d3d6a11..ef755d105 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -1287,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 49bd56206..fd5c4afdb 100644
--- a/app/Models/ServiceApplication.php
+++ b/app/Models/ServiceApplication.php
@@ -109,6 +109,11 @@ public function fileStorages()
return $this->morphMany(LocalFileVolume::class, 'resource');
}
+ public function environment_variables()
+ {
+ return $this->morphMany(EnvironmentVariable::class, 'resourceable');
+ }
+
public function fqdns(): Attribute
{
return Attribute::make(
@@ -174,4 +179,77 @@ public function isBackupSolutionAvailable()
{
return false;
}
+
+ /**
+ * Get the required port for this service application.
+ * Extracts port from SERVICE_URL_* or SERVICE_FQDN_* environment variables
+ * stored at the Service level, filtering by normalized container name.
+ * Falls back to service-level port if no port-specific variable is found.
+ */
+ public function getRequiredPort(): ?int
+ {
+ try {
+ // Normalize container name same way as variable creation
+ // (uppercase, replace - and . with _)
+ $normalizedName = str($this->name)
+ ->upper()
+ ->replace('-', '_')
+ ->replace('.', '_')
+ ->value();
+ // Get all environment variables from the service
+ $serviceEnvVars = $this->service->environment_variables()->get();
+
+ // Look for SERVICE_FQDN_* or SERVICE_URL_* variables that match this container
+ foreach ($serviceEnvVars as $envVar) {
+ $key = str($envVar->key);
+
+ // Check if this is a SERVICE_FQDN_* or SERVICE_URL_* variable
+ if (! $key->startsWith('SERVICE_FQDN_') && ! $key->startsWith('SERVICE_URL_')) {
+ continue;
+ }
+ // Extract the part after SERVICE_FQDN_ or SERVICE_URL_
+ if ($key->startsWith('SERVICE_FQDN_')) {
+ $suffix = $key->after('SERVICE_FQDN_');
+ } else {
+ $suffix = $key->after('SERVICE_URL_');
+ }
+
+ // Check if this variable starts with our normalized container name
+ // Format: {NORMALIZED_NAME}_{PORT} or just {NORMALIZED_NAME}
+ if (! $suffix->startsWith($normalizedName)) {
+ \Log::debug('[ServiceApplication::getRequiredPort] Suffix does not match container', [
+ 'expected_start' => $normalizedName,
+ 'actual_suffix' => $suffix->value(),
+ ]);
+
+ continue;
+ }
+
+ // Check if there's a port suffix after the container name
+ // The suffix should be exactly NORMALIZED_NAME or NORMALIZED_NAME_PORT
+ $afterName = $suffix->after($normalizedName)->value();
+
+ // If there's content after the name, it should start with underscore
+ if ($afterName !== '' && str($afterName)->startsWith('_')) {
+ // Extract port: _3210 -> 3210
+ $port = str($afterName)->after('_')->value();
+ // Validate that the extracted port is numeric
+ if (is_numeric($port)) {
+ \Log::debug('[ServiceApplication::getRequiredPort] MATCH FOUND - Returning port', [
+ 'port' => (int) $port,
+ ]);
+
+ return (int) $port;
+ }
+ }
+ }
+
+ // Fall back to service-level port if no port-specific variable is found
+ $fallbackPort = $this->service->getRequiredPort();
+
+ return $fallbackPort;
+ } catch (\Throwable $e) {
+ return null;
+ }
+ }
}
diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php
index d595721d8..3a249059c 100644
--- a/app/Models/ServiceDatabase.php
+++ b/app/Models/ServiceDatabase.php
@@ -84,6 +84,10 @@ public function databaseType()
$image = str($this->image)->before(':');
if ($image->contains('supabase/postgres')) {
$finalImage = 'supabase/postgres';
+ } elseif ($image->contains('timescale')) {
+ $finalImage = 'postgresql';
+ } elseif ($image->contains('pgvector')) {
+ $finalImage = 'postgresql';
} elseif ($image->contains('postgres') || $image->contains('postgis')) {
$finalImage = 'postgresql';
} else {
diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php
index 245bd85f0..234bc37ad 100644
--- a/app/Notifications/Channels/EmailChannel.php
+++ b/app/Notifications/Channels/EmailChannel.php
@@ -101,6 +101,38 @@ public function send(SendsEmail $notifiable, Notification $notification): void
$mailer->send($email);
}
+ } catch (\Resend\Exceptions\ErrorException $e) {
+ // Map HTTP status codes to user-friendly messages
+ $userMessage = match ($e->getErrorCode()) {
+ 403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.',
+ 401 => 'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.',
+ 429 => 'Resend rate limit exceeded. Please try again in a few minutes.',
+ 400 => 'Email validation failed: '.$e->getErrorMessage(),
+ default => 'Failed to send email via Resend: '.$e->getErrorMessage(),
+ };
+
+ // Log detailed error for admin debugging (redact sensitive data)
+ $emailSettings = $notifiable->emailNotificationSettings ?? instanceSettings();
+ data_set($emailSettings, 'smtp_password', '********');
+ data_set($emailSettings, 'resend_api_key', '********');
+
+ send_internal_notification(sprintf(
+ "Resend Error\nStatus Code: %s\nMessage: %s\nNotification: %s\nEmail Settings:\n%s",
+ $e->getErrorCode(),
+ $e->getErrorMessage(),
+ get_class($notification),
+ json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
+ ));
+
+ // Don't report expected errors (invalid keys, validation) to Sentry
+ if (in_array($e->getErrorCode(), [403, 401, 400])) {
+ throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e));
+ }
+
+ throw new \Exception($userMessage, $e->getCode(), $e);
+ } catch (\Resend\Exceptions\TransporterException $e) {
+ send_internal_notification("Resend Transport Error: {$e->getMessage()}");
+ throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.');
} catch (\Throwable $e) {
// Check if this is a Resend domain verification error on cloud instances
if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) {
diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php
index 4aa5aae8b..58ae5f249 100644
--- a/app/Traits/ExecuteRemoteCommand.php
+++ b/app/Traits/ExecuteRemoteCommand.php
@@ -219,9 +219,22 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
$process_result = $process->wait();
if ($process_result->exitCode() !== 0) {
if (! $ignore_errors) {
+ // Check if deployment was cancelled while command was running
+ if (isset($this->application_deployment_queue)) {
+ $this->application_deployment_queue->refresh();
+ if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
+ throw new \RuntimeException('Deployment cancelled by user', 69420);
+ }
+ }
+
// Don't immediately set to FAILED - let the retry logic handle it
// This prevents premature status changes during retryable SSH errors
- throw new \RuntimeException($process_result->errorOutput());
+ $error = $process_result->errorOutput();
+ if (empty($error)) {
+ $error = $process_result->output() ?: 'Command failed with no error output';
+ }
+ $redactedCommand = $this->redact_sensitive_info($command);
+ throw new \RuntimeException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}");
}
}
}
diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php
index 382e2d015..f588b6c00 100644
--- a/bootstrap/helpers/constants.php
+++ b/bootstrap/helpers/constants.php
@@ -47,6 +47,8 @@
'neo4j',
'influxdb',
'clickhouse/clickhouse-server',
+ 'timescaledb/timescaledb',
+ 'pgvector/pgvector',
];
const SPECIFIC_SERVICES = [
'quay.io/minio/minio',
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 5bccb50f1..c62c2ad8e 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -17,24 +17,44 @@ function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pul
if (! $server->isSwarm()) {
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.applicationId={$id}' --format '{{json .}}' "], $server);
$containers = format_docker_command_output_to_json($containers);
+
$containers = $containers->map(function ($container) use ($pullRequestId, $includePullrequests) {
$labels = data_get($container, 'Labels');
- if (! str($labels)->contains('coolify.pullRequestId=')) {
- data_set($container, 'Labels', $labels.",coolify.pullRequestId={$pullRequestId}");
+ $containerName = data_get($container, 'Names');
+ $hasPrLabel = str($labels)->contains('coolify.pullRequestId=');
+ $prLabelValue = null;
+ if ($hasPrLabel) {
+ preg_match('/coolify\.pullRequestId=(\d+)/', $labels, $matches);
+ $prLabelValue = $matches[1] ?? null;
+ }
+
+ // Treat pullRequestId=0 or missing label as base deployment (convention: 0 = no PR)
+ $isBaseDeploy = ! $hasPrLabel || (int) $prLabelValue === 0;
+
+ // If we're looking for a specific PR and this is a base deployment, exclude it
+ if ($pullRequestId !== null && $pullRequestId !== 0 && $isBaseDeploy) {
+ return null;
+ }
+
+ // If this is a base deployment, include it when not filtering for PRs
+ if ($isBaseDeploy) {
return $container;
}
+
if ($includePullrequests) {
return $container;
}
- if (str($labels)->contains("coolify.pullRequestId=$pullRequestId")) {
+ if ($pullRequestId !== null && $pullRequestId !== 0 && str($labels)->contains("coolify.pullRequestId={$pullRequestId}")) {
return $container;
}
return null;
});
- return $containers->filter();
+ $filtered = $containers->filter();
+
+ return $filtered;
}
return $containers;
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index 1deec45d7..7012e2087 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) {
@@ -453,13 +457,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// for example SERVICE_FQDN_APP_3000 (without a value)
if ($key->startsWith('SERVICE_FQDN_')) {
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
- if (substr_count(str($key)->value(), '_') === 3) {
- $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
- $port = $key->afterLast('_')->value();
- } else {
- $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
- $port = null;
- }
+ $parsed = parseServiceEnvironmentVariable($key->value());
+ $fqdnFor = $parsed['service_name'];
+ $port = $parsed['port'];
$fqdn = $resource->fqdn;
if (blank($resource->fqdn)) {
$fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
@@ -482,7 +482,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$resource->save();
}
- if (substr_count(str($key)->value(), '_') === 2) {
+ if (! $parsed['has_port']) {
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
@@ -492,7 +492,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'is_preview' => false,
]);
}
- if (substr_count(str($key)->value(), '_') === 3) {
+ if ($parsed['has_port']) {
$newKey = str($key)->beforeLast('_');
$resource->environment_variables()->updateOrCreate([
@@ -563,12 +563,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
} elseif ($command->value() === 'URL') {
- $urlFor = $key->after('SERVICE_URL_')->lower()->value();
+ // SERVICE_URL_APP or SERVICE_URL_APP_3000
+ // Detect if there's a port suffix
+ $parsed = parseServiceEnvironmentVariable($key->value());
+ $urlFor = $parsed['service_name'];
+ $port = $parsed['port'];
$originalUrlFor = str($urlFor)->replace('_', '-');
if (str($urlFor)->contains('-')) {
$urlFor = str($urlFor)->replace('-', '_')->replace('.', '_');
}
$url = generateUrl(server: $server, random: "$originalUrlFor-$uuid");
+ // Append port if specified
+ $urlWithPort = $url;
+ if ($port && is_numeric($port)) {
+ $urlWithPort = "$url:$port";
+ }
$resource->environment_variables()->firstOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
@@ -595,12 +604,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
if ($domainExists !== $envExists->value) {
$envExists->update([
- 'value' => $url,
+ 'value' => $urlWithPort,
]);
}
if (is_null($domainExists)) {
$domains->put((string) $urlFor, [
- 'domain' => $url,
+ 'domain' => $urlWithPort,
]);
$resource->docker_compose_domains = $domains->toJson();
$resource->save();
@@ -711,9 +720,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) {
@@ -1293,6 +1305,15 @@ 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
+ $existingEnvFiles = data_get($service, 'env_file');
+ $envFiles = collect(is_null($existingEnvFiles) ? [] : (is_array($existingEnvFiles) ? $existingEnvFiles : [$existingEnvFiles]))
+ ->push('.env')
+ ->unique()
+ ->values();
+
+ $payload['env_file'] = $envFiles;
if ($isPullRequest) {
$serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
}
@@ -1412,22 +1433,40 @@ function serviceParser(Service $resource): Collection
}
$image = data_get_str($service, 'image');
- $isDatabase = isDatabaseImage($image, $service);
- if ($isDatabase) {
- $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
- if ($applicationFound) {
- $savedService = $applicationFound;
+
+ // Check for manually migrated services first (respects user's conversion choice)
+ $migratedApp = ServiceApplication::where('name', $serviceName)
+ ->where('service_id', $resource->id)
+ ->where('is_migrated', true)
+ ->first();
+ $migratedDb = ServiceDatabase::where('name', $serviceName)
+ ->where('service_id', $resource->id)
+ ->where('is_migrated', true)
+ ->first();
+
+ if ($migratedApp || $migratedDb) {
+ // Use the migrated service type, ignoring image detection
+ $isDatabase = (bool) $migratedDb;
+ $savedService = $migratedApp ?: $migratedDb;
+ } else {
+ // Use image detection for non-migrated services
+ $isDatabase = isDatabaseImage($image, $service);
+ if ($isDatabase) {
+ $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
+ if ($applicationFound) {
+ $savedService = $applicationFound;
+ } else {
+ $savedService = ServiceDatabase::firstOrCreate([
+ 'name' => $serviceName,
+ 'service_id' => $resource->id,
+ ]);
+ }
} else {
- $savedService = ServiceDatabase::firstOrCreate([
+ $savedService = ServiceApplication::firstOrCreate([
'name' => $serviceName,
'service_id' => $resource->id,
]);
}
- } else {
- $savedService = ServiceApplication::firstOrCreate([
- 'name' => $serviceName,
- 'service_id' => $resource->id,
- ]);
}
// Update image if it changed
if ($savedService->image !== $image) {
@@ -1442,7 +1481,24 @@ function serviceParser(Service $resource): Collection
$environment = collect(data_get($service, 'environment', []));
$buildArgs = collect(data_get($service, 'build.args', []));
$environment = $environment->merge($buildArgs);
- $isDatabase = isDatabaseImage($image, $service);
+
+ // Check for manually migrated services first (respects user's conversion choice)
+ $migratedApp = ServiceApplication::where('name', $serviceName)
+ ->where('service_id', $resource->id)
+ ->where('is_migrated', true)
+ ->first();
+ $migratedDb = ServiceDatabase::where('name', $serviceName)
+ ->where('service_id', $resource->id)
+ ->where('is_migrated', true)
+ ->first();
+
+ if ($migratedApp || $migratedDb) {
+ // Use the migrated service type, ignoring image detection
+ $isDatabase = (bool) $migratedDb;
+ } else {
+ // Use image detection for non-migrated services
+ $isDatabase = isDatabaseImage($image, $service);
+ }
$containerName = "$serviceName-{$resource->uuid}";
@@ -1462,7 +1518,11 @@ function serviceParser(Service $resource): Collection
if ($serviceName === 'plausible') {
$predefinedPort = '8000';
}
- if ($isDatabase) {
+
+ if ($migratedApp || $migratedDb) {
+ // Use the already determined migrated service
+ $savedService = $migratedApp ?: $migratedDb;
+ } elseif ($isDatabase) {
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($applicationFound) {
$savedService = $applicationFound;
@@ -1525,27 +1585,16 @@ function serviceParser(Service $resource): Collection
// Get magic environments where we need to preset the FQDN / URL
if ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_')) {
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
- if (substr_count(str($key)->value(), '_') === 3) {
- if ($key->startsWith('SERVICE_FQDN_')) {
- $urlFor = null;
- $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
- }
- if ($key->startsWith('SERVICE_URL_')) {
- $fqdnFor = null;
- $urlFor = $key->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
- }
- $port = $key->afterLast('_')->value();
- } else {
- if ($key->startsWith('SERVICE_FQDN_')) {
- $urlFor = null;
- $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
- }
- if ($key->startsWith('SERVICE_URL_')) {
- $fqdnFor = null;
- $urlFor = $key->after('SERVICE_URL_')->lower()->value();
- }
- $port = null;
+ $parsed = parseServiceEnvironmentVariable($key->value());
+ if ($key->startsWith('SERVICE_FQDN_')) {
+ $urlFor = null;
+ $fqdnFor = $parsed['service_name'];
}
+ if ($key->startsWith('SERVICE_URL_')) {
+ $fqdnFor = null;
+ $urlFor = $parsed['service_name'];
+ }
+ $port = $parsed['port'];
if (blank($savedService->fqdn)) {
if ($fqdnFor) {
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
@@ -1590,7 +1639,7 @@ function serviceParser(Service $resource): Collection
}
$savedService->save();
}
- if (substr_count(str($key)->value(), '_') === 2) {
+ if (! $parsed['has_port']) {
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
@@ -1608,7 +1657,7 @@ function serviceParser(Service $resource): Collection
'is_preview' => false,
]);
}
- if (substr_count(str($key)->value(), '_') === 3) {
+ if ($parsed['has_port']) {
// 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([
@@ -1642,8 +1691,17 @@ function serviceParser(Service $resource): Collection
$url = generateUrl(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid");
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
+ // Also check if a port-suffixed version exists (e.g., SERVICE_FQDN_UMAMI_3000)
+ $portSuffixedExists = $resource->environment_variables()
+ ->where('key', 'LIKE', $key->value().'_%')
+ ->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
+ ->exists();
$serviceExists = ServiceApplication::where('name', str($fqdnFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
- if (! $envExists && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) {
+ // Check if FQDN already has a port set (contains ':' after the domain)
+ $fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
+ // Only set FQDN if it's for the current service being processed (prevent race conditions)
+ $isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
+ if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) {
// Save URL otherwise it won't work.
$serviceExists->fqdn = $url;
$serviceExists->save();
@@ -1662,8 +1720,17 @@ function serviceParser(Service $resource): Collection
$url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid");
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
+ // Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791)
+ $portSuffixedExists = $resource->environment_variables()
+ ->where('key', 'LIKE', $key->value().'_%')
+ ->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
+ ->exists();
$serviceExists = ServiceApplication::where('name', str($urlFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
- if (! $envExists && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) {
+ // Check if FQDN already has a port set (contains ':' after the domain)
+ $fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
+ // Only set FQDN if it's for the current service being processed (prevent race conditions)
+ $isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
+ if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) {
$serviceExists->fqdn = $url;
$serviceExists->save();
}
@@ -1728,7 +1795,25 @@ function serviceParser(Service $resource): Collection
$environment = convertToKeyValueCollection($environment);
$coolifyEnvironments = collect([]);
- $isDatabase = isDatabaseImage($image, $service);
+ // Check for manually migrated services first (respects user's conversion choice)
+ $migratedApp = ServiceApplication::where('name', $serviceName)
+ ->where('service_id', $resource->id)
+ ->where('is_migrated', true)
+ ->first();
+ $migratedDb = ServiceDatabase::where('name', $serviceName)
+ ->where('service_id', $resource->id)
+ ->where('is_migrated', true)
+ ->first();
+
+ if ($migratedApp || $migratedDb) {
+ // Use the migrated service type, ignoring image detection
+ $isDatabase = (bool) $migratedDb;
+ $savedService = $migratedApp ?: $migratedDb;
+ } else {
+ // Use image detection for non-migrated services
+ $isDatabase = isDatabaseImage($image, $service);
+ }
+
$volumesParsed = collect([]);
$containerName = "$serviceName-{$resource->uuid}";
@@ -1750,7 +1835,10 @@ function serviceParser(Service $resource): Collection
$predefinedPort = '8000';
}
- if ($isDatabase) {
+ if ($migratedApp || $migratedDb) {
+ // Use the already determined migrated service
+ $savedService = $migratedApp ?: $migratedDb;
+ } elseif ($isDatabase) {
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($applicationFound) {
$savedService = $applicationFound;
@@ -1812,9 +1900,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) {
@@ -2269,6 +2360,15 @@ 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
+ $existingEnvFiles = data_get($service, 'env_file');
+ $envFiles = collect(is_null($existingEnvFiles) ? [] : (is_array($existingEnvFiles) ? $existingEnvFiles : [$existingEnvFiles]))
+ ->push('.env')
+ ->unique()
+ ->values();
+
+ $payload['env_file'] = $envFiles;
$parsedServices->put($serviceName, $payload);
}
diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php
index 924bad307..b8332cba2 100644
--- a/bootstrap/helpers/proxy.php
+++ b/bootstrap/helpers/proxy.php
@@ -212,7 +212,7 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
'services' => [
'traefik' => [
'container_name' => 'coolify-proxy',
- 'image' => 'traefik:v3.1',
+ 'image' => 'traefik:v3.5',
'restart' => RESTART_MODE,
'extra_hosts' => [
'host.docker.internal:host-gateway',
diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php
index a124272a2..a6d427a6b 100644
--- a/bootstrap/helpers/services.php
+++ b/bootstrap/helpers/services.php
@@ -184,3 +184,53 @@ function serviceKeys()
{
return get_service_templates()->keys();
}
+
+/**
+ * Parse a SERVICE_URL_* or SERVICE_FQDN_* variable to extract the service name and port.
+ *
+ * This function detects if a service environment variable has a port suffix by checking
+ * if the last segment after the underscore is numeric.
+ *
+ * Examples:
+ * - SERVICE_URL_APP_3000 → ['service_name' => 'app', 'port' => '3000', 'has_port' => true]
+ * - SERVICE_URL_MY_API_8080 → ['service_name' => 'my_api', 'port' => '8080', 'has_port' => true]
+ * - SERVICE_URL_MY_APP → ['service_name' => 'my_app', 'port' => null, 'has_port' => false]
+ * - SERVICE_FQDN_REDIS_CACHE_6379 → ['service_name' => 'redis_cache', 'port' => '6379', 'has_port' => true]
+ *
+ * @param string $key The environment variable key (e.g., SERVICE_URL_APP_3000)
+ * @return array{service_name: string, port: string|null, has_port: bool} Parsed service information
+ */
+function parseServiceEnvironmentVariable(string $key): array
+{
+ $strKey = str($key);
+ $lastSegment = $strKey->afterLast('_')->value();
+ $hasPort = is_numeric($lastSegment) && ctype_digit($lastSegment);
+
+ if ($hasPort) {
+ // Port-specific variable (e.g., SERVICE_URL_APP_3000)
+ if ($strKey->startsWith('SERVICE_URL_')) {
+ $serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
+ } elseif ($strKey->startsWith('SERVICE_FQDN_')) {
+ $serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
+ } else {
+ $serviceName = '';
+ }
+ $port = $lastSegment;
+ } else {
+ // Base variable without port (e.g., SERVICE_URL_APP)
+ if ($strKey->startsWith('SERVICE_URL_')) {
+ $serviceName = $strKey->after('SERVICE_URL_')->lower()->value();
+ } elseif ($strKey->startsWith('SERVICE_FQDN_')) {
+ $serviceName = $strKey->after('SERVICE_FQDN_')->lower()->value();
+ } else {
+ $serviceName = '';
+ }
+ $port = null;
+ }
+
+ return [
+ 'service_name' => $serviceName,
+ 'port' => $port,
+ 'has_port' => $hasPort,
+ ];
+}
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index effde712a..d9e76f399 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -1353,52 +1353,71 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
// Decide if the service is a database
$image = data_get_str($service, 'image');
- $isDatabase = isDatabaseImage($image, $service);
- data_set($service, 'is_database', $isDatabase);
- // Create new serviceApplication or serviceDatabase
- if ($isDatabase) {
- if ($isNew) {
- $savedService = ServiceDatabase::create([
- 'name' => $serviceName,
- 'image' => $image,
- 'service_id' => $resource->id,
- ]);
- } else {
- $savedService = ServiceDatabase::where([
- 'name' => $serviceName,
- 'service_id' => $resource->id,
- ])->first();
- if (is_null($savedService)) {
+ // Check for manually migrated services first (respects user's conversion choice)
+ $migratedApp = ServiceApplication::where('name', $serviceName)
+ ->where('service_id', $resource->id)
+ ->where('is_migrated', true)
+ ->first();
+ $migratedDb = ServiceDatabase::where('name', $serviceName)
+ ->where('service_id', $resource->id)
+ ->where('is_migrated', true)
+ ->first();
+
+ if ($migratedApp || $migratedDb) {
+ // Use the migrated service type, ignoring image detection
+ $isDatabase = (bool) $migratedDb;
+ $savedService = $migratedApp ?: $migratedDb;
+ } else {
+ // Use image detection for non-migrated services
+ $isDatabase = isDatabaseImage($image, $service);
+
+ // Create new serviceApplication or serviceDatabase
+ if ($isDatabase) {
+ if ($isNew) {
$savedService = ServiceDatabase::create([
'name' => $serviceName,
'image' => $image,
'service_id' => $resource->id,
]);
+ } else {
+ $savedService = ServiceDatabase::where([
+ 'name' => $serviceName,
+ 'service_id' => $resource->id,
+ ])->first();
+ if (is_null($savedService)) {
+ $savedService = ServiceDatabase::create([
+ 'name' => $serviceName,
+ 'image' => $image,
+ 'service_id' => $resource->id,
+ ]);
+ }
}
- }
- } else {
- if ($isNew) {
- $savedService = ServiceApplication::create([
- 'name' => $serviceName,
- 'image' => $image,
- 'service_id' => $resource->id,
- ]);
} else {
- $savedService = ServiceApplication::where([
- 'name' => $serviceName,
- 'service_id' => $resource->id,
- ])->first();
- if (is_null($savedService)) {
+ if ($isNew) {
$savedService = ServiceApplication::create([
'name' => $serviceName,
'image' => $image,
'service_id' => $resource->id,
]);
+ } else {
+ $savedService = ServiceApplication::where([
+ 'name' => $serviceName,
+ 'service_id' => $resource->id,
+ ])->first();
+ if (is_null($savedService)) {
+ $savedService = ServiceApplication::create([
+ 'name' => $serviceName,
+ 'image' => $image,
+ 'service_id' => $resource->id,
+ ]);
+ }
}
}
}
+ data_set($service, 'is_database', $isDatabase);
+
// Check if image changed
if ($savedService->image !== $image) {
$savedService->image = $image;
diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php
index ba252c64f..f7336beeb 100644
--- a/bootstrap/helpers/sudo.php
+++ b/bootstrap/helpers/sudo.php
@@ -58,16 +58,35 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array
$commands = $commands->map(function ($line) {
$line = str($line);
+
+ // Detect complex piped commands that should be wrapped in bash -c
+ $isComplexPipeCommand = (
+ $line->contains(' | sh') ||
+ $line->contains(' | bash') ||
+ ($line->contains(' | ') && ($line->contains('||') || $line->contains('&&')))
+ );
+
+ // If it's a complex pipe command and starts with sudo, wrap it in bash -c
+ if ($isComplexPipeCommand && $line->startsWith('sudo ')) {
+ $commandWithoutSudo = $line->after('sudo ')->value();
+ // Escape single quotes for bash -c by replacing ' with '\''
+ $escapedCommand = str_replace("'", "'\\''", $commandWithoutSudo);
+
+ return "sudo bash -c '$escapedCommand'";
+ }
+
+ // For non-complex commands, apply the original logic
if (str($line)->contains('$(')) {
$line = $line->replace('$(', '$(sudo ');
}
- if (str($line)->contains('||')) {
+ if (! $isComplexPipeCommand && str($line)->contains('||')) {
$line = $line->replace('||', '|| sudo');
}
- if (str($line)->contains('&&')) {
+ if (! $isComplexPipeCommand && str($line)->contains('&&')) {
$line = $line->replace('&&', '&& sudo');
}
- if (str($line)->contains(' | ')) {
+ // Don't insert sudo into pipes for complex commands
+ if (! $isComplexPipeCommand && str($line)->contains(' | ')) {
$line = $line->replace(' | ', ' | sudo ');
}
diff --git a/composer.json b/composer.json
index ea466049d..1db389a57 100644
--- a/composer.json
+++ b/composer.json
@@ -36,7 +36,7 @@
"poliander/cron": "^3.2.1",
"purplepixie/phpdns": "^2.2",
"pusher/pusher-php-server": "^7.2.7",
- "resend/resend-laravel": "^0.19.0",
+ "resend/resend-laravel": "^0.20.0",
"sentry/sentry-laravel": "^4.15.1",
"socialiteproviders/authentik": "^5.2",
"socialiteproviders/clerk": "^5.0",
diff --git a/composer.lock b/composer.lock
index 6320db071..5ffeb7d39 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "a993799242581bd06b5939005ee458d9",
+ "content-hash": "423b7d10901b9f31c926d536ff163a22",
"packages": [
{
"name": "amphp/amp",
@@ -7048,16 +7048,16 @@
},
{
"name": "resend/resend-laravel",
- "version": "v0.19.0",
+ "version": "v0.20.0",
"source": {
"type": "git",
"url": "https://github.com/resend/resend-laravel.git",
- "reference": "ce11e363c42c1d6b93983dfebbaba3f906863c3a"
+ "reference": "f32c2f484df2bc65fba8ea9ab9b210cd42d9f3ed"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/resend/resend-laravel/zipball/ce11e363c42c1d6b93983dfebbaba3f906863c3a",
- "reference": "ce11e363c42c1d6b93983dfebbaba3f906863c3a",
+ "url": "https://api.github.com/repos/resend/resend-laravel/zipball/f32c2f484df2bc65fba8ea9ab9b210cd42d9f3ed",
+ "reference": "f32c2f484df2bc65fba8ea9ab9b210cd42d9f3ed",
"shasum": ""
},
"require": {
@@ -7111,9 +7111,9 @@
],
"support": {
"issues": "https://github.com/resend/resend-laravel/issues",
- "source": "https://github.com/resend/resend-laravel/tree/v0.19.0"
+ "source": "https://github.com/resend/resend-laravel/tree/v0.20.0"
},
- "time": "2025-05-06T21:36:51+00:00"
+ "time": "2025-08-04T19:26:47+00:00"
},
{
"name": "resend/resend-php",
diff --git a/config/constants.php b/config/constants.php
index 02a1eaae6..770e00ffe 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,7 +2,7 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.442',
+ 'version' => '4.0.0-beta.443',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
diff --git a/config/logging.php b/config/logging.php
index 488327414..1a75978f3 100644
--- a/config/logging.php
+++ b/config/logging.php
@@ -129,8 +129,8 @@
'scheduled-errors' => [
'driver' => 'daily',
'path' => storage_path('logs/scheduled-errors.log'),
- 'level' => 'debug',
- 'days' => 7,
+ 'level' => 'warning',
+ 'days' => 14,
],
],
diff --git a/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php b/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php
new file mode 100644
index 000000000..067861e16
--- /dev/null
+++ b/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php
@@ -0,0 +1,28 @@
+integer('timeout')->default(300)->after('frequency');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('scheduled_tasks', function (Blueprint $table) {
+ $table->dropColumn('timeout');
+ });
+ }
+};
diff --git a/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php b/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php
new file mode 100644
index 000000000..14fdd5998
--- /dev/null
+++ b/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php
@@ -0,0 +1,31 @@
+timestamp('started_at')->nullable()->after('scheduled_task_id');
+ $table->integer('retry_count')->default(0)->after('status');
+ $table->decimal('duration', 10, 2)->nullable()->after('retry_count')->comment('Duration in seconds');
+ $table->text('error_details')->nullable()->after('message');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('scheduled_task_executions', function (Blueprint $table) {
+ $table->dropColumn(['started_at', 'retry_count', 'duration', 'error_details']);
+ });
+ }
+};
diff --git a/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php b/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php
new file mode 100644
index 000000000..329ac7af9
--- /dev/null
+++ b/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php
@@ -0,0 +1,30 @@
+integer('restart_count')->default(0)->after('status');
+ $table->timestamp('last_restart_at')->nullable()->after('restart_count');
+ $table->string('last_restart_type', 10)->nullable()->after('last_restart_at');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('applications', function (Blueprint $table) {
+ $table->dropColumn(['restart_count', 'last_restart_at', 'last_restart_type']);
+ });
+ }
+};
diff --git a/docker/development/etc/nginx/site-opts.d/http.conf b/docker/development/etc/nginx/site-opts.d/http.conf
index a5bbd78a3..d7855ae80 100644
--- a/docker/development/etc/nginx/site-opts.d/http.conf
+++ b/docker/development/etc/nginx/site-opts.d/http.conf
@@ -13,6 +13,9 @@ charset utf-8;
# Set max upload to 2048M
client_max_body_size 2048M;
+# Set client body buffer to handle Sentinel payloads in memory
+client_body_buffer_size 256k;
+
# Healthchecks: Set /healthcheck to be the healthcheck URL
location /healthcheck {
access_log off;
diff --git a/docker/production/etc/nginx/site-opts.d/http.conf b/docker/production/etc/nginx/site-opts.d/http.conf
index a5bbd78a3..d7855ae80 100644
--- a/docker/production/etc/nginx/site-opts.d/http.conf
+++ b/docker/production/etc/nginx/site-opts.d/http.conf
@@ -13,6 +13,9 @@ charset utf-8;
# Set max upload to 2048M
client_max_body_size 2048M;
+# Set client body buffer to handle Sentinel payloads in memory
+client_body_buffer_size 256k;
+
# Healthchecks: Set /healthcheck to be the healthcheck URL
location /healthcheck {
access_log off;
diff --git a/jean.json b/jean.json
index c625e08c0..4e5c788ed 100644
--- a/jean.json
+++ b/jean.json
@@ -1,6 +1,5 @@
{
"scripts": {
- "onWorktreeCreate": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json",
- "run": "docker rm -f coolify coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite && spin up"
+ "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 a83b4c8ce..0d9519bf8 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.442"
+ "version": "4.0.0-beta.443"
},
"nightly": {
- "version": "4.0.0-beta.443"
+ "version": "4.0.0-beta.444"
},
"helper": {
"version": "1.0.11"
diff --git a/package-lock.json b/package-lock.json
index 9e8fe7328..b076800e6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2664,11 +2664,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",
diff --git a/public/svgs/postgresus.svg b/public/svgs/postgresus.svg
new file mode 100644
index 000000000..a45e81167
--- /dev/null
+++ b/public/svgs/postgresus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/views/components/status/index.blade.php b/resources/views/components/status/index.blade.php
index d592cff79..57e5409c6 100644
--- a/resources/views/components/status/index.blade.php
+++ b/resources/views/components/status/index.blade.php
@@ -12,6 +12,13 @@
@else