diff --git a/.editorconfig b/.editorconfig
index c998050b7f..7a5dc5155b 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -17,7 +17,10 @@ indent_size = 2
[docker-compose.yml]
indent_size = 4
-[scripts/clean-manifests.sh]
+[{release.json,upstream.json}]
+indent_style = tab
+
+[scripts/{clean-manifests,plan-version,release,sync-cdn,undo-release}.sh]
indent_style = tab
binary_next_line = true
switch_case_indent = true
diff --git a/.env.development.example b/.env.development.example
index 7c8fc5c028..2f474260a9 100644
--- a/.env.development.example
+++ b/.env.development.example
@@ -58,7 +58,6 @@ VITE_PORT=5173
# Selenium Driver URL for Dusk
DUSK_DRIVER_URL=http://selenium:4444
-# Special Keys for Andras
-# For Cloudflare cache purging (token needs Zone > Cache Purge)
+# For scripts/sync-cdn.sh --purge, a Cloudflare token that can purge the cache of the zone (Zone > Cache Purge)
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ZONE_ID=
diff --git a/.gitattributes b/.gitattributes
index c48a5898bc..7c081f94b1 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -5,6 +5,7 @@
*.html diff=html
*.md diff=markdown
*.php diff=php
+CHANGELOG.md merge=union
/.github export-ignore
CHANGELOG.md export-ignore
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
new file mode 100644
index 0000000000..71efd82429
--- /dev/null
+++ b/.github/workflows/build.yaml
@@ -0,0 +1,111 @@
+name: Build
+
+on:
+ schedule:
+ - cron: "50 7 * * 1"
+ workflow_dispatch:
+ workflow_call:
+ inputs:
+ ref:
+ description: The commit to build, the commit of the run when empty
+ type: string
+ default: ""
+ coolify:
+ description: The Coolify version to build, the version release.json sets for the branch when empty
+ type: string
+ default: ""
+
+permissions: {}
+
+cache-mode: none
+
+concurrency:
+ group: build-${{ github.event.pull_request.number || github.run_id }}
+ cancel-in-progress: true
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ plan:
+ name: Plan build
+ permissions:
+ contents: read
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 10
+ outputs:
+ coolify: ${{ inputs.coolify || steps.plan.outputs.coolify }}
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
+ with:
+ ref: ${{ inputs.ref }}
+ persist-credentials: false
+
+ - name: Plan the Coolify version
+ id: plan
+ env:
+ BRANCH: ${{ github.base_ref || github.ref_name }}
+ PULL_REQUEST: ${{ github.event.pull_request.number }}
+ run: |
+ coolify="$(scripts/plan-version.sh --build)"
+ echo "coolify=$coolify" >> "$GITHUB_OUTPUT"
+
+ build:
+ name: Build image (${{ matrix.platform }})
+ needs: plan
+ permissions:
+ contents: read
+ runs-on: ${{ matrix.runner }} # https://github.com/actions/runner-images
+ timeout-minutes: 60
+ strategy:
+ matrix:
+ platform: [linux/amd64, linux/arm64]
+ include:
+ - platform: linux/amd64
+ arch: amd64
+ runner: ubuntu-26.04
+ - platform: linux/arm64
+ arch: arm64
+ runner: ubuntu-26.04-arm
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
+ with:
+ ref: ${{ inputs.ref }}
+ persist-credentials: false
+
+ - name: Read the commit date and hash
+ id: args
+ run: |
+ {
+ echo "epoch=$(git log -1 --format=%ct)"
+ echo "sha=$(git rev-parse HEAD)"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Set up Buildx
+ uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # https://github.com/docker/setup-buildx-action/releases/tag/v4.4.1
+ with:
+ cache-binary: false
+
+ - name: Build the image
+ uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # https://github.com/docker/build-push-action/releases/tag/v7.4.0
+ with:
+ context: .
+ file: docker/production/Dockerfile
+ platforms: ${{ matrix.platform }}
+ tags: coollabsio/coolify:test
+ outputs: type=oci,dest=${{ runner.temp }}/image,tar=false,rewrite-timestamp=true
+ build-args: |
+ COOLIFY_VERSION=${{ needs.plan.outputs.coolify }}
+ SOURCE_DATE_EPOCH=${{ steps.args.outputs.epoch }}
+ GIT_SHA=${{ steps.args.outputs.sha }}
+
+ - name: Upload the image
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # https://github.com/actions/upload-artifact/releases/tag/v7.0.2
+ with:
+ name: image-${{ matrix.arch }}
+ path: ${{ runner.temp }}/image
+ if-no-files-found: error
+ retention-days: 1
diff --git a/.github/workflows/clean-manifests.yaml b/.github/workflows/clean-manifests.yaml
index 28cfebfebb..1679ad669f 100644
--- a/.github/workflows/clean-manifests.yaml
+++ b/.github/workflows/clean-manifests.yaml
@@ -17,7 +17,7 @@ defaults:
jobs:
clean:
- name: Clean
+ name: Clean manifests
if: ${{ github.repository == 'coollabsio/coolify' }}
permissions:
contents: read
diff --git a/.github/workflows/coolify-main-build.yml b/.github/workflows/coolify-main-build.yml
deleted file mode 100644
index 2ab48a4825..0000000000
--- a/.github/workflows/coolify-main-build.yml
+++ /dev/null
@@ -1,122 +0,0 @@
-name: Build Coolify Main
-
-on:
- push:
- branches: ["main"]
-
-permissions:
- contents: read
- packages: write
-
-concurrency:
- group: coolify-main-build
- cancel-in-progress: false
-
-env:
- GITHUB_REGISTRY: ghcr.io
- DOCKER_REGISTRY: docker.io
- IMAGE_NAME: "coollabsio/coolify"
-
-jobs:
- build-push:
- outputs:
- short_sha: ${{ steps.version.outputs.short_sha }}
- strategy:
- matrix:
- include:
- - arch: amd64
- platform: linux/amd64
- runner: ubuntu-24.04
- - arch: aarch64
- platform: linux/aarch64
- runner: ubuntu-24.04-arm
- runs-on: ${{ matrix.runner }}
- steps:
- - uses: actions/checkout@v5
- with:
- persist-credentials: false
-
- - name: Resolve internal version
- id: version
- run: |
- BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)
- echo "version=${BASE_VERSION}-dev.${GITHUB_SHA::9}" >> "$GITHUB_OUTPUT"
- echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
-
- - 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.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Build and Push Image (${{ matrix.arch }})
- uses: docker/build-push-action@v6
- with:
- context: .
- file: docker/production/Dockerfile
- platforms: ${{ matrix.platform }}
- push: true
- build-args: |
- COOLIFY_VERSION=${{ steps.version.outputs.version }}
- labels: |
- org.opencontainers.image.revision=${{ github.sha }}
- tags: |
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:main-${{ matrix.arch }}
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:main-${{ matrix.arch }}
-
- merge-manifest:
- runs-on: ubuntu-24.04
- needs: build-push
- steps:
- - uses: docker/setup-buildx-action@v3
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
- env:
- REGISTRY: ${{ env.GITHUB_REGISTRY }}
- SHA: ${{ needs.build-push.outputs.short_sha }}
- run: |
- IMAGE="${REGISTRY}/${IMAGE_NAME}"
- docker buildx imagetools create \
- "${IMAGE}:main-amd64" \
- "${IMAGE}:main-aarch64" \
- --tag "${IMAGE}:sha-${SHA}" \
- --tag "${IMAGE}:main"
-
- - name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
- env:
- REGISTRY: ${{ env.DOCKER_REGISTRY }}
- SHA: ${{ needs.build-push.outputs.short_sha }}
- run: |
- IMAGE="${REGISTRY}/${IMAGE_NAME}"
- docker buildx imagetools create \
- "${IMAGE}:main-amd64" \
- "${IMAGE}:main-aarch64" \
- --tag "${IMAGE}:sha-${SHA}" \
- --tag "${IMAGE}:main"
-
- - uses: sarisia/actions-status-discord@v1
- if: always()
- with:
- webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}
diff --git a/.github/workflows/coolify-next-build.yml b/.github/workflows/coolify-next-build.yml
deleted file mode 100644
index db0f762653..0000000000
--- a/.github/workflows/coolify-next-build.yml
+++ /dev/null
@@ -1,154 +0,0 @@
-name: Build Coolify Next
-
-on:
- push:
- branches: [next]
- paths-ignore:
- - .github/workflows/coolify-helper.yml
- - .github/workflows/coolify-helper-next.yml
- - .github/workflows/pr-quality.yaml
- - docker/coolify-helper/Dockerfile
- - docker/testing-host/Dockerfile
- - templates/**
- - CHANGELOG.md
-
-permissions:
- contents: read
- packages: write
-
-concurrency:
- group: coolify-next-build
- cancel-in-progress: false
-
-env:
- GITHUB_REGISTRY: ghcr.io
- DOCKER_REGISTRY: docker.io
- IMAGE_NAME: coollabsio/coolify
-
-jobs:
- prepare:
- runs-on: ubuntu-24.04
- outputs:
- rc_version: ${{ steps.version.outputs.rc_version }}
- short_sha: ${{ steps.version.outputs.short_sha }}
- version: ${{ steps.version.outputs.version }}
- steps:
- - uses: actions/checkout@v5
- with:
- persist-credentials: false
-
- - name: Resolve next version
- id: version
- run: |
- RC_VERSION=$(jq -r '.coolify.nightly.version' versions.json)
- if [[ ! "${RC_VERSION}" =~ ^[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
- echo "Invalid next RC version: ${RC_VERSION}"
- exit 1
- fi
-
- SHORT_SHA="${GITHUB_SHA::7}"
- VERSION="${RC_VERSION}.${SHORT_SHA}"
- echo "rc_version=${RC_VERSION}" >> "$GITHUB_OUTPUT"
- echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
-
- build:
- needs: prepare
- strategy:
- matrix:
- include:
- - arch: amd64
- platform: linux/amd64
- runner: ubuntu-24.04
- - arch: aarch64
- platform: linux/aarch64
- runner: ubuntu-24.04-arm
- runs-on: ${{ matrix.runner }}
- steps:
- - uses: actions/checkout@v5
- with:
- persist-credentials: false
-
- - uses: docker/setup-buildx-action@v3
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Build and push next image (${{ matrix.arch }})
- uses: docker/build-push-action@v6
- with:
- context: .
- file: docker/production/Dockerfile
- platforms: ${{ matrix.platform }}
- push: true
- build-args: |
- COOLIFY_VERSION=${{ needs.prepare.outputs.version }}
- tags: |
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }}
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }}
-
- publish:
- needs: [prepare, build]
- runs-on: ubuntu-24.04
- steps:
- - uses: docker/setup-buildx-action@v3
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Publish next manifest on ${{ env.GITHUB_REGISTRY }}
- env:
- REGISTRY: ${{ env.GITHUB_REGISTRY }}
- SHA: ${{ needs.prepare.outputs.short_sha }}
- VERSION: ${{ needs.prepare.outputs.version }}
- run: |
- IMAGE="${REGISTRY}/${IMAGE_NAME}"
- SOURCE="next-build-${SHA}"
- docker buildx imagetools create \
- "${IMAGE}:${SOURCE}-amd64" \
- "${IMAGE}:${SOURCE}-aarch64" \
- --tag "${IMAGE}:sha-${SHA}" \
- --tag "${IMAGE}:${VERSION}" \
- --tag "${IMAGE}:next"
-
- - name: Publish next manifest on ${{ env.DOCKER_REGISTRY }}
- env:
- REGISTRY: ${{ env.DOCKER_REGISTRY }}
- SHA: ${{ needs.prepare.outputs.short_sha }}
- VERSION: ${{ needs.prepare.outputs.version }}
- run: |
- IMAGE="${REGISTRY}/${IMAGE_NAME}"
- SOURCE="next-build-${SHA}"
- docker buildx imagetools create \
- "${IMAGE}:${SOURCE}-amd64" \
- "${IMAGE}:${SOURCE}-aarch64" \
- --tag "${IMAGE}:sha-${SHA}" \
- --tag "${IMAGE}:${VERSION}" \
- --tag "${IMAGE}:next"
-
- - uses: sarisia/actions-status-discord@v1
- if: always()
- with:
- webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}
diff --git a/.github/workflows/coolify-rc-release.yml b/.github/workflows/coolify-rc-release.yml
deleted file mode 100644
index d7f4dc830f..0000000000
--- a/.github/workflows/coolify-rc-release.yml
+++ /dev/null
@@ -1,304 +0,0 @@
-name: Release Coolify RC
-run-name: ${{ inputs.tag }}
-
-on:
- workflow_dispatch:
- inputs:
- tag:
- description: Existing draft prerelease tag (for example, v4.4-rc.1)
- required: true
- type: string
-
-permissions: {}
-
-concurrency:
- group: coolify-rc-release
- cancel-in-progress: false
-
-env:
- GITHUB_REGISTRY: ghcr.io
- DOCKER_REGISTRY: docker.io
- IMAGE_NAME: coollabsio/coolify
-
-jobs:
- validate:
- runs-on: ubuntu-24.04
- permissions:
- contents: write
- outputs:
- release_id: ${{ steps.draft.outputs.release_id }}
- version: ${{ steps.version.outputs.version }}
- steps:
- - name: Reject releases outside next
- if: ${{ github.ref != 'refs/heads/next' }}
- run: |
- echo "RC releases must run from the next branch, not ${{ github.ref }}."
- exit 1
-
- - uses: actions/checkout@v5
- with:
- fetch-depth: 0
- persist-credentials: false
-
- - name: Validate version
- id: version
- env:
- TAG_NAME: ${{ inputs.tag }}
- run: |
- if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
- echo "Unsupported RC tag: ${TAG_NAME}"
- exit 1
- fi
-
- VERSION="${TAG_NAME#v}"
- CONFIG_VERSION=$(jq -r '.coolify.nightly.version' versions.json)
- if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then
- echo "RC tag ${VERSION} does not match nightly version ${CONFIG_VERSION}."
- exit 1
- fi
-
- echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
-
- - name: Validate and pin draft prerelease
- id: draft
- uses: actions/github-script@v8
- env:
- TAG_NAME: ${{ inputs.tag }}
- with:
- script: |
- const releases = await github.paginate(github.rest.repos.listReleases, {
- owner: context.repo.owner,
- repo: context.repo.repo,
- per_page: 100,
- });
- const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME);
-
- if (!release) {
- core.setFailed(`Create a draft prerelease for ${process.env.TAG_NAME} before running this workflow.`);
- return;
- }
- if (!release.draft) {
- core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`);
- return;
- }
- if (!release.prerelease) {
- core.setFailed(`RC release ${process.env.TAG_NAME} must be marked as a prerelease.`);
- return;
- }
- if (!release.body?.trim()) {
- core.setFailed(`Draft prerelease ${process.env.TAG_NAME} must contain reviewed release notes.`);
- return;
- }
-
- try {
- await github.rest.git.getRef({
- owner: context.repo.owner,
- repo: context.repo.repo,
- ref: `tags/${process.env.TAG_NAME}`,
- });
- core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`);
- return;
- } catch (error) {
- if (error.status !== 404) throw error;
- }
-
- await github.rest.repos.updateRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- release_id: release.id,
- tag_name: process.env.TAG_NAME,
- target_commitish: context.sha,
- prerelease: true,
- });
- core.setOutput('release_id', release.id);
-
- build:
- needs: validate
- permissions:
- contents: read
- packages: write
- strategy:
- matrix:
- include:
- - arch: amd64
- platform: linux/amd64
- runner: ubuntu-24.04
- - arch: aarch64
- platform: linux/aarch64
- runner: ubuntu-24.04-arm
- runs-on: ${{ matrix.runner }}
- steps:
- - uses: actions/checkout@v5
- with:
- persist-credentials: false
-
- - uses: docker/setup-buildx-action@v3
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Build and push RC image (${{ matrix.arch }})
- uses: docker/build-push-action@v6
- with:
- context: .
- file: docker/production/Dockerfile
- platforms: ${{ matrix.platform }}
- push: true
- build-args: |
- COOLIFY_VERSION=${{ needs.validate.outputs.version }}
- tags: |
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
-
- revalidate:
- needs: [validate, build]
- runs-on: ubuntu-24.04
- permissions:
- contents: write
- steps:
- - name: Revalidate draft prerelease
- uses: actions/github-script@v8
- env:
- RELEASE_ID: ${{ needs.validate.outputs.release_id }}
- TAG_NAME: ${{ inputs.tag }}
- with:
- script: |
- const releaseId = Number(process.env.RELEASE_ID);
- const { data: release } = await github.rest.repos.getRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- release_id: releaseId,
- });
-
- if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) {
- core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`);
- return;
- }
- if (!release.body?.trim()) {
- core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`);
- return;
- }
- if (release.target_commitish !== context.sha) {
- core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
- return;
- }
-
- try {
- await github.rest.git.getRef({
- owner: context.repo.owner,
- repo: context.repo.repo,
- ref: `tags/${process.env.TAG_NAME}`,
- });
- core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
- } catch (error) {
- if (error.status !== 404) throw error;
- }
-
- publish:
- needs: [validate, build, revalidate]
- runs-on: ubuntu-24.04
- permissions:
- contents: write
- packages: write
- steps:
- - uses: docker/setup-buildx-action@v3
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Publish RC and next on ${{ env.GITHUB_REGISTRY }}
- env:
- REGISTRY: ${{ env.GITHUB_REGISTRY }}
- VERSION: ${{ needs.validate.outputs.version }}
- run: |
- IMAGE="${REGISTRY}/${IMAGE_NAME}"
- SOURCE="rc-release-${VERSION}-${GITHUB_SHA}"
- docker buildx imagetools create \
- "${IMAGE}:${SOURCE}-amd64" \
- "${IMAGE}:${SOURCE}-aarch64" \
- --tag "${IMAGE}:${VERSION}" \
- --tag "${IMAGE}:next"
-
- - name: Publish RC and next on ${{ env.DOCKER_REGISTRY }}
- env:
- REGISTRY: ${{ env.DOCKER_REGISTRY }}
- VERSION: ${{ needs.validate.outputs.version }}
- run: |
- IMAGE="${REGISTRY}/${IMAGE_NAME}"
- SOURCE="rc-release-${VERSION}-${GITHUB_SHA}"
- docker buildx imagetools create \
- "${IMAGE}:${SOURCE}-amd64" \
- "${IMAGE}:${SOURCE}-aarch64" \
- --tag "${IMAGE}:${VERSION}" \
- --tag "${IMAGE}:next"
-
- - name: Publish reviewed draft prerelease
- uses: actions/github-script@v8
- env:
- RELEASE_ID: ${{ needs.validate.outputs.release_id }}
- TAG_NAME: ${{ inputs.tag }}
- with:
- script: |
- const releaseId = Number(process.env.RELEASE_ID);
- const { data: release } = await github.rest.repos.getRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- release_id: releaseId,
- });
-
- if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) {
- core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`);
- return;
- }
- if (!release.body?.trim()) {
- core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`);
- return;
- }
- if (release.target_commitish !== context.sha) {
- core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
- return;
- }
-
- try {
- await github.rest.git.getRef({
- owner: context.repo.owner,
- repo: context.repo.repo,
- ref: `tags/${process.env.TAG_NAME}`,
- });
- core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
- return;
- } catch (error) {
- if (error.status !== 404) throw error;
- }
-
- await github.rest.repos.updateRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- release_id: Number(process.env.RELEASE_ID),
- tag_name: process.env.TAG_NAME,
- target_commitish: context.sha,
- prerelease: true,
- draft: false,
- });
diff --git a/.github/workflows/coolify-release.yml b/.github/workflows/coolify-release.yml
deleted file mode 100644
index 70bb5d6af5..0000000000
--- a/.github/workflows/coolify-release.yml
+++ /dev/null
@@ -1,260 +0,0 @@
-name: Release Coolify Stable
-run-name: ${{ inputs.tag }}
-
-on:
- workflow_dispatch:
- inputs:
- tag:
- description: Existing draft release tag (for example, v4.3.1)
- required: true
- type: string
-
-permissions: {}
-
-concurrency:
- group: coolify-fix-release
- cancel-in-progress: false
-
-env:
- GITHUB_REGISTRY: ghcr.io
- DOCKER_REGISTRY: docker.io
- IMAGE_NAME: coollabsio/coolify
-
-jobs:
- validate:
- runs-on: ubuntu-24.04
- permissions:
- contents: write
- outputs:
- release_id: ${{ steps.draft.outputs.release_id }}
- version: ${{ steps.version.outputs.version }}
- steps:
- - name: Reject releases outside the production branch
- # tmp: allow the 4.4.7 security release from fix/v4.4.7
- if: ${{ github.ref_name != 'main' && !startsWith(github.ref_name, 'fix/v') }}
- run: |
- echo "Stable releases must run from main, not ${{ github.ref_name }}."
- exit 1
-
- - uses: actions/checkout@v5
- with:
- fetch-depth: 0
- persist-credentials: false
-
- - name: Validate version
- id: version
- env:
- TAG_NAME: ${{ inputs.tag }}
- run: |
- if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- echo "Unsupported fix release tag: ${TAG_NAME}"
- exit 1
- fi
-
- VERSION="${TAG_NAME#v}"
- CONFIG_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)
-
- if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then
- echo "Release tag ${VERSION} does not match config version ${CONFIG_VERSION}."
- exit 1
- fi
-
- echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
-
- - name: Validate and pin draft release
- id: draft
- uses: actions/github-script@v8
- env:
- TAG_NAME: ${{ inputs.tag }}
- with:
- script: |
- const releases = await github.paginate(github.rest.repos.listReleases, {
- owner: context.repo.owner,
- repo: context.repo.repo,
- per_page: 100,
- });
- const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME);
-
- if (!release) {
- core.setFailed(`Create a draft release for ${process.env.TAG_NAME} before running this workflow.`);
- return;
- }
- if (!release.draft) {
- core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`);
- return;
- }
- if (release.prerelease) {
- core.setFailed(`Fix release ${process.env.TAG_NAME} cannot be marked as a prerelease.`);
- return;
- }
- if (!release.body?.trim()) {
- core.setFailed(`Draft release ${process.env.TAG_NAME} must contain reviewed release notes.`);
- return;
- }
-
- try {
- await github.rest.git.getRef({
- owner: context.repo.owner,
- repo: context.repo.repo,
- ref: `tags/${process.env.TAG_NAME}`,
- });
- core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`);
- return;
- } catch (error) {
- if (error.status !== 404) throw error;
- }
-
- await github.rest.repos.updateRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- release_id: release.id,
- tag_name: process.env.TAG_NAME,
- target_commitish: context.sha,
- });
- core.setOutput('release_id', release.id);
-
- build:
- needs: validate
- permissions:
- contents: read
- packages: write
- strategy:
- matrix:
- include:
- - arch: amd64
- platform: linux/amd64
- runner: ubuntu-24.04
- - arch: aarch64
- platform: linux/aarch64
- runner: ubuntu-24.04-arm
- runs-on: ${{ matrix.runner }}
- steps:
- - uses: actions/checkout@v5
- with:
- persist-credentials: false
-
- - uses: docker/setup-buildx-action@v3
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Build and push release image (${{ matrix.arch }})
- uses: docker/build-push-action@v6
- with:
- context: .
- file: docker/production/Dockerfile
- platforms: ${{ matrix.platform }}
- push: true
- build-args: |
- COOLIFY_VERSION=${{ needs.validate.outputs.version }}
- tags: |
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
-
- publish:
- needs: [validate, build]
- runs-on: ubuntu-24.04
- permissions:
- contents: write
- packages: write
- steps:
- - uses: docker/setup-buildx-action@v3
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Publish version and latest on ${{ env.GITHUB_REGISTRY }}
- env:
- REGISTRY: ${{ env.GITHUB_REGISTRY }}
- VERSION: ${{ needs.validate.outputs.version }}
- run: |
- IMAGE="${REGISTRY}/${IMAGE_NAME}"
- SOURCE="release-${VERSION}-${GITHUB_SHA}"
- docker buildx imagetools create \
- "${IMAGE}:${SOURCE}-amd64" \
- "${IMAGE}:${SOURCE}-aarch64" \
- --tag "${IMAGE}:${VERSION}" \
- --tag "${IMAGE}:latest"
-
- - name: Publish version and latest on ${{ env.DOCKER_REGISTRY }}
- env:
- REGISTRY: ${{ env.DOCKER_REGISTRY }}
- VERSION: ${{ needs.validate.outputs.version }}
- run: |
- IMAGE="${REGISTRY}/${IMAGE_NAME}"
- SOURCE="release-${VERSION}-${GITHUB_SHA}"
- docker buildx imagetools create \
- "${IMAGE}:${SOURCE}-amd64" \
- "${IMAGE}:${SOURCE}-aarch64" \
- --tag "${IMAGE}:${VERSION}" \
- --tag "${IMAGE}:latest"
-
- - name: Publish reviewed draft release
- uses: actions/github-script@v8
- env:
- RELEASE_ID: ${{ needs.validate.outputs.release_id }}
- TAG_NAME: ${{ inputs.tag }}
- with:
- script: |
- const releaseId = Number(process.env.RELEASE_ID);
- const { data: release } = await github.rest.repos.getRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- release_id: releaseId,
- });
-
- if (release.tag_name !== process.env.TAG_NAME || !release.draft || release.prerelease) {
- core.setFailed(`Draft release ${process.env.TAG_NAME} changed while the images were building.`);
- return;
- }
- if (!release.body?.trim()) {
- core.setFailed(`Draft release ${process.env.TAG_NAME} no longer contains release notes.`);
- return;
- }
- if (release.target_commitish !== context.sha) {
- core.setFailed(`Draft release ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
- return;
- }
-
- try {
- await github.rest.git.getRef({
- owner: context.repo.owner,
- repo: context.repo.repo,
- ref: `tags/${process.env.TAG_NAME}`,
- });
- core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
- return;
- } catch (error) {
- if (error.status !== 404) throw error;
- }
-
- await github.rest.repos.updateRelease({
- owner: context.repo.owner,
- repo: context.repo.repo,
- release_id: Number(process.env.RELEASE_ID),
- tag_name: process.env.TAG_NAME,
- target_commitish: context.sha,
- draft: false,
- });
diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml
deleted file mode 100644
index a9878e7c41..0000000000
--- a/.github/workflows/generate-changelog.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: Generate Changelog
-
-on:
- push:
- branches: [ main ]
- paths-ignore:
- - .github/workflows/coolify-helper.yml
- - .github/workflows/coolify-helper-next.yml
- - .github/workflows/pr-quality.yaml
- workflow_dispatch:
-
-permissions:
- contents: write
-
-jobs:
- changelog:
- name: Generate changelog
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Generate changelog
- uses: orhun/git-cliff-action@v4
- with:
- config: cliff.toml
- args: --verbose
- env:
- OUTPUT: CHANGELOG.md
- GITHUB_REPO: ${{ github.repository }}
-
- - name: Commit
- run: |
- git config user.name 'github-actions[bot]'
- git config user.email 'github-actions[bot]@users.noreply.github.com'
- git add CHANGELOG.md
- git commit -m "docs: update changelog"
- git push https://${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git HEAD:${GITHUB_REF_NAME}
diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
new file mode 100644
index 0000000000..8f64941d57
--- /dev/null
+++ b/.github/workflows/main.yaml
@@ -0,0 +1,88 @@
+name: Main
+
+on:
+ schedule:
+ - cron: "50 9,21 * * *"
+ workflow_dispatch:
+
+permissions: {}
+
+cache-mode: none
+
+concurrency:
+ group: main
+ cancel-in-progress: false
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ plan:
+ name: Plan build
+ if: ${{ github.repository == 'coollabsio/coolify' }}
+ permissions:
+ contents: read
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 10
+ outputs:
+ build: ${{ steps.plan.outputs.build }}
+ steps:
+ - name: Check out main
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
+ with:
+ ref: main
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Plan the main build
+ id: plan
+ env:
+ BRANCH: main
+ run: |
+ labels="$(docker buildx imagetools inspect ghcr.io/coollabsio/coolify:main --format '{{ json (index .Image "linux/amd64").Config.Labels }}')" || labels='{}'
+ MAIN_VERSION="$(jq -r '.["org.opencontainers.image.version"] // empty' <<< "$labels")"
+ MAIN_REVISION="$(jq -r '.["org.opencontainers.image.revision"] // empty' <<< "$labels")"
+ export MAIN_VERSION MAIN_REVISION
+ build="$(scripts/plan-version.sh --main)"
+ echo "build=$build" | tee -a "$GITHUB_OUTPUT"
+
+ build:
+ name: Build
+ needs: plan
+ if: ${{ needs.plan.outputs.build != '' }}
+ permissions:
+ contents: read
+ uses: $/.github/workflows/build.yaml
+ with:
+ ref: ${{ fromJSON(needs.plan.outputs.build).revision }}
+ coolify: ${{ fromJSON(needs.plan.outputs.build).image }}
+
+ publish:
+ name: Publish image
+ needs: build
+ permissions:
+ packages: write # push the built main to ghcr.io and move the main tag
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 30
+ steps:
+ - name: Download the built images
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # https://github.com/actions/download-artifact/releases/tag/v8.0.2
+ with:
+ pattern: image-*
+ path: ${{ runner.temp }}/images
+
+ - name: Log in to ghcr.io
+ uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Push the built main
+ run: |
+ sources=()
+ for index in "$RUNNER_TEMP"/images/*/index.json; do
+ sources+=("oci-layout://${index%/index.json}@$(jq -r '.manifests[0].digest' "$index")")
+ done
+ docker buildx imagetools create --tag ghcr.io/coollabsio/coolify:main "${sources[@]}"
diff --git a/.github/workflows/merge-forward.yaml b/.github/workflows/merge-forward.yaml
new file mode 100644
index 0000000000..cd18c98edb
--- /dev/null
+++ b/.github/workflows/merge-forward.yaml
@@ -0,0 +1,93 @@
+name: Merge forward
+
+on:
+ workflow_dispatch:
+ workflow_call:
+
+permissions: {}
+
+cache-mode: none
+
+concurrency:
+ group: merge-forward
+ cancel-in-progress: false
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ merge:
+ name: Merge forward
+ permissions:
+ contents: write # push the merges
+ pull-requests: write # open a pull request when a merge conflicts
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 10
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Merge main into the release branches and next, and each release branch into next
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh auth setup-git
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ push() {
+ if [ "$(git rev-parse HEAD)" != "$(git rev-parse "origin/$1")" ]; then
+ awk 'NR > 1 && /^#/ && previous != "" { print "" } { print; previous = $0 }' CHANGELOG.md > "$RUNNER_TEMP/CHANGELOG.md"
+ cp "$RUNNER_TEMP/CHANGELOG.md" CHANGELOG.md
+ git diff --quiet || git commit --quiet --amend --no-edit --all
+ fi
+ git push --quiet origin "HEAD:$2"
+ }
+ merge() {
+ local head="chore/merge-${1//\//-}-into-${2//\//-}" tag title conflicts pull_request
+ git switch --quiet --detach "origin/$2"
+ if git merge-base --is-ancestor "origin/$1" HEAD; then
+ echo "$2 contains $1 already"
+ return
+ fi
+ tag="$(git tag --points-at "origin/$1" | sed -n 1p)"
+ title="chore: merge ${tag:-$1} into $2"
+ if git merge --quiet --no-edit --message "$title" "origin/$1"; then
+ push "$1" "$2"
+ return
+ fi
+ conflicts="$(git diff --name-only --diff-filter=U)"
+ git merge --abort
+ if [ "$conflicts" = release.json ] && git merge --quiet --no-edit --strategy-option ours --message "$title" "origin/$1"; then
+ push "$1" "$2"
+ return
+ fi
+ if git rev-parse --verify --quiet "origin/$head" > /dev/null && ! git merge-base --is-ancestor "origin/$head" "origin/$1"; then
+ echo "::notice::$head has commits of its own, $1 follows once its pull request is merged"
+ return
+ fi
+ git push --quiet origin "origin/$1:refs/heads/$head"
+ pull_request="$(gh pr list --head "$head" --base "$2" --json url --jq '.[0].url')"
+ if [ -n "$pull_request" ]; then
+ gh pr edit "$pull_request" --title "$title"
+ return
+ fi
+ cat > "$RUNNER_TEMP/body.md" <
[!IMPORTANT]
+ > Merge this PR with a merge commit, not a squash merge, so the next merge forward does not conflict again.
+ BODY
+ gh pr create --base "$2" --head "$head" --title "$title" --body-file "$RUNNER_TEMP/body.md"
+ }
+ releases="$(git branch --remotes --list 'origin/release/*' --format='%(refname:lstrip=3)' | grep -vE '\.0$' | sort -V || true)"
+ for release in $releases; do
+ merge main "$release"
+ done
+ for release in $releases; do
+ merge "$release" next
+ done
+ merge main next
diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml
new file mode 100644
index 0000000000..49755a44e7
--- /dev/null
+++ b/.github/workflows/nightly.yaml
@@ -0,0 +1,134 @@
+name: Nightly
+
+on:
+ schedule:
+ - cron: "10 9,21 * * *"
+ workflow_dispatch:
+
+permissions: {}
+
+cache-mode: none
+
+concurrency:
+ group: nightly
+ cancel-in-progress: false
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ plan:
+ name: Plan nightly
+ if: ${{ github.repository == 'coollabsio/coolify' }}
+ permissions:
+ contents: read
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 10
+ outputs:
+ nightly: ${{ steps.plan.outputs.nightly }}
+ steps:
+ - name: Check out next
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
+ with:
+ ref: next
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Plan the nightly
+ id: plan
+ env:
+ BRANCH: next
+ run: |
+ labels="$(docker buildx imagetools inspect ghcr.io/coollabsio/coolify:nightly --format '{{ json (index .Image "linux/amd64").Config.Labels }}')" || labels='{}'
+ NIGHTLY_VERSION="$(jq -r '.["org.opencontainers.image.version"] // empty' <<< "$labels")"
+ NIGHTLY_REVISION="$(jq -r '.["org.opencontainers.image.revision"] // empty' <<< "$labels")"
+ export NIGHTLY_VERSION NIGHTLY_REVISION
+ nightly="$(scripts/plan-version.sh --nightly)"
+ echo "nightly=$nightly" | tee -a "$GITHUB_OUTPUT"
+
+ build:
+ name: Build
+ needs: plan
+ if: ${{ needs.plan.outputs.nightly != '' }}
+ permissions:
+ contents: read
+ uses: $/.github/workflows/build.yaml
+ with:
+ ref: ${{ fromJSON(needs.plan.outputs.nightly).revision }}
+ coolify: ${{ fromJSON(needs.plan.outputs.nightly).image }}
+
+ publish:
+ name: Publish nightly (${{ matrix.line.tag }})
+ needs: [plan, build]
+ permissions:
+ packages: write # push the built nightly to ghcr.io and move the nightly tag
+ environment:
+ name: release
+ deployment: false
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 30
+ strategy:
+ matrix:
+ line: ${{ fromJSON(needs.plan.outputs.nightly).lines }}
+ steps:
+ - name: Download the built images
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # https://github.com/actions/download-artifact/releases/tag/v8.0.2
+ with:
+ pattern: image-*
+ path: ${{ runner.temp }}/images
+
+ - name: Log in to ghcr.io
+ uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Log in to docker.io
+ uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Push the built nightly
+ env:
+ TAG: ${{ matrix.line.tag }}
+ LINE: ${{ matrix.line.name }}
+ run: |
+ sources=()
+ for index in "$RUNNER_TEMP"/images/*/index.json; do
+ sources+=("oci-layout://${index%/index.json}@$(jq -r '.manifests[0].digest' "$index")")
+ done
+ for repository in ghcr.io/coollabsio/coolify docker.io/coollabsio/coolify; do
+ tags=(--tag "$repository:${TAG#v}")
+ [ "$LINE" != latest ] || tags+=(--tag "$repository:nightly")
+ docker buildx imagetools create "${tags[@]}" "${sources[@]}"
+ done
+
+ cdn:
+ name: Sync CDN
+ needs: [plan, publish]
+ permissions:
+ contents: read
+ environment:
+ name: release
+ deployment: false
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 10
+ steps:
+ - name: Check out the nightly
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
+ with:
+ ref: ${{ fromJSON(needs.plan.outputs.nightly).revision }}
+ persist-credentials: false
+
+ - name: Sync the nightly channel of the CDN
+ env:
+ GH_TOKEN: ${{ secrets.CDN_TOKEN }}
+ IMAGE_VERSION: ${{ fromJSON(needs.plan.outputs.nightly).image }}
+ run: |
+ gh auth setup-git
+ git config --global user.name "github-actions[bot]"
+ git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ scripts/sync-cdn.sh --nightly "$IMAGE_VERSION"
diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml
new file mode 100644
index 0000000000..0f511c716d
--- /dev/null
+++ b/.github/workflows/pull-request.yaml
@@ -0,0 +1,84 @@
+name: Pull request
+
+on:
+ pull_request:
+
+permissions: {}
+
+cache-mode: none
+
+concurrency:
+ group: pull-request-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ detect:
+ name: Detect release push
+ permissions:
+ contents: read
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 10
+ outputs:
+ release: ${{ steps.detect.outputs.release }}
+ steps:
+ - name: Check whether the push releases
+ id: detect
+ env:
+ GH_TOKEN: ${{ github.token }}
+ ACTION: ${{ github.event.action }}
+ HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
+ HEAD_BRANCH: ${{ github.head_ref }}
+ BEFORE: ${{ github.event.before }}
+ AFTER: ${{ github.event.after }}
+ run: |
+ release=false
+ if [ "$ACTION" = synchronize ] && [ "$HEAD_REPOSITORY" = "$GITHUB_REPOSITORY" ] \
+ && [[ "$HEAD_BRANCH" =~ ^(main|next|v[0-9]+(\.[0-9]+)?\.x|release/v[0-9]+\.[0-9]+)$ ]]; then
+ files="$(gh api "repos/$GITHUB_REPOSITORY/compare/$BEFORE...$AFTER" --jq '.files[].filename')"
+ ! grep -qxE 'release\.json' <<< "$files" || release=true
+ fi
+ echo "release=$release" | tee -a "$GITHUB_OUTPUT"
+
+ build:
+ name: Build
+ needs: detect
+ if: ${{ needs.detect.outputs.release == 'false' }}
+ permissions:
+ contents: read
+ uses: $/.github/workflows/build.yaml
+
+ publish:
+ name: Publish image
+ needs: build
+ if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' }}
+ permissions:
+ packages: write # push the image of the pull request to ghcr.io
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 30
+ steps:
+ - name: Download the built images
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # https://github.com/actions/download-artifact/releases/tag/v8.0.2
+ with:
+ pattern: image-*
+ path: ${{ runner.temp }}/images
+
+ - name: Log in to ghcr.io
+ uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Push the image of the pull request
+ env:
+ TAG: pr-${{ github.event.pull_request.number }}
+ run: |
+ sources=()
+ for index in "$RUNNER_TEMP"/images/*/index.json; do
+ sources+=("oci-layout://${index%/index.json}@$(jq -r '.manifests[0].digest' "$index")")
+ done
+ docker buildx imagetools create --tag "ghcr.io/coollabsio/coolify:$TAG" "${sources[@]}"
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000000..d6614d9027
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,217 @@
+name: Release
+
+on:
+ push:
+ branches:
+ - main
+ - next
+ - v[0-9]+.x
+ - v[0-9]+.[0-9]+.x
+ - release/v[0-9]+.[0-9]+
+ paths:
+ - release.json
+ workflow_dispatch:
+
+permissions: {}
+
+cache-mode: none
+
+concurrency:
+ group: release
+ cancel-in-progress: false
+ queue: max
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ plan:
+ name: Plan releases
+ if: ${{ github.repository == 'coollabsio/coolify' }}
+ permissions:
+ contents: read
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 10
+ outputs:
+ releases: ${{ steps.plan.outputs.releases }}
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Plan the releases
+ id: plan
+ env:
+ BRANCH: ${{ github.ref_name }}
+ run: |
+ releases="$(scripts/plan-version.sh --release)"
+ echo "releases=$releases" | tee -a "$GITHUB_OUTPUT"
+
+ build:
+ name: Build
+ needs: plan
+ if: ${{ needs.plan.outputs.releases != '[]' }}
+ permissions:
+ contents: read
+ uses: $/.github/workflows/build.yaml
+
+ publish:
+ name: Publish release (${{ matrix.release.tag }})
+ needs: [plan, build]
+ permissions:
+ contents: write # create the GitHub release and its tag
+ packages: write # push the built images to ghcr.io
+ environment:
+ name: release
+ deployment: false
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 30
+ strategy:
+ matrix:
+ release: ${{ fromJSON(needs.plan.outputs.releases) }}
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Download the built images
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # https://github.com/actions/download-artifact/releases/tag/v8.0.2
+ with:
+ pattern: image-*
+ path: ${{ runner.temp }}/images
+
+ - name: Log in to ghcr.io
+ uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Log in to docker.io
+ uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Push the built images
+ env:
+ TAG: ${{ matrix.release.tag }}
+ run: |
+ sources=()
+ for index in "$RUNNER_TEMP"/images/*/index.json; do
+ sources+=("oci-layout://${index%/index.json}@$(jq -r '.manifests[0].digest' "$index")")
+ done
+ for repository in ghcr.io/coollabsio/coolify docker.io/coollabsio/coolify; do
+ docker buildx imagetools create --tag "$repository:${TAG#v}" "${sources[@]}"
+ done
+
+ - name: Create the GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ matrix.release.tag }}
+ IMAGE_VERSION: ${{ matrix.release.image }}
+ run: |
+ args=(--title "$TAG" --target "$GITHUB_SHA" --notes-file "$RUNNER_TEMP/notes.md" --latest=false)
+ heading="## [v$IMAGE_VERSION]"
+ [[ "$IMAGE_VERSION" != *-* ]] || { args+=(--prerelease); heading="## [Unreleased]"; }
+ awk -v heading="$heading" 'index($0, heading) == 1 { inside = 1; next } /^## / { inside = 0 } inside { sub(/^### /, "## "); print }' CHANGELOG.md > "$RUNNER_TEMP/notes.md"
+ gh release create "$TAG" "${args[@]}"
+
+ tags:
+ name: Move tags
+ needs: publish
+ permissions:
+ contents: write # mark the GitHub release that latest points at as the latest release
+ packages: write # move the tags on ghcr.io
+ environment:
+ name: release
+ deployment: false
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 10
+ outputs:
+ tags: ${{ steps.plan.outputs.tags }}
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Plan the moving tags
+ id: plan
+ run: |
+ tags="$(scripts/plan-version.sh --tags)"
+ echo "tags=$tags" | tee -a "$GITHUB_OUTPUT"
+
+ - name: Log in to ghcr.io
+ uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Log in to docker.io
+ uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # https://github.com/docker/login-action/releases/tag/v4.7.0
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Move the tags on ghcr.io and docker.io
+ env:
+ TAGS: ${{ steps.plan.outputs.tags }}
+ run: |
+ jq -r 'to_entries[] | "\(.key) \(.value)"' <<< "$TAGS" | while read -r tag release; do
+ for repository in ghcr.io/coollabsio/coolify docker.io/coollabsio/coolify; do
+ docker buildx imagetools create --tag "$repository:${tag#v}" "$repository:${release#v}"
+ done
+ done
+
+ - name: Mark the latest GitHub release
+ if: ${{ fromJSON(steps.plan.outputs.tags).latest }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ LATEST: ${{ fromJSON(steps.plan.outputs.tags).latest }}
+ run: gh release edit "$LATEST" --latest
+
+ cdn:
+ name: Sync CDN
+ needs: tags
+ if: ${{ needs.tags.outputs.tags != '{}' }}
+ permissions:
+ contents: read
+ environment:
+ name: release
+ deployment: false
+ runs-on: ubuntu-26.04 # https://github.com/actions/runner-images
+ timeout-minutes: 10
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout/releases/tag/v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Sync the channels of the moved tags
+ env:
+ GH_TOKEN: ${{ secrets.CDN_TOKEN }}
+ TAGS: ${{ needs.tags.outputs.tags }}
+ run: |
+ gh auth setup-git
+ git config --global user.name "github-actions[bot]"
+ git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ jq -r '{stable: .latest, preview: .preview} | to_entries[] | select(.value) | "\(.key) \(.value)"' <<< "$TAGS" | while read -r channel release; do
+ git switch --quiet --detach "$release"
+ scripts/sync-cdn.sh "--$channel" "${release#v}"
+ done
+
+ merge:
+ name: Merge forward
+ needs: publish
+ permissions:
+ contents: write # push the merges
+ pull-requests: write # open a pull request when a merge conflicts
+ uses: $/.github/workflows/merge-forward.yaml
diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml
deleted file mode 100644
index 6e520633ae..0000000000
--- a/.github/workflows/sync-main-to-next.yml
+++ /dev/null
@@ -1,65 +0,0 @@
-name: Sync main to next
-
-on:
- schedule:
- - cron: '*/10 * * * *'
- workflow_dispatch:
-
-permissions:
- contents: write
- pull-requests: write
-
-concurrency:
- group: sync-main-to-next
- cancel-in-progress: false
-
-jobs:
- sync:
- name: Merge main into next
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@v5
- with:
- fetch-depth: 0
-
- - name: Merge main into next
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- git config user.name 'github-actions[bot]'
- git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
- git fetch origin main next
- git checkout --quiet -B next origin/next
-
- if git merge --no-edit origin/main; then
- git push origin HEAD:refs/heads/next
- exit 0
- fi
-
- conflicts=$(git diff --name-only --diff-filter=U)
- git merge --abort
-
- if [ -z "$conflicts" ]; then
- echo '::error title=main to next sync failed::The merge failed without conflicts, so no pull request was created.'
- exit 1
- fi
-
- sync_branch='automation/sync-main-to-next'
- existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url')
- if [ -n "$existing_pr" ]; then
- # The conflict was already reported when this pull request was opened.
- # Stay green until it is merged instead of failing every scheduled run.
- echo "::notice title=main to next sync blocked::main still conflicts with next; resolve the open pull request: $existing_pr"
- exit 0
- fi
-
- git push --force origin origin/main:"refs/heads/$sync_branch"
- gh pr create \
- --base next \
- --head "$sync_branch" \
- --title 'chore: merge main into next' \
- --body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.'
-
- echo '::error title=main to next sync blocked::main could not be merged into next without conflicts; a pull request was opened to resolve them.'
- exit 1
diff --git a/.github/workflows/tmp-coolify-hotfix-release.yml b/.github/workflows/tmp-coolify-hotfix-release.yml
deleted file mode 100644
index 2ef14b3597..0000000000
--- a/.github/workflows/tmp-coolify-hotfix-release.yml
+++ /dev/null
@@ -1,159 +0,0 @@
-# Temporary: releases the 4.0 to 4.3 security fixes from the v4.0.x to v4.3.x branches without moving latest.
-# Delete this workflow once 4.5 ships.
-name: TMP Release Coolify Hotfix
-run-name: tmp ${{ inputs.branch }}
-
-on:
- workflow_dispatch:
- inputs:
- branch:
- description: Release line branch
- required: true
- type: choice
- options:
- - v4.3.x
- - v4.2.x
- - v4.1.x
- - v4.0.x
-
-permissions: {}
-
-concurrency:
- group: tmp-coolify-hotfix-release
- cancel-in-progress: false
-
-env:
- GITHUB_REGISTRY: ghcr.io
- DOCKER_REGISTRY: docker.io
- IMAGE_NAME: coollabsio/coolify
-
-jobs:
- validate:
- runs-on: ubuntu-24.04
- permissions:
- contents: read
- outputs:
- sha: ${{ steps.version.outputs.sha }}
- version: ${{ steps.version.outputs.version }}
- steps:
- - uses: actions/checkout@v5
- with:
- ref: ${{ inputs.branch }}
- persist-credentials: false
-
- - name: Resolve version
- id: version
- env:
- BRANCH: ${{ inputs.branch }}
- GH_TOKEN: ${{ github.token }}
- run: |
- VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)
- if [[ "${VERSION}" != "${BRANCH:1:3}."* || ! "${VERSION}" =~ ^4\.[0-3]\.[0-9]+$ ]]; then
- echo "Version ${VERSION} does not belong to ${BRANCH}."
- exit 1
- fi
- if gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" > /dev/null 2>&1; then
- echo "Release v${VERSION} already exists."
- exit 1
- fi
- echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
-
- build:
- needs: validate
- permissions:
- contents: read
- packages: write
- 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:
- ref: ${{ needs.validate.outputs.sha }}
- 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.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Build and push image (${{ matrix.arch }})
- uses: docker/build-push-action@v6
- with:
- context: .
- file: docker/production/Dockerfile
- platforms: ${{ matrix.platform }}
- push: true
- build-args: |
- COOLIFY_VERSION=${{ needs.validate.outputs.version }}
- labels: |
- org.opencontainers.image.revision=${{ needs.validate.outputs.sha }}
- tags: |
- ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ needs.validate.outputs.sha }}-${{ matrix.arch }}
- ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ needs.validate.outputs.sha }}-${{ matrix.arch }}
-
- publish:
- needs: [validate, build]
- runs-on: ubuntu-24.04
- permissions:
- contents: write
- packages: write
- steps:
- - uses: docker/setup-buildx-action@v3
-
- - name: Login to ${{ env.GITHUB_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to ${{ env.DOCKER_REGISTRY }}
- uses: docker/login-action@v3
- with:
- registry: ${{ env.DOCKER_REGISTRY }}
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Publish the version tag
- env:
- VERSION: ${{ needs.validate.outputs.version }}
- SHA: ${{ needs.validate.outputs.sha }}
- run: |
- for REGISTRY in "${GITHUB_REGISTRY}" "${DOCKER_REGISTRY}"; do
- IMAGE="${REGISTRY}/${IMAGE_NAME}"
- docker buildx imagetools create \
- "${IMAGE}:release-${VERSION}-${SHA}-amd64" \
- "${IMAGE}:release-${VERSION}-${SHA}-aarch64" \
- --tag "${IMAGE}:${VERSION}"
- done
-
- - name: Publish the GitHub release
- env:
- GH_TOKEN: ${{ github.token }}
- VERSION: ${{ needs.validate.outputs.version }}
- SHA: ${{ needs.validate.outputs.sha }}
- run: |
- gh release create "v${VERSION}" \
- --repo "${GITHUB_REPOSITORY}" \
- --target "${SHA}" \
- --title "v${VERSION}" \
- --latest=false \
- --notes "Security fix release for Coolify ${VERSION%.*}. Apply it with: curl -fsSL https://cdn.coollabs.io/coolify/patch.sh | sudo bash"
diff --git a/.gitignore b/.gitignore
index 7f342543fe..7ad9f5ad58 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,7 +36,6 @@ scripts/load-test/*
.env.dusk.local
docker/coolify-terminal/node_modules
.DS_Store
-CHANGELOG.md
/.workspaces
/.superpowers/
/docs/superpowers/plans/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000000..a38ed4d42a
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,54 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+
+- Publish prereleases from `next` and the release branches, tagged like `4.6.0-beta.1`, and the moving `preview` tag
+- Publish a nightly version twice a day, tagged like `4.6.0-alpha.0.20261026.1`, with the moving `nightly` tag and the nightly channel of the CDN, from `next` or from the release branch while one is in progress
+- Build `main` twice a day when it changed and move the `main` tag on ghcr.io to it, versioned like `4.5.0.20261028.1`, to test fixes before their patch release
+- Publish the image of every pull request from a branch of this repository as `ghcr.io/coollabsio/coolify:pr-`, so a change can be tried before it is merged
+- Add OCI labels with the version and the commit to the image
+
+### Changed
+
+- Replace the release process, `RELEASE.md` describes it
+ - Keep the Coolify versions in `release.json`, `stable.coolify` for `main` and the maintenance branches and `preview.coolify` for `next` and the release branches
+ - Pin the helper, Sentinel and Traefik versions in `upstream.json` instead of `versions.json`, the CDN and the image write `versions.json` from it
+ - Release from `main`, `next`, release branches such as `release/v4.6` and maintenance branches such as `v4.x` and `v4.4.x`
+ - Select the stage and the version with `scripts/release.sh`, which offers the versions the branch allows and preselects the bump the commits need
+ - Take the release commit back before the push with `scripts/undo-release.sh`
+ - Plan the version, the releases, the moving tags and the nightlies with `scripts/plan-version.sh`, which also reports what a push publishes
+ - Publish the version a push of `release.json` sets, unless it is published already, instead of a reviewed draft release
+ - Move `latest` and `preview` to their place among all published tags, so a release of an older version never moves a tag back
+- Merge `main` forward into `next` and the release branches only after a release, instead of every 10 minutes, which cluttered the commit history of `next` with merge commits
+- Rework the CI workflows for the new release process
+ - Split the build and release workflows, the release, nightly, main and pull request workflows call the build workflow
+ - Build each platform once and publish exactly that build, not a second build
+ - Write the notes of the GitHub release from `CHANGELOG.md` instead of git-cliff
+ - Mark the GitHub release that `latest` points at as the latest release
+ - Skip the build of a push to a pull request from `main`, `next`, a release branch or a patch branch that changes `release.json`, the release workflow builds it
+ - Run every step with bash, so a failing command in a pipe fails the step
+ - Queue release runs instead of cancelling them, so no two runs publish or move tags at the same time
+- Streamline the CDN
+ - Sync it with `scripts/sync-cdn.sh` instead of `php artisan sync:cdn`, from the release workflow when `latest` or `preview` moves and from the nightly workflow
+ - Open a pull request for the stable channel and push the preview and nightly channels directly
+ - Add a preview channel for the betas and release candidates
+ - Sync every channel with the install and upgrade scripts and the compose files of the commit it releases, instead of the copies in `other/nightly`
+ - Clear the Cloudflare cache of the CDN with `scripts/sync-cdn.sh --purge`
+ - List the Coolify version of every channel in `/coolify/release.json`
+- Take the Coolify version from the image alone, the upgrade script removes `COOLIFY_VERSION` from `.env`
+
+### Removed
+
+- Remove the git-cliff workflow that generated `CHANGELOG.md`, it is written by hand
+- Remove the temporary hotfix release workflow, patch branches release hotfixes
+- Remove the `next`, `sha-` and `-rc.N.` image tags, prereleases and nightlies take their place, and the `main` tag on docker.io, it moves on ghcr.io alone
+
+### Fixed
+
+- Push nothing but the release index, its images and their attestations, the old workflows pushed every platform image with an index of its own, which stayed behind unused next to the release index on ghcr.io and docker.io
diff --git a/RELEASE.md b/RELEASE.md
index e0847d2cd8..dbf6cfb8b2 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -1,78 +1,188 @@
-# Coolify Release Guide
+# Releases
-## Branches
+Every release is tagged `v`, such as `v4.6.0-beta.1`, and publishes the image with the tag `4.6.0-beta.1` on ghcr.io and Docker Hub.
-| Branch | Purpose |
-| --- | --- |
-| `main` | Latest production source |
-| `next` | Feature integration and RC releases for the next version |
-| `feature/*` | New features based on and merged into `next` |
-| `hotfix/X.Y.Z` | Production fixes based on `main` |
+> [!IMPORTANT]
+> A push that changes `release.json` releases the version it sets, unless that version is released already. Run `scripts/plan-version.sh` first to see what a push releases.
-`next` is the only long-lived development branch. Do not create `release/v*` branches.
+## Rules
-Release workflows never edit or commit versions. Stable versions come from `config/constants.php`; RC versions come from `coolify.nightly.version` in `versions.json` and `other/nightly/versions.json`.
+| Branch | Takes | Releases |
+| -------------- | ---------------------------------------------------------- | -------------------------------------- |
+| `main` | fixes, and `next` or a release branch for a new version | stable versions |
+| `next` | features | prereleases of the next minor or major |
+| `release/v4.6` | the minor 4.6, split off `next` so `next` can go on to 4.7 | prereleases of `4.6.0` |
+| `release/v5.0` | the next major, cut from `next` | prereleases of `5.0.0` |
+| `v4.x` | backports, once `main` is on v5 | stable `4.x` versions |
+| `v4.4.x` | backports and hotfixes, cut from the newest tag of v4.4 | stable `4.4.x` versions |
-## Where changes go
+- `main` takes fixes only. A new minor or major reaches it as a merge of `next` or a release branch
+- `next` and the release branches publish prereleases only, their stable versions are released on `main`
+- Name a branch after what it changes: `fix/`, `feat/`, and `fix/-v4.4.8` for a backport, after the version that releases it
+- Squash merge pull requests from contributors and pull requests with a single change, such as dependency updates
+- Merge everything else with a merge commit: team pull requests with several commits, backports, `next`, the release branches and `v4.5.x` into `main`
+- The merge forward workflow merges `main` into `release/v4.6` and `next`, and `release/v4.6` into `next`
+- While `main` is on 4.5, `v4.3.x` and `v4.4.x` are never merged into another branch
+- Delete `release/v4.6` once `main` has released `4.6.0`
+- Delete `v4.5.x` once it is merged into `main`
+- Delete `v4.3.x` and `v4.4.x` once their release is published, the next backport cuts them again from the newest tag
-- Fixes, security updates, and small improvements target `main`.
-- New features and larger changes target `next`.
-- **Sync main to next** merges `main` into `next` every 10 minutes, so every production fix is included in the next release. If the merge conflicts, it opens a `chore: merge main into next` pull request to resolve.
-- Do not merge `next` into `main` until an RC is approved for a stable release.
+> [!CAUTION]
+> Never squash merge `next` or a release branch. Their commits stay unmerged and come back as conflicts in every later merge.
-## Feature and RC flow
+## Create a release
-```text
-feature/* → next → RC
+> [!NOTE]
+> `next` publishes prereleases only. For its stable version, merge `next` into `main` with a merge commit and create the release on `main`.
+
+1. Describe the changes under `## [Unreleased]` in `CHANGELOG.md`, they become the release notes
+2. Check what the branch releases:
+
+ ```sh
+ scripts/plan-version.sh
+ ```
+
+3. Select the stage and the version, the script commits them and asks to push:
+
+ ```sh
+ scripts/release.sh
+ ```
+
+4. The pushed commit changes `release.json`, so it runs the release workflow, which builds and publishes the release and moves its tags
+5. After a stable release that moves `latest`, merge the pull request of the stable channel on `coollabsio/coollabs-cdn`, see [CDN](#cdn)
+
+> [!TIP]
+> To take the release commit back before the push:
+>
+> ```sh
+> scripts/undo-release.sh
+> ```
+
+## Backport a fix
+
+`main` is on 4.5, and 4.3 and 4.4 still get fixes. A fix reaches each of them through a patch branch and a pull request of its own:
+
+1. Cut the patch branch from the newest tag of the minor, unless it exists:
+
+ ```sh
+ git switch -c v4.4.x v4.4.7 && git push -u origin v4.4.x
+ ```
+
+2. Put the fix on a branch named after the version that releases it, cherry-picked from `main` when it is there already:
+
+ ```sh
+ git switch -c fix/-v4.4.8 origin/v4.4.x && git cherry-pick -x
+ ```
+
+3. Merge a pull request from `fix/-v4.4.8` into `v4.4.x` with a merge commit
+4. Create the release on your checkout of `v4.4.x`:
+
+ ```sh
+ git switch v4.4.x && git pull && scripts/release.sh
+ ```
+
+5. Repeat with `v4.3.x` and `fix/-v4.3.25`. 4.2 gets no backports
+
+> [!IMPORTANT]
+> A hotfix for 4.5 while `main` holds unreleased changes goes through `v4.5.x` the same way. Merge `v4.5.x` into `main` with a merge commit afterwards and delete it. `v4.3.x` and `v4.4.x` are never merged, their fixes come from `main`.
+
+## Release a major version
+
+1. Cut `release/v5.0` from `next` and create its prereleases there with `scripts/release.sh`:
+
+ ```sh
+ git switch -c release/v5.0 origin/next && git push -u origin release/v5.0
+ ```
+
+2. Merge `main` into `release/v5.0`, so v5 has every fix of v4 and the pull request into `main` has no conflicts:
+
+ ```sh
+ git switch release/v5.0 && git pull && git merge origin/main && git push
+ ```
+
+3. Keep v4 on a branch of its own:
+
+ ```sh
+ git switch -c v4.x origin/main && git push -u origin v4.x
+ ```
+
+4. Merge a pull request from `release/v5.0` into `main` with a merge commit
+5. Create the release on `main` and select `5.0.0`:
+
+ ```sh
+ git switch main && git pull && scripts/release.sh
+ ```
+
+6. Delete `release/v5.0`
+
+> [!IMPORTANT]
+> Cut `v4.x` from `main`, or bring an existing one up to date with `main`, before `release/v5.0` is merged into `main`. Afterwards `main` is v5 and v4 lives on `v4.x` alone.
+
+## Nightlies
+
+The nightly workflow publishes a nightly twice a day when its branch has a new commit, versioned `..`:
+
+| State | Nightly |
+| ------------------------------ | -------------------------- |
+| nothing bumped since 5.0.4 | `5.1.0-alpha.0.20261014.1` |
+| after 5.1.0-alpha.1 | `5.1.0-alpha.1.20261014.1` |
+| after 5.1.0-beta.1 | `5.1.0-beta.1.20261014.1` |
+| second nightly on the same day | `5.1.0-beta.1.20261014.2` |
+| after 5.1.0-rc.1 | `5.1.0-rc.1.20261015.1` |
+| after 5.1.0 | `5.2.0-alpha.0.20261016.1` |
+
+- It comes from `next`, or from the lowest release branch while one is in progress: `release/v5.1` takes it over once it is cut and hands it back once `5.1.0` is released
+- The base is the `preview` version of that branch in `release.json` while it is ahead of the newest stable version, a plain one with `-alpha.0`. Otherwise it is the next version with `-alpha.0`, which is never released. A pull request takes the same base
+- N counts up per day and base
+- No git tag and no GitHub release, only its image tag, the moving `nightly` tag and the nightly channel of the CDN
+
+Print what the next run publishes with `scripts/plan-version.sh --nightly`.
+
+## Main builds
+
+The main workflow builds `main` twice a day when it changed since the last build and moves `ghcr.io/coollabsio/coolify:main` to it, to test fixes before their patch release. A main build is versioned `..`, `4.5.0.20261028.1`, which sorts after `4.5.0` and before `4.5.1`.
+
+- ghcr.io only, no version tag, no git tag, no GitHub release and no channel of the CDN
+
+Print what the next run builds with `scripts/plan-version.sh --main`.
+
+## Pull requests
+
+The pull request workflow builds every pull request and publishes the image of a pull request from a branch of this repository as `ghcr.io/coollabsio/coolify:pr-`. Its version is `-pr.`, with the base a nightly of its base branch takes, or the stable version for `main`:
+
+- Into `next`: `4.6.0-beta.1-pr.1234` after `4.6.0-beta.1`, or `4.7.0-alpha.0-pr.1234` before the first alpha of 4.7.0
+- Into `main`: `4.5.3-pr.1234`
+- PHP's `version_compare` sorts it after its base and the nightlies of the base and before the next prerelease or release, so an instance on it updates to the release that ships it
+- Pull requests from forks and Dependabot are built, not published
+- A push to a pull request from `main`, `next`, a release branch or a patch branch that changes `release.json` is not built, the release workflow builds it
+- Pull request builds are published to ghcr.io only, as the Docker Hub credentials are in the `release` environment, which no pull request run can use
+- Pull request builds create no git tag and no GitHub release
+
+## CDN
+
+`scripts/sync-cdn.sh` syncs a channel of `coollabsio/coollabs-cdn` from the commit of the version it syncs: the install and upgrade scripts, the compose files, the environment file, the service templates, the GitHub releases and `versions.json`.
+
+| Channel | Path | Synced by |
+| --------- | ------------------- | --------------------------------------------------------- |
+| `stable` | `/coolify/` | the release workflow when `latest` moves, a pull request |
+| `preview` | `/coolify/preview/` | the release workflow when `preview` moves, pushed to main |
+| `nightly` | `/coolify/nightly/` | the nightly workflow after every nightly, pushed to main |
+
+- The preview channel takes betas and release candidates, alphas reach the nightly channel only
+- `CDN_TOKEN` in the environment `release` is a fine-grained token for `coollabsio/coollabs-cdn` alone, with read and write access to contents and pull requests
+- `versions.json` of a channel names its Coolify version and the helper, Sentinel and Traefik versions of `upstream.json`
+- `/coolify/release.json` lists the Coolify version of every channel, each sync sets the version of its channel
+
+Cloudflare keeps a file of the CDN for up to 10 minutes. To clear the cache sooner, for example once the pull request of the stable channel is merged and deployed, set `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ZONE_ID` in `.env` or the environment, with a token that can purge the cache of the zone:
+
+```sh
+scripts/sync-cdn.sh --purge
```
-1. Merge feature branches into `next`.
-2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.5-rc.1`.
-3. Every push to `next` runs **Build Coolify Next**. It publishes `sha-`, `4.5-rc.1.`, and the moving `next` tag. It never publishes the exact `4.5-rc.1` tag.
-4. Create a reviewed draft GitHub Release named `v4.5-rc.1` and mark it as a prerelease.
-5. Run **Release Coolify RC** manually from `next` and enter `v4.5-rc.1`.
-6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.5-rc.1`, updates the `next` image tag, and publishes the draft prerelease.
-7. Advance `coolify.nightly.version` to the next intended RC version.
+## Update the helper, Sentinel or Traefik
-## Stable release flow
+`upstream.json` pins them. A change of it releases nothing, every Coolify instance reads the versions from the CDN. To ship it before the next release, sync the stable channel on `main`:
-```text
-next → main → stable release
+```sh
+scripts/sync-cdn.sh --stable
```
-
-1. Temporarily stop merging features into `next`.
-2. Change the version on `next` from the approved RC to the stable version, such as `4.5.0`.
-3. Merge `next` into `main`.
-4. Create a reviewed draft GitHub Release named `v4.5.0`.
-5. Run the stable release workflow from `main`.
-6. The workflow rebuilds the exact stable version, publishes `4.5.0` and `latest`, then publishes the draft.
-7. Update the CDN only after the release is approved.
-8. Advance `next` to the next development version, such as `4.6-rc.1`.
-
-## Hotfix flow
-
-```text
-main → hotfix/X.Y.Z → main → next
-```
-
-1. Create `hotfix/X.Y.Z` from `main` when a patch needs an integration branch. A single fix may use a normal branch from `main` instead.
-2. Set the intended patch version.
-3. Implement and test the fix. SHA images report `X.Y.Z-dev.`.
-4. Merge the fix into `main`.
-5. Create a reviewed draft GitHub Release named `vX.Y.Z`.
-6. Run the stable release workflow from `main`.
-7. **Sync main to next** merges `main` into `next`. If the version conflicts, resolve it in favor of the next intended RC in the sync pull request. Delete the hotfix branch if one was used.
-8. Update the CDN only after the release is approved.
-
-## Image tags
-
-| Tag | Meaning |
-| --- | --- |
-| `latest` | Latest stable release |
-| `next` | Latest successful `next` build or RC release |
-| `X.Y-rc.N.` | Exact `next` commit build |
-| `X.Y.Z` | Exact stable release |
-| `X.Y-rc.N` | Exact RC release |
-| `sha-` | Exact commit build |
-
-Git tags use the `v` prefix, such as `v4.5.0`. Docker image tags do not.
diff --git a/app/Console/Commands/SyncCdn.php b/app/Console/Commands/SyncCdn.php
deleted file mode 100644
index c447c5f726..0000000000
--- a/app/Console/Commands/SyncCdn.php
+++ /dev/null
@@ -1,399 +0,0 @@
- $urls
- */
- private function purgeCloudflareCache(array $urls): bool
- {
- $apiToken = config('constants.cloudflare.api_token');
- $zoneId = config('constants.cloudflare.zone_id');
-
- if (blank($apiToken) || blank($zoneId)) {
- $this->error('CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID must be set to purge the Cloudflare cache.');
-
- return false;
- }
-
- foreach ($urls as $url) {
- $this->info('Purging: '.$url);
- }
-
- try {
- $response = Http::withToken($apiToken)
- ->timeout(30)
- ->post("https://api.cloudflare.com/client/v4/zones/{$zoneId}/purge_cache", [
- 'files' => array_values($urls),
- ]);
- } catch (\Throwable $e) {
- $this->error('Failed to purge Cloudflare cache: '.$e->getMessage());
-
- return false;
- }
-
- if (! $response->successful() || $response->json('success') !== true) {
- $this->error('Failed to purge Cloudflare cache: '.$response->body());
-
- return false;
- }
-
- $this->info('Cloudflare cache purged.');
-
- return true;
- }
-
- /**
- * Fetch GitHub releases and sync to GitHub repository
- */
- private function syncReleasesToGitHubRepo(array $files, bool $nightly = false): bool
- {
- $this->info('Fetching releases from GitHub...');
- try {
- $response = Http::timeout(30)
- ->get('https://api.github.com/repos/coollabsio/coolify/releases', [
- 'per_page' => 30, // Fetch more releases for better changelog
- ]);
-
- if (! $response->successful()) {
- $this->error('Failed to fetch releases from GitHub: '.$response->status());
-
- return false;
- }
-
- $releasesFile = tempnam(sys_get_temp_dir(), 'coolify-releases-');
- if ($releasesFile === false || file_put_contents($releasesFile, json_encode($response->json(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
- $this->error('Failed to create temporary releases.json.');
-
- return false;
- }
-
- $files[$releasesFile] = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json';
-
- try {
- return $this->syncFilesToGitHubRepo($files, $nightly);
- } finally {
- @unlink($releasesFile);
- }
- } catch (\Throwable $e) {
- $this->error('Error syncing releases: '.$e->getMessage());
-
- return false;
- }
- }
-
- /**
- * Sync install.sh, docker-compose, and env files to GitHub repository via PR
- */
- private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
- {
- $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
- $this->info("Syncing $envLabel files to GitHub repository...");
- try {
- $timestamp = time();
- $tmpDir = sys_get_temp_dir().'/coollabs-cdn-files-'.$timestamp;
- $branchName = 'update-files-'.$timestamp;
-
- // Clone the repository
- $this->info('Cloning coollabs-cdn repository...');
- $output = [];
- exec('gh repo clone coollabsio/coollabs-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to clone repository: '.implode("\n", $output));
-
- return false;
- }
-
- // Create feature branch
- $this->info('Creating feature branch...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to create branch: '.implode("\n", $output));
- $this->removeTemporaryDirectory($tmpDir);
-
- return false;
- }
-
- // Copy each file to its target path in the CDN repo
- $copiedFiles = [];
- foreach ($files as $sourceFile => $targetPath) {
- if (! file_exists($sourceFile)) {
- $this->warn("Source file not found, skipping: $sourceFile");
-
- continue;
- }
-
- $destPath = "$tmpDir/$targetPath";
- $destDir = dirname($destPath);
-
- if (! is_dir($destDir)) {
- if (! mkdir($destDir, 0755, true)) {
- $this->error("Failed to create directory: $destDir");
- $this->removeTemporaryDirectory($tmpDir);
-
- return false;
- }
- }
-
- if (copy($sourceFile, $destPath) === false) {
- $this->error("Failed to copy $sourceFile to $destPath");
- $this->removeTemporaryDirectory($tmpDir);
-
- return false;
- }
-
- $copiedFiles[] = $targetPath;
- $this->info("Copied: $targetPath");
- }
-
- if (empty($copiedFiles)) {
- $this->warn('No files were copied. Nothing to commit.');
- $this->removeTemporaryDirectory($tmpDir);
-
- return true;
- }
-
- // Stage all copied files
- $this->info('Staging changes...');
- $output = [];
- $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
- exec($stageCmd, $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to stage changes: '.implode("\n", $output));
- $this->removeTemporaryDirectory($tmpDir);
-
- return false;
- }
-
- // Check for changes
- $this->info('Checking for changes...');
- $changedFiles = [];
- exec('cd '.escapeshellarg($tmpDir).' && git diff --cached --name-only 2>&1', $changedFiles, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to check changed files: '.implode("\n", $changedFiles));
- $this->removeTemporaryDirectory($tmpDir);
-
- return false;
- }
-
- $changedFiles = array_values(array_filter($changedFiles));
- if (empty($changedFiles)) {
- $this->info('All files are already up to date. No changes to commit.');
- $this->removeTemporaryDirectory($tmpDir);
-
- return true;
- }
-
- // Commit changes
- $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to commit changes: '.implode("\n", $output));
- $this->removeTemporaryDirectory($tmpDir);
-
- return false;
- }
-
- // Push to remote
- $this->info('Pushing branch to remote...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to push branch: '.implode("\n", $output));
- $this->removeTemporaryDirectory($tmpDir);
-
- return false;
- }
-
- // Create pull request
- $this->info('Creating pull request...');
- $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
- $fileList = implode("\n- ", $changedFiles);
- $prBody = "Automated update of $envLabel files:\n- $fileList";
- $prCommand = 'gh pr create --repo coollabsio/coollabs-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
- $output = [];
- exec($prCommand, $output, $returnCode);
-
- // Clean up
- $this->removeTemporaryDirectory($tmpDir);
-
- if ($returnCode !== 0) {
- $this->error('Failed to create PR: '.implode("\n", $output));
-
- return false;
- }
-
- $this->info('Pull request created successfully!');
- if (! empty($output)) {
- $this->info('PR URL: '.implode("\n", $output));
- }
- $this->info('Files synced: '.count($changedFiles));
-
- return true;
- } catch (\Throwable $e) {
- $this->error('Error syncing files to GitHub: '.$e->getMessage());
-
- return false;
- }
- }
-
- /**
- * Execute the console command.
- */
- public function handle()
- {
- $nightly = select(
- label: 'Which environment would you like to sync?',
- options: [
- 'production' => 'Production',
- 'nightly' => 'Nightly',
- ],
- default: 'production',
- ) === 'nightly';
- $cdn = 'https://cdn.coollabs.io';
-
- $parent_dir = realpath(dirname(__FILE__).'/../../..');
-
- $compose_file = 'docker-compose.yml';
- $compose_file_prod = 'docker-compose.prod.yml';
- $install_script = 'install.sh';
- $upgrade_script = 'upgrade.sh';
- $upgrade_postgres_script = 'upgrade-postgres.sh';
- $production_env = '.env.production';
- $service_template = config('constants.services.file_name');
- $versions = 'versions.json';
-
- $compose_file_location = "$parent_dir/$compose_file";
- $compose_file_prod_location = "$parent_dir/$compose_file_prod";
- $install_script_location = "$parent_dir/scripts/install.sh";
- $upgrade_script_location = "$parent_dir/scripts/upgrade.sh";
- $upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh";
- $patch_script_location = "$parent_dir/scripts/patch.sh";
- $production_env_location = "$parent_dir/.env.production";
- $service_template_location = "$parent_dir/templates/$service_template";
- $versions_location = "$parent_dir/$versions";
-
- try {
- if ($nightly) {
- $compose_file_location = "$parent_dir/other/nightly/$compose_file";
- $compose_file_prod_location = "$parent_dir/other/nightly/$compose_file_prod";
- $production_env_location = "$parent_dir/other/nightly/$production_env";
- $upgrade_script_location = "$parent_dir/other/nightly/$upgrade_script";
- $upgrade_postgres_script_location = "$parent_dir/other/nightly/$upgrade_postgres_script";
- $install_script_location = "$parent_dir/other/nightly/$install_script";
- $versions_location = "$parent_dir/other/nightly/$versions";
- }
- if ($nightly) {
- $files = [
- $versions_location => 'json/coolify/nightly/versions.json',
- $compose_file_location => 'json/coolify/nightly/docker-compose.yml',
- $compose_file_prod_location => 'json/coolify/nightly/docker-compose.prod.yml',
- $production_env_location => 'json/coolify/nightly/.env.production',
- $install_script_location => 'json/coolify/nightly/install.sh',
- $upgrade_script_location => 'json/coolify/nightly/upgrade.sh',
- $upgrade_postgres_script_location => 'json/coolify/nightly/upgrade-postgres.sh',
- $service_template_location => 'json/coolify/nightly/service-templates-latest.json',
- ];
- } else {
- $files = [
- $versions_location => 'json/coolify/versions.json',
- $compose_file_location => 'json/coolify/docker-compose.yml',
- $compose_file_prod_location => 'json/coolify/docker-compose.prod.yml',
- $production_env_location => 'json/coolify/.env.production',
- $install_script_location => 'json/coolify/install.sh',
- $upgrade_script_location => 'json/coolify/upgrade.sh',
- $upgrade_postgres_script_location => 'json/coolify/upgrade-postgres.sh',
- $patch_script_location => 'json/coolify/patch.sh',
- $service_template_location => 'json/coolify/service-templates-latest.json',
- ];
- }
-
- $releasesTarget = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json';
-
- if ($this->option('purge')) {
- $urls = array_map(
- fn (string $targetPath) => $cdn.'/'.substr($targetPath, strlen('json/')),
- [$releasesTarget, ...array_values($files)],
- );
- if ($nightly) {
- // Nightly scripts still download from the legacy /coolify-nightly/ path, which Cloudflare caches separately.
- $urls = [...$urls, ...str_replace('/coolify/nightly/', '/coolify-nightly/', $urls)];
- }
-
- return $this->purgeCloudflareCache($urls) ? self::SUCCESS : self::FAILURE;
- }
-
- $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
- $this->info("About to sync $envLabel releases, versions, compose, and environment files to GitHub repository.");
-
- $options = [$releasesTarget, ...array_values($files)];
- $selectedFiles = multiselect(
- label: 'Which files would you like to sync?',
- options: $options,
- default: $options,
- required: true,
- scroll: count($options),
- );
-
- $includeReleases = in_array($releasesTarget, $selectedFiles, true);
- $files = array_filter(
- $files,
- fn (string $targetPath) => in_array($targetPath, $selectedFiles, true),
- );
-
- $synced = $includeReleases
- ? $this->syncReleasesToGitHubRepo($files, $nightly)
- : $this->syncFilesToGitHubRepo($files, $nightly);
-
- if ($synced) {
- $this->info('After the CDN pull request is merged and deployed, run "php artisan sync:cdn --purge" to purge the Cloudflare cache.');
- }
- } catch (\Throwable $e) {
- $this->error('Error: '.$e->getMessage());
- }
- }
-}
diff --git a/bootstrap/getVersion.php b/bootstrap/getVersion.php
deleted file mode 100644
index 8fa27a3328..0000000000
--- a/bootstrap/getVersion.php
+++ /dev/null
@@ -1,10 +0,0 @@
-
-"""
-# remove the leading and trailing s
-trim = true
-# postprocessors
-postprocessors = [
- # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
-]
-# render body even when there are no releases to process
-# render_always = true
-# output file path
-# output = "test.md"
-
-[git]
-# parse the commits based on https://www.conventionalcommits.org
-conventional_commits = true
-# filter out the commits that are not conventional
-filter_unconventional = true
-# process each line of a commit as an individual commit
-split_commits = false
-# regex for preprocessing the commit messages
-commit_preprocessors = [
- # Replace issue numbers
- #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"},
- # Check spelling of the commit with https://github.com/crate-ci/typos
- # If the spelling is incorrect, it will be automatically fixed.
- #{ pattern = '.*', replace_command = 'typos --write-changes -' },
-]
-# regex for parsing and grouping commits
-commit_parsers = [
- { message = "^feat", group = "🚀 Features" },
- { message = "^fix", group = "🐛 Bug Fixes" },
- { message = "^doc", group = "📚 Documentation" },
- { message = "^perf", group = "⚡ Performance" },
- { message = "^refactor", group = "🚜 Refactor" },
- { message = "^style", group = "🎨 Styling" },
- { message = "^test", group = "🧪 Testing" },
- { message = "^chore\\(release\\): prepare for", skip = true },
- { message = "^chore\\(deps.*\\)", skip = true },
- { message = "^chore\\(pr\\)", skip = true },
- { message = "^chore\\(pull\\)", skip = true },
- { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" },
- { body = ".*security", group = "🛡️ Security" },
- { message = "^revert", group = "◀️ Revert" },
- { message = ".*", group = "💼 Other" },
-]
-# filter out the commits that are not matched by commit parsers
-filter_commits = false
-# sort the tags topologically
-topo_order = false
-# sort the commits inside sections by oldest/newest order
-sort_commits = "oldest"
diff --git a/config/constants.php b/config/constants.php
index 2bacce35e1..38c7ee287a 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -1,12 +1,15 @@
[
- 'version' => env('COOLIFY_VERSION') ?: '4.5.4',
- 'helper_version' => '1.0.19',
+ 'version' => env('COOLIFY_VERSION') ?: $release['stable']['coolify'],
+ 'helper_version' => $upstream['helper']['version'],
'railpack_version' => '0.43.0',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
@@ -139,11 +142,6 @@ return [
'dev_webhook' => env('SERVEO_URL'),
],
- 'cloudflare' => [
- 'api_token' => env('CLOUDFLARE_API_TOKEN'),
- 'zone_id' => env('CLOUDFLARE_ZONE_ID'),
- ],
-
'server_checks' => [
// Notification delay configuration for parallel server checks
// Used for Traefik version checks and other future server check jobs
diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile
index 476b35b704..018dca1d18 100644
--- a/docker/production/Dockerfile
+++ b/docker/production/Dockerfile
@@ -197,8 +197,9 @@ COPY --chown=www-data:www-data resources/views ./resources/views
COPY --chown=www-data:www-data artisan artisan
COPY --chown=www-data:www-data openapi.yaml ./openapi.yaml
COPY --chown=www-data:www-data changelogs/ ./changelogs/
-# Version data for Traefik, Sentinel, and helper checks until CheckForUpdatesJob refreshes it
-COPY --chown=www-data:www-data versions.json ./versions.json
+COPY --chown=www-data:www-data release.json upstream.json ./
+RUN jq --arg version "$COOLIFY_VERSION" '{coolify: ({v4: {$version}} + del(.traefik)), traefik}' upstream.json > versions.json \
+ && chown www-data:www-data versions.json
RUN composer dump-autoload
@@ -219,5 +220,10 @@ RUN mkdir -p /etc/nginx/conf.d && \
COPY --from=minio-client /usr/bin/mc /usr/bin/mc
RUN chmod +x /usr/bin/mc
+ARG GIT_SHA
+
+LABEL org.opencontainers.image.version="${COOLIFY_VERSION}" \
+ org.opencontainers.image.revision="${GIT_SHA}"
+
# Switch to non-root user
USER www-data
diff --git a/other/nightly/.env.production b/other/nightly/.env.production
deleted file mode 100644
index c6990a51d4..0000000000
--- a/other/nightly/.env.production
+++ /dev/null
@@ -1,17 +0,0 @@
-APP_ID=
-APP_NAME=Coolify
-APP_KEY=
-
-DB_USERNAME=coolify
-DB_PASSWORD=
-
-REDIS_PASSWORD=
-
-PUSHER_APP_ID=
-PUSHER_APP_KEY=
-PUSHER_APP_SECRET=
-ROOT_USERNAME=
-ROOT_USER_EMAIL=
-ROOT_USER_PASSWORD=
-
-REGISTRY_URL=docker.io
diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml
deleted file mode 100644
index 8e214b385e..0000000000
--- a/other/nightly/docker-compose.prod.yml
+++ /dev/null
@@ -1,89 +0,0 @@
-services:
- # Placeholder for "soketi" overrides in docker-compose.custom.yml from versions before 4.4.
- # Realtime runs inside the coolify container now. Without this entry, such an override is an
- # incomplete service and every upgrade fails. Zero replicas means it never starts, and
- # "docker compose up" removes the old coolify-realtime container (an inactive profile keeps it).
- # It uses the coolify image, so install and upgrade do not pull another image.
- soketi:
- image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}"
- deploy:
- replicas: 0
- coolify:
- image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}"
- volumes:
- - type: bind
- source: /data/coolify/source/.env
- target: /var/www/html/.env
- read_only: true
- - /data/coolify/ssh:/var/www/html/storage/app/ssh
- - /data/coolify/applications:/var/www/html/storage/app/applications
- - /data/coolify/databases:/var/www/html/storage/app/databases
- - /data/coolify/services:/var/www/html/storage/app/services
- - /data/coolify/backups:/var/www/html/storage/app/backups
- - /data/coolify/images:/var/www/html/storage/app/images
- environment:
- - APP_ENV=${APP_ENV:-production}
- - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M}
- - PHP_FPM_PM_CONTROL=${PHP_FPM_PM_CONTROL:-dynamic}
- - PHP_FPM_PM_START_SERVERS=${PHP_FPM_PM_START_SERVERS:-1}
- - PHP_FPM_PM_MIN_SPARE_SERVERS=${PHP_FPM_PM_MIN_SPARE_SERVERS:-1}
- - PHP_FPM_PM_MAX_SPARE_SERVERS=${PHP_FPM_PM_MAX_SPARE_SERVERS:-10}
- - PUSHER_BACKEND_PORT=${PUSHER_BACKEND_PORT:-6001}
- env_file:
- - /data/coolify/source/.env
- ports:
- - "${APP_PORT:-8000}:8080"
- - "${REVERB_PORT:-${SOKETI_PORT:-6001}}:6001"
- - "6002:6002"
- expose:
- - "${APP_PORT:-8000}"
- - "6001"
- - "6002"
- networks:
- coolify:
- # Realtime ran in the coolify-realtime container before 4.4; keep the name for existing proxy upstreams.
- aliases:
- - coolify-realtime
- healthcheck:
- test: curl --fail http://127.0.0.1:8080/api/health && curl --fail http://127.0.0.1:${PUSHER_BACKEND_PORT:-6001}/up || exit 1
- interval: 5s
- retries: 24
- start_period: 1m
- timeout: 2s
- depends_on:
- postgres:
- condition: service_healthy
- redis:
- condition: service_healthy
- postgres:
- volumes:
- - coolify-db:/var/lib/postgresql/data
- environment:
- POSTGRES_USER: "${DB_USERNAME}"
- POSTGRES_PASSWORD: "${DB_PASSWORD}"
- POSTGRES_DB: "${DB_DATABASE:-coolify}"
- healthcheck:
- test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ]
- interval: 5s
- retries: 10
- timeout: 2s
- redis:
- command: redis-server --save 20 1 --loglevel warning --requirepass ${REDIS_PASSWORD}
- environment:
- REDIS_PASSWORD: "${REDIS_PASSWORD}"
- volumes:
- - coolify-redis:/data
- healthcheck:
- test: redis-cli ping
- interval: 5s
- retries: 10
- timeout: 2s
-volumes:
- coolify-db:
- name: coolify-db
- coolify-redis:
- name: coolify-redis
-
-networks:
- coolify:
- external: true
diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml
deleted file mode 100644
index 347495424f..0000000000
--- a/other/nightly/docker-compose.windows.yml
+++ /dev/null
@@ -1,110 +0,0 @@
-services:
- coolify-testing-host:
- init: true
- image: "docker.io/coollabsio/coolify-testing-host:latest"
- pull_policy: always
- container_name: coolify-testing-host
- volumes:
- - //var/run/docker.sock://var/run/docker.sock
- - ./:/data/coolify
- coolify:
- image: "docker.io/coollabsio/coolify:latest"
- pull_policy: always
- container_name: coolify
- restart: always
- working_dir: /var/www/html
- extra_hosts:
- - 'host.docker.internal:host-gateway'
- volumes:
- - type: bind
- source: .env
- target: /var/www/html/.env
- read_only: true
- - ./ssh:/var/www/html/storage/app/ssh
- - ./applications:/var/www/html/storage/app/applications
- - ./databases:/var/www/html/storage/app/databases
- - ./services:/var/www/html/storage/app/services
- - ./backups:/var/www/html/storage/app/backups
- - ./images:/var/www/html/storage/app/images
- env_file:
- - .env
- environment:
- - APP_ID
- - APP_ENV=production
- - APP_NAME
- - APP_KEY
- - DB_PASSWORD
- - REDIS_PASSWORD
- - SSL_MODE=off
- - PHP_PM_CONTROL=dynamic
- - PHP_PM_START_SERVERS=1
- - PHP_PM_MIN_SPARE_SERVERS=1
- - PHP_PM_MAX_SPARE_SERVERS=10
- - PUSHER_APP_ID
- - PUSHER_APP_KEY
- - PUSHER_APP_SECRET
- - PUSHER_PORT
- - PUSHER_BACKEND_PORT
- - AUTOUPDATE=true
- - SELF_HOSTED=true
- - SSH_MUX_ENABLED=false
- - IS_WINDOWS_DOCKER_DESKTOP=true
- ports:
- - "${APP_PORT:-8000}:8080"
- - "${REVERB_PORT:-${SOKETI_PORT:-6001}}:6001"
- - "6002:6002"
- expose:
- - "${APP_PORT:-8000}"
- - "6001"
- - "6002"
- healthcheck:
- test: curl --fail http://localhost:8080/api/health && curl --fail http://localhost:${PUSHER_BACKEND_PORT:-6001}/up || exit 1
- interval: 5s
- retries: 10
- timeout: 2s
- depends_on:
- postgres:
- condition: service_healthy
- redis:
- condition: service_healthy
- postgres:
- image: postgres:15-alpine
- pull_policy: always
- container_name: coolify-db
- restart: always
- env_file:
- - .env
- volumes:
- - coolify-db:/var/lib/postgresql/data
- environment:
- POSTGRES_USER: "${DB_USERNAME}"
- POSTGRES_PASSWORD: "${DB_PASSWORD}"
- POSTGRES_DB: "${DB_DATABASE:-coolify}"
- healthcheck:
- test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ]
- interval: 5s
- retries: 10
- timeout: 2s
- redis:
- image: redis:7-alpine
- pull_policy: always
- container_name: coolify-redis
- restart: always
- command: redis-server --save 20 1 --loglevel warning --requirepass ${REDIS_PASSWORD}
- env_file:
- - .env
- environment:
- REDIS_PASSWORD: "${REDIS_PASSWORD}"
- volumes:
- - coolify-redis:/data
- healthcheck:
- test: redis-cli ping
- interval: 5s
- retries: 10
- timeout: 2s
-
-volumes:
- coolify-db:
- name: coolify-db
- coolify-redis:
- name: coolify-redis
diff --git a/other/nightly/docker-compose.yml b/other/nightly/docker-compose.yml
deleted file mode 100644
index c4fc1233a6..0000000000
--- a/other/nightly/docker-compose.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-services:
- coolify:
- container_name: coolify
- restart: always
- working_dir: /var/www/html
- extra_hosts:
- - host.docker.internal:host-gateway
- expose:
- - "6001"
- - "6002"
- networks:
- - coolify
- depends_on:
- - postgres
- - redis
- postgres:
- image: postgres:15-alpine
- container_name: coolify-db
- restart: always
- networks:
- - coolify
- redis:
- image: redis:7-alpine
- container_name: coolify-redis
- restart: always
- networks:
- - coolify
-networks:
- coolify:
- name: coolify
- driver: bridge
- external: false
diff --git a/other/nightly/install.sh b/other/nightly/install.sh
deleted file mode 100755
index 7a1783082a..0000000000
--- a/other/nightly/install.sh
+++ /dev/null
@@ -1,1179 +0,0 @@
-#!/bin/bash
-## Do not modify this file. You will lose the ability to install and auto-update!
-
-## Environment variables that can be set:
-## ROOT_USERNAME - Predefined root username
-## ROOT_USER_EMAIL - Predefined root user email
-## ROOT_USER_PASSWORD - Predefined root user password
-## DOCKER_ADDRESS_POOL_BASE - Custom Docker address pool base (default: 10.0.0.0/8)
-## DOCKER_ADDRESS_POOL_SIZE - Custom Docker address pool size (default: 24)
-## DOCKER_POOL_FORCE_OVERRIDE - Force override Docker address pool configuration, also when Docker must restart with running containers (default: false)
-## AUTOUPDATE - Set to "false" to disable auto-updates
-## REGISTRY_URL - Custom registry URL for Docker images (default: docker.io)
-
-set -e # Exit immediately if a command exits with a non-zero status
-## $1 could be empty, so we need to disable this check
-#set -u # Treat unset variables as an error and exit
-set -o pipefail # Cause a pipeline to return the status of the last command that exited with a non-zero status
-CDN="https://cdn.coollabs.io/coolify-nightly"
-DATE=$(date +"%Y%m%d-%H%M%S")
-
-OS_TYPE=$(grep -w "ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
-ENV_FILE="/data/coolify/source/.env"
-DOCKER_VERSION="latest"
-# TODO: Ask for a user
-CURRENT_USER=$USER
-
-if [ $EUID != 0 ]; then
- echo "Please run this script as root or with sudo"
- exit 1
-fi
-
-# Resource directories hold data whose owner and mode the resource images or a non-root SSH user need.
-# Set their owner and mode only when they are created, never on an upgrade (#11945).
-create_data_directory() {
- if [ ! -d "$1" ]; then
- mkdir -p "$1"
- chown 9999:root "$1"
- chmod 700 "$1"
- fi
-}
-
-# Run only after the checks and downloads succeed, so a failed run does not change the data directories (#11973).
-create_coolify_directories() {
- for COOLIFY_DIRECTORY in /data/coolify/{applications,databases,backups,services,proxy,proxy/dynamic,sentinel}; do
- create_data_directory "$COOLIFY_DIRECTORY"
- done
- mkdir -p /data/coolify/images /data/coolify/ssh/{keys,mux}
-}
-
-# Coolify's own directories, used by the coolify container (UID 9999)
-set_coolify_directory_permissions() {
- chown -R 9999:root /data/coolify/{source,ssh,images}
- chmod -R 700 /data/coolify/{source,ssh,images}
-}
-
-# The installation log and the downloaded files go to /data/coolify/source, so only it is created before the checks.
-create_data_directory /data/coolify
-mkdir -p /data/coolify/source
-
-INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log"
-
-# Terminal UI: the terminal only shows the step list, everything else goes to the log file
-TOTAL_STEPS=9
-UI_STEP=0
-UI_STEP_TITLE=""
-UI_STEP_DETAIL=""
-UI_STEP_STARTED=0
-UI_SPINNER_PID=""
-UI_WARNINGS=()
-
-if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ]; then
- UI_TTY=true
-else
- UI_TTY=false
-fi
-
-if [ "$UI_TTY" = true ] && [ -z "${NO_COLOR:-}" ]; then
- C_RESET=$'\033[0m'
- C_BOLD=$'\033[1m'
- C_DIM=$'\033[2m'
- C_PURPLE=$'\033[38;5;135m'
- C_GREEN=$'\033[32m'
- C_RED=$'\033[31m'
- C_YELLOW=$'\033[33m'
-else
- C_RESET="" C_BOLD="" C_DIM="" C_PURPLE="" C_GREEN="" C_RED="" C_YELLOW=""
-fi
-
-UI_WIDTH=$(tput cols 2>/dev/null || true)
-if ! [[ $UI_WIDTH =~ ^[0-9]+$ ]]; then
- UI_WIDTH=80
-fi
-UI_WIDTH=$((UI_WIDTH > 100 ? 100 : UI_WIDTH < 60 ? 60 : UI_WIDTH))
-
-# Current time in milliseconds
-ui_now() {
- if [ -n "${EPOCHREALTIME:-}" ]; then
- local now=${EPOCHREALTIME/[.,]/}
- echo $((now / 1000))
- else
- echo $(($(date +%s) * 1000))
- fi
-}
-
-ui_duration() {
- local ms=$1
- if [ "$ms" -lt 60000 ]; then
- printf '%d.%ds' $((ms / 1000)) $((ms % 1000 / 100))
- else
- printf '%dm %ds' $((ms / 60000)) $((ms % 60000 / 1000))
- fi
-}
-
-ui_repeat() {
- local out="" i
- for ((i = 0; i < $2; i++)); do
- out+="$1"
- done
- printf '%s' "$out"
-}
-
-# Prints the current step line without the trailing status symbol
-ui_render_step() {
- local right="$1"
- local title="$UI_STEP_TITLE"
- local detail="$UI_STEP_DETAIL"
- local plain_detail="${detail//·/.}"
- local left=$((7 + ${#title}))
- local right_width=$((${#right} + 2))
-
- if [ -n "$detail" ] && [ $((left + 2 + ${#plain_detail} + right_width + 2)) -le "$UI_WIDTH" ]; then
- left=$((left + 2 + ${#plain_detail}))
- else
- detail=""
- fi
-
- local pad=$((UI_WIDTH - left - right_width))
- if [ "$pad" -lt 2 ]; then
- pad=2
- fi
- printf ' %s%-5s%s%s%s%s' "$C_DIM" "${UI_STEP}/${TOTAL_STEPS}" "$C_RESET" "$C_BOLD" "$title" "$C_RESET"
- if [ -n "$detail" ]; then
- printf ' %s%s%s' "$C_DIM" "$detail" "$C_RESET"
- fi
- printf '%*s%s%s%s ' "$pad" "" "$C_DIM" "$right" "$C_RESET"
-}
-
-ui_bar() {
- local percent=$1 label=$2
- local cells=$((UI_WIDTH - 30 > 40 ? 40 : UI_WIDTH - 30))
- local filled=$((percent * cells / 100))
- printf ' %s%s%s%s%s %s%3d%%%s %s%s%s' \
- "$C_PURPLE" "$(ui_repeat ■ "$filled")" "$C_DIM" "$(ui_repeat ■ $((cells - filled)))" "$C_RESET" \
- "$C_BOLD" "$percent" "$C_RESET" "$C_DIM" "$label" "$C_RESET"
-}
-
-# Runs in the background and animates the current step line and the progress bar below it
-ui_spinner() {
- set +x
- local frames=(◜ ◠ ◝ ◞ ◡ ◟) i=0 stop=false
- local line bar
- trap 'stop=true' TERM
- line=$(ui_render_step "")
- bar=$(ui_bar $(((UI_STEP - 1) * 100 / TOTAL_STEPS)) "${UI_STEP_TITLE,,}…")
- while [ "$stop" = false ]; do
- printf '\r\033[2K%s%s%s%s\n\033[2K%s\033[1A\r' "$line" "$C_PURPLE" "${frames[i]}" "$C_RESET" "$bar" >&3
- i=$(((i + 1) % ${#frames[@]}))
- sleep 0.12
- done
-}
-
-ui_stop_spinner() {
- if [ -n "$UI_SPINNER_PID" ]; then
- kill "$UI_SPINNER_PID" 2>/dev/null || true
- wait "$UI_SPINNER_PID" 2>/dev/null || true
- UI_SPINNER_PID=""
- fi
-}
-
-# Shows a warning below the current step (or right away when no step is running)
-warn() {
- echo "WARNING: $*"
- UI_WARNINGS+=("$*")
-}
-
-ui_flush_warnings() {
- local warning
- for warning in "${UI_WARNINGS[@]}"; do
- printf ' %s! %s%s\n' "$C_YELLOW" "$warning" "$C_RESET" >&3
- done
- UI_WARNINGS=()
-}
-
-step_start() {
- UI_STEP=$((UI_STEP + 1))
- UI_STEP_TITLE="$1"
- UI_STEP_DETAIL="${2:-}"
- UI_STEP_STARTED=$(ui_now)
- log_section "Step ${UI_STEP}/${TOTAL_STEPS}: $1"
- if [ "$UI_TTY" = true ]; then
- ui_spinner &
- UI_SPINNER_PID=$!
- fi
-}
-
-# Usage: step_done [detail] - optionally replaces the detail text shown next to the step title
-step_done() {
- if [ -n "${1:-}" ]; then
- UI_STEP_DETAIL="$1"
- fi
- ui_stop_spinner
- local elapsed
- elapsed=$(ui_duration $(($(ui_now) - UI_STEP_STARTED)))
- log "Step ${UI_STEP}/${TOTAL_STEPS} completed in ${elapsed}"
- if [ "$UI_TTY" = true ]; then
- printf '\r\033[2K' >&3
- fi
- printf '%s%s✓%s\n' "$(ui_render_step "$elapsed")" "$C_GREEN" "$C_RESET" >&3
- if [ "$UI_TTY" = true ]; then
- printf '\033[2K' >&3
- fi
- ui_flush_warnings
- UI_STEP_TITLE=""
-}
-
-ui_on_exit() {
- local code=$?
- ui_stop_spinner
- if [ "$code" -ne 0 ]; then
- if [ "$UI_TTY" = true ]; then
- printf '\r\033[2K' >&3
- fi
- if [ -n "$UI_STEP_TITLE" ]; then
- printf '%s%s✗%s\n' "$(ui_render_step "$(ui_duration $(($(ui_now) - UI_STEP_STARTED)))")" "$C_RED" "$C_RESET" >&3
- fi
- if [ "$UI_TTY" = true ]; then
- printf '\033[2K' >&3
- fi
- ui_flush_warnings
- printf '\n %s%sInstallation failed.%s Last lines of the log:\n\n' "$C_BOLD" "$C_RED" "$C_RESET" >&3
- tail -n 15 "$INSTALLATION_LOG_WITH_DATE" 2>/dev/null | sed 's/^/ /' >&3
- printf '\n %sFull log: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3
- fi
- if [ "$UI_TTY" = true ]; then
- printf '\033[?25h' >&3
- fi
-}
-
-# fd 3 is the terminal, stdout and stderr go to the log file
-exec 3>&1
-exec >>"$INSTALLATION_LOG_WITH_DATE" 2>&1
-trap ui_on_exit EXIT
-
-if [ "$UI_TTY" = true ]; then
- printf '\033[?25l' >&3
-fi
-printf '\n %sWelcome to Coolify Installer!%s\n' "$C_BOLD" "$C_RESET" >&3
-printf ' %sThis script will install everything for you. Sit back and relax.%s\n' "$C_DIM" "$C_RESET" >&3
-printf ' %sLog file: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3
-UI_INSTALL_STARTED=$(ui_now)
-
-echo "=========================================="
-echo " Coolify Installation - ${DATE}"
-echo "=========================================="
-echo "Source code: https://github.com/coollabsio/coolify/blob/v4.x/scripts/install.sh"
-
-# Predefined root user
-ROOT_USERNAME=${ROOT_USERNAME:-}
-ROOT_USER_EMAIL=${ROOT_USER_EMAIL:-}
-ROOT_USER_PASSWORD=${ROOT_USER_PASSWORD:-}
-
-if [ -n "${REGISTRY_URL+x}" ]; then
- echo "Using registry URL from environment variable: $REGISTRY_URL"
-else
- if [ -f "$ENV_FILE" ] && grep -q "^REGISTRY_URL=" "$ENV_FILE"; then
- REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2)
- echo "Using registry URL from .env: $REGISTRY_URL"
- else
- REGISTRY_URL="docker.io"
- echo "Using default registry URL: $REGISTRY_URL"
- fi
-fi
-
-# Docker address pool configuration defaults
-DOCKER_ADDRESS_POOL_BASE_DEFAULT="10.0.0.0/8"
-DOCKER_ADDRESS_POOL_SIZE_DEFAULT=24
-
-# Check if environment variables were explicitly provided
-DOCKER_POOL_BASE_PROVIDED=false
-DOCKER_POOL_SIZE_PROVIDED=false
-DOCKER_POOL_FORCE_OVERRIDE=${DOCKER_POOL_FORCE_OVERRIDE:-false}
-
-if [ -n "${DOCKER_ADDRESS_POOL_BASE+x}" ]; then
- DOCKER_POOL_BASE_PROVIDED=true
-fi
-
-if [ -n "${DOCKER_ADDRESS_POOL_SIZE+x}" ]; then
- DOCKER_POOL_SIZE_PROVIDED=true
-fi
-
-# OpenRC (Alpine) returns from "service docker start" before the daemon accepts connections
-wait_for_docker_daemon() {
- for _ in $(seq 1 30); do
- if docker info >/dev/null 2>&1; then
- return 0
- fi
- sleep 1
- done
- return 1
-}
-
-restart_docker_service() {
- # Check if systemctl is available
- if command -v systemctl >/dev/null 2>&1; then
- if systemctl restart docker && wait_for_docker_daemon; then
- echo " - Docker daemon restarted successfully"
- else
- echo " - Failed to restart Docker daemon"
- return 1
- fi
- # Check if service command is available
- elif command -v service >/dev/null 2>&1; then
- if service docker restart && wait_for_docker_daemon; then
- echo " - Docker daemon restarted successfully"
- else
- echo " - Failed to restart Docker daemon"
- return 1
- fi
- # If neither systemctl nor service is available
- else
- echo " - Error: No service management system found"
- return 1
- fi
-}
-
-# Function to compare address pools
-compare_address_pools() {
- local base1="$1"
- local size1="$2"
- local base2="$3"
- local size2="$4"
-
- # Normalize CIDR notation for comparison
- local ip1=$(echo "$base1" | cut -d'/' -f1)
- local prefix1=$(echo "$base1" | cut -d'/' -f2)
- local ip2=$(echo "$base2" | cut -d'/' -f1)
- local prefix2=$(echo "$base2" | cut -d'/' -f2)
-
- # Compare IPs and prefixes
- if [ "$ip1" = "$ip2" ] && [ "$prefix1" = "$prefix2" ] && [ "$size1" = "$size2" ]; then
- return 0 # Pools are the same
- else
- return 1 # Pools are different
- fi
-}
-
-# Docker address pool configuration
-DOCKER_ADDRESS_POOL_BASE=${DOCKER_ADDRESS_POOL_BASE:-"$DOCKER_ADDRESS_POOL_BASE_DEFAULT"}
-DOCKER_ADDRESS_POOL_SIZE=${DOCKER_ADDRESS_POOL_SIZE:-$DOCKER_ADDRESS_POOL_SIZE_DEFAULT}
-
-# Load Docker address pool configuration from .env file if it exists and environment variables were not provided
-if [ -f "/data/coolify/source/.env" ] && [ "$DOCKER_POOL_BASE_PROVIDED" = false ] && [ "$DOCKER_POOL_SIZE_PROVIDED" = false ]; then
- ENV_DOCKER_ADDRESS_POOL_BASE=$(grep -E "^DOCKER_ADDRESS_POOL_BASE=" /data/coolify/source/.env | cut -d '=' -f2 || true)
- ENV_DOCKER_ADDRESS_POOL_SIZE=$(grep -E "^DOCKER_ADDRESS_POOL_SIZE=" /data/coolify/source/.env | cut -d '=' -f2 || true)
-
- if [ -n "$ENV_DOCKER_ADDRESS_POOL_BASE" ]; then
- DOCKER_ADDRESS_POOL_BASE="$ENV_DOCKER_ADDRESS_POOL_BASE"
- fi
-
- if [ -n "$ENV_DOCKER_ADDRESS_POOL_SIZE" ]; then
- DOCKER_ADDRESS_POOL_SIZE="$ENV_DOCKER_ADDRESS_POOL_SIZE"
- fi
-fi
-
-# Check if daemon.json exists and extract existing address pool configuration
-EXISTING_POOL_CONFIGURED=false
-if [ -f /etc/docker/daemon.json ]; then
- if jq -e '.["default-address-pools"]' /etc/docker/daemon.json >/dev/null 2>&1; then
- EXISTING_POOL_BASE=$(jq -r '.["default-address-pools"][0].base' /etc/docker/daemon.json 2>/dev/null || true)
- EXISTING_POOL_SIZE=$(jq -r '.["default-address-pools"][0].size' /etc/docker/daemon.json 2>/dev/null || true)
-
- if [ -n "$EXISTING_POOL_BASE" ] && [ -n "$EXISTING_POOL_SIZE" ] && [ "$EXISTING_POOL_BASE" != "null" ] && [ "$EXISTING_POOL_SIZE" != "null" ]; then
- echo "Found existing Docker network pool: $EXISTING_POOL_BASE/$EXISTING_POOL_SIZE"
- EXISTING_POOL_CONFIGURED=true
-
- # Check if environment variables were explicitly provided
- if [ "$DOCKER_POOL_BASE_PROVIDED" = false ] && [ "$DOCKER_POOL_SIZE_PROVIDED" = false ]; then
- DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE"
- DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE"
- else
- # Check if force override is enabled
- if [ "$DOCKER_POOL_FORCE_OVERRIDE" = true ]; then
- echo "Force override enabled - network pool will be updated with $DOCKER_ADDRESS_POOL_BASE/$DOCKER_ADDRESS_POOL_SIZE."
- else
- warn "Custom Docker network pool ignored, using the existing $EXISTING_POOL_BASE/$EXISTING_POOL_SIZE. Set DOCKER_POOL_FORCE_OVERRIDE=true to override it (existing networks are not changed)."
- DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE"
- DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE"
- DOCKER_POOL_BASE_PROVIDED=false
- DOCKER_POOL_SIZE_PROVIDED=false
- fi
- fi
- fi
- fi
-fi
-
-# Validate Docker address pool configuration
-if ! [[ $DOCKER_ADDRESS_POOL_BASE =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]]; then
- warn "Invalid Docker network pool base: $DOCKER_ADDRESS_POOL_BASE"
- if [ "$EXISTING_POOL_CONFIGURED" = true ]; then
- echo "Using existing configuration: $EXISTING_POOL_BASE"
- DOCKER_ADDRESS_POOL_BASE="$EXISTING_POOL_BASE"
- else
- echo "Using default configuration: $DOCKER_ADDRESS_POOL_BASE_DEFAULT"
- DOCKER_ADDRESS_POOL_BASE="$DOCKER_ADDRESS_POOL_BASE_DEFAULT"
- fi
-fi
-
-if ! [[ $DOCKER_ADDRESS_POOL_SIZE =~ ^[0-9]+$ ]] || [ "$DOCKER_ADDRESS_POOL_SIZE" -lt 16 ] || [ "$DOCKER_ADDRESS_POOL_SIZE" -gt 28 ]; then
- warn "Invalid Docker network pool size: $DOCKER_ADDRESS_POOL_SIZE (must be 16-28)"
- if [ "$EXISTING_POOL_CONFIGURED" = true ]; then
- echo "Using existing configuration: $EXISTING_POOL_SIZE"
- DOCKER_ADDRESS_POOL_SIZE="$EXISTING_POOL_SIZE"
- else
- echo "Using default configuration: $DOCKER_ADDRESS_POOL_SIZE_DEFAULT"
- DOCKER_ADDRESS_POOL_SIZE=$DOCKER_ADDRESS_POOL_SIZE_DEFAULT
- fi
-fi
-
-TOTAL_SPACE=$(df -BG / | awk 'NR==2 {print $2}' | sed 's/G//')
-AVAILABLE_SPACE=$(df -BG / | awk 'NR==2 {print $4}' | sed 's/G//')
-REQUIRED_TOTAL_SPACE=30
-REQUIRED_AVAILABLE_SPACE=20
-WARNING_SPACE=false
-
-if [ "$TOTAL_SPACE" -lt "$REQUIRED_TOTAL_SPACE" ]; then
- WARNING_SPACE=true
- warn "Insufficient total disk space: ${TOTAL_SPACE}GB (required: ${REQUIRED_TOTAL_SPACE}GB)"
-fi
-
-if [ "$AVAILABLE_SPACE" -lt "$REQUIRED_AVAILABLE_SPACE" ]; then
- warn "Insufficient available disk space: ${AVAILABLE_SPACE}GB (required: ${REQUIRED_AVAILABLE_SPACE}GB)"
- WARNING_SPACE=true
-fi
-
-if [ "$WARNING_SPACE" = true ]; then
- ui_flush_warnings
- echo "Sleeping for 5 seconds."
- sleep 5
-fi
-
-# Helper function to log with timestamp
-log() {
- echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
-}
-
-# Helper function to log section headers
-log_section() {
- echo ""
- echo "============================================================"
- echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
- echo "============================================================"
-}
-
-# Helper function to check if all required packages are installed
-all_packages_installed() {
- for pkg in curl wget git jq openssl; do
- if ! command -v "$pkg" >/dev/null 2>&1; then
- return 1
- fi
- done
- return 0
-}
-
-# Check if the OS is manjaro, if so, change it to arch
-if [ "$OS_TYPE" = "manjaro" ] || [ "$OS_TYPE" = "manjaro-arm" ]; then
- OS_TYPE="arch"
-fi
-
-# Check if the OS is Endeavour OS, if so, change it to arch
-if [ "$OS_TYPE" = "endeavouros" ]; then
- OS_TYPE="arch"
-fi
-
-# Check if the OS is Cachy OS, if so, change it to arch
-if [ "$OS_TYPE" = "cachyos" ]; then
- OS_TYPE="arch"
-fi
-
-# Check if the OS is Asahi Linux, if so, change it to fedora
-if [ "$OS_TYPE" = "fedora-asahi-remix" ]; then
- OS_TYPE="fedora"
-fi
-
-# Check if the OS is popOS, if so, change it to ubuntu
-if [ "$OS_TYPE" = "pop" ]; then
- OS_TYPE="ubuntu"
-fi
-
-# Check if the OS is linuxmint, if so, change it to ubuntu
-if [ "$OS_TYPE" = "linuxmint" ]; then
- OS_TYPE="ubuntu"
-fi
-
-#Check if the OS is zorin, if so, change it to ubuntu
-if [ "$OS_TYPE" = "zorin" ]; then
- OS_TYPE="ubuntu"
-fi
-
-if [ "$OS_TYPE" = "arch" ] || [ "$OS_TYPE" = "archarm" ]; then
- OS_VERSION="rolling"
-else
- OS_VERSION=$(grep -w "VERSION_ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
-fi
-
-# Install xargs on Amazon Linux 2023 - lol
-if [ "$OS_TYPE" = 'amzn' ]; then
- dnf install -y findutils >/dev/null
-fi
-
-# Fetch versions.json once and parse all values from it
-VERSIONS_JSON=$(curl -L --silent $CDN/versions.json)
-LATEST_VERSION=$(echo "$VERSIONS_JSON" | grep -i version | xargs | awk '{print $2}' | tr -d ',')
-LATEST_HELPER_VERSION=$(echo "$VERSIONS_JSON" | grep -i version | xargs | awk '{print $6}' | tr -d ',')
-
-if [ -z "$LATEST_HELPER_VERSION" ]; then
- LATEST_HELPER_VERSION=latest
-fi
-
-case "$OS_TYPE" in
-arch | ubuntu | debian | raspbian | centos | fedora | rhel | ol | rocky | sles | opensuse-leap | opensuse-tumbleweed | almalinux | amzn | alpine | postmarketos | tencentos) ;;
-*)
- echo "This script only supports Debian, Redhat, Arch Linux, Alpine Linux, or SLES based operating systems for now."
- exit 1
- ;;
-esac
-
-# Overwrite LATEST_VERSION if user pass a version number
-if [ "$1" != "" ]; then
- LATEST_VERSION=$1
- LATEST_VERSION="${LATEST_VERSION,,}"
- LATEST_VERSION="${LATEST_VERSION#v}"
-fi
-
-echo "---------------------------------------------"
-echo "| Operating System | $OS_TYPE $OS_VERSION"
-echo "| Docker | $DOCKER_VERSION"
-echo "| Coolify | $LATEST_VERSION"
-echo "| Helper | $LATEST_HELPER_VERSION"
-echo "| Docker Pool | $DOCKER_ADDRESS_POOL_BASE (size $DOCKER_ADDRESS_POOL_SIZE)"
-echo "| Registry URL | $REGISTRY_URL"
-echo "---------------------------------------------"
-
-ui_flush_warnings
-step_start "Installing required packages" "curl wget git jq openssl"
-
-# Track if apt-get update was run to avoid redundant calls later
-APT_UPDATED=false
-
-if all_packages_installed; then
- log "All required packages already installed, skipping installation"
- echo " - All required packages already installed."
- UI_STEP_DETAIL="already installed"
-else
- case "$OS_TYPE" in
- arch)
- pacman -Sy --noconfirm --needed curl wget git jq openssl >/dev/null || true
- ;;
- alpine | postmarketos)
- sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories
- apk update >/dev/null
- apk add curl wget git jq openssl >/dev/null
- ;;
- ubuntu | debian | raspbian)
- apt-get update -y >/dev/null
- APT_UPDATED=true
- apt-get install -y curl wget git jq openssl >/dev/null
- ;;
- centos | fedora | rhel | ol | rocky | almalinux | amzn | tencentos)
- if [ "$OS_TYPE" = "amzn" ]; then
- dnf install -y wget git jq openssl >/dev/null
- else
- if ! command -v dnf >/dev/null; then
- yum install -y dnf >/dev/null
- fi
- if ! command -v curl >/dev/null; then
- dnf install -y curl >/dev/null
- fi
- dnf install -y wget git jq openssl >/dev/null
- fi
- ;;
- sles | opensuse-leap | opensuse-tumbleweed)
- zypper refresh >/dev/null
- zypper install -y curl wget git jq openssl >/dev/null
- ;;
- *)
- echo "This script only supports Debian, Redhat, Arch Linux, or SLES based operating systems for now."
- exit 1
- ;;
- esac
- log "Required packages installed successfully"
-fi
-step_done
-
-step_start "Checking OpenSSH server configuration"
-
-# Detect OpenSSH server
-SSH_DETECTED=false
-if [ -x "$(command -v systemctl)" ]; then
- if systemctl status sshd >/dev/null 2>&1; then
- echo " - OpenSSH server is installed."
- SSH_DETECTED=true
- elif systemctl status ssh >/dev/null 2>&1; then
- echo " - OpenSSH server is installed."
- SSH_DETECTED=true
- fi
-elif [ -x "$(command -v service)" ]; then
- if service sshd status >/dev/null 2>&1; then
- echo " - OpenSSH server is installed."
- SSH_DETECTED=true
- elif service ssh status >/dev/null 2>&1; then
- echo " - OpenSSH server is installed."
- SSH_DETECTED=true
- fi
-fi
-
-if [ "$SSH_DETECTED" = "false" ]; then
- echo " - OpenSSH server not detected. Installing OpenSSH server."
- case "$OS_TYPE" in
- arch)
- pacman -Sy --noconfirm openssh >/dev/null
- systemctl enable sshd >/dev/null 2>&1
- systemctl start sshd >/dev/null 2>&1
- ;;
- alpine | postmarketos)
- apk add openssh >/dev/null
- rc-update add sshd default >/dev/null 2>&1
- service sshd start >/dev/null 2>&1
- ;;
- ubuntu | debian | raspbian)
- if [ "$APT_UPDATED" = false ]; then
- apt-get update -y >/dev/null
- APT_UPDATED=true
- fi
- apt-get install -y openssh-server >/dev/null
- systemctl enable ssh >/dev/null 2>&1
- systemctl start ssh >/dev/null 2>&1
- ;;
- centos | fedora | rhel | ol | rocky | almalinux | amzn | tencentos)
- if [ "$OS_TYPE" = "amzn" ]; then
- dnf install -y openssh-server >/dev/null
- else
- dnf install -y openssh-server >/dev/null
- fi
- systemctl enable sshd >/dev/null 2>&1
- systemctl start sshd >/dev/null 2>&1
- ;;
- sles | opensuse-leap | opensuse-tumbleweed)
- zypper install -y openssh >/dev/null
- systemctl enable sshd >/dev/null 2>&1
- systemctl start sshd >/dev/null 2>&1
- ;;
- *)
- echo "###############################################################################"
- echo "WARNING: Could not detect and install OpenSSH server - this does not mean that it is not installed or not running, just that we could not detect it."
- echo -e "Please make sure it is installed and running, otherwise Coolify cannot connect to the host system. \n"
- echo "###############################################################################"
- exit 1
- ;;
- esac
- echo " - OpenSSH server installed successfully."
- SSH_DETECTED=true
-fi
-
-# Detect SSH PermitRootLogin
-SSH_PERMIT_ROOT_LOGIN=$(sshd -T | grep -i "permitrootlogin" | awk '{print $2}') || true
-if [ "$SSH_PERMIT_ROOT_LOGIN" = "yes" ] || [ "$SSH_PERMIT_ROOT_LOGIN" = "without-password" ] || [ "$SSH_PERMIT_ROOT_LOGIN" = "prohibit-password" ]; then
- echo " - SSH PermitRootLogin is enabled."
-else
- warn "SSH PermitRootLogin is disabled. If you have problems with SSH, read https://coolify.io/docs/knowledge-base/server/openssh"
-fi
-
-# Detect if docker is installed via snap
-if [ -x "$(command -v snap)" ]; then
- SNAP_DOCKER_INSTALLED=$(snap list docker >/dev/null 2>&1 && echo "true" || echo "false")
- if [ "$SNAP_DOCKER_INSTALLED" = "true" ]; then
- echo "Docker is installed via snap."
- echo " Please note that Coolify does not support Docker installed via snap."
- echo " Please remove Docker with snap (snap remove docker) and reexecute this script."
- exit 1
- fi
-fi
-
-install_docker() {
- set +e
- curl -fsSL https://get.docker.com | sh 2>&1 || true
- if ! [ -x "$(command -v docker)" ]; then
- echo "Automated Docker installation failed. Trying manual installation."
- install_docker_manually
- fi
- set -e
-}
-
-install_docker_manually() {
- case "$OS_TYPE" in
- "ubuntu" | "debian" | "raspbian")
- if [ "$APT_UPDATED" = false ]; then
- apt-get update
- APT_UPDATED=true
- fi
- apt-get install -y ca-certificates curl
- install -m 0755 -d /etc/apt/keyrings
- curl -fsSL https://download.docker.com/linux/$OS_TYPE/gpg -o /etc/apt/keyrings/docker.asc
- chmod a+r /etc/apt/keyrings/docker.asc
-
- # Add the repository to Apt sources
- echo \
- "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$OS_TYPE \
- $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" |
- tee /etc/apt/sources.list.d/docker.list
- apt-get update
- apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
- ;;
- *)
- exit 1
- ;;
- esac
- if ! [ -x "$(command -v docker)" ]; then
- echo "Docker installation failed."
- echo " Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
- exit 1
- else
- echo "Docker installed successfully."
- fi
-}
-
-install_docker_from_rhel_repo() {
- echo " - Installing Docker from the RHEL repository for Rocky Linux..."
- rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo
- dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
- dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
- systemctl --now enable docker
-}
-
-step_done
-
-if ! [ -x "$(command -v docker)" ]; then
- step_start "Installing Docker Engine"
- echo " - Docker is not installed. Installing Docker. It may take a while."
- case "$OS_TYPE" in
- "alpine" | "postmarketos")
- apk add docker docker-cli-compose >/dev/null 2>&1
- rc-update add docker default >/dev/null 2>&1
- service docker start >/dev/null 2>&1
- if ! [ -x "$(command -v docker)" ]; then
- echo " - Failed to install Docker with apk. Try to install it manually."
- echo " Please visit https://wiki.alpinelinux.org/wiki/Docker for more information."
- exit 1
- fi
- ;;
- "arch")
- pacman -Syu --noconfirm --needed docker docker-compose >/dev/null 2>&1
- systemctl enable docker.service >/dev/null 2>&1
- systemctl start docker.service >/dev/null 2>&1
- if ! [ -x "$(command -v docker)" ]; then
- echo " - Failed to install Docker with pacman. Try to install it manually."
- echo " Please visit https://wiki.archlinux.org/title/docker for more information."
- exit 1
- fi
- ;;
- "amzn")
- dnf install docker -y >/dev/null 2>&1
- DOCKER_CONFIG=${DOCKER_CONFIG:-/usr/local/lib/docker}
- mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1
- curl -fsSL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
- chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
- systemctl start docker >/dev/null 2>&1
- systemctl enable docker >/dev/null 2>&1
- if ! [ -x "$(command -v docker)" ]; then
- echo " - Failed to install Docker with dnf. Try to install it manually."
- echo " Please visit https://www.cyberciti.biz/faq/how-to-install-docker-on-amazon-linux-2/ for more information."
- exit 1
- fi
- ;;
- "rocky")
- install_docker_from_rhel_repo
- if ! [ -x "$(command -v docker)" ]; then
- echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
- exit 1
- fi
- ;;
- "almalinux" | "tencentos")
- dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
- dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
- systemctl start docker >/dev/null 2>&1
- systemctl enable docker >/dev/null 2>&1
- if ! [ -x "$(command -v docker)" ]; then
- echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
- exit 1
- fi
- ;;
- "ubuntu" | "debian" | "raspbian" | "centos" | "fedora" | "rhel" | "sles")
- install_docker
- if ! [ -x "$(command -v docker)" ]; then
- echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
- exit 1
- fi
- ;;
- *)
- install_docker
- if ! [ -x "$(command -v docker)" ]; then
- echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
- exit 1
- fi
- ;;
- esac
- echo " - Docker installed successfully."
-else
- step_start "Checking Docker Engine"
- echo " - Docker is installed."
-fi
-
-# Verify minimum Docker version
-MIN_DOCKER_VERSION=24
-wait_for_docker_daemon || true
-INSTALLED_DOCKER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1 || true)
-if [ -z "$INSTALLED_DOCKER_VERSION" ]; then
- warn "Could not determine Docker version. Please ensure Docker $MIN_DOCKER_VERSION+ is installed."
-elif [ "$INSTALLED_DOCKER_VERSION" -lt "$MIN_DOCKER_VERSION" ]; then
- echo " - ERROR: Docker version $INSTALLED_DOCKER_VERSION is too old. Coolify requires Docker $MIN_DOCKER_VERSION or newer."
- echo " Please upgrade Docker: https://docs.docker.com/engine/install/"
- exit 1
-else
- echo " - Docker version $(docker version --format '{{.Server.Version}}' 2>/dev/null) meets minimum requirement ($MIN_DOCKER_VERSION+)."
-fi
-DOCKER_SERVER_VERSION=$(docker version --format '{{.Server.Version}}' 2>/dev/null || true)
-step_done "${DOCKER_SERVER_VERSION:+v$DOCKER_SERVER_VERSION}"
-
-step_start "Configuring Docker daemon" "log rotation · address pools"
-
-# Changing daemon.json needs a Docker restart, which stops every running container.
-# Coolify sets log rotation and the network pool only when no pool is configured or DOCKER_POOL_FORCE_OVERRIDE=true,
-# keeps all other keys, and does not restart a Docker daemon with running containers unless DOCKER_POOL_FORCE_OVERRIDE=true.
-if [ "$DOCKER_POOL_FORCE_OVERRIDE" = true ] || [ "$EXISTING_POOL_CONFIGURED" = false ]; then
- mkdir -p /etc/docker
- CURRENT_DAEMON_CONFIG='{}'
- if [ -s /etc/docker/daemon.json ] && ! CURRENT_DAEMON_CONFIG=$(jq -S . /etc/docker/daemon.json 2>/dev/null); then
- CURRENT_DAEMON_CONFIG=""
- fi
- if [ -z "$CURRENT_DAEMON_CONFIG" ]; then
- warn "Docker daemon configuration not changed: /etc/docker/daemon.json is not valid JSON."
- else
- NEW_DAEMON_CONFIG=$(jq -S --arg base "$DOCKER_ADDRESS_POOL_BASE" --argjson size "$DOCKER_ADDRESS_POOL_SIZE" \
- '. * {"log-driver": "json-file", "log-opts": {"max-size": "10m", "max-file": "3"}, "default-address-pools": [{"base": $base, "size": $size}]}' <<<"$CURRENT_DAEMON_CONFIG")
- RUNNING_CONTAINERS=$({ docker ps -q 2>/dev/null || true; } | wc -l)
- if [ "$NEW_DAEMON_CONFIG" = "$CURRENT_DAEMON_CONFIG" ]; then
- echo " - Configuration is up to date"
- elif [ "$RUNNING_CONTAINERS" -gt 0 ] && [ "$DOCKER_POOL_FORCE_OVERRIDE" != true ]; then
- warn "Docker daemon configuration not changed: log rotation and the ${DOCKER_ADDRESS_POOL_BASE} (size ${DOCKER_ADDRESS_POOL_SIZE}) network pool need a Docker restart, which stops ${RUNNING_CONTAINERS} running container(s) and can change the docker0 bridge IP. To apply it, run the installer again with DOCKER_POOL_FORCE_OVERRIDE=true."
- UI_STEP_DETAIL="skipped · containers running"
- else
- if [ -f /etc/docker/daemon.json ]; then
- cp /etc/docker/daemon.json /etc/docker/daemon.json.original-"$DATE"
- fi
- printf '%s\n' "$NEW_DAEMON_CONFIG" >/etc/docker/daemon.json
- echo " - Network pool configuration: ${DOCKER_ADDRESS_POOL_BASE} (size ${DOCKER_ADDRESS_POOL_SIZE})"
- echo " - Configuration updated - restarting Docker daemon..."
- restart_docker_service
- fi
- fi
-else
- echo " - Using the existing network pool ${DOCKER_ADDRESS_POOL_BASE} (size ${DOCKER_ADDRESS_POOL_SIZE})"
- echo " - To override it: DOCKER_POOL_FORCE_OVERRIDE=true"
-fi
-
-step_done
-
-step_start "Downloading required files from CDN"
-log "Downloading configuration files in parallel..."
-
-# Download files in parallel for faster installation
-curl -fsSL -L $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml &
-PID1=$!
-curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml &
-PID2=$!
-curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production &
-PID3=$!
-curl -fsSL -L $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh &
-PID4=$!
-curl -fsSL -L $CDN/upgrade-postgres.sh -o /data/coolify/source/upgrade-postgres.sh &
-PID5=$!
-
-# Wait for all downloads to complete and check for errors
-DOWNLOAD_FAILED=false
-for PID in $PID1 $PID2 $PID3 $PID4 $PID5; do
- if ! wait $PID; then
- DOWNLOAD_FAILED=true
- fi
-done
-
-if [ "$DOWNLOAD_FAILED" = true ]; then
- echo " - ERROR: One or more downloads failed. Please check your network connection."
- exit 1
-fi
-
-chmod +x /data/coolify/source/upgrade.sh /data/coolify/source/upgrade-postgres.sh
-log "All configuration files downloaded successfully"
-step_done
-
-step_start "Setting up environment variables"
-
-if [ -f "$ENV_FILE" ]; then
- # If .env exists, create backup
- echo " - Creating backup of existing .env file to .env-$DATE"
- cp "$ENV_FILE" "$ENV_FILE-$DATE"
- # Merge .env.production values into .env
- echo " - Merging .env.production values into .env"
- awk -F '=' '!seen[$1]++' "$ENV_FILE" "/data/coolify/source/.env.production" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
- echo " - .env file merged successfully"
-else
- # If no .env exists, copy .env.production to .env
- echo " - No .env file found, copying .env.production to .env"
- cp "/data/coolify/source/.env.production" "$ENV_FILE"
-fi
-log "Environment file setup completed"
-
-update_env_var() {
- local key="$1"
- local value="$2"
-
- # If variable "key=" exists but has no value, update the value of the existing line
- if grep -q "^${key}=$" "$ENV_FILE"; then
- # awk reads the value from the environment, so characters such as | & \ in it stay literal
- KEY="$key" VALUE="$value" awk '$0 == ENVIRON["KEY"] "=" { $0 = ENVIRON["KEY"] "=" ENVIRON["VALUE"] } { print }' "$ENV_FILE" >"$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
- echo " - Updated value of ${key} as the current value was empty"
- # If variable "key=" doesn't exist, append it to the file with value
- elif ! grep -q "^${key}=" "$ENV_FILE"; then
- printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
- echo " - Added ${key} and its value as the variable was missing"
- fi
-}
-
-# Quote a user-supplied value so that Docker Compose and phpdotenv both read it back unchanged
-# (spaces, #, $ and quotes otherwise break parsing or change the value).
-quote_env_value() {
- local value="$1"
- case "$value" in
- *\'*)
- value=${value//\\/\\\\}
- value=${value//\"/\\\"}
- value=${value//\$/\\\$}
- printf '"%s"' "$value"
- ;;
- *)
- printf "'%s'" "$value"
- ;;
- esac
-}
-
-update_env_var "APP_ID" "$(openssl rand -hex 16)"
-update_env_var "APP_KEY" "base64:$(openssl rand -base64 32)"
-# update_env_var "DB_USERNAME" "$(openssl rand -hex 16)" # Causes issues: database "random-user" does not exist
-update_env_var "DB_PASSWORD" "$(openssl rand -base64 32)"
-update_env_var "REDIS_PASSWORD" "$(openssl rand -base64 32)"
-update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
-update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
-update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)"
-update_env_var "PUSHER_BACKEND_PORT" "6001"
-
-# Add default root user credentials from environment variables (ROOT_USERNAME is optional)
-if [ -n "$ROOT_USER_EMAIL" ] && [ -n "$ROOT_USER_PASSWORD" ]; then
- echo " - Setting predefined root user credentials from environment"
- if [ -n "$ROOT_USERNAME" ]; then
- update_env_var "ROOT_USERNAME" "$(quote_env_value "$ROOT_USERNAME")"
- fi
- update_env_var "ROOT_USER_EMAIL" "$(quote_env_value "$ROOT_USER_EMAIL")"
- update_env_var "ROOT_USER_PASSWORD" "$(quote_env_value "$ROOT_USER_PASSWORD")"
-elif [ -n "$ROOT_USERNAME" ] || [ -n "$ROOT_USER_EMAIL" ] || [ -n "$ROOT_USER_PASSWORD" ]; then
- warn "Predefined root user skipped: set both ROOT_USER_EMAIL and ROOT_USER_PASSWORD."
-fi
-
-if [ -n "${REGISTRY_URL+x}" ]; then
- # Only update if REGISTRY_URL was explicitly provided
- update_env_var "REGISTRY_URL" "$REGISTRY_URL"
-fi
-
-if [ "$AUTOUPDATE" = "false" ]; then
- update_env_var "AUTOUPDATE" "false"
-fi
-
-if [ "$DOCKER_POOL_BASE_PROVIDED" = true ]; then
- update_env_var "DOCKER_ADDRESS_POOL_BASE" "$DOCKER_ADDRESS_POOL_BASE"
-else
- # Add with default value if missing
- if ! grep -q "^DOCKER_ADDRESS_POOL_BASE=" "$ENV_FILE"; then
- update_env_var "DOCKER_ADDRESS_POOL_BASE" "$DOCKER_ADDRESS_POOL_BASE"
- fi
-fi
-
-if [ "$DOCKER_POOL_SIZE_PROVIDED" = true ]; then
- update_env_var "DOCKER_ADDRESS_POOL_SIZE" "$DOCKER_ADDRESS_POOL_SIZE"
-else
- # Add with default value if missing
- if ! grep -q "^DOCKER_ADDRESS_POOL_SIZE=" "$ENV_FILE"; then
- update_env_var "DOCKER_ADDRESS_POOL_SIZE" "$DOCKER_ADDRESS_POOL_SIZE"
- fi
-fi
-log "Environment variables check completed"
-step_done
-
-step_start "Checking SSH key for localhost access"
-SSH_KEY_DETAIL="existing key"
-create_coolify_directories
-if [ ! -f ~/.ssh/authorized_keys ]; then
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- touch ~/.ssh/authorized_keys
- chmod 600 ~/.ssh/authorized_keys
-fi
-
-set +e
-IS_COOLIFY_VOLUME_EXISTS=$(docker volume ls | grep coolify-db | wc -l)
-set -e
-
-if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then
- echo " - Generating SSH key."
- SSH_KEY_DETAIL="generated new key"
- test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal
- test -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub && rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub
- ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify
- chown 9999 /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal
- # Remove only the key from a previous install (comment "coolify", or "root@coolify" before September 2024), not other keys that contain "coolify"
- sed -i -E "/[[:space:]](root@)?coolify[[:space:]]*$/d" ~/.ssh/authorized_keys
- cat /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub >>~/.ssh/authorized_keys
- rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub
-fi
-
-set_coolify_directory_permissions
-log "SSH key check completed"
-step_done "$SSH_KEY_DETAIL"
-
-step_start "Pulling images" "coolify · postgres · redis · helper"
-# fd 3 is closed so the detached container restart in upgrade.sh does not hold the terminal open
-if [[ $- == *x* ]]; then
- bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" 3>&-
-else
- bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true" 3>&-
-fi
-step_done
-
-step_start "Starting Coolify" "v${LATEST_VERSION}"
-echo " - Waiting for Coolify to be ready..."
-
-# Wait for upgrade.sh background process to complete
-# upgrade.sh writes status to /data/coolify/source/.upgrade-status
-# Status file format: step|message|timestamp
-# Step 6 = "Upgrade complete", file deleted 10 seconds after
-UPGRADE_STATUS_FILE="/data/coolify/source/.upgrade-status"
-MAX_WAIT=180
-WAITED=0
-SEEN_STATUS_FILE=false
-
-while [ $WAITED -lt $MAX_WAIT ]; do
- if [ -f "$UPGRADE_STATUS_FILE" ]; then
- SEEN_STATUS_FILE=true
- STATUS=$(cat "$UPGRADE_STATUS_FILE" 2>/dev/null | cut -d'|' -f1)
- MESSAGE=$(cat "$UPGRADE_STATUS_FILE" 2>/dev/null | cut -d'|' -f2)
- if [ "$STATUS" = "6" ]; then
- log "Upgrade completed: $MESSAGE"
- echo " - Upgrade complete!"
- break
- elif [ "$STATUS" = "error" ]; then
- echo " - ERROR: Upgrade failed: $MESSAGE"
- echo " - Please check the upgrade logs: /data/coolify/source/upgrade-*.log"
- exit 1
- else
- if [ $((WAITED % 10)) -eq 0 ]; then
- echo " - Upgrade in progress: $MESSAGE (${WAITED}s)"
- fi
- fi
- else
- # Status file doesn't exist
- if [ "$SEEN_STATUS_FILE" = true ]; then
- # We saw the file before, now it's gone = upgrade completed and cleaned up
- log "Upgrade status file cleaned up - upgrade complete"
- echo " - Upgrade complete!"
- break
- fi
- # Haven't seen status file yet - either very early or upgrade.sh hasn't started
- if [ $((WAITED % 10)) -eq 0 ] && [ $WAITED -gt 0 ]; then
- echo " - Waiting for upgrade process to start... (${WAITED}s)"
- fi
- fi
- sleep 2
- WAITED=$((WAITED + 2))
-done
-
-if [ $WAITED -ge $MAX_WAIT ]; then
- if [ "$SEEN_STATUS_FILE" = false ]; then
- # Never saw status file - fallback to old behavior (wait 20s + health check)
- log "Status file not found, using fallback wait"
- echo " - Status file not found, waiting 20 seconds..."
- sleep 20
- else
- echo " - ERROR: Upgrade timed out after ${MAX_WAIT}s"
- echo " - Please check the upgrade logs: /data/coolify/source/upgrade-*.log"
- exit 1
- fi
-fi
-
-# Final health verification - wait for container to be healthy
-echo " - Verifying Coolify is healthy..."
-HEALTH_WAIT=60
-HEALTH_WAITED=0
-while [ $HEALTH_WAITED -lt $HEALTH_WAIT ]; do
- HEALTH=$(docker inspect --format='{{.State.Health.Status}}' coolify 2>/dev/null || echo "unknown")
- if [ "$HEALTH" = "healthy" ]; then
- log "Coolify container is healthy"
- echo " - Coolify is ready!"
- break
- fi
- sleep 2
- HEALTH_WAITED=$((HEALTH_WAITED + 2))
-done
-
-if [ "$HEALTH" != "healthy" ]; then
- echo " - ERROR: Coolify container is not healthy after ${HEALTH_WAIT}s. Status: $HEALTH"
- echo " - Please check: docker logs coolify"
- exit 1
-fi
-step_done
-
-if [ "$UI_TTY" = true ]; then
- printf '%s\n' "$(ui_bar 100 "done")" >&3
-fi
-
-# Fetch public IPs in parallel for faster completion
-IPV4_TMP=$(mktemp)
-IPV6_TMP=$(mktemp)
-curl -4s --max-time 5 https://ifconfig.io > "$IPV4_TMP" 2>/dev/null &
-IPV4_PID=$!
-curl -6s --max-time 5 https://ifconfig.io > "$IPV6_TMP" 2>/dev/null &
-IPV6_PID=$!
-wait $IPV4_PID 2>/dev/null || true
-wait $IPV6_PID 2>/dev/null || true
-IPV4_PUBLIC_IP=$(cat "$IPV4_TMP" 2>/dev/null || true)
-IPV6_PUBLIC_IP=$(cat "$IPV6_TMP" 2>/dev/null || true)
-rm -f "$IPV4_TMP" "$IPV6_TMP"
-
-set +e
-DEFAULT_PRIVATE_IP=$(ip route get 1 | sed -n 's/^.*src \([0-9.]*\) .*$/\1/p')
-PRIVATE_IPS=$(hostname -I 2>/dev/null || ip -o addr show scope global | awk '{print $4}' | cut -d/ -f1)
-set -e
-
-printf '\n %s%sCoolify is ready!%s %sInstalled in %s%s\n\n' "$C_BOLD" "$C_PURPLE" "$C_RESET" "$C_DIM" "$(ui_duration $(($(ui_now) - UI_INSTALL_STARTED)))" "$C_RESET" >&3
-if [ -n "$IPV4_PUBLIC_IP" ]; then
- printf ' Public IPv4 %shttp://%s:8000%s\n' "$C_BOLD" "$IPV4_PUBLIC_IP" "$C_RESET" >&3
-fi
-if [ -n "$IPV6_PUBLIC_IP" ]; then
- printf ' Public IPv6 %shttp://[%s]:8000%s\n' "$C_BOLD" "$IPV6_PUBLIC_IP" "$C_RESET" >&3
-fi
-
-PRIVATE_IP_LABEL="Private IP "
-for IP in $PRIVATE_IPS; do
- if [ "$IP" != "$DEFAULT_PRIVATE_IP" ]; then
- printf ' %s %shttp://%s:8000%s\n' "$PRIVATE_IP_LABEL" "$C_DIM" "$IP" "$C_RESET" >&3
- PRIVATE_IP_LABEL=" "
- fi
-done
-
-if [ "$IS_COOLIFY_VOLUME_EXISTS" -eq 0 ] && { [ -z "$ROOT_USER_EMAIL" ] || [ -z "$ROOT_USER_PASSWORD" ]; }; then
- printf '\n %s! Create the root account within 10 minutes. If the time ends, run %sdocker restart coolify%s%s and try again.%s\n' "$C_YELLOW" "$C_BOLD" "$C_RESET" "$C_YELLOW" "$C_RESET" >&3
-fi
-printf '\n %s! Back up %s/data/coolify/source/.env%s%s to a safe place outside this server (e.g. a password manager).%s\n' "$C_YELLOW" "$C_BOLD" "$C_RESET" "$C_YELLOW" "$C_RESET" >&3
-printf ' %sLog file: %s%s\n\n' "$C_DIM" "$INSTALLATION_LOG_WITH_DATE" "$C_RESET" >&3
-
-log_section "Installation Complete"
-log "Coolify installation completed successfully"
-log "Version: ${LATEST_VERSION}"
-log "Log file: ${INSTALLATION_LOG_WITH_DATE}"
diff --git a/other/nightly/upgrade-postgres.sh b/other/nightly/upgrade-postgres.sh
deleted file mode 100755
index 09065875e5..0000000000
--- a/other/nightly/upgrade-postgres.sh
+++ /dev/null
@@ -1,405 +0,0 @@
-#!/bin/bash
-## Explicit Coolify internal PostgreSQL major-version migrator.
-## This script is intentionally not run by upgrade.sh automatically.
-
-set -Eeuo pipefail
-
-SOURCE_DIR="/data/coolify/source"
-ENV_FILE="${SOURCE_DIR}/.env"
-BACKUP_DIR="/data/coolify/backups/internal-postgres"
-OVERRIDE_FILE="${SOURCE_DIR}/docker-compose.postgres-upgrade.yml"
-ROLLBACK_FILE="${SOURCE_DIR}/postgres-upgrade-rollback.env"
-DATE=$(date +%Y-%m-%d-%H-%M-%S)
-LOGFILE="${SOURCE_DIR}/postgres-upgrade-${DATE}.log"
-COMMAND="${1:-upgrade}"
-TARGET_MAJOR="${1:-18}"
-
-if [ "$COMMAND" = "rollback" ]; then
- TARGET_MAJOR=""
-else
- COMMAND="upgrade"
-fi
-
-TARGET_IMAGE="${COOLIFY_POSTGRES_TARGET_IMAGE:-postgres:${TARGET_MAJOR}-alpine}"
-TARGET_VOLUME="${COOLIFY_POSTGRES_TARGET_VOLUME:-coolify-db-pg${TARGET_MAJOR}}"
-TEMP_CONTAINER="coolify-db-pg${TARGET_MAJOR:-rollback}-restore-${DATE}"
-DUMP_FILE="${BACKUP_DIR}/postgres-upgrade-${DATE}.sql.gz"
-
-log() {
- echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOGFILE"
-}
-
-fail() {
- log "ERROR: $1"
- exit 1
-}
-
-usage() {
- cat <
- $0 rollback
-
-Examples:
- $0 18
- $0 rollback
-
-Environment overrides:
- COOLIFY_POSTGRES_TARGET_IMAGE=postgres:18-alpine
- COOLIFY_POSTGRES_TARGET_VOLUME=coolify-db-pg18
-EOF
-}
-
-cleanup() {
- docker rm -f "$TEMP_CONTAINER" >/dev/null 2>&1 || true
-}
-trap cleanup EXIT
-
-get_env_var() {
- local key="$1"
- local fallback="${2:-}"
- local value
-
- value=$(grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true)
- value="${value%\"}"
- value="${value#\"}"
- value="${value%\'}"
- value="${value#\'}"
-
- if [ -z "$value" ]; then
- printf '%s' "$fallback"
- else
- printf '%s' "$value"
- fi
-}
-
-wait_for_postgres() {
- local container="$1"
- local user="$2"
- local database="$3"
- local attempts=60
-
- for _ in $(seq 1 "$attempts"); do
- if docker exec "$container" pg_isready -U "$user" -d "$database" >/dev/null 2>&1; then
- return 0
- fi
- sleep 2
- done
-
- return 1
-}
-
-compose_files() {
- printf -- '-f %s/docker-compose.yml -f %s/docker-compose.prod.yml ' "$SOURCE_DIR" "$SOURCE_DIR"
-
- if [ -f "${SOURCE_DIR}/docker-compose.custom.yml" ]; then
- printf -- '-f %s/docker-compose.custom.yml ' "$SOURCE_DIR"
- fi
-
- if [ -f "$OVERRIDE_FILE" ]; then
- printf -- '-f %s ' "$OVERRIDE_FILE"
- fi
-}
-
-validate_target_major() {
- case "$TARGET_MAJOR" in
- ''|*[!0-9]*)
- usage
- fail "Target major version must be numeric. Example: $0 18"
- ;;
- esac
-
- if [ "$TARGET_MAJOR" -lt 10 ]; then
- fail "Target major version must be 10 or higher."
- fi
-}
-
-mount_path_for_major() {
- local major="$1"
-
- if [ "$major" -ge 18 ]; then
- printf '%s' '/var/lib/postgresql'
- else
- printf '%s' '/var/lib/postgresql/data'
- fi
-}
-
-current_postgres_mount_name() {
- docker inspect coolify-db --format '{{range .Mounts}}{{if or (eq .Destination "/var/lib/postgresql/data") (eq .Destination "/var/lib/postgresql")}}{{.Name}}{{end}}{{end}}' 2>/dev/null
-}
-
-current_postgres_mount_path() {
- docker inspect coolify-db --format '{{range .Mounts}}{{if or (eq .Destination "/var/lib/postgresql/data") (eq .Destination "/var/lib/postgresql")}}{{.Destination}}{{end}}{{end}}' 2>/dev/null
-}
-
-current_postgres_image() {
- docker inspect coolify-db --format '{{.Config.Image}}' 2>/dev/null
-}
-
-current_coolify_image_tag() {
- local image
- local image_without_digest
- local last_segment
-
- image=$(docker inspect coolify --format '{{.Config.Image}}' 2>/dev/null || true)
- image_without_digest="${image%@*}"
- last_segment="${image_without_digest##*/}"
-
- if [[ "$last_segment" == *:* ]]; then
- printf '%s' "${last_segment##*:}"
- fi
-}
-
-write_override_file() {
- local image="$1"
- local volume="$2"
- local mount_path="$3"
-
- cat > "$OVERRIDE_FILE" < "$ROLLBACK_FILE" </dev/null 2>&1 || fail "Docker is required."
- docker info >/dev/null 2>&1 || fail "Docker daemon is not reachable."
-}
-
-rollback_postgres() {
- validate_common_requirements
-
- [ -f "$ROLLBACK_FILE" ] || fail "Missing rollback metadata file: ${ROLLBACK_FILE}"
-
- # shellcheck disable=SC1090
- . "$ROLLBACK_FILE"
-
- [ -n "${PREVIOUS_IMAGE:-}" ] || fail "Rollback metadata is missing PREVIOUS_IMAGE."
- [ -n "${PREVIOUS_VOLUME:-}" ] || fail "Rollback metadata is missing PREVIOUS_VOLUME."
- [ -n "${PREVIOUS_MOUNT_PATH:-}" ] || fail "Rollback metadata is missing PREVIOUS_MOUNT_PATH."
- [ -n "${PREVIOUS_OVERRIDE_PRESENT:-}" ] || fail "Rollback metadata is missing PREVIOUS_OVERRIDE_PRESENT."
-
- CURRENT_COOLIFY_IMAGE_TAG=$(current_coolify_image_tag)
-
- log "Rolling back Coolify internal PostgreSQL."
- log "Previous image: ${PREVIOUS_IMAGE}"
- log "Previous volume: ${PREVIOUS_VOLUME}"
- log "Previous mount path: ${PREVIOUS_MOUNT_PATH}"
- log "Current Coolify image tag: ${CURRENT_COOLIFY_IMAGE_TAG:-latest}"
-
- docker volume inspect "$PREVIOUS_VOLUME" >/dev/null 2>&1 || fail "Previous volume '${PREVIOUS_VOLUME}' does not exist."
-
- log "Stopping Coolify application container before rollback."
- docker stop coolify >>"$LOGFILE" 2>&1 || true
-
- log "Removing current coolify-db container. Current upgraded volume is kept untouched."
- docker rm -f coolify-db >>"$LOGFILE" 2>&1 || true
-
- if [ "$PREVIOUS_OVERRIDE_PRESENT" = "true" ]; then
- log "Restoring previous PostgreSQL compose override."
- write_override_file "$PREVIOUS_IMAGE" "$PREVIOUS_VOLUME" "$PREVIOUS_MOUNT_PATH"
- else
- log "Removing PostgreSQL compose override to restore base compose configuration."
- rm -f "$OVERRIDE_FILE"
- fi
-
- log "Starting Coolify stack with rollback database volume."
- start_stack "$CURRENT_COOLIFY_IMAGE_TAG" >>"$LOGFILE" 2>&1 || fail "Could not start Coolify stack after rollback. See ${LOGFILE}."
-
- log "Rollback completed successfully."
- cat <>"$LOGFILE" 2>&1 || fail "Could not start coolify-db."
- fi
-
- wait_for_postgres coolify-db "$DB_USERNAME" "$DB_DATABASE" || fail "Existing coolify-db is not ready."
-
- SERVER_VERSION_NUM=$(docker exec coolify-db psql -U "$DB_USERNAME" -d "$DB_DATABASE" -Atc 'SHOW server_version_num;' | tr -d '[:space:]')
- CURRENT_MAJOR=$((SERVER_VERSION_NUM / 10000))
- PREVIOUS_VOLUME=$(current_postgres_mount_name)
- PREVIOUS_MOUNT_PATH=$(current_postgres_mount_path)
- PREVIOUS_IMAGE=$(current_postgres_image)
- CURRENT_COOLIFY_IMAGE_TAG=$(current_coolify_image_tag)
-
- [ -n "$PREVIOUS_VOLUME" ] || fail "Could not detect current PostgreSQL Docker volume."
- [ -n "$PREVIOUS_MOUNT_PATH" ] || fail "Could not detect current PostgreSQL mount path."
- [ -n "$PREVIOUS_IMAGE" ] || fail "Could not detect current PostgreSQL image."
-
- if [ -f "$OVERRIDE_FILE" ]; then
- PREVIOUS_OVERRIDE_PRESENT=true
- else
- PREVIOUS_OVERRIDE_PRESENT=false
- fi
-
- log "Current PostgreSQL major: ${CURRENT_MAJOR}"
- log "Current active volume: ${PREVIOUS_VOLUME}"
- log "Current image: ${PREVIOUS_IMAGE}"
- log "Current mount path: ${PREVIOUS_MOUNT_PATH}"
- log "Current Coolify image tag: ${CURRENT_COOLIFY_IMAGE_TAG:-latest}"
-
- if [ "$CURRENT_MAJOR" -eq "$TARGET_MAJOR" ]; then
- log "PostgreSQL is already on major ${TARGET_MAJOR}. Nothing to do."
- exit 0
- fi
-
- if [ "$CURRENT_MAJOR" -gt "$TARGET_MAJOR" ]; then
- fail "Downgrade from ${CURRENT_MAJOR} to ${TARGET_MAJOR} is not supported. Use '$0 rollback' to restore the previous upgrade state."
- fi
-
- if docker volume inspect "$TARGET_VOLUME" >/dev/null 2>&1; then
- fail "Target volume '${TARGET_VOLUME}' already exists. Set COOLIFY_POSTGRES_TARGET_VOLUME to a new name or remove the old failed target volume."
- fi
-
- log "Stopping Coolify application container to prevent writes during dump."
- docker stop coolify >>"$LOGFILE" 2>&1 || true
-
- log "Creating compressed dump at ${DUMP_FILE}."
- docker exec coolify-db pg_dumpall -U "$DB_USERNAME" | gzip -c > "$DUMP_FILE"
- chmod 600 "$DUMP_FILE"
-
- if [ ! -s "$DUMP_FILE" ]; then
- fail "Dump file is empty. Aborting."
- fi
-
- log "Creating target Docker volume '${TARGET_VOLUME}'."
- docker volume create "$TARGET_VOLUME" >>"$LOGFILE" 2>&1
-
- log "Pulling ${TARGET_IMAGE}."
- docker pull "$TARGET_IMAGE" >>"$LOGFILE" 2>&1
-
- log "Starting temporary PostgreSQL ${TARGET_MAJOR} container."
- docker run -d \
- --name "$TEMP_CONTAINER" \
- --network coolify \
- -e POSTGRES_HOST_AUTH_METHOD=trust \
- -v "${TARGET_VOLUME}:${TARGET_MOUNT_PATH}" \
- "$TARGET_IMAGE" >>"$LOGFILE" 2>&1
-
- wait_for_postgres "$TEMP_CONTAINER" postgres postgres || fail "Temporary PostgreSQL ${TARGET_MAJOR} container did not become ready."
-
- log "Restoring dump into target volume."
- gunzip -c "$DUMP_FILE" | docker exec -i "$TEMP_CONTAINER" psql -U postgres -d postgres >>"$LOGFILE" 2>&1
-
- log "Smoke-checking restored Coolify database."
- docker exec "$TEMP_CONTAINER" psql -U "$DB_USERNAME" -d "$DB_DATABASE" -Atc 'SELECT 1;' | grep -qx '1' || fail "Restored database smoke check failed."
-
- log "Saving rollback metadata to ${ROLLBACK_FILE}."
- write_rollback_file "$PREVIOUS_IMAGE" "$PREVIOUS_VOLUME" "$PREVIOUS_MOUNT_PATH" "$PREVIOUS_OVERRIDE_PRESENT" "$TARGET_IMAGE" "$TARGET_VOLUME"
-
- log "Writing Docker Compose override to ${OVERRIDE_FILE}."
- write_override_file "$TARGET_IMAGE" "$TARGET_VOLUME" "$TARGET_MOUNT_PATH"
-
- log "Stopping temporary restore container."
- docker rm -f "$TEMP_CONTAINER" >>"$LOGFILE" 2>&1 || true
-
- log "Stopping old coolify-db container. Previous volume '${PREVIOUS_VOLUME}' will be kept for rollback."
- docker rm -f coolify-db >>"$LOGFILE" 2>&1 || true
-
- log "Starting Coolify stack with PostgreSQL ${TARGET_MAJOR}."
- start_stack "$CURRENT_COOLIFY_IMAGE_TAG" >>"$LOGFILE" 2>&1 || fail "Could not start Coolify stack with upgraded PostgreSQL. See ${LOGFILE}."
-
- log "Coolify internal PostgreSQL upgrade completed successfully."
- print_rollback_instructions
-}
-
-if [ "${COOLIFY_POSTGRES_UPGRADE_SOURCE_ONLY:-false}" = "true" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then
- return 0
-fi
-
-case "$COMMAND" in
- rollback)
- rollback_postgres
- ;;
- upgrade)
- upgrade_postgres
- ;;
- -h|--help|help)
- usage
- ;;
- *)
- usage
- fail "Unknown command: ${COMMAND}"
- ;;
-esac
diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh
deleted file mode 100644
index fea3c360c7..0000000000
--- a/other/nightly/upgrade.sh
+++ /dev/null
@@ -1,427 +0,0 @@
-#!/bin/bash
-## Do not modify this file. You will lose the ability to autoupdate!
-
-CDN="https://cdn.coollabs.io/coolify-nightly"
-LATEST_IMAGE=${1:-latest}
-LATEST_HELPER_VERSION=${2:-latest}
-ENV_FILE="/data/coolify/source/.env"
-if [ -n "${3+x}" ]; then
- REGISTRY_URL="$3"
-elif [ -f "$ENV_FILE" ] && grep -q "^REGISTRY_URL=" "$ENV_FILE"; then
- REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2- | head -n1)
-else
- REGISTRY_URL="docker.io"
-fi
-SKIP_BACKUP=${4:-false}
-STATUS_FILE="/data/coolify/source/.upgrade-status"
-
-DATE=$(date +%Y-%m-%d-%H-%M-%S)
-LOGFILE="/data/coolify/source/upgrade-${DATE}.log"
-
-# Helper function to log with timestamp
-log() {
- echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >>"$LOGFILE"
-}
-
-# Helper function to log section headers
-log_section() {
- echo "" >>"$LOGFILE"
- echo "============================================================" >>"$LOGFILE"
- echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >>"$LOGFILE"
- echo "============================================================" >>"$LOGFILE"
-}
-
-# Helper function to write upgrade status for API polling
-write_status() {
- local step="$1"
- local message="$2"
- echo "${step}|${message}|$(date -Iseconds)" > "$STATUS_FILE"
-}
-
-# Print the first gateway of a network that Docker reports with a prefix length (fd00::1/64).
-# Docker Engine 27.0 to 28.0.0 does this for IPv6 until the daemon restarts (https://github.com/moby/moby/pull/49520).
-# The Docker CLI and Compose in the helper image fail on such a network with a ParseAddr error.
-network_gateway_with_prefix() {
- docker network inspect "$1" --format '{{range .IPAM.Config}}{{.Gateway}} {{end}}' 2>&1 | grep -oE '[0-9A-Fa-f:.]+/[0-9]+' | head -n1
-}
-
-# Make sure the coolify network exists and the helper image can read it.
-# It is created when starting Coolify with docker compose
-ensure_coolify_network() {
- log "Checking Docker network 'coolify'..."
- if ! docker network inspect coolify >/dev/null 2>&1; then
- log "Network 'coolify' does not exist, creating..."
- if ! docker network create --attachable --ipv6 coolify 2>/dev/null; then
- log "Failed to create network with IPv6, trying without IPv6..."
- docker network create --attachable coolify 2>/dev/null
- log "Network 'coolify' created without IPv6"
- elif [ -n "$(network_gateway_with_prefix coolify)" ]; then
- # No container uses the new network yet, so it is safe to recreate it.
- log "Docker reports the IPv6 gateway of network 'coolify' with a prefix length, recreating it without IPv6..."
- docker network rm coolify >/dev/null 2>&1
- docker network create --attachable coolify 2>/dev/null
- log "Network 'coolify' created without IPv6"
- else
- log "Network 'coolify' created with IPv6 support"
- fi
- return 0
- fi
-
- log "Network 'coolify' already exists"
- local gateway
- gateway=$(network_gateway_with_prefix coolify)
- if [ -n "$gateway" ]; then
- local message="Docker Engine $(docker version --format '{{.Server.Version}}' 2>/dev/null) reports the gateway of the 'coolify' network as ${gateway}, which Coolify cannot read (Docker Engine bug, fixed in 28.0.1). Run 'systemctl restart docker' (this restarts all containers) or upgrade Docker Engine, then upgrade Coolify again."
- log "ERROR: ${message}"
- write_status "error" "$message"
- echo " ERROR: ${message}"
- return 1
- fi
-}
-
-# Pull an image. Retry with backoff, because the registry can rate limit many instances that pull at the same time.
-pull_image() {
- local image="$1"
- local attempt=1
- local max_attempts=5
- local delay=15
- until docker pull "$image" >>"$LOGFILE" 2>&1; do
- if [ "$attempt" -ge "$max_attempts" ]; then
- return 1
- fi
- log "Pull of $image failed (attempt $attempt/$max_attempts). Retrying in ${delay}s..."
- echo " Pull failed (attempt $attempt/$max_attempts). Retrying in ${delay}s..."
- sleep "$((delay + RANDOM % 10))"
- attempt=$((attempt + 1))
- delay=$((delay * 2))
- done
-}
-
-echo ""
-echo "=========================================="
-echo " Coolify Upgrade - ${DATE}"
-echo "=========================================="
-echo ""
-
-# Initialize log file with header
-echo "============================================================" >>"$LOGFILE"
-echo "Coolify Upgrade Log" >>"$LOGFILE"
-echo "Started: $(date '+%Y-%m-%d %H:%M:%S')" >>"$LOGFILE"
-echo "Target Version: ${LATEST_IMAGE}" >>"$LOGFILE"
-echo "Helper Version: ${LATEST_HELPER_VERSION}" >>"$LOGFILE"
-echo "Registry URL: ${REGISTRY_URL}" >>"$LOGFILE"
-echo "============================================================" >>"$LOGFILE"
-
-log_section "Step 1/6: Downloading configuration files"
-write_status "1" "Downloading configuration files"
-echo "1/6 Downloading latest configuration files..."
-log "Downloading docker-compose.yml from ${CDN}/docker-compose.yml"
-curl -fsSL -L $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml
-log "Downloading docker-compose.prod.yml from ${CDN}/docker-compose.prod.yml"
-curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
-log "Downloading .env.production from ${CDN}/.env.production"
-curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production
-log "Downloading upgrade-postgres.sh from ${CDN}/upgrade-postgres.sh"
-curl -fsSL -L $CDN/upgrade-postgres.sh -o /data/coolify/source/upgrade-postgres.sh
-chmod +x /data/coolify/source/upgrade-postgres.sh
-log "Configuration files downloaded successfully"
-echo " Done."
-
-# Extract all images from docker-compose configuration
-log "Extracting all images from docker-compose configuration..."
-COMPOSE_FILES="-f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml"
-
-# Check if custom compose file exists
-if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
- COMPOSE_FILES="$COMPOSE_FILES -f /data/coolify/source/docker-compose.custom.yml"
- log "Including custom docker-compose.yml in image extraction"
-fi
-
-# Check if PostgreSQL upgrade override exists
-if [ -f /data/coolify/source/docker-compose.postgres-upgrade.yml ]; then
- COMPOSE_FILES="$COMPOSE_FILES -f /data/coolify/source/docker-compose.postgres-upgrade.yml"
- log "Including PostgreSQL upgrade compose override in image extraction"
-fi
-
-# Get all unique images from docker compose config
-# LATEST_IMAGE env var is needed for image substitution in compose files
-IMAGES=$(REGISTRY_URL=${REGISTRY_URL} LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u)
-
-if [ -z "$IMAGES" ]; then
- log "ERROR: Failed to extract images from docker-compose files"
- write_status "error" "Failed to parse docker-compose configuration"
- echo " ERROR: Failed to parse docker-compose configuration. Aborting upgrade."
- exit 1
-fi
-
-log "Images to pull:"
-echo "$IMAGES" | while read img; do log " - $img"; done
-
-# Backup existing .env file before making any changes
-if [ "$SKIP_BACKUP" != "true" ]; then
- if [ -f "$ENV_FILE" ]; then
- echo " Creating backup of .env file..."
- log "Creating backup of .env file to .env-$DATE"
- cp "$ENV_FILE" "$ENV_FILE-$DATE"
- log "Backup created: ${ENV_FILE}-${DATE}"
- else
- log "WARNING: No existing .env file found to backup"
- fi
-fi
-
-log_section "Step 2/6: Updating environment configuration"
-write_status "2" "Updating environment configuration"
-echo ""
-echo "2/6 Updating environment configuration..."
-log "Merging .env.production values into .env"
-awk -F '=' '!seen[$1]++' "$ENV_FILE" /data/coolify/source/.env.production > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
-log "Environment file merged successfully"
-
-update_env_var() {
- local key="$1"
- local value="$2"
-
- # If variable "key=" exists but has no value, update the value of the existing line
- if grep -q "^${key}=$" "$ENV_FILE"; then
- sed -i "s|^${key}=$|${key}=${value}|" "$ENV_FILE"
- log "Updated ${key} (was empty)"
- # If variable "key=" doesn't exist, append it to the file with value
- elif ! grep -q "^${key}=" "$ENV_FILE"; then
- printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
- log "Added ${key} (was missing)"
- fi
-}
-
-set_env_var() {
- local key="$1"
- local value="$2"
-
- if grep -q "^${key}=" "$ENV_FILE"; then
- sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE"
- log "Updated ${key}"
- else
- printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
- log "Added ${key}"
- fi
-}
-
-# Quote a value so that Docker Compose and phpdotenv both read it back unchanged (same as install.sh).
-quote_env_value() {
- local value="$1"
- case "$value" in
- *\'*)
- value=${value//\\/\\\\}
- value=${value//\"/\\\"}
- value=${value//\$/\\\$}
- printf '"%s"' "$value"
- ;;
- *)
- printf "'%s'" "$value"
- ;;
- esac
-}
-
-# Installers before 4.5.1 (and manual edits) wrote root user credentials with spaces without quotes.
-# phpdotenv cannot parse such a line, so the recreated Coolify container returns HTTP 500 for every page.
-# Only quote lines that phpdotenv rejects: unquoted values that contain whitespace but no comment.
-quote_root_user_env_values() {
- local key value
- for key in ROOT_USERNAME ROOT_USER_EMAIL ROOT_USER_PASSWORD; do
- value=$(grep -m1 "^${key}=" "$ENV_FILE" | cut -d '=' -f2-) || continue
- # phpdotenv ignores whitespace around an unquoted value (also the \r of CRLF line endings)
- value="${value#"${value%%[![:space:]]*}"}"
- value="${value%"${value##*[![:space:]]}"}"
- case "$value" in
- \"* | \'* | *[[:space:]]\#*) continue ;;
- *[[:space:]]*) ;;
- *) continue ;;
- esac
- # awk reads the value from the environment, so characters such as | & \ in it stay literal
- KEY="$key" VALUE="$(quote_env_value "$value")" awk '!done && index($0, ENVIRON["KEY"] "=") == 1 { $0 = ENVIRON["KEY"] "=" ENVIRON["VALUE"]; done = 1 } { print }' "$ENV_FILE" >"$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
- log "Quoted ${key} (its value has spaces)"
- done
-}
-
-log "Checking environment variables..."
-quote_root_user_env_values
-set_env_var "REGISTRY_URL" "$REGISTRY_URL"
-update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
-update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
-update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)"
-update_env_var "PUSHER_BACKEND_PORT" "6001"
-# The realtime container no longer exists; point older installs at the bundled Reverb server
-if grep -q '^PUSHER_BACKEND_HOST=coolify-realtime$' "$ENV_FILE"; then
- set_env_var "PUSHER_BACKEND_HOST" "127.0.0.1"
-fi
-log "Environment variables check complete"
-echo " Done."
-
-ensure_coolify_network || exit 1
-
-mkdir -p /data/coolify/images/{avatars,project-icons}
-chown -R 9999:root /data/coolify/images
-chmod -R 700 /data/coolify/images
-
-# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621)
-# Only changes owner — preserves existing group to respect custom setups
-SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown")
-if [ "$SSH_OWNER" != "9999" ]; then
- log "Fixing SSH directory ownership (was owned by UID $SSH_OWNER)"
- chown -R 9999 /data/coolify/ssh
- chmod -R 700 /data/coolify/ssh
-fi
-
-# Check if Docker config file exists
-DOCKER_CONFIG_MOUNT=""
-if [ -f /root/.docker/config.json ]; then
- DOCKER_CONFIG_MOUNT="-v /root/.docker/config.json:/root/.docker/config.json"
- log "Docker config mount enabled: /root/.docker/config.json"
-fi
-
-log_section "Step 3/6: Pulling Docker images"
-write_status "3" "Pulling Docker images"
-echo ""
-echo "3/6 Pulling Docker images..."
-echo " This may take a few minutes depending on your connection."
-
-# Also pull the helper image (not in compose files but needed for upgrade)
-HELPER_IMAGE="${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}"
-echo " - Pulling $HELPER_IMAGE..."
-log "Pulling image: $HELPER_IMAGE"
-if pull_image "$HELPER_IMAGE"; then
- log "Successfully pulled $HELPER_IMAGE"
-else
- log "ERROR: Failed to pull $HELPER_IMAGE"
- write_status "error" "Failed to pull $HELPER_IMAGE"
- echo " ERROR: Failed to pull $HELPER_IMAGE. Aborting upgrade."
- exit 1
-fi
-
-# Pull all images from compose config
-# Using a for loop to avoid subshell issues with exit
-for IMAGE in $IMAGES; do
- if [ -n "$IMAGE" ]; then
- echo " - Pulling $IMAGE..."
- log "Pulling image: $IMAGE"
- if pull_image "$IMAGE"; then
- log "Successfully pulled $IMAGE"
- else
- log "ERROR: Failed to pull $IMAGE"
- write_status "error" "Failed to pull $IMAGE"
- echo " ERROR: Failed to pull $IMAGE. Aborting upgrade."
- exit 1
- fi
- fi
-done
-
-log "All images pulled successfully"
-echo " All images pulled successfully."
-
-set_env_var "LATEST_IMAGE" "$LATEST_IMAGE"
-set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE"
-
-log_section "Step 4/6: Stopping and restarting containers"
-write_status "4" "Stopping containers"
-echo ""
-echo "4/6 Stopping containers and starting new ones..."
-echo " This step will restart all Coolify containers."
-echo " Check the log file for details: ${LOGFILE}"
-
-# From this point forward, we need to ensure the script continues even if
-# the SSH connection is lost (which happens when coolify container stops)
-# We use a subshell with nohup to ensure completion
-log "Starting container restart sequence (detached)..."
-
-nohup bash -c "
- LOGFILE='$LOGFILE'
- STATUS_FILE='$STATUS_FILE'
- DOCKER_CONFIG_MOUNT='$DOCKER_CONFIG_MOUNT'
- REGISTRY_URL='$REGISTRY_URL'
- LATEST_HELPER_VERSION='$LATEST_HELPER_VERSION'
- LATEST_IMAGE='$LATEST_IMAGE'
-
- log() {
- echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] \$1\" >>\"\$LOGFILE\"
- }
-
- write_status() {
- echo \"\$1|\$2|\$(date -Iseconds)\" > \"\$STATUS_FILE\"
- }
-
- # Stop and remove containers
- # coolify-realtime is kept for upgrades from versions that still ran the separate realtime container
- for container in coolify coolify-db coolify-redis coolify-realtime; do
- if docker ps -a --format '{{.Names}}' | grep -q \"^\${container}\$\"; then
- log \"Stopping container: \${container}\"
- docker stop \"\$container\" >>\"\$LOGFILE\" 2>&1 || true
- log \"Removing container: \${container}\"
- docker rm \"\$container\" >>\"\$LOGFILE\" 2>&1 || true
- log \"Container \${container} stopped and removed\"
- else
- log \"Container \${container} not found (skipping)\"
- fi
- done
- log \"Container cleanup complete\"
-
- # Start new containers
- echo '' >>\"\$LOGFILE\"
- echo '============================================================' >>\"\$LOGFILE\"
- log 'Step 5/6: Starting new containers'
- echo '============================================================' >>\"\$LOGFILE\"
- write_status '5' 'Starting new containers'
-
- COMPOSE_FILES='-f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml'
- if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
- log 'Using custom docker-compose.yml'
- COMPOSE_FILES=\"\$COMPOSE_FILES -f /data/coolify/source/docker-compose.custom.yml\"
- fi
- if [ -f /data/coolify/source/docker-compose.postgres-upgrade.yml ]; then
- log 'Using PostgreSQL upgrade compose override'
- COMPOSE_FILES=\"\$COMPOSE_FILES -f /data/coolify/source/docker-compose.postgres-upgrade.yml\"
- fi
-
- log 'Running docker compose up...'
- if docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1; then
- log 'Docker compose up completed'
- elif [ \"\$(docker inspect --format '{{.State.Running}}' coolify 2>/dev/null)\" = 'true' ]; then
- # A slow first start (for example long migrations) can pass the wait timeout. Coolify is still starting.
- log 'Docker compose up did not finish waiting, but the coolify container is running'
- else
- log 'ERROR: Docker compose up failed and the coolify container is not running'
- write_status 'error' \"Coolify did not start. Check the upgrade log: \$LOGFILE\"
- exit 1
- fi
-
- # Final log entry
- echo '' >>\"\$LOGFILE\"
- echo '============================================================' >>\"\$LOGFILE\"
- log 'Step 6/6: Upgrade complete'
- echo '============================================================' >>\"\$LOGFILE\"
- write_status '6' 'Upgrade complete'
- log 'Coolify upgrade completed successfully'
- log \"Version: \${LATEST_IMAGE}\"
- echo '' >>\"\$LOGFILE\"
- echo '============================================================' >>\"\$LOGFILE\"
- echo \"Upgrade completed: \$(date '+%Y-%m-%d %H:%M:%S')\" >>\"\$LOGFILE\"
- echo '============================================================' >>\"\$LOGFILE\"
-
- # Clean up status file after a short delay to allow frontend to read completion
- sleep 10
- rm -f \"\$STATUS_FILE\"
- log 'Status file cleaned up'
-" >>"$LOGFILE" 2>&1 &
-
-# Give the background process a moment to start
-sleep 2
-log "Container restart sequence started in background (PID: $!)"
-echo ""
-echo "5/6 Containers are being restarted in the background..."
-echo "6/6 Upgrade process initiated!"
-echo ""
-echo "=========================================="
-echo " Coolify upgrade to ${LATEST_IMAGE} in progress"
-echo "=========================================="
-echo ""
-echo " The upgrade will continue in the background."
-echo " Coolify will be available again shortly."
-echo " Log file: ${LOGFILE}"
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
deleted file mode 100644
index c2fec20f13..0000000000
--- a/other/nightly/versions.json
+++ /dev/null
@@ -1,31 +0,0 @@
-{
- "coolify": {
- "v4": {
- "version": "4.5.4"
- },
- "nightly": {
- "version": "4.6-rc.1"
- },
- "helper": {
- "version": "1.0.19"
- },
- "sentinel": {
- "version": "1.1.3"
- },
- "sentinel-host": {
- "version": "1.1.3",
- "digest": "sha256:bca77d22355c9005185b438dad406747b4b776018215a0a4a75c88618d681ff7"
- }
- },
- "traefik": {
- "v3.7": "3.7.16",
- "v3.6": "3.6.25",
- "v3.5": "3.5.6",
- "v3.4": "3.4.5",
- "v3.3": "3.3.7",
- "v3.2": "3.2.5",
- "v3.1": "3.1.7",
- "v3.0": "3.0.4",
- "v2.11": "2.11.60"
- }
-}
diff --git a/release.json b/release.json
new file mode 100644
index 0000000000..e2c962aef4
--- /dev/null
+++ b/release.json
@@ -0,0 +1,8 @@
+{
+ "stable": {
+ "coolify": "4.5.3"
+ },
+ "preview": {
+ "coolify": "4.5.3"
+ }
+}
diff --git a/scripts/install.sh b/scripts/install.sh
index c9801fbc0b..eab94dbeae 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -519,8 +519,8 @@ fi
# Fetch versions.json once and parse all values from it
VERSIONS_JSON=$(curl -L --silent $CDN/versions.json)
-LATEST_VERSION=$(echo "$VERSIONS_JSON" | grep -i version | xargs | awk '{print $2}' | tr -d ',')
-LATEST_HELPER_VERSION=$(echo "$VERSIONS_JSON" | grep -i version | xargs | awk '{print $6}' | tr -d ',')
+LATEST_VERSION=$(echo "$VERSIONS_JSON" | sed -n '/"v4"/,/}/s/.*"version": *"\([^"]*\)".*/\1/p' | head -n 1)
+LATEST_HELPER_VERSION=$(echo "$VERSIONS_JSON" | sed -n '/"helper"/,/}/s/.*"version": *"\([^"]*\)".*/\1/p' | head -n 1)
if [ -z "$LATEST_HELPER_VERSION" ]; then
LATEST_HELPER_VERSION=latest
diff --git a/scripts/plan-version.sh b/scripts/plan-version.sh
new file mode 100755
index 0000000000..c93efa9e3d
--- /dev/null
+++ b/scripts/plan-version.sh
@@ -0,0 +1,360 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+OPTION="$*"
+BRANCH="${BRANCH:-}"
+PULL_REQUEST="${PULL_REQUEST:-}"
+NIGHTLY_VERSION="${NIGHTLY_VERSION:-}"
+NIGHTLY_REVISION="${NIGHTLY_REVISION:-}"
+MAIN_VERSION="${MAIN_VERSION:-}"
+MAIN_REVISION="${MAIN_REVISION:-}"
+VERSION_FILE=release.json
+ARTIFACT=coolify
+UPSTREAM_FILE=upstream.json
+UPSTREAM=
+
+BOLD=$'\033[1m' BLUE=$'\033[34m' GREEN=$'\033[32m' YELLOW=$'\033[33m' RED=$'\033[31m' RESET=$'\033[0m'
+
+info() { [ -n "$OPTION" ] || printf ' %sℹ%s %s\n' "$BLUE" "$RESET" "$*" >&2; }
+ok() { [ -n "$OPTION" ] || printf ' %s✔%s %s\n' "$GREEN" "$RESET" "$*" >&2; }
+warn() { [ -n "$OPTION" ] || printf ' %s⚠%s %s\n' "$YELLOW" "$RESET" "$*" >&2; }
+die() {
+ printf ' %s✖%s %s\n' "$RED" "$RESET" "$*" >&2
+ exit 1
+}
+
+cd "$(dirname "$0")/.."
+
+main() {
+ [[ "$OPTION" =~ ^(--build|--release|--tags|--nightly|--main)?$ ]] || die "'$OPTION' is not an option, expected --build, --release, --tags, --nightly, --main or none"
+ read_version
+ print_build_version
+ fetch_origin
+ print_tags
+ print_next_build
+ check_branch
+ check_version
+ select_releases
+ check_unreleased
+ print_upcoming_versions
+ print_releases
+}
+
+read_version() {
+ branch="${BRANCH:-$(git branch --show-current)}"
+ channel=stable
+ [[ ! "$branch" =~ ^(next|release/v[0-9]+\.[0-9]+)$ ]] || channel=preview
+ version="$(artifact_version "$channel")"
+ [ -n "$version" ] || die "$VERSION_FILE has no $channel version of $ARTIFACT"
+}
+
+print_build_version() {
+ [ "$OPTION" = --build ] || return 0
+ if [ -n "$PULL_REQUEST" ] && [ "$channel" = preview ]; then
+ tags="$(git ls-remote --tags --refs origin | sed 's|.*refs/tags/||' | filter_release_tags | sort -V)" || die "could not list the tags of origin"
+ line="${branch#release/v}"
+ [ "$line" != "$branch" ] || line=""
+ revision=HEAD
+ select_newest_stable
+ select_preview_base
+ version="$base"
+ fi
+ echo "$version${PULL_REQUEST:+-pr.$PULL_REQUEST}"
+ exit 0
+}
+
+fetch_origin() {
+ local current behind
+ [ "$(git rev-parse --is-shallow-repository)" = false ] || die "the clone is shallow, run git fetch --unshallow first"
+ git fetch --quiet --prune --tags origin || die "could not fetch origin"
+ current="$(git branch --show-current)"
+ if [ -n "$current" ] && behind="$(git rev-list --count "HEAD..origin/$current" 2> /dev/null)"; then
+ [ "$behind" = 0 ] || die "$current is $behind commit(s) behind origin/$current, pull first"
+ fi
+ tags="$(release_tags | sort -V)"
+}
+
+print_tags() {
+ local released
+ [ "$OPTION" = --tags ] || return 0
+ released="$(release_tags --points-at HEAD)"
+ moved_tags "$(grep -vxF -f <(echo "$released") <<< "$tags" || true)" "$tags" \
+ | jq -Rnc '[inputs | split(" ") | {(.[0]): .[1]}] | add // {}'
+ exit 0
+}
+
+print_next_build() {
+ local build current_version current_revision
+ [[ "$OPTION" =~ ^--(nightly|main)$ ]] || return 0
+ if [ "$OPTION" = --main ]; then
+ branch=main
+ revision="$(git rev-parse origin/main)"
+ current_version="$MAIN_VERSION" current_revision="$MAIN_REVISION"
+ else
+ select_nightly_branch
+ current_version="$NIGHTLY_VERSION" current_revision="$NIGHTLY_REVISION"
+ fi
+ [ "$revision" != "$current_revision" ] || exit 0
+ if [ "$OPTION" = --main ]; then
+ base="$(artifact_version stable <(git show "$revision:$VERSION_FILE" 2> /dev/null))"
+ [ -n "$base" ] || die "main has no stable version of $ARTIFACT in $VERSION_FILE, expected the release process there"
+ else
+ select_preview_base
+ fi
+ [ -z "$UPSTREAM" ] || current_version="${current_version#*-v}"
+ build="$base.$(date -u +%Y%m%d)"
+ if [[ "$current_version" == "$build".* ]]; then
+ build+=".$((${current_version##*.} + 1))"
+ else
+ build+=".1"
+ fi
+ upstream_lines <(git show "$revision:$UPSTREAM_FILE") | while read -r name upstream; do
+ jq -nc --arg name "$name" --arg version "$upstream" --arg tag "v${upstream:+$upstream-v}$build" '{$name, $version, $tag}'
+ done | jq -sc --arg branch "$branch" --arg revision "$revision" --arg image "$build" '{$branch, $revision, $image, lines: .}'
+ exit 0
+}
+
+select_nightly_branch() {
+ select_newest_stable
+ line="$(git for-each-ref --format='%(refname:lstrip=4)' 'refs/remotes/origin/release/v*' | sed -n 's/^v\([0-9]*\.[0-9]*\)$/\1/p' | sort -V \
+ | while read -r release; do ! newer "$release.0" "$stable" || { echo "$release" && break; }; done)"
+ branch="${line:+release/v$line}"
+ branch="${branch:-next}"
+ revision="$(git rev-parse "origin/$branch")"
+}
+
+check_branch() {
+ [[ "$branch" =~ ^(main|next|v([0-9]+(\.[0-9]+)?)\.x|release/v([0-9]+\.[0-9]+))$ ]] || die "${branch:-a detached HEAD} releases nothing"
+ line="${BASH_REMATCH[2]}${BASH_REMATCH[4]}"
+ case "$branch" in
+ main) ok "main releases stable versions" ;;
+ next) ok "next releases prereleases" ;;
+ release/*) ok "$branch releases prereleases of $line.0" ;;
+ *) ok "$branch releases stable $line.x versions" ;;
+ esac
+}
+
+check_version() {
+ local from
+ if [ "$channel" = stable ]; then
+ [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "$VERSION_FILE sets '$version', expected major.minor.patch"
+ [ -z "$line" ] || [[ "$version" == "$line".* ]] || die "$VERSION_FILE sets $version, expected a $line.x version"
+ else
+ [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]] \
+ || die "$VERSION_FILE sets '$version', expected major.minor.patch with an optional -alpha.N, -beta.N or -rc.N"
+ [ -z "$line" ] || [ "${version%%-*}" = "$line.0" ] || die "$VERSION_FILE sets $version, expected a prerelease of $line.0"
+ fi
+ last="$(release_tags --merged HEAD | tag_versions | awk -v channel="$channel" 'channel == "preview" || !/-/' | newest)"
+ earlier="$(with_version "$version" <<< "$tags" | tail -n 1)"
+ publish=false
+ if newer "$last" "$version"; then
+ info "$VERSION_FILE sets $version, $branch released $last already"
+ elif [ "$version" = 0.0.0 ]; then
+ info "$VERSION_FILE sets 0.0.0, $branch releases nothing before scripts/release.sh sets the first version"
+ elif [ "$channel" = preview ] && [[ ! "$version" =~ -[a-z]+\.[1-9] ]]; then
+ info "$VERSION_FILE sets $version, $branch publishes numbered prereleases only"
+ elif [ "$channel" = preview ] && [ -n "$earlier" ]; then
+ ok "$VERSION_FILE sets $BOLD$version$RESET"
+ info "$earlier is published, $branch publishes a prerelease once"
+ else
+ from="${last:+$last → }"
+ [ "$version" != "$last" ] || from=
+ ok "$VERSION_FILE sets $from$BOLD$version$RESET"
+ publish=true
+ fi
+ if [ "$publish" = true ] && taken "${version%%-*}"; then
+ die "$VERSION_FILE sets $version, ${version%%-*} is taken by $holder"
+ fi
+ if [ "$publish" = true ] && in_progress "${version%%-*}"; then
+ warn "${version%%-*} is in progress, held by $holder"
+ fi
+}
+
+select_releases() {
+ local name sqlite tag previous older moves listed added=""
+ releases=
+ [ "$publish" = true ] || return 0
+ while read -r name sqlite; do
+ tag="v${sqlite:+$sqlite-v}$version"
+ if grep -qxF "$tag" <<< "$tags"; then
+ info "$tag is published"
+ continue
+ fi
+ previous=
+ while read -r older; do
+ ! newer "$sqlite" "$older" || previous="$older"
+ done < <(with_version "$version" <<< "$tags" | sed -nE 's/^v(.*)-v.*/\1/p')
+ added+="$tag"$'\n'
+ releases+="$(jq -nc --arg name "$name" --arg version "$sqlite" --arg image "$version" --arg tag "$tag" --arg previous "$previous" \
+ '{$name, $version, $image, $tag, $previous}')"$'\n'
+ done < <(upstream_lines)
+ moves="$(moved_tags "$tags" "$tags"$'\n'"$added")"
+ for tag in $added; do
+ listed="$(moved_to "$tag")"
+ ok "$BOLD$tag$RESET is new, a push publishes it${listed:+ and moves $listed}"
+ done
+ awk 'NF { print $2 }' <<< "$moves" | sort -u | while read -r tag; do
+ grep -qxF "$tag" <<< "$added" || info "a push moves $(moved_to "$tag") to $tag"
+ done
+}
+
+check_unreleased() {
+ local count
+ [ -n "$releases" ] && [ -n "$earlier" ] || return 0
+ count="$(git rev-list --no-merges "$earlier..HEAD" -- src | grep -cvxF -f <(git rev-list "$earlier..HEAD" -- "$UPSTREAM_FILE") || true)"
+ [ "$count" = 0 ] || die "$count commit(s) since $earlier change src/ and would ship as $version, release them first with scripts/release.sh"
+}
+
+print_upcoming_versions() {
+ local base stable upcoming
+ [ -z "$releases" ] || return 0
+ if [ "$channel" = stable ]; then
+ base="$version"
+ ! newer "$last" "$base" || base="$last"
+ case "$branch" in
+ main) upcoming="$(next_version "$base" patch), $(next_version "$base" minor) or $(next_version "$base" major)" ;;
+ v*.*.x) upcoming="$(next_version "$base" patch)" ;;
+ *) upcoming="$(next_version "$base" patch) or $(next_version "$base" minor)" ;;
+ esac
+ [ "$base" != 0.0.0 ] || upcoming="$(next_version "$base" minor) or $(next_version "$base" major)"
+ elif [ -n "$line" ]; then
+ upcoming="a prerelease of $line.0"
+ else
+ stable="$(release_tags --merged HEAD | tag_versions | awk '!/-/' | newest)"
+ stable="${stable:-$(artifact_version stable)}"
+ upcoming="a prerelease of $(next_version "$stable" minor) or $(next_version "$stable" major)"
+ fi
+ info "the upcoming version on $branch is $upcoming, scripts/release.sh sets it"
+}
+
+print_releases() {
+ [ "$OPTION" = --release ] || return 0
+ jq -sc . <<< "$releases"
+}
+
+select_newest_stable() {
+ stable="$(tag_versions <<< "$tags" | awk '!/-/' | newest)"
+ stable="${stable:-$(artifact_version stable)}"
+}
+
+select_preview_base() {
+ version="$(artifact_version preview <(git show "$revision:$VERSION_FILE"))"
+ if [ -n "$line" ]; then
+ base="$line.0-alpha.0"
+ [[ "$version" != "$line.0-"* ]] || base="$version"
+ elif newer "$version" "$stable"; then
+ base="$version"
+ [[ "$version" == *-* ]] || base="$version-alpha.0"
+ else
+ base="$(next_version "$stable" minor)-alpha.0"
+ fi
+}
+
+artifact_version() {
+ jq -r --arg channel "$1" --arg artifact "$ARTIFACT" '.[$channel][$artifact] // empty' "${2:-$VERSION_FILE}"
+}
+
+upstream_lines() {
+ if [ -z "$UPSTREAM" ]; then
+ echo latest
+ return
+ fi
+ jq -r --arg upstream "$UPSTREAM" '.[$upstream] | to_entries[] | "\(.key) \(.value.version)"' "${1:-$UPSTREAM_FILE}"
+}
+
+next_version() {
+ local major minor patch
+ IFS=. read -r major minor patch <<< "${1%%-*}"
+ while true; do
+ case "$2" in
+ patch) patch=$((patch + 1)) ;;
+ minor) minor=$((minor + 1)) patch=0 ;;
+ major) major=$((major + 1)) minor=0 patch=0 ;;
+ esac
+ taken "$major.$minor.$patch" || break
+ done
+ echo "$major.$minor.$patch"
+}
+
+taken() {
+ local tag
+ for tag in $(release_tags "*v$1"); do
+ holder="$tag, which $branch does not contain"
+ git merge-base --is-ancestor "$tag" HEAD || return 0
+ done
+ [ "$channel" = preview ] && in_progress "$1"
+}
+
+in_progress() {
+ local tag release="release/v${1%.0}"
+ if [ "$release" != "$branch" ] && git rev-parse --quiet --verify "origin/$release" > /dev/null; then
+ holder="$release"
+ [ "$channel" = stable ] || return 0
+ git merge-base --is-ancestor "origin/$release" HEAD || return 0
+ fi
+ for tag in $(release_tags "*v$1-*"); do
+ holder="$tag, which $branch does not contain"
+ git merge-base --is-ancestor "$tag" HEAD || return 0
+ done
+ return 1
+}
+
+moved_tags() {
+ moving_tags <<< "$2" | grep -vxF -f <(moving_tags <<< "$1") || true
+}
+
+moving_tags() {
+ local tags releases latest preview base image sqlite prefix seen=" "
+ tags="$(cat)"
+ releases="$(sed -nE 's/^v((.*)-v)?([^-]*)$/\3 \2/p' <<< "$tags" | sort -k1,1rV -k2,2rV)"
+ latest="$(awk 'NR == 1 && NF { print "v" ($2 == "" ? "" : $2 "-v") $1 }' <<< "$releases")"
+ preview="$latest"
+ while read -r base; do
+ if newer "$base" "${latest##*v}"; then
+ preview="$(tag_versions <<< "$tags" | grep "^$base-" | newest)"
+ preview="$(with_version "$preview" <<< "$tags" | sort -V | tail -n 1)"
+ break
+ fi
+ done < <(tag_versions <<< "$tags" | sed -n 's/-.*//p' | sort -uV)
+ [ -z "$latest" ] || echo "latest $latest"
+ [ -z "$preview" ] || echo "preview $preview"
+ while read -r image sqlite; do
+ prefix="$sqlite"
+ while [ -n "$prefix" ] && [[ "$seen" != *" $prefix "* ]]; do
+ echo "$prefix v$sqlite-v$image"
+ seen+="$prefix "
+ [[ "$prefix" == *.* ]] || break
+ prefix="${prefix%.*}"
+ done
+ done <<< "$releases"
+}
+
+release_tags() {
+ git tag --list "$@" | filter_release_tags
+}
+
+filter_release_tags() {
+ grep -E "^v${UPSTREAM:+[^-]+-v}[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$" || true
+}
+
+tag_versions() {
+ sed 's/.*v//'
+}
+
+with_version() {
+ awk -v version="$1" '{ tagged = $0; sub(/.*v/, "", tagged) } tagged == version'
+}
+
+moved_to() {
+ awk -v tag="$1" '$2 == tag { print $1 }' <<< "$moves" | paste -sd , - | sed 's/,/, /g; s/\(.*\), /\1 and /'
+}
+
+newer() {
+ [ "$1" != "$2" ] && [ "$(printf '%s\n' "$1" "$2" | newest)" = "$1" ]
+}
+
+newest() {
+ sed 's/-/~/' | sort -V | sed 's/~/-/' | tail -n 1
+}
+
+main
diff --git a/scripts/release.sh b/scripts/release.sh
new file mode 100755
index 0000000000..79e19e8df8
--- /dev/null
+++ b/scripts/release.sh
@@ -0,0 +1,276 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+OPTION="$*"
+VERSION_FILE=release.json
+ARTIFACT=coolify
+UPSTREAM=
+BREAKING_CHANGE='^([a-z]+(\([^)]*\))?!|BREAKING[ -]CHANGE): '
+WORK_DIR="$(mktemp -d)"
+
+BOLD=$'\033[1m' BLUE=$'\033[34m' GREEN=$'\033[32m' YELLOW=$'\033[33m' RED=$'\033[31m' DIM=$'\033[2m' RESET=$'\033[0m'
+
+heading() { printf '\n %s%s%s\n' "$BOLD" "$*" "$RESET"; }
+info() { printf ' %sℹ%s %s\n' "$BLUE" "$RESET" "$*"; }
+ok() { printf ' %s✔%s %s\n' "$GREEN" "$RESET" "$*"; }
+warn() { printf ' %s⚠%s %s\n' "$YELLOW" "$RESET" "$*"; }
+die() {
+ printf ' %s✖%s %s\n' "$RED" "$RESET" "$*" >&2
+ exit 1
+}
+confirm() {
+ local answer
+ printf '\n %s [y/N] ' "$*"
+ read -r answer
+ [[ "$answer" =~ ^[Yy]$ ]]
+}
+hint() { printf ' %-26s%s%s%s\n' "$1" "$DIM" "$2" "$RESET"; }
+
+cd "$(dirname "$0")/.."
+trap 'rm -rf "$WORK_DIR"; tput cnorm 2> /dev/null || true' EXIT
+
+main() {
+ [ -z "$OPTION" ] || die "'$OPTION' is not an option, expected none"
+ check_repository
+ read_versions
+ read_changes
+ select_stage
+ select_version
+ heading "Releasing v$new_version"
+ update_changelog
+ update_version
+ commit
+ push
+}
+
+check_repository() {
+ local counts behind ahead entries
+ heading "Checking the repository"
+ git rev-parse --is-inside-work-tree > /dev/null 2>&1 || die "$PWD is not a git repository"
+ branch="$(git symbolic-ref --quiet --short HEAD)" || die "HEAD is detached, expected a branch"
+ [[ "$branch" =~ ^(main|next|v[0-9]+(\.[0-9]+)?\.x|release/v([0-9]+\.[0-9]+))$ ]] \
+ || die "$branch releases nothing"
+ line="${BASH_REMATCH[3]}"
+ channel=stable
+ [[ ! "$branch" =~ ^(next|release/) ]] || channel=preview
+ [ -z "$(git status --porcelain)" ] || die "the working tree is not clean, commit or stash first"
+ [ "$(git rev-parse --is-shallow-repository)" = false ] || die "the clone is shallow, run git fetch --unshallow first"
+ git fetch --quiet --prune --tags origin || die "could not fetch origin"
+ counts="$(git rev-list --left-right --count "origin/$branch...HEAD" 2> /dev/null)" || die "origin has no branch $branch, push it first"
+ read -r behind ahead <<< "$counts"
+ [ "$behind$ahead" = 00 ] || die "$branch is $ahead commit(s) ahead of and $behind behind origin/$branch, expected it in sync"
+ ok "$branch is clean and in sync with origin/$branch"
+ entries="$(awk '/^## \[Unreleased\]$/ { inside = 1; next } /^## / { inside = 0 } inside && /^- / { count++ } END { print count + 0 }' CHANGELOG.md)"
+ [ "$entries" != 0 ] || die "CHANGELOG.md has no entries under ## [Unreleased], describe the changes first"
+ ok "CHANGELOG.md lists $BOLD$entries$RESET change(s) under [Unreleased], the notes of this release"
+}
+
+read_versions() {
+ local released
+ version="$(artifact_version "$channel")"
+ released="$(artifact_version stable)"
+ [ -n "$version" ] && [ -n "$released" ] || die "$VERSION_FILE has no versions of $ARTIFACT"
+ stable="$(release_tags --merged HEAD | tag_versions | awk '!/-/' | sort -V | tail -n 1)"
+ stable="${stable:-$released}"
+ ! newer "$released" "$stable" || die "$VERSION_FILE sets the stable version $released, $branch released $stable last, publish $released first"
+ ok "$VERSION_FILE sets the $channel version $BOLD$version$RESET"
+}
+
+read_changes() {
+ local tag range hash subject
+ tag="$(git describe --tags --abbrev=0 --match "v${UPSTREAM:+*-v}$stable" 2> /dev/null)" || true
+ range="${tag:+$tag..}HEAD"
+ required="patch"
+ if [ -n "$(git log -E --grep="$BREAKING_CHANGE" --format=%h "$range")" ]; then
+ required=major
+ [ "${stable%%.*}" != 0 ] || required=minor
+ elif git log --format=%s "$range" | awk '/^feat(\([^)]*\))?!?: / && !/^feat\(release\): bump / { found = 1 } END { exit !found }'; then
+ required=minor
+ fi
+ info "the commits since ${tag:-the first commit} need a $BOLD$required$RESET release"
+ git log -E --grep="$BREAKING_CHANGE" --format='%h %s' "$range" | while read -r hash subject; do
+ warn "$DIM$hash$RESET $subject is a breaking change"
+ done
+}
+
+select_stage() {
+ local stages=(alpha beta rc) selected=0 i
+ stage=
+ [ "$channel" = preview ] || return 0
+ for i in "${!stages[@]}"; do
+ [[ "$version" != *-"${stages[$i]}".* ]] || selected="$i"
+ done
+ heading "Select the stage"
+ stage="${stages[$(menu "$selected" "${stages[@]}")]}"
+}
+
+select_version() {
+ local bases=() base candidate options=() selected=0
+ if [ -n "$line" ]; then
+ bases=("$line.0")
+ elif [ "$channel" = preview ]; then
+ bases=("$(next_version "$stable" minor)" "$(next_version "$stable" major)")
+ [ "$required" != major ] || selected=1
+ if [[ "$version" == *-* ]] && newer "${version%%-*}" "$stable"; then
+ if taken "${version%%-*}"; then
+ info "${version%%-*} is taken by $holder"
+ else
+ bases=("${version%%-*}" "${bases[@]}")
+ selected=0
+ fi
+ fi
+ elif [ "$stable" = 0.0.0 ]; then
+ bases=("$(next_version "$stable" minor)" "$(next_version "$stable" major)")
+ else
+ bases=("$(next_version "$stable" patch)")
+ [[ "$branch" == v*.*.x ]] || bases+=("$(next_version "$stable" minor)")
+ [ "$branch" != main ] || bases+=("$(next_version "$stable" major)")
+ case "$required" in
+ minor) selected=1 ;;
+ major) selected=2 ;;
+ esac
+ fi
+ for base in "${bases[@]}"; do
+ candidate="$base"
+ if [ -n "$stage" ]; then
+ candidate="$base-$stage.1"
+ [ "$base-$stage" != "${version%.*}" ] || candidate="$base-$stage.$((${version##*.} + 1))"
+ fi
+ if newer "$candidate" "$version" && [[ " ${options[*]:-} " != *" $candidate "* ]]; then
+ options+=("$candidate")
+ ! in_progress "$base" || warn "$base is in progress, held by $holder"
+ fi
+ done
+ [ -n "${options[*]:-}" ] || die "$branch is at $version, no $stage prerelease follows it"
+ [ "$selected" -lt "${#options[@]}" ] || selected=$((${#options[@]} - 1))
+ heading "Select the version"
+ new_version="${options[$(menu "$selected" "${options[@]/#/v}")]}"
+}
+
+update_changelog() {
+ local today
+ if [ "$channel" = preview ]; then
+ info "CHANGELOG.md keeps the entries under [Unreleased] until the stable release dates them"
+ return
+ fi
+ today="$(date +%Y-%m-%d)"
+ awk -v heading="## [v$new_version] - $today" '
+ /^#/ && NR > 1 && previous != "" { print "" }
+ { print; previous = $0 }
+ /^## \[Unreleased\]$/ { print ""; print heading; previous = heading }
+ ' CHANGELOG.md > "$WORK_DIR/CHANGELOG.md"
+ cp "$WORK_DIR/CHANGELOG.md" CHANGELOG.md
+ ok "CHANGELOG.md dates the unreleased entries as ${BOLD}v$new_version$RESET, $today"
+}
+
+update_version() {
+ jq --tab --arg channel "$channel" --arg artifact "$ARTIFACT" --arg version "$new_version" '.[$channel][$artifact] = $version' "$VERSION_FILE" > "$WORK_DIR/release.json"
+ cp "$WORK_DIR/release.json" "$VERSION_FILE"
+ ok "$VERSION_FILE sets the $channel version $version → $BOLD$new_version$RESET"
+}
+
+commit() {
+ git add "$VERSION_FILE" CHANGELOG.md
+ git commit --quiet -S --message "feat(release): bump $ARTIFACT version to v$new_version"
+ ok "signed commit $DIM$(git rev-parse --short HEAD)$RESET $(git log -1 --format=%s)"
+}
+
+push() {
+ local runs
+ if confirm "Push to origin/$branch?"; then
+ git push --quiet origin "$branch"
+ ok "pushed ${BOLD}v$new_version$RESET to origin/$branch"
+ runs="$(git remote get-url origin | sed -E 's#^(ssh://)?git@github\.com[:/]#https://github.com/#; s#\.git$##')/actions/workflows/release.yaml?query=branch%3A$branch"
+ info "the release workflow runs at $runs"
+ else
+ info "not pushed"
+ heading "Next"
+ hint "git push" "push the release commit later"
+ hint "scripts/undo-release.sh" "undo the release commit"
+ fi
+}
+
+artifact_version() {
+ jq -r --arg channel "$1" --arg artifact "$ARTIFACT" '.[$channel][$artifact] // empty' "$VERSION_FILE"
+}
+
+release_tags() {
+ git tag --list "$@" | grep -E "^v${UPSTREAM:+[^-]+-v}[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$" || true
+}
+
+tag_versions() {
+ sed 's/.*v//'
+}
+
+next_version() {
+ local major minor patch
+ IFS=. read -r major minor patch <<< "${1%%-*}"
+ while true; do
+ case "$2" in
+ patch) patch=$((patch + 1)) ;;
+ minor) minor=$((minor + 1)) patch=0 ;;
+ major) major=$((major + 1)) minor=0 patch=0 ;;
+ esac
+ taken "$major.$minor.$patch" || break
+ done
+ echo "$major.$minor.$patch"
+}
+
+taken() {
+ local tag
+ for tag in $(release_tags "*v$1"); do
+ holder="$tag, which $branch does not contain"
+ git merge-base --is-ancestor "$tag" HEAD || return 0
+ done
+ [ "$channel" = preview ] && in_progress "$1"
+}
+
+in_progress() {
+ local tag release="release/v${1%.0}"
+ if [ "$release" != "$branch" ] && git rev-parse --quiet --verify "origin/$release" > /dev/null; then
+ holder="$release"
+ [ "$channel" = stable ] || return 0
+ git merge-base --is-ancestor "origin/$release" HEAD || return 0
+ fi
+ for tag in $(release_tags "*v$1-*"); do
+ holder="$tag, which $branch does not contain"
+ git merge-base --is-ancestor "$tag" HEAD || return 0
+ done
+ return 1
+}
+
+newer() {
+ [ "$1" != "$2" ] && [ "$(printf '%s\n' "$1" "$2" | sed 's/-/~/' | sort -V | tail -n 1 | sed 's/~/-/')" = "$1" ]
+}
+
+menu() {
+ local selected="$1" options key i
+ shift
+ options=("$@")
+ tput civis >&2 2> /dev/null || true
+ while true; do
+ for i in "${!options[@]}"; do
+ if [ "$i" = "$selected" ]; then
+ printf ' %s❯ %s%s\n' "$GREEN" "${options[$i]}" "$RESET" >&2
+ else
+ printf ' %s %s%s\n' "$DIM" "${options[$i]}" "$RESET" >&2
+ fi
+ done
+ IFS= read -rsn1 key
+ if [ "$key" = $'\x1b' ]; then
+ IFS= read -rsn2 key
+ if [ "$key" = '[A' ] && [ "$selected" -gt 0 ]; then
+ selected=$((selected - 1))
+ elif [ "$key" = '[B' ] && [ "$selected" -lt $((${#options[@]} - 1)) ]; then
+ selected=$((selected + 1))
+ fi
+ elif [ -z "$key" ]; then
+ break
+ fi
+ printf '\033[%dA\033[J' "${#options[@]}" >&2
+ done
+ tput cnorm >&2 2> /dev/null || true
+ echo "$selected"
+}
+
+main
diff --git a/scripts/run b/scripts/run
index d605cfc574..9dbeada869 100755
--- a/scripts/run
+++ b/scripts/run
@@ -27,10 +27,6 @@ function test {
docker compose -f docker-compose.yml -f docker-compose.dev.yml exec -T coolify php artisan test --testsuite=Feature -p
}
-function sync:cdn {
- php artisan sync:cdn --env=secrets
-}
-
function db:reset {
docker compose -f docker-compose.yml -f docker-compose.dev.yml exec -u www-data coolify php artisan migrate:fresh --seed
}
diff --git a/scripts/sync-cdn.sh b/scripts/sync-cdn.sh
new file mode 100755
index 0000000000..067c428520
--- /dev/null
+++ b/scripts/sync-cdn.sh
@@ -0,0 +1,142 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+OPTION="$*"
+CHANNEL="${1:-}"
+VERSION="${2:-}"
+CDN_REPOSITORY=coollabsio/coollabs-cdn
+CDN_URL=https://cdn.coollabs.io
+FILES=versions.json,releases.json,docker-compose.yml,docker-compose.prod.yml,.env.production,install.sh,upgrade.sh,upgrade-postgres.sh,patch.sh,service-templates-latest.json
+WORK_DIR="$(mktemp -d)"
+
+BOLD=$'\033[1m' BLUE=$'\033[34m' GREEN=$'\033[32m' RED=$'\033[31m' RESET=$'\033[0m'
+
+info() { printf ' %sℹ%s %s\n' "$BLUE" "$RESET" "$*"; }
+ok() { printf ' %s✔%s %s\n' "$GREEN" "$RESET" "$*"; }
+die() {
+ printf ' %s✖%s %s\n' "$RED" "$RESET" "$*" >&2
+ exit 1
+}
+
+cd "$(dirname "$0")/.."
+trap 'rm -rf "$WORK_DIR"' EXIT
+
+main() {
+ [[ "$OPTION" =~ ^(--stable [0-9]+\.[0-9]+\.[0-9]+|--preview [0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?|--nightly [0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)\.[0-9]+\.[0-9]{8}\.[0-9]+|--purge)$ ]] \
+ || die "'$OPTION' is not an option, expected --stable, --preview or --nightly with the version of the channel, or --purge"
+ if [ "$OPTION" = --purge ]; then
+ purge_cache
+ return
+ fi
+ select_channel
+ write_versions
+ fetch_releases
+ clone_cdn
+ write_release
+ commit_channel
+ publish_channel
+}
+
+select_channel() {
+ channel="${CHANNEL#--}"
+ channel_path=json/coolify channel_url="$CDN_URL/coolify"
+ if [ "$channel" != stable ]; then
+ channel_path+="/$channel" channel_url+="/$channel"
+ fi
+ if [[ "$channel:$VERSION" == preview:*-alpha.* ]]; then
+ info "the preview channel takes no alphas, Coolify $VERSION reaches the nightly channel only"
+ exit 0
+ fi
+ info "syncing $channel_url to Coolify $BOLD$VERSION$RESET"
+}
+
+write_versions() {
+ jq --indent 4 --arg version "$VERSION" '{coolify: ({v4: {$version}} + del(.traefik)), traefik}' upstream.json > "$WORK_DIR/versions.json"
+ ok "versions.json names Coolify $VERSION and the helper, Sentinel and Traefik versions of upstream.json"
+}
+
+fetch_releases() {
+ gh api "repos/coollabsio/coolify/releases?per_page=30" | jq . > "$WORK_DIR/releases.json"
+ ok "releases.json lists the $(jq length "$WORK_DIR/releases.json") newest GitHub releases"
+}
+
+clone_cdn() {
+ gh repo clone "$CDN_REPOSITORY" "$WORK_DIR/cdn" -- --quiet --depth 1
+ ok "cloned $CDN_REPOSITORY"
+}
+
+write_release() {
+ local release="$WORK_DIR/cdn/json/coolify/release.json"
+ [ -f "$release" ] || echo '{}' > "$release"
+ jq --tab --arg channel "$channel" --arg version "$VERSION" '.[$channel].coolify = $version | {stable, preview, nightly} | with_entries(select(.value))' \
+ "$release" > "$WORK_DIR/release.json"
+ cp "$WORK_DIR/release.json" "$release"
+ ok "release.json sets the $channel version $BOLD$VERSION$RESET"
+}
+
+commit_channel() {
+ local name count
+ mkdir -p "$WORK_DIR/cdn/$channel_path"
+ for name in ${FILES//,/ }; do
+ channel_file "$name" > "$WORK_DIR/cdn/$channel_path/$name"
+ done
+ cdn add "$channel_path" json/coolify/release.json
+ if cdn diff --cached --quiet; then
+ ok "$channel_path on $CDN_REPOSITORY is up to date"
+ exit 0
+ fi
+ title="chore: sync the $channel channel to Coolify $VERSION"
+ count="$(cdn diff --cached --name-only | wc -l | tr -d ' ')"
+ cdn commit --quiet --message "$title"
+ ok "committed $count changed file(s) of $channel_path"
+}
+
+publish_channel() {
+ local previous from branch pull_request
+ if [ "$channel" != stable ]; then
+ cdn push --quiet origin HEAD:main
+ ok "pushed the $channel channel to main of $CDN_REPOSITORY"
+ return
+ fi
+ previous="$(cdn show HEAD~1:json/coolify/versions.json 2> /dev/null | jq -r '.coolify.v4.version // empty' || true)"
+ [ "$previous" = "$VERSION" ] || from="${previous:+from Coolify $previous to }"
+ branch="sync/stable-$(date -u +%Y%m%d%H%M%S)"
+ cdn push --quiet origin "HEAD:refs/heads/$branch"
+ pull_request="$(gh pr create --repo "$CDN_REPOSITORY" --base main --head "$branch" --title "$title" \
+ --body "Syncs the stable channel ${from:-to Coolify }$VERSION, see the [release notes](https://github.com/coollabsio/coolify/releases/tag/v$VERSION).")"
+ ok "opened $pull_request"
+ info "Cloudflare keeps the old files for up to 10 minutes, scripts/sync-cdn.sh --purge clears them once this pull request is merged and deployed"
+}
+
+purge_cache() {
+ local token zone
+ token="${CLOUDFLARE_API_TOKEN:-$(env_value CLOUDFLARE_API_TOKEN)}"
+ zone="${CLOUDFLARE_ZONE_ID:-$(env_value CLOUDFLARE_ZONE_ID)}"
+ [ -n "$token" ] && [ -n "$zone" ] || die "CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID are set neither in the environment nor in .env, expected a token that can purge the cache of the zone"
+ clone_cdn
+ cdn ls-files json/coolify | sed "s|^json/|$CDN_URL/|" | xargs -n 30 | while read -r urls; do
+ curl --proto '=https' --tlsv1.2 -sSf --retry 3 -H "Authorization: Bearer $token" -H "Content-Type: application/json" \
+ --data "$(tr ' ' '\n' <<< "$urls" | jq -Rnc '{files: [inputs]}')" "https://api.cloudflare.com/client/v4/zones/$zone/purge_cache" \
+ | jq -e .success > /dev/null || die "Cloudflare did not purge the files of $CDN_URL/coolify"
+ done
+ ok "purged every file of $CDN_URL/coolify from the Cloudflare cache"
+}
+
+channel_file() {
+ case "$1" in
+ versions.json | releases.json) cat "$WORK_DIR/$1" ;;
+ service-templates-latest.json) cat "templates/$1" ;;
+ *.sh) sed "s|^CDN=\"$CDN_URL/coolify\"$|CDN=\"$channel_url\"|" "scripts/$1" ;;
+ *) cat "$1" ;;
+ esac
+}
+
+cdn() {
+ git -C "$WORK_DIR/cdn" "$@"
+}
+
+env_value() {
+ [ ! -f .env ] || sed -n "s/^$1=//p" .env | tail -n 1 | tr -d "\"'"
+}
+
+main
diff --git a/scripts/undo-release.sh b/scripts/undo-release.sh
new file mode 100755
index 0000000000..843e107c9e
--- /dev/null
+++ b/scripts/undo-release.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+OPTION="$*"
+VERSION_FILE=release.json
+
+BOLD=$'\033[1m' BLUE=$'\033[34m' GREEN=$'\033[32m' RED=$'\033[31m' DIM=$'\033[2m' RESET=$'\033[0m'
+
+heading() { printf '\n %s%s%s\n' "$BOLD" "$*" "$RESET"; }
+info() { printf ' %sℹ%s %s\n' "$BLUE" "$RESET" "$*"; }
+ok() { printf ' %s✔%s %s\n' "$GREEN" "$RESET" "$*"; }
+die() {
+ printf ' %s✖%s %s\n' "$RED" "$RESET" "$*" >&2
+ exit 1
+}
+confirm() {
+ local answer
+ printf '\n %s [y/N] ' "$*"
+ read -r answer
+ [[ "$answer" =~ ^[Yy]$ ]]
+}
+
+cd "$(dirname "$0")/.."
+
+main() {
+ [ -z "$OPTION" ] || die "'$OPTION' is not an option, expected none"
+ check_repository
+ heading "Undoing $(git log -1 --format='%h %s')"
+ info "resets the commit and restores $VERSION_FILE and CHANGELOG.md"
+ confirm "Continue?" || die "aborted, nothing changed"
+ undo_commit
+}
+
+check_repository() {
+ local branch
+ git rev-parse --is-inside-work-tree > /dev/null 2>&1 || die "$PWD is not a git repository"
+ branch="$(git rev-parse --abbrev-ref HEAD)"
+ git diff --quiet HEAD~1 HEAD -- . ":(exclude)$VERSION_FILE" ":(exclude)CHANGELOG.md" || die "the last commit changes more than $VERSION_FILE and CHANGELOG.md, expected a release commit"
+ [ "$(versions_at HEAD~1)" != "$(versions_at HEAD)" ] || die "the last commit keeps the versions in $VERSION_FILE at $(versions_at HEAD), expected a release commit"
+ [ -z "$(git status --porcelain -- "$VERSION_FILE" CHANGELOG.md)" ] || die "$VERSION_FILE or CHANGELOG.md has uncommitted changes, commit or stash them first"
+ git fetch --quiet origin || die "could not fetch origin"
+ [ "$(git rev-list --count "origin/$branch..HEAD")" -ge 1 ] || die "the release commit is on origin/$branch already, nothing to undo locally"
+}
+
+undo_commit() {
+ git reset --quiet --soft HEAD~1
+ git restore --staged --worktree "$VERSION_FILE" CHANGELOG.md
+ ok "reset to $DIM$(git rev-parse --short HEAD)$RESET $(git log -1 --format=%s)"
+ ok "$VERSION_FILE is back to $BOLD$(versions_at HEAD)$RESET, the entries are back under [Unreleased]"
+}
+
+versions_at() {
+ git show "$1:$VERSION_FILE" | jq -r '[paths(scalars) as $path | "\($path | join(".")) \(getpath($path))"] | join(", ")'
+}
+
+main
diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh
index e39f6378e7..af8d12c67a 100644
--- a/scripts/upgrade.sh
+++ b/scripts/upgrade.sh
@@ -318,7 +318,7 @@ log "All images pulled successfully"
echo " All images pulled successfully."
set_env_var "LATEST_IMAGE" "$LATEST_IMAGE"
-set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE"
+sed -i '/^COOLIFY_VERSION=/d' "$ENV_FILE"
log_section "Step 4/6: Stopping and restarting containers"
write_status "4" "Stopping containers"
diff --git a/tests/Feature/Jobs/CheckTraefikVersionJobTest.php b/tests/Feature/Jobs/CheckTraefikVersionJobTest.php
index 66b40a717b..71ab87cfb9 100644
--- a/tests/Feature/Jobs/CheckTraefikVersionJobTest.php
+++ b/tests/Feature/Jobs/CheckTraefikVersionJobTest.php
@@ -55,8 +55,8 @@ it('notification settings have traefik_outdated fields', function () {
expect($team->webhookNotificationSettings)->toHaveKey('traefik_outdated_webhook_notifications');
});
-it('versions.json contains traefik branches with patch versions', function () {
- $versionsPath = base_path('versions.json');
+it('upstream.json contains traefik branches with patch versions', function () {
+ $versionsPath = base_path('upstream.json');
expect(File::exists($versionsPath))->toBeTrue();
$versions = json_decode(File::get($versionsPath), true);
diff --git a/tests/Feature/PersistentImageStorageTest.php b/tests/Feature/PersistentImageStorageTest.php
index 59d88e3f0b..1439f00302 100644
--- a/tests/Feature/PersistentImageStorageTest.php
+++ b/tests/Feature/PersistentImageStorageTest.php
@@ -14,7 +14,6 @@ it('persists images in stable and nightly production compose files', function (s
->toContain('/data/coolify/images:/var/www/html/storage/app/images');
})->with([
'stable' => 'docker-compose.prod.yml',
- 'nightly' => 'other/nightly/docker-compose.prod.yml',
]);
it('persists images in the Windows compose file', function () {
diff --git a/tests/Feature/PostgresUpgradeScriptTest.php b/tests/Feature/PostgresUpgradeScriptTest.php
index bdb6f7c4e4..79b3c7e315 100644
--- a/tests/Feature/PostgresUpgradeScriptTest.php
+++ b/tests/Feature/PostgresUpgradeScriptTest.php
@@ -49,9 +49,6 @@ it('detects the current Coolify image tag from the running container image', fun
expect($exitCode)->toBe(0)
->and($output)->toBe($expectedTag);
})->with([
- 'nightly standard registry image' => ['other/nightly/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.0-beta.420', '4.0.0-beta.420'],
- 'nightly registry with port' => ['other/nightly/upgrade-postgres.sh', 'registry.example.com:5000/coollabsio/coolify:4.0.1', '4.0.1'],
- 'nightly digest suffix' => ['other/nightly/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.2@sha256:abcdef', '4.0.2'],
'scripts standard registry image' => ['scripts/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.0-beta.420', '4.0.0-beta.420'],
'scripts registry with port' => ['scripts/upgrade-postgres.sh', 'registry.example.com:5000/coollabsio/coolify:4.0.1', '4.0.1'],
'scripts digest suffix' => ['scripts/upgrade-postgres.sh', 'ghcr.io/coollabsio/coolify:4.0.2@sha256:abcdef', '4.0.2'],
@@ -66,6 +63,5 @@ it('passes the preserved Coolify image tag to docker compose when starting the s
->and($output)->toBe('')
->and($latestImage)->toBe('4.0.0-beta.420');
})->with([
- 'nightly script' => 'other/nightly/upgrade-postgres.sh',
'release script' => 'scripts/upgrade-postgres.sh',
]);
diff --git a/tests/Feature/ReverbAndTerminalPackagingTest.php b/tests/Feature/ReverbAndTerminalPackagingTest.php
index df1932ad49..ef8f39684c 100644
--- a/tests/Feature/ReverbAndTerminalPackagingTest.php
+++ b/tests/Feature/ReverbAndTerminalPackagingTest.php
@@ -70,7 +70,6 @@ it('rewrites the legacy realtime backend host during upgrades', function (string
->toContain('set_env_var "PUSHER_BACKEND_HOST" "127.0.0.1"');
})->with([
'upgrade script' => ['scripts/upgrade.sh'],
- 'nightly upgrade script' => ['other/nightly/upgrade.sh'],
]);
it('includes Reverb but not the terminal server in the Coolify container healthcheck', function (string $composeFile) {
@@ -82,9 +81,7 @@ it('includes Reverb but not the terminal server in the Coolify container healthc
->not->toContain('6002/ready');
})->with([
'production compose' => ['docker-compose.prod.yml'],
- 'nightly production compose' => ['other/nightly/docker-compose.prod.yml'],
'windows compose' => ['docker-compose.windows.yml'],
- 'nightly windows compose' => ['other/nightly/docker-compose.windows.yml'],
]);
it('removes the legacy realtime container during upgrades', function (string $script) {
@@ -92,7 +89,6 @@ it('removes the legacy realtime container during upgrades', function (string $sc
->toContain('for container in coolify coolify-db coolify-redis coolify-realtime; do');
})->with([
'upgrade script' => ['scripts/upgrade.sh'],
- 'nightly upgrade script' => ['other/nightly/upgrade.sh'],
]);
it('runs Reverb and terminal websocket services inside the Coolify containers', function (string $dockerfile, string $dependencyService) {
@@ -195,11 +191,6 @@ it('uses Pusher environment keys for self-hosted Reverb compatibility', function
'docker-compose.windows.yml',
'scripts/install.sh',
'scripts/upgrade.sh',
- 'other/nightly/.env.production',
- 'other/nightly/docker-compose.prod.yml',
- 'other/nightly/docker-compose.windows.yml',
- 'other/nightly/install.sh',
- 'other/nightly/upgrade.sh',
];
foreach ($files as $file) {
@@ -230,9 +221,7 @@ it('does not use the browser websocket port as the Docker host port', function (
->not->toContain('"${PUSHER_PORT:-6001}:6001"');
})->with([
'production compose' => ['docker-compose.prod.yml'],
- 'nightly production compose' => ['other/nightly/docker-compose.prod.yml'],
'windows compose' => ['docker-compose.windows.yml'],
- 'nightly windows compose' => ['other/nightly/docker-compose.windows.yml'],
]);
it('preserves existing public websocket port overrides during install and upgrade', function (string $script) {
@@ -244,28 +233,22 @@ it('preserves existing public websocket port overrides during install and upgrad
})->with([
'install script' => ['scripts/install.sh'],
'upgrade script' => ['scripts/upgrade.sh'],
- 'nightly install script' => ['other/nightly/install.sh'],
- 'nightly upgrade script' => ['other/nightly/upgrade.sh'],
]);
it('stops publishing or preserving the obsolete realtime image', function () {
$productionInstallScript = file_get_contents(base_path('scripts/install.sh'));
- $nightlyInstallScript = file_get_contents(base_path('other/nightly/install.sh'));
expect(file_get_contents(config_path('constants.php')))
->not->toContain('realtime_version')
->not->toContain('realtime_image')
->and(file_get_contents(app_path('Actions/Server/CleanupDocker.php')))
->not->toContain('realtimeImage')
- ->and(file_get_contents(base_path('versions.json')))
+ ->and(file_get_contents(base_path('upstream.json')))
->not->toContain('"realtime"')
->and(is_dir(base_path('docker/coolify-realtime')))->toBeFalse()
->and(file_exists(base_path('.github/workflows/coolify-realtime.yml')))->toBeFalse()
->and(file_exists(base_path('.github/workflows/coolify-realtime-next.yml')))->toBeFalse()
- ->and(file_get_contents(base_path('.github/workflows/coolify-next-build.yml')))->not->toContain('coolify-realtime')
->and($productionInstallScript)->not->toContain('LATEST_REALTIME_VERSION')
- ->not->toContain('| Realtime')
- ->and($nightlyInstallScript)->not->toContain('LATEST_REALTIME_VERSION')
->not->toContain('| Realtime');
});
@@ -279,7 +262,6 @@ it('keeps 4.3.23 realtime setups working with the production compose files', fun
->and(implode("\n", [...$coolify['ports'], ...$coolify['expose']]))->not->toContain('TERMINAL_PORT');
})->with([
'production compose' => ['docker-compose.prod.yml'],
- 'nightly production compose' => ['other/nightly/docker-compose.prod.yml'],
]);
it('keeps TERMINAL_PORT browser-only in the Windows compose files', function (string $composeFile) {
@@ -289,7 +271,6 @@ it('keeps TERMINAL_PORT browser-only in the Windows compose files', function (st
->and(implode("\n", [...$coolify['ports'], ...$coolify['expose']]))->not->toContain('TERMINAL_PORT');
})->with([
'windows compose' => ['docker-compose.windows.yml'],
- 'nightly windows compose' => ['other/nightly/docker-compose.windows.yml'],
]);
it('resolves the browser websocket port like 4.3.23', function (string $pageUrl, ?string $pusherPort, ?string $expectedPort) {
@@ -336,7 +317,6 @@ it('accepts a soketi override from 4.3.23 in docker-compose.custom.yml with zero
})->with([
'production compose, partial override' => ['docker-compose.prod.yml', "services:\n soketi:\n environment:\n SOKETI_DEBUG: 'true'\n ports:\n - '6001:6001'\n"],
'production compose, full service' => ['docker-compose.prod.yml', "services:\n soketi:\n image: quay.io/soketi/soketi:1.6-16-alpine\n container_name: coolify-realtime\n ports:\n - '6001:6001'\n"],
- 'nightly production compose, partial override' => ['other/nightly/docker-compose.prod.yml', "services:\n soketi:\n environment:\n SOKETI_DEBUG: 'true'\n"],
]);
it('does not pull an extra image for the soketi placeholder', function (string $composeFile) {
@@ -363,5 +343,4 @@ it('does not pull an extra image for the soketi placeholder', function (string $
->toBe(['postgres:15-alpine', 'redis:7-alpine', 'registry.example.com/coollabsio/coolify:4.5.1']);
})->with([
'production compose' => ['docker-compose.prod.yml'],
- 'nightly production compose' => ['other/nightly/docker-compose.prod.yml'],
]);
diff --git a/tests/Feature/SyncCdnTest.php b/tests/Feature/SyncCdnTest.php
deleted file mode 100644
index 52e0ea8945..0000000000
--- a/tests/Feature/SyncCdnTest.php
+++ /dev/null
@@ -1,212 +0,0 @@
-getDefinition();
-
- expect($definition->hasOption('bunny'))->toBeFalse()
- ->and($definition->hasOption('github-releases'))->toBeFalse()
- ->and($definition->hasOption('release'))->toBeFalse()
- ->and($definition->hasOption('nightly'))->toBeFalse()
- ->and($definition->hasOption('templates'))->toBeFalse();
-});
-
-it('loads service templates from the Coollabs CDN', function () {
- expect(config('constants.services.official'))
- ->toBe('https://cdn.coollabs.io/coolify/service-templates-latest.json');
-});
-
-it('only removes validated Coolify CDN temporary directories', function () {
- $command = new class extends SyncCdn
- {
- public function removeDirectory(string $path): void
- {
- $this->removeTemporaryDirectory($path);
- }
- };
-
- $invalidDirectory = sys_get_temp_dir().'/unrelated-directory-'.uniqid();
- $validDirectory = sys_get_temp_dir().'/coollabs-cdn-files-'.uniqid();
- mkdir($invalidDirectory);
- mkdir($validDirectory);
-
- $command->removeDirectory('');
- $command->removeDirectory($invalidDirectory);
- $command->removeDirectory($validDirectory);
-
- expect($invalidDirectory)->toBeDirectory()
- ->and($validDirectory)->not->toBeDirectory();
-
- rmdir($invalidDirectory);
-});
-
-it('selects the environment and release files to sync to GitHub', function (string $targetDirectory, string $environment, array $selectedBasenames) {
- Http::fake([
- 'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([], 200),
- ]);
-
- $binDir = sys_get_temp_dir().'/sync-cdn-bin-'.uniqid();
- $logFile = sys_get_temp_dir().'/sync-cdn-'.uniqid().'.log';
-
- mkdir($binDir, 0755, true);
-
- createFakeSyncCdnBinary($binDir, 'gh', <<<'SH'
-#!/bin/sh
-printf 'gh %s\n' "$*" >> "$SYNC_CDN_TEST_LOG"
-if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then
- mkdir -p "$4"
-fi
-exit 0
-SH);
-
- createFakeSyncCdnBinary($binDir, 'git', <<<'SH'
-#!/bin/sh
-printf 'git %s\n' "$*" >> "$SYNC_CDN_TEST_LOG"
-if [ "$1" = "status" ]; then
- printf 'M json/releases.json\n'
-fi
-if [ "$1" = "diff" ]; then
- if [ -f json/coolify/nightly/releases.json ]; then
- printf 'json/coolify/nightly/releases.json\n'
- else
- printf 'json/coolify/releases.json\n'
- fi
-fi
-exit 0
-SH);
-
- $originalPath = getenv('PATH') ?: '';
- putenv("PATH={$binDir}:{$originalPath}");
- putenv("SYNC_CDN_TEST_LOG={$logFile}");
-
- $allBasenames = [
- 'releases.json',
- 'versions.json',
- 'docker-compose.yml',
- 'docker-compose.prod.yml',
- '.env.production',
- 'install.sh',
- 'upgrade.sh',
- 'upgrade-postgres.sh',
- ...($environment === 'production' ? ['patch.sh'] : []),
- 'service-templates-latest.json',
- ];
- $allTargets = array_map(fn (string $file) => "$targetDirectory/$file", $allBasenames);
- $selectedTargets = array_map(fn (string $file) => "$targetDirectory/$file", $selectedBasenames);
-
- try {
- $this->artisan('sync:cdn')
- ->expectsChoice('Which environment would you like to sync?', $environment, [
- 'production' => 'Production',
- 'nightly' => 'Nightly',
- ])
- ->expectsChoice('Which files would you like to sync?', $selectedTargets, $allTargets)
- ->assertExitCode(0);
- } finally {
- putenv("PATH={$originalPath}");
- putenv('SYNC_CDN_TEST_LOG');
- }
-
- $log = file_get_contents($logFile);
-
- expect($log)
- ->toContain('gh pr create --repo coollabsio/coollabs-cdn')
- ->not->toContain('coollabsio/coolify-cdn');
-
- foreach ($selectedTargets as $selectedTarget) {
- expect($log)->toContain($selectedTarget);
- }
-
- foreach (array_diff($allTargets, $selectedTargets) as $unselectedTarget) {
- expect($log)->not->toContain($unselectedTarget);
- }
-
- $pullRequestCommand = substr($log, strrpos($log, 'gh pr create'));
-
- expect($pullRequestCommand)
- ->toContain("$targetDirectory/releases.json")
- ->not->toContain("$targetDirectory/versions.json");
-
- Http::assertSentCount(1);
-})->with([
- 'select production files' => ['json/coolify', 'production', ['releases.json', 'versions.json']],
- 'select nightly with all files selected by default' => ['json/coolify/nightly', 'nightly', [
- 'releases.json',
- 'versions.json',
- 'docker-compose.yml',
- 'docker-compose.prod.yml',
- '.env.production',
- 'install.sh',
- 'upgrade.sh',
- 'upgrade-postgres.sh',
- 'service-templates-latest.json',
- ]],
-]);
-
-it('purges the CDN files from the Cloudflare cache', function (string $environment, array $cdnDirectories) {
- config()->set('constants.cloudflare.api_token', 'test-token');
- config()->set('constants.cloudflare.zone_id', 'test-zone');
-
- Http::fake([
- 'https://api.cloudflare.com/*' => Http::response(['success' => true], 200),
- ]);
-
- $this->artisan('sync:cdn --purge')
- ->expectsChoice('Which environment would you like to sync?', $environment, [
- 'production' => 'Production',
- 'nightly' => 'Nightly',
- ])
- ->assertExitCode(0);
-
- Http::assertSentCount(1);
- Http::assertSent(fn ($request) => $request->method() === 'POST'
- && $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache'
- && $request->hasHeader('Authorization', 'Bearer test-token')
- && $request['files'] === collect($cdnDirectories)->flatMap(fn (string $cdnDirectory) => array_map(fn (string $file) => "https://cdn.coollabs.io/$cdnDirectory/$file", [
- 'releases.json',
- 'versions.json',
- 'docker-compose.yml',
- 'docker-compose.prod.yml',
- '.env.production',
- 'install.sh',
- 'upgrade.sh',
- 'upgrade-postgres.sh',
- ...($environment === 'production' ? ['patch.sh'] : []),
- 'service-templates-latest.json',
- ]))->all());
-})->with([
- 'production' => ['production', ['coolify']],
- 'nightly with legacy path' => ['nightly', ['coolify/nightly', 'coolify-nightly']],
-]);
-
-it('fails the Cloudflare purge without credentials or on an API error', function (?string $apiToken, array $response) {
- config()->set('constants.cloudflare.api_token', $apiToken);
- config()->set('constants.cloudflare.zone_id', 'test-zone');
-
- Http::fake([
- 'https://api.cloudflare.com/*' => Http::response($response, 400),
- ]);
-
- $this->artisan('sync:cdn --purge')
- ->expectsChoice('Which environment would you like to sync?', 'production', [
- 'production' => 'Production',
- 'nightly' => 'Nightly',
- ])
- ->assertExitCode(1);
-
- if ($apiToken === null) {
- Http::assertNothingSent();
- }
-})->with([
- 'missing token' => [null, []],
- 'api error' => ['test-token', ['success' => false, 'errors' => [['code' => 10000, 'message' => 'Authentication error']]]],
-]);
diff --git a/tests/Unit/InstallScriptAuthorizedKeysTest.php b/tests/Unit/InstallScriptAuthorizedKeysTest.php
index a105cea8b0..3caaa213ce 100644
--- a/tests/Unit/InstallScriptAuthorizedKeysTest.php
+++ b/tests/Unit/InstallScriptAuthorizedKeysTest.php
@@ -38,5 +38,4 @@ it('removes only previous Coolify keys from authorized_keys', function (string $
exec('rm -rf '.escapeshellarg($home));
})->with([
'stable install script' => ['scripts/install.sh'],
- 'nightly install script' => ['other/nightly/install.sh'],
]);
diff --git a/tests/Unit/InstallScriptDirectoryPermissionsTest.php b/tests/Unit/InstallScriptDirectoryPermissionsTest.php
index e43e0ccff2..259ad41d13 100644
--- a/tests/Unit/InstallScriptDirectoryPermissionsTest.php
+++ b/tests/Unit/InstallScriptDirectoryPermissionsTest.php
@@ -46,7 +46,6 @@ function installScriptFileMode(string $path): string
$installScripts = [
'stable install' => ['scripts/install.sh', ['source', 'ssh', 'images']],
- 'nightly install' => ['other/nightly/install.sh', ['source', 'ssh', 'images']],
];
$resourceDirectories = ['applications', 'databases', 'backups', 'services', 'proxy', 'sentinel'];
diff --git a/tests/Unit/InstallScriptDockerDaemonConfigTest.php b/tests/Unit/InstallScriptDockerDaemonConfigTest.php
index 2dab67a70d..f25bb21136 100644
--- a/tests/Unit/InstallScriptDockerDaemonConfigTest.php
+++ b/tests/Unit/InstallScriptDockerDaemonConfigTest.php
@@ -56,7 +56,7 @@ function runInstallScriptDockerDaemonStep(string $scriptPath, ?string $daemonJso
return ['config' => $config, 'restarted' => str_contains($output, 'RESTARTED'), 'output' => $output];
}
-$installScripts = ['stable install' => 'scripts/install.sh', 'nightly install' => 'other/nightly/install.sh'];
+$installScripts = ['stable install' => 'scripts/install.sh'];
it('does not create daemon.json or restart Docker when containers are running', function (string $scriptPath) {
$result = runInstallScriptDockerDaemonStep($scriptPath, null, runningContainers: 30);
diff --git a/tests/Unit/InstallScriptRockyDockerRepoTest.php b/tests/Unit/InstallScriptRockyDockerRepoTest.php
index 0e0a92806d..926bc14df3 100644
--- a/tests/Unit/InstallScriptRockyDockerRepoTest.php
+++ b/tests/Unit/InstallScriptRockyDockerRepoTest.php
@@ -22,7 +22,3 @@ function expectRockyInstallScriptToUseRhelRepo(string $path): void
it('uses the rocky linux documented docker install flow in the stable install script', function () {
expectRockyInstallScriptToUseRhelRepo('scripts/install.sh');
});
-
-it('uses the rocky linux documented docker install flow in the nightly install script', function () {
- expectRockyInstallScriptToUseRhelRepo('other/nightly/install.sh');
-});
diff --git a/tests/Unit/InstallScriptRootUserEnvTest.php b/tests/Unit/InstallScriptRootUserEnvTest.php
index aab987d738..a067e688d7 100644
--- a/tests/Unit/InstallScriptRootUserEnvTest.php
+++ b/tests/Unit/InstallScriptRootUserEnvTest.php
@@ -41,7 +41,7 @@ function writeRootUserCredentialsWithInstallScript(string $scriptPath, array $cr
return Dotenv::parse($contents);
}
-$installScripts = ['stable install' => 'scripts/install.sh', 'nightly install' => 'other/nightly/install.sh'];
+$installScripts = ['stable install' => 'scripts/install.sh'];
$credentials = [
'username with a space' => ['ROOT_USERNAME', 'Coolify Lab'],
diff --git a/tests/Unit/InstallScriptTerminalUiTest.php b/tests/Unit/InstallScriptTerminalUiTest.php
index b5cc1628ff..4dc5454a59 100644
--- a/tests/Unit/InstallScriptTerminalUiTest.php
+++ b/tests/Unit/InstallScriptTerminalUiTest.php
@@ -25,7 +25,6 @@ it('shows every installer step with the terminal UI and sends command output to
->and(preg_match_all('/^step_done(?!\()/m', $script))->toBe(9);
})->with([
'stable install script' => ['scripts/install.sh'],
- 'nightly install script' => ['other/nightly/install.sh'],
]);
it('renders aligned step lines, warnings and failures without a terminal', function (string $path) {
@@ -68,5 +67,4 @@ it('renders aligned step lines, warnings and failures without a terminal', funct
->and(array_map(fn (string $line) => mb_strlen($line), $stepLines))->each->toBe(80);
})->with([
'stable install script' => ['scripts/install.sh'],
- 'nightly install script' => ['other/nightly/install.sh'],
]);
diff --git a/tests/Unit/ProductionComposeHealthcheckTest.php b/tests/Unit/ProductionComposeHealthcheckTest.php
index 6bbe5280e5..31f97f5b2c 100644
--- a/tests/Unit/ProductionComposeHealthcheckTest.php
+++ b/tests/Unit/ProductionComposeHealthcheckTest.php
@@ -11,5 +11,4 @@ it('allows Coolify enough time to start before counting healthcheck failures', f
->retries->toBe(24);
})->with([
'stable' => 'docker-compose.prod.yml',
- 'nightly' => 'other/nightly/docker-compose.prod.yml',
]);
diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php
index 98adac798b..97b8700457 100644
--- a/tests/Unit/ProductionImageWorkflowTest.php
+++ b/tests/Unit/ProductionImageWorkflowTest.php
@@ -1,72 +1,12 @@
toContain('name: Build Coolify Main')
- ->toContain('branches: ["main"]')
- ->not->toContain('v4.x')
- ->toContain('group: coolify-main-build')
- ->toContain('cancel-in-progress: false')
- ->toContain('IMAGE_NAME }}:main-${{ matrix.arch }}')
- ->toContain('--tag "${IMAGE}:main"')
- ->toContain('short_sha=${GITHUB_SHA::7}')
- ->toContain('SHA: ${{ needs.build-push.outputs.short_sha }}')
- ->toContain('--tag "${IMAGE}:sha-${SHA}"')
- ->not->toContain('sha-${{ steps.version.outputs.short_sha }}-${{ matrix.arch }}')
- ->toContain('org.opencontainers.image.revision=${{ github.sha }}')
- ->toContain('php bootstrap/getVersion.php')
- ->toContain('version=${BASE_VERSION}-dev.${GITHUB_SHA::9}')
- ->toContain('COOLIFY_VERSION=${{ steps.version.outputs.version }}')
- ->toContain('sarisia/actions-status-discord@v1')
- ->toContain('webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}')
- ->not->toContain('IMAGE_NAME }}:latest')
- ->and($dockerfile)
- ->toContain('ARG COOLIFY_VERSION')
- ->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}')
- ->and($constants)
- ->toContain("'version' => env('COOLIFY_VERSION') ?: '{$versions['coolify']['v4']['version']}'")
- ->and($versions['coolify']['nightly']['version'])->toMatch('/^\d+\.\d+-rc\.\d+$/')
- ->and(version_compare($versions['coolify']['v4']['version'], $versions['coolify']['nightly']['version'], '<'))->toBeTrue()
- ->and($nightlyVersions)->toBe($versions);
-});
-
-it('orders a maintenance development build before its stable release', function () {
- expect(version_compare('4.3.2-dev.d64cbda3e', '4.3.2', '<'))->toBeTrue()
- ->and(version_compare('4.3.2', '4.3.2-dev.d64cbda3e', '>'))->toBeTrue();
-});
-
-it('orders rolling and exact release candidates before the stable release', function () {
- expect(version_compare('4.4-rc.1.d64cbda', '4.4-rc.1', '<'))->toBeTrue()
- ->and(version_compare('4.4-rc.1', '4.4-rc.2', '<'))->toBeTrue()
- ->and(version_compare('4.4-rc.2', '4.4.0', '<'))->toBeTrue();
-});
-
-it('requires a reviewed draft release before building a stable version', function () {
- $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml');
-
- expect($workflow)
- ->toContain('name: Release Coolify Stable')
- ->toContain('run-name: ${{ inputs.tag }}')
- ->toContain('workflow_dispatch:')
- ->toContain('tag:')
- ->toContain("github.ref_name != 'main'")
- ->toContain('github.paginate(github.rest.repos.listReleases')
- ->toContain('release.draft')
- ->toContain('release.prerelease')
- ->toContain('release.body?.trim()')
- ->toContain('bootstrap/getVersion.php')
- ->toContain('target_commitish: context.sha')
- ->toContain('tag_name: process.env.TAG_NAME')
- ->toContain('actions/github-script@v8')
- ->not->toContain('actions/github-script@v7')
- ->not->toContain('environment: production-release')
- ->not->toContain('generate-notes');
+ expect(trim($output))->toBe("4.6.0-beta.1 {$helper}");
});
it('runs support image workflows from main', function (string $workflowFile) {
@@ -112,93 +52,6 @@ it('publishes the testing host only to Docker Hub', function () {
->toContain('dockerfile: ./docker/testing-host/Dockerfile');
});
-it('generates the production changelog from main', function () {
- $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/generate-changelog.yml');
-
- expect($workflow)
- ->toContain('branches: [ main ]')
- ->not->toContain('v4.x');
-});
-
-it('publishes traceable rolling builds from next without creating an exact rc tag', function () {
- $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-next-build.yml');
-
- expect($workflow)
- ->toContain('name: Build Coolify Next')
- ->toContain('branches: [next]')
- ->toContain('group: coolify-next-build')
- ->toContain("jq -r '.coolify.nightly.version' versions.json")
- ->toContain('VERSION="${RC_VERSION}.${SHORT_SHA}"')
- ->toContain('COOLIFY_VERSION=${{ needs.prepare.outputs.version }}')
- ->toContain('--tag "${IMAGE}:sha-${SHA}"')
- ->toContain('--tag "${IMAGE}:${VERSION}"')
- ->toContain('--tag "${IMAGE}:next"')
- ->toContain('sarisia/actions-status-discord@v1')
- ->toContain('webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}')
- ->not->toContain('--tag "${IMAGE}:${RC_VERSION}"')
- ->not->toContain('--tag "${IMAGE}:latest"');
-});
-
-it('requires a reviewed draft prerelease before publishing an exact rc', function () {
- $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-rc-release.yml');
-
- expect($workflow)
- ->toContain('name: Release Coolify RC')
- ->toContain('run-name: ${{ inputs.tag }}')
- ->toContain("github.ref != 'refs/heads/next'")
- ->toContain('group: coolify-rc-release')
- ->toContain('^v[0-9]+\\.[0-9]+-rc\\.[0-9]+$')
- ->toContain("jq -r '.coolify.nightly.version' versions.json")
- ->toContain('release.draft')
- ->toContain('!release.prerelease')
- ->toContain('release.body?.trim()')
- ->toContain('revalidate:')
- ->toMatch('/revalidate:.*?permissions:\s+contents: write/s')
- ->toContain('needs: [validate, build, revalidate]')
- ->toContain('COOLIFY_VERSION=${{ needs.validate.outputs.version }}')
- ->toContain('--tag "${IMAGE}:${VERSION}"')
- ->toContain('--tag "${IMAGE}:next"')
- ->toContain('prerelease: true')
- ->toContain('actions/github-script@v8')
- ->not->toContain('--tag "${IMAGE}:latest"')
- ->not->toContain('environment:');
-});
-
-it('rebuilds stable images and publishes the reviewed draft after both architectures succeed', function () {
- $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-release.yml');
-
- expect($workflow)
- ->toContain('docker/build-push-action@v6')
- ->toContain('COOLIFY_VERSION=${{ needs.validate.outputs.version }}')
- ->toContain('release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}')
- ->toContain('--tag "${IMAGE}:${VERSION}"')
- ->toContain('--tag "${IMAGE}:latest"')
- ->toContain('github.rest.repos.getRelease')
- ->toContain('release.target_commitish !== context.sha')
- ->toContain('release_id: Number(process.env.RELEASE_ID)')
- ->toContain('draft: false')
- ->toContain('permissions: {}')
- ->not->toContain('sarisia/actions-status-discord@v1')
- ->not->toContain('SOURCE_TAG="sha-${RELEASE_SHA}"');
-});
-
-it('documents the production, rc, and hotfix release flows', function () {
- $releaseGuide = file_get_contents(dirname(__DIR__, 2).'/RELEASE.md');
-
- expect($releaseGuide)
- ->toContain('| `main` | Latest production source |')
- ->toContain('| `next` | Feature integration and RC releases |')
- ->toContain('| `hotfix/X.Y.Z` | Production fixes based on `main` |')
- ->toContain('feature/* → next → RC')
- ->toContain('next → main → stable release')
- ->toContain('main → hotfix/X.Y.Z → main → next')
- ->toContain('reviewed draft GitHub Release')
- ->toContain('workflows never edit or commit versions')
- ->toContain('Update the CDN only after the release is approved')
- ->not->toContain('`edge`')
- ->not->toContain('promotes the existing SHA image');
-});
-
it('documents pull request targets for fixes and features', function () {
$contributingGuide = file_get_contents(dirname(__DIR__, 2).'/CONTRIBUTING.md');
@@ -223,12 +76,13 @@ it('guides issue authors to the correct contribution branch', function () {
->toContain('Small fixes should target `main`; larger changes should target `next`');
});
-it('ships versions.json in the production image so version checks work right after an upgrade', function () {
+it('writes versions.json in the production image from upstream.json so version checks work right after an upgrade', function () {
$dockerfile = file_get_contents(dirname(__DIR__, 2).'/docker/production/Dockerfile');
$dockerignore = file_get_contents(dirname(__DIR__, 2).'/.dockerignore');
expect($dockerfile)
- ->toContain('COPY --chown=www-data:www-data versions.json ./versions.json')
+ ->toContain('COPY --chown=www-data:www-data release.json upstream.json ./')
+ ->toContain('upstream.json > versions.json')
->and($dockerignore)
- ->not->toMatch('/^\/?versions\.json$/m');
+ ->not->toMatch('/^\/?(release|upstream)\.json$/m');
});
diff --git a/tests/Unit/UpgradePostgresScriptTest.php b/tests/Unit/UpgradePostgresScriptTest.php
index 677a8fec3e..2821fb3b3d 100644
--- a/tests/Unit/UpgradePostgresScriptTest.php
+++ b/tests/Unit/UpgradePostgresScriptTest.php
@@ -26,7 +26,6 @@ function assertBashSyntaxIsValid(string $path): void
it('ships postgres upgrade scripts with valid bash syntax', function () {
assertBashSyntaxIsValid('scripts/upgrade-postgres.sh');
- assertBashSyntaxIsValid('other/nightly/upgrade-postgres.sh');
});
it('downloads postgres upgrade script during install and upgrade without auto-running it', function (string $path) {
@@ -39,9 +38,7 @@ it('downloads postgres upgrade script during install and upgrade without auto-ru
->not->toContain('bash /data/coolify/source/upgrade-postgres.sh');
})->with([
'stable install' => 'scripts/install.sh',
- 'nightly install' => 'other/nightly/install.sh',
'stable upgrade' => 'scripts/upgrade.sh',
- 'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('uses the selected registry url when extracting upgrade images', function (string $path) {
@@ -50,7 +47,6 @@ it('uses the selected registry url when extracting upgrade images', function (st
expect($script)->toContain('IMAGES=$(REGISTRY_URL=${REGISTRY_URL} LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images');
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
- 'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('persists the selected registry url during upgrades', function (string $path) {
@@ -59,10 +55,9 @@ it('persists the selected registry url during upgrades', function (string $path)
expect($script)->toContain('set_env_var "REGISTRY_URL" "$REGISTRY_URL"');
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
- 'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
-it('persists the target image and runtime version before recreating containers', function (string $path) {
+it('persists the target image and lets the image set its version before recreating containers', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
if ($script === false) {
throw new RuntimeException("Unable to read {$path}");
@@ -78,7 +73,7 @@ it('persists the target image and runtime version before recreating containers',
};
$latestImagePosition = $position('set_env_var "LATEST_IMAGE" "$LATEST_IMAGE"');
- $coolifyVersionPosition = $position('set_env_var "COOLIFY_VERSION" "$LATEST_IMAGE"');
+ $coolifyVersionPosition = $position('sed -i \'/^COOLIFY_VERSION=/d\' "$ENV_FILE"');
$imagesPulledPosition = $position('log "All images pulled successfully"');
$composeUpPosition = $position('docker compose --env-file /data/coolify/source/.env');
@@ -88,7 +83,6 @@ it('persists the target image and runtime version before recreating containers',
->and($coolifyVersionPosition)->toBeLessThan($composeUpPosition);
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
- 'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('uses the existing env registry url when old callers do not pass a registry argument', function (string $path) {
@@ -102,7 +96,6 @@ it('uses the existing env registry url when old callers do not pass a registry a
->toContain('REGISTRY_URL="docker.io"');
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
- 'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('keeps postgres upgrade compose override in future upgrade compose commands', function (string $path) {
@@ -114,7 +107,6 @@ it('keeps postgres upgrade compose override in future upgrade compose commands',
->toContain('Using PostgreSQL upgrade compose override');
})->with([
'stable upgrade' => 'scripts/upgrade.sh',
- 'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('uses postgres 18 compatible mount path in generated override and restore container', function () {
diff --git a/tests/Unit/UpgradeScriptContainerStartTest.php b/tests/Unit/UpgradeScriptContainerStartTest.php
index efc16ad510..e543c53bef 100644
--- a/tests/Unit/UpgradeScriptContainerStartTest.php
+++ b/tests/Unit/UpgradeScriptContainerStartTest.php
@@ -46,18 +46,18 @@ it('reports an error when the new Coolify containers do not start', function (st
expect($result['exitCode'])->toBe(1)
->and($result['status'])->toStartWith('error|')
->toContain('upgrade log');
-})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
+})->with(['scripts/upgrade.sh']);
it('completes the upgrade when the coolify container runs but is not healthy within the wait timeout', function (string $script) {
$result = runUpgradeScriptContainerStart($script, composeExitCode: 1, coolifyRunning: true);
expect($result['exitCode'])->toBe(0)
->and($result['status'])->toBe('');
-})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
+})->with(['scripts/upgrade.sh']);
it('completes the upgrade when docker compose up succeeds', function (string $script) {
$result = runUpgradeScriptContainerStart($script, composeExitCode: 0, coolifyRunning: true);
expect($result['exitCode'])->toBe(0)
->and($result['status'])->toBe('');
-})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
+})->with(['scripts/upgrade.sh']);
diff --git a/tests/Unit/UpgradeScriptCoolifyNetworkTest.php b/tests/Unit/UpgradeScriptCoolifyNetworkTest.php
index ab3d497730..7a4b89fe3c 100644
--- a/tests/Unit/UpgradeScriptCoolifyNetworkTest.php
+++ b/tests/Unit/UpgradeScriptCoolifyNetworkTest.php
@@ -64,19 +64,19 @@ it('stops the upgrade before any container changes when the coolify network has
->and($result['status'])->toStartWith('error|')
->toContain('fd00:1::1/64')
->toContain('systemctl restart docker');
-})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
+})->with(['scripts/upgrade.sh']);
it('keeps a readable coolify network', function (string $script) {
$result = runUpgradeScriptEnsureCoolifyNetwork($script, existingGateways: '172.19.0.1 fd00:1::1 ', createdIpv6Gateway: 'unused');
expect($result)->toBe(['exitCode' => 0, 'calls' => [], 'status' => '']);
-})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
+})->with(['scripts/upgrade.sh']);
it('creates the coolify network with IPv6', function (string $script) {
$result = runUpgradeScriptEnsureCoolifyNetwork($script, existingGateways: null, createdIpv6Gateway: 'fd00:1::1');
expect($result)->toBe(['exitCode' => 0, 'calls' => ['network create --attachable --ipv6 coolify'], 'status' => '']);
-})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
+})->with(['scripts/upgrade.sh']);
it('recreates a new coolify network without IPv6 when Docker reports its gateway with a prefix length', function (string $script) {
$result = runUpgradeScriptEnsureCoolifyNetwork($script, existingGateways: null, createdIpv6Gateway: 'fd00:1::1/64');
@@ -90,4 +90,4 @@ it('recreates a new coolify network without IPv6 when Docker reports its gateway
],
'status' => '',
]);
-})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
+})->with(['scripts/upgrade.sh']);
diff --git a/tests/Unit/UpgradeScriptPullRetryTest.php b/tests/Unit/UpgradeScriptPullRetryTest.php
index d30514a965..34e9a07d3a 100644
--- a/tests/Unit/UpgradeScriptPullRetryTest.php
+++ b/tests/Unit/UpgradeScriptPullRetryTest.php
@@ -35,8 +35,8 @@ function runUpgradeScriptPullImage(string $script, int $failures): array
it('retries a rate limited image pull until it succeeds', function (string $script) {
expect(runUpgradeScriptPullImage($script, failures: 2))->toBe(['exitCode' => 0, 'pulls' => 3]);
-})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
+})->with(['scripts/upgrade.sh']);
it('fails the image pull after five attempts', function (string $script) {
expect(runUpgradeScriptPullImage($script, failures: 10))->toBe(['exitCode' => 1, 'pulls' => 5]);
-})->with(['scripts/upgrade.sh', 'other/nightly/upgrade.sh']);
+})->with(['scripts/upgrade.sh']);
diff --git a/tests/Unit/UpgradeScriptRootUserEnvTest.php b/tests/Unit/UpgradeScriptRootUserEnvTest.php
index 6829e17155..ac5962544f 100644
--- a/tests/Unit/UpgradeScriptRootUserEnvTest.php
+++ b/tests/Unit/UpgradeScriptRootUserEnvTest.php
@@ -33,7 +33,7 @@ function repairRootUserEnvWithUpgradeScript(string $scriptPath, string $initialE
return $contents;
}
-$upgradeScripts = ['stable upgrade' => 'scripts/upgrade.sh', 'nightly upgrade' => 'other/nightly/upgrade.sh'];
+$upgradeScripts = ['stable upgrade' => 'scripts/upgrade.sh'];
it('quotes unquoted root user credentials with spaces so that .env parses again', function (string $scriptPath) {
$env = repairRootUserEnvWithUpgradeScript($scriptPath, implode("\n", [
diff --git a/upstream.json b/upstream.json
new file mode 100644
index 0000000000..7a51650b5a
--- /dev/null
+++ b/upstream.json
@@ -0,0 +1,23 @@
+{
+ "helper": {
+ "version": "1.0.19"
+ },
+ "sentinel": {
+ "version": "1.1.3"
+ },
+ "sentinel-host": {
+ "version": "1.1.3",
+ "digest": "sha256:bca77d22355c9005185b438dad406747b4b776018215a0a4a75c88618d681ff7"
+ },
+ "traefik": {
+ "v3.7": "3.7.16",
+ "v3.6": "3.6.25",
+ "v3.5": "3.5.6",
+ "v3.4": "3.4.5",
+ "v3.3": "3.3.7",
+ "v3.2": "3.2.5",
+ "v3.1": "3.1.7",
+ "v3.0": "3.0.4",
+ "v2.11": "2.11.60"
+ }
+}
diff --git a/versions.json b/versions.json
deleted file mode 100644
index c7b80ac79f..0000000000
--- a/versions.json
+++ /dev/null
@@ -1,31 +0,0 @@
-{
- "coolify": {
- "v4": {
- "version": "4.5.4"
- },
- "nightly": {
- "version": "4.6-rc.1"
- },
- "helper": {
- "version": "1.0.19"
- },
- "sentinel": {
- "version": "1.1.3"
- },
- "sentinel-host": {
- "version": "1.1.3",
- "digest": "sha256:bca77d22355c9005185b438dad406747b4b776018215a0a4a75c88618d681ff7"
- }
- },
- "traefik": {
- "v3.7": "3.7.16",
- "v3.6": "3.6.25",
- "v3.5": "3.5.6",
- "v3.4": "3.4.5",
- "v3.3": "3.3.7",
- "v3.2": "3.2.5",
- "v3.1": "3.1.7",
- "v3.0": "3.0.4",
- "v2.11": "2.11.60"
- }
-}
\ No newline at end of file