diff --git a/resources/views/livewire/project/shared/scheduled-task/all.blade.php b/resources/views/livewire/project/shared/scheduled-task/all.blade.php
index 74b962edb6..54b0e5b4a2 100644
--- a/resources/views/livewire/project/shared/scheduled-task/all.blade.php
+++ b/resources/views/livewire/project/shared/scheduled-task/all.blade.php
@@ -52,6 +52,9 @@
{{ $task->name }}
+ @unless ($task->enabled)
+
+ @endunless
@if ($task->container)
{{ $task->container }}
From 6c650dd12734c4a2434b2cacda63b9809f937ab4 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 2 Nov 2026 14:33:29 +0100
Subject: [PATCH 11/18] fix(api): document request bodies for shared env create
and update endpoints
Closes #12395
Co-Authored-By: Claude Opus 5.5
---
.../SharedEnvironmentVariablesController.php | 48 +++--
openapi.json | 168 ++++++++++++++----
openapi.yaml | 105 +++++++++--
.../SharedEnvironmentVariablesOpenApiTest.php | 37 ++++
4 files changed, 296 insertions(+), 62 deletions(-)
create mode 100644 tests/Unit/SharedEnvironmentVariablesOpenApiTest.php
diff --git a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php
index 32145f4ec3..61289e3c0a 100644
--- a/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php
+++ b/app/Http/Controllers/Api/SharedEnvironmentVariablesController.php
@@ -12,6 +12,32 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
+#[OA\Schema(
+ schema: 'SharedEnvironmentVariableCreateRequest',
+ type: 'object',
+ required: ['key'],
+ properties: [
+ new OA\Property(property: 'key', type: 'string'),
+ new OA\Property(property: 'value', type: 'string', nullable: true),
+ new OA\Property(property: 'is_literal', type: 'boolean'),
+ new OA\Property(property: 'is_multiline', type: 'boolean'),
+ new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'),
+ new OA\Property(property: 'comment', type: 'string', maxLength: 256, nullable: true),
+ ],
+)]
+#[OA\Schema(
+ schema: 'SharedEnvironmentVariableUpdateRequest',
+ description: 'Send at least one field.',
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'key', type: 'string'),
+ new OA\Property(property: 'value', type: 'string', nullable: true),
+ new OA\Property(property: 'is_literal', type: 'boolean'),
+ new OA\Property(property: 'is_multiline', type: 'boolean'),
+ new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'),
+ new OA\Property(property: 'comment', type: 'string', maxLength: 256, nullable: true),
+ ],
+)]
class SharedEnvironmentVariablesController extends Controller
{
private const ALLOWED_FIELDS = ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment'];
@@ -347,20 +373,7 @@ class SharedEnvironmentVariablesController extends Controller
operationId: 'create-team-shared-env',
security: [['bearerAuth' => []]],
tags: ['Shared Environment Variables'],
- requestBody: new OA\RequestBody(
- required: true,
- content: new OA\JsonContent(
- required: ['key'],
- properties: [
- new OA\Property(property: 'key', type: 'string'),
- new OA\Property(property: 'value', type: 'string', nullable: true),
- new OA\Property(property: 'is_literal', type: 'boolean'),
- new OA\Property(property: 'is_multiline', type: 'boolean'),
- new OA\Property(property: 'is_shown_once', type: 'boolean', description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'),
- new OA\Property(property: 'comment', type: 'string', nullable: true),
- ],
- ),
- ),
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')),
responses: [
new OA\Response(response: 201, description: 'Environment variable created.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -388,6 +401,7 @@ class SharedEnvironmentVariablesController extends Controller
parameters: [
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')),
responses: [
new OA\Response(response: 200, description: 'Environment variable updated.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -476,6 +490,7 @@ class SharedEnvironmentVariablesController extends Controller
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')),
responses: [
new OA\Response(response: 201, description: 'Environment variable created.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -512,6 +527,7 @@ class SharedEnvironmentVariablesController extends Controller
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')),
responses: [
new OA\Response(response: 200, description: 'Environment variable updated.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -633,6 +649,7 @@ class SharedEnvironmentVariablesController extends Controller
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Project UUID', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')),
responses: [
new OA\Response(response: 201, description: 'Environment variable created.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -675,6 +692,7 @@ class SharedEnvironmentVariablesController extends Controller
new OA\Parameter(name: 'environment_name_or_uuid', in: 'path', required: true, description: 'Environment name or UUID', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')),
responses: [
new OA\Response(response: 200, description: 'Environment variable updated.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -800,6 +818,7 @@ class SharedEnvironmentVariablesController extends Controller
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest')),
responses: [
new OA\Response(response: 201, description: 'Environment variable created.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
@@ -836,6 +855,7 @@ class SharedEnvironmentVariablesController extends Controller
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'env_id', in: 'path', required: true, description: 'Shared env id (integer).', schema: new OA\Schema(type: 'integer')),
],
+ requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest')),
responses: [
new OA\Response(response: 200, description: 'Environment variable updated.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
diff --git a/openapi.json b/openapi.json
index 53f3325211..e77c49af10 100644
--- a/openapi.json
+++ b/openapi.json
@@ -22230,37 +22230,7 @@
"content": {
"application\/json": {
"schema": {
- "required": [
- "key"
- ],
- "properties": {
- "key": {
- "type": "string"
- },
- "value": {
- "type": [
- "string",
- "null"
- ]
- },
- "is_literal": {
- "type": "boolean"
- },
- "is_multiline": {
- "type": "boolean"
- },
- "is_shown_once": {
- "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.",
- "type": "boolean"
- },
- "comment": {
- "type": [
- "string",
- "null"
- ]
- }
- },
- "type": "object"
+ "$ref": "#\/components\/schemas\/SharedEnvironmentVariableCreateRequest"
}
}
}
@@ -22340,6 +22310,16 @@
}
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/SharedEnvironmentVariableUpdateRequest"
+ }
+ }
+ }
+ },
"responses": {
"200": {
"description": "Environment variable updated."
@@ -22415,6 +22395,16 @@
}
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/SharedEnvironmentVariableCreateRequest"
+ }
+ }
+ }
+ },
"responses": {
"201": {
"description": "Environment variable created."
@@ -22511,6 +22501,16 @@
}
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/SharedEnvironmentVariableUpdateRequest"
+ }
+ }
+ }
+ },
"responses": {
"200": {
"description": "Environment variable updated."
@@ -22604,6 +22604,16 @@
}
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/SharedEnvironmentVariableCreateRequest"
+ }
+ }
+ }
+ },
"responses": {
"201": {
"description": "Environment variable created."
@@ -22718,6 +22728,16 @@
}
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/SharedEnvironmentVariableUpdateRequest"
+ }
+ }
+ }
+ },
"responses": {
"200": {
"description": "Environment variable updated."
@@ -22793,6 +22813,16 @@
}
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/SharedEnvironmentVariableCreateRequest"
+ }
+ }
+ }
+ },
"responses": {
"201": {
"description": "Environment variable created."
@@ -22889,6 +22919,16 @@
}
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "$ref": "#\/components\/schemas\/SharedEnvironmentVariableUpdateRequest"
+ }
+ }
+ }
+ },
"responses": {
"200": {
"description": "Environment variable updated."
@@ -24732,6 +24772,72 @@
},
"type": "object"
},
+ "SharedEnvironmentVariableCreateRequest": {
+ "required": [
+ "key"
+ ],
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "is_literal": {
+ "type": "boolean"
+ },
+ "is_multiline": {
+ "type": "boolean"
+ },
+ "is_shown_once": {
+ "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.",
+ "type": "boolean"
+ },
+ "comment": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 256
+ }
+ },
+ "type": "object"
+ },
+ "SharedEnvironmentVariableUpdateRequest": {
+ "description": "Send at least one field.",
+ "properties": {
+ "key": {
+ "type": "string"
+ },
+ "value": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "is_literal": {
+ "type": "boolean"
+ },
+ "is_multiline": {
+ "type": "boolean"
+ },
+ "is_shown_once": {
+ "description": "If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.",
+ "type": "boolean"
+ },
+ "comment": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 256
+ }
+ },
+ "type": "object"
+ },
"VolumeBackupScheduleRequest": {
"required": [
"frequency"
diff --git a/openapi.yaml b/openapi.yaml
index 743cf4b7ee..c5ec7a7029 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -14008,23 +14008,7 @@ paths:
content:
application/json:
schema:
- required:
- - key
- properties:
- key:
- type: string
- value:
- type: [string, 'null']
- is_literal:
- type: boolean
- is_multiline:
- type: boolean
- is_shown_once:
- description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'
- type: boolean
- comment:
- type: [string, 'null']
- type: object
+ $ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest'
responses:
'201':
description: 'Environment variable created.'
@@ -14076,6 +14060,12 @@ paths:
required: true
schema:
type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest'
responses:
'200':
description: 'Environment variable updated.'
@@ -14127,6 +14117,12 @@ paths:
required: true
schema:
type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest'
responses:
'201':
description: 'Environment variable created.'
@@ -14194,6 +14190,12 @@ paths:
required: true
schema:
type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest'
responses:
'200':
description: 'Environment variable updated.'
@@ -14259,6 +14261,12 @@ paths:
required: true
schema:
type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest'
responses:
'201':
description: 'Environment variable created.'
@@ -14340,6 +14348,12 @@ paths:
required: true
schema:
type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest'
responses:
'200':
description: 'Environment variable updated.'
@@ -14391,6 +14405,12 @@ paths:
required: true
schema:
type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SharedEnvironmentVariableCreateRequest'
responses:
'201':
description: 'Environment variable created.'
@@ -14458,6 +14478,12 @@ paths:
required: true
schema:
type: integer
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SharedEnvironmentVariableUpdateRequest'
responses:
'200':
description: 'Environment variable updated.'
@@ -15702,6 +15728,51 @@ components:
preview:
$ref: '#/components/schemas/ApplicationPreview'
type: object
+ SharedEnvironmentVariableCreateRequest:
+ required:
+ - key
+ properties:
+ key:
+ type: string
+ value:
+ type:
+ - string
+ - 'null'
+ is_literal:
+ type: boolean
+ is_multiline:
+ type: boolean
+ is_shown_once:
+ description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'
+ type: boolean
+ comment:
+ type:
+ - string
+ - 'null'
+ maxLength: 256
+ type: object
+ SharedEnvironmentVariableUpdateRequest:
+ description: 'Send at least one field.'
+ properties:
+ key:
+ type: string
+ value:
+ type:
+ - string
+ - 'null'
+ is_literal:
+ type: boolean
+ is_multiline:
+ type: boolean
+ is_shown_once:
+ description: 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'
+ type: boolean
+ comment:
+ type:
+ - string
+ - 'null'
+ maxLength: 256
+ type: object
VolumeBackupScheduleRequest:
required:
- frequency
diff --git a/tests/Unit/SharedEnvironmentVariablesOpenApiTest.php b/tests/Unit/SharedEnvironmentVariablesOpenApiTest.php
new file mode 100644
index 0000000000..1ed2a710af
--- /dev/null
+++ b/tests/Unit/SharedEnvironmentVariablesOpenApiTest.php
@@ -0,0 +1,37 @@
+toBeTrue()
+ ->and($operation['requestBody']['content']['application/json']['schema']['$ref'] ?? null)
+ ->toBe("#/components/schemas/{$schemaName}");
+})->with([
+ ['/team/envs', 'post', 'SharedEnvironmentVariableCreateRequest'],
+ ['/team/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'],
+ ['/projects/{uuid}/envs', 'post', 'SharedEnvironmentVariableCreateRequest'],
+ ['/projects/{uuid}/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'],
+ ['/projects/{uuid}/environments/{environment_name_or_uuid}/envs', 'post', 'SharedEnvironmentVariableCreateRequest'],
+ ['/projects/{uuid}/environments/{environment_name_or_uuid}/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'],
+ ['/servers/{uuid}/envs', 'post', 'SharedEnvironmentVariableCreateRequest'],
+ ['/servers/{uuid}/envs/{env_id}', 'patch', 'SharedEnvironmentVariableUpdateRequest'],
+]);
+
+it('documents the fields accepted by the shared env validator', function () {
+ $schemas = sharedEnvOpenApiSpec()['components']['schemas'];
+ $fields = ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment'];
+
+ expect(array_keys($schemas['SharedEnvironmentVariableCreateRequest']['properties']))->toBe($fields)
+ ->and($schemas['SharedEnvironmentVariableCreateRequest']['required'])->toBe(['key'])
+ ->and(array_keys($schemas['SharedEnvironmentVariableUpdateRequest']['properties']))->toBe($fields)
+ ->and($schemas['SharedEnvironmentVariableUpdateRequest'])->not->toHaveKey('required');
+});
From dce02411a032567f71addf8fef252822c6f2c493 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 2 Nov 2026 14:38:21 +0100
Subject: [PATCH 12/18] fix(service): keep WordPress URLs in sync with the
Coolify domain
Define WP_HOME and WP_SITEURL from SERVICE_URL_WORDPRESS in the MariaDB
and MySQL templates, so a domain change no longer makes the login
redirect to the domain used at install time.
Closes #12406
Co-Authored-By: Claude Opus 5.5
---
templates/compose/wordpress-with-mariadb.yaml | 1 +
templates/compose/wordpress-with-mysql.yaml | 1 +
templates/service-templates-latest.json | 8 ++++----
templates/service-templates.json | 8 ++++----
4 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/templates/compose/wordpress-with-mariadb.yaml b/templates/compose/wordpress-with-mariadb.yaml
index 7822a05539..c1db1c6535 100644
--- a/templates/compose/wordpress-with-mariadb.yaml
+++ b/templates/compose/wordpress-with-mariadb.yaml
@@ -16,6 +16,7 @@ services:
- WORDPRESS_DB_USER=$SERVICE_USER_WORDPRESS
- WORDPRESS_DB_PASSWORD=$SERVICE_PASSWORD_WORDPRESS
- WORDPRESS_DB_NAME=wordpress
+ - "WORDPRESS_CONFIG_EXTRA=if (getenv('SERVICE_URL_WORDPRESS')) { define('WP_HOME', getenv('SERVICE_URL_WORDPRESS')); define('WP_SITEURL', getenv('SERVICE_URL_WORDPRESS')); }"
depends_on:
- mariadb
healthcheck:
diff --git a/templates/compose/wordpress-with-mysql.yaml b/templates/compose/wordpress-with-mysql.yaml
index 269208e514..65aa38d411 100644
--- a/templates/compose/wordpress-with-mysql.yaml
+++ b/templates/compose/wordpress-with-mysql.yaml
@@ -16,6 +16,7 @@ services:
- WORDPRESS_DB_USER=$SERVICE_USER_WORDPRESS
- WORDPRESS_DB_PASSWORD=$SERVICE_PASSWORD_WORDPRESS
- WORDPRESS_DB_NAME=wordpress
+ - "WORDPRESS_CONFIG_EXTRA=if (getenv('SERVICE_URL_WORDPRESS')) { define('WP_HOME', getenv('SERVICE_URL_WORDPRESS')); define('WP_SITEURL', getenv('SERVICE_URL_WORDPRESS')); }"
depends_on:
- mysql
healthcheck:
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index b1bf9f973c..e394176477 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -1148,7 +1148,7 @@
"category": "devtools",
"logo": "svgs/dozzle.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-08-18T12:35:50+02:00",
+ "template_last_updated_at": "2026-10-26T00:01:07+01:00",
"port": "8080"
},
"dozzle": {
@@ -2457,7 +2457,7 @@
"category": "productivity",
"logo": "svgs/hi-events.svg",
"minversion": "0.0.0",
- "template_last_updated_at": null,
+ "template_last_updated_at": "2026-10-29T18:55:45+01:00",
"port": "80"
},
"homarr": {
@@ -5967,7 +5967,7 @@
"wordpress-with-mariadb": {
"documentation": "https://wordpress.org?utm_source=coolify.io",
"slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.",
- "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gV09SRFBSRVNTX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX05BTUU9d29yZHByZXNzCiAgICBkZXBlbmRzX29uOgogICAgICAtIG1hcmlhZGIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEnCiAgICB2b2x1bWVzOgogICAgICAtICdtYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT13b3JkcHJlc3MKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
+ "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gV09SRFBSRVNTX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX05BTUU9d29yZHByZXNzCiAgICAgIC0gIldPUkRQUkVTU19DT05GSUdfRVhUUkE9aWYgKGdldGVudignU0VSVklDRV9VUkxfV09SRFBSRVNTJykpIHsgZGVmaW5lKCdXUF9IT01FJywgZ2V0ZW52KCdTRVJWSUNFX1VSTF9XT1JEUFJFU1MnKSk7IGRlZmluZSgnV1BfU0lURVVSTCcsIGdldGVudignU0VSVklDRV9VUkxfV09SRFBSRVNTJykpOyB9IgogICAgZGVwZW5kc19vbjoKICAgICAgLSBtYXJpYWRiCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"cms",
"blog",
@@ -5984,7 +5984,7 @@
"wordpress-with-mysql": {
"documentation": "https://wordpress.org?utm_source=coolify.io",
"slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.",
- "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1teXNxbAogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBteXNxbAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgbXlzcWw6CiAgICBpbWFnZTogJ215c3FsOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdteXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
+ "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1teXNxbAogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgICAtICJXT1JEUFJFU1NfQ09ORklHX0VYVFJBPWlmIChnZXRlbnYoJ1NFUlZJQ0VfVVJMX1dPUkRQUkVTUycpKSB7IGRlZmluZSgnV1BfSE9NRScsIGdldGVudignU0VSVklDRV9VUkxfV09SRFBSRVNTJykpOyBkZWZpbmUoJ1dQX1NJVEVVUkwnLCBnZXRlbnYoJ1NFUlZJQ0VfVVJMX1dPUkRQUkVTUycpKTsgfSIKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbXlzcWwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
"tags": [
"cms",
"blog",
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 2a9588fd96..153f55ef96 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -1148,7 +1148,7 @@
"category": "devtools",
"logo": "svgs/dozzle.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-08-18T12:35:50+02:00",
+ "template_last_updated_at": "2026-10-26T00:01:07+01:00",
"port": "8080"
},
"dozzle": {
@@ -2457,7 +2457,7 @@
"category": "productivity",
"logo": "svgs/hi-events.svg",
"minversion": "0.0.0",
- "template_last_updated_at": null,
+ "template_last_updated_at": "2026-10-29T18:55:45+01:00",
"port": "80"
},
"homarr": {
@@ -5967,7 +5967,7 @@
"wordpress-with-mariadb": {
"documentation": "https://wordpress.org?utm_source=coolify.io",
"slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.",
- "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBtYXJpYWRiCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
+ "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgICAtICJXT1JEUFJFU1NfQ09ORklHX0VYVFJBPWlmIChnZXRlbnYoJ1NFUlZJQ0VfRlFETl9XT1JEUFJFU1MnKSkgeyBkZWZpbmUoJ1dQX0hPTUUnLCBnZXRlbnYoJ1NFUlZJQ0VfRlFETl9XT1JEUFJFU1MnKSk7IGRlZmluZSgnV1BfU0lURVVSTCcsIGdldGVudignU0VSVklDRV9GUUROX1dPUkRQUkVTUycpKTsgfSIKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbWFyaWFkYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21hcmlhZGItZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
"tags": [
"cms",
"blog",
@@ -5984,7 +5984,7 @@
"wordpress-with-mysql": {
"documentation": "https://wordpress.org?utm_source=coolify.io",
"slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.",
- "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bXlzcWwKICAgICAgLSBXT1JEUFJFU1NfREJfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfTkFNRT13b3JkcHJlc3MKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbXlzcWwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
+ "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bXlzcWwKICAgICAgLSBXT1JEUFJFU1NfREJfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfTkFNRT13b3JkcHJlc3MKICAgICAgLSAiV09SRFBSRVNTX0NPTkZJR19FWFRSQT1pZiAoZ2V0ZW52KCdTRVJWSUNFX0ZRRE5fV09SRFBSRVNTJykpIHsgZGVmaW5lKCdXUF9IT01FJywgZ2V0ZW52KCdTRVJWSUNFX0ZRRE5fV09SRFBSRVNTJykpOyBkZWZpbmUoJ1dQX1NJVEVVUkwnLCBnZXRlbnYoJ1NFUlZJQ0VfRlFETl9XT1JEUFJFU1MnKSk7IH0iCiAgICBkZXBlbmRzX29uOgogICAgICAtIG15c3FsCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWw6OCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT13b3JkcHJlc3MKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gMTI3LjAuMC4xCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
"tags": [
"cms",
"blog",
From 1af7de8a8580f556f48e7102239f1646c76d012f Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 2 Nov 2026 14:38:49 +0100
Subject: [PATCH 13/18] fix(backups): pin a reachable IP for S3 uploads on
IPv6-only servers
mc keeps one --resolve IP per host, and Coolify always pinned the IPv4
address of a dual-stack endpoint. On servers without IPv4, uploads failed
with "network is unreachable". A helper container on the upload network
now connects to each validated IP, IPv4 first, and the first IP that
connects is pinned. S3 restores are now pinned in the same way.
Closes #12397
Co-Authored-By: Claude Opus 5.5
---
app/Actions/Database/StartDatabaseImport.php | 21 +++--
app/Jobs/DatabaseBackupJob.php | 6 +-
app/Jobs/VolumeBackupJob.php | 27 ++----
app/Models/S3Storage.php | 42 +++++++++
app/Rules/SafeWebhookUrl.php | 20 ++---
.../S3StorageMinioClientResolveTest.php | 89 +++++++++++++++++++
.../StartDatabaseImportS3CredentialsTest.php | 37 ++++++++
tests/Unit/SafeWebhookUrlTest.php | 7 +-
8 files changed, 204 insertions(+), 45 deletions(-)
create mode 100644 tests/Feature/S3StorageMinioClientResolveTest.php
diff --git a/app/Actions/Database/StartDatabaseImport.php b/app/Actions/Database/StartDatabaseImport.php
index f8c3c861ba..b4b12ec517 100644
--- a/app/Actions/Database/StartDatabaseImport.php
+++ b/app/Actions/Database/StartDatabaseImport.php
@@ -159,10 +159,10 @@ class StartDatabaseImport
throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.');
}
$helper = "s3-restore-{$operation}";
- $this->startS3Helper($storage, $server, $helper, $network);
+ $resolveOptions = $this->startS3Helper($storage, $server, $helper, $network);
$sourceArg = escapeshellarg("s3temp/{$storage->bucket}/{$key}");
$commandList = [
- 'docker exec '.escapeshellarg($helper).' mc cp '.$sourceArg.' /tmp/restore',
+ 'docker exec '.escapeshellarg($helper).' mc cp'.$resolveOptions.' '.$sourceArg.' /tmp/restore',
...$this->streamFromHelper($helper, $container, $containerPath),
'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
];
@@ -294,10 +294,12 @@ class StartDatabaseImport
* Starts the S3 restore helper and sets its `s3temp` mc alias. The S3 keys go to mc through the
* stdin of a separate SSH process, so they are not in the arguments of any process on Coolify or
* on the server. The helper is removed when either step fails.
+ *
+ * @return string The mc `--resolve` option that pins the endpoint host.
*/
- private function startS3Helper(S3Storage $storage, Server $server, string $helper, string $network): void
+ private function startS3Helper(S3Storage $storage, Server $server, string $helper, string $network): string
{
- $image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion());
+ $image = coolifyHelperImage().':'.getHelperVersion();
try {
instant_remote_process([
@@ -305,9 +307,12 @@ class StartDatabaseImport
'docker run -d --network '.escapeshellarg($network)
.' --name '.escapeshellarg($helper)
.' '.$storage->minioClientRegionOption()
- .' '.$image.' sleep 86400',
+ .' '.escapeshellarg($image).' sleep 86400',
], $server);
- $this->setMinioClientAlias($storage, $server, $helper);
+ $resolveOptions = $storage->minioClientResolveOptions($server, $image, $network);
+ $this->setMinioClientAlias($storage, $server, $helper, $resolveOptions);
+
+ return $resolveOptions;
} catch (Throwable) {
instant_remote_process(['docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true'], $server, throwError: false);
@@ -315,10 +320,10 @@ class StartDatabaseImport
}
}
- private function setMinioClientAlias(S3Storage $storage, Server $server, string $helper): void
+ private function setMinioClientAlias(S3Storage $storage, Server $server, string $helper, string $resolveOptions): void
{
$remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($helper)
- .' mc alias set'.$storage->minioClientAliasOptions().' s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null';
+ .' mc alias set'.$resolveOptions.$storage->minioClientAliasOptions().' s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null';
$timeout = (int) config('constants.ssh.command_timeout');
SshRetryHandler::retry(
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 02094a9dc2..6ffcda6ed4 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -20,7 +20,6 @@ use App\Models\Team;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
-use App\Rules\SafeWebhookUrl;
use App\Services\ScheduledJobDeliveryService;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\BackupCompression;
@@ -934,10 +933,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$escapedBackupLocation = escapeshellarg($this->backup_location);
$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
- $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint, $s3->trustedInternalHosts()))
- ->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption))
- ->implode(' ');
- $resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
+ $resolveOptions = $s3->minioClientResolveOptions($this->server, $fullImageName, $network);
instant_remote_process($commands, $this->server, true, false, $this->commandTimeout(), disableMultiplexing: true);
$this->setMinioClientAlias($this->server, "backup-of-{$this->backup_log_uuid}", $s3, $resolveOptions);
diff --git a/app/Jobs/VolumeBackupJob.php b/app/Jobs/VolumeBackupJob.php
index 42f209d1e5..90f75c8c6b 100644
--- a/app/Jobs/VolumeBackupJob.php
+++ b/app/Jobs/VolumeBackupJob.php
@@ -13,7 +13,6 @@ use App\Models\Server;
use App\Models\VolumeBackupS3Replica;
use App\Notifications\VolumeBackup\BackupFailed;
use App\Notifications\VolumeBackup\BackupSuccess;
-use App\Rules\SafeWebhookUrl;
use App\Services\ScheduledJobDeliveryService;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\BackupCompression;
@@ -104,7 +103,10 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$archiveScript = self::archiveScript($compressionCpuPercentage);
if ($streamToS3) {
$this->execution->update(['local_storage_deleted' => true]);
- $archiveCommand = $this->streamToS3Command($destinations->first(), $archiveScript, $backupLocation, $containerName);
+ $streamS3 = $destinations->first();
+ $streamS3->testConnection(shouldSave: true);
+ $streamResolveOptions = $streamS3->minioClientResolveOptions($server, $image);
+ $archiveCommand = $this->streamToS3Command($streamS3, $archiveScript, $backupLocation, $containerName, $streamResolveOptions);
$this->execution->update(['s3_cleanup_pending' => true]);
} else {
$archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName)
@@ -128,7 +130,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
try {
if ($streamToS3) {
- $this->startStreamHelper($destinations->first(), $verifySourceCommand, $source, $containerName, $image, $server);
+ $this->startStreamHelper($streamS3, $verifySourceCommand, $source, $containerName, $image, $server, $streamResolveOptions);
}
$archiveOutput = instant_remote_process(array_filter([
$verifySourceCommand,
@@ -440,7 +442,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
$s3->testConnection(shouldSave: true);
$containerName = 'volume-upload-'.$this->execution->uuid;
$image = coolifyHelperImage().':'.getHelperVersion();
- $resolveOptions = $this->minioClientResolveOptions($s3);
+ $resolveOptions = $s3->minioClientResolveOptions($server, $image);
try {
instant_remote_process([
@@ -467,7 +469,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
* Starts the helper container that streams the volume to S3 and sets its mc alias. The archive then
* runs in it with `docker exec`, so the S3 keys are never part of a command line.
*/
- private function startStreamHelper(S3Storage $s3, string $verifySourceCommand, string $source, string $containerName, string $image, Server $server): void
+ private function startStreamHelper(S3Storage $s3, string $verifySourceCommand, string $source, string $containerName, string $image, Server $server, string $resolveOptions): void
{
instant_remote_process([
$verifySourceCommand,
@@ -477,7 +479,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
.' -v '.escapeshellarg($source.':/volume:ro')
.' '.escapeshellarg($image).' >/dev/null',
], $server, timeout: $this->timeout, disableMultiplexing: true);
- $this->setMinioClientAlias($server, $containerName, $s3, $this->minioClientResolveOptions($s3));
+ $this->setMinioClientAlias($server, $containerName, $s3, $resolveOptions);
}
/**
@@ -506,19 +508,8 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
);
}
- private function minioClientResolveOptions(S3Storage $s3): string
+ private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $containerName, string $resolveOptions): string
{
- $resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($s3->endpoint, $s3->trustedInternalHosts()))
- ->map(fn (string $option): string => '--resolve '.escapeshellarg($option))
- ->implode(' ');
-
- return $resolveOptions === '' ? '' : ' '.$resolveOptions;
- }
-
- private function streamToS3Command(S3Storage $s3, string $archiveScript, string $backupLocation, string $containerName): string
- {
- $s3->testConnection(shouldSave: true);
- $resolveOptions = $this->minioClientResolveOptions($s3);
$destination = 'temporary/'.$s3->bucket.$backupLocation;
// `mc pipe --quiet` panics in recent mc releases, so keep its progress bar out of the error output instead.
// Each progress frame starts with "\r", so printing the last field per line keeps only the final frame and real errors.
diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php
index 84bf072dc1..eabe8f322c 100644
--- a/app/Models/S3Storage.php
+++ b/app/Models/S3Storage.php
@@ -44,6 +44,11 @@ class S3Storage extends BaseModel
*/
private const REMOTE_COMMAND_TIMEOUT_EXIT_CODE = 124;
+ /**
+ * How long the helper container waits for a TCP connection to one IP of the endpoint.
+ */
+ private const RESOLVE_PROBE_TIMEOUT_SECONDS = 5;
+
protected $fillable = [
'team_id',
'name',
@@ -369,6 +374,43 @@ class S3Storage extends BaseModel
return ' --api S3v4'.($this->usesPathStyleEndpoint() ? '' : ' --path off');
}
+ /**
+ * The mc `--resolve` option that pins the endpoint host to one of its validated IPs. mc keeps one IP per host,
+ * so when the host has more than one IP, a helper container on the upload network connects to each IP, IPv4
+ * first, and the first IP that connects is used. A server without IPv4 then uses an IPv6 address of a
+ * dual-stack endpoint. When no IP connects, the first IP is used, so mc reports the real connection error.
+ */
+ public function minioClientResolveOptions(Server $server, string $image, ?string $network = null): string
+ {
+ $entries = SafeWebhookUrl::minioClientResolveOptions($this->endpoint, $this->trustedInternalHosts());
+ if ($entries === []) {
+ return '';
+ }
+
+ $entry = $entries[0];
+ if (count($entries) > 1) {
+ $ips = array_map(fn (string $entry): string => str($entry)->afterLast('=')->toString(), $entries);
+ $port = (int) str($entries[0])->beforeLast('=')->afterLast(':')->toString();
+ $probe = 'for ip in '.implode(' ', array_map('escapeshellarg', $ips)).'; do'
+ .' if timeout '.self::RESOLVE_PROBE_TIMEOUT_SECONDS.' bash -c "exec 3<>/dev/tcp/$ip/'.$port.'" 2>/dev/null; then echo "$ip"; exit 0; fi;'
+ .' done';
+ $reachableIp = trim((string) instant_remote_process(
+ ['docker run --rm'.($network === null ? '' : ' --network '.escapeshellarg($network))
+ .' '.escapeshellarg($image).' bash -c '.escapeshellarg($probe)],
+ $server,
+ throwError: false,
+ timeout: count($ips) * self::RESOLVE_PROBE_TIMEOUT_SECONDS + 60,
+ disableMultiplexing: true,
+ ));
+ $reachableIndex = array_search($reachableIp, $ips, true);
+ if ($reachableIndex !== false) {
+ $entry = $entries[$reachableIndex];
+ }
+ }
+
+ return ' --resolve '.escapeshellarg($entry);
+ }
+
/**
* Runs an upload of a local file with the MinIO client again when it fails, for example after a short network
* outage. A timeout is not retried, because another full attempt can pass the timeout of the backup job.
diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php
index c872dde82b..23010dc6d5 100644
--- a/app/Rules/SafeWebhookUrl.php
+++ b/app/Rules/SafeWebhookUrl.php
@@ -194,7 +194,7 @@ class SafeWebhookUrl implements ValidationRule
}
/**
- * Build mc --resolve mappings that pin the endpoint host for S3 backups.
+ * Build mc --resolve mappings, IPv4 first, that pin the endpoint host for S3 backups.
*
* @return array
*/
@@ -206,17 +206,17 @@ class SafeWebhookUrl implements ValidationRule
return [];
}
- // The MinIO client keeps one --resolve IP per host and parses it with Go's
- // netip.ParseAddr, which rejects bracketed IPv6. Emit a single entry, preferring
- // IPv4 for reachability, without brackets.
- $preferredIps = array_values(array_filter(
- $target['ips'],
- fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false,
- ));
+ // mc parses --resolve values with Go's netip.ParseAddr, which rejects bracketed IPv6.
+ // mc also keeps one IP per host, so S3Storage::minioClientResolveOptions() picks one entry.
+ $ips = $target['ips'];
+ usort($ips, fn (string $a, string $b): int => self::isIpv6($a) <=> self::isIpv6($b));
- $ip = $preferredIps[0] ?? $target['ips'][0];
+ return array_map(fn (string $ip): string => sprintf('%s:%d=%s', $target['host'], $target['port'], $ip), $ips);
+ }
- return [sprintf('%s:%d=%s', $target['host'], $target['port'], $ip)];
+ private static function isIpv6(string $ip): bool
+ {
+ return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
}
public static function redactedUrlForLog(string $url): string
diff --git a/tests/Feature/S3StorageMinioClientResolveTest.php b/tests/Feature/S3StorageMinioClientResolveTest.php
new file mode 100644
index 0000000000..69fe5e0967
--- /dev/null
+++ b/tests/Feature/S3StorageMinioClientResolveTest.php
@@ -0,0 +1,89 @@
+ InstanceSettings::query()->updateOrCreate(['id' => 0], [
+ 'webhook_allowed_internal_hosts' => ['localhost'],
+ 'webhook_allow_localhost' => true,
+ ]));
+});
+
+afterEach(function () {
+ Server::flushIdentityMap();
+});
+
+/**
+ * @return array{0: Server, 1: S3Storage}
+ */
+function minioResolveTestSetup(string $endpoint, string $sshUser = 'root'): array
+{
+ $team = Team::factory()->create();
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'user' => $sshUser,
+ 'ip' => '203.0.113.10',
+ 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id,
+ ]);
+ $storage = S3Storage::create([
+ 'name' => 'Destination',
+ 'region' => 'us-east-1',
+ 'key' => 'key',
+ 'secret' => 'secret',
+ 'bucket' => 'bucket',
+ 'endpoint' => $endpoint,
+ 'team_id' => $team->id,
+ 'is_usable' => true,
+ ]);
+
+ return [$server, $storage];
+}
+
+it('pins the IPv6 address of a dual-stack S3 host when only IPv6 connects from the upload network', function () {
+ [$server, $storage] = minioResolveTestSetup('http://localhost:9000');
+ fakeRemoteProcesses(['*/dev/tcp/*' => '::1', '*' => '']);
+
+ $options = $storage->minioClientResolveOptions($server, 'ghcr.io/coollabsio/coolify-helper:1.0.0', 'coolify');
+
+ expect($options)->toBe(" --resolve 'localhost:9000=::1'");
+ Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker run --rm --network 'coolify' 'ghcr.io/coollabsio/coolify-helper:1.0.0' bash -c")
+ && str_contains(remoteProcessScript($process), "for ip in '\\''127.0.0.1'\\'' '\\''::1'\\''; do")
+ && str_contains(remoteProcessScript($process), '/dev/tcp/$ip/9000'));
+});
+
+it('pins the IPv4 address when it connects or when no address connects', function (string $probeOutput) {
+ [$server, $storage] = minioResolveTestSetup('http://localhost:9000');
+ fakeRemoteProcesses(['*/dev/tcp/*' => $probeOutput, '*' => '']);
+
+ expect($storage->minioClientResolveOptions($server, 'helper:1.0.0'))->toBe(" --resolve 'localhost:9000=127.0.0.1'");
+})->with([
+ 'IPv4 connects' => '127.0.0.1',
+ 'no address connects' => '',
+ 'unexpected output' => '10.0.0.1',
+]);
+
+it('runs the probe with sudo on servers with a non-root SSH user', function () {
+ [$server, $storage] = minioResolveTestSetup('http://localhost:9000', 'deploy');
+ fakeRemoteProcesses(['*/dev/tcp/*' => '::1', '*' => '']);
+
+ expect($storage->minioClientResolveOptions($server, 'helper:1.0.0'))->toBe(" --resolve 'localhost:9000=::1'");
+ Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "sudo docker run --rm 'helper:1.0.0' bash -c")
+ && str_contains(remoteProcessScript($process), 'if timeout 5 bash -c "exec 3<>/dev/tcp/$ip/9000" 2>/dev/null; then echo "$ip"; exit 0; fi; done'));
+});
+
+it('does not pin or probe an S3 endpoint that is an IP address', function () {
+ [$server, $storage] = minioResolveTestSetup('https://8.8.8.8');
+ Process::fake();
+
+ expect($storage->minioClientResolveOptions($server, 'helper:1.0.0'))->toBe('');
+ Process::assertNothingRan();
+});
diff --git a/tests/Feature/StartDatabaseImportS3CredentialsTest.php b/tests/Feature/StartDatabaseImportS3CredentialsTest.php
index 15b0041535..045d5c1db9 100644
--- a/tests/Feature/StartDatabaseImportS3CredentialsTest.php
+++ b/tests/Feature/StartDatabaseImportS3CredentialsTest.php
@@ -282,6 +282,43 @@ function importS3ArgvTestRunAliasProcess(array $aliasProcess): array
}
}
+it('pins the reachable IP of a dual-stack S3 host for the restore mc commands', function () {
+ InstanceSettings::query()->whereKey(0)->update([
+ 'webhook_allowed_internal_hosts' => ['localhost'],
+ 'webhook_allow_localhost' => true,
+ ]);
+ $storage = S3Storage::create([
+ 'name' => 'Import S3',
+ 'region' => 'us-east-1',
+ 'key' => 'key',
+ 'secret' => 'secret',
+ 'bucket' => 'test-bucket',
+ 'endpoint' => 'http://localhost:9000',
+ 'is_usable' => true,
+ 'team_id' => $this->team->id,
+ ]);
+ $disk = Mockery::mock(FilesystemAdapter::class);
+ $disk->shouldReceive('exists')->once()->andReturn(true);
+ $disk->shouldReceive('size')->once()->andReturn(1024);
+ $filesystem = Mockery::mock(FilesystemManager::class, [app()])->makePartial();
+ $filesystem->shouldReceive('build')->once()->andReturn($disk);
+ Storage::swap($filesystem);
+ fakeRemoteProcesses(['*/dev/tcp/*' => '::1', '*' => '']);
+ Queue::fake();
+
+ $activity = app(StartDatabaseImport::class)->handle(
+ $this->database,
+ new DatabaseImportSource('s3', path: 'backups/restore.sql', s3StorageUuid: $storage->uuid),
+ $this->team->id,
+ );
+
+ Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker run --rm --network 'coolify'")
+ && str_contains(remoteProcessScript($process), '/dev/tcp/$ip/9000'));
+ Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "mc alias set --resolve '\\''localhost:9000=::1'\\'' --api S3v4 s3temp"));
+ expect((string) RemoteProcessCommand::read($activity))
+ ->toContain("mc cp --resolve 'localhost:9000=::1' ".escapeshellarg('s3temp/test-bucket/backups/restore.sql'));
+});
+
it('passes the S3 keys of an import to mc on stdin, not in a command line', function (string $sshUser) {
[$processes, $activity, $error] = importS3ArgvTestRun($this, $sshUser);
diff --git a/tests/Unit/SafeWebhookUrlTest.php b/tests/Unit/SafeWebhookUrlTest.php
index 83d8c9557c..abd7e85937 100644
--- a/tests/Unit/SafeWebhookUrlTest.php
+++ b/tests/Unit/SafeWebhookUrlTest.php
@@ -278,17 +278,16 @@ it('builds MinIO client resolve options for S3 backup uploads', function () {
expect($options)->toContain('localhost:9000=127.0.0.1');
});
-it('collapses dual-stack S3 hosts to a single bracket-free IPv4 resolve entry', function () {
+it('lists every S3 host IP as a bracket-free resolve entry, IPv4 first', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
'webhook_allowed_internal_hosts' => ['localhost'],
'webhook_allow_localhost' => true,
]));
- // localhost resolves to both 127.0.0.1 and ::1. The MinIO client keeps one IP per
- // host and rejects bracketed IPv6, so exactly one bracket-free IPv4 entry is produced.
+ // mc rejects bracketed IPv6, and S3Storage::minioClientResolveOptions() picks the reachable entry.
$options = SafeWebhookUrl::minioClientResolveOptions('http://localhost:9000');
- expect($options)->toBe(['localhost:9000=127.0.0.1']);
+ expect($options)->toBe(['localhost:9000=127.0.0.1', 'localhost:9000=::1']);
});
it('rejects trailing-dot hostnames to avoid DNS pinning mismatch', function () {
From 8dea52609803f95a55716a08ace41c1ce47c2bc1 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 2 Nov 2026 14:44:20 +0100
Subject: [PATCH 14/18] Revert "fix(service): keep WordPress URLs in sync with
the Coolify domain"
Do not overwrite the WordPress site address from the Coolify domain.
WordPress owns this setting; the docs now explain how to change it.
This reverts commit dce02411a.
Co-Authored-By: Claude Opus 5.5
---
templates/compose/wordpress-with-mariadb.yaml | 1 -
templates/compose/wordpress-with-mysql.yaml | 1 -
templates/service-templates-latest.json | 8 ++++----
templates/service-templates.json | 8 ++++----
4 files changed, 8 insertions(+), 10 deletions(-)
diff --git a/templates/compose/wordpress-with-mariadb.yaml b/templates/compose/wordpress-with-mariadb.yaml
index c1db1c6535..7822a05539 100644
--- a/templates/compose/wordpress-with-mariadb.yaml
+++ b/templates/compose/wordpress-with-mariadb.yaml
@@ -16,7 +16,6 @@ services:
- WORDPRESS_DB_USER=$SERVICE_USER_WORDPRESS
- WORDPRESS_DB_PASSWORD=$SERVICE_PASSWORD_WORDPRESS
- WORDPRESS_DB_NAME=wordpress
- - "WORDPRESS_CONFIG_EXTRA=if (getenv('SERVICE_URL_WORDPRESS')) { define('WP_HOME', getenv('SERVICE_URL_WORDPRESS')); define('WP_SITEURL', getenv('SERVICE_URL_WORDPRESS')); }"
depends_on:
- mariadb
healthcheck:
diff --git a/templates/compose/wordpress-with-mysql.yaml b/templates/compose/wordpress-with-mysql.yaml
index 65aa38d411..269208e514 100644
--- a/templates/compose/wordpress-with-mysql.yaml
+++ b/templates/compose/wordpress-with-mysql.yaml
@@ -16,7 +16,6 @@ services:
- WORDPRESS_DB_USER=$SERVICE_USER_WORDPRESS
- WORDPRESS_DB_PASSWORD=$SERVICE_PASSWORD_WORDPRESS
- WORDPRESS_DB_NAME=wordpress
- - "WORDPRESS_CONFIG_EXTRA=if (getenv('SERVICE_URL_WORDPRESS')) { define('WP_HOME', getenv('SERVICE_URL_WORDPRESS')); define('WP_SITEURL', getenv('SERVICE_URL_WORDPRESS')); }"
depends_on:
- mysql
healthcheck:
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index e394176477..b1bf9f973c 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -1148,7 +1148,7 @@
"category": "devtools",
"logo": "svgs/dozzle.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-10-26T00:01:07+01:00",
+ "template_last_updated_at": "2026-08-18T12:35:50+02:00",
"port": "8080"
},
"dozzle": {
@@ -2457,7 +2457,7 @@
"category": "productivity",
"logo": "svgs/hi-events.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-10-29T18:55:45+01:00",
+ "template_last_updated_at": null,
"port": "80"
},
"homarr": {
@@ -5967,7 +5967,7 @@
"wordpress-with-mariadb": {
"documentation": "https://wordpress.org?utm_source=coolify.io",
"slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.",
- "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gV09SRFBSRVNTX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX05BTUU9d29yZHByZXNzCiAgICAgIC0gIldPUkRQUkVTU19DT05GSUdfRVhUUkE9aWYgKGdldGVudignU0VSVklDRV9VUkxfV09SRFBSRVNTJykpIHsgZGVmaW5lKCdXUF9IT01FJywgZ2V0ZW52KCdTRVJWSUNFX1VSTF9XT1JEUFJFU1MnKSk7IGRlZmluZSgnV1BfU0lURVVSTCcsIGdldGVudignU0VSVklDRV9VUkxfV09SRFBSRVNTJykpOyB9IgogICAgZGVwZW5kc19vbjoKICAgICAgLSBtYXJpYWRiCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
+ "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gV09SRFBSRVNTX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX05BTUU9d29yZHByZXNzCiAgICBkZXBlbmRzX29uOgogICAgICAtIG1hcmlhZGIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEnCiAgICB2b2x1bWVzOgogICAgICAtICdtYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT13b3JkcHJlc3MKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
"tags": [
"cms",
"blog",
@@ -5984,7 +5984,7 @@
"wordpress-with-mysql": {
"documentation": "https://wordpress.org?utm_source=coolify.io",
"slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.",
- "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1teXNxbAogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgICAtICJXT1JEUFJFU1NfQ09ORklHX0VYVFJBPWlmIChnZXRlbnYoJ1NFUlZJQ0VfVVJMX1dPUkRQUkVTUycpKSB7IGRlZmluZSgnV1BfSE9NRScsIGdldGVudignU0VSVklDRV9VUkxfV09SRFBSRVNTJykpOyBkZWZpbmUoJ1dQX1NJVEVVUkwnLCBnZXRlbnYoJ1NFUlZJQ0VfVVJMX1dPUkRQUkVTUycpKTsgfSIKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbXlzcWwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
+ "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1teXNxbAogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBteXNxbAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgbXlzcWw6CiAgICBpbWFnZTogJ215c3FsOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdteXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"cms",
"blog",
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 153f55ef96..2a9588fd96 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -1148,7 +1148,7 @@
"category": "devtools",
"logo": "svgs/dozzle.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-10-26T00:01:07+01:00",
+ "template_last_updated_at": "2026-08-18T12:35:50+02:00",
"port": "8080"
},
"dozzle": {
@@ -2457,7 +2457,7 @@
"category": "productivity",
"logo": "svgs/hi-events.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-10-29T18:55:45+01:00",
+ "template_last_updated_at": null,
"port": "80"
},
"homarr": {
@@ -5967,7 +5967,7 @@
"wordpress-with-mariadb": {
"documentation": "https://wordpress.org?utm_source=coolify.io",
"slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.",
- "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgICAtICJXT1JEUFJFU1NfQ09ORklHX0VYVFJBPWlmIChnZXRlbnYoJ1NFUlZJQ0VfRlFETl9XT1JEUFJFU1MnKSkgeyBkZWZpbmUoJ1dQX0hPTUUnLCBnZXRlbnYoJ1NFUlZJQ0VfRlFETl9XT1JEUFJFU1MnKSk7IGRlZmluZSgnV1BfU0lURVVSTCcsIGdldGVudignU0VSVklDRV9GUUROX1dPUkRQUkVTUycpKTsgfSIKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbWFyaWFkYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21hcmlhZGItZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
+ "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBtYXJpYWRiCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"cms",
"blog",
@@ -5984,7 +5984,7 @@
"wordpress-with-mysql": {
"documentation": "https://wordpress.org?utm_source=coolify.io",
"slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.",
- "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bXlzcWwKICAgICAgLSBXT1JEUFJFU1NfREJfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfTkFNRT13b3JkcHJlc3MKICAgICAgLSAiV09SRFBSRVNTX0NPTkZJR19FWFRSQT1pZiAoZ2V0ZW52KCdTRVJWSUNFX0ZRRE5fV09SRFBSRVNTJykpIHsgZGVmaW5lKCdXUF9IT01FJywgZ2V0ZW52KCdTRVJWSUNFX0ZRRE5fV09SRFBSRVNTJykpOyBkZWZpbmUoJ1dQX1NJVEVVUkwnLCBnZXRlbnYoJ1NFUlZJQ0VfRlFETl9XT1JEUFJFU1MnKSk7IH0iCiAgICBkZXBlbmRzX29uOgogICAgICAtIG15c3FsCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWw6OCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT13b3JkcHJlc3MKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gMTI3LjAuMC4xCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK",
+ "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bXlzcWwKICAgICAgLSBXT1JEUFJFU1NfREJfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfTkFNRT13b3JkcHJlc3MKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbXlzcWwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
"tags": [
"cms",
"blog",
From bc855fdc68ed5ae77eb23579f92bb504b649334d Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 2 Nov 2026 14:46:19 +0100
Subject: [PATCH 15/18] fix(transfer): resolve IPv6-only server host names
The domain check of the transfer checklist resolved the server host name
with gethostbynamel(), which returns IPv4 only, so a domain that points to
an IPv6-only server was reported as pointing to another address. Resolve A
and AAAA records and compare normalized addresses. The Cloud SSH probe now
also resolves AAAA records when a host name has no A record.
Closes #12397
Co-Authored-By: Claude Opus 5.5
---
.../Api/ServerTransferController.php | 2 +-
.../TransferFinishChecklist.php | 21 ++++++++++++++++---
2 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/app/Http/Controllers/Api/ServerTransferController.php b/app/Http/Controllers/Api/ServerTransferController.php
index efd8f428b7..0dc0229a30 100644
--- a/app/Http/Controllers/Api/ServerTransferController.php
+++ b/app/Http/Controllers/Api/ServerTransferController.php
@@ -232,7 +232,7 @@ class ServerTransferController extends Controller
Server::throttleValidation($teamId, $ip);
$probeIp = Server::normalizeIp($ip);
if (isCloud() && ! filter_var($probeIp, FILTER_VALIDATE_IP)) {
- $probeIp = gethostbyname($probeIp);
+ $probeIp = resolveHostToIp($probeIp);
}
$probeHost = filter_var($probeIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? "[{$probeIp}]" : $probeIp;
$probeAllowed = ! isCloud() || (filter_var($probeIp, FILTER_VALIDATE_IP)
diff --git a/app/Services/ServerTransfer/TransferFinishChecklist.php b/app/Services/ServerTransfer/TransferFinishChecklist.php
index d894b49cb3..25d6d19e61 100644
--- a/app/Services/ServerTransfer/TransferFinishChecklist.php
+++ b/app/Services/ServerTransfer/TransferFinishChecklist.php
@@ -407,14 +407,29 @@ class TransferFinishChecklist
: ['action', 'This instance does not own the record yet.', 'Until it owns the record, this instance does not change or delete it.'];
}
+ /**
+ * The IPv4 and IPv6 addresses of a host name, in the normalized form of normalizeIpAddress().
+ *
+ * @return array
+ */
+ private function resolveIps(string $host): array
+ {
+ $records = @dns_get_record($host, DNS_A | DNS_AAAA) ?: [];
+
+ return array_values(array_filter(array_map(
+ fn (array $record): ?string => isset($record['ip']) || isset($record['ipv6']) ? normalizeIpAddress($record['ip'] ?? $record['ipv6']) : null,
+ $records,
+ )));
+ }
+
/**
* @return array{0: string, 1: string, 2: string|null}
*/
private function checkDomain(Server $server, string $host): array
{
- $serverIps = filter_var($server->ip, FILTER_VALIDATE_IP) ? [$server->ip] : (gethostbynamel($server->ip) ?: []);
- $records = @dns_get_record($host, DNS_A | DNS_AAAA) ?: [];
- $ips = array_values(array_filter(array_map(fn (array $record) => $record['ip'] ?? $record['ipv6'] ?? null, $records)));
+ $serverIp = normalizeIpAddress($server->ip);
+ $serverIps = filter_var($serverIp, FILTER_VALIDATE_IP) ? [$serverIp] : $this->resolveIps($serverIp);
+ $ips = $this->resolveIps($host);
if ($ips === []) {
return ['action', 'The domain does not resolve.', null];
}
From 5cf1abc0fc0cbdc39276d03607c342633a8a6cc0 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 2 Nov 2026 14:49:11 +0100
Subject: [PATCH 16/18] fix(env): keep pre-upgrade values for legacy compose
variables
Closes #12408
Co-Authored-By: Claude Opus 5.5
---
app/Models/EnvironmentVariable.php | 6 ++-
app/Traits/HasSecretManager.php | 44 ++++++++++++++--
bootstrap/helpers/shared.php | 48 ++++++++++++++++++
...ationDeploymentControlVarFilteringTest.php | 50 +++++++++++++++++++
.../EnvironmentVariableLegacyEscapingTest.php | 34 +++++++++++++
5 files changed, 177 insertions(+), 5 deletions(-)
diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php
index b40ca8ebb3..430727a502 100644
--- a/app/Models/EnvironmentVariable.php
+++ b/app/Models/EnvironmentVariable.php
@@ -291,7 +291,11 @@ class EnvironmentVariable extends BaseModel
$value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables, $sharedVariables);
- return $value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping ? $value : $this->legacyEscapedValue($value);
+ if ($value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping) {
+ return $value;
+ }
+
+ return method_exists($resource, 'useExactEscaping') && $resource->useExactEscaping($this) ? $value : $this->legacyEscapedValue($value);
}
/**
diff --git a/app/Traits/HasSecretManager.php b/app/Traits/HasSecretManager.php
index dd9195c9af..621a29ca01 100644
--- a/app/Traits/HasSecretManager.php
+++ b/app/Traits/HasSecretManager.php
@@ -146,11 +146,46 @@ trait HasSecretManager
* Decides the escaping of a variable for all paths (service .env, database environment,
* Redis password). A variable saved before exact escaping keeps the old escaping, so its
* container gets the same value as before the upgrade. Saving the variable again switches
- * it to exact escaping.
+ * it to exact escaping. Compose references that got the stored value before the upgrade
+ * (such as `FOO=${FOO:-}`) use exact escaping, so they keep getting that value. So do old
+ * .env lines that Docker Compose cannot read, because they stop the deployment.
*/
public function useExactEscaping(EnvironmentVariable $environmentVariable): bool
{
- return ! $environmentVariable->uses_legacy_escaping;
+ if (! $environmentVariable->uses_legacy_escaping) {
+ return true;
+ }
+
+ if (! $this instanceof Service && ! $this instanceof Application) {
+ return false;
+ }
+
+ if ($this->legacyEnvFileLineIsUnreadable($environmentVariable)) {
+ return true;
+ }
+
+ $parsesCompose = $this instanceof Service
+ || ($this->build_pack === 'dockercompose' && ! $this->settings?->is_raw_compose_deployment_enabled);
+
+ return $parsesCompose
+ && dockerComposeVariablesWithStoredValueBeforeExactEscaping($this->docker_compose_raw)->contains($environmentVariable->key);
+ }
+
+ /**
+ * The old escaping puts literal and multiline values in single quotes without escaping. Docker
+ * Compose ends the value at the next quote, so a value with a quote breaks the whole .env file.
+ */
+ private function legacyEnvFileLineIsUnreadable(EnvironmentVariable $environmentVariable): bool
+ {
+ if (! $environmentVariable->is_literal && ! $environmentVariable->is_multiline) {
+ return false;
+ }
+
+ $value = $this->resolvedEnvironmentVariableValue($environmentVariable);
+
+ return is_string($value)
+ && str_contains($value, "'")
+ && ! (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '[')));
}
/**
@@ -210,8 +245,9 @@ trait HasSecretManager
}
if ($environmentVariable->is_literal || $environmentVariable->is_multiline) {
- // Service and application .env files strip the old single quotes; database environment lists keep them.
- return ($this instanceof Service || $this instanceof Application) ? str_contains($value, "'") : true;
+ // Service and application .env files strip the old single quotes (values with a quote use exact
+ // escaping); database environment lists keep them.
+ return ! ($this instanceof Service || $this instanceof Application);
}
return escapeEnvVariables($value) !== $value
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 10255bcebd..e852ec50f0 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -5605,6 +5605,54 @@ function dockerComposeEnvironmentVariableShouldBeUnset(?string $dockerCompose, s
return extractDockerComposeEnvironmentVariableReferences($dockerCompose)->contains($key);
}
+/**
+ * Variables whose stored value the Compose parser before v4.5.0 wrote straight into the `environment:`
+ * list, so their containers got the value without the old .env escaping: references with a default or
+ * under another name (`FOO=${FOO:-}`, `BAR=${FOO}`, `BAR=$FOO`) and references in such a default.
+ * A plain `FOO=${FOO}` reference kept reading the escaped .env value.
+ *
+ * @return Collection
+ */
+function dockerComposeVariablesWithStoredValueBeforeExactEscaping(?string $dockerCompose): Collection
+{
+ static $cache = [];
+ if (blank($dockerCompose)) {
+ return collect();
+ }
+
+ $cacheKey = hash('xxh128', $dockerCompose);
+ if (isset($cache[$cacheKey])) {
+ return $cache[$cacheKey];
+ }
+
+ $keys = collect();
+ foreach (extractHardcodedEnvironmentVariables($dockerCompose) as $assignment) {
+ $value = $assignment['value'] ?? null;
+ if (! is_string($value) || ! str_starts_with($value, '$') || replaceVariables($value)->value() === (string) $assignment['key']) {
+ continue;
+ }
+
+ $reference = extractBalancedBraceContent($value, 0);
+ if ($reference === null) {
+ if (preg_match('/\A\$([A-Za-z_][A-Za-z0-9_]*)\z/', $value, $match) === 1) {
+ $keys->push($match[1]);
+ }
+
+ continue;
+ }
+
+ $split = splitOnOperatorOutsideNested($reference['content']);
+ $keys->push($split === null ? $reference['content'] : $split['variable']);
+ if ($split !== null) {
+ $keys = $keys->concat(extractDockerComposeEnvironmentVariableReferences($split['default']));
+ }
+ }
+
+ $cache = [$cacheKey => $keys->reject(fn (string $key): bool => str_starts_with($key, 'SERVICE_'))->unique()->values()];
+
+ return $cache[$cacheKey];
+}
+
/** @param Collection $environmentVariables */
function preserveLegacyDockerComposeNullAssignments(Collection $environment, Collection $environmentVariables): Collection
{
diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
index fc3fdcf363..ebdecee8da 100644
--- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
+++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php
@@ -1842,6 +1842,56 @@ it('keeps the old runtime env format for variables saved before exact escaping',
->toContain('EXACT_PLAIN='.escapeComposeEnvFileValue('a\'b"c\\d', allowInterpolation: true));
});
+it('gives legacy compose variables the value that the compose file got before exact escaping', function (bool $rawCompose, string $expectedDefault) {
+ $compose = <<<'YAML'
+services:
+ app:
+ image: alpine
+ environment:
+ - TRUSTED_HOST_PATTERNS=${TRUSTED_HOST_PATTERNS:-}
+ - PLAIN_REFERENCE=${PLAIN_REFERENCE}
+YAML;
+ [$application, $server] = makeDeploymentControlVarFixture([
+ 'build_pack' => 'dockercompose',
+ 'docker_compose_raw' => $compose,
+ 'docker_compose' => $compose,
+ ]);
+ $application->settings()->update(['is_raw_compose_deployment_enabled' => $rawCompose]);
+
+ foreach (['TRUSTED_HOST_PATTERNS', 'PLAIN_REFERENCE'] as $key) {
+ $variable = createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => '^example\\.com$', 'is_runtime' => true, 'is_buildtime' => false]);
+ $variable->uses_legacy_escaping = true;
+ $variable->save();
+ }
+
+ [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
+
+ /** @var Collection $runtimeEnvs */
+ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
+
+ expect($runtimeEnvs)
+ ->toContain('TRUSTED_HOST_PATTERNS='.$expectedDefault)
+ ->toContain('PLAIN_REFERENCE=^example\\\\.com$');
+})->with([
+ 'parsed compose' => [false, '"^example\\\\.com$"'],
+ 'raw compose deployment' => [true, '^example\\\\.com$'],
+]);
+
+it('writes a readable runtime env line for legacy literal values with a single quote', function () {
+ [$application, $server] = makeDeploymentControlVarFixture();
+
+ $variable = createApplicationEnvironmentVariable($application, ['key' => 'LEGACY_QUOTE', 'value' => "it's", 'is_literal' => true, 'is_runtime' => true, 'is_buildtime' => false]);
+ $variable->uses_legacy_escaping = true;
+ $variable->save();
+
+ [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
+
+ /** @var Collection $runtimeEnvs */
+ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
+
+ expect($runtimeEnvs)->toContain('LEGACY_QUOTE="it\'s"');
+});
+
it('keeps the old runtime env format for preview variables saved before exact escaping', function () {
[$application, $server] = makeDeploymentControlVarFixture();
diff --git a/tests/Feature/EnvironmentVariableLegacyEscapingTest.php b/tests/Feature/EnvironmentVariableLegacyEscapingTest.php
index 96382a231c..6823dfc7b5 100644
--- a/tests/Feature/EnvironmentVariableLegacyEscapingTest.php
+++ b/tests/Feature/EnvironmentVariableLegacyEscapingTest.php
@@ -95,6 +95,40 @@ test('legacy service variables keep the old env file format', function () {
->and($this->service->composeEnvironmentFileLine($empty))->toBe('EMPTY=');
});
+test('legacy service variables keep the value that the compose file got before exact escaping', function () {
+ $this->service->update(['docker_compose_raw' => <<<'YAML'
+services:
+ app:
+ image: alpine
+ environment:
+ - WITH_DEFAULT=${WITH_DEFAULT:-x}
+ - OTHER_NAME=${RENAMED}
+ - NESTED=${NESTED:-${NESTED_DEFAULT}}
+ - PLAIN=${PLAIN}
+YAML]);
+ $variables = collect(['WITH_DEFAULT', 'RENAMED', 'NESTED_DEFAULT', 'PLAIN'])
+ ->mapWithKeys(fn (string $key) => [$key => legacyVariable($this->service, ['key' => $key, 'value' => 'a\\.b'])]);
+
+ expect($this->service->composeEnvironmentFileLine($variables['WITH_DEFAULT']))->toBe('WITH_DEFAULT="a\\\\.b"')
+ ->and($this->service->composeEnvironmentFileLine($variables['RENAMED']))->toBe('RENAMED="a\\\\.b"')
+ ->and($this->service->composeEnvironmentFileLine($variables['NESTED_DEFAULT']))->toBe('NESTED_DEFAULT="a\\\\.b"')
+ ->and($this->service->composeEnvironmentFileLine($variables['PLAIN']))->toBe('PLAIN=a\\\\.b')
+ ->and($this->service->legacyEscapingChangesValue($variables['WITH_DEFAULT']))->toBeFalse()
+ ->and($this->service->legacyEscapingChangesValue($variables['PLAIN']))->toBeTrue()
+ ->and($variables['WITH_DEFAULT']->fresh()->real_value)->toBe('a\\.b');
+});
+
+test('legacy literal and multiline variables with a single quote get a line that docker compose can read', function () {
+ $literal = legacyVariable($this->service, ['key' => 'LITERAL_QUOTE', 'value' => "it's", 'is_literal' => true]);
+ $multiline = legacyVariable($this->service, ['key' => 'MULTILINE_QUOTE', 'value' => "it's\n\"two\"", 'is_multiline' => true]);
+ $withoutQuote = legacyVariable($this->service, ['key' => 'LITERAL_PLAIN', 'value' => 'x$y', 'is_literal' => true]);
+
+ expect($this->service->composeEnvironmentFileLine($literal))->toBe('LITERAL_QUOTE="it\'s"')
+ ->and($this->service->composeEnvironmentFileLine($multiline))->toBe("MULTILINE_QUOTE=\"it's\n\\\"two\\\"\"")
+ ->and($this->service->composeEnvironmentFileLine($withoutQuote))->toBe("LITERAL_PLAIN='x\$y'")
+ ->and($this->service->legacyEscapingChangesValue($literal))->toBeFalse();
+});
+
test('legacy database variables keep the old compose environment format', function () {
$database = StandalonePostgresql::forceCreate([
'uuid' => 'postgres-legacy-test',
From 6977caaa9b4f62d58c6f76d6227cc14753c794af Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 2 Nov 2026 14:55:38 +0100
Subject: [PATCH 17/18] fix(service): store the Compose default for bare
variable references
Closes #12412
---
bootstrap/helpers/parsers.php | 2 +
bootstrap/helpers/shared.php | 57 +++++++++++++++++++
.../Feature/EnvironmentVariableSearchTest.php | 39 +++++++++++++
3 files changed, 98 insertions(+)
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index 7aa5ef279e..b723993b67 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -2422,6 +2422,7 @@ function serviceParser(Service $resource): Collection
->orWhere('key', 'LIKE', '%}');
})
->delete();
+ $existingEnvironmentVariableKeys = $resource->environment_variables()->pluck('key');
$topLevel = collect([
'volumes' => collect(data_get($yaml, 'volumes', [])),
@@ -3671,6 +3672,7 @@ function serviceParser(Service $resource): Collection
data_forget($resource, 'environment_variables_preview');
$resource->save();
+ fillDockerComposeDefaultsForBareReferences($resource, $existingEnvironmentVariableKeys);
syncDockerComposeEnvironmentVariableRequiredState($resource);
return $topLevel;
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index e852ec50f0..420f4322fe 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -5580,6 +5580,63 @@ function dockerComposeEnvironmentVariableInfo(?string $dockerCompose, string $ke
];
}
+/**
+ * Docker Compose gives a bare $VAR or ${VAR} reference no default, and the service parser does not store
+ * defaults. So `/$POSTGRES_DB` next to `${POSTGRES_DB:-umami}` in another service resolves to an empty value.
+ * For new variables, store the default when the Compose file has a bare reference and exactly one default.
+ *
+ * @param Collection $existingKeys
+ */
+function fillDockerComposeDefaultsForBareReferences(Service $resource, Collection $existingKeys): void
+{
+ $dockerCompose = $resource->docker_compose_raw;
+ if (blank($dockerCompose)) {
+ return;
+ }
+
+ $resource->environment_variables()
+ ->whereNull('value')
+ ->whereNotIn('key', $existingKeys)
+ ->get()
+ ->each(function (EnvironmentVariable $environmentVariable) use ($dockerCompose): void {
+ $default = dockerComposeBareReferenceDefault($dockerCompose, $environmentVariable->key);
+ // A nested default (`${A:-${B}}`) must stay with Compose: stored, it would be escaped to a literal.
+ if (filled($default) && ! str_contains($default, '$')) {
+ $environmentVariable->update(['value' => $default]);
+ }
+ });
+}
+
+/** The single default of a variable that the Compose file also references without a default, otherwise null. */
+function dockerComposeBareReferenceDefault(string $dockerCompose, string $key): ?string
+{
+ $quotedKey = preg_quote($key, '/');
+ if (preg_match('/(? 0 && $dockerCompose[$referenceStart - 1] === '$') {
+ continue;
+ }
+
+ $reference = extractBalancedBraceContent(substr($dockerCompose, $referenceStart));
+ $split = $reference === null ? null : splitOnOperatorOutsideNested($reference['content']);
+ if ($split === null || $split['variable'] !== $key) {
+ continue;
+ }
+ if (str_contains($split['operator'], '?')) {
+ return null;
+ }
+ $defaults[$split['default']] = true;
+ }
+
+ return count($defaults) === 1 ? (string) array_key_first($defaults) : null;
+}
+
function dockerComposeEnvironmentVariableIsRequired(?string $dockerCompose, string $key): bool
{
if (blank($dockerCompose)) {
diff --git a/tests/Feature/EnvironmentVariableSearchTest.php b/tests/Feature/EnvironmentVariableSearchTest.php
index 67c713cad1..9b522e060c 100644
--- a/tests/Feature/EnvironmentVariableSearchTest.php
+++ b/tests/Feature/EnvironmentVariableSearchTest.php
@@ -396,6 +396,45 @@ YAML;
->and(Yaml::parse($service->fresh()->docker_compose_raw))->toBe(Yaml::parse($dockerCompose));
});
+it('stores the Compose default for a variable that another service references without one', function (string $dockerCompose) {
+ $service = Service::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id,
+ 'docker_compose_raw' => $dockerCompose,
+ ]);
+
+ $service->parse();
+
+ expect($service->environment_variables()->where('key', 'POSTGRES_DB')->value('value'))->toBe('umami');
+})->with([
+ 'reference before default' => "services:\n app:\n image: nginx\n environment:\n DATABASE_URL: postgres://user:pass@postgresql:5432/\$POSTGRES_DB\n postgresql:\n image: postgres:16-alpine\n environment:\n POSTGRES_DB: \${POSTGRES_DB:-umami}\n",
+ 'default before reference' => "services:\n postgresql:\n image: postgres:16-alpine\n environment:\n POSTGRES_DB: \${POSTGRES_DB:-umami}\n app:\n image: nginx\n environment:\n DATABASE_URL: postgres://user:pass@postgresql:5432/\${POSTGRES_DB}\n",
+]);
+
+it('does not store a nested Compose default for a bare reference', function () {
+ $service = Service::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id,
+ 'docker_compose_raw' => "services:\n app:\n image: alpine\n environment:\n DB: \${DB:-\${APP_NAME}}\n URL: postgres://db/\$DB\n",
+ ]);
+
+ $service->parse();
+
+ expect($service->environment_variables()->where('key', 'DB')->firstOrFail()->value)->toBeNull();
+});
+
+it('does not store a Compose default when the defaults for a bare reference conflict', function () {
+ $service = Service::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id,
+ 'docker_compose_raw' => "services:\n app:\n image: alpine\n environment:\n LEVEL: \${LEVEL:-info}\n LOG: level=\$LEVEL\n worker:\n image: alpine\n environment:\n LEVEL: \${LEVEL:-warn}\n",
+ ]);
+
+ $service->parse();
+
+ expect($service->environment_variables()->where('key', 'LEVEL')->firstOrFail()->value)->toBeNull();
+});
+
it('keeps editable and literal rows when services use the same key differently', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
From 6ef29c64a10f66e0a614219b12f9f7f9867e368e Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 2 Nov 2026 14:57:48 +0100
Subject: [PATCH 18/18] fix(cdn): block releases sync before the current
release is published
Co-Authored-By: Claude Opus 5.5
---
app/Console/Commands/SyncCdn.php | 7 +++++++
tests/Feature/SyncCdnTest.php | 32 +++++++++++++++++++++++++++++++-
2 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/app/Console/Commands/SyncCdn.php b/app/Console/Commands/SyncCdn.php
index c447c5f726..97319997e2 100644
--- a/app/Console/Commands/SyncCdn.php
+++ b/app/Console/Commands/SyncCdn.php
@@ -103,6 +103,13 @@ class SyncCdn extends Command
return false;
}
+ $currentTag = 'v'.config('constants.coolify.version');
+ if (! $nightly && ! collect($response->json())->contains('tag_name', $currentTag)) {
+ $this->error("GitHub release {$currentTag} is not published yet. Publish it first, or the What's New page will not show it.");
+
+ return false;
+ }
+
$releasesFile = tempnam(sys_get_temp_dir(), 'coolify-releases-');
if ($releasesFile === false || file_put_contents($releasesFile, json_encode($response->json(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
$this->error('Failed to create temporary releases.json.');
diff --git a/tests/Feature/SyncCdnTest.php b/tests/Feature/SyncCdnTest.php
index 52e0ea8945..5b40372985 100644
--- a/tests/Feature/SyncCdnTest.php
+++ b/tests/Feature/SyncCdnTest.php
@@ -51,7 +51,9 @@ it('only removes validated Coolify CDN temporary directories', function () {
it('selects the environment and release files to sync to GitHub', function (string $targetDirectory, string $environment, array $selectedBasenames) {
Http::fake([
- 'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([], 200),
+ 'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([
+ ['tag_name' => 'v'.config('constants.coolify.version')],
+ ], 200),
]);
$binDir = sys_get_temp_dir().'/sync-cdn-bin-'.uniqid();
@@ -152,6 +154,34 @@ SH);
]],
]);
+it('does not sync production releases before the current release is published', function () {
+ Http::fake([
+ 'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([['tag_name' => 'v0.0.1']], 200),
+ ]);
+
+ $this->artisan('sync:cdn')
+ ->expectsChoice('Which environment would you like to sync?', 'production', [
+ 'production' => 'Production',
+ 'nightly' => 'Nightly',
+ ])
+ ->expectsChoice('Which files would you like to sync?', ['json/coolify/releases.json'], [
+ 'json/coolify/releases.json',
+ 'json/coolify/versions.json',
+ 'json/coolify/docker-compose.yml',
+ 'json/coolify/docker-compose.prod.yml',
+ 'json/coolify/.env.production',
+ 'json/coolify/install.sh',
+ 'json/coolify/upgrade.sh',
+ 'json/coolify/upgrade-postgres.sh',
+ 'json/coolify/patch.sh',
+ 'json/coolify/service-templates-latest.json',
+ ])
+ ->expectsOutputToContain('is not published yet')
+ ->doesntExpectOutputToContain('After the CDN pull request is merged');
+
+ Http::assertSentCount(1);
+});
+
it('purges the CDN files from the Cloudflare cache', function (string $environment, array $cdnDirectories) {
config()->set('constants.cloudflare.api_token', 'test-token');
config()->set('constants.cloudflare.zone_id', 'test-zone');