From 793077d74fdbadc3c7388f13cfc6e4135dc96bf4 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 23 Mar 2026 17:12:02 +0000 Subject: [PATCH 01/20] feat(buildpack): add Railpack as a build pack option --- app/Enums/BuildPackTypes.php | 1 + .../Api/ApplicationsController.php | 10 +- app/Jobs/ApplicationDeploymentJob.php | 176 +++++++++++++++++- app/Livewire/Project/Application/General.php | 2 +- .../Project/New/GithubPrivateRepository.php | 4 +- .../New/GithubPrivateRepositoryDeployKey.php | 4 +- .../Project/New/PublicGitRepository.php | 4 +- app/Models/Application.php | 24 ++- docker/coolify-helper/Dockerfile | 20 ++ openapi.json | 6 + openapi.yaml | 11 +- .../project/application/general.blade.php | 11 +- ...ub-private-repository-deploy-key.blade.php | 1 + .../new/github-private-repository.blade.php | 1 + .../new/public-git-repository.blade.php | 1 + .../ApplicationBuildpackCleanupTest.php | 46 +++++ tests/Feature/ApplicationRailpackTest.php | 168 +++++++++++++++++ tests/Feature/BuildpackSwitchCleanupTest.php | 23 +++ 18 files changed, 485 insertions(+), 28 deletions(-) create mode 100644 tests/Feature/ApplicationRailpackTest.php diff --git a/app/Enums/BuildPackTypes.php b/app/Enums/BuildPackTypes.php index cb51db6d6..eee898823 100644 --- a/app/Enums/BuildPackTypes.php +++ b/app/Enums/BuildPackTypes.php @@ -8,4 +8,5 @@ enum BuildPackTypes: string case STATIC = 'static'; case DOCKERFILE = 'dockerfile'; case DOCKERCOMPOSE = 'dockercompose'; + case RAILPACK = 'railpack'; } diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 3444f9f14..4abf1c6e0 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -150,7 +150,7 @@ public function applications(Request $request) 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], 'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], @@ -318,7 +318,7 @@ public function create_public_application(Request $request) 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], @@ -483,7 +483,7 @@ public function create_private_gh_app_application(Request $request) 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], @@ -644,7 +644,7 @@ public function create_private_deploy_key_application(Request $request) 'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'], 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], @@ -2318,7 +2318,7 @@ public function delete_by_uuid(Request $request) 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], 'description' => ['type' => 'string', 'description' => 'The application description.'], 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e30af5cc7..33905ce59 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -121,6 +121,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private $env_nixpacks_args; + private $env_railpack_args; + private $docker_compose; private $docker_compose_base64; @@ -476,8 +478,12 @@ private function decide_what_to_do() $this->deploy_dockerfile_buildpack(); } elseif ($this->application->build_pack === 'static') { $this->deploy_static_buildpack(); - } else { + } elseif ($this->application->build_pack === 'nixpacks') { $this->deploy_nixpacks_buildpack(); + } elseif ($this->application->build_pack === 'railpack') { + $this->deploy_railpack_buildpack(); + } else { + throw new \RuntimeException("Unsupported build pack: {$this->application->build_pack}"); } $this->post_deployment(); } @@ -921,6 +927,37 @@ private function deploy_nixpacks_buildpack() $this->rolling_update(); } + private function deploy_railpack_buildpack() + { + if ($this->use_build_server) { + $this->server = $this->build_server; + } + $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}."); + $this->prepare_builder_image(); + $this->check_git_if_build_needed(); + $this->generate_image_names(); + if (! $this->force_rebuild) { + $this->check_image_locally_or_remotely(); + if ($this->should_skip_build()) { + return; + } + } + $this->clone_repository(); + $this->cleanup_git(); + $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + + $this->generate_build_env_variables(); + $this->build_railpack_image(); + + // Save runtime environment variables AFTER the build + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); + $this->rolling_update(); + } + private function deploy_static_buildpack() { if ($this->use_build_server) { @@ -1943,7 +1980,11 @@ private function deploy_pull_request() if ($this->application->build_pack === 'dockerfile') { $this->add_build_env_variables_to_dockerfile(); } - $this->build_image(); + if ($this->application->build_pack === 'railpack') { + $this->build_railpack_image(); + } else { + $this->build_image(); + } // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); @@ -2376,6 +2417,137 @@ private function generate_nixpacks_env_variables() $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } + private function generate_railpack_env_variables(): void + { + $this->env_railpack_args = collect([]); + if ($this->pull_request_id === 0) { + foreach ($this->application->railpack_environment_variables as $env) { + if (! is_null($env->real_value) && $env->real_value !== '') { + $this->env_railpack_args->push("--env {$env->key}={$env->real_value}"); + } + } + } else { + foreach ($this->application->railpack_environment_variables_preview as $env) { + if (! is_null($env->real_value) && $env->real_value !== '') { + $this->env_railpack_args->push("--env {$env->key}={$env->real_value}"); + } + } + } + + // Note: COOLIFY_* vars are NOT passed to railpack prepare because railpack treats + // all --env vars as secrets that must be provided during docker buildx build. + // COOLIFY_* vars are informational and available at runtime via .env file. + + $this->env_railpack_args = $this->env_railpack_args->implode(' '); + } + + private function build_railpack_image(): void + { + $this->generate_railpack_env_variables(); + + // Step 1: Generate build plan with railpack prepare + $prepare_command = 'railpack prepare'; + + if ($this->env_railpack_args) { + $prepare_command .= " {$this->env_railpack_args}"; + } + if ($this->application->build_command) { + $prepare_command .= " --build-cmd \"{$this->application->build_command}\""; + } + if ($this->application->start_command) { + $prepare_command .= " --start-cmd \"{$this->application->start_command}\""; + } + if ($this->application->install_command) { + $prepare_command .= " --env RAILPACK_INSTALL_CMD=\"{$this->application->install_command}\""; + } + + $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}"; + + $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.'); + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true], + ); + + // Step 2: Build image using docker buildx with railpack frontend. + // Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder. + $this->application_deployment_queue->addLogEntry('Building docker image with Railpack.'); + $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.'); + + $image_name = $this->application->settings->is_static + ? $this->build_image_name + : $this->production_image_name; + + if ($this->application->settings->is_static && $this->application->static_image) { + $this->pull_latest_image($this->application->static_image); + } + + $cache_args = ''; + if ($this->force_rebuild) { + $cache_args = '--no-cache'; + } else { + $cache_args = "--build-arg cache-key='{$this->application->uuid}'"; + } + + $build_command = 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' + .' && docker buildx build --builder coolify-railpack' + ." {$this->addHosts} --network host" + ." --build-arg BUILDKIT_SYNTAX='ghcr.io/railwayapp/railpack-frontend'" + ." {$cache_args}" + .' -f /artifacts/railpack-plan.json' + .' --progress plain' + .' --load' + ." -t {$image_name}" + ." {$this->workdir}"; + + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), + 'hidden' => true, + ] + ); + + // Step 3: If static, copy built assets into nginx image + if ($this->application->settings->is_static) { + $publishDir = trim($this->application->publish_directory, '/'); + $publishDir = $publishDir ? "/{$publishDir}" : ''; + $dockerfile = base64_encode("FROM {$this->application->static_image} +WORKDIR /usr/share/nginx/html/ +LABEL coolify.deploymentId={$this->deployment_uuid} +COPY --from={$this->build_image_name} /app{$publishDir} . +COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); + + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + $nginx_config = $this->application->settings->is_spa + ? base64_encode(defaultNginxConfiguration('spa')) + : base64_encode(defaultNginxConfiguration()); + } + + $static_build = $this->dockerBuildkitSupported + ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}" + : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; + + $base64_static_build = base64_encode($static_build); + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")], + [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")], + [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true], + [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true], + [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true], + ); + } + } + private function generate_coolify_env_variables(bool $forBuildTime = false): Collection { $coolify_envs = collect([]); diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index ca1daef72..bc24c3944 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -598,7 +598,7 @@ public function updatedBuildPack() // Sync property to model before checking/modifying $this->syncData(toModel: true); - if ($this->buildPack !== 'nixpacks') { + if ($this->buildPack !== 'nixpacks' && $this->buildPack !== 'railpack') { $this->isStatic = false; $this->application->settings->is_static = false; $this->application->settings->save(); diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 61ae0e151..c208e2cd2 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -62,7 +62,7 @@ class GithubPrivateRepository extends Component protected int $page = 1; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; @@ -82,7 +82,7 @@ public function updatedSelectedRepositoryId(): void public function updatedBuildPack() { - if ($this->build_pack === 'nixpacks') { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; $this->port = 3000; } elseif ($this->build_pack === 'static') { diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index e46ad7d78..f312a9dc0 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -45,7 +45,7 @@ class GithubPrivateRepositoryDeployKey extends Component public string $branch; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; @@ -95,7 +95,7 @@ public function mount() public function updatedBuildPack() { - if ($this->build_pack === 'nixpacks') { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; $this->port = 3000; } elseif ($this->build_pack === 'static') { diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 3df31a6a3..eb4ce7b84 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -57,7 +57,7 @@ class PublicGitRepository extends Component public string $git_repository; - public $build_pack = 'nixpacks'; + public $build_pack = 'railpack'; public bool $show_is_static = true; @@ -99,7 +99,7 @@ public function mount() public function updatedBuildPack() { - if ($this->build_pack === 'nixpacks') { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; $this->port = 3000; } elseif ($this->build_pack === 'static') { diff --git a/app/Models/Application.php b/app/Models/Application.php index 4cc2dcf74..b97201faa 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -39,7 +39,7 @@ 'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'], 'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'], 'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'], - 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose']], + 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose']], 'static_image' => ['type' => 'string', 'description' => 'Static image used when static site is deployed.'], 'install_command' => ['type' => 'string', 'description' => 'Install command.'], 'build_command' => ['type' => 'string', 'description' => 'Build command.'], @@ -854,7 +854,8 @@ public function runtime_environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) - ->where('key', 'not like', 'NIXPACKS_%'); + ->where('key', 'not like', 'NIXPACKS_%') + ->where('key', 'not like', 'RAILPACK_%'); } public function nixpacks_environment_variables() @@ -864,6 +865,13 @@ public function nixpacks_environment_variables() ->where('key', 'like', 'NIXPACKS_%'); } + public function railpack_environment_variables() + { + return $this->morphMany(EnvironmentVariable::class, 'resourceable') + ->where('is_preview', false) + ->where('key', 'like', 'RAILPACK_%'); + } + public function environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') @@ -882,7 +890,8 @@ public function runtime_environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) - ->where('key', 'not like', 'NIXPACKS_%'); + ->where('key', 'not like', 'NIXPACKS_%') + ->where('key', 'not like', 'RAILPACK_%'); } public function nixpacks_environment_variables_preview() @@ -892,6 +901,13 @@ public function nixpacks_environment_variables_preview() ->where('key', 'like', 'NIXPACKS_%'); } + public function railpack_environment_variables_preview() + { + return $this->morphMany(EnvironmentVariable::class, 'resourceable') + ->where('is_preview', true) + ->where('key', 'like', 'RAILPACK_%'); + } + public function scheduled_tasks(): HasMany { return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc'); @@ -1011,7 +1027,7 @@ public function deploymentType() public function could_set_build_commands(): bool { - if ($this->build_pack === 'nixpacks') { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { return true; } diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 14879eb96..ebe667437 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -11,6 +11,10 @@ ARG DOCKER_BUILDX_VERSION=0.25.0 ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases ARG NIXPACKS_VERSION=1.41.0 +# https://github.com/railwayapp/railpack/releases +ARG RAILPACK_VERSION=0.21.0 +# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt) +ARG MISE_VERSION=2026.3.12 # https://github.com/minio/mc/releases ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z @@ -25,17 +29,32 @@ ARG DOCKER_COMPOSE_VERSION ARG DOCKER_BUILDX_VERSION ARG PACK_VERSION ARG NIXPACKS_VERSION +ARG RAILPACK_VERSION +ARG MISE_VERSION USER root WORKDIR /artifacts RUN apk add --no-cache bash curl git git-lfs openssh-client tar tini RUN mkdir -p ~/.docker/cli-plugins + +# Install mise (musl build) at the path railpack expects (/tmp/railpack/mise/mise-VERSION). +# Railpack hardcodes a glibc mise download that fails on Alpine, so we pre-place a musl binary. +RUN mkdir -p /tmp/railpack/mise && \ + if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \ + curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64-musl.tar.gz" | tar xz && \ + mv mise/bin/mise "/tmp/railpack/mise/mise-${MISE_VERSION}" && rm -rf mise; \ + elif [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \ + curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-arm64-musl.tar.gz" | tar xz && \ + mv mise/bin/mise "/tmp/railpack/mise/mise-${MISE_VERSION}" && rm -rf mise; \ + fi + RUN if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \ curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx && \ curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose && \ (curl -sSL https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) && \ (curl -sSL https://github.com/buildpacks/pack/releases/download/v${PACK_VERSION}/pack-v${PACK_VERSION}-linux.tgz | tar -C /usr/local/bin/ --no-same-owner -xzv pack) && \ curl -sSL https://nixpacks.com/install.sh | bash && \ + curl -sSL https://railpack.com/install.sh | RAILPACK_VERSION=${RAILPACK_VERSION} sh && \ chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /usr/local/bin/pack /root/.docker/cli-plugins/docker-buildx \ ;fi @@ -45,6 +64,7 @@ RUN if [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \ (curl -sSL https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) && \ (curl -sSL https://github.com/buildpacks/pack/releases/download/v${PACK_VERSION}/pack-v${PACK_VERSION}-linux-arm64.tgz | tar -C /usr/local/bin/ --no-same-owner -xzv pack) && \ curl -sSL https://nixpacks.com/install.sh | bash && \ + curl -sSL https://railpack.com/install.sh | RAILPACK_VERSION=${RAILPACK_VERSION} sh && \ chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /usr/local/bin/pack /root/.docker/cli-plugins/docker-buildx \ ;fi diff --git a/openapi.json b/openapi.json index d119176a1..a23a9df40 100644 --- a/openapi.json +++ b/openapi.json @@ -111,6 +111,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -564,6 +565,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -1009,6 +1011,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -1434,6 +1437,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -2442,6 +2446,7 @@ "type": "string", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" @@ -11509,6 +11514,7 @@ "description": "Build pack.", "enum": [ "nixpacks", + "railpack", "static", "dockerfile", "dockercompose" diff --git a/openapi.yaml b/openapi.yaml index 7064be28a..e3168d131 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -81,7 +81,7 @@ paths: description: 'The git branch.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' ports_exposes: type: string @@ -371,7 +371,7 @@ paths: description: 'The destination UUID.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' name: type: string @@ -655,7 +655,7 @@ paths: description: 'The destination UUID.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' name: type: string @@ -923,7 +923,7 @@ paths: description: 'The Dockerfile content.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' ports_exposes: type: string @@ -1556,7 +1556,7 @@ paths: description: 'The destination UUID.' build_pack: type: string - enum: [nixpacks, static, dockerfile, dockercompose] + enum: [nixpacks, railpack, static, dockerfile, dockercompose] description: 'The build pack type.' name: type: string @@ -7256,6 +7256,7 @@ components: description: 'Build pack.' enum: - nixpacks + - railpack - static - dockerfile - dockercompose diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index e27eda8b6..639be8f5d 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -31,6 +31,7 @@
+ @@ -218,16 +219,16 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" id="customDockerRunOptions" label="Custom Docker Options" x-bind:disabled="!canUpdate" /> @else @if ($application->could_set_build_commands()) - @if ($buildPack === 'nixpacks') + @if ($buildPack === 'nixpacks' || $buildPack === 'railpack')
- - -
-
Nixpacks will detect the required configuration +
{{ $buildPack === 'railpack' ? 'Railpack' : 'Nixpacks' }} will detect the required configuration automatically. Framework Specific Docs diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 9eb9baea8..3c3313643 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -51,6 +51,7 @@ class="loading loading-xs dark:text-warning loading-spinner">
+ diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php index 129c508a9..2d68b1900 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -77,6 +77,7 @@ @endforeach + diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 02489719a..03fc71a5d 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -41,6 +41,7 @@ helper="You can select other branches after configuration is done." /> @endif + diff --git a/tests/Feature/ApplicationBuildpackCleanupTest.php b/tests/Feature/ApplicationBuildpackCleanupTest.php index b6b535a76..857410920 100644 --- a/tests/Feature/ApplicationBuildpackCleanupTest.php +++ b/tests/Feature/ApplicationBuildpackCleanupTest.php @@ -117,6 +117,52 @@ expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1); }); + test('model clears dockerfile fields when build_pack changes from dockerfile to railpack', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockerfile', + 'dockerfile' => 'FROM node:18', + 'dockerfile_location' => '/Dockerfile', + 'dockerfile_target_build' => 'production', + 'custom_healthcheck_found' => true, + ]); + + $application->build_pack = 'railpack'; + $application->save(); + $application->refresh(); + + expect($application->build_pack)->toBe('railpack'); + expect($application->dockerfile)->toBeNull(); + expect($application->dockerfile_location)->toBeNull(); + expect($application->dockerfile_target_build)->toBeNull(); + expect($application->custom_healthcheck_found)->toBeFalse(); + }); + + test('model clears dockercompose fields when build_pack changes from dockercompose to railpack', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => '{"app": "example.com"}', + 'docker_compose_raw' => 'version: "3.8"\nservices:\n app:\n image: nginx', + ]); + + $application->build_pack = 'railpack'; + $application->save(); + $application->refresh(); + + expect($application->build_pack)->toBe('railpack'); + expect($application->docker_compose_domains)->toBeNull(); + expect($application->docker_compose_raw)->toBeNull(); + }); + test('model does not clear dockerfile fields when switching to dockerfile', function () { $team = Team::factory()->create(); $project = Project::factory()->create(['team_id' => $team->id]); diff --git a/tests/Feature/ApplicationRailpackTest.php b/tests/Feature/ApplicationRailpackTest.php new file mode 100644 index 000000000..f3e49cc21 --- /dev/null +++ b/tests/Feature/ApplicationRailpackTest.php @@ -0,0 +1,168 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'railpack', + ]); + + expect($application->could_set_build_commands())->toBeTrue(); + }); + + test('could_set_build_commands returns true for nixpacks', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'nixpacks', + ]); + + expect($application->could_set_build_commands())->toBeTrue(); + }); + + test('could_set_build_commands returns false for dockerfile', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockerfile', + ]); + + expect($application->could_set_build_commands())->toBeFalse(); + }); + + test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'railpack', + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_buildtime' => true, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'REGULAR_VAR', + 'value' => 'value', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '18', + 'is_buildtime' => true, + 'is_preview' => false, + ]); + + $railpackVars = $application->railpack_environment_variables; + expect($railpackVars)->toHaveCount(1); + expect($railpackVars->first()->key)->toBe('RAILPACK_NODE_VERSION'); + }); + + test('runtime_environment_variables excludes RAILPACK_ and NIXPACKS_ prefixed vars', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'railpack', + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_buildtime' => true, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '18', + 'is_buildtime' => true, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + + $runtimeVars = $application->runtime_environment_variables; + expect($runtimeVars)->toHaveCount(1); + expect($runtimeVars->first()->key)->toBe('APP_ENV'); + }); + + test('railpack_environment_variables_preview returns only RAILPACK_ prefixed preview vars', function () { + $team = Team::factory()->create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'railpack', + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'RAILPACK_BUILD_CMD', + 'value' => 'npm run build', + 'is_buildtime' => true, + 'is_preview' => true, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'REGULAR_VAR', + 'value' => 'value', + 'is_buildtime' => false, + 'is_preview' => true, + ]); + + $previewVars = $application->railpack_environment_variables_preview; + expect($previewVars)->toHaveCount(1); + expect($previewVars->first()->key)->toBe('RAILPACK_BUILD_CMD'); + }); +}); diff --git a/tests/Feature/BuildpackSwitchCleanupTest.php b/tests/Feature/BuildpackSwitchCleanupTest.php index b040f9a8f..babd940cb 100644 --- a/tests/Feature/BuildpackSwitchCleanupTest.php +++ b/tests/Feature/BuildpackSwitchCleanupTest.php @@ -111,6 +111,29 @@ expect($application->dockerfile)->toBeNull(); }); + test('clears dockerfile fields when switching from dockerfile to railpack', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'build_pack' => 'dockerfile', + 'dockerfile' => 'FROM node:18', + 'dockerfile_location' => '/Dockerfile', + 'dockerfile_target_build' => 'production', + 'custom_healthcheck_found' => true, + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->set('buildPack', 'railpack') + ->call('updatedBuildPack'); + + $application->refresh(); + expect($application->build_pack)->toBe('railpack'); + expect($application->dockerfile)->toBeNull(); + expect($application->dockerfile_location)->toBeNull(); + expect($application->dockerfile_target_build)->toBeNull(); + expect($application->custom_healthcheck_found)->toBeFalse(); + }); + test('clears dockerfile fields when switching from dockerfile to dockercompose', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, From cddbaf581fd319d9266f31a97194c04ea166fe1e Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 23 Mar 2026 19:02:10 +0000 Subject: [PATCH 02/20] refactor(railpack): extract static image build, fix port logic, bump to v0.22.0 Extract build_railpack_static_image() into its own method, prevent port override when is_static is set, bump Railpack to 0.22.0, and improve test setup with beforeEach and correct polymorphic env var fields. --- app/Jobs/ApplicationDeploymentJob.php | 53 ++++++++++--------- .../Project/New/GithubPrivateRepository.php | 4 +- .../New/GithubPrivateRepositoryDeployKey.php | 4 +- .../Project/New/PublicGitRepository.php | 4 +- docker/coolify-helper/Dockerfile | 2 +- .../ApplicationBuildpackCleanupTest.php | 50 ++++++++++++++--- tests/Feature/ApplicationRailpackTest.php | 38 ++++--------- 7 files changed, 93 insertions(+), 62 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 33905ce59..460a7bf3d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -483,7 +483,7 @@ private function decide_what_to_do() } elseif ($this->application->build_pack === 'railpack') { $this->deploy_railpack_buildpack(); } else { - throw new \RuntimeException("Unsupported build pack: {$this->application->build_pack}"); + throw new DeploymentException("Unsupported build pack: {$this->application->build_pack}"); } $this->post_deployment(); } @@ -2517,35 +2517,40 @@ private function build_railpack_image(): void // Step 3: If static, copy built assets into nginx image if ($this->application->settings->is_static) { - $publishDir = trim($this->application->publish_directory, '/'); - $publishDir = $publishDir ? "/{$publishDir}" : ''; - $dockerfile = base64_encode("FROM {$this->application->static_image} + $this->build_railpack_static_image(); + } + } + + private function build_railpack_static_image(): void + { + $publishDir = trim($this->application->publish_directory, '/'); + $publishDir = $publishDir ? "/{$publishDir}" : ''; + $dockerfile = base64_encode("FROM {$this->application->static_image} WORKDIR /usr/share/nginx/html/ LABEL coolify.deploymentId={$this->deployment_uuid} COPY --from={$this->build_image_name} /app{$publishDir} . COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); - if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { - $nginx_config = base64_encode($this->application->custom_nginx_configuration); - } else { - $nginx_config = $this->application->settings->is_spa - ? base64_encode(defaultNginxConfiguration('spa')) - : base64_encode(defaultNginxConfiguration()); - } - - $static_build = $this->dockerBuildkitSupported - ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}" - : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; - - $base64_static_build = base64_encode($static_build); - $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")], - [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")], - [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true], - [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true], - [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true], - ); + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + $nginx_config = $this->application->settings->is_spa + ? base64_encode(defaultNginxConfiguration('spa')) + : base64_encode(defaultNginxConfiguration()); } + + $static_build = $this->dockerBuildkitSupported + ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}" + : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}"; + + $base64_static_build = base64_encode($static_build); + $this->execute_remote_command( + [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")], + [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")], + [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true], + [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true], + [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true], + ); } private function generate_coolify_env_variables(bool $forBuildTime = false): Collection diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index c208e2cd2..63240620b 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -84,7 +84,9 @@ public function updatedBuildPack() { if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; - $this->port = 3000; + if (! $this->is_static) { + $this->port = 3000; + } } elseif ($this->build_pack === 'static') { $this->show_is_static = false; $this->is_static = false; diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index f312a9dc0..92d388234 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -97,7 +97,9 @@ public function updatedBuildPack() { if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; - $this->port = 3000; + if (! $this->is_static) { + $this->port = 3000; + } } elseif ($this->build_pack === 'static') { $this->show_is_static = false; $this->is_static = false; diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index eb4ce7b84..dd7a682d1 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -101,7 +101,9 @@ public function updatedBuildPack() { if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { $this->show_is_static = true; - $this->port = 3000; + if (! $this->isStatic) { + $this->port = 3000; + } } elseif ($this->build_pack === 'static') { $this->show_is_static = false; $this->isStatic = false; diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index ebe667437..1f4ca8788 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -12,7 +12,7 @@ ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases ARG NIXPACKS_VERSION=1.41.0 # https://github.com/railwayapp/railpack/releases -ARG RAILPACK_VERSION=0.21.0 +ARG RAILPACK_VERSION=0.22.0 # https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt) ARG MISE_VERSION=2026.3.12 # https://github.com/minio/mc/releases diff --git a/tests/Feature/ApplicationBuildpackCleanupTest.php b/tests/Feature/ApplicationBuildpackCleanupTest.php index 857410920..0dc0a8303 100644 --- a/tests/Feature/ApplicationBuildpackCleanupTest.php +++ b/tests/Feature/ApplicationBuildpackCleanupTest.php @@ -78,26 +78,29 @@ // Add environment variables that should be deleted EnvironmentVariable::create([ - 'application_id' => $application->id, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, 'key' => 'SERVICE_FQDN_APP', 'value' => 'app.example.com', - 'is_build_time' => false, + 'is_buildtime' => false, 'is_preview' => false, ]); EnvironmentVariable::create([ - 'application_id' => $application->id, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, 'key' => 'SERVICE_URL_APP', 'value' => 'http://app.example.com', - 'is_build_time' => false, + 'is_buildtime' => false, 'is_preview' => false, ]); EnvironmentVariable::create([ - 'application_id' => $application->id, + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, 'key' => 'REGULAR_VAR', 'value' => 'should_remain', - 'is_build_time' => false, + 'is_buildtime' => false, 'is_preview' => false, ]); @@ -154,6 +157,34 @@ 'docker_compose_raw' => 'version: "3.8"\nservices:\n app:\n image: nginx', ]); + // Add environment variables that should be deleted + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'SERVICE_FQDN_APP', + 'value' => 'app.example.com', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'SERVICE_URL_APP', + 'value' => 'http://app.example.com', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + + EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'key' => 'REGULAR_VAR', + 'value' => 'should_remain', + 'is_buildtime' => false, + 'is_preview' => false, + ]); + $application->build_pack = 'railpack'; $application->save(); $application->refresh(); @@ -161,6 +192,13 @@ expect($application->build_pack)->toBe('railpack'); expect($application->docker_compose_domains)->toBeNull(); expect($application->docker_compose_raw)->toBeNull(); + + // Verify SERVICE_FQDN_* and SERVICE_URL_* were deleted + expect($application->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0); + expect($application->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0); + + // Verify regular variables remain + expect($application->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1); }); test('model does not clear dockerfile fields when switching to dockerfile', function () { diff --git a/tests/Feature/ApplicationRailpackTest.php b/tests/Feature/ApplicationRailpackTest.php index f3e49cc21..59e8a82e0 100644 --- a/tests/Feature/ApplicationRailpackTest.php +++ b/tests/Feature/ApplicationRailpackTest.php @@ -10,13 +10,15 @@ uses(RefreshDatabase::class); describe('Application Railpack Support', function () { - test('could_set_build_commands returns true for railpack', function () { + beforeEach(function () { $team = Team::factory()->create(); $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + }); + test('could_set_build_commands returns true for railpack', function () { $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'railpack', ]); @@ -24,12 +26,8 @@ }); test('could_set_build_commands returns true for nixpacks', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'nixpacks', ]); @@ -37,12 +35,8 @@ }); test('could_set_build_commands returns false for dockerfile', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'dockerfile', ]); @@ -50,12 +44,8 @@ }); test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'railpack', ]); @@ -92,12 +82,8 @@ }); test('runtime_environment_variables excludes RAILPACK_ and NIXPACKS_ prefixed vars', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'railpack', ]); @@ -134,12 +120,8 @@ }); test('railpack_environment_variables_preview returns only RAILPACK_ prefixed preview vars', function () { - $team = Team::factory()->create(); - $project = Project::factory()->create(['team_id' => $team->id]); - $environment = Environment::factory()->create(['project_id' => $project->id]); - $application = Application::factory()->create([ - 'environment_id' => $environment->id, + 'environment_id' => $this->environment->id, 'build_pack' => 'railpack', ]); From ff0de0bc31c80245d310c9d39bd6caa653e00914 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:59:29 +0100 Subject: [PATCH 03/20] fix(docker): add docker buildx prune for coolify-railpack builder --- app/Actions/Server/CleanupDocker.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 0d9ca0153..135623b1f 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -51,6 +51,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', $imagePruneCmd, 'docker builder prune -af', + 'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true', "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", From 7dd648e549aa9e86299cbd32dc13e26b17a9dfd9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:59:45 +0100 Subject: [PATCH 04/20] feat(seeders): add railpack-static example application seed data Add ApplicationSeeder entry for railpack-static example with railpack build pack and corresponding application settings configuration. --- database/seeders/ApplicationSeeder.php | 16 ++++++++++++++++ database/seeders/ApplicationSettingsSeeder.php | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index 2a0273e0f..edb28c377 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -145,5 +145,21 @@ public function run(): void 'source_id' => 1, 'source_type' => GitlabApp::class, ]); + Application::create([ + 'uuid' => 'railpack-static', + 'name' => 'Railpack Static Example', + 'fqdn' => 'http://railpack-static.127.0.0.1.sslip.io', + 'repository_project_id' => 603035348, + 'git_repository' => 'coollabsio/coolify-examples', + 'git_branch' => 'v4.x', + 'base_directory' => '/static', + 'build_pack' => 'railpack', + 'ports_exposes' => '80', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GithubApp::class, + ]); } } diff --git a/database/seeders/ApplicationSettingsSeeder.php b/database/seeders/ApplicationSettingsSeeder.php index 87236df8a..e8be0ba70 100644 --- a/database/seeders/ApplicationSettingsSeeder.php +++ b/database/seeders/ApplicationSettingsSeeder.php @@ -22,5 +22,12 @@ public function run(): void $gitlabPublic->settings->is_static = true; $gitlabPublic->settings->save(); } + + $railpackStatic = Application::where('uuid', 'railpack-static')->first(); + if ($railpackStatic) { + $railpackStatic->load(['settings']); + $railpackStatic->settings->is_static = true; + $railpackStatic->settings->save(); + } } } From 4afcbbb2096df7db98663b2aeb93f315b85431a1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Mar 2026 07:09:24 +0100 Subject: [PATCH 05/20] fix(deployment): properly escape shell arguments in railpack prepare command --- app/Jobs/ApplicationDeploymentJob.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 13511957f..f4ec4abda 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2454,13 +2454,13 @@ private function build_railpack_image(): void $prepare_command .= " {$this->env_railpack_args}"; } if ($this->application->build_command) { - $prepare_command .= " --build-cmd \"{$this->application->build_command}\""; + $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); } if ($this->application->start_command) { - $prepare_command .= " --start-cmd \"{$this->application->start_command}\""; + $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command); } if ($this->application->install_command) { - $prepare_command .= " --env RAILPACK_INSTALL_CMD=\"{$this->application->install_command}\""; + $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); } $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}"; From 18508e91495e2f5662ecbf918d82e104aaee4845 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:13:26 +0200 Subject: [PATCH 06/20] fix(railpack): pass build and start commands via --env instead of dedicated flags Replace --build-cmd and --start-cmd with --env RAILPACK_BUILD_CMD and --env RAILPACK_START_CMD to align with how install_command is already passed, matching the expected railpack CLI interface. --- app/Jobs/ApplicationDeploymentJob.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 26cef35d7..e9b4667ab 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2498,10 +2498,10 @@ private function build_railpack_image(): void $prepare_command .= " {$this->env_railpack_args}"; } if ($this->application->build_command) { - $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); + $prepare_command .= ' --env '.escapeShellValue("RAILPACK_BUILD_CMD={$this->application->build_command}"); } if ($this->application->start_command) { - $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command); + $prepare_command .= ' --env '.escapeShellValue("RAILPACK_START_CMD={$this->application->start_command}"); } if ($this->application->install_command) { $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); From 0649a424b8468ff8be371643e7ed8d7a5ff539b4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:48:17 +0200 Subject: [PATCH 07/20] fix(buildpack): revert default build pack to nixpacks and reorder selector Change default build_pack from railpack back to nixpacks in all new application flows (GithubPrivateRepository, GithubPrivateRepositoryDeployKey, PublicGitRepository) and reorder the build pack dropdown so Nixpacks appears before Railpack across all relevant views. Add feature tests covering the nixpacks default and selector ordering. --- .../Project/New/GithubPrivateRepository.php | 2 +- .../New/GithubPrivateRepositoryDeployKey.php | 2 +- .../Project/New/PublicGitRepository.php | 2 +- .../project/application/general.blade.php | 2 +- ...ub-private-repository-deploy-key.blade.php | 2 +- .../new/github-private-repository.blade.php | 2 +- .../new/public-git-repository.blade.php | 2 +- ...pplicationGeneralBuildpackSelectorTest.php | 68 +++++++++++++++++++ .../NewApplicationBuildpackDefaultsTest.php | 43 ++++++++++++ 9 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/ApplicationGeneralBuildpackSelectorTest.php create mode 100644 tests/Feature/NewApplicationBuildpackDefaultsTest.php diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index b9db7373f..be7daddd7 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -63,7 +63,7 @@ class GithubPrivateRepository extends Component protected int $page = 1; - public $build_pack = 'railpack'; + public $build_pack = 'nixpacks'; public bool $show_is_static = true; diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 1acf5bc18..e81139792 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -46,7 +46,7 @@ class GithubPrivateRepositoryDeployKey extends Component public string $branch; - public $build_pack = 'railpack'; + public $build_pack = 'nixpacks'; public bool $show_is_static = true; diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 8213f3cd0..fb24ba284 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -58,7 +58,7 @@ class PublicGitRepository extends Component public string $git_repository; - public $build_pack = 'railpack'; + public $build_pack = 'nixpacks'; public bool $show_is_static = true; diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 87465c5d3..2aab1ab92 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -31,8 +31,8 @@
- + diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 3c3313643..ca3c977a7 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -51,8 +51,8 @@ class="loading loading-xs dark:text-warning loading-spinner">
- + diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php index 31dbed038..acbff15a6 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -82,8 +82,8 @@ @endforeach - + diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 03fc71a5d..1df5cf907 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -41,8 +41,8 @@ helper="You can select other branches after configuration is done." /> @endif - + diff --git a/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php new file mode 100644 index 000000000..9b4c4c00d --- /dev/null +++ b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php @@ -0,0 +1,68 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + InstanceSettings::unguarded(function () { + InstanceSettings::updateOrCreate(['id' => 0], []); + }); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + 'team_id' => $this->team->id, + ]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']); +}); + +test('existing application buildpack selector lists nixpacks before railpack', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'nixpacks', + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->assertSeeInOrder([ + '', + '', + ], false); +}); diff --git a/tests/Feature/NewApplicationBuildpackDefaultsTest.php b/tests/Feature/NewApplicationBuildpackDefaultsTest.php new file mode 100644 index 000000000..49c1ee7b2 --- /dev/null +++ b/tests/Feature/NewApplicationBuildpackDefaultsTest.php @@ -0,0 +1,43 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +describe('new application buildpack defaults', function () { + test('github app repository flow defaults to nixpacks', function () { + Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app']) + ->assertSet('build_pack', 'nixpacks'); + }); + + test('deploy key repository flow defaults to nixpacks', function () { + Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key']) + ->assertSet('build_pack', 'nixpacks'); + }); + + test('public repository flow defaults to nixpacks and lists railpack second', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->assertSet('build_pack', 'nixpacks'); + }); + + test('public repository flow keeps railpack available after branch lookup', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('branchFound', true) + ->assertSeeInOrder(['Nixpacks', 'Railpack']); + }); +}); From d7e1b7ec375c8f4d0700e845161c5f24ac51b8be Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:45:42 +0200 Subject: [PATCH 08/20] feat(railpack): add config merging, beta badge, and nodejs seeder example - Implement railpack.json + generated config deep merging logic in ApplicationDeploymentJob with JSON validation and assoc array checks - Label Railpack as "Beta" in all build pack selectors and show a visible beta badge when railpack is selected in new-app forms - Add railpack-nodejs Fastify example to ApplicationSeeder - Add ApplicationSeederTest and ApplicationDeploymentRailpackConfigTest covering config merge behavior and seeder correctness --- app/Jobs/ApplicationDeploymentJob.php | 169 ++++++++++++++- database/seeders/ApplicationSeeder.php | 16 ++ .../project/application/general.blade.php | 10 +- ...ub-private-repository-deploy-key.blade.php | 10 +- .../new/github-private-repository.blade.php | 10 +- .../new/public-git-repository.blade.php | 10 +- ...pplicationGeneralBuildpackSelectorTest.php | 20 +- tests/Feature/ApplicationSeederTest.php | 51 +++++ .../NewApplicationBuildpackDefaultsTest.php | 10 +- ...pplicationDeploymentRailpackConfigTest.php | 195 ++++++++++++++++++ 10 files changed, 482 insertions(+), 19 deletions(-) create mode 100644 tests/Feature/ApplicationSeederTest.php create mode 100644 tests/Unit/ApplicationDeploymentRailpackConfigTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e9b4667ab..d5c6b1c80 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -33,6 +33,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Sleep; use Illuminate\Support\Str; +use JsonException; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; use Throwable; @@ -48,6 +49,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json'; + private const RAILPACK_REPOSITORY_CONFIG_PATH = 'railpack.json'; + + private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; + public $tries = 1; public $timeout = 3600; @@ -2487,28 +2492,170 @@ private function generate_railpack_env_variables(): void $this->env_railpack_args = $this->env_railpack_args->implode(' '); } - private function build_railpack_image(): void + private function decode_railpack_config(string $config, string $source): array { - $this->generate_railpack_env_variables(); + try { + $decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception); + } - // Step 1: Generate build plan with railpack prepare + if (! is_array($decoded)) { + throw new DeploymentException("Invalid {$source}: expected a JSON object."); + } + + return $decoded; + } + + private function is_assoc_array(array $value): bool + { + if ($value === []) { + return false; + } + + return array_keys($value) !== range(0, count($value) - 1); + } + + private function merge_railpack_config(array $base, array $overrides): array + { + foreach ($overrides as $key => $value) { + if ( + array_key_exists($key, $base) + && is_array($base[$key]) + && is_array($value) + && $this->is_assoc_array($base[$key]) + && $this->is_assoc_array($value) + ) { + $base[$key] = $this->merge_railpack_config($base[$key], $value); + } else { + $base[$key] = $value; + } + } + + return $base; + } + + private function railpack_config_overrides(): array + { + return []; + } + + private function railpack_prepare_environment_variables(): Collection + { + $variables = collect([]); + + if ($this->application->install_command) { + $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); + } + + if ($this->application->build_command) { + $variables->put('RAILPACK_BUILD_CMD', $this->application->build_command); + } + + if ($this->application->start_command) { + $variables->put('RAILPACK_START_CMD', $this->application->start_command); + } + + return $variables; + } + + private function generated_railpack_config_relative_path(): string + { + return self::RAILPACK_GENERATED_CONFIG_PATH; + } + + private function generated_railpack_config_absolute_path(): string + { + return "{$this->workdir}/".self::RAILPACK_GENERATED_CONFIG_PATH; + } + + private function generate_railpack_config_file(): ?string + { + $repositoryConfig = []; + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH." && echo 'exists' || echo 'missing'"), + 'hidden' => true, + 'save' => 'railpack_config_exists', + ]); + + if (str($this->saved_outputs->get('railpack_config_exists'))->trim()->toString() === 'exists') { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH), + 'hidden' => true, + 'save' => 'railpack_repository_config', + ]); + + $repositoryConfig = $this->decode_railpack_config( + $this->saved_outputs->get('railpack_repository_config', ''), + 'repository railpack.json' + ); + } + + $overrides = $this->railpack_config_overrides(); + if ($repositoryConfig === [] && $overrides === []) { + return null; + } + + $mergedConfig = $this->merge_railpack_config($repositoryConfig, $overrides); + if (! array_key_exists('$schema', $mergedConfig)) { + $mergedConfig['$schema'] = 'https://schema.railpack.com'; + } + + try { + $encodedConfig = json_encode($mergedConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new DeploymentException("Failed to encode generated Railpack config: {$exception->getMessage()}", $exception->getCode(), $exception); + } + + $configPath = $this->generated_railpack_config_absolute_path(); + $encodedConfig = base64_encode($encodedConfig); + + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}/.coolify"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, "echo '{$encodedConfig}' | base64 -d | tee {$configPath} > /dev/null"), + 'hidden' => true, + ] + ); + + return $this->generated_railpack_config_relative_path(); + } + + private function railpack_prepare_command(?string $configFilePath = null): string + { $prepare_command = 'railpack prepare'; + $prepareEnvironmentVariables = $this->railpack_prepare_environment_variables() + ->map(fn ($value, $key) => "{$key}=".escapeShellValue($value)) + ->implode(' '); + + if ($prepareEnvironmentVariables !== '') { + $prepare_command = "{$prepareEnvironmentVariables} {$prepare_command}"; + } if ($this->env_railpack_args) { $prepare_command .= " {$this->env_railpack_args}"; } - if ($this->application->build_command) { - $prepare_command .= ' --env '.escapeShellValue("RAILPACK_BUILD_CMD={$this->application->build_command}"); - } - if ($this->application->start_command) { - $prepare_command .= ' --env '.escapeShellValue("RAILPACK_START_CMD={$this->application->start_command}"); - } - if ($this->application->install_command) { - $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); + + if ($configFilePath) { + $prepare_command .= ' --config-file '.escapeShellValue($configFilePath); } $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}"; + return $prepare_command; + } + + private function build_railpack_image(): void + { + $this->generate_railpack_env_variables(); + $railpackConfigPath = $this->generate_railpack_config_file(); + + // Step 1: Generate build plan with railpack prepare + $prepare_command = $this->railpack_prepare_command($railpackConfigPath); + $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.'); $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true], diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php index edb28c377..212bcce79 100644 --- a/database/seeders/ApplicationSeeder.php +++ b/database/seeders/ApplicationSeeder.php @@ -47,6 +47,22 @@ public function run(): void 'source_id' => 1, 'source_type' => GithubApp::class, ]); + Application::create([ + 'uuid' => 'railpack-nodejs', + 'name' => 'Railpack NodeJS Fastify Example', + 'fqdn' => 'http://railpack-nodejs.127.0.0.1.sslip.io', + 'repository_project_id' => 603035348, + 'git_repository' => 'coollabsio/coolify-examples', + 'git_branch' => 'v4.x', + 'base_directory' => '/nodejs', + 'build_pack' => 'railpack', + 'ports_exposes' => '3000', + 'environment_id' => 1, + 'destination_id' => 0, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 1, + 'source_type' => GithubApp::class, + ]); Application::create([ 'uuid' => 'dockerfile', 'name' => 'Dockerfile Example', diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 2aab1ab92..382f05278 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -32,7 +32,7 @@ - + @@ -236,11 +236,15 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
-
{{ $buildPack === 'railpack' ? 'Railpack' : 'Nixpacks' }} will detect the required configuration - automatically. + @if ($buildPack === 'nixpacks') +
+ + Nixpacks + will detect the required configuration automatically. Framework Specific Docs
+ @endif @endif @endif diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index ca3c977a7..249ded1f7 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -52,7 +52,7 @@ class="loading loading-xs dark:text-warning loading-spinner"> - + @@ -61,6 +61,14 @@ class="loading loading-xs dark:text-warning loading-spinner"> @endif
+ @if ($build_pack === 'railpack') +
+ + Beta + +
+ @endif @if ($build_pack === 'dockercompose')
- + @@ -93,6 +93,14 @@ helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." /> @endif
+ @if ($build_pack === 'railpack') +
+ + Beta + +
+ @endif @if ($build_pack === 'dockercompose')
- + @@ -52,6 +52,14 @@ helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." /> @endif
+ @if ($build_pack === 'railpack') +
+ + Beta + +
+ @endif @if ($build_pack === 'dockercompose')
Nixpacks', - '', + '', ], false); }); + +test('existing application shows railpack beta badge in build helper copy', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'railpack', + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->assertSee('Railpack') + ->assertSee('Beta'); +}); diff --git a/tests/Feature/ApplicationSeederTest.php b/tests/Feature/ApplicationSeederTest.php new file mode 100644 index 000000000..ac39ea4a7 --- /dev/null +++ b/tests/Feature/ApplicationSeederTest.php @@ -0,0 +1,51 @@ +seed([ + UserSeeder::class, + TeamSeeder::class, + PrivateKeySeeder::class, + ServerSeeder::class, + ProjectSeeder::class, + StandaloneDockerSeeder::class, + GithubAppSeeder::class, + ApplicationSeeder::class, + ]); + + $nixpacksExample = Application::where('uuid', 'nodejs')->first(); + $railpackExample = Application::where('uuid', 'railpack-nodejs')->first(); + + expect($nixpacksExample) + ->not->toBeNull() + ->and($nixpacksExample->name)->toBe('NodeJS Fastify Example') + ->and($nixpacksExample->build_pack)->toBe('nixpacks') + ->and($nixpacksExample->base_directory)->toBe('/nodejs') + ->and($nixpacksExample->ports_exposes)->toBe('3000'); + + expect($railpackExample) + ->not->toBeNull() + ->and($railpackExample->name)->toBe('Railpack NodeJS Fastify Example') + ->and($railpackExample->fqdn)->toBe('http://railpack-nodejs.127.0.0.1.sslip.io') + ->and($railpackExample->repository_project_id)->toBe(603035348) + ->and($railpackExample->git_repository)->toBe('coollabsio/coolify-examples') + ->and($railpackExample->git_branch)->toBe('v4.x') + ->and($railpackExample->base_directory)->toBe('/nodejs') + ->and($railpackExample->build_pack)->toBe('railpack') + ->and($railpackExample->ports_exposes)->toBe('3000') + ->and($railpackExample->environment_id)->toBe(1) + ->and($railpackExample->destination_id)->toBe(0) + ->and($railpackExample->source_id)->toBe(1); +}); diff --git a/tests/Feature/NewApplicationBuildpackDefaultsTest.php b/tests/Feature/NewApplicationBuildpackDefaultsTest.php index 49c1ee7b2..f1bcdcb65 100644 --- a/tests/Feature/NewApplicationBuildpackDefaultsTest.php +++ b/tests/Feature/NewApplicationBuildpackDefaultsTest.php @@ -38,6 +38,14 @@ test('public repository flow keeps railpack available after branch lookup', function () { Livewire::test(PublicGitRepository::class, ['type' => 'public']) ->set('branchFound', true) - ->assertSeeInOrder(['Nixpacks', 'Railpack']); + ->assertSeeInOrder(['Nixpacks', 'Railpack (Beta)']) + ->assertSee('Beta'); + }); + + test('deploy key repository flow shows railpack beta label in build pack selector', function () { + Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key']) + ->set('current_step', 'repository') + ->assertSee('Railpack (Beta)') + ->assertSee('Beta'); }); }); diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php new file mode 100644 index 000000000..5cd238b99 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -0,0 +1,195 @@ +recordedCommands[] = $commands; + } +} + +function makeRailpackDeploymentJob(array $applicationAttributes = [], array $savedOutputs = []): array +{ + $job = new TestableRailpackDeploymentJob; + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + $application = new Application($applicationAttributes); + + foreach ([ + 'application' => $application, + 'workdir' => '/artifacts/test-app', + 'deployment_uuid' => 'deployment-uuid', + 'saved_outputs' => new Collection($savedOutputs), + 'env_railpack_args' => "--env 'RAILPACK_NODE_VERSION=22'", + ] as $property => $value) { + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($job, $value); + } + + return [$job, $reflection]; +} + +function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $method, array $arguments = []): mixed +{ + $reflectionMethod = $reflection->getMethod($method); + $reflectionMethod->setAccessible(true); + + return $reflectionMethod->invokeArgs($job, $arguments); +} + +it('deep merges repository railpack config with coolify overrides', function () { + $repositoryConfigJson = json_encode([ + '$schema' => 'https://schema.railpack.com', + 'packages' => [ + 'node' => '20', + ], + 'steps' => [ + 'build' => [ + 'inputs' => [['step' => 'install']], + 'commands' => ['npm run build'], + ], + ], + 'deploy' => [ + 'variables' => [ + 'NODE_ENV' => 'production', + ], + 'startCommand' => 'node index.js', + ], + ], JSON_THROW_ON_ERROR); + + [$job, $reflection] = makeRailpackDeploymentJob( + [ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build:prod', + 'start_command' => 'node server.js', + ], + [ + 'railpack_config_exists' => 'exists', + 'railpack_repository_config' => $repositoryConfigJson, + ], + ); + + $repositoryConfig = invokeRailpackMethod( + $job, + $reflection, + 'decode_railpack_config', + [$repositoryConfigJson, 'repository railpack.json'], + ); + $overrides = [ + 'deploy' => [ + 'variables' => [ + 'APP_ENV' => 'production', + ], + ], + 'packages' => [ + 'python' => '3.13', + ], + ]; + $generatedConfig = invokeRailpackMethod($job, $reflection, 'merge_railpack_config', [$repositoryConfig, $overrides]); + + expect($generatedConfig)->toMatchArray([ + '$schema' => 'https://schema.railpack.com', + 'packages' => [ + 'node' => '20', + 'python' => '3.13', + ], + 'steps' => [ + 'build' => [ + 'inputs' => [['step' => 'install']], + 'commands' => ['npm run build'], + ], + ], + 'deploy' => [ + 'variables' => [ + 'NODE_ENV' => 'production', + 'APP_ENV' => 'production', + ], + 'startCommand' => 'node index.js', + ], + ]); +}); + +it('writes a generated railpack config file when repository config exists', function () { + [$job, $reflection] = makeRailpackDeploymentJob( + ['build_command' => 'npm run build'], + [ + 'railpack_config_exists' => 'exists', + 'railpack_repository_config' => json_encode([ + '$schema' => 'https://schema.railpack.com', + 'steps' => [ + 'build' => [ + 'commands' => ['npm run build'], + ], + ], + ], JSON_THROW_ON_ERROR), + ], + ); + + $configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file'); + + expect($configPath)->toBe('.coolify/railpack.generated.json'); + expect($job->recordedCommands)->toHaveCount(3); +}); + +it('does not generate a railpack config file for command overrides alone', function () { + [$job, $reflection] = makeRailpackDeploymentJob([ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build', + 'start_command' => 'node server.js', + ]); + + $configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file'); + + expect($configPath)->toBeNull(); + expect($job->recordedCommands)->toHaveCount(1); +}); + +it('fails fast when repository railpack config is invalid json', function () { + [$job, $reflection] = makeRailpackDeploymentJob( + ['build_command' => 'npm run build'], + [ + 'railpack_config_exists' => 'exists', + 'railpack_repository_config' => '{"steps":{"build":', + ], + ); + + expect(fn () => invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file')) + ->toThrow(DeploymentException::class, 'Invalid repository railpack.json'); +}); + +it('builds railpack prepare command using process env vars for command overrides', function () { + [$job, $reflection] = makeRailpackDeploymentJob( + [ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build', + 'start_command' => 'node server.js', + ], + ); + + $command = invokeRailpackMethod( + $job, + $reflection, + 'railpack_prepare_command', + ['.coolify/railpack.generated.json'], + ); + + expect($command)->toContain("railpack prepare --env 'RAILPACK_NODE_VERSION=22'"); + expect($command)->toContain('RAILPACK_INSTALL_CMD='.escapeshellarg('npm ci')); + expect($command)->toContain('RAILPACK_BUILD_CMD='.escapeshellarg('npm run build')); + expect($command)->toContain('RAILPACK_START_CMD='.escapeshellarg('node server.js')); + expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json')); + expect($command)->toContain('--plan-out /artifacts/railpack-plan.json /artifacts/test-app'); + expect($command)->not->toContain("--env 'RAILPACK_BUILD_CMD="); + expect($command)->not->toContain("--env 'RAILPACK_START_CMD="); + expect($command)->not->toContain("--env 'RAILPACK_INSTALL_CMD="); +}); From 3c51b1aaccee3fdd906c33f9f5527f3fded49e6b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:01:52 +0200 Subject: [PATCH 09/20] fix(railpack): pass command overrides through supported prepare/build args Use Railpack's install env handling and dedicated CLI flags for build/start overrides, and forward install commands into docker build secrets so image builds stay aligned with prepare-time configuration. Update the railpack config test to cover the new command format. --- app/Jobs/ApplicationDeploymentJob.php | 47 +++++++++---------- ...pplicationDeploymentRailpackConfigTest.php | 14 +++--- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index d5c6b1c80..40f917601 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2540,25 +2540,6 @@ private function railpack_config_overrides(): array return []; } - private function railpack_prepare_environment_variables(): Collection - { - $variables = collect([]); - - if ($this->application->install_command) { - $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); - } - - if ($this->application->build_command) { - $variables->put('RAILPACK_BUILD_CMD', $this->application->build_command); - } - - if ($this->application->start_command) { - $variables->put('RAILPACK_START_CMD', $this->application->start_command); - } - - return $variables; - } - private function generated_railpack_config_relative_path(): string { return self::RAILPACK_GENERATED_CONFIG_PATH; @@ -2627,12 +2608,17 @@ private function generate_railpack_config_file(): ?string private function railpack_prepare_command(?string $configFilePath = null): string { $prepare_command = 'railpack prepare'; - $prepareEnvironmentVariables = $this->railpack_prepare_environment_variables() - ->map(fn ($value, $key) => "{$key}=".escapeShellValue($value)) - ->implode(' '); - if ($prepareEnvironmentVariables !== '') { - $prepare_command = "{$prepareEnvironmentVariables} {$prepare_command}"; + if ($this->application->install_command) { + $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); + } + + if ($this->application->build_command) { + $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); + } + + if ($this->application->start_command) { + $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command); } if ($this->env_railpack_args) { @@ -2681,11 +2667,22 @@ private function build_railpack_image(): void $cache_args = "--build-arg cache-key='{$this->application->uuid}'"; } + $installCommandEnv = ''; + $installCommandSecret = ''; + if ($this->application->install_command) { + $installCommandEnv = 'env RAILPACK_INSTALL_CMD='.escapeShellValue($this->application->install_command).' '; + $installCommandSecret = ' --secret id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'; + $cache_args .= ' --build-arg secrets-hash='.$this->generate_secrets_hash(collect([ + 'RAILPACK_INSTALL_CMD' => $this->application->install_command, + ])); + } + $build_command = 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' - .' && docker buildx build --builder coolify-railpack' + ." && {$installCommandEnv}docker buildx build --builder coolify-railpack" ." {$this->addHosts} --network host" ." --build-arg BUILDKIT_SYNTAX='ghcr.io/railwayapp/railpack-frontend'" ." {$cache_args}" + ."{$installCommandSecret}" .' -f /artifacts/railpack-plan.json' .' --progress plain' .' --load' diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 5cd238b99..63ad618ae 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -167,7 +167,7 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ ->toThrow(DeploymentException::class, 'Invalid repository railpack.json'); }); -it('builds railpack prepare command using process env vars for command overrides', function () { +it('builds railpack prepare command using railpack env for install and cli flags for build/start overrides', function () { [$job, $reflection] = makeRailpackDeploymentJob( [ 'install_command' => 'npm ci', @@ -183,13 +183,15 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ ['.coolify/railpack.generated.json'], ); - expect($command)->toContain("railpack prepare --env 'RAILPACK_NODE_VERSION=22'"); - expect($command)->toContain('RAILPACK_INSTALL_CMD='.escapeshellarg('npm ci')); - expect($command)->toContain('RAILPACK_BUILD_CMD='.escapeshellarg('npm run build')); - expect($command)->toContain('RAILPACK_START_CMD='.escapeshellarg('node server.js')); + expect($command)->toContain('railpack prepare'); + expect($command)->toContain('--env '.escapeshellarg('RAILPACK_INSTALL_CMD=npm ci')); + expect($command)->toContain("--env 'RAILPACK_NODE_VERSION=22'"); + expect($command)->toContain('--build-cmd '.escapeshellarg('npm run build')); + expect($command)->toContain('--start-cmd '.escapeshellarg('node server.js')); expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json')); expect($command)->toContain('--plan-out /artifacts/railpack-plan.json /artifacts/test-app'); expect($command)->not->toContain("--env 'RAILPACK_BUILD_CMD="); expect($command)->not->toContain("--env 'RAILPACK_START_CMD="); - expect($command)->not->toContain("--env 'RAILPACK_INSTALL_CMD="); + expect($command)->not->toContain('RAILPACK_BUILD_CMD='); + expect($command)->not->toContain('RAILPACK_START_CMD='); }); From b3339d1034079a75e5a05c48c840c1f308fc89fd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:37:31 +0200 Subject: [PATCH 10/20] feat(railpack): add buildpack control var filtering and dev seeder Extract NIXPACKS_/RAILPACK_ prefix filtering into a reusable `scopeWithoutBuildpackControlVariables` query scope on EnvironmentVariable. Apply scope consistently to runtime vars, runtime preview vars, and buildtime var generation in ApplicationDeploymentJob. Refactor `generate_railpack_env_variables` to return a Collection. Add `RAILPACK_FRONTEND_IMAGE` constant and bake it into the coolify-helper Dockerfile as a build arg. Add DevelopmentRailpackExamplesSeeder (dev/local env only) for seeding example Railpack apps, wired into DatabaseSeeder. Add tests: - ApplicationDeploymentControlVarFilteringTest: verifies control vars are excluded from runtime and buildtime envs - DevelopmentRailpackExamplesSeederTest: verifies seeder behavior - ApplicationDeploymentRailpackEnvParityTest: parity checks for env handling across build/runtime paths --- app/Jobs/ApplicationDeploymentJob.php | 175 ++++--- app/Models/Application.php | 6 +- app/Models/EnvironmentVariable.php | 31 +- database/seeders/DatabaseSeeder.php | 6 + .../DevelopmentRailpackExamplesSeeder.php | 441 ++++++++++++++++++ docker/coolify-helper/Dockerfile | 2 + openapi.json | 12 +- openapi.yaml | 12 +- ...ationDeploymentControlVarFilteringTest.php | 287 ++++++++++++ .../DevelopmentRailpackExamplesSeederTest.php | 121 +++++ ...pplicationDeploymentRailpackConfigTest.php | 37 +- ...icationDeploymentRailpackEnvParityTest.php | 124 +++++ 12 files changed, 1177 insertions(+), 77 deletions(-) create mode 100644 database/seeders/DevelopmentRailpackExamplesSeeder.php create mode 100644 tests/Feature/ApplicationDeploymentControlVarFilteringTest.php create mode 100644 tests/Feature/DevelopmentRailpackExamplesSeederTest.php create mode 100644 tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e12c8eabf..fbf981483 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -53,6 +53,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; + private const RAILPACK_FRONTEND_IMAGE_ENV = '${RAILPACK_FRONTEND_IMAGE}'; + public $tries = 1; public $timeout = 3600; @@ -1259,11 +1261,11 @@ private function generate_runtime_environment_variables() $envs = collect([]); $sort = $this->application->settings->is_env_sorting_enabled; if ($sort) { - $sorted_environment_variables = $this->application->environment_variables->sortBy('key'); - $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('key'); + $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('key'); + $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('key'); } else { - $sorted_environment_variables = $this->application->environment_variables->sortBy('id'); - $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id'); + $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('id'); + $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id'); } if ($this->build_pack === 'dockercompose') { $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { @@ -1634,6 +1636,7 @@ private function generate_buildtime_environment_variables() // 4. Add user-defined build-time variables LAST (highest priority - can override everything) if ($this->pull_request_id === 0) { $sorted_environment_variables = $this->application->environment_variables() + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) // ONLY build-time variables ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); @@ -1686,6 +1689,7 @@ private function generate_buildtime_environment_variables() } } else { $sorted_environment_variables = $this->application->environment_variables_preview() + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) // ONLY build-time variables ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') ->get(); @@ -2468,28 +2472,114 @@ private function generate_nixpacks_env_variables() $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); } - private function generate_railpack_env_variables(): void + private function generate_railpack_env_variables(): Collection + { + $variables = $this->railpack_build_variables(); + + $this->env_railpack_args = $variables + ->map(function ($value, $key) { + return '--env '.escapeShellValue("{$key}={$value}"); + }) + ->implode(' '); + + return $variables; + } + + private function railpack_environment_variables_collection(): Collection { - $this->env_railpack_args = collect([]); if ($this->pull_request_id === 0) { - foreach ($this->application->railpack_environment_variables as $env) { - if (! is_null($env->real_value) && $env->real_value !== '') { - $this->env_railpack_args->push("--env {$env->key}={$env->real_value}"); - } - } - } else { - foreach ($this->application->railpack_environment_variables_preview as $env) { - if (! is_null($env->real_value) && $env->real_value !== '') { - $this->env_railpack_args->push("--env {$env->key}={$env->real_value}"); - } - } + return $this->application->railpack_environment_variables; } - // Note: COOLIFY_* vars are NOT passed to railpack prepare because railpack treats - // all --env vars as secrets that must be provided during docker buildx build. - // COOLIFY_* vars are informational and available at runtime via .env file. + return $this->application->railpack_environment_variables_preview; + } - $this->env_railpack_args = $this->env_railpack_args->implode(' '); + private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string + { + $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer); + if (is_null($resolvedValue) || $resolvedValue === '') { + return null; + } + + if ($environmentVariable->is_literal || $environmentVariable->is_multiline) { + return trim($resolvedValue, "'"); + } + + return $resolvedValue; + } + + private function railpack_build_variables(): Collection + { + $variables = $this->railpack_environment_variables_collection() + ->mapWithKeys(function (EnvironmentVariable $environmentVariable) { + $value = $this->normalize_resolved_build_variable_value($environmentVariable); + if (is_null($value) || $value === '') { + return []; + } + + return [$environmentVariable->key => $value]; + }); + + if ($this->application->install_command) { + $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); + } + + return $variables; + } + + private function railpack_build_environment_prefix(Collection $variables): string + { + if ($variables->isEmpty()) { + return ''; + } + + return 'env '.$variables + ->map(function ($value, $key) { + return "{$key}=".escapeShellValue($value); + }) + ->implode(' ').' '; + } + + private function railpack_build_secret_flags(Collection $variables): string + { + if ($variables->isEmpty()) { + return ''; + } + + return ' '.$variables + ->map(function ($value, $key) { + return "--secret id={$key},env={$key}"; + }) + ->implode(' '); + } + + private function railpack_build_command(string $imageName, Collection $variables): string + { + $cacheArgs = ''; + if ($this->force_rebuild) { + $cacheArgs = '--no-cache'; + } else { + $cacheArgs = "--build-arg cache-key='{$this->application->uuid}'"; + } + + if ($variables->isNotEmpty()) { + $cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables); + } + + $environmentPrefix = $this->railpack_build_environment_prefix($variables); + $secretFlags = $this->railpack_build_secret_flags($variables); + + return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' + ." && {$environmentPrefix}docker buildx build --builder coolify-railpack" + ." {$this->addHosts} --network host" + .' --build-arg BUILDKIT_SYNTAX="'.self::RAILPACK_FRONTEND_IMAGE_ENV.'"' + ." {$cacheArgs}" + ."{$secretFlags}" + .' -f /artifacts/railpack-plan.json' + .' --progress plain' + .' --load' + ." -t {$imageName}" + ." {$this->workdir}"; } private function decode_railpack_config(string $config, string $source): array @@ -2609,10 +2699,6 @@ private function railpack_prepare_command(?string $configFilePath = null): strin { $prepare_command = 'railpack prepare'; - if ($this->application->install_command) { - $prepare_command .= ' --env '.escapeShellValue("RAILPACK_INSTALL_CMD={$this->application->install_command}"); - } - if ($this->application->build_command) { $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command); } @@ -2636,7 +2722,7 @@ private function railpack_prepare_command(?string $configFilePath = null): strin private function build_railpack_image(): void { - $this->generate_railpack_env_variables(); + $railpackVariables = $this->generate_railpack_env_variables(); $railpackConfigPath = $this->generate_railpack_config_file(); // Step 1: Generate build plan with railpack prepare @@ -2660,34 +2746,7 @@ private function build_railpack_image(): void $this->pull_latest_image($this->application->static_image); } - $cache_args = ''; - if ($this->force_rebuild) { - $cache_args = '--no-cache'; - } else { - $cache_args = "--build-arg cache-key='{$this->application->uuid}'"; - } - - $installCommandEnv = ''; - $installCommandSecret = ''; - if ($this->application->install_command) { - $installCommandEnv = 'env RAILPACK_INSTALL_CMD='.escapeShellValue($this->application->install_command).' '; - $installCommandSecret = ' --secret id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'; - $cache_args .= ' --build-arg secrets-hash='.$this->generate_secrets_hash(collect([ - 'RAILPACK_INSTALL_CMD' => $this->application->install_command, - ])); - } - - $build_command = 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' - ." && {$installCommandEnv}docker buildx build --builder coolify-railpack" - ." {$this->addHosts} --network host" - ." --build-arg BUILDKIT_SYNTAX='ghcr.io/railwayapp/railpack-frontend'" - ." {$cache_args}" - ."{$installCommandSecret}" - .' -f /artifacts/railpack-plan.json' - .' --progress plain' - .' --load' - ." -t {$image_name}" - ." {$this->workdir}"; + $build_command = $this->railpack_build_command($image_name, $railpackVariables); $base64_build_command = base64_encode($build_command); $this->execute_remote_command( @@ -2859,7 +2918,7 @@ private function generate_env_variables() // For build process, include only environment variables where is_buildtime = true if ($this->pull_request_id === 0) { $envs = $this->application->environment_variables() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); @@ -2871,7 +2930,7 @@ private function generate_env_variables() } } else { $envs = $this->application->environment_variables_preview() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); @@ -3951,7 +4010,7 @@ private function add_build_env_variables_to_dockerfile() if ($this->pull_request_id === 0) { // Only add environment variables that are available during build $envs = $this->application->environment_variables() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); foreach ($envs as $env) { @@ -3973,7 +4032,7 @@ private function add_build_env_variables_to_dockerfile() } else { // Only add preview environment variables that are available during build $envs = $this->application->environment_variables_preview() - ->where('key', 'not like', 'NIXPACKS_%') + ->withoutBuildpackControlVariables() ->where('is_buildtime', true) ->get(); foreach ($envs as $env) { diff --git a/app/Models/Application.php b/app/Models/Application.php index 2201b1d16..2a364e13f 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -960,8 +960,7 @@ public function runtime_environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) - ->where('key', 'not like', 'NIXPACKS_%') - ->where('key', 'not like', 'RAILPACK_%'); + ->withoutBuildpackControlVariables(); } public function nixpacks_environment_variables() @@ -996,8 +995,7 @@ public function runtime_environment_variables_preview() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) - ->where('key', 'not like', 'NIXPACKS_%') - ->where('key', 'not like', 'RAILPACK_%'); + ->withoutBuildpackControlVariables(); } public function nixpacks_environment_variables_preview() diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 83212267c..25e2dcfb3 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use OpenApi\Attributes as OA; @@ -32,6 +33,8 @@ )] class EnvironmentVariable extends BaseModel { + public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; + protected $attributes = [ 'is_runtime' => true, 'is_buildtime' => true, @@ -78,7 +81,7 @@ class EnvironmentVariable extends BaseModel protected static function booted() { - static::created(function (EnvironmentVariable $environment_variable) { + static::created(function (ModelsEnvironmentVariable $environment_variable) { if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) { $found = ModelsEnvironmentVariable::where('key', $environment_variable->key) ->where('resourceable_type', Application::class) @@ -109,7 +112,7 @@ protected static function booted() ]); }); - static::saving(function (EnvironmentVariable $environmentVariable) { + static::saving(function (ModelsEnvironmentVariable $environmentVariable) { $environmentVariable->updateIsShared(); }); } @@ -119,6 +122,30 @@ public function service() return $this->belongsTo(Service::class); } + public function scopeWithoutBuildpackControlVariables(Builder $query): Builder + { + foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) { + $query->where('key', 'not like', "{$prefix}%"); + } + + return $query; + } + + public static function isBuildpackControlKey(?string $key): bool + { + if (blank($key)) { + return false; + } + + foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) { + if (str($key)->startsWith($prefix)) { + return true; + } + } + + return false; + } + protected function value(): Attribute { return Attribute::make( diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 57ccab4ae..4f5c4431a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -31,5 +31,11 @@ public function run(): void CaSslCertSeeder::class, PersonalAccessTokenSeeder::class, ]); + + if (in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + $this->call([ + DevelopmentRailpackExamplesSeeder::class, + ]); + } } } diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php new file mode 100644 index 000000000..4629f29ca --- /dev/null +++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php @@ -0,0 +1,441 @@ +isDevelopmentEnvironment()) { + $this->command?->warn('Skipping DevelopmentRailpackExamplesSeeder outside development mode.'); + + return; + } + + $this->ensureDevelopmentPrerequisitesExist(); + $destination = StandaloneDocker::query()->find(0); + + if (! $destination) { + throw new RuntimeException('StandaloneDocker with id=0 is required before running DevelopmentRailpackExamplesSeeder.'); + } + + $environment = $this->prepareEnvironment(); + + foreach (self::examples() as $example) { + $this->upsertApplication($environment, $destination, $example); + } + } + + /** + * @return array> + */ + public static function examples(): array + { + return [ + [ + 'uuid' => 'railpack-simple-webserver', + 'name' => 'Railpack Simple Webserver Example', + 'base_directory' => '/node/simple-webserver', + 'ports_exposes' => '3000', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-expressjs', + 'name' => 'Railpack Express.js Example', + 'base_directory' => '/node/expressjs', + 'ports_exposes' => '3000', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-fastify', + 'name' => 'Railpack Fastify Example', + 'base_directory' => '/node/fastify', + 'ports_exposes' => '3000', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-nestjs', + 'name' => 'Railpack NestJS Example', + 'base_directory' => '/node/nestjs', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start:prod', + ], + [ + 'uuid' => 'railpack-adonisjs', + 'name' => 'Railpack AdonisJS Example', + 'base_directory' => '/node/adonisjs', + 'ports_exposes' => '3333', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-hono', + 'name' => 'Railpack Hono Example', + 'base_directory' => '/node/hono', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-koa', + 'name' => 'Railpack Koa Example', + 'base_directory' => '/node/koa', + 'ports_exposes' => '3000', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-nextjs-ssr', + 'name' => 'Railpack Next.js SSR Example', + 'base_directory' => '/node/nextjs/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-nuxtjs-ssr', + 'name' => 'Railpack NuxtJS SSR Example', + 'base_directory' => '/node/nuxtjs/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run preview -- --host 0.0.0.0 --port 3000', + ], + [ + 'uuid' => 'railpack-astro-ssr', + 'name' => 'Railpack Astro SSR Example', + 'base_directory' => '/node/astro/ssr', + 'ports_exposes' => '4321', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-sveltekit-ssr', + 'name' => 'Railpack SvelteKit SSR Example', + 'base_directory' => '/node/sveltekit/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-tanstack-start-ssr', + 'name' => 'Railpack TanStack Start SSR Example', + 'base_directory' => '/node/tanstack-start/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-angular-ssr', + 'name' => 'Railpack Angular SSR Example', + 'base_directory' => '/node/angular/ssr', + 'ports_exposes' => '4000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-vue-ssr', + 'name' => 'Railpack Vue SSR Example', + 'base_directory' => '/node/vue/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run start', + ], + [ + 'uuid' => 'railpack-qwik-ssr', + 'name' => 'Railpack Qwik SSR Example', + 'base_directory' => '/node/qwik/ssr', + 'ports_exposes' => '3000', + 'build_command' => 'npm run build', + 'start_command' => 'npm run serve', + ], + [ + 'uuid' => 'railpack-react-static', + 'name' => 'Railpack React Static Example', + 'base_directory' => '/node/react', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-vite-static', + 'name' => 'Railpack Vite Static Example', + 'base_directory' => '/node/vite', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-eleventy-static', + 'name' => 'Railpack Eleventy Static Example', + 'base_directory' => '/node/eleventy', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/_site', + 'is_static' => true, + ], + [ + 'uuid' => 'railpack-gatsby-static', + 'name' => 'Railpack Gatsby Static Example', + 'base_directory' => '/node/gatsby', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/public', + 'is_static' => true, + ], + [ + 'uuid' => 'railpack-nextjs-static', + 'name' => 'Railpack Next.js Static Example', + 'base_directory' => '/node/nextjs/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/out', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-nuxtjs-static', + 'name' => 'Railpack NuxtJS Static Example', + 'base_directory' => '/node/nuxtjs/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/.output/public', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-astro-static', + 'name' => 'Railpack Astro Static Example', + 'base_directory' => '/node/astro/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + ], + [ + 'uuid' => 'railpack-sveltekit-static', + 'name' => 'Railpack SvelteKit Static Example', + 'base_directory' => '/node/sveltekit/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/build', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-tanstack-start-static', + 'name' => 'Railpack TanStack Start Static Example', + 'base_directory' => '/node/tanstack-start/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/.output/public', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-angular-static', + 'name' => 'Railpack Angular Static Example', + 'base_directory' => '/node/angular/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist/static/browser', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-vue-static', + 'name' => 'Railpack Vue Static Example', + 'base_directory' => '/node/vue/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + 'is_spa' => true, + ], + [ + 'uuid' => 'railpack-qwik-static', + 'name' => 'Railpack Qwik Static Example', + 'base_directory' => '/node/qwik/static', + 'ports_exposes' => '80', + 'build_command' => 'npm run build', + 'publish_directory' => '/dist', + 'is_static' => true, + 'is_spa' => true, + ], + ]; + } + + private function ensureDevelopmentPrerequisitesExist(): void + { + Team::query()->firstOrCreate( + ['id' => 0], + [ + 'name' => 'Root Team', + 'description' => 'The root team', + 'personal_team' => true, + ], + ); + + PrivateKey::query()->firstOrCreate( + ['id' => 1], + [ + 'uuid' => 'ssh', + 'team_id' => 0, + 'name' => 'Testing Host Key', + 'description' => 'This is a test docker container', + 'private_key' => <<<'KEY' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY----- +KEY, + ], + ); + + Server::query()->firstOrCreate( + ['id' => 0], + [ + 'uuid' => 'localhost', + 'name' => 'localhost', + 'description' => 'This is a test docker container in development mode', + 'ip' => 'coolify-testing-host', + 'team_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], + ], + ); + + StandaloneDocker::query()->firstOrCreate( + ['id' => 0], + [ + 'uuid' => 'docker', + 'name' => 'Standalone Docker 1', + 'network' => 'coolify', + 'server_id' => 0, + ], + ); + + $this->ensurePublicGithubSourceExists(); + } + + private function ensurePublicGithubSourceExists(): void + { + GithubApp::query()->firstOrCreate( + ['id' => 0], + [ + 'uuid' => 'github-public', + 'name' => 'Public GitHub', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => true, + 'team_id' => 0, + ], + ); + } + + private function isDevelopmentEnvironment(): bool + { + return in_array(config('app.env'), ['local', 'development', 'dev'], true); + } + + private function prepareEnvironment(): Environment + { + $project = Project::query()->firstOrNew(['uuid' => self::PROJECT_UUID]); + $project->fill([ + 'name' => 'Railpack Examples', + 'description' => 'Development-only Railpack examples from coollabsio/coolify-examples@next.', + 'team_id' => 0, + ]); + $project->save(); + + $environment = $project->environments()->first(); + + if (! $environment) { + $environment = $project->environments()->create([ + 'name' => 'production', + 'uuid' => self::ENVIRONMENT_UUID, + ]); + } else { + $environment->update([ + 'name' => 'production', + 'uuid' => self::ENVIRONMENT_UUID, + ]); + } + + return $environment; + } + + /** + * @param array $example + */ + private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example): void + { + $application = Application::withTrashed()->firstOrNew(['uuid' => $example['uuid']]); + $application->fill([ + 'name' => $example['name'], + 'description' => $example['name'], + 'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io", + 'repository_project_id' => self::REPOSITORY_PROJECT_ID, + 'git_repository' => self::GIT_REPOSITORY, + 'git_branch' => self::GIT_BRANCH, + 'build_pack' => 'railpack', + 'ports_exposes' => $example['ports_exposes'], + 'base_directory' => $example['base_directory'], + 'publish_directory' => $example['publish_directory'] ?? null, + 'static_image' => 'nginx:alpine', + 'install_command' => $example['install_command'] ?? null, + 'build_command' => $example['build_command'] ?? null, + 'start_command' => $example['start_command'] ?? null, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + 'source_id' => 0, + 'source_type' => GithubApp::class, + ]); + $application->save(); + + if ($application->trashed()) { + $application->restore(); + } + + $application->settings()->updateOrCreate( + ['application_id' => $application->id], + [ + 'is_static' => $example['is_static'] ?? false, + 'is_spa' => $example['is_spa'] ?? false, + ], + ); + } +} diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 1b95efe6c..35798000b 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -34,6 +34,8 @@ ARG MISE_VERSION USER root WORKDIR /artifacts +ENV RAILPACK_VERSION=${RAILPACK_VERSION} +ENV RAILPACK_FRONTEND_IMAGE=ghcr.io/railwayapp/railpack-frontend:v${RAILPACK_VERSION} RUN apk upgrade --no-cache && \ apk add --no-cache bash curl git git-lfs openssh-client tar tini RUN mkdir -p ~/.docker/cli-plugins diff --git a/openapi.json b/openapi.json index d8557e607..453289970 100644 --- a/openapi.json +++ b/openapi.json @@ -4386,8 +4386,8 @@ "description": "Number of days to retain backups locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", - "description": "Max storage (MB) for local backups" + "type": "number", + "description": "Max storage (GB) for local backups" }, "database_backup_retention_amount_s3": { "type": "integer", @@ -4398,8 +4398,8 @@ "description": "Number of days to retain backups in S3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", - "description": "Max storage (MB) for S3 backups" + "type": "number", + "description": "Max storage (GB) for S3 backups" }, "timeout": { "type": "integer", @@ -4956,7 +4956,7 @@ "description": "Retention days of the backup locally" }, "database_backup_retention_max_storage_locally": { - "type": "integer", + "type": "number", "description": "Max storage of the backup locally" }, "database_backup_retention_amount_s3": { @@ -4968,7 +4968,7 @@ "description": "Retention days of the backup in s3" }, "database_backup_retention_max_storage_s3": { - "type": "integer", + "type": "number", "description": "Max storage of the backup in S3" }, "timeout": { diff --git a/openapi.yaml b/openapi.yaml index df2515b06..a3844bb18 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2765,8 +2765,8 @@ paths: type: integer description: 'Number of days to retain backups locally' database_backup_retention_max_storage_locally: - type: integer - description: 'Max storage (MB) for local backups' + type: number + description: 'Max storage (GB) for local backups' database_backup_retention_amount_s3: type: integer description: 'Number of backups to retain in S3' @@ -2774,8 +2774,8 @@ paths: type: integer description: 'Number of days to retain backups in S3' database_backup_retention_max_storage_s3: - type: integer - description: 'Max storage (MB) for S3 backups' + type: number + description: 'Max storage (GB) for S3 backups' timeout: type: integer description: 'Backup job timeout in seconds (min: 60, max: 36000)' @@ -3160,7 +3160,7 @@ paths: type: integer description: 'Retention days of the backup locally' database_backup_retention_max_storage_locally: - type: integer + type: number description: 'Max storage of the backup locally' database_backup_retention_amount_s3: type: integer @@ -3169,7 +3169,7 @@ paths: type: integer description: 'Retention days of the backup in s3' database_backup_retention_max_storage_s3: - type: integer + type: number description: 'Max storage of the backup in S3' timeout: type: integer diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php new file mode 100644 index 000000000..0fa4af749 --- /dev/null +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -0,0 +1,287 @@ +recordedCommands[] = $commands; + + foreach ($commands as $command) { + $commandString = is_array($command) ? ($command['command'] ?? $command[0] ?? null) : $command; + + if (! is_string($commandString)) { + continue; + } + + if (preg_match('/echo .*?([A-Za-z0-9+\\/=]{16,}).*?\\| base64 -d \\| tee \\/artifacts\\/test-app\\/Dockerfile > \\/dev\\/null/', $commandString, $matches) === 1) { + $this->writtenDockerfile = base64_decode($matches[1]) ?: null; + } + } + } +} + +function makeDeploymentControlVarFixture(array $applicationAttributes = []): array +{ + $team = Team::create([ + 'name' => 'Control Var Team', + 'description' => 'Team for deployment control var tests.', + 'personal_team' => false, + 'show_boarding' => false, + ]); + $project = Project::create([ + 'name' => 'Control Var Project', + 'team_id' => $team->id, + ]); + $environment = Environment::where('project_id', $project->id)->firstOrFail(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + ]); + + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'build_pack' => 'dockerfile', + ...$applicationAttributes, + ]); + + $application->settings()->update([ + 'inject_build_args_to_dockerfile' => true, + 'include_source_commit_in_build' => false, + 'is_env_sorting_enabled' => false, + ]); + + return [$application->fresh(), $server]; +} + +function createApplicationEnvironmentVariable(Application $application, array $attributes): EnvironmentVariable +{ + return EnvironmentVariable::create([ + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => true, + 'is_multiline' => false, + 'is_literal' => false, + ...$attributes, + ]); +} + +function makeControlVarFilteringJob(Application $application, Server $server, array $overrides = []): array +{ + $job = new TestableControlVarFilteringDeploymentJob; + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + $queue = Mockery::mock(ApplicationDeploymentQueue::class); + $queue->shouldReceive('addLogEntry')->andReturnNull(); + + $properties = [ + 'application' => $application->fresh(), + 'application_deployment_queue' => $queue, + 'build_pack' => $application->build_pack, + 'mainServer' => $server, + 'pull_request_id' => 0, + 'commit' => 'HEAD', + 'workdir' => '/artifacts/test-app', + 'deployment_uuid' => 'deployment-uuid', + 'dockerfile_location' => '/Dockerfile', + 'container_name' => 'control-var-app', + 'coolify_variables' => null, + 'dockerSecretsSupported' => false, + ]; + + $mergedProperties = array_merge($properties, $overrides); + $mergedProperties['saved_outputs'] = new Collection($overrides['saved_outputs'] ?? []); + + if (($mergedProperties['pull_request_id'] ?? 0) !== 0 && ! array_key_exists('preview', $mergedProperties)) { + $mergedProperties['preview'] = ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => $mergedProperties['pull_request_id'], + 'pull_request_html_url' => 'https://example.com/pr/'.$mergedProperties['pull_request_id'], + 'fqdn' => 'https://preview.example.com', + ]); + } + + foreach ($mergedProperties as $property => $value) { + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($job, $value); + } + + return [$job, $reflection]; +} + +function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method): mixed +{ + $reflectionMethod = $reflection->getMethod($method); + $reflectionMethod->setAccessible(true); + + return $reflectionMethod->invoke($job); +} + +function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed +{ + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + + return $reflectionProperty->getValue($job); +} + +it('filters buildpack control vars from generic build args', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_ENV', + 'value' => 'production', + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server); + + invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables'); + + /** @var Collection $envArgs */ + $envArgs = readDeploymentJobProperty($job, $reflection, 'env_args'); + + expect($envArgs->get('APP_ENV'))->toBe('production'); + expect($envArgs->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); + expect($envArgs->has('RAILPACK_NODE_VERSION'))->toBeFalse(); +}); + +it('filters buildpack control vars from preview build-time env files', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_preview' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_preview' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_preview' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'pull_request_id' => 42, + ]); + + /** @var Collection $buildtimeEnvs */ + $buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); + + expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_ENV=')))->toBeTrue(); + expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse(); + expect($buildtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse(); +}); + +it('filters buildpack control vars from preview runtime env fallback', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_NAME', + 'value' => 'coolify', + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'PREVIEW_FLAG', + 'value' => 'enabled', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + + $application->environment_variables_preview() + ->whereIn('key', ['APP_NAME', 'NIXPACKS_NODE_VERSION', 'RAILPACK_NODE_VERSION']) + ->delete(); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'pull_request_id' => 99, + ]); + + /** @var Collection $runtimeEnvs */ + $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=')))->toBeTrue(); + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('PREVIEW_FLAG=')))->toBeTrue(); + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('NIXPACKS_NODE_VERSION=')))->toBeFalse(); + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('RAILPACK_NODE_VERSION=')))->toBeFalse(); +}); + +it('filters buildpack control vars from dockerfile arg injection', function () { + [$application, $server] = makeDeploymentControlVarFixture(); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + 'saved_outputs' => [ + 'dockerfile' => "FROM php:8.4-cli\nRUN php -v", + ], + ]); + + invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile'); + + expect($job->writtenDockerfile)->toContain('ARG APP_ENV=production'); + expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION='); + expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION='); +}); diff --git a/tests/Feature/DevelopmentRailpackExamplesSeederTest.php b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php new file mode 100644 index 000000000..2f224fda7 --- /dev/null +++ b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php @@ -0,0 +1,121 @@ +seed([ + UserSeeder::class, + TeamSeeder::class, + PrivateKeySeeder::class, + ServerSeeder::class, + ProjectSeeder::class, + StandaloneDockerSeeder::class, + GithubAppSeeder::class, + ]); +} + +it('can seed the railpack examples directly on a clean development database', function () { + config()->set('app.env', 'local'); + + $this->seed(DevelopmentRailpackExamplesSeeder::class); + + expect(Team::query()->find(0))->not->toBeNull(); + expect(PrivateKey::query()->find(1))->not->toBeNull(); + expect(Server::query()->find(0))->not->toBeNull(); + expect(StandaloneDocker::query()->find(0))->not->toBeNull(); + expect(GithubApp::query()->find(0))->not->toBeNull(); + expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue(); + expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples())); +}); + +it('seeds the railpack examples in development mode', function () { + config()->set('app.env', 'local'); + + seedRailpackExamplePrerequisites(); + $this->seed(DevelopmentRailpackExamplesSeeder::class); + + $project = Project::query() + ->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID) + ->first(); + + expect($project) + ->not->toBeNull() + ->and($project->name)->toBe('Railpack Examples') + ->and($project->environments)->toHaveCount(1) + ->and($project->environments->first()->uuid)->toBe(DevelopmentRailpackExamplesSeeder::ENVIRONMENT_UUID); + + $applications = $project->applications()->with('settings')->orderBy('uuid')->get(); + + expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples())); + expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue(); + expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue(); + expect($applications->every(fn (Application $application) => $application->git_branch === DevelopmentRailpackExamplesSeeder::GIT_BRANCH))->toBeTrue(); + + $nestjs = $applications->firstWhere('uuid', 'railpack-nestjs'); + $angularStatic = $applications->firstWhere('uuid', 'railpack-angular-static'); + $eleventyStatic = $applications->firstWhere('uuid', 'railpack-eleventy-static'); + + expect($nestjs) + ->not->toBeNull() + ->and($nestjs->base_directory)->toBe('/node/nestjs') + ->and($nestjs->ports_exposes)->toBe('3000') + ->and($nestjs->build_command)->toBe('npm run build') + ->and($nestjs->start_command)->toBe('npm run start:prod') + ->and($nestjs->settings->is_static)->toBeFalse(); + + expect($angularStatic) + ->not->toBeNull() + ->and($angularStatic->publish_directory)->toBe('/dist/static/browser') + ->and($angularStatic->ports_exposes)->toBe('80') + ->and($angularStatic->settings->is_static)->toBeTrue() + ->and($angularStatic->settings->is_spa)->toBeTrue(); + + expect($eleventyStatic) + ->not->toBeNull() + ->and($eleventyStatic->publish_directory)->toBe('/_site') + ->and($eleventyStatic->settings->is_static)->toBeTrue() + ->and($eleventyStatic->settings->is_spa)->toBeFalse(); +}); + +it('skips the railpack examples outside development mode', function () { + config()->set('app.env', 'testing'); + + seedRailpackExamplePrerequisites(); + $this->seed(DevelopmentRailpackExamplesSeeder::class); + + expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeFalse(); + expect(Application::query()->where('uuid', 'railpack-nextjs-ssr')->exists())->toBeFalse(); +}); + +it('is idempotent when run multiple times', function () { + config()->set('app.env', 'local'); + + seedRailpackExamplePrerequisites(); + $this->seed(DevelopmentRailpackExamplesSeeder::class); + $this->seed(DevelopmentRailpackExamplesSeeder::class); + + $project = Project::query() + ->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID) + ->first(); + + expect($project)->not->toBeNull(); + expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples())); +}); diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 63ad618ae..e3516268c 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -30,6 +30,9 @@ function makeRailpackDeploymentJob(array $applicationAttributes = [], array $sav 'deployment_uuid' => 'deployment-uuid', 'saved_outputs' => new Collection($savedOutputs), 'env_railpack_args' => "--env 'RAILPACK_NODE_VERSION=22'", + 'force_rebuild' => false, + 'addHosts' => '', + 'secrets_hash_key' => 'testing-app-key', ] as $property => $value) { $reflectionProperty = $reflection->getProperty($property); $reflectionProperty->setAccessible(true); @@ -175,6 +178,9 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ 'start_command' => 'node server.js', ], ); + $envRailpackArgsProperty = $reflection->getProperty('env_railpack_args'); + $envRailpackArgsProperty->setAccessible(true); + $envRailpackArgsProperty->setValue($job, "--env 'RAILPACK_NODE_VERSION=22' --env 'RAILPACK_INSTALL_CMD=npm ci'"); $command = invokeRailpackMethod( $job, @@ -184,8 +190,8 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ ); expect($command)->toContain('railpack prepare'); - expect($command)->toContain('--env '.escapeshellarg('RAILPACK_INSTALL_CMD=npm ci')); expect($command)->toContain("--env 'RAILPACK_NODE_VERSION=22'"); + expect($command)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci'"); expect($command)->toContain('--build-cmd '.escapeshellarg('npm run build')); expect($command)->toContain('--start-cmd '.escapeshellarg('node server.js')); expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json')); @@ -195,3 +201,32 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ expect($command)->not->toContain('RAILPACK_BUILD_CMD='); expect($command)->not->toContain('RAILPACK_START_CMD='); }); + +it('builds railpack docker command with matching env and secret flags for all railpack variables', function () { + [$job, $reflection] = makeRailpackDeploymentJob([ + 'uuid' => 'application-uuid', + ]); + + $command = invokeRailpackMethod( + $job, + $reflection, + 'railpack_build_command', + [ + 'coollabsio/coolify:test', + collect([ + 'RAILPACK_NODE_VERSION' => '22', + 'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall', + 'SECRET_JSON' => '{"token":"abc"}', + ]), + ], + ); + + expect($command)->toContain("env RAILPACK_NODE_VERSION='22'"); + expect($command)->toContain("RAILPACK_INSTALL_CMD='npm ci && npm run postinstall'"); + expect($command)->toContain("SECRET_JSON='{\"token\":\"abc\"}'"); + expect($command)->toContain('--secret id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'); + expect($command)->toContain('--secret id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'); + expect($command)->toContain('--secret id=SECRET_JSON,env=SECRET_JSON'); + expect($command)->toContain(' --build-arg secrets-hash='); + expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="${RAILPACK_FRONTEND_IMAGE}"'); +}); diff --git a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php new file mode 100644 index 000000000..8ed7b2c41 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php @@ -0,0 +1,124 @@ +shouldReceive('getAttribute')->with('install_command')->andReturn('npm ci && npm run postinstall'); + + $nodeVersion = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $nodeVersion->forceFill([ + 'key' => 'RAILPACK_NODE_VERSION', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $nodeVersion->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('22'); + + $literalValue = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $literalValue->forceFill([ + 'key' => 'RAILPACK_CUSTOM_FLAG', + 'is_literal' => true, + 'is_multiline' => false, + ]); + $literalValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn("'hello world'"); + + $jsonValue = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $jsonValue->forceFill([ + 'key' => 'RAILPACK_JSON', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $jsonValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('{"token":"abc"}'); + + $nullValue = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $nullValue->forceFill([ + 'key' => 'RAILPACK_NULL', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $nullValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn(null); + + $application->shouldReceive('getAttribute') + ->with('railpack_environment_variables') + ->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue])); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $application); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + $mainServerProperty = $reflection->getProperty('mainServer'); + $mainServerProperty->setAccessible(true); + $mainServerProperty->setValue($job, Mockery::mock(Server::class)); + + $method = $reflection->getMethod('generate_railpack_env_variables'); + $method->setAccessible(true); + $variables = $method->invoke($job); + + $envArgsProperty = $reflection->getProperty('env_railpack_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + expect($variables->all())->toBe([ + 'RAILPACK_NODE_VERSION' => '22', + 'RAILPACK_CUSTOM_FLAG' => 'hello world', + 'RAILPACK_JSON' => '{"token":"abc"}', + 'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall', + ]); + expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'"); + expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'"); + expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'"); + expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'"); + expect($envArgs)->not->toContain('RAILPACK_NULL'); +}); + +it('uses preview railpack environment variables for preview deployments', function () { + $application = Mockery::mock(Application::class); + $application->shouldReceive('getAttribute')->with('install_command')->andReturn(null); + + $previewValue = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $previewValue->forceFill([ + 'key' => 'RAILPACK_PREVIEW_ONLY', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $previewValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('preview-value'); + + $application->shouldReceive('getAttribute') + ->with('railpack_environment_variables_preview') + ->andReturn(collect([$previewValue])); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $application); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 42); + + $mainServerProperty = $reflection->getProperty('mainServer'); + $mainServerProperty->setAccessible(true); + $mainServerProperty->setValue($job, Mockery::mock(Server::class)); + + $method = $reflection->getMethod('generate_railpack_env_variables'); + $method->setAccessible(true); + $variables = $method->invoke($job); + + expect($variables->all())->toBe([ + 'RAILPACK_PREVIEW_ONLY' => 'preview-value', + ]); +}); From 28320858caae09713368dfe19b78121fb285747a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:22:15 +0200 Subject: [PATCH 11/20] feat(railpack): log generated config and build plan, add multi-language dev examples Log Railpack config JSON (dev-only) after generation and capture railpack-plan.json post-prepare step. In prod, strip secrets array before logging. In dev, log full plan. Add 10 multi-language seeder examples (Python/Flask, Go/Gin, Rust, Laravel, Symfony, Rails, Elixir/Phoenix, Bun) targeting v4.x branch. Support per-example git_branch override in upsertApplication. --- app/Jobs/ApplicationDeploymentJob.php | 23 ++++++ .../DevelopmentRailpackExamplesSeeder.php | 74 ++++++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index fbf981483..85f438879 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2692,6 +2692,10 @@ private function generate_railpack_config_file(): ?string ] ); + if (isDev()) { + $this->application_deployment_queue->addLogEntry('Generated Railpack config: '.json_encode($mergedConfig, JSON_PRETTY_PRINT), hidden: true); + } + return $this->generated_railpack_config_relative_path(); } @@ -2731,8 +2735,27 @@ private function build_railpack_image(): void $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.'); $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/railpack-plan.json'), + 'hidden' => true, + 'save' => 'railpack_plan', + ], ); + $railpackPlanRaw = $this->saved_outputs->get('railpack_plan'); + if (! empty($railpackPlanRaw)) { + if (isDev()) { + $this->application_deployment_queue->addLogEntry("Final Railpack plan: {$railpackPlanRaw}", hidden: true); + } else { + $parsedPlan = json_decode($railpackPlanRaw, true); + if (is_array($parsedPlan)) { + // Strip secrets array to avoid logging variable names in production. + unset($parsedPlan['secrets']); + $this->application_deployment_queue->addLogEntry('Final Railpack plan: '.json_encode($parsedPlan, JSON_PRETTY_PRINT), hidden: true); + } + } + } + // Step 2: Build image using docker buildx with railpack frontend. // Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder. $this->application_deployment_queue->addLogEntry('Building docker image with Railpack.'); diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php index 4629f29ca..dec7864db 100644 --- a/database/seeders/DevelopmentRailpackExamplesSeeder.php +++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php @@ -288,6 +288,78 @@ public static function examples(): array 'is_static' => true, 'is_spa' => true, ], + // Multi-language examples (only available on v4.x branch). + [ + 'uuid' => 'railpack-python-flask', + 'name' => 'Railpack Python Flask Example', + 'base_directory' => '/flask', + 'ports_exposes' => '5000', + 'git_branch' => 'v4.x', + 'start_command' => 'gunicorn app:app --bind 0.0.0.0:5000', + ], + [ + 'uuid' => 'railpack-go-gin', + 'name' => 'Railpack Go Gin Example', + 'base_directory' => '/go/gin', + 'ports_exposes' => '8080', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-rust', + 'name' => 'Railpack Rust Example', + 'base_directory' => '/rust', + 'ports_exposes' => '8080', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-laravel', + 'name' => 'Railpack Laravel Example', + 'base_directory' => '/laravel', + 'ports_exposes' => '80', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-laravel-pure', + 'name' => 'Railpack Laravel Pure Example', + 'base_directory' => '/laravel-pure', + 'ports_exposes' => '80', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-laravel-inertia', + 'name' => 'Railpack Laravel Inertia Example', + 'base_directory' => '/laravel-inertia', + 'ports_exposes' => '80', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-symfony', + 'name' => 'Railpack Symfony Example', + 'base_directory' => '/symfony', + 'ports_exposes' => '80', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-rails', + 'name' => 'Railpack Ruby on Rails Example', + 'base_directory' => '/rails-example', + 'ports_exposes' => '3000', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-elixir-phoenix', + 'name' => 'Railpack Elixir Phoenix Example', + 'base_directory' => '/elixir-phoenix', + 'ports_exposes' => '4000', + 'git_branch' => 'v4.x', + ], + [ + 'uuid' => 'railpack-bun', + 'name' => 'Railpack Bun Example', + 'base_directory' => '/bun', + 'ports_exposes' => '3000', + 'git_branch' => 'v4.x', + ], ]; } @@ -409,7 +481,7 @@ private function upsertApplication(Environment $environment, StandaloneDocker $d 'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io", 'repository_project_id' => self::REPOSITORY_PROJECT_ID, 'git_repository' => self::GIT_REPOSITORY, - 'git_branch' => self::GIT_BRANCH, + 'git_branch' => $example['git_branch'] ?? self::GIT_BRANCH, 'build_pack' => 'railpack', 'ports_exposes' => $example['ports_exposes'], 'base_directory' => $example['base_directory'], From ec71d33f5e5c80ee4e9bc87aaebd578fdd15a675 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:27:08 +0200 Subject: [PATCH 12/20] fix(railpack): pin frontend image version via config constant Remove RAILPACK_FRONTEND_IMAGE env var from helper Dockerfile and resolve the image ref at runtime using a new `railpack_version` constant in config. Eliminates Docker build-time env interpolation for BUILDKIT_SYNTAX arg. --- app/Jobs/ApplicationDeploymentJob.php | 5 ++--- config/constants.php | 1 + docker/coolify-helper/Dockerfile | 1 - tests/Unit/ApplicationDeploymentRailpackConfigTest.php | 5 ++++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 85f438879..420fe8fd4 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -53,8 +53,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; - private const RAILPACK_FRONTEND_IMAGE_ENV = '${RAILPACK_FRONTEND_IMAGE}'; - public $tries = 1; public $timeout = 3600; @@ -2568,11 +2566,12 @@ private function railpack_build_command(string $imageName, Collection $variables $environmentPrefix = $this->railpack_build_environment_prefix($variables); $secretFlags = $this->railpack_build_secret_flags($variables); + $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version'); return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' ." && {$environmentPrefix}docker buildx build --builder coolify-railpack" ." {$this->addHosts} --network host" - .' --build-arg BUILDKIT_SYNTAX="'.self::RAILPACK_FRONTEND_IMAGE_ENV.'"' + ." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\"" ." {$cacheArgs}" ."{$secretFlags}" .' -f /artifacts/railpack-plan.json' diff --git a/config/constants.php b/config/constants.php index 867cc22d9..dedafa7d5 100644 --- a/config/constants.php +++ b/config/constants.php @@ -5,6 +5,7 @@ 'version' => '4.1.0', 'helper_version' => '1.0.13', 'realtime_version' => '1.0.14', + 'railpack_version' => '0.22.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 35798000b..263c5a311 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -35,7 +35,6 @@ ARG MISE_VERSION USER root WORKDIR /artifacts ENV RAILPACK_VERSION=${RAILPACK_VERSION} -ENV RAILPACK_FRONTEND_IMAGE=ghcr.io/railwayapp/railpack-frontend:v${RAILPACK_VERSION} RUN apk upgrade --no-cache && \ apk add --no-cache bash curl git git-lfs openssh-client tar tini RUN mkdir -p ~/.docker/cli-plugins diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index e3516268c..5314fa6b8 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -4,6 +4,9 @@ use App\Jobs\ApplicationDeploymentJob; use App\Models\Application; use Illuminate\Support\Collection; +use Tests\TestCase; + +uses(TestCase::class); class TestableRailpackDeploymentJob extends ApplicationDeploymentJob { @@ -228,5 +231,5 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ expect($command)->toContain('--secret id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'); expect($command)->toContain('--secret id=SECRET_JSON,env=SECRET_JSON'); expect($command)->toContain(' --build-arg secrets-hash='); - expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="${RAILPACK_FRONTEND_IMAGE}"'); + expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version').'"'); }); From ace643d3d8375308ac98f9b6d1e546559d1bbd1d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:38:58 +0200 Subject: [PATCH 13/20] fix(railpack): query buildtime env vars directly instead of via computed attribute Replace `railpack_environment_variables_collection()` helper (which returned pre-filtered Eloquent attribute collections) with inline queries on `environment_variables()` / `environment_variables_preview()` filtered by `is_buildtime`. This ensures Railpack build variables are sourced from the same query path as the rest of the deployment pipeline and avoids relying on a now-removed accessor that silently included all railpack vars regardless of build context. --- app/Jobs/ApplicationDeploymentJob.php | 25 +++++++++++-------- ...icationDeploymentRailpackEnvParityTest.php | 14 ++++++----- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 420fe8fd4..6f9aabfd5 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2483,15 +2483,6 @@ private function generate_railpack_env_variables(): Collection return $variables; } - private function railpack_environment_variables_collection(): Collection - { - if ($this->pull_request_id === 0) { - return $this->application->railpack_environment_variables; - } - - return $this->application->railpack_environment_variables_preview; - } - private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string { $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer); @@ -2506,9 +2497,23 @@ private function normalize_resolved_build_variable_value(EnvironmentVariable $en return $resolvedValue; } + /** + * All buildtime variables that must reach the Railpack build. + * + * Railpack's BuildKit frontend treats every `--env` passed to `railpack prepare` + * as a build secret entry in the generated plan, then pairs it with `--secret id=,env=` + * on `docker buildx build`. Because Railpack's schema disallows top-level `variables` + * (unlike Nixpacks, which bakes variables into the plan), this `--env` → `--secret` + * channel is the only way user-defined buildtime variables become available to + * commands declared with `useSecrets: true`. + */ private function railpack_build_variables(): Collection { - $variables = $this->railpack_environment_variables_collection() + $envCollection = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); + + $variables = $envCollection ->mapWithKeys(function (EnvironmentVariable $environmentVariable) { $value = $this->normalize_resolved_build_variable_value($environmentVariable); if (is_null($value) || $value === '') { diff --git a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php index 8ed7b2c41..487268fb6 100644 --- a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php @@ -41,9 +41,10 @@ ]); $nullValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn(null); - $application->shouldReceive('getAttribute') - ->with('railpack_environment_variables') - ->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue])); + $envQuery = Mockery::mock(); + $envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf(); + $envQuery->shouldReceive('get')->once()->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue])); + $application->shouldReceive('environment_variables')->once()->andReturn($envQuery); $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); $job->shouldAllowMockingProtectedMethods(); @@ -94,9 +95,10 @@ ]); $previewValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('preview-value'); - $application->shouldReceive('getAttribute') - ->with('railpack_environment_variables_preview') - ->andReturn(collect([$previewValue])); + $previewQuery = Mockery::mock(); + $previewQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf(); + $previewQuery->shouldReceive('get')->once()->andReturn(collect([$previewValue])); + $application->shouldReceive('environment_variables_preview')->once()->andReturn($previewQuery); $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); $job->shouldAllowMockingProtectedMethods(); From b6ca6b1b2038795481027580773fd86e4183fad4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:31:41 +0200 Subject: [PATCH 14/20] feat(railpack): expose COOLIFY_* vars at build time and generalize buildpack control flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors Nixpacks behavior: inject COOLIFY_* and SOURCE_COMMIT into railpack build variables so apps (e.g. SPAs baking public URLs) can read them via /run/secrets/. Rename is_nixpacks → is_buildpack_control to cover both NIXPACKS_ and RAILPACK_ prefixed keys. Update the env variable view and appends list accordingly. Promote generate_coolify_env_variables to protected for testability. --- app/Jobs/ApplicationDeploymentJob.php | 10 ++- app/Models/EnvironmentVariable.php | 12 +--- .../environment-variable/show.blade.php | 8 +-- ...icationDeploymentRailpackEnvParityTest.php | 69 +++++++++++++++++++ ...nvironmentVariableBuildpackControlTest.php | 37 ++++++++++ 5 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/EnvironmentVariableBuildpackControlTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 6f9aabfd5..0131d218a 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2527,6 +2527,14 @@ private function railpack_build_variables(): Collection $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); } + // Mirror Nixpacks behavior: expose COOLIFY_* and SOURCE_COMMIT to the build so apps + // (e.g. SPAs baking the public URL) can read them via /run/secrets/. + foreach ($this->generate_coolify_env_variables(forBuildTime: true) as $key => $value) { + if (! is_null($value) && $value !== '') { + $variables->put($key, $value); + } + } + return $variables; } @@ -2829,7 +2837,7 @@ private function build_railpack_static_image(): void ); } - private function generate_coolify_env_variables(bool $forBuildTime = false): Collection + protected function generate_coolify_env_variables(bool $forBuildTime = false): Collection { $coolify_envs = collect([]); $local_branch = $this->branch; diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 25e2dcfb3..ac0d238b3 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -77,7 +77,7 @@ class EnvironmentVariable extends BaseModel 'resourceable_id' => 'integer', ]; - protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify']; + protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_buildpack_control', 'is_coolify']; protected static function booted() { @@ -215,16 +215,10 @@ protected function isReallyRequired(): Attribute ); } - protected function isNixpacks(): Attribute + protected function isBuildpackControl(): Attribute { return Attribute::make( - get: function () { - if (str($this->key)->startsWith('NIXPACKS_')) { - return true; - } - - return false; - } + get: fn () => self::isBuildpackControlKey($this->key), ); } diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 6630d0500..cbb7afa2f 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -58,7 +58,7 @@ @endif @else - @if (!$env->is_nixpacks) + @if (!$env->is_buildpack_control) @@ -67,7 +67,7 @@ helper="Make this variable available in the running container at runtime." label="Available at Runtime" /> @if (!$isMagicVariable) - @if (!$env->is_nixpacks) + @if (!$env->is_buildpack_control) @if ($is_multiline === false) @endif @else - @if (!$env->is_nixpacks) + @if (!$env->is_buildpack_control) @@ -245,7 +245,7 @@ helper="Make this variable available in the running container at runtime." label="Available at Runtime" /> @if (!$isMagicVariable) - @if (!$env->is_nixpacks) + @if (!$env->is_buildpack_control) @if ($is_multiline === false) makePartial(); $job->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([])); $reflection = new ReflectionClass(ApplicationDeploymentJob::class); $applicationProperty = $reflection->getProperty('application'); @@ -102,6 +103,7 @@ $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); $job->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([])); $reflection = new ReflectionClass(ApplicationDeploymentJob::class); $applicationProperty = $reflection->getProperty('application'); @@ -124,3 +126,70 @@ 'RAILPACK_PREVIEW_ONLY' => 'preview-value', ]); }); + +it('merges coolify env variables into railpack build variables', function () { + $application = Mockery::mock(Application::class); + $application->shouldReceive('getAttribute')->with('install_command')->andReturn(null); + + $userVar = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $userVar->forceFill([ + 'key' => 'MY_BUILD_VAR', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $userVar->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('hello'); + + $envQuery = Mockery::mock(); + $envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf(); + $envQuery->shouldReceive('get')->once()->andReturn(collect([$userVar])); + $application->shouldReceive('environment_variables')->once()->andReturn($envQuery); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('generate_coolify_env_variables') + ->with(true) + ->andReturn(collect([ + 'COOLIFY_URL' => 'https://app.example.com', + 'COOLIFY_FQDN' => 'app.example.com', + 'COOLIFY_BRANCH' => 'main', + 'COOLIFY_RESOURCE_UUID' => 'app-uuid', + 'SOURCE_COMMIT' => 'abc123', + 'EMPTY_VAR' => '', + 'NULL_VAR' => null, + ])); + + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $application); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + $mainServerProperty = $reflection->getProperty('mainServer'); + $mainServerProperty->setAccessible(true); + $mainServerProperty->setValue($job, Mockery::mock(Server::class)); + + $method = $reflection->getMethod('generate_railpack_env_variables'); + $method->setAccessible(true); + $variables = $method->invoke($job); + + expect($variables->all())->toBe([ + 'MY_BUILD_VAR' => 'hello', + 'COOLIFY_URL' => 'https://app.example.com', + 'COOLIFY_FQDN' => 'app.example.com', + 'COOLIFY_BRANCH' => 'main', + 'COOLIFY_RESOURCE_UUID' => 'app-uuid', + 'SOURCE_COMMIT' => 'abc123', + ]); + + $envArgsProperty = $reflection->getProperty('env_railpack_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'"); + expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'"); + expect($envArgs)->not->toContain('EMPTY_VAR'); + expect($envArgs)->not->toContain('NULL_VAR'); +}); diff --git a/tests/Unit/EnvironmentVariableBuildpackControlTest.php b/tests/Unit/EnvironmentVariableBuildpackControlTest.php new file mode 100644 index 000000000..1a277bcdd --- /dev/null +++ b/tests/Unit/EnvironmentVariableBuildpackControlTest.php @@ -0,0 +1,37 @@ +key = 'NIXPACKS_NODE_VERSION'; + + expect($env->is_buildpack_control)->toBeTrue(); +}); + +it('flags RAILPACK_ keys as buildpack control variables', function () { + $env = new EnvironmentVariable; + $env->key = 'RAILPACK_NODE_VERSION'; + + expect($env->is_buildpack_control)->toBeTrue(); +}); + +it('does not flag user-defined keys as buildpack control variables', function () { + $env = new EnvironmentVariable; + $env->key = 'MY_BUILD_VAR'; + + expect($env->is_buildpack_control)->toBeFalse(); +}); + +it('does not flag empty key as buildpack control variable', function () { + $env = new EnvironmentVariable; + + expect($env->is_buildpack_control)->toBeFalse(); +}); + +it('lists is_buildpack_control in appends and drops legacy is_nixpacks', function () { + $env = new EnvironmentVariable; + + expect($env->getAppends())->toContain('is_buildpack_control'); + expect($env->getAppends())->not->toContain('is_nixpacks'); +}); From 22a2c05a1d4fddb6e64bb552c7eaf8a4ccb694d3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 5 May 2026 15:32:43 +0200 Subject: [PATCH 15/20] test(railpack): add API, Livewire UI tests and e2e smoke script Add feature tests covering railpack build pack via REST API and Livewire UI components, plus a bash smoke test that deploys seeded railpack-* example apps against the local dev stack and verifies COOLIFY_*, SOURCE_COMMIT, and RAILPACK_* env vars land correctly. --- scripts/railpack-smoke.sh | 322 ++++++++++++++++ tests/Feature/Api/RailpackApiTest.php | 345 ++++++++++++++++++ .../Livewire/RailpackLivewireUiTest.php | 121 ++++++ 3 files changed, 788 insertions(+) create mode 100755 scripts/railpack-smoke.sh create mode 100644 tests/Feature/Api/RailpackApiTest.php create mode 100644 tests/Feature/Livewire/RailpackLivewireUiTest.php diff --git a/scripts/railpack-smoke.sh b/scripts/railpack-smoke.sh new file mode 100755 index 000000000..92e621c3b --- /dev/null +++ b/scripts/railpack-smoke.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# +# Railpack end-to-end deploy smoke test against the local dev stack. +# +# Walks a curated set of railpack-* example apps from +# DevelopmentRailpackExamplesSeeder, triggers a deploy via the Coolify API, +# waits for the deployment queue to finish, then exec()s into the resulting +# container and checks that COOLIFY_*, SOURCE_COMMIT, and any RAILPACK_* +# build inputs landed correctly. Optionally curls the FQDN. +# +# Requires: +# - Dev stack running: spin up (or docker compose -f docker-compose.dev.yml up -d) +# - Seeder run: php artisan db:seed --class=DevelopmentRailpackExamplesSeeder +# - Personal token: PersonalAccessTokenSeeder run (creates Bearer 'root') +# - jq, curl available on host +# +# Usage: +# scripts/railpack-smoke.sh # default subset +# scripts/railpack-smoke.sh --app railpack-laravel # single app +# scripts/railpack-smoke.sh --all # every seeded railpack-* app +# scripts/railpack-smoke.sh --timeout 900 # build wait per app, seconds +# scripts/railpack-smoke.sh --no-curl # skip FQDN curl +# scripts/railpack-smoke.sh --extra-env KEY=VALUE # build+runtime env (alias of --both-env) +# scripts/railpack-smoke.sh --build-env KEY=VALUE # buildtime-only env (must reach build, NOT runtime) +# scripts/railpack-smoke.sh --runtime-env KEY=VALUE # runtime-only env (must reach runtime, NOT build) +# scripts/railpack-smoke.sh --both-env KEY=VALUE # buildtime+runtime env +# +set -euo pipefail + +API_BASE="${COOLIFY_API_BASE:-http://localhost:8000/api/v1}" +TOKEN="${COOLIFY_API_TOKEN:-root}" +TIMEOUT="${SMOKE_TIMEOUT:-600}" +DO_CURL=1 +BUILD_ENVS=() +RUNTIME_ENVS=() +BOTH_ENVS=() +APPS=() + +DEFAULT_APPS=( + railpack-expressjs + railpack-nestjs + railpack-nextjs-ssr + railpack-vite-static + railpack-astro-static + railpack-python-flask + railpack-go-gin + railpack-rust + railpack-laravel + railpack-bun +) + +while (( $# > 0 )); do + case "$1" in + --app) APPS+=("$2"); shift 2 ;; + --all) APPS=(__ALL__); shift ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + --no-curl) DO_CURL=0; shift ;; + --extra-env|--both-env) BOTH_ENVS+=("$2"); shift 2 ;; + --build-env) BUILD_ENVS+=("$2"); shift 2 ;; + --runtime-env) RUNTIME_ENVS+=("$2"); shift 2 ;; + --base) API_BASE="$2"; shift 2 ;; + --token) TOKEN="$2"; shift 2 ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +if ! command -v jq >/dev/null; then + echo "jq required" >&2; exit 2 +fi +if ! command -v docker >/dev/null; then + echo "docker required" >&2; exit 2 +fi + +curl_api() { + local method="$1"; shift + local path="$1"; shift + curl -fsS -X "$method" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API_BASE}${path}" \ + "$@" +} + +if (( ${#APPS[@]} == 0 )); then + APPS=("${DEFAULT_APPS[@]}") +fi + +if [[ "${APPS[0]}" == "__ALL__" ]]; then + mapfile -t APPS < <(curl_api GET /applications | jq -r '.[].uuid' | grep '^railpack-' || true) +fi + +log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; } +fail() { printf '\033[31m[FAIL]\033[0m %s: %s\n' "$1" "$2"; FAILED+=("$1: $2"); } +pass() { printf '\033[32m[ OK ]\033[0m %s: %s\n' "$1" "$2"; } + +upsert_env() { + local app_uuid="$1" key="$2" value="$3" buildtime="$4" runtime="$5" existing + existing=$(curl_api GET "/applications/${app_uuid}/envs" | jq -r --arg k "$key" '.[] | select(.key==$k) | .uuid' | head -1) + local payload + payload=$(jq -nc --arg k "$key" --arg v "$value" --argjson b "$buildtime" --argjson r "$runtime" \ + '{key:$k, value:$v, is_buildtime:$b, is_runtime:$r, is_preview:false}') + if [[ -n "$existing" ]]; then + curl_api PATCH "/applications/${app_uuid}/envs" --data "$payload" >/dev/null + log " env ${key} updated (buildtime=${buildtime} runtime=${runtime})" + else + curl_api POST "/applications/${app_uuid}/envs" --data "$payload" >/dev/null + log " env ${key} created (buildtime=${buildtime} runtime=${runtime})" + fi +} + +ensure_envs() { + local app_uuid="$1" kv key value + for kv in "${BUILD_ENVS[@]:-}"; do + [[ -z "$kv" ]] && continue + key="${kv%%=*}"; value="${kv#*=}" + upsert_env "$app_uuid" "$key" "$value" true false + done + for kv in "${RUNTIME_ENVS[@]:-}"; do + [[ -z "$kv" ]] && continue + key="${kv%%=*}"; value="${kv#*=}" + upsert_env "$app_uuid" "$key" "$value" false true + done + for kv in "${BOTH_ENVS[@]:-}"; do + [[ -z "$kv" ]] && continue + key="${kv%%=*}"; value="${kv#*=}" + upsert_env "$app_uuid" "$key" "$value" true true + done +} + +trigger_deploy() { + local app_uuid="$1" + curl_api POST "/applications/${app_uuid}/start?force=true&instant_deploy=true" \ + | jq -r '.deployment_uuid // empty' +} + +wait_for_deploy() { + local dep_uuid="$1" deadline="$2" status + while (( $(date +%s) < deadline )); do + status=$(curl_api GET "/deployments/${dep_uuid}" | jq -r '.status // "unknown"') + case "$status" in + finished) echo finished; return 0 ;; + failed|cancelled) echo "$status"; return 1 ;; + queued|in_progress) sleep 5 ;; + *) sleep 5 ;; + esac + done + echo timeout; return 1 +} + +container_for_app() { + local app_uuid="$1" + docker ps --filter "name=^${app_uuid}-" --format '{{.Names}}' | head -1 +} + +assert_envs_present() { + local container="$1" app_uuid="$2" + local env_dump + env_dump=$(docker exec "$container" env 2>/dev/null || true) + + local missing=() + for required in COOLIFY_FQDN COOLIFY_URL COOLIFY_BRANCH COOLIFY_RESOURCE_UUID COOLIFY_CONTAINER_NAME SOURCE_COMMIT; do + if ! grep -q "^${required}=" <<<"$env_dump"; then + missing+=("$required") + fi + done + + local resource_uuid + resource_uuid=$(grep '^COOLIFY_RESOURCE_UUID=' <<<"$env_dump" | cut -d= -f2- || true) + if [[ "$resource_uuid" != "$app_uuid" ]]; then + missing+=("COOLIFY_RESOURCE_UUID-mismatch(got=${resource_uuid})") + fi + + if (( ${#missing[@]} == 0 )); then + pass "$app_uuid" "runtime envs present (${resource_uuid})" + return 0 + fi + fail "$app_uuid" "missing/incorrect envs: ${missing[*]}" + return 1 +} + +deploy_logs_text() { + local dep_uuid="$1" + curl_api GET "/deployments/${dep_uuid}" | jq -r '(.logs | fromjson? // []) | .[].output' 2>/dev/null +} + +assert_runtime_only_envs() { + local container="$1" app_uuid="$2" + [[ ${#RUNTIME_ENVS[@]} -eq 0 ]] && return 0 + local env_dump key value actual + env_dump=$(docker exec "$container" env 2>/dev/null || true) + for kv in "${RUNTIME_ENVS[@]}"; do + key="${kv%%=*}"; value="${kv#*=}" + if ! grep -q "^${key}=" <<<"$env_dump"; then + fail "$app_uuid" "runtime-only env ${key} missing at runtime" + return 1 + fi + actual=$(grep "^${key}=" <<<"$env_dump" | head -1 | cut -d= -f2-) + if [[ "$actual" != "$value" ]]; then + fail "$app_uuid" "runtime env ${key} value mismatch (got=${actual} want=${value})" + return 1 + fi + done + pass "$app_uuid" "runtime-only envs present at runtime (${#RUNTIME_ENVS[@]} key(s))" +} + +assert_build_only_envs() { + local container="$1" app_uuid="$2" dep_uuid="$3" + [[ ${#BUILD_ENVS[@]} -eq 0 ]] && return 0 + local env_dump logs key + env_dump=$(docker exec "$container" env 2>/dev/null || true) + logs=$(deploy_logs_text "$dep_uuid") + + for kv in "${BUILD_ENVS[@]}"; do + key="${kv%%=*}" + # Reach build: railpack passes buildtime envs as docker buildx --secret id=KEY + if ! grep -q -- "--secret id=${key}" <<<"$logs"; then + fail "$app_uuid" "build-only env ${key} not seen as --secret in deploy logs" + return 1 + fi + # Must NOT leak to runtime container + if grep -q "^${key}=" <<<"$env_dump"; then + fail "$app_uuid" "build-only env ${key} LEAKED to runtime container" + return 1 + fi + done + pass "$app_uuid" "build-only envs in build secret + absent at runtime (${#BUILD_ENVS[@]} key(s))" +} + +assert_both_envs() { + local container="$1" app_uuid="$2" dep_uuid="$3" + [[ ${#BOTH_ENVS[@]} -eq 0 ]] && return 0 + local env_dump logs key + env_dump=$(docker exec "$container" env 2>/dev/null || true) + logs=$(deploy_logs_text "$dep_uuid") + for kv in "${BOTH_ENVS[@]}"; do + key="${kv%%=*}" + if [[ "$key" =~ ^RAILPACK_ ]]; then + # RAILPACK_* are buildtime-only by railpack convention; skip runtime check + grep -q -- "--secret id=${key}" <<<"$logs" \ + || { fail "$app_uuid" "${key} not seen in build secrets"; return 1; } + continue + fi + grep -q "^${key}=" <<<"$env_dump" \ + || { fail "$app_uuid" "both-env ${key} missing at runtime"; return 1; } + done + pass "$app_uuid" "both-envs reached runtime (${#BOTH_ENVS[@]} key(s))" +} + +assert_fqdn_responds() { + local app_uuid="$1" + local fqdn + fqdn=$(curl_api GET "/applications/${app_uuid}" | jq -r '.fqdn // empty') + [[ -z "$fqdn" ]] && return 0 + local code + code=$(curl -ksSL -o /dev/null -w '%{http_code}' --max-time 10 "$fqdn" || echo "000") + case "$code" in + 2*|3*) pass "$app_uuid" "fqdn ${fqdn} -> ${code}" ;; + *) fail "$app_uuid" "fqdn ${fqdn} -> ${code}" ;; + esac +} + +run_one() { + local app_uuid="$1" + log "==> ${app_uuid}" + + if ! curl_api GET "/applications/${app_uuid}" >/dev/null 2>&1; then + fail "$app_uuid" "application not found via API (run seeder?)" + return + fi + + ensure_envs "$app_uuid" + + local dep + dep=$(trigger_deploy "$app_uuid") + if [[ -z "$dep" ]]; then + fail "$app_uuid" "no deployment_uuid returned" + return + fi + log " deploy queued: ${dep}" + + local deadline=$(( $(date +%s) + TIMEOUT )) + local result + result=$(wait_for_deploy "$dep" "$deadline") || { + fail "$app_uuid" "deploy ${result}" + return + } + pass "$app_uuid" "deploy ${result}" + + sleep 2 + local container + container=$(container_for_app "$app_uuid") + if [[ -z "$container" ]]; then + fail "$app_uuid" "no running container matching name=^${app_uuid}-" + return + fi + pass "$app_uuid" "container ${container} running" + + assert_envs_present "$container" "$app_uuid" || true + assert_runtime_only_envs "$container" "$app_uuid" || true + assert_build_only_envs "$container" "$app_uuid" "$dep" || true + assert_both_envs "$container" "$app_uuid" "$dep" || true + + if (( DO_CURL )); then + assert_fqdn_responds "$app_uuid" || true + fi +} + +FAILED=() +for app in "${APPS[@]}"; do + run_one "$app" +done + +echo +echo "=== summary ===" +if (( ${#FAILED[@]} == 0 )); then + echo "all apps passed" + exit 0 +fi +printf '%s failure(s):\n' "${#FAILED[@]}" +printf ' - %s\n' "${FAILED[@]}" +exit 1 diff --git a/tests/Feature/Api/RailpackApiTest.php b/tests/Feature/Api/RailpackApiTest.php new file mode 100644 index 000000000..c9dbced4b --- /dev/null +++ b/tests/Feature/Api/RailpackApiTest.php @@ -0,0 +1,345 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'railpack-api-test-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function railpackApiHeaders(string $bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +function makeRailpackApp(array $overrides = []): Application +{ + return Application::factory()->create(array_merge([ + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'build_pack' => 'railpack', + ], $overrides)); +} + +describe('PATCH /api/v1/applications/{uuid} build_pack=railpack', function () { + test('rejects unsupported build_pack at controller layer', function () { + $app = makeRailpackApp(); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}", [ + 'build_pack' => 'totally-bogus', + ]); + + $response->assertStatus(422); + }); + + test('switching from dockerfile to railpack clears dockerfile fields', function () { + $app = makeRailpackApp([ + 'build_pack' => 'dockerfile', + 'dockerfile' => 'FROM node:20', + 'dockerfile_location' => '/Dockerfile', + 'dockerfile_target_build' => 'production', + 'custom_healthcheck_found' => true, + ]); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}", [ + 'build_pack' => 'railpack', + ]); + + $response->assertOk(); + + $app->refresh(); + expect($app->build_pack)->toBe('railpack'); + expect($app->dockerfile)->toBeNull(); + // NOTE: dockerfile_location is normalized to '/Dockerfile' by the model + // mutator when set to null, so we cannot assert it becomes null here. + expect($app->dockerfile_target_build)->toBeNull(); + expect((bool) $app->custom_healthcheck_found)->toBeFalse(); + }); + + test('switching from dockercompose to railpack clears compose fields and SERVICE_* envs', function () { + $app = makeRailpackApp([ + 'build_pack' => 'dockercompose', + 'docker_compose_domains' => '{"app": "example.com"}', + 'docker_compose_raw' => "version: '3'\nservices:\n app:\n image: nginx", + ]); + + $app->environment_variables()->createMany([ + ['key' => 'SERVICE_FQDN_APP', 'value' => 'app.example.com', 'is_buildtime' => false, 'is_preview' => false], + ['key' => 'SERVICE_URL_APP', 'value' => 'http://app.example.com', 'is_buildtime' => false, 'is_preview' => false], + ['key' => 'REGULAR_VAR', 'value' => 'keep_me', 'is_buildtime' => false, 'is_preview' => false], + ]); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}", [ + 'build_pack' => 'railpack', + ]); + + $response->assertOk(); + + $app->refresh(); + expect($app->build_pack)->toBe('railpack'); + expect($app->docker_compose_domains)->toBeNull(); + expect($app->docker_compose_raw)->toBeNull(); + expect($app->environment_variables()->where('key', 'SERVICE_FQDN_APP')->count())->toBe(0); + expect($app->environment_variables()->where('key', 'SERVICE_URL_APP')->count())->toBe(0); + expect($app->environment_variables()->where('key', 'REGULAR_VAR')->count())->toBe(1); + }); + + test('install/build/start commands persist for railpack apps', function () { + $app = makeRailpackApp(); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}", [ + 'install_command' => 'npm ci', + 'build_command' => 'npm run build', + 'start_command' => 'node server.js', + ]); + + $response->assertOk(); + + $app->refresh(); + expect($app->install_command)->toBe('npm ci'); + expect($app->build_command)->toBe('npm run build'); + expect($app->start_command)->toBe('node server.js'); + }); +}); + +describe('POST /api/v1/applications/{uuid}/envs RAILPACK_* handling', function () { + test('adding RAILPACK_NODE_VERSION via API surfaces in railpack_environment_variables only', function () { + $app = makeRailpackApp(); + + $response = $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + ]); + + $response->assertCreated(); + + $app->refresh(); + expect($app->railpack_environment_variables)->toHaveCount(1); + expect($app->railpack_environment_variables->first()->key)->toBe('RAILPACK_NODE_VERSION'); + expect($app->runtime_environment_variables->where('key', 'RAILPACK_NODE_VERSION'))->toHaveCount(0); + }); + + test('runtime envs added via API surface in runtime_environment_variables but not railpack_*', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_runtime' => true, + 'is_buildtime' => false, + 'is_preview' => false, + ])->assertCreated(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '18', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $runtime = $app->runtime_environment_variables; + expect($runtime->pluck('key')->all())->toBe(['APP_ENV']); + expect($app->railpack_environment_variables)->toHaveCount(0); + }); + + test('preview RAILPACK_* envs surface in railpack_environment_variables_preview only', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'RAILPACK_BUILD_CMD', + 'value' => 'npm run build', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => true, + ])->assertCreated(); + + $app->refresh(); + expect($app->railpack_environment_variables_preview)->toHaveCount(1); + expect($app->railpack_environment_variables)->toHaveCount(0); + }); + + test('buildtime-only env has is_buildtime=true and is_runtime=false', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'API_KEY', + 'value' => 'sekret', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'API_KEY')->first(); + expect($env)->not->toBeNull(); + expect((bool) $env->is_buildtime)->toBeTrue(); + expect((bool) $env->is_runtime)->toBeFalse(); + // Buildtime-only non-RAILPACK_ var: visible to runtime relation (it's not a buildpack-control var) + // but is_runtime flag is false; consumers gate runtime via is_runtime, not via the relation alone. + expect($env->resourceable_id)->toBe($app->id); + }); + + test('runtime-only env has is_runtime=true and is_buildtime=false', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'LOG_LEVEL', + 'value' => 'debug', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'LOG_LEVEL')->first(); + expect((bool) $env->is_buildtime)->toBeFalse(); + expect((bool) $env->is_runtime)->toBeTrue(); + }); + + test('railpack build variables collection includes only is_buildtime=true entries', function () { + // Sanity check the underlying query used by the deploy job: railpack_build_variables() + // pulls $application->environment_variables()->where('is_buildtime', true)->get() + // (see ApplicationDeploymentJob::railpack_build_variables). + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'BUILD_ARG', + 'value' => 'in-build', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + ])->assertCreated(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'RUNTIME_ARG', + 'value' => 'in-runtime', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $buildtime = $app->environment_variables()->where('is_buildtime', true)->pluck('key')->all(); + expect($buildtime)->toContain('BUILD_ARG'); + expect($buildtime)->not->toContain('RUNTIME_ARG'); + }); + + test('user-defined COOLIFY_FQDN takes precedence over auto-generated', function () { + // Documents generate_coolify_env_variables() override behavior: + // it skips generation when application->environment_variables already has the key. + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'COOLIFY_FQDN', + 'value' => 'overridden.example.com', + 'is_buildtime' => true, + 'is_runtime' => true, + 'is_preview' => false, + ])->assertCreated(); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'COOLIFY_FQDN')->first(); + expect($env)->not->toBeNull(); + expect($env->value)->toBe('overridden.example.com'); + // Confirm the model relation used by override-skip logic finds it + expect($app->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty())->toBeFalse(); + }); + + test('is_literal flag persists on create', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'RAILPACK_LITERAL_FLAG', + 'value' => '$NOT_INTERPOLATED', + 'is_buildtime' => true, + 'is_runtime' => false, + 'is_preview' => false, + 'is_literal' => true, + ])->assertCreated(); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'RAILPACK_LITERAL_FLAG')->first(); + expect((bool) $env->is_literal)->toBeTrue(); + }); + + test('PATCH env updates buildtime/runtime flags', function () { + $app = makeRailpackApp(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->postJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'TOGGLE_VAR', + 'value' => 'v1', + 'is_buildtime' => true, + 'is_runtime' => true, + 'is_preview' => false, + ])->assertCreated(); + + $this->withHeaders(railpackApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$app->uuid}/envs", [ + 'key' => 'TOGGLE_VAR', + 'value' => 'v2', + 'is_buildtime' => false, + 'is_runtime' => true, + 'is_multiline' => false, + 'is_shown_once' => false, + ])->assertStatus(201); + + $app->refresh(); + $env = $app->environment_variables()->where('key', 'TOGGLE_VAR')->first(); + expect($env->value)->toBe('v2'); + expect((bool) $env->is_buildtime)->toBeFalse(); + expect((bool) $env->is_runtime)->toBeTrue(); + }); +}); diff --git a/tests/Feature/Livewire/RailpackLivewireUiTest.php b/tests/Feature/Livewire/RailpackLivewireUiTest.php new file mode 100644 index 000000000..b21037b11 --- /dev/null +++ b/tests/Feature/Livewire/RailpackLivewireUiTest.php @@ -0,0 +1,121 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + InstanceSettings::unguarded(function () { + InstanceSettings::updateOrCreate(['id' => 0], []); + }); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +describe('PublicGitRepository port handling for railpack', function () { + test('switching to railpack resets port to 3000 when not static', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('build_pack', 'dockerfile') + ->assertSet('port', 3000) + ->set('build_pack', 'railpack') + ->assertSet('port', 3000); + }); + + test('switching to railpack preserves port when isStatic is true', function () { + $component = Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('isStatic', true) + ->call('instantSave'); + + // After instantSave with isStatic=true, port becomes 80 + $component->assertSet('port', 80); + + // Switching from nixpacks to railpack should NOT clobber port back to 3000 + $component->set('build_pack', 'railpack') + ->assertSet('port', 80); + }); + + test('switching to static sets port to 80 and disables show_is_static', function () { + Livewire::test(PublicGitRepository::class, ['type' => 'public']) + ->set('build_pack', 'static') + ->assertSet('port', 80) + ->assertSet('isStatic', false) + ->assertSet('show_is_static', false); + }); +}); + +describe('General view railpack helper text', function () { + beforeEach(function () { + $this->privateKey = PrivateKey::create([ + 'name' => 'Test Key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', + 'team_id' => $this->team->id, + ]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']); + }); + + test('railpack app shows railpack.json helper text and not nixpacks.toml', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'railpack', + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->assertSee('railpack.json') + ->assertDontSee('nixpacks.toml'); + }); + + test('nixpacks app shows nixpacks.toml helper text and not railpack.json', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'nixpacks', + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->assertSee('nixpacks.toml') + ->assertDontSee('railpack.json'); + }); +}); From d5946dcfcabe481d335ff06e2d1d0db2496c8190 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 11 May 2026 13:29:21 +0200 Subject: [PATCH 16/20] fix(railpack): include scoped env vars in builds Build Railpack variables from generic build-time vars plus Railpack-specific vars, filter unrelated buildpack control vars, and ensure curl/wget deploy apt packages are present. Add coverage for standard and preview deployments. --- app/Jobs/ApplicationDeploymentJob.php | 32 +++++- templates/service-templates-latest.json | 10 +- templates/service-templates.json | 10 +- ...ationDeploymentControlVarFilteringTest.php | 101 ++++++++++++++++++ ...pplicationDeploymentRailpackConfigTest.php | 3 + ...icationDeploymentRailpackEnvParityTest.php | 76 ++++++++++++- 6 files changed, 216 insertions(+), 16 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 0131d218a..beb1f5c05 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2509,11 +2509,16 @@ private function normalize_resolved_build_variable_value(EnvironmentVariable $en */ private function railpack_build_variables(): Collection { - $envCollection = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('is_buildtime', true)->get() - : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); + $genericBuildVariables = $this->pull_request_id === 0 + ? $this->application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get(); - $variables = $envCollection + $railpackVariables = $this->pull_request_id === 0 + ? $this->application->railpack_environment_variables()->get() + : $this->application->railpack_environment_variables_preview()->get(); + + $variables = $genericBuildVariables + ->merge($railpackVariables) ->mapWithKeys(function (EnvironmentVariable $environmentVariable) { $value = $this->normalize_resolved_build_variable_value($environmentVariable); if (is_null($value) || $value === '') { @@ -2527,6 +2532,8 @@ private function railpack_build_variables(): Collection $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command); } + $variables = $this->merge_railpack_deploy_apt_packages($variables); + // Mirror Nixpacks behavior: expose COOLIFY_* and SOURCE_COMMIT to the build so apps // (e.g. SPAs baking the public URL) can read them via /run/secrets/. foreach ($this->generate_coolify_env_variables(forBuildTime: true) as $key => $value) { @@ -2538,6 +2545,23 @@ private function railpack_build_variables(): Collection return $variables; } + private function merge_railpack_deploy_apt_packages(Collection $variables): Collection + { + $packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: []) + ->filter() + ->values(); + + foreach (['curl', 'wget'] as $package) { + if (! $packages->contains($package)) { + $packages->push($package); + } + } + + $variables->put('RAILPACK_DEPLOY_APT_PACKAGES', $packages->implode(' ')); + + return $variables; + } + private function railpack_build_environment_prefix(Collection $variables): string { if ($variables->isEmpty()) { diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index eb667fcb8..b57d9d29c 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -299,7 +299,7 @@ "bluesky-pds": { "documentation": "https://github.com/bluesky-social/pds?utm_source=coolify.io", "slogan": "Bluesky PDS (Personal Data Server)", - "compose": "c2VydmljZXM6CiAgcGRzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2JsdWVza3ktc29jaWFsL3BkczowLjQuMTgyJwogICAgdm9sdW1lczoKICAgICAgLSAncGRzLWRhdGE6L3BkcycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1BEU18zMDAwCiAgICAgIC0gJ1BEU19IT1NUTkFNRT0ke1NFUlZJQ0VfRlFETl9QRFN9JwogICAgICAtICdQRFNfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzMyX0pXVFNFQ1JFVH0nCiAgICAgIC0gJ1BEU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgICAtICdQRFNfQURNSU5fRU1BSUw9JHtQRFNfQURNSU5fRU1BSUx9JwogICAgICAtICdQRFNfUExDX1JPVEFUSU9OX0tFWV9LMjU2X1BSSVZBVEVfS0VZX0hFWD0ke1NFUlZJQ0VfSEVYXzMyX1JPVEFUSU9OS0VZfScKICAgICAgLSAnUERTX0RBVEFfRElSRUNUT1JZPSR7UERTX0RBVEFfRElSRUNUT1JZOi0vcGRzfScKICAgICAgLSAnUERTX0JMT0JTVE9SRV9ESVNLX0xPQ0FUSU9OPSR7UERTX0RBVEFfRElSRUNUT1JZOi0vcGRzfS9ibG9ja3MnCiAgICAgIC0gJ1BEU19CTE9CX1VQTE9BRF9MSU1JVD0ke1BEU19CTE9CX1VQTE9BRF9MSU1JVDotMTA0ODU3NjAwfScKICAgICAgLSAnUERTX0RJRF9QTENfVVJMPSR7UERTX0RJRF9QTENfVVJMOi1odHRwczovL3BsYy5kaXJlY3Rvcnl9JwogICAgICAtICdQRFNfRU1BSUxfRlJPTV9BRERSRVNTPSR7UERTX0VNQUlMX0ZST01fQUREUkVTU30nCiAgICAgIC0gJ1BEU19FTUFJTF9TTVRQX1VSTD0ke1BEU19FTUFJTF9TTVRQX1VSTH0nCiAgICAgIC0gJ1BEU19CU0tZX0FQUF9WSUVXX1VSTD0ke1BEU19CU0tZX0FQUF9WSUVXX1VSTDotaHR0cHM6Ly9hcGkuYnNreS5hcHB9JwogICAgICAtICdQRFNfQlNLWV9BUFBfVklFV19ESUQ9JHtQRFNfQlNLWV9BUFBfVklFV19ESUQ6LWRpZDp3ZWI6YXBpLmJza3kuYXBwfScKICAgICAgLSAnUERTX1JFUE9SVF9TRVJWSUNFX1VSTD0ke1BEU19SRVBPUlRfU0VSVklDRV9VUkw6LWh0dHBzOi8vbW9kLmJza3kuYXBwL3hycGMvY29tLmF0cHJvdG8ubW9kZXJhdGlvbi5jcmVhdGVSZXBvcnR9JwogICAgICAtICdQRFNfUkVQT1JUX1NFUlZJQ0VfRElEPSR7UERTX1JFUE9SVF9TRVJWSUNFX0RJRDotZGlkOnBsYzphcjdjNGJ5NDZxamR5ZGhkZXZ2cm5kYWN9JwogICAgICAtICdQRFNfQ1JBV0xFUlM9JHtQRFNfQ1JBV0xFUlM6LWh0dHBzOi8vYnNreS5uZXR3b3JrfScKICAgICAgLSAnTE9HX0VOQUJMRUQ9JHtMT0dfRU5BQkxFRDotdHJ1ZX0nCiAgICBjb21tYW5kOiAic2ggLWMgJ1xuICBzZXQgLWV1byBwaXBlZmFpbFxuICBlY2hvIFwiSW5zdGFsbGluZyByZXF1aXJlZCBwYWNrYWdlcyBhbmQgcGRzYWRtaW4uLi5cIlxuICBhcGsgYWRkIC0tbm8tY2FjaGUgb3BlbnNzbCBjdXJsIGJhc2gganEgY29yZXV0aWxzIGdudXBnIHV0aWwtbGludXgtbWlzYyA+L2Rldi9udWxsXG4gIGN1cmwgLW8gL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2JsdWVza3ktc29jaWFsL3Bkcy9tYWluL3Bkc2FkbWluLnNoXG4gIGNobW9kIDcwMCAvdXNyL2xvY2FsL2Jpbi9wZHNhZG1pbi5zaFxuICBsbiAtc2YgL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW5cbiAgZWNobyBcIkNyZWF0aW5nIGFuIGVtcHR5IHBkcy5lbnYgZmlsZSBzbyBwZHNhZG1pbiB3b3Jrcy4uLlwiXG4gIHRvdWNoICR7UERTX0RBVEFfRElSRUNUT1JZfS9wZHMuZW52XG4gIGVjaG8gXCJMYXVuY2hpbmcgUERTLCBlbmpveSEuLi5cIlxuICBleGVjIG5vZGUgLS1lbmFibGUtc291cmNlLW1hcHMgaW5kZXguanNcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwL3hycGMvX2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgcGRzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2JsdWVza3ktc29jaWFsL3BkczowLjQuMTgyJwogICAgdm9sdW1lczoKICAgICAgLSAncGRzLWRhdGE6L3BkcycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1BEU18zMDAwCiAgICAgIC0gJ1BEU19IT1NUTkFNRT0ke1NFUlZJQ0VfRlFETl9QRFN9JwogICAgICAtICdQRFNfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzY0X0pXVFNFQ1JFVH0nCiAgICAgIC0gJ1BEU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgICAtICdQRFNfQURNSU5fRU1BSUw9JHtQRFNfQURNSU5fRU1BSUx9JwogICAgICAtICdQRFNfUExDX1JPVEFUSU9OX0tFWV9LMjU2X1BSSVZBVEVfS0VZX0hFWD0ke1NFUlZJQ0VfSEVYXzY0X1JPVEFUSU9OS0VZfScKICAgICAgLSAnUERTX0RBVEFfRElSRUNUT1JZPSR7UERTX0RBVEFfRElSRUNUT1JZOi0vcGRzfScKICAgICAgLSAnUERTX0JMT0JTVE9SRV9ESVNLX0xPQ0FUSU9OPSR7UERTX0RBVEFfRElSRUNUT1JZOi0vcGRzfS9ibG9ja3MnCiAgICAgIC0gJ1BEU19CTE9CX1VQTE9BRF9MSU1JVD0ke1BEU19CTE9CX1VQTE9BRF9MSU1JVDotMTA0ODU3NjAwfScKICAgICAgLSAnUERTX0RJRF9QTENfVVJMPSR7UERTX0RJRF9QTENfVVJMOi1odHRwczovL3BsYy5kaXJlY3Rvcnl9JwogICAgICAtICdQRFNfRU1BSUxfRlJPTV9BRERSRVNTPSR7UERTX0VNQUlMX0ZST01fQUREUkVTU30nCiAgICAgIC0gJ1BEU19FTUFJTF9TTVRQX1VSTD0ke1BEU19FTUFJTF9TTVRQX1VSTH0nCiAgICAgIC0gJ1BEU19CU0tZX0FQUF9WSUVXX1VSTD0ke1BEU19CU0tZX0FQUF9WSUVXX1VSTDotaHR0cHM6Ly9hcGkuYnNreS5hcHB9JwogICAgICAtICdQRFNfQlNLWV9BUFBfVklFV19ESUQ9JHtQRFNfQlNLWV9BUFBfVklFV19ESUQ6LWRpZDp3ZWI6YXBpLmJza3kuYXBwfScKICAgICAgLSAnUERTX1JFUE9SVF9TRVJWSUNFX1VSTD0ke1BEU19SRVBPUlRfU0VSVklDRV9VUkw6LWh0dHBzOi8vbW9kLmJza3kuYXBwL3hycGMvY29tLmF0cHJvdG8ubW9kZXJhdGlvbi5jcmVhdGVSZXBvcnR9JwogICAgICAtICdQRFNfUkVQT1JUX1NFUlZJQ0VfRElEPSR7UERTX1JFUE9SVF9TRVJWSUNFX0RJRDotZGlkOnBsYzphcjdjNGJ5NDZxamR5ZGhkZXZ2cm5kYWN9JwogICAgICAtICdQRFNfQ1JBV0xFUlM9JHtQRFNfQ1JBV0xFUlM6LWh0dHBzOi8vYnNreS5uZXR3b3JrfScKICAgICAgLSAnTE9HX0VOQUJMRUQ9JHtMT0dfRU5BQkxFRDotdHJ1ZX0nCiAgICBjb21tYW5kOiAic2ggLWMgJ1xuICBzZXQgLWV1byBwaXBlZmFpbFxuICBlY2hvIFwiSW5zdGFsbGluZyByZXF1aXJlZCBwYWNrYWdlcyBhbmQgcGRzYWRtaW4uLi5cIlxuICBhcGsgYWRkIC0tbm8tY2FjaGUgb3BlbnNzbCBjdXJsIGJhc2gganEgY29yZXV0aWxzIGdudXBnIHV0aWwtbGludXgtbWlzYyA+L2Rldi9udWxsXG4gIGN1cmwgLW8gL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2JsdWVza3ktc29jaWFsL3Bkcy9tYWluL3Bkc2FkbWluLnNoXG4gIGNobW9kIDcwMCAvdXNyL2xvY2FsL2Jpbi9wZHNhZG1pbi5zaFxuICBsbiAtc2YgL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW5cbiAgZWNobyBcIkNyZWF0aW5nIGFuIGVtcHR5IHBkcy5lbnYgZmlsZSBzbyBwZHNhZG1pbiB3b3Jrcy4uLlwiXG4gIHRvdWNoICR7UERTX0RBVEFfRElSRUNUT1JZfS9wZHMuZW52XG4gIGVjaG8gXCJMYXVuY2hpbmcgUERTLCBlbmpveSEuLi5cIlxuICBleGVjIG5vZGUgLS1lbmFibGUtc291cmNlLW1hcHMgaW5kZXguanNcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwL3hycGMvX2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "bluesky", "pds", @@ -730,7 +730,7 @@ "convex": { "documentation": "https://github.com/get-convex/convex-backend/blob/main/self-hosted/README.md?utm_source=coolify.io", "slogan": "Convex is the open-source reactive database for app developers.", - "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0JBQ0tFTkRfMzIxMAogICAgICAtICdJTlNUQU5DRV9OQU1FPSR7SU5TVEFOQ0VfTkFNRTotc2VsZi1ob3N0ZWQtY29udmV4fScKICAgICAgLSAnSU5TVEFOQ0VfU0VDUkVUPSR7U0VSVklDRV9IRVhfMzJfU0VDUkVUfScKICAgICAgLSAnQ09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY9JHtDT05WRVhfUkVMRUFTRV9WRVJTSU9OX0RFVjotfScKICAgICAgLSAnQUNUSU9OU19VU0VSX1RJTUVPVVRfU0VDUz0ke0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M6LX0nCiAgICAgIC0gJ0NPTlZFWF9DTE9VRF9PUklHSU49JHtTRVJWSUNFX1VSTF9EQVNIQk9BUkR9JwogICAgICAtICdDT05WRVhfU0lURV9PUklHSU49JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDphOWE3NjBjYTEwMzk5ZWQ0MmUxYjRiYjg3Yzc4NTM5YTIzNTQ4OGM3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfREFTSEJPQVJEXzY3OTEKICAgICAgLSAnTkVYVF9QVUJMSUNfREVQTE9ZTUVOVF9VUkw9JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgIGRlcGVuZHNfb246CiAgICAgIGJhY2tlbmQ6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjY3OTEvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", + "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0JBQ0tFTkRfMzIxMAogICAgICAtICdJTlNUQU5DRV9OQU1FPSR7SU5TVEFOQ0VfTkFNRTotc2VsZi1ob3N0ZWQtY29udmV4fScKICAgICAgLSAnSU5TVEFOQ0VfU0VDUkVUPSR7U0VSVklDRV9IRVhfNjRfU0VDUkVUfScKICAgICAgLSAnQ09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY9JHtDT05WRVhfUkVMRUFTRV9WRVJTSU9OX0RFVjotfScKICAgICAgLSAnQUNUSU9OU19VU0VSX1RJTUVPVVRfU0VDUz0ke0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M6LX0nCiAgICAgIC0gJ0NPTlZFWF9DTE9VRF9PUklHSU49JHtTRVJWSUNFX1VSTF9EQVNIQk9BUkR9JwogICAgICAtICdDT05WRVhfU0lURV9PUklHSU49JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDphOWE3NjBjYTEwMzk5ZWQ0MmUxYjRiYjg3Yzc4NTM5YTIzNTQ4OGM3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfREFTSEJPQVJEXzY3OTEKICAgICAgLSAnTkVYVF9QVUJMSUNfREVQTE9ZTUVOVF9VUkw9JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgIGRlcGVuZHNfb246CiAgICAgIGJhY2tlbmQ6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjY3OTEvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "database", "reactive", @@ -1608,7 +1608,7 @@ "getoutline": { "documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io", "slogan": "Your team\u2019s knowledge base", - "compose": "c2VydmljZXM6CiAgb3V0bGluZToKICAgIGltYWdlOiAnZG9ja2VyLmdldG91dGxpbmUuY29tL291dGxpbmV3aWtpL291dGxpbmU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZS1kYXRhOi92YXIvbGliL291dGxpbmUvZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PVVRMSU5FXzMwMDAKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gJ1NFQ1JFVF9LRVk9JHtTRVJWSUNFX0hFWF8zMl9PVVRMSU5FfScKICAgICAgLSAnVVRJTFNfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9PVVRMSU5FfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF82NF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vOiR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnVVJMPSR7U0VSVklDRV9VUkxfT1VUTElORX0nCiAgICAgIC0gJ1BPUlQ9JHtPVVRMSU5FX1BPUlQ6LTMwMDB9JwogICAgICAtICdGSUxFX1NUT1JBR0U9JHtGSUxFX1NUT1JBR0U6LWxvY2FsfScKICAgICAgLSAnRklMRV9TVE9SQUdFX0xPQ0FMX1JPT1RfRElSPSR7RklMRV9TVE9SQUdFX0xPQ0FMX1JPT1RfRElSOi0vdmFyL2xpYi9vdXRsaW5lL2RhdGF9JwogICAgICAtICdGSUxFX1NUT1JBR0VfVVBMT0FEX01BWF9TSVpFPSR7RklMRV9TVE9SQUdFX1VQTE9BRF9NQVhfU0laRTotMjAwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9JTVBPUlRfTUFYX1NJWkU9JHtGSUxFX1NUT1JBR0VfSU1QT1JUX01BWF9TSVpFOi0xMDB9JwogICAgICAtICdGSUxFX1NUT1JBR0VfV09SS1NQQUNFX0lNUE9SVF9NQVhfU0laRT0ke0ZJTEVfU1RPUkFHRV9XT1JLU1BBQ0VfSU1QT1JUX01BWF9TSVpFfScKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtBV1NfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke0FXU19TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OfScKICAgICAgLSAnQVdTX1MzX0FDQ0VMRVJBVEVfVVJMPSR7QVdTX1MzX0FDQ0VMRVJBVEVfVVJMfScKICAgICAgLSAnQVdTX1MzX1VQTE9BRF9CVUNLRVRfVVJMPSR7QVdTX1MzX1VQTE9BRF9CVUNLRVRfVVJMfScKICAgICAgLSAnQVdTX1MzX1VQTE9BRF9CVUNLRVRfTkFNRT0ke0FXU19TM19VUExPQURfQlVDS0VUX05BTUV9JwogICAgICAtICdBV1NfUzNfRk9SQ0VfUEFUSF9TVFlMRT0ke0FXU19TM19GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnQVdTX1MzX0FDTD0ke0FXU19TM19BQ0w6LXByaXZhdGV9JwogICAgICAtICdTTEFDS19DTElFTlRfSUQ9JHtTTEFDS19DTElFTlRfSUR9JwogICAgICAtICdTTEFDS19DTElFTlRfU0VDUkVUPSR7U0xBQ0tfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0dPT0dMRV9DTElFTlRfSUQ9JHtHT09HTEVfQ0xJRU5UX0lEfScKICAgICAgLSAnR09PR0xFX0NMSUVOVF9TRUNSRVQ9JHtHT09HTEVfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0FaVVJFX0NMSUVOVF9JRD0ke0FaVVJFX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0FaVVJFX0NMSUVOVF9TRUNSRVQ9JHtBWlVSRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnQVpVUkVfUkVTT1VSQ0VfQVBQX0lEPSR7QVpVUkVfUkVTT1VSQ0VfQVBQX0lEfScKICAgICAgLSAnT0lEQ19DTElFTlRfSUQ9JHtPSURDX0NMSUVOVF9JRH0nCiAgICAgIC0gJ09JRENfQ0xJRU5UX1NFQ1JFVD0ke09JRENfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ09JRENfQVVUSF9VUkk9JHtPSURDX0FVVEhfVVJJfScKICAgICAgLSAnT0lEQ19UT0tFTl9VUkk9JHtPSURDX1RPS0VOX1VSSX0nCiAgICAgIC0gJ09JRENfVVNFUklORk9fVVJJPSR7T0lEQ19VU0VSSU5GT19VUkl9JwogICAgICAtICdPSURDX0xPR09VVF9VUkk9JHtPSURDX0xPR09VVF9VUkl9JwogICAgICAtICdPSURDX1VTRVJOQU1FX0NMQUlNPSR7T0lEQ19VU0VSTkFNRV9DTEFJTX0nCiAgICAgIC0gJ09JRENfRElTUExBWV9OQU1FPSR7T0lEQ19ESVNQTEFZX05BTUV9JwogICAgICAtICdPSURDX1NDT1BFUz0ke09JRENfU0NPUEVTfScKICAgICAgLSAnR0lUSFVCX0NMSUVOVF9JRD0ke0dJVEhVQl9DTElFTlRfSUR9JwogICAgICAtICdHSVRIVUJfQ0xJRU5UX1NFQ1JFVD0ke0dJVEhVQl9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnR0lUSFVCX0FQUF9OQU1FPSR7R0lUSFVCX0FQUF9OQU1FfScKICAgICAgLSAnR0lUSFVCX0FQUF9JRD0ke0dJVEhVQl9BUFBfSUR9JwogICAgICAtICdHSVRIVUJfQVBQX1BSSVZBVEVfS0VZPSR7R0lUSFVCX0FQUF9QUklWQVRFX0tFWX0nCiAgICAgIC0gJ0RJU0NPUkRfQ0xJRU5UX0lEPSR7RElTQ09SRF9DTElFTlRfSUR9JwogICAgICAtICdESVNDT1JEX0NMSUVOVF9TRUNSRVQ9JHtESVNDT1JEX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdESVNDT1JEX1NFUlZFUl9JRD0ke0RJU0NPUkRfU0VSVkVSX0lEfScKICAgICAgLSAnRElTQ09SRF9TRVJWRVJfUk9MRVM9JHtESVNDT1JEX1NFUlZFUl9ST0xFU30nCiAgICAgIC0gJ1BHU1NMTU9ERT0ke1BHU1NMTU9ERTotZGlzYWJsZX0nCiAgICAgIC0gJ0ZPUkNFX0hUVFBTPSR7Rk9SQ0VfSFRUUFM6LXRydWV9JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdTTVRQX1BPUlQ9JHtTTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1VTRVJOQU1FPSR7U01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ1NNVFBfUEFTU1dPUkQ9JHtTTVRQX1BBU1NXT1JEfScKICAgICAgLSAnU01UUF9GUk9NX0VNQUlMPSR7U01UUF9GUk9NX0VNQUlMfScKICAgICAgLSAnU01UUF9SRVBMWV9FTUFJTD0ke1NNVFBfUkVQTFlfRU1BSUx9JwogICAgICAtICdTTVRQX1RMU19DSVBIRVJTPSR7U01UUF9UTFNfQ0lQSEVSU30nCiAgICAgIC0gJ1NNVFBfU0VDVVJFPSR7U01UUF9TRUNVUkV9JwogICAgICAtICdTTVRQX05BTUU9JHtTTVRQX05BTUV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIGRpc2FibGU6IHRydWUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6YWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICBjb21tYW5kOgogICAgICAtIHJlZGlzLXNlcnZlcgogICAgICAtICctLXJlcXVpcmVwYXNzJwogICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMzBzCiAgICAgIHJldHJpZXM6IDMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTItYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc2UtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREFUQUJBU0U6LW91dGxpbmV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHBnX2lzcmVhZHkKICAgICAgICAtICctVScKICAgICAgICAtICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgICAgLSAnLWQnCiAgICAgICAgLSAnJHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDMK", + "compose": "c2VydmljZXM6CiAgb3V0bGluZToKICAgIGltYWdlOiAnZG9ja2VyLmdldG91dGxpbmUuY29tL291dGxpbmV3aWtpL291dGxpbmU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZS1kYXRhOi92YXIvbGliL291dGxpbmUvZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PVVRMSU5FXzMwMDAKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gJ1NFQ1JFVF9LRVk9JHtTRVJWSUNFX0hFWF82NF9PVVRMSU5FfScKICAgICAgLSAnVVRJTFNfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9PVVRMSU5FfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF82NF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vOiR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnVVJMPSR7U0VSVklDRV9VUkxfT1VUTElORX0nCiAgICAgIC0gJ1BPUlQ9JHtPVVRMSU5FX1BPUlQ6LTMwMDB9JwogICAgICAtICdGSUxFX1NUT1JBR0U9JHtGSUxFX1NUT1JBR0U6LWxvY2FsfScKICAgICAgLSAnRklMRV9TVE9SQUdFX0xPQ0FMX1JPT1RfRElSPSR7RklMRV9TVE9SQUdFX0xPQ0FMX1JPT1RfRElSOi0vdmFyL2xpYi9vdXRsaW5lL2RhdGF9JwogICAgICAtICdGSUxFX1NUT1JBR0VfVVBMT0FEX01BWF9TSVpFPSR7RklMRV9TVE9SQUdFX1VQTE9BRF9NQVhfU0laRTotMjAwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9JTVBPUlRfTUFYX1NJWkU9JHtGSUxFX1NUT1JBR0VfSU1QT1JUX01BWF9TSVpFOi0xMDB9JwogICAgICAtICdGSUxFX1NUT1JBR0VfV09SS1NQQUNFX0lNUE9SVF9NQVhfU0laRT0ke0ZJTEVfU1RPUkFHRV9XT1JLU1BBQ0VfSU1QT1JUX01BWF9TSVpFfScKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtBV1NfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke0FXU19TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OfScKICAgICAgLSAnQVdTX1MzX0FDQ0VMRVJBVEVfVVJMPSR7QVdTX1MzX0FDQ0VMRVJBVEVfVVJMfScKICAgICAgLSAnQVdTX1MzX1VQTE9BRF9CVUNLRVRfVVJMPSR7QVdTX1MzX1VQTE9BRF9CVUNLRVRfVVJMfScKICAgICAgLSAnQVdTX1MzX1VQTE9BRF9CVUNLRVRfTkFNRT0ke0FXU19TM19VUExPQURfQlVDS0VUX05BTUV9JwogICAgICAtICdBV1NfUzNfRk9SQ0VfUEFUSF9TVFlMRT0ke0FXU19TM19GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnQVdTX1MzX0FDTD0ke0FXU19TM19BQ0w6LXByaXZhdGV9JwogICAgICAtICdTTEFDS19DTElFTlRfSUQ9JHtTTEFDS19DTElFTlRfSUR9JwogICAgICAtICdTTEFDS19DTElFTlRfU0VDUkVUPSR7U0xBQ0tfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0dPT0dMRV9DTElFTlRfSUQ9JHtHT09HTEVfQ0xJRU5UX0lEfScKICAgICAgLSAnR09PR0xFX0NMSUVOVF9TRUNSRVQ9JHtHT09HTEVfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0FaVVJFX0NMSUVOVF9JRD0ke0FaVVJFX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0FaVVJFX0NMSUVOVF9TRUNSRVQ9JHtBWlVSRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnQVpVUkVfUkVTT1VSQ0VfQVBQX0lEPSR7QVpVUkVfUkVTT1VSQ0VfQVBQX0lEfScKICAgICAgLSAnT0lEQ19DTElFTlRfSUQ9JHtPSURDX0NMSUVOVF9JRH0nCiAgICAgIC0gJ09JRENfQ0xJRU5UX1NFQ1JFVD0ke09JRENfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ09JRENfQVVUSF9VUkk9JHtPSURDX0FVVEhfVVJJfScKICAgICAgLSAnT0lEQ19UT0tFTl9VUkk9JHtPSURDX1RPS0VOX1VSSX0nCiAgICAgIC0gJ09JRENfVVNFUklORk9fVVJJPSR7T0lEQ19VU0VSSU5GT19VUkl9JwogICAgICAtICdPSURDX0xPR09VVF9VUkk9JHtPSURDX0xPR09VVF9VUkl9JwogICAgICAtICdPSURDX1VTRVJOQU1FX0NMQUlNPSR7T0lEQ19VU0VSTkFNRV9DTEFJTX0nCiAgICAgIC0gJ09JRENfRElTUExBWV9OQU1FPSR7T0lEQ19ESVNQTEFZX05BTUV9JwogICAgICAtICdPSURDX1NDT1BFUz0ke09JRENfU0NPUEVTfScKICAgICAgLSAnR0lUSFVCX0NMSUVOVF9JRD0ke0dJVEhVQl9DTElFTlRfSUR9JwogICAgICAtICdHSVRIVUJfQ0xJRU5UX1NFQ1JFVD0ke0dJVEhVQl9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnR0lUSFVCX0FQUF9OQU1FPSR7R0lUSFVCX0FQUF9OQU1FfScKICAgICAgLSAnR0lUSFVCX0FQUF9JRD0ke0dJVEhVQl9BUFBfSUR9JwogICAgICAtICdHSVRIVUJfQVBQX1BSSVZBVEVfS0VZPSR7R0lUSFVCX0FQUF9QUklWQVRFX0tFWX0nCiAgICAgIC0gJ0RJU0NPUkRfQ0xJRU5UX0lEPSR7RElTQ09SRF9DTElFTlRfSUR9JwogICAgICAtICdESVNDT1JEX0NMSUVOVF9TRUNSRVQ9JHtESVNDT1JEX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdESVNDT1JEX1NFUlZFUl9JRD0ke0RJU0NPUkRfU0VSVkVSX0lEfScKICAgICAgLSAnRElTQ09SRF9TRVJWRVJfUk9MRVM9JHtESVNDT1JEX1NFUlZFUl9ST0xFU30nCiAgICAgIC0gJ1BHU1NMTU9ERT0ke1BHU1NMTU9ERTotZGlzYWJsZX0nCiAgICAgIC0gJ0ZPUkNFX0hUVFBTPSR7Rk9SQ0VfSFRUUFM6LXRydWV9JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdTTVRQX1BPUlQ9JHtTTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1VTRVJOQU1FPSR7U01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ1NNVFBfUEFTU1dPUkQ9JHtTTVRQX1BBU1NXT1JEfScKICAgICAgLSAnU01UUF9GUk9NX0VNQUlMPSR7U01UUF9GUk9NX0VNQUlMfScKICAgICAgLSAnU01UUF9SRVBMWV9FTUFJTD0ke1NNVFBfUkVQTFlfRU1BSUx9JwogICAgICAtICdTTVRQX1RMU19DSVBIRVJTPSR7U01UUF9UTFNfQ0lQSEVSU30nCiAgICAgIC0gJ1NNVFBfU0VDVVJFPSR7U01UUF9TRUNVUkV9JwogICAgICAtICdTTVRQX05BTUU9JHtTTVRQX05BTUV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIGRpc2FibGU6IHRydWUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6YWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICBjb21tYW5kOgogICAgICAtIHJlZGlzLXNlcnZlcgogICAgICAtICctLXJlcXVpcmVwYXNzJwogICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMzBzCiAgICAgIHJldHJpZXM6IDMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTItYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc2UtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREFUQUJBU0U6LW91dGxpbmV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHBnX2lzcmVhZHkKICAgICAgICAtICctVScKICAgICAgICAtICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgICAgLSAnLWQnCiAgICAgICAgLSAnJHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDMK", "tags": [ "knowledge base", "documentation" @@ -2000,7 +2000,7 @@ "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", "slogan": "Homarr is a self-hosted homepage for your services.", - "compose": "c2VydmljZXM6CiAgaG9tYXJyOgogICAgaW1hZ2U6ICdnaGNyLmlvL2hvbWFyci1sYWJzL2hvbWFycjp2MS40MC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSE9NQVJSXzc1NzUKICAgICAgLSBTRVJWSUNFX0hFWF8zMl9IT01BUlIKICAgICAgLSAnU0VDUkVUX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9IRVhfMzJfSE9NQVJSfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrJwogICAgICAtICcuL2hvbWFyci9hcHBkYXRhOi9hcHBkYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjc1NzUnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgaG9tYXJyOgogICAgaW1hZ2U6ICdnaGNyLmlvL2hvbWFyci1sYWJzL2hvbWFycjp2MS40MC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSE9NQVJSXzc1NzUKICAgICAgLSAnU0VDUkVUX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9IRVhfNjRfSE9NQVJSfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrJwogICAgICAtICcuL2hvbWFyci9hcHBkYXRhOi9hcHBkYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjc1NzUnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "homarr", "self-hosted", @@ -3408,7 +3408,7 @@ "open-archiver": { "documentation": "https://docs.openarchiver.com/?utm_source=coolify.io", "slogan": "A self-hosted, open-source email archiving solution with full-text search capability.", - "compose": "c2VydmljZXM6CiAgb3Blbi1hcmNoaXZlcjoKICAgIGltYWdlOiAnbG9naWNsYWJzaHEvb3Blbi1hcmNoaXZlcjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PUEVOQVJDSElWRVJfMzAwMAogICAgICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfSEVYXzMyX0VOQ1JZUFRJT05LRVl9JwogICAgICAtICdTVE9SQUdFX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9IRVhfMzJfU1RPUkFHRUVOQ1JZUFRJT05LRVl9JwogICAgICAtICdQT1JUX0JBQ0tFTkQ9JHtQT1JUX0JBQ0tFTkQ6LTQwMDB9JwogICAgICAtICdQT1JUX0ZST05URU5EPSR7UE9SVF9GUk9OVEVORDotMzAwMH0nCiAgICAgIC0gJ05PREVfRU5WPSR7Tk9ERV9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdTWU5DX0ZSRVFVRU5DWT0ke1NZTkNfRlJFUVVFTkNZOi0qICogKiAqICp9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1vcGVuX2FyY2hpdmV9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LW9wZW4tYXJjaGl2ZXItZGJ9JwogICAgICAtICdNRUlMSV9NQVNURVJfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NRUlMSVNFQVJDSH0nCiAgICAgIC0gJ01FSUxJX0hPU1Q9aHR0cDovL21laWxpc2VhcmNoOjc3MDAnCiAgICAgIC0gUkVESVNfSE9TVD12YWxrZXkKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1ZBTEtFWX0nCiAgICAgIC0gUkVESVNfVExTX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnU1RPUkFHRV9UWVBFPSR7U1RPUkFHRV9UWVBFOi1sb2NhbH0nCiAgICAgIC0gJ1NUT1JBR0VfTE9DQUxfUk9PVF9QQVRIPSR7U1RPUkFHRV9MT0NBTF9ST09UX1BBVEg6LS92YXIvZGF0YS9vcGVuLWFyY2hpdmVyfScKICAgICAgLSAnQk9EWV9TSVpFX0xJTUlUPSR7Qk9EWV9TSVpFX0xJTUlUOi0xMDBNfScKICAgICAgLSAnU1RPUkFHRV9TM19FTkRQT0lOVD0ke1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdTVE9SQUdFX1MzX0JVQ0tFVD0ke1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lEPSR7U1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0ke1NUT1JBR0VfUzNfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdTVE9SQUdFX1MzX1JFR0lPTj0ke1NUT1JBR0VfUzNfUkVHSU9OfScKICAgICAgLSAnU1RPUkFHRV9TM19GT1JDRV9QQVRIX1NUWUxFPSR7U1RPUkFHRV9TM19GT1JDRV9QQVRIX1NUWUxFOi1mYWxzZX0nCiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF8xMjhfSldUfScKICAgICAgLSAnSldUX0VYUElSRVNfSU49JHtKV1RfRVhQSVJFU19JTjotN2R9JwogICAgICAtICdSQVRFX0xJTUlUX1dJTkRPV19NUz0ke1JBVEVfTElNSVRfV0lORE9XX01TOi02MDAwMH0nCiAgICAgIC0gJ1JBVEVfTElNSVRfTUFYX1JFUVVFU1RTPSR7UkFURV9MSU1JVF9NQVhfUkVRVUVTVFM6LTEwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdhcmNoaXZlci1kYXRhOi92YXIvZGF0YS9vcGVuLWFyY2hpdmVyJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG1laWxpc2VhcmNoOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1vcGVuLWFyY2hpdmVyLWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gTENfQUxMPUMKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB2YWxrZXk6CiAgICBpbWFnZTogJ3ZhbGtleS92YWxrZXk6OC1hbHBpbmUnCiAgICBjb21tYW5kOiAndmFsa2V5LXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9WQUxLRVl9JwogICAgdm9sdW1lczoKICAgICAgLSAndmFsa2V5LWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1laWxpc2VhcmNoOgogICAgaW1hZ2U6ICdnZXRtZWlsaS9tZWlsaXNlYXJjaDp2MS4xNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdNRUlMSV9NQVNURVJfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NRUlMSVNFQVJDSH0nCiAgICB2b2x1bWVzOgogICAgICAtICdtZWlsaXNlYXJjaC1kYXRhOi9tZWlsaV9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjc3MDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", + "compose": "c2VydmljZXM6CiAgb3Blbi1hcmNoaXZlcjoKICAgIGltYWdlOiAnbG9naWNsYWJzaHEvb3Blbi1hcmNoaXZlcjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PUEVOQVJDSElWRVJfMzAwMAogICAgICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfSEVYXzY0X0VOQ1JZUFRJT05LRVl9JwogICAgICAtICdTVE9SQUdFX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9IRVhfNjRfU1RPUkFHRUVOQ1JZUFRJT05LRVl9JwogICAgICAtICdQT1JUX0JBQ0tFTkQ9JHtQT1JUX0JBQ0tFTkQ6LTQwMDB9JwogICAgICAtICdQT1JUX0ZST05URU5EPSR7UE9SVF9GUk9OVEVORDotMzAwMH0nCiAgICAgIC0gJ05PREVfRU5WPSR7Tk9ERV9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdTWU5DX0ZSRVFVRU5DWT0ke1NZTkNfRlJFUVVFTkNZOi0qICogKiAqICp9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1vcGVuX2FyY2hpdmV9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LW9wZW4tYXJjaGl2ZXItZGJ9JwogICAgICAtICdNRUlMSV9NQVNURVJfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NRUlMSVNFQVJDSH0nCiAgICAgIC0gJ01FSUxJX0hPU1Q9aHR0cDovL21laWxpc2VhcmNoOjc3MDAnCiAgICAgIC0gUkVESVNfSE9TVD12YWxrZXkKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1ZBTEtFWX0nCiAgICAgIC0gUkVESVNfVExTX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnU1RPUkFHRV9UWVBFPSR7U1RPUkFHRV9UWVBFOi1sb2NhbH0nCiAgICAgIC0gJ1NUT1JBR0VfTE9DQUxfUk9PVF9QQVRIPSR7U1RPUkFHRV9MT0NBTF9ST09UX1BBVEg6LS92YXIvZGF0YS9vcGVuLWFyY2hpdmVyfScKICAgICAgLSAnQk9EWV9TSVpFX0xJTUlUPSR7Qk9EWV9TSVpFX0xJTUlUOi0xMDBNfScKICAgICAgLSAnU1RPUkFHRV9TM19FTkRQT0lOVD0ke1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdTVE9SQUdFX1MzX0JVQ0tFVD0ke1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lEPSR7U1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0ke1NUT1JBR0VfUzNfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdTVE9SQUdFX1MzX1JFR0lPTj0ke1NUT1JBR0VfUzNfUkVHSU9OfScKICAgICAgLSAnU1RPUkFHRV9TM19GT1JDRV9QQVRIX1NUWUxFPSR7U1RPUkFHRV9TM19GT1JDRV9QQVRIX1NUWUxFOi1mYWxzZX0nCiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF8xMjhfSldUfScKICAgICAgLSAnSldUX0VYUElSRVNfSU49JHtKV1RfRVhQSVJFU19JTjotN2R9JwogICAgICAtICdSQVRFX0xJTUlUX1dJTkRPV19NUz0ke1JBVEVfTElNSVRfV0lORE9XX01TOi02MDAwMH0nCiAgICAgIC0gJ1JBVEVfTElNSVRfTUFYX1JFUVVFU1RTPSR7UkFURV9MSU1JVF9NQVhfUkVRVUVTVFM6LTEwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdhcmNoaXZlci1kYXRhOi92YXIvZGF0YS9vcGVuLWFyY2hpdmVyJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG1laWxpc2VhcmNoOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1vcGVuLWFyY2hpdmVyLWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gTENfQUxMPUMKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB2YWxrZXk6CiAgICBpbWFnZTogJ3ZhbGtleS92YWxrZXk6OC1hbHBpbmUnCiAgICBjb21tYW5kOiAndmFsa2V5LXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9WQUxLRVl9JwogICAgdm9sdW1lczoKICAgICAgLSAndmFsa2V5LWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1laWxpc2VhcmNoOgogICAgaW1hZ2U6ICdnZXRtZWlsaS9tZWlsaXNlYXJjaDp2MS4xNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdNRUlMSV9NQVNURVJfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NRUlMSVNFQVJDSH0nCiAgICB2b2x1bWVzOgogICAgICAtICdtZWlsaXNlYXJjaC1kYXRhOi9tZWlsaV9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjc3MDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", "tags": [ "email archiving", "email", diff --git a/templates/service-templates.json b/templates/service-templates.json index cc909dc68..ea9fd145a 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -299,7 +299,7 @@ "bluesky-pds": { "documentation": "https://github.com/bluesky-social/pds?utm_source=coolify.io", "slogan": "Bluesky PDS (Personal Data Server)", - "compose": "c2VydmljZXM6CiAgcGRzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2JsdWVza3ktc29jaWFsL3BkczowLjQuMTgyJwogICAgdm9sdW1lczoKICAgICAgLSAncGRzLWRhdGE6L3BkcycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9QRFNfMzAwMAogICAgICAtICdQRFNfSE9TVE5BTUU9JHtTRVJWSUNFX0ZRRE5fUERTfScKICAgICAgLSAnUERTX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX0hFWF8zMl9KV1RTRUNSRVR9JwogICAgICAtICdQRFNfQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUERTX0FETUlOX0VNQUlMPSR7UERTX0FETUlOX0VNQUlMfScKICAgICAgLSAnUERTX1BMQ19ST1RBVElPTl9LRVlfSzI1Nl9QUklWQVRFX0tFWV9IRVg9JHtTRVJWSUNFX0hFWF8zMl9ST1RBVElPTktFWX0nCiAgICAgIC0gJ1BEU19EQVRBX0RJUkVDVE9SWT0ke1BEU19EQVRBX0RJUkVDVE9SWTotL3Bkc30nCiAgICAgIC0gJ1BEU19CTE9CU1RPUkVfRElTS19MT0NBVElPTj0ke1BEU19EQVRBX0RJUkVDVE9SWTotL3Bkc30vYmxvY2tzJwogICAgICAtICdQRFNfQkxPQl9VUExPQURfTElNSVQ9JHtQRFNfQkxPQl9VUExPQURfTElNSVQ6LTEwNDg1NzYwMH0nCiAgICAgIC0gJ1BEU19ESURfUExDX1VSTD0ke1BEU19ESURfUExDX1VSTDotaHR0cHM6Ly9wbGMuZGlyZWN0b3J5fScKICAgICAgLSAnUERTX0VNQUlMX0ZST01fQUREUkVTUz0ke1BEU19FTUFJTF9GUk9NX0FERFJFU1N9JwogICAgICAtICdQRFNfRU1BSUxfU01UUF9VUkw9JHtQRFNfRU1BSUxfU01UUF9VUkx9JwogICAgICAtICdQRFNfQlNLWV9BUFBfVklFV19VUkw9JHtQRFNfQlNLWV9BUFBfVklFV19VUkw6LWh0dHBzOi8vYXBpLmJza3kuYXBwfScKICAgICAgLSAnUERTX0JTS1lfQVBQX1ZJRVdfRElEPSR7UERTX0JTS1lfQVBQX1ZJRVdfRElEOi1kaWQ6d2ViOmFwaS5ic2t5LmFwcH0nCiAgICAgIC0gJ1BEU19SRVBPUlRfU0VSVklDRV9GUUROPSR7UERTX1JFUE9SVF9TRVJWSUNFX0ZRRE46LWh0dHBzOi8vbW9kLmJza3kuYXBwL3hycGMvY29tLmF0cHJvdG8ubW9kZXJhdGlvbi5jcmVhdGVSZXBvcnR9JwogICAgICAtICdQRFNfUkVQT1JUX1NFUlZJQ0VfRElEPSR7UERTX1JFUE9SVF9TRVJWSUNFX0RJRDotZGlkOnBsYzphcjdjNGJ5NDZxamR5ZGhkZXZ2cm5kYWN9JwogICAgICAtICdQRFNfQ1JBV0xFUlM9JHtQRFNfQ1JBV0xFUlM6LWh0dHBzOi8vYnNreS5uZXR3b3JrfScKICAgICAgLSAnTE9HX0VOQUJMRUQ9JHtMT0dfRU5BQkxFRDotdHJ1ZX0nCiAgICBjb21tYW5kOiAic2ggLWMgJ1xuICBzZXQgLWV1byBwaXBlZmFpbFxuICBlY2hvIFwiSW5zdGFsbGluZyByZXF1aXJlZCBwYWNrYWdlcyBhbmQgcGRzYWRtaW4uLi5cIlxuICBhcGsgYWRkIC0tbm8tY2FjaGUgb3BlbnNzbCBjdXJsIGJhc2gganEgY29yZXV0aWxzIGdudXBnIHV0aWwtbGludXgtbWlzYyA+L2Rldi9udWxsXG4gIGN1cmwgLW8gL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2JsdWVza3ktc29jaWFsL3Bkcy9tYWluL3Bkc2FkbWluLnNoXG4gIGNobW9kIDcwMCAvdXNyL2xvY2FsL2Jpbi9wZHNhZG1pbi5zaFxuICBsbiAtc2YgL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW5cbiAgZWNobyBcIkNyZWF0aW5nIGFuIGVtcHR5IHBkcy5lbnYgZmlsZSBzbyBwZHNhZG1pbiB3b3Jrcy4uLlwiXG4gIHRvdWNoICR7UERTX0RBVEFfRElSRUNUT1JZfS9wZHMuZW52XG4gIGVjaG8gXCJMYXVuY2hpbmcgUERTLCBlbmpveSEuLi5cIlxuICBleGVjIG5vZGUgLS1lbmFibGUtc291cmNlLW1hcHMgaW5kZXguanNcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwL3hycGMvX2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgcGRzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2JsdWVza3ktc29jaWFsL3BkczowLjQuMTgyJwogICAgdm9sdW1lczoKICAgICAgLSAncGRzLWRhdGE6L3BkcycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9QRFNfMzAwMAogICAgICAtICdQRFNfSE9TVE5BTUU9JHtTRVJWSUNFX0ZRRE5fUERTfScKICAgICAgLSAnUERTX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX0hFWF82NF9KV1RTRUNSRVR9JwogICAgICAtICdQRFNfQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUERTX0FETUlOX0VNQUlMPSR7UERTX0FETUlOX0VNQUlMfScKICAgICAgLSAnUERTX1BMQ19ST1RBVElPTl9LRVlfSzI1Nl9QUklWQVRFX0tFWV9IRVg9JHtTRVJWSUNFX0hFWF82NF9ST1RBVElPTktFWX0nCiAgICAgIC0gJ1BEU19EQVRBX0RJUkVDVE9SWT0ke1BEU19EQVRBX0RJUkVDVE9SWTotL3Bkc30nCiAgICAgIC0gJ1BEU19CTE9CU1RPUkVfRElTS19MT0NBVElPTj0ke1BEU19EQVRBX0RJUkVDVE9SWTotL3Bkc30vYmxvY2tzJwogICAgICAtICdQRFNfQkxPQl9VUExPQURfTElNSVQ9JHtQRFNfQkxPQl9VUExPQURfTElNSVQ6LTEwNDg1NzYwMH0nCiAgICAgIC0gJ1BEU19ESURfUExDX1VSTD0ke1BEU19ESURfUExDX1VSTDotaHR0cHM6Ly9wbGMuZGlyZWN0b3J5fScKICAgICAgLSAnUERTX0VNQUlMX0ZST01fQUREUkVTUz0ke1BEU19FTUFJTF9GUk9NX0FERFJFU1N9JwogICAgICAtICdQRFNfRU1BSUxfU01UUF9VUkw9JHtQRFNfRU1BSUxfU01UUF9VUkx9JwogICAgICAtICdQRFNfQlNLWV9BUFBfVklFV19VUkw9JHtQRFNfQlNLWV9BUFBfVklFV19VUkw6LWh0dHBzOi8vYXBpLmJza3kuYXBwfScKICAgICAgLSAnUERTX0JTS1lfQVBQX1ZJRVdfRElEPSR7UERTX0JTS1lfQVBQX1ZJRVdfRElEOi1kaWQ6d2ViOmFwaS5ic2t5LmFwcH0nCiAgICAgIC0gJ1BEU19SRVBPUlRfU0VSVklDRV9GUUROPSR7UERTX1JFUE9SVF9TRVJWSUNFX0ZRRE46LWh0dHBzOi8vbW9kLmJza3kuYXBwL3hycGMvY29tLmF0cHJvdG8ubW9kZXJhdGlvbi5jcmVhdGVSZXBvcnR9JwogICAgICAtICdQRFNfUkVQT1JUX1NFUlZJQ0VfRElEPSR7UERTX1JFUE9SVF9TRVJWSUNFX0RJRDotZGlkOnBsYzphcjdjNGJ5NDZxamR5ZGhkZXZ2cm5kYWN9JwogICAgICAtICdQRFNfQ1JBV0xFUlM9JHtQRFNfQ1JBV0xFUlM6LWh0dHBzOi8vYnNreS5uZXR3b3JrfScKICAgICAgLSAnTE9HX0VOQUJMRUQ9JHtMT0dfRU5BQkxFRDotdHJ1ZX0nCiAgICBjb21tYW5kOiAic2ggLWMgJ1xuICBzZXQgLWV1byBwaXBlZmFpbFxuICBlY2hvIFwiSW5zdGFsbGluZyByZXF1aXJlZCBwYWNrYWdlcyBhbmQgcGRzYWRtaW4uLi5cIlxuICBhcGsgYWRkIC0tbm8tY2FjaGUgb3BlbnNzbCBjdXJsIGJhc2gganEgY29yZXV0aWxzIGdudXBnIHV0aWwtbGludXgtbWlzYyA+L2Rldi9udWxsXG4gIGN1cmwgLW8gL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2JsdWVza3ktc29jaWFsL3Bkcy9tYWluL3Bkc2FkbWluLnNoXG4gIGNobW9kIDcwMCAvdXNyL2xvY2FsL2Jpbi9wZHNhZG1pbi5zaFxuICBsbiAtc2YgL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW5cbiAgZWNobyBcIkNyZWF0aW5nIGFuIGVtcHR5IHBkcy5lbnYgZmlsZSBzbyBwZHNhZG1pbiB3b3Jrcy4uLlwiXG4gIHRvdWNoICR7UERTX0RBVEFfRElSRUNUT1JZfS9wZHMuZW52XG4gIGVjaG8gXCJMYXVuY2hpbmcgUERTLCBlbmpveSEuLi5cIlxuICBleGVjIG5vZGUgLS1lbmFibGUtc291cmNlLW1hcHMgaW5kZXguanNcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwL3hycGMvX2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "bluesky", "pds", @@ -730,7 +730,7 @@ "convex": { "documentation": "https://github.com/get-convex/convex-backend/blob/main/self-hosted/README.md?utm_source=coolify.io", "slogan": "Convex is the open-source reactive database for app developers.", - "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9CQUNLRU5EXzMyMTAKICAgICAgLSAnSU5TVEFOQ0VfTkFNRT0ke0lOU1RBTkNFX05BTUU6LXNlbGYtaG9zdGVkLWNvbnZleH0nCiAgICAgIC0gJ0lOU1RBTkNFX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzMyX1NFQ1JFVH0nCiAgICAgIC0gJ0NPTlZFWF9SRUxFQVNFX1ZFUlNJT05fREVWPSR7Q09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY6LX0nCiAgICAgIC0gJ0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M9JHtBQ1RJT05TX1VTRVJfVElNRU9VVF9TRUNTOi19JwogICAgICAtICdDT05WRVhfQ0xPVURfT1JJR0lOPSR7U0VSVklDRV9GUUROX0RBU0hCT0FSRH0nCiAgICAgIC0gJ0NPTlZFWF9TSVRFX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDphOWE3NjBjYTEwMzk5ZWQ0MmUxYjRiYjg3Yzc4NTM5YTIzNTQ4OGM3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0RBU0hCT0FSRF82NzkxCiAgICAgIC0gJ05FWFRfUFVCTElDX0RFUExPWU1FTlRfVVJMPSR7U0VSVklDRV9GUUROX0JBQ0tFTkR9JwogICAgZGVwZW5kc19vbjoKICAgICAgYmFja2VuZDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIC1mIGh0dHA6Ly8xMjcuMC4wLjE6Njc5MS8nCiAgICAgIGludGVydmFsOiA1cwogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9CQUNLRU5EXzMyMTAKICAgICAgLSAnSU5TVEFOQ0VfTkFNRT0ke0lOU1RBTkNFX05BTUU6LXNlbGYtaG9zdGVkLWNvbnZleH0nCiAgICAgIC0gJ0lOU1RBTkNFX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzY0X1NFQ1JFVH0nCiAgICAgIC0gJ0NPTlZFWF9SRUxFQVNFX1ZFUlNJT05fREVWPSR7Q09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY6LX0nCiAgICAgIC0gJ0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M9JHtBQ1RJT05TX1VTRVJfVElNRU9VVF9TRUNTOi19JwogICAgICAtICdDT05WRVhfQ0xPVURfT1JJR0lOPSR7U0VSVklDRV9GUUROX0RBU0hCT0FSRH0nCiAgICAgIC0gJ0NPTlZFWF9TSVRFX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDphOWE3NjBjYTEwMzk5ZWQ0MmUxYjRiYjg3Yzc4NTM5YTIzNTQ4OGM3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0RBU0hCT0FSRF82NzkxCiAgICAgIC0gJ05FWFRfUFVCTElDX0RFUExPWU1FTlRfVVJMPSR7U0VSVklDRV9GUUROX0JBQ0tFTkR9JwogICAgZGVwZW5kc19vbjoKICAgICAgYmFja2VuZDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIC1mIGh0dHA6Ly8xMjcuMC4wLjE6Njc5MS8nCiAgICAgIGludGVydmFsOiA1cwogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "database", "reactive", @@ -1608,7 +1608,7 @@ "getoutline": { "documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io", "slogan": "Your team\u2019s knowledge base", - "compose": "c2VydmljZXM6CiAgb3V0bGluZToKICAgIGltYWdlOiAnZG9ja2VyLmdldG91dGxpbmUuY29tL291dGxpbmV3aWtpL291dGxpbmU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZS1kYXRhOi92YXIvbGliL291dGxpbmUvZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1VUTElORV8zMDAwCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtICdTRUNSRVRfS0VZPSR7U0VSVklDRV9IRVhfMzJfT1VUTElORX0nCiAgICAgIC0gJ1VUSUxTX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfT1VUTElORX0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovLzoke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9QHJlZGlzOjYzNzknCiAgICAgIC0gJ1VSTD0ke1NFUlZJQ0VfRlFETl9PVVRMSU5FfScKICAgICAgLSAnUE9SVD0ke09VVExJTkVfUE9SVDotMzAwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRT0ke0ZJTEVfU1RPUkFHRTotbG9jYWx9JwogICAgICAtICdGSUxFX1NUT1JBR0VfTE9DQUxfUk9PVF9ESVI9JHtGSUxFX1NUT1JBR0VfTE9DQUxfUk9PVF9ESVI6LS92YXIvbGliL291dGxpbmUvZGF0YX0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9VUExPQURfTUFYX1NJWkU9JHtGSUxFX1NUT1JBR0VfVVBMT0FEX01BWF9TSVpFOi0yMDAwfScKICAgICAgLSAnRklMRV9TVE9SQUdFX0lNUE9SVF9NQVhfU0laRT0ke0ZJTEVfU1RPUkFHRV9JTVBPUlRfTUFYX1NJWkU6LTEwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9XT1JLU1BBQ0VfSU1QT1JUX01BWF9TSVpFPSR7RklMRV9TVE9SQUdFX1dPUktTUEFDRV9JTVBPUlRfTUFYX1NJWkV9JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7QVdTX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnQVdTX1JFR0lPTj0ke0FXU19SRUdJT059JwogICAgICAtICdBV1NfUzNfQUNDRUxFUkFURV9VUkw9JHtBV1NfUzNfQUNDRUxFUkFURV9VUkx9JwogICAgICAtICdBV1NfUzNfVVBMT0FEX0JVQ0tFVF9VUkw9JHtBV1NfUzNfVVBMT0FEX0JVQ0tFVF9VUkx9JwogICAgICAtICdBV1NfUzNfVVBMT0FEX0JVQ0tFVF9OQU1FPSR7QVdTX1MzX1VQTE9BRF9CVUNLRVRfTkFNRX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdBV1NfUzNfQUNMPSR7QVdTX1MzX0FDTDotcHJpdmF0ZX0nCiAgICAgIC0gJ1NMQUNLX0NMSUVOVF9JRD0ke1NMQUNLX0NMSUVOVF9JRH0nCiAgICAgIC0gJ1NMQUNLX0NMSUVOVF9TRUNSRVQ9JHtTTEFDS19DTElFTlRfU0VDUkVUfScKICAgICAgLSAnR09PR0xFX0NMSUVOVF9JRD0ke0dPT0dMRV9DTElFTlRfSUR9JwogICAgICAtICdHT09HTEVfQ0xJRU5UX1NFQ1JFVD0ke0dPT0dMRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnQVpVUkVfQ0xJRU5UX0lEPSR7QVpVUkVfQ0xJRU5UX0lEfScKICAgICAgLSAnQVpVUkVfQ0xJRU5UX1NFQ1JFVD0ke0FaVVJFX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdBWlVSRV9SRVNPVVJDRV9BUFBfSUQ9JHtBWlVSRV9SRVNPVVJDRV9BUFBfSUR9JwogICAgICAtICdPSURDX0NMSUVOVF9JRD0ke09JRENfQ0xJRU5UX0lEfScKICAgICAgLSAnT0lEQ19DTElFTlRfU0VDUkVUPSR7T0lEQ19DTElFTlRfU0VDUkVUfScKICAgICAgLSAnT0lEQ19BVVRIX1VSST0ke09JRENfQVVUSF9VUkl9JwogICAgICAtICdPSURDX1RPS0VOX1VSST0ke09JRENfVE9LRU5fVVJJfScKICAgICAgLSAnT0lEQ19VU0VSSU5GT19VUkk9JHtPSURDX1VTRVJJTkZPX1VSSX0nCiAgICAgIC0gJ09JRENfTE9HT1VUX1VSST0ke09JRENfTE9HT1VUX1VSSX0nCiAgICAgIC0gJ09JRENfVVNFUk5BTUVfQ0xBSU09JHtPSURDX1VTRVJOQU1FX0NMQUlNfScKICAgICAgLSAnT0lEQ19ESVNQTEFZX05BTUU9JHtPSURDX0RJU1BMQVlfTkFNRX0nCiAgICAgIC0gJ09JRENfU0NPUEVTPSR7T0lEQ19TQ09QRVN9JwogICAgICAtICdHSVRIVUJfQ0xJRU5UX0lEPSR7R0lUSFVCX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9DTElFTlRfU0VDUkVUPSR7R0lUSFVCX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdHSVRIVUJfQVBQX05BTUU9JHtHSVRIVUJfQVBQX05BTUV9JwogICAgICAtICdHSVRIVUJfQVBQX0lEPSR7R0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9BUFBfUFJJVkFURV9LRVk9JHtHSVRIVUJfQVBQX1BSSVZBVEVfS0VZfScKICAgICAgLSAnRElTQ09SRF9DTElFTlRfSUQ9JHtESVNDT1JEX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0RJU0NPUkRfQ0xJRU5UX1NFQ1JFVD0ke0RJU0NPUkRfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0RJU0NPUkRfU0VSVkVSX0lEPSR7RElTQ09SRF9TRVJWRVJfSUR9JwogICAgICAtICdESVNDT1JEX1NFUlZFUl9ST0xFUz0ke0RJU0NPUkRfU0VSVkVSX1JPTEVTfScKICAgICAgLSAnUEdTU0xNT0RFPSR7UEdTU0xNT0RFOi1kaXNhYmxlfScKICAgICAgLSAnRk9SQ0VfSFRUUFM9JHtGT1JDRV9IVFRQUzotdHJ1ZX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUk5BTUU9JHtTTVRQX1VTRVJOQU1FfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdTTVRQX0ZST01fRU1BSUw9JHtTTVRQX0ZST01fRU1BSUx9JwogICAgICAtICdTTVRQX1JFUExZX0VNQUlMPSR7U01UUF9SRVBMWV9FTUFJTH0nCiAgICAgIC0gJ1NNVFBfVExTX0NJUEhFUlM9JHtTTVRQX1RMU19DSVBIRVJTfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ1NNVFBfTkFNRT0ke1NNVFBfTkFNRX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgZGlzYWJsZTogdHJ1ZQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczphbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tcmVxdWlyZXBhc3MnCiAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogMwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxMi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhYmFzZS1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgICAtICctZCcKICAgICAgICAtICcke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwo=", + "compose": "c2VydmljZXM6CiAgb3V0bGluZToKICAgIGltYWdlOiAnZG9ja2VyLmdldG91dGxpbmUuY29tL291dGxpbmV3aWtpL291dGxpbmU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZS1kYXRhOi92YXIvbGliL291dGxpbmUvZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1VUTElORV8zMDAwCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtICdTRUNSRVRfS0VZPSR7U0VSVklDRV9IRVhfNjRfT1VUTElORX0nCiAgICAgIC0gJ1VUSUxTX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfT1VUTElORX0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovLzoke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9QHJlZGlzOjYzNzknCiAgICAgIC0gJ1VSTD0ke1NFUlZJQ0VfRlFETl9PVVRMSU5FfScKICAgICAgLSAnUE9SVD0ke09VVExJTkVfUE9SVDotMzAwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRT0ke0ZJTEVfU1RPUkFHRTotbG9jYWx9JwogICAgICAtICdGSUxFX1NUT1JBR0VfTE9DQUxfUk9PVF9ESVI9JHtGSUxFX1NUT1JBR0VfTE9DQUxfUk9PVF9ESVI6LS92YXIvbGliL291dGxpbmUvZGF0YX0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9VUExPQURfTUFYX1NJWkU9JHtGSUxFX1NUT1JBR0VfVVBMT0FEX01BWF9TSVpFOi0yMDAwfScKICAgICAgLSAnRklMRV9TVE9SQUdFX0lNUE9SVF9NQVhfU0laRT0ke0ZJTEVfU1RPUkFHRV9JTVBPUlRfTUFYX1NJWkU6LTEwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9XT1JLU1BBQ0VfSU1QT1JUX01BWF9TSVpFPSR7RklMRV9TVE9SQUdFX1dPUktTUEFDRV9JTVBPUlRfTUFYX1NJWkV9JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7QVdTX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnQVdTX1JFR0lPTj0ke0FXU19SRUdJT059JwogICAgICAtICdBV1NfUzNfQUNDRUxFUkFURV9VUkw9JHtBV1NfUzNfQUNDRUxFUkFURV9VUkx9JwogICAgICAtICdBV1NfUzNfVVBMT0FEX0JVQ0tFVF9VUkw9JHtBV1NfUzNfVVBMT0FEX0JVQ0tFVF9VUkx9JwogICAgICAtICdBV1NfUzNfVVBMT0FEX0JVQ0tFVF9OQU1FPSR7QVdTX1MzX1VQTE9BRF9CVUNLRVRfTkFNRX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdBV1NfUzNfQUNMPSR7QVdTX1MzX0FDTDotcHJpdmF0ZX0nCiAgICAgIC0gJ1NMQUNLX0NMSUVOVF9JRD0ke1NMQUNLX0NMSUVOVF9JRH0nCiAgICAgIC0gJ1NMQUNLX0NMSUVOVF9TRUNSRVQ9JHtTTEFDS19DTElFTlRfU0VDUkVUfScKICAgICAgLSAnR09PR0xFX0NMSUVOVF9JRD0ke0dPT0dMRV9DTElFTlRfSUR9JwogICAgICAtICdHT09HTEVfQ0xJRU5UX1NFQ1JFVD0ke0dPT0dMRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnQVpVUkVfQ0xJRU5UX0lEPSR7QVpVUkVfQ0xJRU5UX0lEfScKICAgICAgLSAnQVpVUkVfQ0xJRU5UX1NFQ1JFVD0ke0FaVVJFX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdBWlVSRV9SRVNPVVJDRV9BUFBfSUQ9JHtBWlVSRV9SRVNPVVJDRV9BUFBfSUR9JwogICAgICAtICdPSURDX0NMSUVOVF9JRD0ke09JRENfQ0xJRU5UX0lEfScKICAgICAgLSAnT0lEQ19DTElFTlRfU0VDUkVUPSR7T0lEQ19DTElFTlRfU0VDUkVUfScKICAgICAgLSAnT0lEQ19BVVRIX1VSST0ke09JRENfQVVUSF9VUkl9JwogICAgICAtICdPSURDX1RPS0VOX1VSST0ke09JRENfVE9LRU5fVVJJfScKICAgICAgLSAnT0lEQ19VU0VSSU5GT19VUkk9JHtPSURDX1VTRVJJTkZPX1VSSX0nCiAgICAgIC0gJ09JRENfTE9HT1VUX1VSST0ke09JRENfTE9HT1VUX1VSSX0nCiAgICAgIC0gJ09JRENfVVNFUk5BTUVfQ0xBSU09JHtPSURDX1VTRVJOQU1FX0NMQUlNfScKICAgICAgLSAnT0lEQ19ESVNQTEFZX05BTUU9JHtPSURDX0RJU1BMQVlfTkFNRX0nCiAgICAgIC0gJ09JRENfU0NPUEVTPSR7T0lEQ19TQ09QRVN9JwogICAgICAtICdHSVRIVUJfQ0xJRU5UX0lEPSR7R0lUSFVCX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9DTElFTlRfU0VDUkVUPSR7R0lUSFVCX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdHSVRIVUJfQVBQX05BTUU9JHtHSVRIVUJfQVBQX05BTUV9JwogICAgICAtICdHSVRIVUJfQVBQX0lEPSR7R0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9BUFBfUFJJVkFURV9LRVk9JHtHSVRIVUJfQVBQX1BSSVZBVEVfS0VZfScKICAgICAgLSAnRElTQ09SRF9DTElFTlRfSUQ9JHtESVNDT1JEX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0RJU0NPUkRfQ0xJRU5UX1NFQ1JFVD0ke0RJU0NPUkRfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0RJU0NPUkRfU0VSVkVSX0lEPSR7RElTQ09SRF9TRVJWRVJfSUR9JwogICAgICAtICdESVNDT1JEX1NFUlZFUl9ST0xFUz0ke0RJU0NPUkRfU0VSVkVSX1JPTEVTfScKICAgICAgLSAnUEdTU0xNT0RFPSR7UEdTU0xNT0RFOi1kaXNhYmxlfScKICAgICAgLSAnRk9SQ0VfSFRUUFM9JHtGT1JDRV9IVFRQUzotdHJ1ZX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUk5BTUU9JHtTTVRQX1VTRVJOQU1FfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdTTVRQX0ZST01fRU1BSUw9JHtTTVRQX0ZST01fRU1BSUx9JwogICAgICAtICdTTVRQX1JFUExZX0VNQUlMPSR7U01UUF9SRVBMWV9FTUFJTH0nCiAgICAgIC0gJ1NNVFBfVExTX0NJUEhFUlM9JHtTTVRQX1RMU19DSVBIRVJTfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ1NNVFBfTkFNRT0ke1NNVFBfTkFNRX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgZGlzYWJsZTogdHJ1ZQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczphbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tcmVxdWlyZXBhc3MnCiAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogMwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxMi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhYmFzZS1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgICAtICctZCcKICAgICAgICAtICcke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwo=", "tags": [ "knowledge base", "documentation" @@ -2000,7 +2000,7 @@ "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", "slogan": "Homarr is a self-hosted homepage for your services.", - "compose": "c2VydmljZXM6CiAgaG9tYXJyOgogICAgaW1hZ2U6ICdnaGNyLmlvL2hvbWFyci1sYWJzL2hvbWFycjp2MS40MC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hPTUFSUl83NTc1CiAgICAgIC0gU0VSVklDRV9IRVhfMzJfSE9NQVJSCiAgICAgIC0gJ1NFQ1JFVF9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfSEVYXzMyX0hPTUFSUn0nCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnLi9ob21hcnIvYXBwZGF0YTovYXBwZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo3NTc1JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgaG9tYXJyOgogICAgaW1hZ2U6ICdnaGNyLmlvL2hvbWFyci1sYWJzL2hvbWFycjp2MS40MC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hPTUFSUl83NTc1CiAgICAgIC0gJ1NFQ1JFVF9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfSEVYXzY0X0hPTUFSUn0nCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnLi9ob21hcnIvYXBwZGF0YTovYXBwZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo3NTc1JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "homarr", "self-hosted", @@ -3408,7 +3408,7 @@ "open-archiver": { "documentation": "https://docs.openarchiver.com/?utm_source=coolify.io", "slogan": "A self-hosted, open-source email archiving solution with full-text search capability.", - "compose": "c2VydmljZXM6CiAgb3Blbi1hcmNoaXZlcjoKICAgIGltYWdlOiAnbG9naWNsYWJzaHEvb3Blbi1hcmNoaXZlcjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1BFTkFSQ0hJVkVSXzMwMDAKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX0hFWF8zMl9FTkNSWVBUSU9OS0VZfScKICAgICAgLSAnU1RPUkFHRV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfSEVYXzMyX1NUT1JBR0VFTkNSWVBUSU9OS0VZfScKICAgICAgLSAnUE9SVF9CQUNLRU5EPSR7UE9SVF9CQUNLRU5EOi00MDAwfScKICAgICAgLSAnUE9SVF9GUk9OVEVORD0ke1BPUlRfRlJPTlRFTkQ6LTMwMDB9JwogICAgICAtICdOT0RFX0VOVj0ke05PREVfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnU1lOQ19GUkVRVUVOQ1k9JHtTWU5DX0ZSRVFVRU5DWTotKiAqICogKiAqfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotb3Blbl9hcmNoaXZlfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1vcGVuLWFyY2hpdmVyLWRifScKICAgICAgLSAnTUVJTElfTUFTVEVSX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUVJTElTRUFSQ0h9JwogICAgICAtICdNRUlMSV9IT1NUPWh0dHA6Ly9tZWlsaXNlYXJjaDo3NzAwJwogICAgICAtIFJFRElTX0hPU1Q9dmFsa2V5CiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9WQUxLRVl9JwogICAgICAtIFJFRElTX1RMU19FTkFCTEVEPWZhbHNlCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtICdTVE9SQUdFX0xPQ0FMX1JPT1RfUEFUSD0ke1NUT1JBR0VfTE9DQUxfUk9PVF9QQVRIOi0vdmFyL2RhdGEvb3Blbi1hcmNoaXZlcn0nCiAgICAgIC0gJ0JPRFlfU0laRV9MSU1JVD0ke0JPRFlfU0laRV9MSU1JVDotMTAwTX0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtTVE9SQUdFX1MzX0VORFBPSU5UfScKICAgICAgLSAnU1RPUkFHRV9TM19CVUNLRVQ9JHtTVE9SQUdFX1MzX0JVQ0tFVH0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0ke1NUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfU0VDUkVUX0FDQ0VTU19LRVk9JHtTVE9SQUdFX1MzX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnU1RPUkFHRV9TM19SRUdJT049JHtTVE9SQUdFX1MzX1JFR0lPTn0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRT0ke1NUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRTotZmFsc2V9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfMTI4X0pXVH0nCiAgICAgIC0gJ0pXVF9FWFBJUkVTX0lOPSR7SldUX0VYUElSRVNfSU46LTdkfScKICAgICAgLSAnUkFURV9MSU1JVF9XSU5ET1dfTVM9JHtSQVRFX0xJTUlUX1dJTkRPV19NUzotNjAwMDB9JwogICAgICAtICdSQVRFX0xJTUlUX01BWF9SRVFVRVNUUz0ke1JBVEVfTElNSVRfTUFYX1JFUVVFU1RTOi0xMDB9JwogICAgdm9sdW1lczoKICAgICAgLSAnYXJjaGl2ZXItZGF0YTovdmFyL2RhdGEvb3Blbi1hcmNoaXZlcicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHZhbGtleToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtZWlsaXNlYXJjaDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotb3Blbi1hcmNoaXZlci1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtIExDX0FMTD1DCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3ZhbGtleS1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfVkFMS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3ZhbGtleS1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtZWlsaXNlYXJjaDoKICAgIGltYWdlOiAnZ2V0bWVpbGkvbWVpbGlzZWFyY2g6djEuMTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUVJTElfTUFTVEVSX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUVJTElTRUFSQ0h9JwogICAgdm9sdW1lczoKICAgICAgLSAnbWVpbGlzZWFyY2gtZGF0YTovbWVpbGlfZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo3NzAwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", + "compose": "c2VydmljZXM6CiAgb3Blbi1hcmNoaXZlcjoKICAgIGltYWdlOiAnbG9naWNsYWJzaHEvb3Blbi1hcmNoaXZlcjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1BFTkFSQ0hJVkVSXzMwMDAKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX0hFWF82NF9FTkNSWVBUSU9OS0VZfScKICAgICAgLSAnU1RPUkFHRV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfSEVYXzY0X1NUT1JBR0VFTkNSWVBUSU9OS0VZfScKICAgICAgLSAnUE9SVF9CQUNLRU5EPSR7UE9SVF9CQUNLRU5EOi00MDAwfScKICAgICAgLSAnUE9SVF9GUk9OVEVORD0ke1BPUlRfRlJPTlRFTkQ6LTMwMDB9JwogICAgICAtICdOT0RFX0VOVj0ke05PREVfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnU1lOQ19GUkVRVUVOQ1k9JHtTWU5DX0ZSRVFVRU5DWTotKiAqICogKiAqfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotb3Blbl9hcmNoaXZlfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1vcGVuLWFyY2hpdmVyLWRifScKICAgICAgLSAnTUVJTElfTUFTVEVSX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUVJTElTRUFSQ0h9JwogICAgICAtICdNRUlMSV9IT1NUPWh0dHA6Ly9tZWlsaXNlYXJjaDo3NzAwJwogICAgICAtIFJFRElTX0hPU1Q9dmFsa2V5CiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9WQUxLRVl9JwogICAgICAtIFJFRElTX1RMU19FTkFCTEVEPWZhbHNlCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtICdTVE9SQUdFX0xPQ0FMX1JPT1RfUEFUSD0ke1NUT1JBR0VfTE9DQUxfUk9PVF9QQVRIOi0vdmFyL2RhdGEvb3Blbi1hcmNoaXZlcn0nCiAgICAgIC0gJ0JPRFlfU0laRV9MSU1JVD0ke0JPRFlfU0laRV9MSU1JVDotMTAwTX0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtTVE9SQUdFX1MzX0VORFBPSU5UfScKICAgICAgLSAnU1RPUkFHRV9TM19CVUNLRVQ9JHtTVE9SQUdFX1MzX0JVQ0tFVH0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0ke1NUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfU0VDUkVUX0FDQ0VTU19LRVk9JHtTVE9SQUdFX1MzX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnU1RPUkFHRV9TM19SRUdJT049JHtTVE9SQUdFX1MzX1JFR0lPTn0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRT0ke1NUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRTotZmFsc2V9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfMTI4X0pXVH0nCiAgICAgIC0gJ0pXVF9FWFBJUkVTX0lOPSR7SldUX0VYUElSRVNfSU46LTdkfScKICAgICAgLSAnUkFURV9MSU1JVF9XSU5ET1dfTVM9JHtSQVRFX0xJTUlUX1dJTkRPV19NUzotNjAwMDB9JwogICAgICAtICdSQVRFX0xJTUlUX01BWF9SRVFVRVNUUz0ke1JBVEVfTElNSVRfTUFYX1JFUVVFU1RTOi0xMDB9JwogICAgdm9sdW1lczoKICAgICAgLSAnYXJjaGl2ZXItZGF0YTovdmFyL2RhdGEvb3Blbi1hcmNoaXZlcicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHZhbGtleToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtZWlsaXNlYXJjaDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotb3Blbi1hcmNoaXZlci1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtIExDX0FMTD1DCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3ZhbGtleS1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfVkFMS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3ZhbGtleS1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtZWlsaXNlYXJjaDoKICAgIGltYWdlOiAnZ2V0bWVpbGkvbWVpbGlzZWFyY2g6djEuMTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUVJTElfTUFTVEVSX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUVJTElTRUFSQ0h9JwogICAgdm9sdW1lczoKICAgICAgLSAnbWVpbGlzZWFyY2gtZGF0YTovbWVpbGlfZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo3NzAwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", "tags": [ "email archiving", "email", diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 0fa4af749..d728f5ad7 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -285,3 +285,104 @@ function readDeploymentJobProperty(object $job, ReflectionClass $reflection, str expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION='); expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION='); }); + +it('builds railpack variables from generic buildtime vars railpack vars and coolify vars only', function () { + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'railpack', + 'fqdn' => 'https://railpack.example.com', + 'install_command' => 'pnpm install --frozen-lockfile', + ]); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'APP_ENV', + 'value' => 'production', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RUNTIME_ONLY', + 'value' => 'runtime', + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ + 'build_pack' => 'railpack', + 'branch' => 'main', + ]); + + /** @var Collection $variables */ + $variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables'); + + expect($variables->get('APP_ENV'))->toBe('production'); + expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); + expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget'); + expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); + expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); + expect($variables->has('RUNTIME_ONLY'))->toBeFalse(); +}); + +it('builds preview railpack variables without leaking stale nixpacks vars', function () { + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'railpack', + 'fqdn' => 'https://railpack.example.com', + ]); + + createApplicationEnvironmentVariable($application, [ + 'key' => 'PREVIEW_BUILD_FLAG', + 'value' => 'enabled', + 'is_preview' => true, + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'PREVIEW_RUNTIME_ONLY', + 'value' => 'runtime', + 'is_preview' => true, + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'NIXPACKS_NODE_VERSION', + 'value' => '22', + 'is_preview' => true, + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + createApplicationEnvironmentVariable($application, [ + 'key' => 'RAILPACK_NODE_VERSION', + 'value' => '20', + 'is_preview' => true, + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ + 'build_pack' => 'railpack', + 'branch' => 'feature/railpack', + 'pull_request_id' => 123, + ]); + + /** @var Collection $variables */ + $variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables'); + + expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled'); + expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget'); + expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); + expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); + expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse(); +}); diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 5314fa6b8..361ca666b 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -219,6 +219,7 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ collect([ 'RAILPACK_NODE_VERSION' => '22', 'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall', + 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', 'SECRET_JSON' => '{"token":"abc"}', ]), ], @@ -226,9 +227,11 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ expect($command)->toContain("env RAILPACK_NODE_VERSION='22'"); expect($command)->toContain("RAILPACK_INSTALL_CMD='npm ci && npm run postinstall'"); + expect($command)->toContain("RAILPACK_DEPLOY_APT_PACKAGES='curl wget'"); expect($command)->toContain("SECRET_JSON='{\"token\":\"abc\"}'"); expect($command)->toContain('--secret id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'); expect($command)->toContain('--secret id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'); + expect($command)->toContain('--secret id=RAILPACK_DEPLOY_APT_PACKAGES,env=RAILPACK_DEPLOY_APT_PACKAGES'); expect($command)->toContain('--secret id=SECRET_JSON,env=SECRET_JSON'); expect($command)->toContain(' --build-arg secrets-hash='); expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version').'"'); diff --git a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php index 02d93fa93..1dda8b8c3 100644 --- a/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php @@ -42,10 +42,15 @@ $nullValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn(null); $envQuery = Mockery::mock(); + $envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf(); $envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf(); - $envQuery->shouldReceive('get')->once()->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue])); + $envQuery->shouldReceive('get')->once()->andReturn(collect([])); $application->shouldReceive('environment_variables')->once()->andReturn($envQuery); + $railpackQuery = Mockery::mock(); + $railpackQuery->shouldReceive('get')->once()->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue])); + $application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery); + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); $job->shouldAllowMockingProtectedMethods(); $job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([])); @@ -76,11 +81,13 @@ 'RAILPACK_CUSTOM_FLAG' => 'hello world', 'RAILPACK_JSON' => '{"token":"abc"}', 'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall', + 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', ]); expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'"); expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'"); expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'"); expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'"); expect($envArgs)->not->toContain('RAILPACK_NULL'); }); @@ -97,10 +104,15 @@ $previewValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('preview-value'); $previewQuery = Mockery::mock(); + $previewQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf(); $previewQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf(); - $previewQuery->shouldReceive('get')->once()->andReturn(collect([$previewValue])); + $previewQuery->shouldReceive('get')->once()->andReturn(collect([])); $application->shouldReceive('environment_variables_preview')->once()->andReturn($previewQuery); + $railpackPreviewQuery = Mockery::mock(); + $railpackPreviewQuery->shouldReceive('get')->once()->andReturn(collect([$previewValue])); + $application->shouldReceive('railpack_environment_variables_preview')->once()->andReturn($railpackPreviewQuery); + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); $job->shouldAllowMockingProtectedMethods(); $job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([])); @@ -124,6 +136,7 @@ expect($variables->all())->toBe([ 'RAILPACK_PREVIEW_ONLY' => 'preview-value', + 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', ]); }); @@ -140,10 +153,15 @@ $userVar->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('hello'); $envQuery = Mockery::mock(); + $envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf(); $envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf(); $envQuery->shouldReceive('get')->once()->andReturn(collect([$userVar])); $application->shouldReceive('environment_variables')->once()->andReturn($envQuery); + $railpackQuery = Mockery::mock(); + $railpackQuery->shouldReceive('get')->once()->andReturn(collect([])); + $application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery); + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); $job->shouldAllowMockingProtectedMethods(); $job->shouldReceive('generate_coolify_env_variables') @@ -177,6 +195,7 @@ expect($variables->all())->toBe([ 'MY_BUILD_VAR' => 'hello', + 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget', 'COOLIFY_URL' => 'https://app.example.com', 'COOLIFY_FQDN' => 'app.example.com', 'COOLIFY_BRANCH' => 'main', @@ -190,6 +209,59 @@ expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'"); expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'"); + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'"); expect($envArgs)->not->toContain('EMPTY_VAR'); expect($envArgs)->not->toContain('NULL_VAR'); }); + +it('preserves user railpack deploy apt packages while adding healthcheck tools once', function () { + $application = Mockery::mock(Application::class); + $application->shouldReceive('getAttribute')->with('install_command')->andReturn(null); + + $deployPackages = Mockery::mock(EnvironmentVariable::class)->makePartial(); + $deployPackages->forceFill([ + 'key' => 'RAILPACK_DEPLOY_APT_PACKAGES', + 'is_literal' => false, + 'is_multiline' => false, + ]); + $deployPackages->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('ffmpeg curl'); + + $envQuery = Mockery::mock(); + $envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf(); + $envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf(); + $envQuery->shouldReceive('get')->once()->andReturn(collect([])); + $application->shouldReceive('environment_variables')->once()->andReturn($envQuery); + + $railpackQuery = Mockery::mock(); + $railpackQuery->shouldReceive('get')->once()->andReturn(collect([$deployPackages])); + $application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery); + + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([])); + + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $application); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + $mainServerProperty = $reflection->getProperty('mainServer'); + $mainServerProperty->setAccessible(true); + $mainServerProperty->setValue($job, Mockery::mock(Server::class)); + + $method = $reflection->getMethod('generate_railpack_env_variables'); + $method->setAccessible(true); + $variables = $method->invoke($job); + + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget'); + + $envArgsProperty = $reflection->getProperty('env_railpack_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'"); +}); From b5ff124446b9bd5bdecdf9f69777dab0454f952e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 11 May 2026 15:43:09 +0200 Subject: [PATCH 17/20] fix(env): validate Docker-compatible variable keys Add shared environment variable key validation and normalization for Livewire forms and models, allowing Docker-compatible keys while rejecting invalid entries such as keys containing equals signs. Also quote Railpack build environment and secret arguments safely. --- app/Jobs/ApplicationDeploymentJob.php | 4 +- .../Shared/EnvironmentVariable/Add.php | 50 +++++++++----- .../Shared/EnvironmentVariable/All.php | 30 +++++++-- .../Shared/EnvironmentVariable/Show.php | 61 ++++++++++------- app/Models/EnvironmentVariable.php | 3 +- app/Models/SharedEnvironmentVariable.php | 9 +++ app/Support/ValidationPatterns.php | 67 +++++++++++++++++++ .../EnvironmentVariableKeyValidationTest.php | 39 +++++++++++ ...pplicationDeploymentRailpackConfigTest.php | 16 ++--- ...nvironmentVariableBuildpackControlTest.php | 37 ++++++++++ tests/Unit/ValidationPatternsTest.php | 35 ++++++++++ 11 files changed, 294 insertions(+), 57 deletions(-) create mode 100644 tests/Feature/EnvironmentVariableKeyValidationTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index beb1f5c05..663499cee 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2570,7 +2570,7 @@ private function railpack_build_environment_prefix(Collection $variables): strin return 'env '.$variables ->map(function ($value, $key) { - return "{$key}=".escapeShellValue($value); + return escapeShellValue("{$key}={$value}"); }) ->implode(' ').' '; } @@ -2583,7 +2583,7 @@ private function railpack_build_secret_flags(Collection $variables): string return ' '.$variables ->map(function ($value, $key) { - return "--secret id={$key},env={$key}"; + return '--secret '.escapeShellValue("id={$key},env={$key}"); }) ->implode(' '); } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index c51b27b6a..1dcb7c781 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -2,9 +2,14 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Application; use App\Models\Environment; use App\Models\Project; +use App\Models\Server; +use App\Models\Service; +use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Component; @@ -37,15 +42,23 @@ class Add extends Component protected $listeners = ['clearAddEnv' => 'clear']; - protected $rules = [ - 'key' => 'required|string', - 'value' => 'nullable', - 'is_multiline' => 'required|boolean', - 'is_literal' => 'required|boolean', - 'is_runtime' => 'required|boolean', - 'is_buildtime' => 'required|boolean', - 'comment' => 'nullable|string|max:256', - ]; + protected function rules(): array + { + return [ + 'key' => ValidationPatterns::environmentVariableKeyRules(), + 'value' => 'nullable', + 'is_multiline' => 'required|boolean', + 'is_literal' => 'required|boolean', + 'is_runtime' => 'required|boolean', + 'is_buildtime' => 'required|boolean', + 'comment' => 'nullable|string|max:256', + ]; + } + + protected function messages(): array + { + return ValidationPatterns::environmentVariableKeyMessages('key'); + } protected $validationAttributes = [ 'key' => 'key', @@ -85,7 +98,7 @@ public function availableSharedVariables(): array $result['team'] = $team->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view team variables } @@ -116,12 +129,12 @@ public function availableSharedVariables(): array $result['environment'] = $environment->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view environment variables } } } - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view project variables } } @@ -131,7 +144,7 @@ public function availableSharedVariables(): array $serverUuid = data_get($this->parameters, 'server_uuid'); if ($serverUuid) { // If we have a specific server_uuid, show variables for that server - $server = \App\Models\Server::where('team_id', $team->id) + $server = Server::where('team_id', $team->id) ->where('uuid', $serverUuid) ->first(); @@ -141,7 +154,7 @@ public function availableSharedVariables(): array $result['server'] = $server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } @@ -149,7 +162,7 @@ public function availableSharedVariables(): array // For application environment variables, try to use the application's destination server $applicationUuid = data_get($this->parameters, 'application_uuid'); if ($applicationUuid) { - $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id) + $application = Application::whereRelation('environment.project.team', 'id', $team->id) ->where('uuid', $applicationUuid) ->with('destination.server') ->first(); @@ -160,7 +173,7 @@ public function availableSharedVariables(): array $result['server'] = $application->destination->server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } @@ -168,7 +181,7 @@ public function availableSharedVariables(): array // For service environment variables, try to use the service's server $serviceUuid = data_get($this->parameters, 'service_uuid'); if ($serviceUuid) { - $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id) + $service = Service::whereRelation('environment.project.team', 'id', $team->id) ->where('uuid', $serviceUuid) ->with('server') ->first(); @@ -179,7 +192,7 @@ public function availableSharedVariables(): array $result['server'] = $service->server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } @@ -192,6 +205,7 @@ public function availableSharedVariables(): array public function submit() { + $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key); $this->validate(); $this->dispatch('saveKey', [ 'key' => $this->key, diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index f250a860b..53b55009e 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -2,7 +2,9 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Application; use App\Models\EnvironmentVariable; +use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableProtection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -38,7 +40,7 @@ public function mount() $this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false); $this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false); $this->resourceClass = get_class($this->resource); - $resourceWithPreviews = [\App\Models\Application::class]; + $resourceWithPreviews = [Application::class]; $simpleDockerfile = filled(data_get($this->resource, 'dockerfile')); if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) { $this->showPreview = true; @@ -194,7 +196,7 @@ public function submit($data = null) private function updateOrder() { - $variables = parseEnvFormatToArray($this->variables); + $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables)); $order = 1; foreach ($variables as $key => $value) { $env = $this->resource->environment_variables()->where('key', $key)->first(); @@ -206,7 +208,7 @@ private function updateOrder() } if ($this->showPreview) { - $previewVariables = parseEnvFormatToArray($this->variablesPreview); + $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview)); $order = 1; foreach ($previewVariables as $key => $value) { $env = $this->resource->environment_variables_preview()->where('key', $key)->first(); @@ -221,7 +223,7 @@ private function updateOrder() private function handleBulkSubmit() { - $variables = parseEnvFormatToArray($this->variables); + $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables)); $changesMade = false; $errorOccurred = false; @@ -241,7 +243,7 @@ private function handleBulkSubmit() } if ($this->showPreview) { - $previewVariables = parseEnvFormatToArray($this->variablesPreview); + $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview)); // Try to delete removed preview variables $deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables); @@ -267,6 +269,7 @@ private function handleBulkSubmit() private function handleSingleSubmit($data) { + $data['key'] = ValidationPatterns::validatedEnvironmentVariableKey($data['key']); $found = $this->resource->environment_variables()->where('key', $data['key'])->first(); if ($found) { $this->dispatch('error', 'Environment variable already exists.'); @@ -334,6 +337,23 @@ private function deleteRemovedVariables($isPreview, $variables) return $variablesToDelete->count(); } + private function normalizeEnvironmentVariables(array $variables): array + { + $normalizedVariables = []; + + foreach ($variables as $key => $data) { + $normalizedKey = ValidationPatterns::validatedEnvironmentVariableKey((string) $key); + + if (array_key_exists($normalizedKey, $normalizedVariables)) { + throw new \InvalidArgumentException("Duplicate environment variable key after normalization: {$normalizedKey}."); + } + + $normalizedVariables[$normalizedKey] = $data; + } + + return $normalizedVariables; + } + private function updateOrCreateVariables($isPreview, $variables) { $count = 0; diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 4e8521f27..26369852e 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -2,12 +2,17 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\Application; use App\Models\Environment; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Models\Project; +use App\Models\Server; +use App\Models\Service; use App\Models\SharedEnvironmentVariable; +use App\Support\ValidationPatterns; use App\Traits\EnvironmentVariableAnalyzer; use App\Traits\EnvironmentVariableProtection; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Computed; use Livewire\Component; @@ -64,23 +69,31 @@ class Show extends Component 'compose_loaded' => '$refresh', ]; - protected $rules = [ - 'key' => 'required|string', - 'value' => 'nullable', - 'comment' => 'nullable|string|max:256', - 'is_multiline' => 'required|boolean', - 'is_literal' => 'required|boolean', - 'is_shown_once' => 'required|boolean', - 'is_runtime' => 'required|boolean', - 'is_buildtime' => 'required|boolean', - 'real_value' => 'nullable', - 'is_required' => 'required|boolean', - ]; + protected function rules(): array + { + return [ + 'key' => ValidationPatterns::environmentVariableKeyRules(), + 'value' => 'nullable', + 'comment' => 'nullable|string|max:256', + 'is_multiline' => 'required|boolean', + 'is_literal' => 'required|boolean', + 'is_shown_once' => 'required|boolean', + 'is_runtime' => 'required|boolean', + 'is_buildtime' => 'required|boolean', + 'real_value' => 'nullable', + 'is_required' => 'required|boolean', + ]; + } + + protected function messages(): array + { + return ValidationPatterns::environmentVariableKeyMessages('key'); + } public function mount() { $this->syncData(); - if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) { + if ($this->env->getMorphClass() === SharedEnvironmentVariable::class) { $this->isSharedVariable = true; } $this->parameters = get_route_parameters(); @@ -108,9 +121,11 @@ public function refresh() public function syncData(bool $toModel = false) { if ($toModel) { + $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key); + if ($this->isSharedVariable) { $this->validate([ - 'key' => 'required|string', + 'key' => ValidationPatterns::environmentVariableKeyRules(), 'value' => 'nullable', 'comment' => 'nullable|string|max:256', 'is_multiline' => 'required|boolean', @@ -233,7 +248,7 @@ public function availableSharedVariables(): array $result['team'] = $team->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view team variables } @@ -264,12 +279,12 @@ public function availableSharedVariables(): array $result['environment'] = $environment->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view environment variables } } } - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view project variables } } @@ -279,7 +294,7 @@ public function availableSharedVariables(): array $serverUuid = data_get($this->parameters, 'server_uuid'); if ($serverUuid) { // If we have a specific server_uuid, show variables for that server - $server = \App\Models\Server::where('team_id', $team->id) + $server = Server::where('team_id', $team->id) ->where('uuid', $serverUuid) ->first(); @@ -289,7 +304,7 @@ public function availableSharedVariables(): array $result['server'] = $server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } @@ -297,7 +312,7 @@ public function availableSharedVariables(): array // For application environment variables, try to use the application's destination server $applicationUuid = data_get($this->parameters, 'application_uuid'); if ($applicationUuid) { - $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id) + $application = Application::whereRelation('environment.project.team', 'id', $team->id) ->where('uuid', $applicationUuid) ->with('destination.server') ->first(); @@ -308,7 +323,7 @@ public function availableSharedVariables(): array $result['server'] = $application->destination->server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } @@ -316,7 +331,7 @@ public function availableSharedVariables(): array // For service environment variables, try to use the service's server $serviceUuid = data_get($this->parameters, 'service_uuid'); if ($serviceUuid) { - $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id) + $service = Service::whereRelation('environment.project.team', 'id', $team->id) ->where('uuid', $serviceUuid) ->with('server') ->first(); @@ -327,7 +342,7 @@ public function availableSharedVariables(): array $result['server'] = $service->server->environment_variables() ->pluck('key') ->toArray(); - } catch (\Illuminate\Auth\Access\AuthorizationException $e) { + } catch (AuthorizationException $e) { // User not authorized to view server variables } } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index ac0d238b3..dcbdad253 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; +use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use OpenApi\Attributes as OA; @@ -370,7 +371,7 @@ private function set_environment_variables(?string $environment_variable = null) protected function key(): Attribute { return Attribute::make( - set: fn (string $value) => str($value)->trim()->replace(' ', '_')->value, + set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey($value), ); } diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index fa6fd45e0..eadc33ec2 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; class SharedEnvironmentVariable extends Model @@ -33,6 +35,13 @@ class SharedEnvironmentVariable extends Model 'value' => 'encrypted', ]; + protected function key(): Attribute + { + return Attribute::make( + set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey($value), + ); + } + public function team() { return $this->belongsTo(Team::class); diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php index 58dbbe1ac..07926e1cf 100644 --- a/app/Support/ValidationPatterns.php +++ b/app/Support/ValidationPatterns.php @@ -82,6 +82,12 @@ class ValidationPatterns */ public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'; + /** + * Pattern for Docker-compatible environment variable keys. + * Docker environment entries are KEY=value strings, so keys must be non-empty and cannot contain '=' or NUL. + */ + public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[^=\x00]+\z/u'; + /** * Pattern for SQL-safe unquoted database identifiers (usernames, database names). * Allows letters, digits, underscore; first char must be letter or underscore. @@ -96,6 +102,67 @@ class ValidationPatterns */ public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/'; + /** + * Normalize environment variable keys before validation and storage. + */ + public static function normalizeEnvironmentVariableKey(string $value): string + { + return str($value)->trim()->value; + } + + /** + * Get validation rules for environment variable keys. + */ + public static function environmentVariableKeyRules(bool $required = true, int $maxLength = 255): array + { + $rules = []; + + if ($required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + $rules[] = 'string'; + $rules[] = "max:$maxLength"; + $rules[] = 'regex:'.self::ENVIRONMENT_VARIABLE_KEY_PATTERN; + + return $rules; + } + + /** + * Get validation messages for environment variable key fields. + */ + public static function environmentVariableKeyMessages(string $field = 'key', string $label = 'key'): array + { + return [ + "{$field}.regex" => "The {$label} must be a non-empty Docker-compatible environment variable key and cannot contain '=' or NUL characters.", + "{$field}.max" => "The {$label} may not be greater than :max characters.", + ]; + } + + /** + * Check if a string is a valid environment variable key. + */ + public static function isValidEnvironmentVariableKey(string $value): bool + { + return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1; + } + + /** + * Normalize and validate an environment variable key. + */ + public static function validatedEnvironmentVariableKey(string $value, string $label = 'key'): string + { + $key = self::normalizeEnvironmentVariableKey($value); + + if (! self::isValidEnvironmentVariableKey($key)) { + throw new \InvalidArgumentException(self::environmentVariableKeyMessages(label: $label)['key.regex']); + } + + return $key; + } + /** * Get validation rules for database identifier fields (username, database name). * diff --git a/tests/Feature/EnvironmentVariableKeyValidationTest.php b/tests/Feature/EnvironmentVariableKeyValidationTest.php new file mode 100644 index 000000000..4f41ed3d6 --- /dev/null +++ b/tests/Feature/EnvironmentVariableKeyValidationTest.php @@ -0,0 +1,39 @@ +set('key', 'BAD=KEY') + ->set('value', 'value') + ->call('submit') + ->assertHasErrors(['key' => 'regex']); +}); + +it('allows Docker-compatible environment variable keys in the add form', function (string $key) { + Livewire::test(Add::class) + ->set('key', $key) + ->set('value', 'value') + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('saveKey', function ($event, array $data) use ($key) { + return data_get($data, 'key') === $key || data_get($data, '0.key') === $key; + }); +})->with([ + 'starts with digit' => '1BAD', + 'hyphen' => 'BAD-KEY', + 'dot' => 'node.name', + 'uppercase dots' => 'XPACK.SECURITY.ENABLED', +]); + +it('trims surrounding whitespace in environment variable keys in the add form', function () { + Livewire::test(Add::class) + ->set('key', ' node.name ') + ->set('value', 'value') + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('saveKey', function ($event, array $data) { + return data_get($data, 'key') === 'node.name' || data_get($data, '0.key') === 'node.name'; + }); +}); diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 361ca666b..15bee488c 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -225,14 +225,14 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ ], ); - expect($command)->toContain("env RAILPACK_NODE_VERSION='22'"); - expect($command)->toContain("RAILPACK_INSTALL_CMD='npm ci && npm run postinstall'"); - expect($command)->toContain("RAILPACK_DEPLOY_APT_PACKAGES='curl wget'"); - expect($command)->toContain("SECRET_JSON='{\"token\":\"abc\"}'"); - expect($command)->toContain('--secret id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'); - expect($command)->toContain('--secret id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'); - expect($command)->toContain('--secret id=RAILPACK_DEPLOY_APT_PACKAGES,env=RAILPACK_DEPLOY_APT_PACKAGES'); - expect($command)->toContain('--secret id=SECRET_JSON,env=SECRET_JSON'); + expect($command)->toContain("env 'RAILPACK_NODE_VERSION=22'"); + expect($command)->toContain("'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'"); + expect($command)->toContain("'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'"); + expect($command)->toContain("'SECRET_JSON={\"token\":\"abc\"}'"); + expect($command)->toContain("--secret 'id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'"); + expect($command)->toContain("--secret 'id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'"); + expect($command)->toContain("--secret 'id=RAILPACK_DEPLOY_APT_PACKAGES,env=RAILPACK_DEPLOY_APT_PACKAGES'"); + expect($command)->toContain("--secret 'id=SECRET_JSON,env=SECRET_JSON'"); expect($command)->toContain(' --build-arg secrets-hash='); expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version').'"'); }); diff --git a/tests/Unit/EnvironmentVariableBuildpackControlTest.php b/tests/Unit/EnvironmentVariableBuildpackControlTest.php index 1a277bcdd..c24956c0d 100644 --- a/tests/Unit/EnvironmentVariableBuildpackControlTest.php +++ b/tests/Unit/EnvironmentVariableBuildpackControlTest.php @@ -1,6 +1,7 @@ getAppends())->toContain('is_buildpack_control'); expect($env->getAppends())->not->toContain('is_nixpacks'); }); + +it('normalizes environment variable keys before storing them on the model', function () { + $env = new EnvironmentVariable; + $env->key = ' node.name '; + + expect($env->key)->toBe('node.name'); +}); + +it('allows Docker-compatible environment variable keys on the model', function (string $key) { + $env = new EnvironmentVariable; + $env->key = $key; + + expect($env->key)->toBe($key); +})->with([ + 'starts with digit' => '1BAD', + 'hyphen' => 'BAD-KEY', + 'dot' => 'node.name', + 'uppercase dots' => 'XPACK.SECURITY.ENABLED', + 'semicolon' => 'BAD;KEY', +]); + +it('rejects environment variable keys Docker cannot represent on the model', function () { + $env = new EnvironmentVariable; + + expect(function () use ($env) { + $env->key = 'BAD=KEY'; + })->toThrow(InvalidArgumentException::class, 'Docker-compatible'); +}); + +it('rejects shared environment variable keys Docker cannot represent on the model', function () { + $env = new SharedEnvironmentVariable; + + expect(function () use ($env) { + $env->key = 'BAD=KEY'; + })->toThrow(InvalidArgumentException::class, 'Docker-compatible'); +}); diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php index 9ecffe46d..092c37b25 100644 --- a/tests/Unit/ValidationPatternsTest.php +++ b/tests/Unit/ValidationPatternsTest.php @@ -130,3 +130,38 @@ expect($rules)->toContain('nullable') ->not->toContain('required'); }); + +it('accepts Docker-compatible environment variable keys', function (string $key) { + expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeTrue(); +})->with([ + 'letters' => 'APP_ENV', + 'leading underscore' => '_TOKEN', + 'railpack control variable' => 'RAILPACK_NODE_VERSION', + 'digits after first character' => 'NODE_VERSION_20', + 'starts with digit' => '1BAD', + 'hyphen' => 'BAD-KEY', + 'dot' => 'node.name', + 'uppercase dots' => 'XPACK.SECURITY.ENABLED', + 'semicolon' => 'BAD;KEY', + 'space' => 'BAD KEY', +]); + +it('rejects environment variable keys Docker cannot represent', function (string $key) { + expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeFalse(); +})->with([ + 'equals' => 'BAD=KEY', + 'empty' => '', +]); + +it('generates environment variable key rules with correct defaults', function () { + $rules = ValidationPatterns::environmentVariableKeyRules(); + + expect($rules)->toContain('required') + ->toContain('string') + ->toContain('max:255') + ->toContain('regex:'.ValidationPatterns::ENVIRONMENT_VARIABLE_KEY_PATTERN); +}); + +it('normalizes environment variable keys by trimming surrounding whitespace', function () { + expect(ValidationPatterns::normalizeEnvironmentVariableKey(' node.name '))->toBe('node.name'); +}); From 0395db30f03c132c8f8887472ed4eee3ae927da8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 11 May 2026 17:13:55 +0200 Subject: [PATCH 18/20] fix(railpack): align example ports and smoke checks Update Railpack seed examples to use the expected Flask start command and Go/Rust exposed ports. Adjust smoke coverage to run Symfony by default and accept reachable 4xx responses, and extend seeder tests for per-example branch and port assertions. --- .../DevelopmentRailpackExamplesSeeder.php | 6 ++--- scripts/railpack-smoke.sh | 4 ++-- .../DevelopmentRailpackExamplesSeederTest.php | 22 ++++++++++++++++++- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php index dec7864db..78659b457 100644 --- a/database/seeders/DevelopmentRailpackExamplesSeeder.php +++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php @@ -295,20 +295,20 @@ public static function examples(): array 'base_directory' => '/flask', 'ports_exposes' => '5000', 'git_branch' => 'v4.x', - 'start_command' => 'gunicorn app:app --bind 0.0.0.0:5000', + 'start_command' => 'flask run --host=0.0.0.0 --port=5000', ], [ 'uuid' => 'railpack-go-gin', 'name' => 'Railpack Go Gin Example', 'base_directory' => '/go/gin', - 'ports_exposes' => '8080', + 'ports_exposes' => '3000', 'git_branch' => 'v4.x', ], [ 'uuid' => 'railpack-rust', 'name' => 'Railpack Rust Example', 'base_directory' => '/rust', - 'ports_exposes' => '8080', + 'ports_exposes' => '8000', 'git_branch' => 'v4.x', ], [ diff --git a/scripts/railpack-smoke.sh b/scripts/railpack-smoke.sh index 92e621c3b..0fe757316 100755 --- a/scripts/railpack-smoke.sh +++ b/scripts/railpack-smoke.sh @@ -45,7 +45,7 @@ DEFAULT_APPS=( railpack-python-flask railpack-go-gin railpack-rust - railpack-laravel + railpack-symfony railpack-bun ) @@ -255,7 +255,7 @@ assert_fqdn_responds() { local code code=$(curl -ksSL -o /dev/null -w '%{http_code}' --max-time 10 "$fqdn" || echo "000") case "$code" in - 2*|3*) pass "$app_uuid" "fqdn ${fqdn} -> ${code}" ;; + 2*|3*|4*) pass "$app_uuid" "fqdn ${fqdn} -> ${code}" ;; *) fail "$app_uuid" "fqdn ${fqdn} -> ${code}" ;; esac } diff --git a/tests/Feature/DevelopmentRailpackExamplesSeederTest.php b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php index 2f224fda7..59646a804 100644 --- a/tests/Feature/DevelopmentRailpackExamplesSeederTest.php +++ b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php @@ -67,11 +67,18 @@ function seedRailpackExamplePrerequisites(): void expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples())); expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue(); expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue(); - expect($applications->every(fn (Application $application) => $application->git_branch === DevelopmentRailpackExamplesSeeder::GIT_BRANCH))->toBeTrue(); + + $examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid'); + expect($applications->every( + fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH) + ))->toBeTrue(); $nestjs = $applications->firstWhere('uuid', 'railpack-nestjs'); $angularStatic = $applications->firstWhere('uuid', 'railpack-angular-static'); $eleventyStatic = $applications->firstWhere('uuid', 'railpack-eleventy-static'); + $pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask'); + $goGin = $applications->firstWhere('uuid', 'railpack-go-gin'); + $rust = $applications->firstWhere('uuid', 'railpack-rust'); expect($nestjs) ->not->toBeNull() @@ -93,6 +100,19 @@ function seedRailpackExamplePrerequisites(): void ->and($eleventyStatic->publish_directory)->toBe('/_site') ->and($eleventyStatic->settings->is_static)->toBeTrue() ->and($eleventyStatic->settings->is_spa)->toBeFalse(); + + expect($pythonFlask) + ->not->toBeNull() + ->and($pythonFlask->ports_exposes)->toBe('5000') + ->and($pythonFlask->start_command)->toBe('flask run --host=0.0.0.0 --port=5000'); + + expect($goGin) + ->not->toBeNull() + ->and($goGin->ports_exposes)->toBe('3000'); + + expect($rust) + ->not->toBeNull() + ->and($rust->ports_exposes)->toBe('8000'); }); it('skips the railpack examples outside development mode', function () { From ab1958d741117a2d22d92272646e2b04a01f3c7b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 11 May 2026 17:31:29 +0200 Subject: [PATCH 19/20] fix(railpack): fail fast when buildx is unavailable Require Docker buildx before Railpack builds, normalize environment variable keys before validation, and align private deploy key API docs with the supported dockerfile build pack. --- .../Controllers/Api/ApplicationsController.php | 2 +- app/Jobs/ApplicationDeploymentJob.php | 18 ++++++++++++++++++ app/Models/EnvironmentVariable.php | 4 +++- openapi.json | 6 +----- openapi.yaml | 2 +- ...ApplicationDeploymentRailpackConfigTest.php | 11 +++++++++++ tests/Unit/ValidationPatternsTest.php | 8 ++++++++ 7 files changed, 43 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 43f114bcf..9919a8054 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -650,7 +650,7 @@ public function create_private_deploy_key_application(Request $request) 'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'], 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], - 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'build_pack' => ['type' => 'string', 'enum' => ['dockerfile'], 'description' => 'The build pack type.'], 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], 'name' => ['type' => 'string', 'description' => 'The application name.'], diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 663499cee..591159c5f 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -181,6 +181,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $dockerBuildkitSupported = false; + private bool $dockerBuildxAvailable = false; + private bool $dockerSecretsSupported = false; private bool $skip_build = false; @@ -421,6 +423,7 @@ private function detectBuildKitCapabilities(): void if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) { $this->dockerBuildkitSupported = false; + $this->dockerBuildxAvailable = false; $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+)."); return; @@ -434,8 +437,11 @@ private function detectBuildKitCapabilities(): void if (trim($buildxAvailable) === 'available') { $this->dockerBuildkitSupported = true; + $this->dockerBuildxAvailable = true; $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}."); } else { + $this->dockerBuildxAvailable = false; + // Fallback: test DOCKER_BUILDKIT=1 support via --progress flag $buildkitTest = instant_remote_process( ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"], @@ -468,6 +474,7 @@ private function detectBuildKitCapabilities(): void } } catch (Exception $e) { $this->dockerBuildkitSupported = false; + $this->dockerBuildxAvailable = false; $this->dockerSecretsSupported = false; $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); } @@ -2760,8 +2767,19 @@ private function railpack_prepare_command(?string $configFilePath = null): strin return $prepare_command; } + private function ensure_docker_buildx_available_for_railpack(): void + { + if ($this->dockerBuildxAvailable) { + return; + } + + throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.'); + } + private function build_railpack_image(): void { + $this->ensure_docker_buildx_available_for_railpack(); + $railpackVariables = $this->generate_railpack_env_variables(); $railpackConfigPath = $this->generate_railpack_config_file(); diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index dcbdad253..bfb02a470 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -371,7 +371,9 @@ private function set_environment_variables(?string $environment_variable = null) protected function key(): Attribute { return Attribute::make( - set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey($value), + set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey( + ValidationPatterns::normalizeEnvironmentVariableKey($value) + ), ); } diff --git a/openapi.json b/openapi.json index 1e9fc4170..25aada1e1 100644 --- a/openapi.json +++ b/openapi.json @@ -1451,11 +1451,7 @@ "build_pack": { "type": "string", "enum": [ - "nixpacks", - "railpack", - "static", - "dockerfile", - "dockercompose" + "dockerfile" ], "description": "The build pack type." }, diff --git a/openapi.yaml b/openapi.yaml index 3e652fa7b..4597b06f7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -935,7 +935,7 @@ paths: description: 'The Dockerfile content.' build_pack: type: string - enum: [nixpacks, railpack, static, dockerfile, dockercompose] + enum: [dockerfile] description: 'The build pack type.' ports_exposes: type: string diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php index 15bee488c..e1449a59e 100644 --- a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php +++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php @@ -205,6 +205,17 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $ expect($command)->not->toContain('RAILPACK_START_CMD='); }); +it('fails fast when docker buildx is unavailable for railpack builds', function () { + [$job, $reflection] = makeRailpackDeploymentJob(); + + $dockerBuildxAvailableProperty = $reflection->getProperty('dockerBuildxAvailable'); + $dockerBuildxAvailableProperty->setAccessible(true); + $dockerBuildxAvailableProperty->setValue($job, false); + + expect(fn () => invokeRailpackMethod($job, $reflection, 'ensure_docker_buildx_available_for_railpack')) + ->toThrow(DeploymentException::class, 'Railpack deployments require the Docker buildx CLI plugin'); +}); + it('builds railpack docker command with matching env and secret flags for all railpack variables', function () { [$job, $reflection] = makeRailpackDeploymentJob([ 'uuid' => 'application-uuid', diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php index 092c37b25..a959b18d5 100644 --- a/tests/Unit/ValidationPatternsTest.php +++ b/tests/Unit/ValidationPatternsTest.php @@ -1,5 +1,6 @@ toBe('node.name'); }); + +it('normalizes environment variable keys before model validation', function () { + $environmentVariable = new EnvironmentVariable; + $environmentVariable->key = ' APP_ENV '; + + expect($environmentVariable->key)->toBe('APP_ENV'); +}); From 94c7968c4fb05c28d61c822cbb3ae637bfdd1d73 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 11 May 2026 17:33:12 +0200 Subject: [PATCH 20/20] style(railpack): add return type to deploy method --- app/Jobs/ApplicationDeploymentJob.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 591159c5f..4f9481794 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -956,7 +956,7 @@ private function deploy_nixpacks_buildpack() $this->rolling_update(); } - private function deploy_railpack_buildpack() + private function deploy_railpack_buildpack(): void { if ($this->use_build_server) { $this->server = $this->build_server;