Refund
-
- @if ($refundCheckLoading)
- Request Full Refund
- @elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
-
- @else
- Request Full Refund
- @endif
-
+ @if ($refundCheckLoading || ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end))
+
+ @if ($refundCheckLoading)
+ Request Full Refund
+ @else
+
+ @endif
+
+ @endif
@if ($refundCheckLoading)
Checking refund eligibility...
diff --git a/resources/views/livewire/switch-team.blade.php b/resources/views/livewire/switch-team.blade.php
index b46c1ecf6..b979647cb 100644
--- a/resources/views/livewire/switch-team.blade.php
+++ b/resources/views/livewire/switch-team.blade.php
@@ -1,6 +1,49 @@
-
-
- @foreach (auth()->user()->teams as $team)
-
- @endforeach
-
+@php
+ $currentTeam = auth()->user()->currentTeam();
+ $teamInitial = strtoupper(mb_substr($currentTeam->name, 0, 1));
+@endphp
+
+
+
+
+ @foreach (auth()->user()->teams as $team)
+
+ @endforeach
+
+
+
+
+
+
Switch team
+ @foreach (auth()->user()->teams as $team)
+
+ @endforeach
+
+
+
diff --git a/routes/ai.php b/routes/ai.php
new file mode 100644
index 000000000..7d3a858c5
--- /dev/null
+++ b/routes/ai.php
@@ -0,0 +1,7 @@
+middleware(['mcp.enabled', 'auth:sanctum']);
diff --git a/routes/api.php b/routes/api.php
index 7394d4e16..cc380b2be 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -35,6 +35,8 @@
], function () {
Route::get('/enable', [OtherController::class, 'enable_api']);
Route::get('/disable', [OtherController::class, 'disable_api']);
+ Route::post('/mcp/enable', [OtherController::class, 'enable_mcp']);
+ Route::post('/mcp/disable', [OtherController::class, 'disable_mcp']);
});
Route::group([
'middleware' => ['auth:sanctum', ApiAllowed::class, 'api.sensitive'],
@@ -106,11 +108,6 @@
Route::post('/applications/dockerfile', [ApplicationsController::class, 'create_dockerfile_application'])->middleware(['api.ability:write']);
Route::post('/applications/dockerimage', [ApplicationsController::class, 'create_dockerimage_application'])->middleware(['api.ability:write']);
- /**
- * @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is a unstable duplicate of POST /api/v1/services.
- */
- Route::post('/applications/dockercompose', [ApplicationsController::class, 'create_dockercompose_application'])->middleware(['api.ability:write']);
-
Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']);
Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
@@ -215,6 +212,8 @@
Route::post('/sentinel/push', function () {
$token = request()->header('Authorization');
if (! $token) {
+ auditLogWebhookFailure('sentinel', 'token_missing');
+
return response()->json(['message' => 'Unauthorized'], 401);
}
$naked_token = str_replace('Bearer ', '', $token);
@@ -222,26 +221,49 @@
$decrypted = decrypt($naked_token);
$decrypted_token = json_decode($decrypted, true);
} catch (Exception $e) {
+ auditLogWebhookFailure('sentinel', 'decrypt_failed');
+
return response()->json(['message' => 'Invalid token'], 401);
}
$server_uuid = data_get($decrypted_token, 'server_uuid');
if (! $server_uuid) {
+ auditLogWebhookFailure('sentinel', 'invalid_token_payload');
+
return response()->json(['message' => 'Invalid token'], 401);
}
$server = Server::where('uuid', $server_uuid)->first();
if (! $server) {
+ auditLogWebhookFailure('sentinel', 'server_not_found', [
+ 'server_uuid' => $server_uuid,
+ ]);
+
return response()->json(['message' => 'Server not found'], 404);
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
+ auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+
return response()->json(['message' => 'Unauthorized'], 401);
}
if ($server->isFunctional() === false) {
+ auditLogWebhookFailure('sentinel', 'server_not_functional', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+
return response()->json(['message' => 'Server is not functional'], 401);
}
if ($server->settings->sentinel_token !== $naked_token) {
+ auditLogWebhookFailure('sentinel', 'token_mismatch', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+
return response()->json(['message' => 'Unauthorized'], 401);
}
$data = request()->all();
@@ -249,6 +271,11 @@
// \App\Jobs\ServerCheckNewJob::dispatch($server, $data);
PushServerUpdateJob::dispatch($server, $data);
+ auditLog('sentinel.metrics_pushed', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+
return response()->json(['message' => 'ok'], 200);
});
});
diff --git a/scripts/railpack-smoke.sh b/scripts/railpack-smoke.sh
new file mode 100755
index 000000000..0fe757316
--- /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-symfony
+ 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*|4*) 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/templates/compose/bluesky-pds.yaml b/templates/compose/bluesky-pds.yaml
index de764f08c..d3a7f1239 100644
--- a/templates/compose/bluesky-pds.yaml
+++ b/templates/compose/bluesky-pds.yaml
@@ -13,10 +13,10 @@ services:
environment:
- SERVICE_URL_PDS_3000
- 'PDS_HOSTNAME=${SERVICE_FQDN_PDS}'
- - 'PDS_JWT_SECRET=${SERVICE_HEX_32_JWTSECRET}'
+ - 'PDS_JWT_SECRET=${SERVICE_HEX_64_JWTSECRET}'
- 'PDS_ADMIN_PASSWORD=${SERVICE_PASSWORD_ADMIN}'
- 'PDS_ADMIN_EMAIL=${PDS_ADMIN_EMAIL}'
- - 'PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${SERVICE_HEX_32_ROTATIONKEY}'
+ - 'PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=${SERVICE_HEX_64_ROTATIONKEY}'
- 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}'
- 'PDS_BLOBSTORE_DISK_LOCATION=${PDS_DATA_DIRECTORY:-/pds}/blocks'
- 'PDS_BLOB_UPLOAD_LIMIT=${PDS_BLOB_UPLOAD_LIMIT:-104857600}'
diff --git a/templates/compose/convex.yaml b/templates/compose/convex.yaml
index e80cc4254..29d4144c3 100644
--- a/templates/compose/convex.yaml
+++ b/templates/compose/convex.yaml
@@ -13,7 +13,7 @@ services:
environment:
- SERVICE_URL_BACKEND_3210
- INSTANCE_NAME=${INSTANCE_NAME:-self-hosted-convex}
- - INSTANCE_SECRET=${SERVICE_HEX_32_SECRET}
+ - INSTANCE_SECRET=${SERVICE_HEX_64_SECRET}
- CONVEX_RELEASE_VERSION_DEV=${CONVEX_RELEASE_VERSION_DEV:-}
- ACTIONS_USER_TIMEOUT_SECS=${ACTIONS_USER_TIMEOUT_SECS:-}
# URL of the Convex API as accessed by the client/frontend.
diff --git a/templates/compose/docmost.yaml b/templates/compose/docmost.yaml
index 4a996973e..ce643f629 100644
--- a/templates/compose/docmost.yaml
+++ b/templates/compose/docmost.yaml
@@ -19,7 +19,7 @@ services:
- APP_URL=$SERVICE_URL_DOCMOST_3000
- DATABASE_URL=postgresql://$SERVICE_USER_POSTGRES:$SERVICE_PASSWORD_POSTGRES@postgresql/docmost?schema=public
- REDIS_URL=redis://redis:6379
- - MAIL_DRIVER=${MAIL_DRIVER}
+ - MAIL_DRIVER=${MAIL_DRIVER:?}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_USERNAME=${SMTP_USERNAME}
diff --git a/templates/compose/getoutline.yaml b/templates/compose/getoutline.yaml
index 7ce7774c1..712a262ec 100644
--- a/templates/compose/getoutline.yaml
+++ b/templates/compose/getoutline.yaml
@@ -18,7 +18,7 @@ services:
environment:
- SERVICE_URL_OUTLINE_3000
- NODE_ENV=production
- - SECRET_KEY=${SERVICE_HEX_32_OUTLINE}
+ - SECRET_KEY=${SERVICE_HEX_64_OUTLINE}
- UTILS_SECRET=${SERVICE_PASSWORD_64_OUTLINE}
- DATABASE_URL=postgres://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_64_POSTGRES}@postgres:5432/${POSTGRES_DATABASE:-outline}
- REDIS_URL=redis://:${SERVICE_PASSWORD_64_REDIS}@redis:6379
diff --git a/templates/compose/gitea-runner.yaml b/templates/compose/gitea-runner.yaml
new file mode 100644
index 000000000..81cce4492
--- /dev/null
+++ b/templates/compose/gitea-runner.yaml
@@ -0,0 +1,26 @@
+# documentation: https://github.com/go-gitea/gitea
+# category: devtools
+# slogan: Gitea Actions runner for docker
+# tags: gitea,actions,runner,docker
+# logo: svgs/gitea.svg
+
+services:
+ runner:
+ image: 'docker.io/gitea/runner:1.0.0'
+ environment:
+ - 'GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL}'
+ - 'GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}'
+ - 'GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME:-gitea-runner}'
+ - 'GITEA_RUNNER_LABELS=${GITEA_RUNNER_LABELS:-ubuntu-latest:docker://node:22}'
+ - 'GITEA_TOKEN=${GITEA_TOKEN}'
+ working_dir: /data
+ volumes:
+ - 'runner-data:/data'
+ - '/var/run/docker.sock:/var/run/docker.sock'
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - "ps aux | grep '[R]unner' > /dev/null || exit 1"
+ interval: 5s
+ timeout: 10s
+ retries: 15
diff --git a/templates/compose/homarr.yaml b/templates/compose/homarr.yaml
index 117fd8738..5934e9799 100644
--- a/templates/compose/homarr.yaml
+++ b/templates/compose/homarr.yaml
@@ -10,8 +10,7 @@ services:
image: ghcr.io/homarr-labs/homarr:v1.40.0
environment:
- SERVICE_URL_HOMARR_7575
- - SERVICE_HEX_32_HOMARR
- - 'SECRET_ENCRYPTION_KEY=${SERVICE_HEX_32_HOMARR}'
+ - 'SECRET_ENCRYPTION_KEY=${SERVICE_HEX_64_HOMARR}'
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./homarr/appdata:/appdata
diff --git a/templates/compose/litequeen.yaml b/templates/compose/litequeen.yaml
index cf0c041c2..bda2d40c8 100644
--- a/templates/compose/litequeen.yaml
+++ b/templates/compose/litequeen.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://litequeen.com/
# slogan: Lite Queen is an open-source SQLite database management software that runs on your server.
# category: database
diff --git a/templates/compose/open-archiver.yaml b/templates/compose/open-archiver.yaml
index f6a7ba9b0..49eda85ff 100644
--- a/templates/compose/open-archiver.yaml
+++ b/templates/compose/open-archiver.yaml
@@ -10,8 +10,8 @@ services:
image: logiclabshq/open-archiver:latest
environment:
- SERVICE_URL_OPENARCHIVER_3000
- - ENCRYPTION_KEY=${SERVICE_HEX_32_ENCRYPTIONKEY}
- - STORAGE_ENCRYPTION_KEY=${SERVICE_HEX_32_STORAGEENCRYPTIONKEY}
+ - ENCRYPTION_KEY=${SERVICE_HEX_64_ENCRYPTIONKEY}
+ - STORAGE_ENCRYPTION_KEY=${SERVICE_HEX_64_STORAGEENCRYPTIONKEY}
- PORT_BACKEND=${PORT_BACKEND:-4000}
- PORT_FRONTEND=${PORT_FRONTEND:-3000}
- NODE_ENV=${NODE_ENV:-production}
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index eb667fcb8..d1cebb2ca 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",
@@ -887,7 +887,7 @@
"docmost": {
"documentation": "https://docmost.com/docs/?utm_source=coolify.io",
"slogan": "Open-source collaborative wiki and documentation software",
- "compose": "c2VydmljZXM6CiAgZG9jbW9zdDoKICAgIGltYWdlOiAnZG9jbW9zdC9kb2Ntb3N0OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0RPQ01PU1RfMzAwMAogICAgICAtIEFQUF9TRUNSRVQ9JFNFUlZJQ0VfQkFTRTY0X0FQUEtFWQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfVVJMX0RPQ01PU1RfMzAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcG9zdGdyZXNxbC9kb2Ntb3N0P3NjaGVtYT1wdWJsaWMnCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3JlZGlzOjYzNzknCiAgICAgIC0gJ01BSUxfRFJJVkVSPSR7TUFJTF9EUklWRVJ9JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdTTVRQX1BPUlQ9JHtTTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1VTRVJOQU1FPSR7U01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ1NNVFBfUEFTU1dPUkQ9JHtTTVRQX1BBU1NXT1JEfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ01BSUxfRlJPTV9BRERSRVNTPSR7TUFJTF9GUk9NX0FERFJFU1N9JwogICAgICAtICdNQUlMX0ZST01fTkFNRT0ke01BSUxfRlJPTV9OQU1FfScKICAgICAgLSAnUE9TVE1BUktfVE9LRU49JHtQT1NUTUFSS19UT0tFTn0nCiAgICB2b2x1bWVzOgogICAgICAtICdkb2Ntb3N0Oi9hcHAvZGF0YS9zdG9yYWdlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX0RCPWRvY21vc3QKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==",
+ "compose": "c2VydmljZXM6CiAgZG9jbW9zdDoKICAgIGltYWdlOiAnZG9jbW9zdC9kb2Ntb3N0OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0RPQ01PU1RfMzAwMAogICAgICAtIEFQUF9TRUNSRVQ9JFNFUlZJQ0VfQkFTRTY0X0FQUEtFWQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfVVJMX0RPQ01PU1RfMzAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcG9zdGdyZXNxbC9kb2Ntb3N0P3NjaGVtYT1wdWJsaWMnCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3JlZGlzOjYzNzknCiAgICAgIC0gJ01BSUxfRFJJVkVSPSR7TUFJTF9EUklWRVI6P30nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUk5BTUU9JHtTTVRQX1VTRVJOQU1FfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdTTVRQX1NFQ1VSRT0ke1NNVFBfU0VDVVJFfScKICAgICAgLSAnTUFJTF9GUk9NX0FERFJFU1M9JHtNQUlMX0ZST01fQUREUkVTU30nCiAgICAgIC0gJ01BSUxfRlJPTV9OQU1FPSR7TUFJTF9GUk9NX05BTUV9JwogICAgICAtICdQT1NUTUFSS19UT0tFTj0ke1BPU1RNQVJLX1RPS0VOfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RvY21vc3Q6L2FwcC9kYXRhL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfREI9ZG9jbW9zdAogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny4yLWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK",
"tags": [
"documentation",
"opensource",
@@ -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"
@@ -1634,6 +1634,20 @@
"minversion": "0.0.0",
"port": "2368"
},
+ "gitea-runner": {
+ "documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io",
+ "slogan": "Gitea Actions runner for docker",
+ "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
+ "tags": [
+ "gitea",
+ "actions",
+ "runner",
+ "docker"
+ ],
+ "category": "devtools",
+ "logo": "svgs/gitea.svg",
+ "minversion": "0.0.0"
+ },
"gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io",
"slogan": "Gitea is a self-hosted, lightweight Git service, offering version control, collaboration, and code hosting.",
@@ -2000,7 +2014,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",
@@ -2587,22 +2601,6 @@
"minversion": "0.0.0",
"port": "4000"
},
- "litequeen": {
- "documentation": "https://litequeen.com/?utm_source=coolify.io",
- "slogan": "Lite Queen is an open-source SQLite database management software that runs on your server.",
- "compose": "c2VydmljZXM6CiAgbGl0ZXF1ZWVuOgogICAgaW1hZ2U6ICdraXZzZWdyb2IvbGl0ZS1xdWVlbjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9MSVRFUVVFRU5fODAwMAogICAgdm9sdW1lczoKICAgICAgLSAnbGl0ZXF1ZWVuLWRhdGE6L2hvbWUvbGl0ZXF1ZWVuL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2RhdGFiYXNlcwogICAgICAgIHRhcmdldDogL3NydgogICAgICAgIGlzX2RpcmVjdG9yeTogdHJ1ZQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc6PiAvZGV2L3RjcC8xMjcuMC4wLjEvODAwMCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=",
- "tags": [
- "sqlite",
- "sqlite-database-management",
- "self-hosted",
- "vps",
- "database"
- ],
- "category": "database",
- "logo": "svgs/litequeen.svg",
- "minversion": "0.0.0",
- "port": "8000"
- },
"lobe-chat": {
"documentation": "https://github.com/lobehub/lobe-chat?tab=readme-ov-file#b-deploying-with-docker?utm_source=coolify.io",
"slogan": "An open-source, modern-design AI chat framework.",
@@ -3408,7 +3406,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..206a8cd6e 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",
@@ -887,7 +887,7 @@
"docmost": {
"documentation": "https://docmost.com/docs/?utm_source=coolify.io",
"slogan": "Open-source collaborative wiki and documentation software",
- "compose": "c2VydmljZXM6CiAgZG9jbW9zdDoKICAgIGltYWdlOiAnZG9jbW9zdC9kb2Ntb3N0OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9ET0NNT1NUXzMwMDAKICAgICAgLSBBUFBfU0VDUkVUPSRTRVJWSUNFX0JBU0U2NF9BUFBLRVkKICAgICAgLSBBUFBfVVJMPSRTRVJWSUNFX0ZRRE5fRE9DTU9TVF8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0Bwb3N0Z3Jlc3FsL2RvY21vc3Q/c2NoZW1hPXB1YmxpYycKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnTUFJTF9EUklWRVI9JHtNQUlMX0RSSVZFUn0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUk5BTUU9JHtTTVRQX1VTRVJOQU1FfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdTTVRQX1NFQ1VSRT0ke1NNVFBfU0VDVVJFfScKICAgICAgLSAnTUFJTF9GUk9NX0FERFJFU1M9JHtNQUlMX0ZST01fQUREUkVTU30nCiAgICAgIC0gJ01BSUxfRlJPTV9OQU1FPSR7TUFJTF9GUk9NX05BTUV9JwogICAgICAtICdQT1NUTUFSS19UT0tFTj0ke1BPU1RNQVJLX1RPS0VOfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RvY21vc3Q6L2FwcC9kYXRhL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfREI9ZG9jbW9zdAogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny4yLWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK",
+ "compose": "c2VydmljZXM6CiAgZG9jbW9zdDoKICAgIGltYWdlOiAnZG9jbW9zdC9kb2Ntb3N0OmxhdGVzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9ET0NNT1NUXzMwMDAKICAgICAgLSBBUFBfU0VDUkVUPSRTRVJWSUNFX0JBU0U2NF9BUFBLRVkKICAgICAgLSBBUFBfVVJMPSRTRVJWSUNFX0ZRRE5fRE9DTU9TVF8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0Bwb3N0Z3Jlc3FsL2RvY21vc3Q/c2NoZW1hPXB1YmxpYycKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnTUFJTF9EUklWRVI9JHtNQUlMX0RSSVZFUjo/fScKICAgICAgLSAnU01UUF9IT1NUPSR7U01UUF9IT1NUfScKICAgICAgLSAnU01UUF9QT1JUPSR7U01UUF9QT1JUfScKICAgICAgLSAnU01UUF9VU0VSTkFNRT0ke1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdTTVRQX1BBU1NXT1JEPSR7U01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ1NNVFBfU0VDVVJFPSR7U01UUF9TRUNVUkV9JwogICAgICAtICdNQUlMX0ZST01fQUREUkVTUz0ke01BSUxfRlJPTV9BRERSRVNTfScKICAgICAgLSAnTUFJTF9GUk9NX05BTUU9JHtNQUlMX0ZST01fTkFNRX0nCiAgICAgIC0gJ1BPU1RNQVJLX1RPS0VOPSR7UE9TVE1BUktfVE9LRU59JwogICAgdm9sdW1lczoKICAgICAgLSAnZG9jbW9zdDovYXBwL2RhdGEvc3RvcmFnZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19EQj1kb2Ntb3N0CiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjItYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAo=",
"tags": [
"documentation",
"opensource",
@@ -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"
@@ -1634,6 +1634,20 @@
"minversion": "0.0.0",
"port": "2368"
},
+ "gitea-runner": {
+ "documentation": "https://github.com/go-gitea/gitea?utm_source=coolify.io",
+ "slogan": "Gitea Actions runner for docker",
+ "compose": "c2VydmljZXM6CiAgcnVubmVyOgogICAgaW1hZ2U6ICdkb2NrZXIuaW8vZ2l0ZWEvcnVubmVyOjEuMC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0dJVEVBX0lOU1RBTkNFX1VSTD0ke0dJVEVBX0lOU1RBTkNFX1VSTH0nCiAgICAgIC0gJ0dJVEVBX1JVTk5FUl9SRUdJU1RSQVRJT05fVE9LRU49JHtHSVRFQV9SVU5ORVJfUkVHSVNUUkFUSU9OX1RPS0VOfScKICAgICAgLSAnR0lURUFfUlVOTkVSX05BTUU9JHtHSVRFQV9SVU5ORVJfTkFNRTotZ2l0ZWEtcnVubmVyfScKICAgICAgLSAnR0lURUFfUlVOTkVSX0xBQkVMUz0ke0dJVEVBX1JVTk5FUl9MQUJFTFM6LXVidW50dS1sYXRlc3Q6ZG9ja2VyOi8vbm9kZToyMn0nCiAgICAgIC0gJ0dJVEVBX1RPS0VOPSR7R0lURUFfVE9LRU59JwogICAgd29ya2luZ19kaXI6IC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdydW5uZXItZGF0YTovZGF0YScKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgJ1tSXXVubmVyJyA+IC9kZXYvbnVsbCB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK",
+ "tags": [
+ "gitea",
+ "actions",
+ "runner",
+ "docker"
+ ],
+ "category": "devtools",
+ "logo": "svgs/gitea.svg",
+ "minversion": "0.0.0"
+ },
"gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io",
"slogan": "Gitea is a self-hosted, lightweight Git service, offering version control, collaboration, and code hosting.",
@@ -2000,7 +2014,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",
@@ -2587,22 +2601,6 @@
"minversion": "0.0.0",
"port": "4000"
},
- "litequeen": {
- "documentation": "https://litequeen.com/?utm_source=coolify.io",
- "slogan": "Lite Queen is an open-source SQLite database management software that runs on your server.",
- "compose": "c2VydmljZXM6CiAgbGl0ZXF1ZWVuOgogICAgaW1hZ2U6ICdraXZzZWdyb2IvbGl0ZS1xdWVlbjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTElURVFVRUVOXzgwMDAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xpdGVxdWVlbi1kYXRhOi9ob21lL2xpdGVxdWVlbi9kYXRhJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9kYXRhYmFzZXMKICAgICAgICB0YXJnZXQ6IC9zcnYKICAgICAgICBpc19kaXJlY3Rvcnk6IHRydWUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzgwMDAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMK",
- "tags": [
- "sqlite",
- "sqlite-database-management",
- "self-hosted",
- "vps",
- "database"
- ],
- "category": "database",
- "logo": "svgs/litequeen.svg",
- "minversion": "0.0.0",
- "port": "8000"
- },
"lobe-chat": {
"documentation": "https://github.com/lobehub/lobe-chat?tab=readme-ov-file#b-deploying-with-docker?utm_source=coolify.io",
"slogan": "An open-source, modern-design AI chat framework.",
@@ -3408,7 +3406,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/Api/RailpackApiTest.php b/tests/Feature/Api/RailpackApiTest.php
new file mode 100644
index 000000000..096774686
--- /dev/null
+++ b/tests/Feature/Api/RailpackApiTest.php
@@ -0,0 +1,344 @@
+ 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();
+ expect($app->dockerfile_location)->toBeNull();
+ 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/ApiTokenExpirationWarningTest.php b/tests/Feature/ApiTokenExpirationWarningTest.php
index 5255581dd..beea1f126 100644
--- a/tests/Feature/ApiTokenExpirationWarningTest.php
+++ b/tests/Feature/ApiTokenExpirationWarningTest.php
@@ -6,6 +6,7 @@
use App\Models\User;
use App\Notifications\ApiTokenExpiringNotification;
use Carbon\Carbon;
+use Illuminate\Contracts\Notifications\Dispatcher;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
@@ -29,11 +30,12 @@
Notification::fake();
});
-function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken
+function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt, ?Carbon $warningSentAt = null): PersonalAccessToken
{
$plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
$token = $plain->accessToken;
$token->team_id = $team->id;
+ $token->api_token_expiration_warning_sent_at = $warningSentAt;
$token->save();
return $token->fresh();
@@ -41,14 +43,30 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): Person
describe('ApiTokenExpirationWarningJob', function () {
test('notifies team when token expires within 24h', function () {
- createTokenExpiring($this->user, $this->team, now()->addHours(23));
+ $token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
+ expect($token->fresh()->api_token_expiration_warning_sent_at)->not->toBeNull();
});
- test('rate limiter prevents duplicate warnings on repeat runs', function () {
+ test('does not mark token as warned when notification fails', function () {
+ $token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
+ $dispatcher = Mockery::mock(Dispatcher::class);
+ $dispatcher->shouldReceive('send')
+ ->once()
+ ->andThrow(new RuntimeException('Notification failed'));
+
+ $this->app->instance(Dispatcher::class, $dispatcher);
+
+ expect(fn () => (new ApiTokenExpirationWarningJob)->handle())
+ ->toThrow(RuntimeException::class, 'Notification failed');
+
+ expect($token->fresh()->api_token_expiration_warning_sent_at)->toBeNull();
+ });
+
+ test('database marker prevents duplicate warnings on repeat runs', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(12));
(new ApiTokenExpirationWarningJob)->handle();
@@ -57,6 +75,35 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): Person
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
});
+ test('database marker prevents duplicate warnings after cache is flushed', function () {
+ createTokenExpiring($this->user, $this->team, now()->addHours(12));
+
+ (new ApiTokenExpirationWarningJob)->handle();
+
+ Cache::flush();
+
+ (new ApiTokenExpirationWarningJob)->handle();
+
+ Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
+ });
+
+ test('skips tokens that already have an expiration warning marker', function () {
+ createTokenExpiring($this->user, $this->team, now()->addHours(12), now()->subHour());
+
+ (new ApiTokenExpirationWarningJob)->handle();
+
+ Notification::assertNothingSent();
+ });
+
+ test('notifies once for each unmarked expiring token', function () {
+ createTokenExpiring($this->user, $this->team, now()->addHours(12));
+ createTokenExpiring($this->user, $this->team, now()->addHours(23));
+
+ (new ApiTokenExpirationWarningJob)->handle();
+
+ Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 2);
+ });
+
test('skips tokens expiring more than 24h out', function () {
createTokenExpiring($this->user, $this->team, now()->addDays(3));
diff --git a/tests/Feature/ApplicationBuildpackCleanupTest.php b/tests/Feature/ApplicationBuildpackCleanupTest.php
index b6b535a76..0b23684ef 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,
]);
@@ -117,6 +120,87 @@
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',
+ ]);
+
+ // 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();
+
+ 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 () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
@@ -156,6 +240,27 @@
expect($application->dockerfile)->toBeNull();
});
+ test('dockerfile location defaults only for dockerfile buildpack', function () {
+ $team = Team::factory()->create();
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+
+ $nixpacksApplication = Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'build_pack' => 'nixpacks',
+ 'dockerfile_location' => null,
+ ]);
+
+ $dockerfileApplication = Application::factory()->create([
+ 'environment_id' => $environment->id,
+ 'build_pack' => 'dockerfile',
+ 'dockerfile_location' => null,
+ ]);
+
+ expect($nixpacksApplication->refresh()->dockerfile_location)->toBeNull();
+ expect($dockerfileApplication->refresh()->dockerfile_location)->toBe('/Dockerfile');
+ });
+
test('model does not trigger cleanup when build_pack is not changed', function () {
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
diff --git a/tests/Feature/ApplicationConfigurationChangedTest.php b/tests/Feature/ApplicationConfigurationChangedTest.php
new file mode 100644
index 000000000..f862f840d
--- /dev/null
+++ b/tests/Feature/ApplicationConfigurationChangedTest.php
@@ -0,0 +1,97 @@
+create();
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+
+ return Application::factory()->create(array_merge([
+ 'environment_id' => $environment->id,
+ 'status' => 'running:healthy',
+ 'build_command' => 'npm run build',
+ ], $attributes));
+}
+
+function configurationChangedDeployment(Application $application): ApplicationDeploymentQueue
+{
+ return ApplicationDeploymentQueue::create([
+ 'application_id' => (string) $application->id,
+ 'deployment_uuid' => (string) Str::uuid(),
+ 'status' => 'finished',
+ 'commit' => 'HEAD',
+ ]);
+}
+
+it('stores deployment configuration snapshot and clears pending changes', function () {
+ $application = configurationChangedTestApplication();
+ $deployment = configurationChangedDeployment($application);
+
+ $application->markDeploymentConfigurationApplied($deployment);
+
+ expect($deployment->refresh()->configuration_hash)->not->toBeNull()
+ ->and($deployment->configuration_snapshot)->toBeArray()
+ ->and($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
+});
+
+it('stores a diff between successful deployments', function () {
+ $application = configurationChangedTestApplication();
+ $firstDeployment = configurationChangedDeployment($application);
+ $application->markDeploymentConfigurationApplied($firstDeployment);
+
+ $application->update(['build_command' => 'pnpm build']);
+ $secondDeployment = configurationChangedDeployment($application->refresh());
+ $application->markDeploymentConfigurationApplied($secondDeployment);
+
+ expect($secondDeployment->refresh()->configuration_diff['count'])->toBe(1)
+ ->and(data_get($secondDeployment->configuration_diff, 'changes.0.label'))->toBe('Build command');
+});
+
+it('checks legacy preview deployment configuration hash using preview environment variable query', function () {
+ $application = configurationChangedTestApplication();
+
+ EnvironmentVariable::create([
+ 'key' => 'APP_ENV',
+ 'value' => 'preview',
+ 'is_preview' => true,
+ 'is_multiline' => false,
+ 'is_literal' => false,
+ 'is_buildtime' => true,
+ 'is_runtime' => true,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ $application->forceFill([
+ 'config_hash' => 'legacy-hash',
+ 'pull_request_id' => 123,
+ ]);
+
+ $diff = $application->pendingDeploymentConfigurationDiff();
+
+ expect($diff->isLegacyFallback())->toBeTrue()
+ ->and($diff->isChanged())->toBeTrue();
+});
+
+it('falls back to legacy configuration hash when no deployment snapshot exists', function () {
+ $application = configurationChangedTestApplication();
+ $application->isConfigurationChanged(save: true);
+
+ expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
+
+ $application->update(['build_command' => 'pnpm build']);
+
+ expect($application->refresh()->pendingDeploymentConfigurationDiff()->isLegacyFallback())->toBeTrue()
+ ->and($application->pendingDeploymentConfigurationDiff()->isChanged())->toBeTrue();
+});
diff --git a/tests/Feature/ApplicationCustomNginxConfigurationApiTest.php b/tests/Feature/ApplicationCustomNginxConfigurationApiTest.php
new file mode 100644
index 000000000..358003588
--- /dev/null
+++ b/tests/Feature/ApplicationCustomNginxConfigurationApiTest.php
@@ -0,0 +1,150 @@
+ 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' => 'custom-nginx-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 customNginxApiHeaders(string $bearerToken): array
+{
+ return [
+ 'Authorization' => 'Bearer '.$bearerToken,
+ 'Content-Type' => 'application/json',
+ ];
+}
+
+function customNginxConfig(): string
+{
+ return <<<'NGINX'
+server {
+ listen 80;
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
+NGINX;
+}
+
+function makeCustomNginxApplication(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' => 'static',
+ ], $overrides));
+}
+
+describe('PATCH /api/v1/applications/{uuid} custom_nginx_configuration', function () {
+ test('decodes base64 custom nginx configuration before storing it', function () {
+ $application = makeCustomNginxApplication();
+ $configuration = customNginxConfig();
+ $encodedConfiguration = base64_encode($configuration);
+
+ $response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$application->uuid}", [
+ 'custom_nginx_configuration' => $encodedConfiguration,
+ ]);
+
+ $response->assertOk();
+
+ $application->refresh();
+ expect($application->custom_nginx_configuration)->toBe($configuration);
+
+ $storedConfiguration = DB::table('applications')
+ ->where('id', $application->id)
+ ->value('custom_nginx_configuration');
+
+ expect($storedConfiguration)->toBe(base64_encode($configuration));
+
+ $this->withHeaders(customNginxApiHeaders($this->bearerToken))
+ ->getJson("/api/v1/applications/{$application->uuid}")
+ ->assertOk()
+ ->assertJsonPath('custom_nginx_configuration', $configuration);
+ });
+
+ test('rejects custom nginx configuration that is not base64 encoded', function () {
+ $application = makeCustomNginxApplication();
+
+ $response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$application->uuid}", [
+ 'custom_nginx_configuration' => customNginxConfig(),
+ ]);
+
+ $response->assertUnprocessable()
+ ->assertJsonPath('errors.custom_nginx_configuration', 'The custom_nginx_configuration should be base64 encoded.');
+ });
+
+ test('can clear custom nginx configuration with null', function () {
+ $application = makeCustomNginxApplication([
+ 'custom_nginx_configuration' => customNginxConfig(),
+ ]);
+
+ $response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$application->uuid}", [
+ 'custom_nginx_configuration' => null,
+ ]);
+
+ $response->assertOk();
+
+ $application->refresh();
+ expect($application->custom_nginx_configuration)->toBeNull();
+ });
+});
+
+describe('POST /api/v1/applications/public custom_nginx_configuration', function () {
+ test('decodes base64 custom nginx configuration before storing it on create', function () {
+ $configuration = customNginxConfig();
+
+ $response = $this->withHeaders(customNginxApiHeaders($this->bearerToken))
+ ->postJson('/api/v1/applications/public', [
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => $this->environment->uuid,
+ 'server_uuid' => $this->server->uuid,
+ 'git_repository' => 'https://gitlab.com/coolify/test-static-app',
+ 'git_branch' => 'main',
+ 'build_pack' => 'static',
+ 'ports_exposes' => '80',
+ 'custom_nginx_configuration' => base64_encode($configuration),
+ 'autogenerate_domain' => false,
+ ]);
+
+ $response->assertCreated();
+
+ $application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
+
+ expect($application->custom_nginx_configuration)->toBe($configuration);
+ });
+});
diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
new file mode 100644
index 000000000..d728f5ad7
--- /dev/null
+++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
@@ -0,0 +1,388 @@
+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=');
+});
+
+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/Feature/ApplicationGeneralBuildpackSelectorTest.php b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php
new file mode 100644
index 000000000..4611d61f4
--- /dev/null
+++ b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php
@@ -0,0 +1,85 @@
+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);
+});
+
+test('existing application shows railpack beta label in build pack selector', 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 (Beta)');
+});
diff --git a/tests/Feature/ApplicationPreviewImageNameTest.php b/tests/Feature/ApplicationPreviewImageNameTest.php
new file mode 100644
index 000000000..a8d3c0e9d
--- /dev/null
+++ b/tests/Feature/ApplicationPreviewImageNameTest.php
@@ -0,0 +1,126 @@
+newInstanceWithoutConstructor();
+
+ $application = new Application;
+ $application->uuid = 'preview-app';
+ $application->build_pack = 'dockerfile';
+ $application->dockerfile = null;
+ $application->docker_registry_image_name = $registryImageName;
+
+ foreach ([
+ 'application' => $application,
+ 'pull_request_id' => $pullRequestId,
+ 'commit' => $commit,
+ 'deployment_uuid' => $deploymentUuid,
+ ] as $property => $value) {
+ $reflectionProperty = $reflection->getProperty($property);
+ $reflectionProperty->setAccessible(true);
+ $reflectionProperty->setValue($job, $value);
+ }
+
+ return $job;
+}
+
+function generatePreviewImageNames(object $job): array
+{
+ $reflection = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $reflection->getMethod('generate_image_names');
+ $method->setAccessible(true);
+ $method->invoke($job);
+
+ $buildImageName = $reflection->getProperty('build_image_name');
+ $buildImageName->setAccessible(true);
+
+ $productionImageName = $reflection->getProperty('production_image_name');
+ $productionImageName->setAccessible(true);
+
+ return [
+ 'build' => $buildImageName->getValue($job),
+ 'production' => $productionImageName->getValue($job),
+ ];
+}
+
+it('includes the pull request id and commit in preview image names', function () {
+ $names = generatePreviewImageNames(makePreviewImageNameJob(
+ commit: '111222333444555666777888999000aaabbbccc1',
+ pullRequestId: 123,
+ ));
+
+ expect($names['production'])->toBe('preview-app:pr-123-111222333444555666777888999000aaabbbccc1')
+ ->and($names['build'])->toBe('preview-app:pr-123-111222333444555666777888999000aaabbbccc1-build');
+});
+
+it('generates different preview image names for different commits on the same pull request', function () {
+ $firstCommitNames = generatePreviewImageNames(makePreviewImageNameJob(
+ commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ pullRequestId: 123,
+ ));
+ $secondCommitNames = generatePreviewImageNames(makePreviewImageNameJob(
+ commit: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+ pullRequestId: 123,
+ ));
+
+ expect($firstCommitNames['production'])->not->toBe($secondCommitNames['production'])
+ ->and($firstCommitNames['build'])->not->toBe($secondCommitNames['build']);
+});
+
+it('uses the deployment uuid for preview image names when commit is HEAD', function () {
+ $firstDeploymentNames = generatePreviewImageNames(makePreviewImageNameJob(
+ commit: 'HEAD',
+ pullRequestId: 123,
+ deploymentUuid: 'deployment-one',
+ ));
+ $secondDeploymentNames = generatePreviewImageNames(makePreviewImageNameJob(
+ commit: 'HEAD',
+ pullRequestId: 123,
+ deploymentUuid: 'deployment-two',
+ ));
+
+ expect($firstDeploymentNames['production'])->toBe('preview-app:pr-123-deployment-one')
+ ->and($firstDeploymentNames['build'])->toBe('preview-app:pr-123-deployment-one-build')
+ ->and($secondDeploymentNames['production'])->toBe('preview-app:pr-123-deployment-two')
+ ->and($secondDeploymentNames['build'])->toBe('preview-app:pr-123-deployment-two-build');
+});
+
+it('uses the configured registry image name for commit-specific preview tags', function () {
+ $names = generatePreviewImageNames(makePreviewImageNameJob(
+ commit: '111222333444555666777888999000aaabbbccc1',
+ pullRequestId: 123,
+ registryImageName: 'registry.example.com/team/app',
+ ));
+
+ expect($names['production'])->toBe('registry.example.com/team/app:pr-123-111222333444555666777888999000aaabbbccc1')
+ ->and($names['build'])->toBe('registry.example.com/team/app:pr-123-111222333444555666777888999000aaabbbccc1-build');
+});
+
+it('sanitizes and truncates preview image tags to docker tag limits', function () {
+ $names = generatePreviewImageNames(makePreviewImageNameJob(
+ commit: str_repeat('feature/add dockerfile changes/', 10),
+ pullRequestId: 123,
+ ));
+
+ $productionTag = str($names['production'])->after(':')->toString();
+ $buildTag = str($names['build'])->after(':')->toString();
+
+ expect(strlen($productionTag))->toBeLessThanOrEqual(128)
+ ->and(strlen($buildTag))->toBeLessThanOrEqual(128)
+ ->and($productionTag)->toMatch('/^pr-123-[A-Za-z0-9_.-]+$/')
+ ->and($buildTag)->toMatch('/^pr-123-[A-Za-z0-9_.-]+-build$/');
+});
+
+it('keeps non-preview dockerfile image names commit based', function () {
+ $names = generatePreviewImageNames(makePreviewImageNameJob(
+ commit: '111222333444555666777888999000aaabbbccc1',
+ pullRequestId: 0,
+ ));
+
+ expect($names['production'])->toBe('preview-app:111222333444555666777888999000aaabbbccc1')
+ ->and($names['build'])->toBe('preview-app:111222333444555666777888999000aaabbbccc1-build');
+});
diff --git a/tests/Feature/ApplicationRailpackTest.php b/tests/Feature/ApplicationRailpackTest.php
new file mode 100644
index 000000000..59e8a82e0
--- /dev/null
+++ b/tests/Feature/ApplicationRailpackTest.php
@@ -0,0 +1,150 @@
+create();
+ $project = Project::factory()->create(['team_id' => $team->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' => $this->environment->id,
+ 'build_pack' => 'railpack',
+ ]);
+
+ expect($application->could_set_build_commands())->toBeTrue();
+ });
+
+ test('could_set_build_commands returns true for nixpacks', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'build_pack' => 'nixpacks',
+ ]);
+
+ expect($application->could_set_build_commands())->toBeTrue();
+ });
+
+ test('could_set_build_commands returns false for dockerfile', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'build_pack' => 'dockerfile',
+ ]);
+
+ expect($application->could_set_build_commands())->toBeFalse();
+ });
+
+ test('railpack_environment_variables returns only RAILPACK_ prefixed vars', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->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 () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->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 () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->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/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/ApplicationSourceLocalhostKeyTest.php b/tests/Feature/ApplicationSourceLocalhostKeyTest.php
index 9b9b7b184..1a38bd26e 100644
--- a/tests/Feature/ApplicationSourceLocalhostKeyTest.php
+++ b/tests/Feature/ApplicationSourceLocalhostKeyTest.php
@@ -24,12 +24,23 @@
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
+function applicationSourceValidPrivateKey(): string
+{
+ return '-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
+hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
+AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
+uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
+-----END OPENSSH PRIVATE KEY-----';
+}
+
describe('Application Source with localhost key (id=0)', function () {
test('renders deploy key section when private_key_id is 0', function () {
$privateKey = PrivateKey::create([
'id' => 0,
'name' => 'localhost',
- 'private_key' => 'test-key-content',
+ 'private_key' => applicationSourceValidPrivateKey(),
'team_id' => $this->team->id,
]);
@@ -56,4 +67,19 @@
->assertDontSee('Deploy Key')
->assertSee('No source connected');
});
+
+ test('dispatches configuration changed when source settings are saved', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'git_repository' => 'coollabsio/coolify',
+ 'git_branch' => 'main',
+ 'git_commit_sha' => 'HEAD',
+ ]);
+
+ Livewire::test(Source::class, ['application' => $application])
+ ->set('gitBranch', 'next')
+ ->call('submit')
+ ->assertHasNoErrors()
+ ->assertDispatched('configurationChanged');
+ });
});
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,
diff --git a/tests/Feature/DeploymentsIndicatorLayoutTest.php b/tests/Feature/DeploymentsIndicatorLayoutTest.php
new file mode 100644
index 000000000..84659ec3e
--- /dev/null
+++ b/tests/Feature/DeploymentsIndicatorLayoutTest.php
@@ -0,0 +1,18 @@
+toContain('transition-[left] duration-200')
+ ->toContain(":class=\"collapsed ? 'lg:left-16' : 'lg:left-56'\"")
+ ->not->toContain('fixed bottom-0 z-60 mb-4 left-0 lg:left-56 ml-4');
+
+ expect($layoutView)
+ ->toContain('toBeLessThan(strpos($layoutView, ''));
+});
diff --git a/tests/Feature/DeprecatedDockerComposeApplicationEndpointTest.php b/tests/Feature/DeprecatedDockerComposeApplicationEndpointTest.php
new file mode 100644
index 000000000..35dff7bd4
--- /dev/null
+++ b/tests/Feature/DeprecatedDockerComposeApplicationEndpointTest.php
@@ -0,0 +1,22 @@
+getRoutes())
+ ->filter(fn ($route) => in_array('POST', $route->methods(), true))
+ ->filter(fn ($route) => $route->uri() === 'api/v1/applications/dockercompose');
+
+ expect($routes)->toBeEmpty();
+
+ $this->postJson('/api/v1/applications/dockercompose')->assertNotFound();
+});
+
+test('custom docker compose services endpoint remains registered', function () {
+ $route = collect(Route::getRoutes()->getRoutes())
+ ->first(fn ($route) => in_array('POST', $route->methods(), true) && $route->uri() === 'api/v1/services');
+
+ expect($route)->not->toBeNull()
+ ->and($route->getActionName())->toBe(ServicesController::class.'@create_service');
+});
diff --git a/tests/Feature/DevelopmentRailpackExamplesSeederTest.php b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php
new file mode 100644
index 000000000..59646a804
--- /dev/null
+++ b/tests/Feature/DevelopmentRailpackExamplesSeederTest.php
@@ -0,0 +1,141 @@
+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();
+
+ $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()
+ ->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();
+
+ 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 () {
+ 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/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/Feature/Livewire/ConfigurationCheckerTest.php b/tests/Feature/Livewire/ConfigurationCheckerTest.php
new file mode 100644
index 000000000..edf8c5044
--- /dev/null
+++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php
@@ -0,0 +1,158 @@
+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]);
+
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+function configurationCheckerApplication(Environment $environment, array $attributes = []): Application
+{
+ return Application::factory()->create(array_merge([
+ 'environment_id' => $environment->id,
+ 'status' => 'running:healthy',
+ 'build_command' => 'npm run build',
+ 'fqdn' => 'https://example.com',
+ ], $attributes));
+}
+
+function markConfigurationCheckerApplicationDeployed(Application $application): void
+{
+ $deployment = ApplicationDeploymentQueue::create([
+ 'application_id' => (string) $application->id,
+ 'deployment_uuid' => (string) Str::uuid(),
+ 'status' => 'finished',
+ 'commit' => 'HEAD',
+ ]);
+
+ $application->markDeploymentConfigurationApplied($deployment);
+}
+
+it('does not render the notification for preview deployment toggles', function () {
+ $application = configurationCheckerApplication($this->environment);
+ markConfigurationCheckerApplicationDeployed($application);
+
+ $application->settings->update(['is_preview_deployments_enabled' => true]);
+
+ Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
+ ->assertDontSee('The latest deployment is not using the current configuration')
+ ->assertSet('isConfigurationChanged', false);
+});
+
+it('renders the changed configuration labels', function () {
+ $application = configurationCheckerApplication($this->environment);
+ markConfigurationCheckerApplicationDeployed($application);
+
+ $application->update(['build_command' => 'pnpm build']);
+
+ Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
+ ->assertSee('The latest configuration has not been applied')
+ ->assertSee('Build command')
+ ->assertSee('A rebuild is required.');
+});
+
+it('refreshes configuration changes when the event is received', function () {
+ $application = configurationCheckerApplication($this->environment);
+ markConfigurationCheckerApplicationDeployed($application);
+
+ $component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
+ ->assertSet('isConfigurationChanged', false)
+ ->assertDontSee('The latest configuration has not been applied');
+
+ $application->update(['build_command' => 'pnpm build']);
+
+ $component
+ ->dispatch('configurationChanged')
+ ->assertSet('isConfigurationChanged', true)
+ ->assertSee('The latest configuration has not been applied')
+ ->assertSee('Build command');
+});
+
+it('refreshes stale modal configuration diff before opening changes', function () {
+ $application = configurationCheckerApplication($this->environment);
+ markConfigurationCheckerApplicationDeployed($application);
+
+ $application->update(['build_command' => 'pnpm build']);
+
+ $component = Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
+ ->assertSee('Build command')
+ ->assertDontSee('Start command');
+
+ $application->update([
+ 'build_command' => 'npm run build',
+ 'start_command' => 'node server.js',
+ ]);
+
+ $component
+ ->call('refreshConfigurationChanges')
+ ->assertSet('isConfigurationChanged', true)
+ ->assertSee('Start command')
+ ->assertDontSee('Build command');
+});
+
+it('does not render environment variable secret values', function () {
+ $application = configurationCheckerApplication($this->environment);
+ EnvironmentVariable::create([
+ 'key' => 'API_TOKEN',
+ 'value' => 'old-secret',
+ 'is_buildtime' => false,
+ 'is_runtime' => true,
+ 'is_preview' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+ markConfigurationCheckerApplicationDeployed($application->refresh());
+
+ $application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
+
+ Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
+ ->assertSee('API_TOKEN')
+ ->assertSee('changed')
+ ->assertSee('Set')
+ ->assertDontSee('Hidden')
+ ->assertDontSee('old-secret')
+ ->assertDontSee('new-secret');
+});
+
+it('renders added environment variables as set without exposing secret values', function () {
+ $application = configurationCheckerApplication($this->environment);
+ markConfigurationCheckerApplicationDeployed($application);
+
+ EnvironmentVariable::create([
+ 'key' => 'API_TOKEN',
+ 'value' => 'new-secret',
+ 'is_buildtime' => false,
+ 'is_runtime' => true,
+ 'is_preview' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ Livewire::test(ConfigurationChecker::class, ['resource' => $application->refresh()])
+ ->assertSee('API_TOKEN')
+ ->assertSee('From')
+ ->assertSee('Not set')
+ ->assertSee('To')
+ ->assertSee('Set')
+ ->assertDontSee('Hidden')
+ ->assertDontSee('new-secret');
+});
diff --git a/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php b/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php
new file mode 100644
index 000000000..1bc179502
--- /dev/null
+++ b/tests/Feature/Livewire/Project/Application/AdvancedStopGracePeriodTest.php
@@ -0,0 +1,108 @@
+create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+
+ return Application::create([
+ 'name' => 'stop-grace-period-test-app',
+ 'git_repository' => 'https://github.com/coollabsio/coolify',
+ 'git_branch' => 'main',
+ 'build_pack' => 'nixpacks',
+ 'ports_exposes' => '3000',
+ 'environment_id' => $environment->id,
+ 'destination_id' => $server->standaloneDockers()->firstOrFail()->id,
+ 'destination_type' => $server->standaloneDockers()->firstOrFail()->getMorphClass(),
+ ]);
+}
+
+beforeEach(function () {
+ $this->actingAs(User::factory()->create());
+});
+
+it('saves a valid stop grace period', function () {
+ $application = createApplicationForAdvancedStopGracePeriodTest();
+
+ Livewire::test(Advanced::class, ['application' => $application])
+ ->set('stopGracePeriod', '300')
+ ->call('saveStopGracePeriod')
+ ->assertHasNoErrors()
+ ->assertDispatched('success');
+
+ expect($application->settings()->first()->stop_grace_period)->toBe(300);
+});
+
+it('dispatches configuration changed when advanced settings are saved', function () {
+ $application = createApplicationForAdvancedStopGracePeriodTest();
+
+ Livewire::test(Advanced::class, ['application' => $application])
+ ->set('includeSourceCommitInBuild', true)
+ ->call('submit')
+ ->assertHasNoErrors()
+ ->assertDispatched('configurationChanged');
+});
+
+it('clears the stop grace period when submitted empty', function () {
+ $application = createApplicationForAdvancedStopGracePeriodTest();
+ $application->settings->update(['stop_grace_period' => 300]);
+
+ Livewire::test(Advanced::class, ['application' => $application->fresh()])
+ ->set('stopGracePeriod', '')
+ ->call('saveStopGracePeriod')
+ ->assertHasNoErrors()
+ ->assertDispatched('success');
+
+ expect($application->settings()->first()->stop_grace_period)->toBeNull();
+});
+
+it('rejects invalid stop grace periods', function (string $value, string $rule) {
+ $application = createApplicationForAdvancedStopGracePeriodTest();
+
+ Livewire::test(Advanced::class, ['application' => $application])
+ ->set('stopGracePeriod', $value)
+ ->call('saveStopGracePeriod')
+ ->assertHasErrors(['stopGracePeriod' => [$rule]]);
+
+ expect($application->settings()->first()->stop_grace_period)->toBeNull();
+})->with([
+ 'below minimum' => ['0', 'min'],
+ 'above maximum' => [(string) (MAX_STOP_GRACE_PERIOD_SECONDS + 1), 'max'],
+ 'malformed integer' => ['10abc', 'integer'],
+ 'decimal' => ['1.9', 'integer'],
+]);
+
+it('uses one second deployment timeout in local only when stop grace period is unset', function () {
+ config(['app.env' => 'local']);
+
+ $setting = new ApplicationSetting;
+
+ expect($setting->deploymentStopGracePeriodSeconds())->toBe(MIN_STOP_GRACE_PERIOD_SECONDS);
+
+ $setting->stop_grace_period = 10;
+
+ expect($setting->deploymentStopGracePeriodSeconds())->toBe(10);
+});
+
+it('uses default deployment timeout outside local when stop grace period is unset', function () {
+ config(['app.env' => 'production']);
+
+ $setting = new ApplicationSetting;
+
+ expect($setting->deploymentStopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
+});
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');
+ });
+});
diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php
new file mode 100644
index 000000000..34ae493cc
--- /dev/null
+++ b/tests/Feature/Mcp/McpEndpointTest.php
@@ -0,0 +1,194 @@
+where('id', 0)->delete();
+ InstanceSettings::query()->delete();
+ $settings = new InstanceSettings(['is_mcp_server_enabled' => true]);
+ $settings->id = 0;
+ $settings->save();
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+});
+
+function mcpPost(array $payload, ?string $token = null)
+{
+ $headers = [
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json, text/event-stream',
+ ];
+ if ($token) {
+ $headers['Authorization'] = 'Bearer '.$token;
+ }
+
+ return test()->withHeaders($headers)->postJson('/mcp', $payload);
+}
+
+function mcpListTools(string $token)
+{
+ return mcpPost([
+ 'jsonrpc' => '2.0',
+ 'id' => 1,
+ 'method' => 'tools/list',
+ 'params' => (object) [],
+ ], $token);
+}
+
+function mcpCallTool(string $token, string $name, array $arguments = [])
+{
+ return mcpPost([
+ 'jsonrpc' => '2.0',
+ 'id' => 1,
+ 'method' => 'tools/call',
+ 'params' => [
+ 'name' => $name,
+ 'arguments' => (object) $arguments,
+ ],
+ ], $token);
+}
+
+function mcpToolJson($response): array
+{
+ return json_decode($response->json('result.content.0.text'), true);
+}
+
+test('MCP endpoint returns 404 when the instance setting is disabled', function () {
+ InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]);
+ Once::flush();
+
+ $response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
+ $response->assertStatus(404);
+});
+
+test('MCP endpoint rejects unauthenticated requests', function () {
+ $response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
+ $response->assertStatus(401);
+});
+
+test('MCP endpoint lists tools for an authenticated token', function () {
+ $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
+
+ $response = mcpListTools($token);
+ $response->assertOk();
+
+ $toolNames = collect($response->json('result.tools'))->pluck('name')->all();
+ expect($toolNames)->toContain(
+ 'get_infrastructure_overview',
+ 'list_servers',
+ 'get_server',
+ 'list_projects',
+ 'list_applications',
+ 'get_application',
+ 'list_databases',
+ 'get_database',
+ 'list_services',
+ 'get_service',
+ );
+ expect($toolNames)->not->toContain('get_resource_status');
+});
+
+test('list_projects returns summary + pagination scoped to the token team', function () {
+ $project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]);
+
+ $otherTeam = Team::factory()->create();
+ Project::create(['name' => 'Theirs', 'team_id' => $otherTeam->id]);
+
+ $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
+
+ $response = mcpCallTool($token, 'list_projects');
+ $response->assertOk();
+
+ $body = mcpToolJson($response);
+
+ expect($body)->toHaveKey('data');
+ expect($body)->toHaveKey('_pagination');
+ expect($body['_pagination']['total'])->toBe(1);
+ expect($body['_pagination']['per_page'])->toBe(50);
+ expect($body['_pagination'])->not->toHaveKey('next');
+
+ $uuids = collect($body['data'])->pluck('uuid')->all();
+ $names = collect($body['data'])->pluck('name')->all();
+ expect($uuids)->toContain($project->uuid);
+ expect($names)->not->toContain('Theirs');
+ expect($body['data'][0])->toHaveKeys(['uuid', 'name', 'description']);
+});
+
+test('list_projects paginates with per_page cap at 100', function () {
+ for ($i = 0; $i < 3; $i++) {
+ Project::create(['name' => "P{$i}", 'team_id' => $this->team->id]);
+ }
+ $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
+
+ $response = mcpCallTool($token, 'list_projects', ['per_page' => 2, 'page' => 1]);
+ $body = mcpToolJson($response);
+
+ expect($body['_pagination']['total'])->toBe(3);
+ expect($body['_pagination']['total_pages'])->toBe(2);
+ expect($body['_pagination']['next']['args'])->toMatchArray(['page' => 2, 'per_page' => 2]);
+ expect($body['data'])->toHaveCount(2);
+
+ // Verify max cap
+ $capped = mcpCallTool($token, 'list_projects', ['per_page' => 500]);
+ $cappedBody = mcpToolJson($capped);
+ expect($cappedBody['_pagination']['per_page'])->toBe(100);
+});
+
+test('get_infrastructure_overview returns counts', function () {
+ Project::create(['name' => 'One', 'team_id' => $this->team->id]);
+ Project::create(['name' => 'Two', 'team_id' => $this->team->id]);
+
+ $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
+
+ $response = mcpCallTool($token, 'get_infrastructure_overview');
+ $response->assertOk();
+
+ $body = mcpToolJson($response);
+ expect($body)->toHaveKey('data');
+ expect($body['data'])->toHaveKeys(['coolify_version', 'servers', 'projects', 'counts']);
+ expect($body['data']['counts']['projects'])->toBe(2);
+ expect($body['data']['projects'])->toHaveCount(2);
+ expect($body['data']['projects'][0])->toHaveKey('counts');
+});
+
+test('get_server scrubs sensitive nested data and exposes connection_timeout', function () {
+ $server = Server::factory()->create(['team_id' => $this->team->id]);
+ // creating hook auto-generates a sentinel_token; bump connection_timeout
+ // via saveQuietly to avoid triggering restartSentinel.
+ $server->settings->forceFill(['connection_timeout' => 42])->saveQuietly();
+
+ $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
+
+ $response = mcpCallTool($token, 'get_server', ['uuid' => $server->uuid]);
+ $response->assertOk();
+
+ $body = mcpToolJson($response);
+ $raw = json_encode($body);
+
+ expect($raw)->not->toContain('sentinel_token');
+ expect($raw)->not->toContain('"team_id"');
+ expect($raw)->not->toContain('"private_key_id"');
+ expect($body['data']['connection_timeout'])->toBe(42);
+ expect($body['data']['uuid'])->toBe($server->uuid);
+});
+
+test('tool calls fail when the token lacks the read ability', function () {
+ $token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken;
+
+ $response = mcpCallTool($token, 'list_projects');
+ $response->assertOk();
+
+ expect($response->json('result.isError'))->toBeTrue();
+ expect($response->json('result.content.0.text'))->toContain('Missing required permissions');
+});
diff --git a/tests/Feature/Mcp/McpToggleApiTest.php b/tests/Feature/Mcp/McpToggleApiTest.php
new file mode 100644
index 000000000..68d5d335a
--- /dev/null
+++ b/tests/Feature/Mcp/McpToggleApiTest.php
@@ -0,0 +1,107 @@
+delete();
+ $settings = new InstanceSettings([
+ 'is_mcp_server_enabled' => false,
+ 'is_api_enabled' => true,
+ ]);
+ $settings->id = 0;
+ $settings->save();
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+});
+
+function makeRootMcpToken(User $user): string
+{
+ $token = $user->createToken('mcp-root', ['root']);
+ DB::table('personal_access_tokens')
+ ->where('id', $token->accessToken->id)
+ ->update(['team_id' => '0']);
+
+ return $token->plainTextToken;
+}
+
+function makeNonRootMcpToken(User $user, Team $team, array $abilities = ['write']): string
+{
+ $token = $user->createToken('mcp-write', $abilities);
+ DB::table('personal_access_tokens')
+ ->where('id', $token->accessToken->id)
+ ->update(['team_id' => (string) $team->id]);
+
+ return $token->plainTextToken;
+}
+
+test('POST /api/v1/mcp/enable enables MCP server with root token', function () {
+ $token = makeRootMcpToken($this->user);
+
+ $response = test()->withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ ])->postJson('/api/v1/mcp/enable');
+
+ $response->assertOk();
+ $response->assertJson(['message' => 'MCP server enabled.']);
+ expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
+});
+
+test('POST /api/v1/mcp/disable disables MCP server with root token', function () {
+ InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
+ $token = makeRootMcpToken($this->user);
+
+ $response = test()->withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ ])->postJson('/api/v1/mcp/disable');
+
+ $response->assertOk();
+ $response->assertJson(['message' => 'MCP server disabled.']);
+ expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
+});
+
+test('non-root token cannot enable MCP server', function () {
+ $token = makeNonRootMcpToken($this->user, $this->team);
+
+ $response = test()->withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ ])->postJson('/api/v1/mcp/enable');
+
+ $response->assertStatus(403);
+ expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse();
+});
+
+test('non-root token cannot disable MCP server', function () {
+ InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => true]);
+ $token = makeNonRootMcpToken($this->user, $this->team);
+
+ $response = test()->withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ ])->postJson('/api/v1/mcp/disable');
+
+ $response->assertStatus(403);
+ expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue();
+});
+
+test('unauthenticated request to /api/v1/mcp/enable returns 401', function () {
+ $response = test()->postJson('/api/v1/mcp/enable');
+ $response->assertStatus(401);
+});
+
+test('read-only token cannot toggle MCP server (lacks write ability)', function () {
+ $token = makeNonRootMcpToken($this->user, $this->team, ['read']);
+
+ $response = test()->withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ ])->postJson('/api/v1/mcp/enable');
+
+ $response->assertStatus(403);
+});
diff --git a/tests/Feature/ModelFillableCreationTest.php b/tests/Feature/ModelFillableCreationTest.php
index b72e7381e..e6d340a4f 100644
--- a/tests/Feature/ModelFillableCreationTest.php
+++ b/tests/Feature/ModelFillableCreationTest.php
@@ -299,6 +299,7 @@
'inject_build_args_to_dockerfile' => true,
'include_source_commit_in_build' => true,
'docker_images_to_keep' => 5,
+ 'stop_grace_period' => 300,
]);
expect($setting->exists)->toBeTrue();
@@ -309,6 +310,7 @@
expect($setting->custom_internal_name)->toBe('my-custom-app');
expect($setting->is_spa)->toBeTrue();
expect($setting->docker_images_to_keep)->toBe(5);
+ expect($setting->stop_grace_period)->toBe(300);
});
it('creates ServerSetting with all fillable attributes', function () {
diff --git a/tests/Feature/NewApplicationBuildpackDefaultsTest.php b/tests/Feature/NewApplicationBuildpackDefaultsTest.php
new file mode 100644
index 000000000..24c2a8fcf
--- /dev/null
+++ b/tests/Feature/NewApplicationBuildpackDefaultsTest.php
@@ -0,0 +1,49 @@
+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 (Beta)']);
+ });
+
+ test('deploy key repository flow shows railpack beta label in build pack selector without beta badge', function () {
+ Livewire::test(GithubPrivateRepositoryDeployKey::class, ['type' => 'private-deploy-key'])
+ ->set('current_step', 'repository')
+ ->assertSee('Railpack (Beta)');
+ });
+});
diff --git a/tests/Feature/OauthControllerTest.php b/tests/Feature/OauthControllerTest.php
new file mode 100644
index 000000000..af5fb0658
--- /dev/null
+++ b/tests/Feature/OauthControllerTest.php
@@ -0,0 +1,79 @@
+ 0,
+ 'is_registration_enabled' => false,
+ ]);
+
+ OauthSetting::create([
+ 'provider' => 'google',
+ 'client_id' => 'client-id',
+ 'client_secret' => 'client-secret',
+ 'redirect_uri' => 'https://coolify.example.com/auth/google/callback',
+ 'tenant' => 'example.com',
+ ]);
+});
+
+it('logs in an existing user when the oauth provider returns a mixed-case email', function () {
+ config()->set('app.maintenance.driver', 'file');
+
+ $user = User::factory()->create([
+ 'email' => 'username@example.edu',
+ ]);
+
+ $provider = \Mockery::mock();
+ $provider->shouldReceive('setConfig')->once()->andReturnSelf();
+ $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
+ $provider->shouldReceive('user')->once()->andReturn((object) [
+ 'email' => 'UserName@example.edu',
+ 'name' => 'Example User',
+ 'id' => 'google-user-id',
+ ]);
+
+ Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
+
+ $response = $this->get(route('auth.callback', 'google'));
+
+ $response->assertRedirect('/');
+ $this->assertAuthenticatedAs($user);
+ expect(User::count())->toBe(1);
+});
+
+it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) {
+ config()->set('app.maintenance.driver', 'file');
+ InstanceSettings::firstOrCreate([
+ 'id' => 0,
+ ], [
+ 'is_registration_enabled' => false,
+ ])->update([
+ 'is_registration_enabled' => true,
+ ]);
+
+ $provider = \Mockery::mock();
+ $provider->shouldReceive('setConfig')->once()->andReturnSelf();
+ $provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
+ $provider->shouldReceive('user')->once()->andReturn((object) [
+ 'email' => $providerEmail,
+ 'name' => 'Example User',
+ 'id' => 'google-user-id',
+ ]);
+
+ Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
+
+ $response = $this->from('/login')->get(route('auth.callback', 'google'));
+
+ $response->assertRedirect('/login');
+ expect(User::count())->toBe(0);
+})->with([
+ 'null email' => [null],
+ 'blank email' => [' '],
+]);
diff --git a/tests/Feature/QueryDatabaseByUuidWithinTeamTest.php b/tests/Feature/QueryDatabaseByUuidWithinTeamTest.php
new file mode 100644
index 000000000..db7eb16b2
--- /dev/null
+++ b/tests/Feature/QueryDatabaseByUuidWithinTeamTest.php
@@ -0,0 +1,70 @@
+teamA = Team::factory()->create();
+ $this->teamB = Team::factory()->create();
+
+ $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
+ $this->destinationA = StandaloneDocker::where('server_id', $this->serverA->id)->first();
+ $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
+ $this->envA = Environment::factory()->create(['project_id' => $this->projectA->id]);
+});
+
+test('queryDatabaseByUuidWithinTeam returns database when team owns it', function () {
+ $database = StandalonePostgresql::create([
+ 'name' => 'pg-team-a',
+ 'image' => 'postgres:15-alpine',
+ 'postgres_user' => 'postgres',
+ 'postgres_password' => 'password',
+ 'postgres_db' => 'postgres',
+ 'environment_id' => $this->envA->id,
+ 'destination_id' => $this->destinationA->id,
+ 'destination_type' => $this->destinationA->getMorphClass(),
+ ]);
+
+ $found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamA->id);
+
+ expect($found)->not->toBeNull();
+ expect($found->uuid)->toBe($database->uuid);
+ expect($found)->toBeInstanceOf(StandalonePostgresql::class);
+});
+
+test('queryDatabaseByUuidWithinTeam returns null when team does not own the database', function () {
+ $database = StandalonePostgresql::create([
+ 'name' => 'pg-team-a',
+ 'image' => 'postgres:15-alpine',
+ 'postgres_user' => 'postgres',
+ 'postgres_password' => 'password',
+ 'postgres_db' => 'postgres',
+ 'environment_id' => $this->envA->id,
+ 'destination_id' => $this->destinationA->id,
+ 'destination_type' => $this->destinationA->getMorphClass(),
+ ]);
+
+ $found = queryDatabaseByUuidWithinTeam($database->uuid, (string) $this->teamB->id);
+
+ expect($found)->toBeNull();
+});
+
+test('queryDatabaseByUuidWithinTeam returns null for unknown uuid', function () {
+ $found = queryDatabaseByUuidWithinTeam('does-not-exist', (string) $this->teamA->id);
+
+ expect($found)->toBeNull();
+});
+
+test('queryDatabaseByUuidWithinTeam can query every registered standalone database type without error', function () {
+ foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) {
+ $count = $modelClass::query()->whereUuid('non-existent-uuid')->count();
+ expect($count)->toBe(0, "{$modelClass} ({$slug}) failed whereUuid() smoke query");
+ }
+});
diff --git a/tests/Feature/QueueApplicationDeploymentCommitTest.php b/tests/Feature/QueueApplicationDeploymentCommitTest.php
new file mode 100644
index 000000000..ac6be5c9e
--- /dev/null
+++ b/tests/Feature/QueueApplicationDeploymentCommitTest.php
@@ -0,0 +1,107 @@
+team = Team::factory()->create();
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::factory()->create([
+ 'server_id' => $this->server->id,
+ 'network' => 'test-network-'.fake()->unique()->word(),
+ ]);
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+function makeApplication(int $environmentId, int $destinationId, ?string $gitCommitSha): Application
+{
+ $attributes = [
+ 'environment_id' => $environmentId,
+ 'destination_id' => $destinationId,
+ 'destination_type' => StandaloneDocker::class,
+ ];
+
+ if ($gitCommitSha !== null) {
+ $attributes['git_commit_sha'] = $gitCommitSha;
+ }
+
+ return Application::factory()->create($attributes);
+}
+
+describe('queue_application_deployment commit resolution', function () {
+ test('uses application git_commit_sha when commit parameter omitted', function () {
+ $pinnedSha = 'abc123def456abc123def456abc123def456abc1';
+ $application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
+
+ $result = queue_application_deployment(
+ application: $application,
+ deployment_uuid: 'test-deploy-uuid-1',
+ );
+
+ expect($result['status'])->toBe('queued');
+
+ $deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-1')->first();
+ expect($deployment)->not->toBeNull();
+ expect($deployment->commit)->toBe($pinnedSha);
+ });
+
+ test('falls back to HEAD when both commit parameter and git_commit_sha are unset', function () {
+ $application = makeApplication($this->environment->id, $this->destination->id, 'HEAD');
+
+ $result = queue_application_deployment(
+ application: $application,
+ deployment_uuid: 'test-deploy-uuid-2',
+ );
+
+ expect($result['status'])->toBe('queued');
+
+ $deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-2')->first();
+ expect($deployment->commit)->toBe('HEAD');
+ });
+
+ test('explicit commit parameter overrides application git_commit_sha', function () {
+ $pinnedSha = 'abc123def456abc123def456abc123def456abc1';
+ $webhookSha = '111222333444555666777888999000aaabbbccc1';
+ $application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
+
+ $result = queue_application_deployment(
+ application: $application,
+ deployment_uuid: 'test-deploy-uuid-3',
+ commit: $webhookSha,
+ );
+
+ expect($result['status'])->toBe('queued');
+
+ $deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-3')->first();
+ expect($deployment->commit)->toBe($webhookSha);
+ });
+
+ test('treats empty string commit parameter as unset and uses git_commit_sha', function () {
+ $pinnedSha = 'abc123def456abc123def456abc123def456abc1';
+ $application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
+
+ $result = queue_application_deployment(
+ application: $application,
+ deployment_uuid: 'test-deploy-uuid-4',
+ commit: '',
+ );
+
+ expect($result['status'])->toBe('queued');
+
+ $deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-4')->first();
+ expect($deployment->commit)->toBe($pinnedSha);
+ });
+});
diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php
index e8fa5ff76..ba01deca5 100644
--- a/tests/Feature/RealtimeTerminalPackagingTest.php
+++ b/tests/Feature/RealtimeTerminalPackagingTest.php
@@ -32,3 +32,75 @@
->toContain('if (!terminalDebugEnabled) {')
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
});
+
+it('configures a server-initiated WebSocket heartbeat to survive proxy idle timeouts', function () {
+ $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
+
+ expect($terminalServer)
+ ->toContain('ws.isAlive = true;')
+ ->toContain("ws.on('pong'")
+ ->toContain('ws.ping();')
+ ->toContain('ws.terminate();')
+ ->toContain('HEARTBEAT_INTERVAL_MS');
+});
+
+it('removes the keepalive short-circuit that fired when the tab was hidden', function () {
+ $terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
+
+ expect($terminalClient)
+ ->not->toContain('// Skip keepalive when document is hidden to prevent unnecessary disconnects');
+});
+
+it('uses a fast probe timeout when the tab regains visibility', function () {
+ $terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
+
+ expect($terminalClient)
+ ->toContain("'Visibility-resume timeout'");
+});
+
+it('closes idle terminal sessions after 30 minutes on the server', function () {
+ $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
+
+ expect($terminalServer)
+ ->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
+ ->toContain('lastActivityAt')
+ ->toContain("ws.send('idle-timeout');")
+ ->toContain("ws.close(1000, 'Idle timeout');");
+});
+
+it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () {
+ $terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
+
+ expect($terminalClient)
+ ->toContain("event.data === 'idle-timeout'")
+ ->toContain('Terminal closed after 30 minutes of inactivity.');
+});
+
+it('replays the last command on reconnect so the PTY respawns automatically', function () {
+ $terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
+
+ expect($terminalClient)
+ ->toContain('lastSentCommand')
+ ->toContain('Replaying last command after reconnect.')
+ ->toContain('this.lastSentCommand = null;');
+});
+
+it('buffers messages received before the realtime server finishes auth so the replay is not lost', function () {
+ $terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
+
+ expect($terminalServer)
+ ->toContain('authReady: false')
+ ->toContain('pendingMessages: []')
+ ->toContain('userSession.pendingMessages.push(message)')
+ ->toContain('userSession.authReady = true');
+});
+
+it('preserves terminal scrollback across transient reconnects', function () {
+ $terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
+
+ expect($terminalClient)
+ ->toContain('── Connection lost at')
+ ->toContain('── Reconnected at')
+ // resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback.
+ ->not->toContain("this.term.reset();\n this.term.clear();");
+});
diff --git a/tests/Feature/ScheduledTaskServerTest.php b/tests/Feature/ScheduledTaskServerTest.php
new file mode 100644
index 000000000..68a9020d0
--- /dev/null
+++ b/tests/Feature/ScheduledTaskServerTest.php
@@ -0,0 +1,66 @@
+team = Team::factory()->create();
+ $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 = $this->project->environments()->first();
+});
+
+it('returns null when neither application nor service is set', function () {
+ $task = ScheduledTask::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+
+ expect($task->server())->toBeNull();
+});
+
+it('does not throw when accessing dynamic properties on a parentless task', function () {
+ $task = ScheduledTask::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+
+ expect(fn () => $task->server())->not->toThrow(Exception::class);
+});
+
+it('resolves server via application destination', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $task = ScheduledTask::factory()->create([
+ 'application_id' => $application->id,
+ 'team_id' => $this->team->id,
+ ]);
+
+ expect($task->server()?->id)->toBe($this->server->id);
+});
+
+it('resolves server via service destination', function () {
+ $service = Service::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $task = ScheduledTask::factory()->create([
+ 'service_id' => $service->id,
+ 'team_id' => $this->team->id,
+ ]);
+
+ expect($task->server()?->id)->toBe($this->server->id);
+});
diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php
new file mode 100644
index 000000000..34e9168ec
--- /dev/null
+++ b/tests/Feature/Security/AuditLogTest.php
@@ -0,0 +1,445 @@
+create();
+ $user = User::factory()->create();
+ $team->members()->attach($user->id, ['role' => 'owner']);
+ session(['currentTeam' => $team]);
+ test()->actingAs($user);
+
+ return [$team, $user];
+}
+
+function makeAuditApiToken(User $user, Team $team, array $abilities = ['root']): string
+{
+ $token = $user->createToken('audit-test', $abilities);
+ DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
+ 'team_id' => $team->id,
+ ]);
+
+ return $token->plainTextToken;
+}
+
+function makeAuditApplication(string $repo = 'test-org/test-repo'): Application
+{
+ $team = Team::factory()->create();
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $destination = $server->standaloneDockers()->firstOrFail();
+
+ return Application::create([
+ 'name' => 'audit-test-app',
+ 'git_repository' => "https://github.com/{$repo}",
+ 'git_branch' => 'main',
+ 'build_pack' => 'nixpacks',
+ 'ports_exposes' => '3000',
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ ]);
+}
+
+describe('audit channel helper', function () {
+ test('auditLog writes structured payload to audit channel', function () {
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('info')
+ ->once()
+ ->with('test.event', Mockery::on(function ($context) {
+ return $context['event'] === 'test.event'
+ && $context['custom_field'] === 'value'
+ && array_key_exists('ip', $context)
+ && array_key_exists('user_id', $context);
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+
+ auditLog('test.event', ['custom_field' => 'value']);
+ });
+
+ test('auditLog warning level routes correctly', function () {
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('warning')->once()->with('test.failed', Mockery::any());
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+
+ auditLog('test.failed', [], 'warning');
+ });
+
+ test('auditLogWebhookFailure logs warning with provider tag', function () {
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('warning')
+ ->once()
+ ->with('webhook.github.signature_failed', Mockery::on(function ($context) {
+ return $context['reason'] === 'invalid_signature'
+ && $context['event'] === 'webhook.github.signature_failed'
+ && array_key_exists('ip', $context);
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+
+ auditLogWebhookFailure('github', 'invalid_signature', ['extra' => 'context']);
+ });
+
+ test('auditLog never includes raw secret keys in context', function () {
+ $captured = null;
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('info')
+ ->once()
+ ->with(Mockery::any(), Mockery::on(function ($context) use (&$captured) {
+ $captured = $context;
+
+ return true;
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+
+ auditLog('test.private_key.created', [
+ 'team_id' => '1',
+ 'private_key_uuid' => 'abc',
+ 'fingerprint' => 'SHA256:xyz',
+ ]);
+
+ expect($captured)->toBeArray();
+ // Helper itself never injects secret-bearing keys.
+ $disallowed = ['private_key', 'password', 'token', 'webhook_secret', 'signature', 'client_secret'];
+ foreach (array_keys($captured) as $key) {
+ expect(in_array(strtolower($key), $disallowed, true))->toBeFalse();
+ }
+ });
+});
+
+describe('webhook signature failure logging', function () {
+ test('GitHub manual webhook with bad signature logs to audit channel', function () {
+ $app = makeAuditApplication();
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('warning')
+ ->atLeast()
+ ->once()
+ ->with('webhook.github.signature_failed', Mockery::any());
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+ Log::shouldReceive('warning')->andReturnNull();
+ Log::shouldReceive('info')->andReturnNull();
+ Log::shouldReceive('error')->andReturnNull();
+
+ $payload = json_encode([
+ 'ref' => 'refs/heads/main',
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ 'after' => 'abc123',
+ 'commits' => [],
+ ]);
+
+ $response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
+ 'HTTP_X-GitHub-Event' => 'push',
+ 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Invalid signature');
+ });
+
+ test('GitLab manual webhook with bad token logs to audit channel', function () {
+ $app = makeAuditApplication();
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('warning')
+ ->atLeast()
+ ->once()
+ ->with('webhook.gitlab.signature_failed', Mockery::any());
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+ Log::shouldReceive('warning')->andReturnNull();
+ Log::shouldReceive('info')->andReturnNull();
+ Log::shouldReceive('error')->andReturnNull();
+
+ $response = $this->postJson('/webhooks/source/gitlab/events/manual', [
+ 'object_kind' => 'push',
+ 'ref' => 'refs/heads/main',
+ 'project' => ['path_with_namespace' => 'test-org/test-repo'],
+ 'after' => 'abc123',
+ 'commits' => [],
+ ], [
+ 'X-Gitlab-Token' => 'wrong-token',
+ ]);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Invalid signature');
+ });
+
+ test('Bitbucket manual webhook with malformed signature logs to audit channel', function () {
+ $app = makeAuditApplication();
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('warning')
+ ->atLeast()
+ ->once()
+ ->with('webhook.bitbucket.signature_failed', Mockery::any());
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+ Log::shouldReceive('warning')->andReturnNull();
+ Log::shouldReceive('info')->andReturnNull();
+ Log::shouldReceive('error')->andReturnNull();
+
+ $payload = json_encode([
+ 'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ ]);
+
+ $response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
+ 'HTTP_X-Event-Key' => 'repo:push',
+ 'HTTP_X-Hub-Signature' => 'sha1=anyvalue',
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Invalid signature');
+ });
+
+ test('Gitea manual webhook with bad signature logs to audit channel', function () {
+ $app = makeAuditApplication();
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('warning')
+ ->atLeast()
+ ->once()
+ ->with('webhook.gitea.signature_failed', Mockery::any());
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+ Log::shouldReceive('warning')->andReturnNull();
+ Log::shouldReceive('info')->andReturnNull();
+ Log::shouldReceive('error')->andReturnNull();
+
+ $payload = json_encode([
+ 'ref' => 'refs/heads/main',
+ 'repository' => ['full_name' => 'test-org/test-repo'],
+ 'after' => 'abc123',
+ 'commits' => [],
+ ]);
+
+ $response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
+ 'HTTP_X-Gitea-Event' => 'push',
+ 'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
+ 'CONTENT_TYPE' => 'application/json',
+ ], $payload);
+
+ $response->assertOk();
+ expect($response->getContent())->toContain('Invalid signature');
+ });
+});
+
+describe('API mutation audit logging', function () {
+ test('private key creation emits api.private_key.created audit event', function () {
+ [$team, $user] = makeAuditTeamUser();
+ $token = makeAuditApiToken($user, $team);
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('info')
+ ->atLeast()
+ ->once()
+ ->with('api.private_key.created', Mockery::on(function ($context) {
+ return $context['event'] === 'api.private_key.created'
+ && ! array_key_exists('private_key', $context);
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+ Log::shouldReceive('warning')->andReturnNull();
+ Log::shouldReceive('info')->andReturnNull();
+ Log::shouldReceive('error')->andReturnNull();
+
+ // Generate a valid OpenSSH-format private key for the test.
+ $opensshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n".
+ base64_encode(str_repeat('a', 256)).
+ "\n-----END OPENSSH PRIVATE KEY-----";
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ 'Content-Type' => 'application/json',
+ ])->postJson('/api/v1/security/keys', [
+ 'name' => 'test-key',
+ 'description' => 'audit test',
+ 'private_key' => $opensshKey,
+ ]);
+
+ // Either 201 or 422 acceptable depending on validation; the assertion above verifies log if 201.
+ expect($response->status())->toBeIn([201, 422]);
+ });
+
+ test('enable_api denial for non-root team emits warning audit event', function () {
+ [$team, $user] = makeAuditTeamUser();
+ $token = makeAuditApiToken($user, $team);
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('warning')
+ ->atLeast()
+ ->once()
+ ->with('api.instance.enable_denied', Mockery::any());
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+ Log::shouldReceive('warning')->andReturnNull();
+ Log::shouldReceive('info')->andReturnNull();
+ Log::shouldReceive('error')->andReturnNull();
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ ])->getJson('/api/v1/enable');
+
+ $response->assertStatus(403);
+ });
+
+ test('project creation emits api.project.created audit event', function () {
+ [$team, $user] = makeAuditTeamUser();
+ $token = makeAuditApiToken($user, $team);
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('info')
+ ->atLeast()
+ ->once()
+ ->with('api.project.created', Mockery::on(function ($context) {
+ return $context['event'] === 'api.project.created'
+ && ! empty($context['project_uuid'])
+ && $context['project_name'] === 'audit-project';
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+ Log::shouldReceive('warning')->andReturnNull();
+ Log::shouldReceive('info')->andReturnNull();
+ Log::shouldReceive('error')->andReturnNull();
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ 'Content-Type' => 'application/json',
+ ])->postJson('/api/v1/projects', [
+ 'name' => 'audit-project',
+ 'description' => 'audit',
+ ]);
+
+ $response->assertStatus(201);
+ });
+});
+
+describe('threat-detection audit logging (Phase 2)', function () {
+ test('missing bearer token logs api.auth.unauthenticated', function () {
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('warning')
+ ->atLeast()
+ ->once()
+ ->with('api.auth.unauthenticated', Mockery::any());
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+ Log::shouldReceive('warning')->andReturnNull();
+ Log::shouldReceive('info')->andReturnNull();
+ Log::shouldReceive('error')->andReturnNull();
+
+ $response = $this->getJson('/api/v1/projects');
+
+ $response->assertStatus(401);
+ });
+
+ test('expired bearer token logs api.auth.unauthenticated', function () {
+ [$team, $user] = makeAuditTeamUser();
+ $token = $user->createToken('expired-audit', ['read'], now()->subDay());
+ DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
+ 'team_id' => $team->id,
+ ]);
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('warning')
+ ->atLeast()
+ ->once()
+ ->with('api.auth.unauthenticated', Mockery::any());
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+ Log::shouldReceive('warning')->andReturnNull();
+ Log::shouldReceive('info')->andReturnNull();
+ Log::shouldReceive('error')->andReturnNull();
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ ])->getJson('/api/v1/projects');
+
+ $response->assertStatus(401);
+ });
+
+ test('read-only token hitting write endpoint logs api.auth.ability_denied', function () {
+ [$team, $user] = makeAuditTeamUser();
+ $readToken = makeAuditApiToken($user, $team, ['read']);
+
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('warning')
+ ->atLeast()
+ ->once()
+ ->with('api.auth.ability_denied', Mockery::on(function ($ctx) {
+ return in_array('write', $ctx['required_abilities'] ?? [], true);
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+ Log::shouldReceive('warning')->andReturnNull();
+ Log::shouldReceive('info')->andReturnNull();
+ Log::shouldReceive('error')->andReturnNull();
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$readToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson('/api/v1/projects', [
+ 'name' => 'should-fail',
+ ]);
+
+ $response->assertStatus(403);
+ });
+
+ test('sentinel push without Authorization logs token_missing', function () {
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('warning')
+ ->atLeast()
+ ->once()
+ ->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
+ return $ctx['reason'] === 'token_missing';
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+ Log::shouldReceive('warning')->andReturnNull();
+ Log::shouldReceive('info')->andReturnNull();
+ Log::shouldReceive('error')->andReturnNull();
+
+ $response = $this->postJson('/api/v1/sentinel/push', []);
+
+ $response->assertStatus(401);
+ });
+
+ test('sentinel push with un-decryptable bearer logs decrypt_failed', function () {
+ $auditChannel = Mockery::mock();
+ $auditChannel->shouldReceive('warning')
+ ->atLeast()
+ ->once()
+ ->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
+ return $ctx['reason'] === 'decrypt_failed';
+ }));
+
+ Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
+ Log::shouldReceive('warning')->andReturnNull();
+ Log::shouldReceive('info')->andReturnNull();
+ Log::shouldReceive('error')->andReturnNull();
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer not-a-valid-encrypted-payload',
+ ])->postJson('/api/v1/sentinel/push', []);
+
+ $response->assertStatus(401);
+ });
+});
diff --git a/tests/Feature/SentinelTokenValidationTest.php b/tests/Feature/SentinelTokenValidationTest.php
index 43048fcaa..14f24d03a 100644
--- a/tests/Feature/SentinelTokenValidationTest.php
+++ b/tests/Feature/SentinelTokenValidationTest.php
@@ -4,6 +4,7 @@
use App\Models\ServerSetting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
@@ -78,11 +79,73 @@
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
});
+ it('returns false for null sentinel token', function () {
+ expect(ServerSetting::isValidSentinelToken(null))->toBeFalse();
+ });
+
it('rejects the reported PoC payload', function () {
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse();
});
});
+describe('ServerSetting::ensureValidSentinelToken', function () {
+ it('regenerates empty sentinel token via ensureValidSentinelToken', function () {
+ $settings = $this->server->settings;
+ DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
+
+ $settings->refresh();
+ $token = $settings->ensureValidSentinelToken();
+
+ expect($token)->not->toBeEmpty();
+ expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
+ expect($settings->fresh()->sentinel_token)->toBe($token);
+ });
+
+ it('regenerates token when stored value cannot be decrypted', function () {
+ $settings = $this->server->settings;
+ DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => 'not-encrypted-junk']);
+
+ $settings->refresh();
+ $token = $settings->ensureValidSentinelToken();
+
+ expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
+ expect($settings->fresh()->sentinel_token)->toBe($token);
+ });
+
+ it('returns existing valid token without regenerating', function () {
+ $settings = $this->server->settings;
+ $original = $settings->sentinel_token;
+
+ $token = $settings->ensureValidSentinelToken();
+
+ expect($token)->toBe($original);
+ });
+
+ it('throws RuntimeException only when regeneration also fails', function () {
+ $settings = $this->server->settings;
+ DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
+
+ $stub = new class extends ServerSetting
+ {
+ protected $table = 'server_settings';
+
+ public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
+ {
+ DB::table('server_settings')->where('id', $this->id)->update([
+ 'sentinel_token' => encrypt('invalid token with spaces!'),
+ ]);
+
+ return '';
+ }
+ };
+ $stub->setRawAttributes($settings->fresh()->getAttributes(), true);
+ $stub->exists = true;
+
+ expect(fn () => $stub->ensureValidSentinelToken())
+ ->toThrow(RuntimeException::class, 'Sentinel token invalid after regeneration');
+ });
+});
+
describe('generated sentinel tokens are valid', function () {
it('generates tokens that pass format validation', function () {
$settings = $this->server->settings;
@@ -92,4 +155,11 @@
expect($token)->not->toBeEmpty();
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
});
+
+ it('returns the same value the cast reads back', function () {
+ $settings = $this->server->settings;
+ $returned = $settings->generateSentinelToken(save: true, ignoreEvent: true);
+
+ expect($settings->fresh()->sentinel_token)->toBe($returned);
+ });
});
diff --git a/tests/Feature/ServerConnectionTimeoutApiTest.php b/tests/Feature/ServerConnectionTimeoutApiTest.php
new file mode 100644
index 000000000..287122523
--- /dev/null
+++ b/tests/Feature/ServerConnectionTimeoutApiTest.php
@@ -0,0 +1,74 @@
+ 0, 'is_api_enabled' => true]);
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+
+ $newToken = $this->user->createToken('write-token', ['write']);
+ $newToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
+ $this->token = $newToken->plainTextToken;
+});
+
+it('PATCH updates connection_timeout via API', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->token,
+ 'Content-Type' => 'application/json',
+ ])->patchJson('/api/v1/servers/'.$this->server->uuid, [
+ 'connection_timeout' => 45,
+ ]);
+
+ $response->assertStatus(201);
+ expect($this->server->settings->fresh()->connection_timeout)->toBe(45);
+});
+
+it('PATCH rejects connection_timeout out of range', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->token,
+ 'Content-Type' => 'application/json',
+ ])->patchJson('/api/v1/servers/'.$this->server->uuid, [
+ 'connection_timeout' => 0,
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonStructure(['errors' => ['connection_timeout']]);
+});
+
+it('PATCH rejects connection_timeout above max', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->token,
+ 'Content-Type' => 'application/json',
+ ])->patchJson('/api/v1/servers/'.$this->server->uuid, [
+ 'connection_timeout' => 999,
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonStructure(['errors' => ['connection_timeout']]);
+});
+
+it('PATCH rejects non-integer connection_timeout', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->token,
+ 'Content-Type' => 'application/json',
+ ])->patchJson('/api/v1/servers/'.$this->server->uuid, [
+ 'connection_timeout' => 'fast',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonStructure(['errors' => ['connection_timeout']]);
+});
diff --git a/tests/Feature/ServerConnectionTimeoutTest.php b/tests/Feature/ServerConnectionTimeoutTest.php
new file mode 100644
index 000000000..b457f3f01
--- /dev/null
+++ b/tests/Feature/ServerConnectionTimeoutTest.php
@@ -0,0 +1,43 @@
+create();
+ $this->team = $user->teams()->first();
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+});
+
+it('defaults connection_timeout to 10 seconds for new servers', function () {
+ expect($this->server->settings->connection_timeout)->toBe(10);
+});
+
+it('persists a custom connection_timeout value', function () {
+ $this->server->settings->connection_timeout = 30;
+ $this->server->settings->save();
+
+ expect($this->server->settings->fresh()->connection_timeout)->toBe(30);
+});
+
+it('returns the per-server connection_timeout from getConnectionTimeout', function () {
+ $this->server->settings->connection_timeout = 45;
+ $this->server->settings->save();
+
+ expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe(45);
+});
+
+it('falls back to config default when connection_timeout is invalid', function () {
+ $this->server->settings->connection_timeout = 0;
+ $this->server->settings->saveQuietly();
+
+ $expected = (int) config('constants.ssh.connection_timeout');
+
+ expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe($expected);
+});
diff --git a/tests/Feature/ServerReachabilityNotificationTest.php b/tests/Feature/ServerReachabilityNotificationTest.php
new file mode 100644
index 000000000..e996ba028
--- /dev/null
+++ b/tests/Feature/ServerReachabilityNotificationTest.php
@@ -0,0 +1,105 @@
+team = Team::factory()->create();
+ $this->team->emailNotificationSettings()->update([
+ 'use_instance_email_settings' => true,
+ 'server_unreachable_email_notifications' => true,
+ 'server_reachable_email_notifications' => true,
+ ]);
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ Notification::fake();
+});
+
+it('sends Unreachable notification when threshold reached and not yet notified', function () {
+ $this->server->settings()->update(['is_reachable' => false]);
+ $this->server->forceFill([
+ 'unreachable_count' => 2,
+ 'unreachable_notification_sent' => false,
+ ])->save();
+
+ ServerReachabilityChanged::dispatch($this->server->fresh());
+
+ Notification::assertSentTo($this->team, Unreachable::class);
+ expect($this->server->fresh()->unreachable_notification_sent)->toBeTrue();
+});
+
+it('does not send Unreachable on first transient failure (count=1)', function () {
+ $this->server->settings()->update(['is_reachable' => false]);
+ $this->server->forceFill([
+ 'unreachable_count' => 1,
+ 'unreachable_notification_sent' => false,
+ ])->save();
+
+ ServerReachabilityChanged::dispatch($this->server->fresh());
+
+ Notification::assertNothingSent();
+});
+
+it('does not send Unreachable when already notified', function () {
+ $this->server->settings()->update(['is_reachable' => false]);
+ $this->server->forceFill([
+ 'unreachable_count' => 5,
+ 'unreachable_notification_sent' => true,
+ ])->save();
+
+ ServerReachabilityChanged::dispatch($this->server->fresh());
+
+ Notification::assertNothingSent();
+});
+
+it('sends Reachable notification on recovery when previously notified', function () {
+ $this->server->settings()->update(['is_reachable' => true]);
+ $this->server->forceFill([
+ 'unreachable_count' => 0,
+ 'unreachable_notification_sent' => true,
+ ])->save();
+
+ $fresh = $this->server->fresh();
+ expect($fresh->unreachable_notification_sent)->toBeTrue();
+ expect((bool) $fresh->settings->is_reachable)->toBeTrue();
+
+ ServerReachabilityChanged::dispatch($fresh);
+
+ Notification::assertSentTo($this->team, Reachable::class);
+ expect($this->server->fresh()->unreachable_notification_sent)->toBeFalse();
+});
+
+it('does not send Reachable when never notified', function () {
+ $this->server->settings()->update(['is_reachable' => true]);
+ $this->server->forceFill([
+ 'unreachable_count' => 0,
+ 'unreachable_notification_sent' => false,
+ ])->save();
+
+ ServerReachabilityChanged::dispatch($this->server->fresh());
+
+ Notification::assertNothingSent();
+});
+
+it('routes Unreachable notification through EmailChannel when email toggle is on', function () {
+ $this->server->settings()->update(['is_reachable' => false]);
+ $this->server->forceFill([
+ 'unreachable_count' => 2,
+ 'unreachable_notification_sent' => false,
+ ])->save();
+
+ ServerReachabilityChanged::dispatch($this->server->fresh());
+
+ Notification::assertSentTo($this->team, Unreachable::class, function ($notification, $channels) {
+ return in_array(EmailChannel::class, $channels);
+ });
+});
diff --git a/tests/Feature/StandaloneDockerDatabasesTest.php b/tests/Feature/StandaloneDockerDatabasesTest.php
new file mode 100644
index 000000000..8d7889149
--- /dev/null
+++ b/tests/Feature/StandaloneDockerDatabasesTest.php
@@ -0,0 +1,71 @@
+team = Team::factory()->create();
+ $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 attachDb(string $modelClass, array $extra, $destination, $environment)
+{
+ return $modelClass::create(array_merge([
+ 'name' => 'test-'.strtolower(class_basename($modelClass)),
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => $destination->getMorphClass(),
+ ], $extra));
+}
+
+test('StandaloneDocker::databases() includes attached keydb', function () {
+ attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment);
+
+ expect($this->destination->databases()->count())->toBe(1);
+ expect($this->destination->attachedTo())->toBeTrue();
+});
+
+test('StandaloneDocker::databases() includes attached dragonfly', function () {
+ attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment);
+
+ expect($this->destination->databases()->count())->toBe(1);
+ expect($this->destination->attachedTo())->toBeTrue();
+});
+
+test('StandaloneDocker::databases() includes attached clickhouse', function () {
+ attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment);
+
+ expect($this->destination->databases()->count())->toBe(1);
+ expect($this->destination->attachedTo())->toBeTrue();
+});
+
+test('StandaloneDocker::databases() includes all 8 standalone database types', function () {
+ attachDb(StandalonePostgresql::class, ['postgres_password' => 'pw'], $this->destination, $this->environment);
+ attachDb(StandaloneRedis::class, ['redis_password' => 'pw'], $this->destination, $this->environment);
+ attachDb(StandaloneMongodb::class, ['mongo_initdb_root_password' => 'pw'], $this->destination, $this->environment);
+ attachDb(StandaloneMysql::class, ['mysql_root_password' => 'pw', 'mysql_password' => 'pw'], $this->destination, $this->environment);
+ attachDb(StandaloneMariadb::class, ['mariadb_root_password' => 'pw', 'mariadb_password' => 'pw'], $this->destination, $this->environment);
+ attachDb(StandaloneKeydb::class, ['keydb_password' => 'pw'], $this->destination, $this->environment);
+ attachDb(StandaloneDragonfly::class, ['dragonfly_password' => 'pw'], $this->destination, $this->environment);
+ attachDb(StandaloneClickhouse::class, ['clickhouse_admin_password' => 'pw'], $this->destination, $this->environment);
+
+ expect($this->destination->databases()->count())->toBe(8);
+ expect($this->destination->attachedTo())->toBeTrue();
+});
diff --git a/tests/Feature/Subscription/StripeProcessJobTest.php b/tests/Feature/Subscription/StripeProcessJobTest.php
index 0a93f858c..a95e08338 100644
--- a/tests/Feature/Subscription/StripeProcessJobTest.php
+++ b/tests/Feature/Subscription/StripeProcessJobTest.php
@@ -2,10 +2,14 @@
use App\Jobs\ServerLimitCheckJob;
use App\Jobs\StripeProcessJob;
+use App\Jobs\SubscriptionInvoiceFailedJob;
+use App\Jobs\VerifyStripeSubscriptionStatusJob;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
+use App\Notifications\Internal\GeneralNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
@@ -228,3 +232,65 @@
Queue::assertNotPushed(ServerLimitCheckJob::class);
});
});
+
+describe('missing subscription Stripe webhooks are ignored', function () {
+ test('does not send internal notifications or queue follow-up jobs', function (array $event) {
+ Queue::fake();
+
+ $rootTeam = Team::factory()->create(['id' => 0]);
+ $rootTeam->discordNotificationSettings()->update(['discord_enabled' => true]);
+
+ Notification::fake();
+
+ $job = new StripeProcessJob($event);
+ $job->handle();
+
+ Notification::assertNothingSent();
+ Notification::assertNotSentTo($rootTeam, GeneralNotification::class);
+ Queue::assertNotPushed(SubscriptionInvoiceFailedJob::class);
+ Queue::assertNotPushed(VerifyStripeSubscriptionStatusJob::class);
+ })->with([
+ 'invoice paid' => [[
+ 'type' => 'invoice.paid',
+ 'data' => [
+ 'object' => [
+ 'customer' => 'cus_missing_invoice_paid',
+ 'amount_paid' => 1000,
+ 'subscription' => 'sub_missing_invoice_paid',
+ 'lines' => [
+ 'data' => [[
+ 'plan' => ['id' => 'price_dynamic_monthly'],
+ ]],
+ ],
+ ],
+ ],
+ ]],
+ 'invoice payment failed' => [[
+ 'type' => 'invoice.payment_failed',
+ 'data' => [
+ 'object' => [
+ 'customer' => 'cus_missing_invoice_payment_failed',
+ 'id' => 'in_missing_invoice_payment_failed',
+ 'payment_intent' => null,
+ ],
+ ],
+ ]],
+ 'payment intent payment failed' => [[
+ 'type' => 'payment_intent.payment_failed',
+ 'data' => [
+ 'object' => [
+ 'customer' => 'cus_missing_payment_intent_failed',
+ ],
+ ],
+ ]],
+ 'customer subscription deleted' => [[
+ 'type' => 'customer.subscription.deleted',
+ 'data' => [
+ 'object' => [
+ 'customer' => 'cus_missing_subscription_deleted',
+ 'id' => 'sub_missing_subscription_deleted',
+ ],
+ ],
+ ]],
+ ]);
+});
diff --git a/tests/Feature/SuppressHorizonJobFailuresTest.php b/tests/Feature/SuppressHorizonJobFailuresTest.php
new file mode 100644
index 000000000..ead342c31
--- /dev/null
+++ b/tests/Feature/SuppressHorizonJobFailuresTest.php
@@ -0,0 +1,65 @@
+shouldIgnoreMissing();
+ $job->shouldReceive('uuid')->andReturn($uuid);
+ $job->shouldReceive('getJobId')->andReturn($uuid);
+
+ return $job;
+}
+
+function fireJobFailed(Job $job, Throwable $exception): void
+{
+ event(new JobFailed('redis', $job, $exception));
+}
+
+beforeEach(function () {
+ config(['constants.coolify.self_hosted' => false]);
+});
+
+test('scrubs Horizon failed entry for DeploymentException on cloud', function () {
+ $uuid = 'uuid-deployment-1';
+
+ $this->mock(JobRepository::class, function (MockInterface $mock) use ($uuid) {
+ $mock->shouldReceive('deleteFailed')->once()->with($uuid);
+ });
+
+ fireJobFailed(fakeJob($uuid), new DeploymentException('build failed'));
+});
+
+test('scrubs Horizon failed entry for TimeoutExceededException on cloud', function () {
+ $uuid = 'uuid-timeout-1';
+
+ $this->mock(JobRepository::class, function (MockInterface $mock) use ($uuid) {
+ $mock->shouldReceive('deleteFailed')->once()->with($uuid);
+ });
+
+ fireJobFailed(fakeJob($uuid), new TimeoutExceededException('worker timeout'));
+});
+
+test('does not scrub generic exceptions on cloud', function () {
+ $this->mock(JobRepository::class, function (MockInterface $mock) {
+ $mock->shouldNotReceive('deleteFailed');
+ });
+
+ fireJobFailed(fakeJob('uuid-generic-1'), new RuntimeException('boom'));
+});
+
+test('does not scrub when self-hosted even for filtered exceptions', function () {
+ config(['constants.coolify.self_hosted' => true]);
+
+ $this->mock(JobRepository::class, function (MockInterface $mock) {
+ $mock->shouldNotReceive('deleteFailed');
+ });
+
+ fireJobFailed(fakeJob('uuid-deployment-2'), new DeploymentException('build failed'));
+ fireJobFailed(fakeJob('uuid-timeout-2'), new TimeoutExceededException('worker timeout'));
+});
diff --git a/tests/Unit/ApplicationDeploymentRailpackConfigTest.php b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php
new file mode 100644
index 000000000..e1449a59e
--- /dev/null
+++ b/tests/Unit/ApplicationDeploymentRailpackConfigTest.php
@@ -0,0 +1,249 @@
+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'",
+ 'force_rebuild' => false,
+ 'addHosts' => '',
+ 'secrets_hash_key' => 'testing-app-key',
+ ] 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 railpack env for install and cli flags for build/start overrides', function () {
+ [$job, $reflection] = makeRailpackDeploymentJob(
+ [
+ 'install_command' => 'npm ci',
+ 'build_command' => 'npm run build',
+ '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,
+ $reflection,
+ 'railpack_prepare_command',
+ ['.coolify/railpack.generated.json'],
+ );
+
+ expect($command)->toContain('railpack prepare');
+ 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'));
+ 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('RAILPACK_BUILD_CMD=');
+ 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',
+ ]);
+
+ $command = invokeRailpackMethod(
+ $job,
+ $reflection,
+ 'railpack_build_command',
+ [
+ 'coollabsio/coolify:test',
+ collect([
+ 'RAILPACK_NODE_VERSION' => '22',
+ 'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
+ 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
+ '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("'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
new file mode 100644
index 000000000..1dda8b8c3
--- /dev/null
+++ b/tests/Unit/ApplicationDeploymentRailpackEnvParityTest.php
@@ -0,0 +1,267 @@
+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);
+
+ $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([$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([]));
+
+ $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',
+ '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');
+});
+
+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');
+
+ $previewQuery = Mockery::mock();
+ $previewQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
+ $previewQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
+ $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([]));
+
+ $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',
+ 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
+ ]);
+});
+
+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('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')
+ ->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',
+ 'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
+ '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)->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'");
+});
diff --git a/tests/Unit/ApplicationSettingStaticCastTest.php b/tests/Unit/ApplicationSettingStaticCastTest.php
index 35ab7faaf..fe0eaec22 100644
--- a/tests/Unit/ApplicationSettingStaticCastTest.php
+++ b/tests/Unit/ApplicationSettingStaticCastTest.php
@@ -11,7 +11,7 @@
it('casts is_static to boolean when true', function () {
$setting = new ApplicationSetting;
- $setting->is_static = true;
+ $setting->setRawAttributes(['is_static' => true]);
// Verify it's cast to boolean
expect($setting->is_static)->toBeTrue()
@@ -20,7 +20,7 @@
it('casts is_static to boolean when false', function () {
$setting = new ApplicationSetting;
- $setting->is_static = false;
+ $setting->setRawAttributes(['is_static' => false]);
// Verify it's cast to boolean
expect($setting->is_static)->toBeFalse()
@@ -29,7 +29,7 @@
it('casts is_static from string "1" to boolean true', function () {
$setting = new ApplicationSetting;
- $setting->is_static = '1';
+ $setting->setRawAttributes(['is_static' => '1']);
// Should cast string to boolean
expect($setting->is_static)->toBeTrue()
@@ -38,7 +38,7 @@
it('casts is_static from string "0" to boolean false', function () {
$setting = new ApplicationSetting;
- $setting->is_static = '0';
+ $setting->setRawAttributes(['is_static' => '0']);
// Should cast string to boolean
expect($setting->is_static)->toBeFalse()
@@ -47,7 +47,7 @@
it('casts is_static from integer 1 to boolean true', function () {
$setting = new ApplicationSetting;
- $setting->is_static = 1;
+ $setting->setRawAttributes(['is_static' => 1]);
// Should cast integer to boolean
expect($setting->is_static)->toBeTrue()
@@ -56,7 +56,7 @@
it('casts is_static from integer 0 to boolean false', function () {
$setting = new ApplicationSetting;
- $setting->is_static = 0;
+ $setting->setRawAttributes(['is_static' => 0]);
// Should cast integer to boolean
expect($setting->is_static)->toBeFalse()
@@ -103,3 +103,65 @@
->and($casts[$field])->toBe('boolean');
}
});
+
+it('casts stop_grace_period to integer', function () {
+ $setting = new ApplicationSetting;
+ $casts = $setting->getCasts();
+
+ expect($casts)->toHaveKey('stop_grace_period')
+ ->and($casts['stop_grace_period'])->toBe('integer');
+});
+
+it('handles null stop_grace_period for default behavior', function () {
+ $setting = new ApplicationSetting;
+ $setting->stop_grace_period = null;
+
+ expect($setting->stop_grace_period)->toBeNull();
+});
+
+it('casts stop_grace_period from string to integer', function () {
+ $setting = new ApplicationSetting;
+ $setting->stop_grace_period = '60';
+
+ expect($setting->stop_grace_period)->toBe(60)
+ ->and($setting->stop_grace_period)->toBeInt();
+});
+
+it('casts stop_grace_period zero to integer (documents fallback trigger)', function () {
+ $setting = new ApplicationSetting;
+ $setting->stop_grace_period = 0;
+
+ expect($setting->stop_grace_period)->toBe(0)
+ ->and($setting->stop_grace_period)->toBeInt();
+});
+
+it('casts stop_grace_period negative value to integer (documents fallback trigger)', function () {
+ $setting = new ApplicationSetting;
+ $setting->stop_grace_period = -10;
+
+ expect($setting->stop_grace_period)->toBe(-10)
+ ->and($setting->stop_grace_period)->toBeInt();
+});
+
+it('resolves valid stop grace periods', function (?int $storedValue, int $expectedValue) {
+ $setting = new ApplicationSetting;
+ $setting->stop_grace_period = $storedValue;
+
+ expect($setting->stopGracePeriodSeconds())->toBe($expectedValue);
+})->with([
+ 'minimum' => [MIN_STOP_GRACE_PERIOD_SECONDS, MIN_STOP_GRACE_PERIOD_SECONDS],
+ 'custom' => [300, 300],
+ 'maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS, MAX_STOP_GRACE_PERIOD_SECONDS],
+]);
+
+it('falls back to default stop grace period for invalid stored values', function (?int $storedValue) {
+ $setting = new ApplicationSetting;
+ $setting->stop_grace_period = $storedValue;
+
+ expect($setting->stopGracePeriodSeconds())->toBe(DEFAULT_STOP_GRACE_PERIOD_SECONDS);
+})->with([
+ 'null' => [null],
+ 'zero' => [0],
+ 'negative' => [-10],
+ 'above maximum' => [MAX_STOP_GRACE_PERIOD_SECONDS + 1],
+]);
diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php
new file mode 100644
index 000000000..2106697b2
--- /dev/null
+++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php
@@ -0,0 +1,123 @@
+create();
+ $project = Project::factory()->create(['team_id' => $team->id]);
+ $environment = Environment::factory()->create(['project_id' => $project->id]);
+
+ return Application::factory()->create(array_merge([
+ 'environment_id' => $environment->id,
+ 'status' => 'running:healthy',
+ 'fqdn' => 'https://example.com',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ], $attributes));
+}
+
+function markSnapshotTestApplicationDeployed(Application $application): ApplicationDeploymentQueue
+{
+ $deployment = ApplicationDeploymentQueue::create([
+ 'application_id' => (string) $application->id,
+ 'deployment_uuid' => (string) Str::uuid(),
+ 'status' => 'finished',
+ 'commit' => 'HEAD',
+ ]);
+
+ $application->markDeploymentConfigurationApplied($deployment);
+
+ return $deployment->refresh();
+}
+
+it('does not report preview deployment toggles as pending production configuration changes', function () {
+ $application = snapshotTestApplication();
+ markSnapshotTestApplicationDeployed($application);
+
+ $application->settings->update(['is_preview_deployments_enabled' => true]);
+
+ expect($application->refresh()->pendingDeploymentConfigurationDiff()->isChanged())->toBeFalse();
+});
+
+it('detects build-impacting changes', function () {
+ $application = snapshotTestApplication();
+ markSnapshotTestApplicationDeployed($application);
+
+ $application->update(['build_command' => 'pnpm build']);
+ $diff = $application->refresh()->pendingDeploymentConfigurationDiff();
+
+ expect($diff->isChanged())->toBeTrue()
+ ->and($diff->requiresBuild())->toBeTrue()
+ ->and(collect($diff->changes())->pluck('label'))->toContain('Build command');
+});
+
+it('detects redeploy-only domain changes', function () {
+ $application = snapshotTestApplication();
+ markSnapshotTestApplicationDeployed($application);
+
+ $application->update(['fqdn' => 'https://new.example.com']);
+ $diff = $application->refresh()->pendingDeploymentConfigurationDiff();
+
+ expect($diff->isChanged())->toBeTrue()
+ ->and($diff->requiresBuild())->toBeFalse()
+ ->and(collect($diff->changes())->pluck('label'))->toContain('Domains');
+});
+
+it('detects environment variable value changes without exposing secret values', function () {
+ $application = snapshotTestApplication();
+ EnvironmentVariable::create([
+ 'key' => 'API_TOKEN',
+ 'value' => 'old-secret',
+ 'is_buildtime' => false,
+ 'is_runtime' => true,
+ 'is_preview' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+ markSnapshotTestApplicationDeployed($application->refresh());
+
+ $application->environment_variables()->where('key', 'API_TOKEN')->first()->update(['value' => 'new-secret']);
+ $diff = $application->refresh()->pendingDeploymentConfigurationDiff();
+ $change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
+
+ expect($change)->not->toBeNull()
+ ->and($change['display_summary'])->toBe('Changed')
+ ->and($change['old_display_value'])->toBe('Set')
+ ->and($change['new_display_value'])->toBe('Set')
+ ->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
+});
+
+it('describes added environment variables as set without exposing secret values', function () {
+ $application = snapshotTestApplication();
+ markSnapshotTestApplicationDeployed($application);
+
+ EnvironmentVariable::create([
+ 'key' => 'API_TOKEN',
+ 'value' => 'new-secret',
+ 'is_buildtime' => false,
+ 'is_runtime' => true,
+ 'is_preview' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ ]);
+
+ $diff = $application->refresh()->pendingDeploymentConfigurationDiff();
+ $change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
+
+ expect($change)->not->toBeNull()
+ ->and($change['display_summary'])->toBeNull()
+ ->and($change['old_display_value'])->toBe('Not set')
+ ->and($change['new_display_value'])->toBe('Set')
+ ->and(json_encode($diff->toArray()))->not->toContain('new-secret');
+});
diff --git a/tests/Unit/DetectsSkipDeployCommitsTest.php b/tests/Unit/DetectsSkipDeployCommitsTest.php
new file mode 100644
index 000000000..5255df5d6
--- /dev/null
+++ b/tests/Unit/DetectsSkipDeployCommitsTest.php
@@ -0,0 +1,115 @@
+toBeFalse();
+ });
+
+ test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) {
+ expect($harnessClass::shouldSkipDeploy([null, '', null]))->toBeFalse();
+ });
+
+ test('returns true when all messages contain [skip ci]', function () use ($harnessClass) {
+ $messages = [
+ 'Update docs [skip ci]',
+ 'Fix typo [skip ci]',
+ ];
+ expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
+ });
+
+ test('returns true when single message contains [skip cd]', function () use ($harnessClass) {
+ expect($harnessClass::shouldSkipDeploy(['Update README [skip cd]']))->toBeTrue();
+ });
+
+ test('returns true with mixed [skip ci] and [skip cd] (case-insensitive)', function () use ($harnessClass) {
+ $messages = [
+ 'Docs [SKIP CI]',
+ 'Changelog [Skip Cd]',
+ ];
+ expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
+ });
+
+ test('returns false when at least one message has no skip marker', function () use ($harnessClass) {
+ $messages = [
+ 'Update docs [skip ci]',
+ 'Actual feature change',
+ ];
+ expect($harnessClass::shouldSkipDeploy($messages))->toBeFalse();
+ });
+
+ test('returns false when single message has no skip marker', function () use ($harnessClass) {
+ expect($harnessClass::shouldSkipDeploy(['Deploy this please']))->toBeFalse();
+ });
+
+ test('null entries are filtered before evaluation', function () use ($harnessClass) {
+ $messages = [
+ null,
+ 'Docs [skip ci]',
+ null,
+ ];
+ expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
+ });
+
+ test('matches PR title scenario (single string)', function () use ($harnessClass) {
+ expect($harnessClass::shouldSkipDeploy(['chore: update readme [skip ci]']))->toBeTrue();
+ expect($harnessClass::shouldSkipDeploy(['feat: real change']))->toBeFalse();
+ expect($harnessClass::shouldSkipDeploy([null]))->toBeFalse();
+ });
+});
+
+describe('shouldSkipDeployAny (any-marker)', function () use ($harnessClass) {
+ test('returns false when messages array is empty', function () use ($harnessClass) {
+ expect($harnessClass::shouldSkipDeployAny([]))->toBeFalse();
+ });
+
+ test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) {
+ expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse();
+ });
+
+ test('returns true when any one message contains [skip ci]', function () use ($harnessClass) {
+ $messages = [
+ 'Real feature change',
+ 'docs: update readme [skip ci]',
+ ];
+ expect($harnessClass::shouldSkipDeployAny($messages))->toBeTrue();
+ });
+
+ test('returns true when any one message contains [skip cd]', function () use ($harnessClass) {
+ expect($harnessClass::shouldSkipDeployAny(['feature change', 'chore [skip cd]']))->toBeTrue();
+ });
+
+ test('returns true case-insensitively', function () use ($harnessClass) {
+ expect($harnessClass::shouldSkipDeployAny(['feat: docs [SKIP CI]']))->toBeTrue();
+ expect($harnessClass::shouldSkipDeployAny(['feat: docs [Skip Cd]']))->toBeTrue();
+ });
+
+ test('returns false when no message contains a skip marker', function () use ($harnessClass) {
+ $messages = [
+ 'feat: add new endpoint',
+ 'fix: handle edge case',
+ ];
+ expect($harnessClass::shouldSkipDeployAny($messages))->toBeFalse();
+ });
+
+ test('null and empty entries are skipped, real markers still match', function () use ($harnessClass) {
+ expect($harnessClass::shouldSkipDeployAny([null, '', 'docs [skip ci]', null]))->toBeTrue();
+ expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse();
+ });
+
+ test('PR title alone with skip marker triggers skip', function () use ($harnessClass) {
+ expect($harnessClass::shouldSkipDeployAny(['chore: update readme [skip ci]']))->toBeTrue();
+ });
+
+ test('PR title without skip marker but commit message with skip marker triggers skip', function () use ($harnessClass) {
+ expect($harnessClass::shouldSkipDeployAny(['feat: real change', 'wip [skip cd]']))->toBeTrue();
+ });
+});
diff --git a/tests/Unit/DocmostMailDriverTemplateTest.php b/tests/Unit/DocmostMailDriverTemplateTest.php
new file mode 100644
index 000000000..3512017ef
--- /dev/null
+++ b/tests/Unit/DocmostMailDriverTemplateTest.php
@@ -0,0 +1,23 @@
+toContain('MAIL_DRIVER=${MAIL_DRIVER:?}')
+ ->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}');
+
+ foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) {
+ $templates = json_decode(
+ file_get_contents(__DIR__."/../../templates/{$templateFile}"),
+ associative: true,
+ flags: JSON_THROW_ON_ERROR,
+ );
+
+ $generatedCompose = base64_decode($templates['docmost']['compose'], strict: true);
+
+ expect($generatedCompose)
+ ->toContain('MAIL_DRIVER=${MAIL_DRIVER:?}')
+ ->not->toContain('MAIL_DRIVER=${MAIL_DRIVER}');
+ }
+});
diff --git a/tests/Unit/EnvironmentVariableBuildpackControlTest.php b/tests/Unit/EnvironmentVariableBuildpackControlTest.php
new file mode 100644
index 000000000..c24956c0d
--- /dev/null
+++ b/tests/Unit/EnvironmentVariableBuildpackControlTest.php
@@ -0,0 +1,74 @@
+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');
+});
+
+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/GenerateEnvValueTest.php b/tests/Unit/GenerateEnvValueTest.php
new file mode 100644
index 000000000..7e7755f4d
--- /dev/null
+++ b/tests/Unit/GenerateEnvValueTest.php
@@ -0,0 +1,29 @@
+toBeString()
+ ->toMatch('/^[0-9a-f]+$/');
+
+ expect(strlen($value))->toBe($expectedLength);
+})->with([
+ 'HEX_32' => ['HEX_32', 32],
+ 'HEX_64' => ['HEX_64', 64],
+ 'HEX_128' => ['HEX_128', 128],
+]);
+
+test('real base64 magic variables generate valid base64 strings from expected byte lengths', function (string $command, int $expectedBytes) {
+ $value = generateEnvValue($command);
+ $decodedValue = base64_decode($value, true);
+
+ expect($value)->toBeString();
+ expect($decodedValue)->not->toBeFalse();
+ expect(strlen($decodedValue))->toBe($expectedBytes);
+})->with([
+ 'REALBASE64' => ['REALBASE64', 32],
+ 'REALBASE64_32' => ['REALBASE64_32', 32],
+ 'REALBASE64_64' => ['REALBASE64_64', 64],
+ 'REALBASE64_128' => ['REALBASE64_128', 128],
+]);
diff --git a/tests/Unit/IsReachableChangedTest.php b/tests/Unit/IsReachableChangedTest.php
new file mode 100644
index 000000000..76f9863bf
--- /dev/null
+++ b/tests/Unit/IsReachableChangedTest.php
@@ -0,0 +1,61 @@
+is_reachable = $isReachable;
+
+ $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
+ $server->shouldReceive('refresh')->andReturnSelf();
+ $server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
+ $server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn($notificationSent);
+ $server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
+
+ return $server;
+}
+
+it('sends Reachable notification when reachable and notification was previously sent', function () {
+ $server = makeServerForReachabilityTest(isReachable: true, notificationSent: true, unreachableCount: 0);
+ $server->shouldReceive('sendReachableNotification')->once();
+ $server->shouldNotReceive('sendUnreachableNotification');
+
+ $server->isReachableChanged();
+});
+
+it('does not send any notification when reachable and notification was never sent', function () {
+ $server = makeServerForReachabilityTest(isReachable: true, notificationSent: false, unreachableCount: 0);
+ $server->shouldNotReceive('sendReachableNotification');
+ $server->shouldNotReceive('sendUnreachableNotification');
+
+ $server->isReachableChanged();
+});
+
+it('sends Unreachable notification when count >= 2 and not yet notified', function () {
+ $server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 2);
+ $server->shouldReceive('sendUnreachableNotification')->once();
+ $server->shouldNotReceive('sendReachableNotification');
+
+ $server->isReachableChanged();
+});
+
+it('does not send Unreachable notification on first transient failure (count=1)', function () {
+ $server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 1);
+ $server->shouldNotReceive('sendUnreachableNotification');
+ $server->shouldNotReceive('sendReachableNotification');
+
+ $server->isReachableChanged();
+});
+
+it('does not double-send Unreachable when already notified', function () {
+ $server = makeServerForReachabilityTest(isReachable: false, notificationSent: true, unreachableCount: 5);
+ $server->shouldNotReceive('sendUnreachableNotification');
+ $server->shouldNotReceive('sendReachableNotification');
+
+ $server->isReachableChanged();
+});
diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php
new file mode 100644
index 000000000..1fd315884
--- /dev/null
+++ b/tests/Unit/LocalFileVolumeContentSizeTest.php
@@ -0,0 +1,66 @@
+toBe(5_242_880);
+});
+
+it('exposes binary and too-large placeholder constants', function () {
+ expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]');
+ expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]');
+});
+
+it('flags is_too_large when content matches the placeholder', function () {
+ $volume = new LocalFileVolume;
+ $volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
+
+ expect($volume->is_too_large)->toBeTrue();
+ expect($volume->is_binary)->toBeFalse();
+});
+
+it('flags is_binary when content matches the placeholder', function () {
+ $volume = new LocalFileVolume;
+ $volume->content = LocalFileVolume::BINARY_PLACEHOLDER;
+
+ expect($volume->is_binary)->toBeTrue();
+ expect($volume->is_too_large)->toBeFalse();
+});
+
+it('does not flag normal content as binary or too large', function () {
+ $volume = new LocalFileVolume;
+ $volume->content = "hello\nworld\n";
+
+ expect($volume->is_binary)->toBeFalse();
+ expect($volume->is_too_large)->toBeFalse();
+});
+
+it('does not flag empty content as binary or too large', function () {
+ $volume = new LocalFileVolume;
+ $volume->content = null;
+
+ expect($volume->is_binary)->toBeFalse();
+ expect($volume->is_too_large)->toBeFalse();
+});
+
+it('exposes the too-large flag via toArray for Livewire serialization', function () {
+ $volume = new LocalFileVolume;
+ $volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
+
+ $array = $volume->toArray();
+
+ expect($array)->toHaveKey('is_too_large');
+ expect($array['is_too_large'])->toBeTrue();
+});
diff --git a/tests/Unit/ServerBackoffTest.php b/tests/Unit/ServerBackoffTest.php
index bdcefb74f..9f1f747d4 100644
--- a/tests/Unit/ServerBackoffTest.php
+++ b/tests/Unit/ServerBackoffTest.php
@@ -1,11 +1,13 @@
is_reachable = true;
$settings->shouldReceive('update')
->with(['is_reachable' => false, 'is_usable' => false])
->once();
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
+ $server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false);
$server->shouldReceive('increment')->with('unreachable_count')->once();
$server->id = 1;
$server->name = 'test-server';
+ $server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is
$job = new ServerConnectionCheckJob($server);
$job->failed(new TimeoutExceededException);
@@ -152,6 +159,50 @@
});
});
+describe('ServerConnectionCheckJob ServerReachabilityChanged dispatch', function () {
+ // ServerReachabilityChanged's constructor calls $server->isReachableChanged() — verifying that
+ // call is a clean proxy for "the event was dispatched", and avoids serializing a Mockery proxy
+ // through the event dispatcher (which trips Eloquent static method lookups on the proxy class).
+ $invoke = function (bool $wasReachable, bool $wasNotified, bool $isReachable, int $unreachableCount, bool $expectDispatch) {
+ $server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
+ $server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
+ $server->shouldReceive('getAttribute')->with('id')->andReturn(1);
+ if ($expectDispatch) {
+ $server->shouldReceive('isReachableChanged')->once()->andReturnNull();
+ } else {
+ $server->shouldNotReceive('isReachableChanged');
+ }
+
+ $job = new ServerConnectionCheckJob($server);
+ $method = new ReflectionMethod($job, 'dispatchReachabilityChangedIfNeeded');
+ $method->invoke($job, $wasReachable, $wasNotified, $isReachable);
+ };
+
+ it('dispatches event when count crosses unreachable threshold', function () use ($invoke) {
+ $invoke(true, false, false, 2, true);
+ });
+
+ it('does not dispatch on first transient failure (count=1)', function () use ($invoke) {
+ $invoke(true, false, false, 1, false);
+ });
+
+ it('does not dispatch when already notified and still unreachable', function () use ($invoke) {
+ $invoke(false, true, false, 5, false);
+ });
+
+ it('dispatches recovery event when previously unreachable', function () use ($invoke) {
+ $invoke(false, false, true, 0, true);
+ });
+
+ it('dispatches recovery event when previously notified', function () use ($invoke) {
+ $invoke(true, true, true, 0, true);
+ });
+
+ it('does not dispatch when consistently reachable and never notified', function () use ($invoke) {
+ $invoke(true, false, true, 0, false);
+ });
+});
+
describe('ServerCheckJob unreachable_count', function () {
it('increments unreachable_count on timeout', function () {
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
diff --git a/tests/Unit/StandaloneDatabaseRegistryTest.php b/tests/Unit/StandaloneDatabaseRegistryTest.php
new file mode 100644
index 000000000..7c56d5f8d
--- /dev/null
+++ b/tests/Unit/StandaloneDatabaseRegistryTest.php
@@ -0,0 +1,45 @@
+not->toBeEmpty();
+
+ $onDisk = collect($files)
+ ->map(fn (string $path) => 'App\\Models\\'.basename($path, '.php'))
+ ->reject(fn (string $class) => $class === StandaloneDocker::class)
+ ->sort()
+ ->values()
+ ->all();
+
+ $registered = collect(STANDALONE_DATABASE_MODELS)->values()->sort()->values()->all();
+
+ expect($registered)->toBe(
+ $onDisk,
+ 'STANDALONE_DATABASE_MODELS in bootstrap/helpers/constants.php is out of sync with the App\\Models\\Standalone* classes on disk. '
+ .'Add the missing model(s) to the registry (and to DATABASE_TYPES) so MCP/API helpers can resolve them.'
+ );
+});
+
+test('STANDALONE_DATABASE_MODELS keys mirror DATABASE_TYPES', function () {
+ expect(array_keys(STANDALONE_DATABASE_MODELS))->toEqualCanonicalizing(DATABASE_TYPES);
+});
+
+test('every STANDALONE_DATABASE_MODELS entry is an Eloquent model with whereUuid scope', function () {
+ foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) {
+ expect(class_exists($modelClass))->toBeTrue("{$slug} maps to non-existent class {$modelClass}");
+ expect(is_subclass_of($modelClass, Model::class))
+ ->toBeTrue("{$modelClass} is not an Eloquent model");
+ expect(method_exists($modelClass, 'team'))
+ ->toBeTrue("{$modelClass} is missing team() accessor required by queryDatabaseByUuidWithinTeam()");
+ }
+});
diff --git a/tests/Unit/ValidationPatternsTest.php b/tests/Unit/ValidationPatternsTest.php
index 9ecffe46d..a959b18d5 100644
--- a/tests/Unit/ValidationPatternsTest.php
+++ b/tests/Unit/ValidationPatternsTest.php
@@ -1,5 +1,6 @@
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');
+});
+
+it('normalizes environment variable keys before model validation', function () {
+ $environmentVariable = new EnvironmentVariable;
+ $environmentVariable->key = ' APP_ENV ';
+
+ expect($environmentVariable->key)->toBe('APP_ENV');
+});
diff --git a/versions.json b/versions.json
index 3307b7f2e..b40eafe2c 100644
--- a/versions.json
+++ b/versions.json
@@ -1,16 +1,16 @@
{
"coolify": {
"v4": {
- "version": "4.0.0"
+ "version": "4.1.0"
},
"nightly": {
"version": "4.0.0"
},
"helper": {
- "version": "1.0.13"
+ "version": "1.0.14"
},
"realtime": {
- "version": "1.0.13"
+ "version": "1.0.15"
},
"sentinel": {
"version": "0.0.21"
diff --git a/vite.config.js b/vite.config.js
index fc739c95d..6c706d272 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -4,6 +4,8 @@ import vue from "@vitejs/plugin-vue";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
+ const viteHost = env.VITE_HOST || null;
+ const vitePort = Number(env.VITE_PORT || 5173);
return {
server: {
@@ -14,9 +16,20 @@ export default defineConfig(({ mode }) => {
],
},
host: "0.0.0.0",
- hmr: {
- host: env.VITE_HOST || '0.0.0.0'
+ allowedHosts: true,
+ cors: {
+ origin: [
+ /^https?:\/\/localhost(:\d+)?$/,
+ /^https?:\/\/127\.0\.0\.1(:\d+)?$/,
+ /^https?:\/\/\[::1\](:\d+)?$/,
+ ...(env.APP_URL ? [env.APP_URL] : []),
+ ...(viteHost ? [`http://${viteHost}:${vitePort}`, `https://${viteHost}:${vitePort}`] : []),
+ ],
},
+ origin: viteHost ? `http://${viteHost}:${vitePort}` : undefined,
+ hmr: viteHost
+ ? { host: viteHost, clientPort: vitePort }
+ : true,
},
plugins: [
laravel({