diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index fed63d798..0192befd1 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -969,6 +969,13 @@ private function create_application(Request $request, $type)
], 422);
}
+ $return = $this->validateTagsParameter($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
+ $tagNames = $request->input('tags') ?? [];
+
$environmentUuid = $request->environment_uuid;
$environmentName = $request->environment_name;
if (blank($environmentUuid) && blank($environmentName)) {
@@ -1226,8 +1233,8 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($application, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true);
@@ -1472,8 +1479,8 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($application, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true);
@@ -1688,8 +1695,8 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($application, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true);
@@ -1815,8 +1822,8 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($application, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true);
@@ -1941,8 +1948,8 @@ private function create_application(Request $request, $type)
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($application, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($application, $tagNames, $teamId);
}
$application->isConfigurationChanged(true);
diff --git a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php
index b6d6d259a..4c15d0726 100644
--- a/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php
+++ b/app/Http/Controllers/Api/Concerns/HandlesTagsApi.php
@@ -6,7 +6,6 @@
use App\Models\Tag;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
trait HandlesTagsApi
@@ -46,7 +45,7 @@ public function createTag(Request $request): JsonResponse
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -65,9 +64,9 @@ public function createTag(Request $request): JsonResponse
}
$validator = Validator::make($request->all(), [
- 'tag_name' => 'required_without:tag_names|string|min:2',
+ 'tag_name' => 'required_without:tag_names|string',
'tag_names' => 'required_without:tag_name|array|min:1',
- 'tag_names.*' => 'string|min:2',
+ 'tag_names.*' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']);
@@ -85,7 +84,14 @@ public function createTag(Request $request): JsonResponse
], 422);
}
- $tagNames = $request->has('tag_names') ? $request->tag_names : [$request->tag_name];
+ $tagNames = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]);
+ $invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
+ if (! empty($invalidTags)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']],
+ ], 422);
+ }
$this->attachTagsToResource($resource, $tagNames, $teamId);
@@ -111,32 +117,58 @@ public function deleteTag(Request $request): JsonResponse
return response()->json(['message' => 'Tag not found.'], 404);
}
- $resource->tags()->detach($tag->id);
-
- if (DB::table('taggables')->where('tag_id', $tag->id)->count() === 0) {
- $tag->delete();
+ if (! $resource->tags()->whereKey($tag->id)->exists()) {
+ return response()->json(['message' => 'Tag not found on resource.'], 404);
}
+ $resource->tags()->detach($tag->id);
+ $tag->deleteIfOrphaned();
+
return response()->json(['message' => 'Tag removed.']);
}
protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void
{
- foreach ($tagNames as $tagName) {
- $tagName = strtolower(strip_tags($tagName));
- if (strlen($tagName) < 2) {
+ foreach ($this->normalizeTagNames($tagNames) as $tagName) {
+ if (mb_strlen($tagName) < 2) {
continue;
}
- $tag = Tag::where('team_id', $teamId)->where('name', $tagName)->first();
- if (! $tag) {
- $tag = Tag::create([
- 'name' => $tagName,
- 'team_id' => $teamId,
- ]);
- }
+ $tag = Tag::query()->createOrFirst([
+ 'team_id' => $teamId,
+ 'name' => $tagName,
+ ]);
$resource->tags()->syncWithoutDetaching([$tag->id]);
}
}
+
+ protected function validateTagsParameter(Request $request): ?JsonResponse
+ {
+ if (! $request->has('tags')) {
+ return null;
+ }
+
+ $tagNames = $this->normalizeTagNames($request->input('tags', []));
+ $invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
+ if (! empty($invalidTags)) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']],
+ ], 422);
+ }
+
+ $request->merge(['tags' => $tagNames]);
+
+ return null;
+ }
+
+ protected function normalizeTagNames(array $tagNames): array
+ {
+ return collect($tagNames)
+ ->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName))))
+ ->unique()
+ ->values()
+ ->all();
+ }
}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index a8c89ca13..d3dd77955 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -1663,7 +1663,7 @@ public function create_database_mongodb(Request $request)
public function create_database(Request $request, NewDatabaseTypes $type)
{
- $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
+ $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -1771,6 +1771,13 @@ public function create_database(Request $request, NewDatabaseTypes $type)
'errors' => $validator->errors(),
], 422);
}
+ $return = $this->validateTagsParameter($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
+ $tagNames = $request->input('tags') ?? [];
+
if ($request->public_port) {
if ($request->public_port < 1024 || $request->public_port > 65535) {
return response()->json([
@@ -1830,8 +1837,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($database, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
$payload = [
@@ -1901,8 +1908,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($database, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
@@ -1973,8 +1980,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($database, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
@@ -2042,8 +2049,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($database, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
@@ -2092,8 +2099,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($database, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
}
return response()->json(serializeApiResponse([
@@ -2144,8 +2151,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($database, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
@@ -2193,8 +2200,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($database, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
@@ -2264,8 +2271,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
- if ($request->has('tags')) {
- $this->attachTagsToResource($database, $request->tags, $teamId);
+ if ($tagNames !== []) {
+ $this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh();
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 60ef6b3eb..46e93ca1e 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -352,6 +352,11 @@ public function create_service(Request $request)
], 422);
}
+ $return = $this->validateTagsParameter($request);
+ if ($return instanceof JsonResponse) {
+ return $return;
+ }
+
if (filled($request->type) && filled($request->docker_compose_raw)) {
return response()->json([
'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.',
@@ -533,6 +538,8 @@ public function create_service(Request $request)
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
+ 'tags' => 'array|nullable',
+ 'tags.*' => 'string|min:2',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
diff --git a/app/Livewire/Project/Shared/Tags.php b/app/Livewire/Project/Shared/Tags.php
index 37b8b277a..61d04e20b 100644
--- a/app/Livewire/Project/Shared/Tags.php
+++ b/app/Livewire/Project/Shared/Tags.php
@@ -91,9 +91,7 @@ public function deleteTag(string $id)
$this->authorize('update', $this->resource);
$this->resource->tags()->detach($id);
$found_more_tags = Tag::ownedByCurrentTeam()->find($id);
- if ($found_more_tags && $found_more_tags->applications()->count() == 0 && $found_more_tags->services()->count() == 0) {
- $found_more_tags->delete();
- }
+ $found_more_tags?->deleteIfOrphaned();
$this->refresh();
$this->dispatch('success', 'Tag deleted.');
} catch (\Exception $e) {
diff --git a/app/Models/Tag.php b/app/Models/Tag.php
index 0080b79d6..d5cccabd8 100644
--- a/app/Models/Tag.php
+++ b/app/Models/Tag.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\HasSafeStringAttribute;
+use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
#[OA\Schema(
@@ -34,6 +35,13 @@ public static function ownedByCurrentTeam()
return Tag::whereTeamId(currentTeam()->id)->orderBy('name');
}
+ public function deleteIfOrphaned(): void
+ {
+ if (DB::table('taggables')->where('tag_id', $this->id)->doesntExist()) {
+ $this->delete();
+ }
+ }
+
public function applications()
{
return $this->morphedByMany(Application::class, 'taggable');
diff --git a/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php b/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php
new file mode 100644
index 000000000..a25fdc18b
--- /dev/null
+++ b/database/migrations/2026_07_07_113248_add_team_name_unique_index_to_tags_table.php
@@ -0,0 +1,76 @@
+indexExists()) {
+ return;
+ }
+
+ DB::table('tags')
+ ->select('team_id', 'name', DB::raw('MIN(id) as keep_id'), DB::raw('COUNT(*) as tag_count'))
+ ->whereNotNull('team_id')
+ ->groupBy('team_id', 'name')
+ ->havingRaw('COUNT(*) > 1')
+ ->orderBy('keep_id')
+ ->cursor()
+ ->each(function ($duplicate): void {
+ DB::table('tags')
+ ->select('id')
+ ->where('team_id', $duplicate->team_id)
+ ->where('name', $duplicate->name)
+ ->where('id', '!=', $duplicate->keep_id)
+ ->orderBy('id')
+ ->cursor()
+ ->each(function ($duplicateTag) use ($duplicate): void {
+ DB::table('taggables')
+ ->where('tag_id', $duplicateTag->id)
+ ->orderBy('taggable_id')
+ ->cursor()
+ ->each(function ($taggable) use ($duplicate): void {
+ DB::table('taggables')->updateOrInsert([
+ 'tag_id' => $duplicate->keep_id,
+ 'taggable_id' => $taggable->taggable_id,
+ 'taggable_type' => $taggable->taggable_type,
+ ]);
+ });
+
+ DB::table('taggables')->where('tag_id', $duplicateTag->id)->delete();
+ DB::table('tags')->where('id', $duplicateTag->id)->delete();
+ });
+ });
+
+ Schema::table('tags', function (Blueprint $table) {
+ $table->unique(['team_id', 'name'], 'tags_team_id_name_unique');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ if (! $this->indexExists()) {
+ return;
+ }
+
+ Schema::table('tags', function (Blueprint $table) {
+ $table->dropUnique('tags_team_id_name_unique');
+ });
+ }
+
+ private function indexExists(): bool
+ {
+ return collect(Schema::getIndexes('tags'))
+ ->contains(fn (array $index): bool => $index['name'] === 'tags_team_id_name_unique');
+ }
+};
diff --git a/openapi.json b/openapi.json
index 00c1e3733..3b324a546 100644
--- a/openapi.json
+++ b/openapi.json
@@ -412,6 +412,13 @@
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
},
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the application."
+ },
"is_preserve_repository_enabled": {
"type": "boolean",
"default": false,
@@ -866,6 +873,13 @@
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
},
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the application."
+ },
"is_preserve_repository_enabled": {
"type": "boolean",
"default": false,
@@ -1320,6 +1334,13 @@
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
},
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the application."
+ },
"is_preserve_repository_enabled": {
"type": "boolean",
"default": false,
@@ -1678,6 +1699,13 @@
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the application."
}
},
"type": "object"
@@ -2017,6 +2045,13 @@
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off."
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the application."
}
},
"type": "object"
@@ -3720,6 +3755,179 @@
]
}
},
+ "\/applications\/{uuid}\/tags": {
+ "get": {
+ "tags": [
+ "Applications"
+ ],
+ "summary": "List Tags",
+ "description": "List tags for an application by UUID.",
+ "operationId": "list-tags-by-application-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the application.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of tags.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#\/components\/schemas\/Tag"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Applications"
+ ],
+ "summary": "Create Tag",
+ "description": "Add tag(s) to an application by UUID.",
+ "operationId": "create-tag-by-application-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the application.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "tag_name": {
+ "type": "string",
+ "description": "The tag name (min 2 characters). Required if tag_names is not provided."
+ },
+ "tag_names": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Tags added successfully.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#\/components\/schemas\/Tag"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/applications\/{uuid}\/tags\/{tag_uuid}": {
+ "delete": {
+ "tags": [
+ "Applications"
+ ],
+ "summary": "Delete Tag",
+ "description": "Remove a tag from an application by UUID.",
+ "operationId": "delete-tag-by-application-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the application.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "tag_uuid",
+ "in": "path",
+ "description": "UUID of the tag.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Tag removed."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/cloud-tokens": {
"get": {
"tags": [
@@ -5018,6 +5226,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5150,6 +5365,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5278,6 +5500,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5410,6 +5639,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5542,6 +5778,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5686,6 +5929,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5830,6 +6080,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -5962,6 +6219,13 @@
"instant_deploy": {
"type": "boolean",
"description": "Instant deploy the database"
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the database."
}
},
"type": "object"
@@ -7106,6 +7370,179 @@
]
}
},
+ "\/databases\/{uuid}\/tags": {
+ "get": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "List Tags",
+ "description": "List tags for a database by UUID.",
+ "operationId": "list-tags-by-database-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of tags.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#\/components\/schemas\/Tag"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Create Tag",
+ "description": "Add tag(s) to a database by UUID.",
+ "operationId": "create-tag-by-database-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "tag_name": {
+ "type": "string",
+ "description": "The tag name (min 2 characters). Required if tag_names is not provided."
+ },
+ "tag_names": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Tags added successfully.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#\/components\/schemas\/Tag"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/databases\/{uuid}\/tags\/{tag_uuid}": {
+ "delete": {
+ "tags": [
+ "Databases"
+ ],
+ "summary": "Delete Tag",
+ "description": "Remove a tag from a database by UUID.",
+ "operationId": "delete-tag-by-database-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the database.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "tag_uuid",
+ "in": "path",
+ "description": "UUID of the tag.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Tag removed."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/deployments": {
"get": {
"tags": [
@@ -10968,6 +11405,13 @@
"type": "boolean",
"default": true,
"description": "Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off."
+ },
+ "tags": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Tags to assign to the service."
}
},
"type": "object"
@@ -12390,6 +12834,215 @@
]
}
},
+ "\/services\/{uuid}\/tags": {
+ "get": {
+ "tags": [
+ "Services"
+ ],
+ "summary": "List Tags",
+ "description": "List tags for a service by UUID.",
+ "operationId": "list-tags-by-service-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the service.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of tags.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#\/components\/schemas\/Tag"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "Services"
+ ],
+ "summary": "Create Tag",
+ "description": "Add tag(s) to a service by UUID.",
+ "operationId": "create-tag-by-service-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the service.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "tag_name": {
+ "type": "string",
+ "description": "The tag name (min 2 characters). Required if tag_names is not provided."
+ },
+ "tag_names": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Array of tag names (each min 2 characters). Required if tag_name is not provided."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Tags added successfully.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#\/components\/schemas\/Tag"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/services\/{uuid}\/tags\/{tag_uuid}": {
+ "delete": {
+ "tags": [
+ "Services"
+ ],
+ "summary": "Delete Tag",
+ "description": "Remove a tag from a service by UUID.",
+ "operationId": "delete-tag-by-service-uuid",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the service.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "tag_uuid",
+ "in": "path",
+ "description": "UUID of the tag.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Tag removed."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/tags": {
+ "get": {
+ "tags": [
+ "Tags"
+ ],
+ "summary": "List",
+ "description": "List all tags for the current team.",
+ "operationId": "list-tags",
+ "responses": {
+ "200": {
+ "description": "All tags for the current team.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#\/components\/schemas\/Tag"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/teams": {
"get": {
"tags": [
@@ -13610,6 +14263,24 @@
},
"type": "object"
},
+ "Tag": {
+ "description": "Tag model",
+ "properties": {
+ "uuid": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"Team": {
"description": "Team model",
"properties": {
@@ -13864,6 +14535,10 @@
"name": "Services",
"description": "Services"
},
+ {
+ "name": "Tags",
+ "description": "Tags"
+ },
{
"name": "Teams",
"description": "Teams"
diff --git a/openapi.yaml b/openapi.yaml
index dea6b8589..03486bb40 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -293,6 +293,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the application.'
is_preserve_repository_enabled:
type: boolean
default: false
@@ -583,6 +587,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the application.'
is_preserve_repository_enabled:
type: boolean
default: false
@@ -873,6 +881,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the application.'
is_preserve_repository_enabled:
type: boolean
default: false
@@ -1104,6 +1116,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the application.'
type: object
responses:
'201':
@@ -1321,6 +1337,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the application.'
type: object
responses:
'201':
@@ -2376,6 +2396,121 @@ paths:
security:
-
bearerAuth: []
+ '/applications/{uuid}/tags':
+ get:
+ tags:
+ - Applications
+ summary: 'List Tags'
+ description: 'List tags for an application by UUID.'
+ operationId: list-tags-by-application-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the application.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'List of tags.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Tag'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ post:
+ tags:
+ - Applications
+ summary: 'Create Tag'
+ description: 'Add tag(s) to an application by UUID.'
+ operationId: create-tag-by-application-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the application.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ properties:
+ tag_name:
+ type: string
+ description: 'The tag name (min 2 characters). Required if tag_names is not provided.'
+ tag_names:
+ type: array
+ items: { type: string }
+ description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.'
+ type: object
+ responses:
+ '201':
+ description: 'Tags added successfully.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Tag'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/applications/{uuid}/tags/{tag_uuid}':
+ delete:
+ tags:
+ - Applications
+ summary: 'Delete Tag'
+ description: 'Remove a tag from an application by UUID.'
+ operationId: delete-tag-by-application-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the application.'
+ required: true
+ schema:
+ type: string
+ -
+ name: tag_uuid
+ in: path
+ description: 'UUID of the tag.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Tag removed.'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
/cloud-tokens:
get:
tags:
@@ -3244,6 +3379,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3339,6 +3478,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3431,6 +3574,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3526,6 +3673,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3621,6 +3772,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3725,6 +3880,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3829,6 +3988,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -3924,6 +4087,10 @@ paths:
instant_deploy:
type: boolean
description: 'Instant deploy the database'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the database.'
type: object
responses:
'200':
@@ -4636,6 +4803,121 @@ paths:
security:
-
bearerAuth: []
+ '/databases/{uuid}/tags':
+ get:
+ tags:
+ - Databases
+ summary: 'List Tags'
+ description: 'List tags for a database by UUID.'
+ operationId: list-tags-by-database-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'List of tags.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Tag'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ post:
+ tags:
+ - Databases
+ summary: 'Create Tag'
+ description: 'Add tag(s) to a database by UUID.'
+ operationId: create-tag-by-database-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ properties:
+ tag_name:
+ type: string
+ description: 'The tag name (min 2 characters). Required if tag_names is not provided.'
+ tag_names:
+ type: array
+ items: { type: string }
+ description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.'
+ type: object
+ responses:
+ '201':
+ description: 'Tags added successfully.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Tag'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/databases/{uuid}/tags/{tag_uuid}':
+ delete:
+ tags:
+ - Databases
+ summary: 'Delete Tag'
+ description: 'Remove a tag from a database by UUID.'
+ operationId: delete-tag-by-database-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the database.'
+ required: true
+ schema:
+ type: string
+ -
+ name: tag_uuid
+ in: path
+ description: 'UUID of the tag.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Tag removed.'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
/deployments:
get:
tags:
@@ -7008,6 +7290,10 @@ paths:
type: boolean
default: true
description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'
+ tags:
+ type: array
+ items: { type: string }
+ description: 'Tags to assign to the service.'
type: object
responses:
'201':
@@ -7860,6 +8146,144 @@ paths:
security:
-
bearerAuth: []
+ '/services/{uuid}/tags':
+ get:
+ tags:
+ - Services
+ summary: 'List Tags'
+ description: 'List tags for a service by UUID.'
+ operationId: list-tags-by-service-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the service.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'List of tags.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Tag'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ post:
+ tags:
+ - Services
+ summary: 'Create Tag'
+ description: 'Add tag(s) to a service by UUID.'
+ operationId: create-tag-by-service-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the service.'
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ properties:
+ tag_name:
+ type: string
+ description: 'The tag name (min 2 characters). Required if tag_names is not provided.'
+ tag_names:
+ type: array
+ items: { type: string }
+ description: 'Array of tag names (each min 2 characters). Required if tag_name is not provided.'
+ type: object
+ responses:
+ '201':
+ description: 'Tags added successfully.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Tag'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
+ '/services/{uuid}/tags/{tag_uuid}':
+ delete:
+ tags:
+ - Services
+ summary: 'Delete Tag'
+ description: 'Remove a tag from a service by UUID.'
+ operationId: delete-tag-by-service-uuid
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the service.'
+ required: true
+ schema:
+ type: string
+ -
+ name: tag_uuid
+ in: path
+ description: 'UUID of the tag.'
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Tag removed.'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ /tags:
+ get:
+ tags:
+ - Tags
+ summary: List
+ description: 'List all tags for the current team.'
+ operationId: list-tags
+ responses:
+ '200':
+ description: 'All tags for the current team.'
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Tag'
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ security:
+ -
+ bearerAuth: []
/teams:
get:
tags:
@@ -8740,6 +9164,18 @@ components:
type: string
description: 'The date and time when the service was deleted.'
type: object
+ Tag:
+ description: 'Tag model'
+ properties:
+ uuid:
+ type: string
+ name:
+ type: string
+ created_at:
+ type: string
+ updated_at:
+ type: string
+ type: object
Team:
description: 'Team model'
properties:
@@ -8911,6 +9347,9 @@ tags:
-
name: Services
description: Services
+ -
+ name: Tags
+ description: Tags
-
name: Teams
description: Teams
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json
index 8d1a3369b..0a2dfda9d 100644
--- a/templates/service-templates-latest.json
+++ b/templates/service-templates-latest.json
@@ -803,7 +803,7 @@
"category": "backend",
"logo": "svgs/convex.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-05-09T19:26:30+05:30",
+ "template_last_updated_at": "2026-06-12T10:45:52+02:00",
"port": "6791"
},
"cryptgeon": {
@@ -1779,7 +1779,7 @@
"category": "devtools",
"logo": "svgs/gitea.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-06-01T07:54:27-05:00"
+ "template_last_updated_at": "2026-06-06T00:11:24+02:00"
},
"gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io",
@@ -2361,7 +2361,7 @@
"category": "automation",
"logo": "svgs/inngest.png",
"minversion": "0.0.0",
- "template_last_updated_at": null,
+ "template_last_updated_at": "2026-07-02T13:25:47+02:00",
"port": "8288"
},
"invoice-ninja": {
diff --git a/templates/service-templates.json b/templates/service-templates.json
index 2ad3008f5..4d97d8d5e 100644
--- a/templates/service-templates.json
+++ b/templates/service-templates.json
@@ -803,7 +803,7 @@
"category": "backend",
"logo": "svgs/convex.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-05-09T19:26:30+05:30",
+ "template_last_updated_at": "2026-06-12T10:45:52+02:00",
"port": "6791"
},
"cryptgeon": {
@@ -1779,7 +1779,7 @@
"category": "devtools",
"logo": "svgs/gitea.svg",
"minversion": "0.0.0",
- "template_last_updated_at": "2026-06-01T07:54:27-05:00"
+ "template_last_updated_at": "2026-06-06T00:11:24+02:00"
},
"gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io",
@@ -2361,7 +2361,7 @@
"category": "automation",
"logo": "svgs/inngest.png",
"minversion": "0.0.0",
- "template_last_updated_at": null,
+ "template_last_updated_at": "2026-07-02T13:25:47+02:00",
"port": "8288"
},
"invoice-ninja": {
diff --git a/tests/Feature/TagApiTest.php b/tests/Feature/TagApiTest.php
index dd62c1062..448d7917f 100644
--- a/tests/Feature/TagApiTest.php
+++ b/tests/Feature/TagApiTest.php
@@ -1,5 +1,6 @@
0]);
+ InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -55,7 +58,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/tags');
- $response->assertStatus(200);
+ $response->assertOk();
$response->assertJsonCount(2);
$response->assertJsonFragment(['name' => 'production']);
$response->assertJsonFragment(['name' => 'staging']);
@@ -65,7 +68,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/tags');
- $response->assertStatus(200);
+ $response->assertOk();
$response->assertJsonCount(0);
});
@@ -77,7 +80,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/tags');
- $response->assertStatus(200);
+ $response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'my-tag']);
$response->assertJsonMissing(['name' => 'other-team-tag']);
@@ -92,7 +95,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
- $response->assertStatus(200);
+ $response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'production']);
});
@@ -101,14 +104,14 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson('/api/v1/applications/non-existent-uuid/tags');
- $response->assertStatus(404);
+ $response->assertNotFound();
});
test('returns empty array when application has no tags', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
- $response->assertStatus(200);
+ $response->assertOk();
$response->assertJsonCount(0);
});
});
@@ -120,7 +123,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'production',
]);
- $response->assertStatus(201);
+ $response->assertCreated();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'production']);
@@ -133,7 +136,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_names' => ['production', 'frontend'],
]);
- $response->assertStatus(201);
+ $response->assertCreated();
$response->assertJsonCount(2);
expect($this->application->tags()->count())->toBe(2);
@@ -147,7 +150,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'production',
]);
- $response->assertStatus(201);
+ $response->assertCreated();
expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->count())->toBe(1);
});
@@ -157,7 +160,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'x',
]);
- $response->assertStatus(422);
+ $response->assertUnprocessable();
});
test('rejects both tag_name and tag_names provided simultaneously', function () {
@@ -167,7 +170,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_names' => ['staging'],
]);
- $response->assertStatus(422);
+ $response->assertUnprocessable();
$response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]);
});
@@ -180,7 +183,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'production',
]);
- $response->assertStatus(201);
+ $response->assertCreated();
expect($this->application->tags()->count())->toBe(1);
});
@@ -190,7 +193,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'production',
]);
- $response->assertStatus(404);
+ $response->assertNotFound();
});
});
@@ -202,7 +205,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
- $response->assertStatus(200);
+ $response->assertOk();
expect($this->application->tags()->count())->toBe(0);
});
@@ -216,6 +219,16 @@ function tagApiAuthHeaders($bearerToken): array
expect(Tag::find($tag->id))->toBeNull();
});
+ test('returns 404 when deleting a tag that is not attached to the application', function () {
+ $tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
+
+ $response->assertNotFound();
+ expect(Tag::find($tag->id))->not->toBeNull();
+ });
+
test('keeps tag if still used by other resources', function () {
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
$this->application->tags()->attach($tag->id);
@@ -237,7 +250,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/non-existent-uuid");
- $response->assertStatus(404);
+ $response->assertNotFound();
});
});
@@ -257,7 +270,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/databases/{$database->uuid}/tags");
- $response->assertStatus(200);
+ $response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'database-tag']);
});
@@ -278,7 +291,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'database-tag',
]);
- $response->assertStatus(201);
+ $response->assertCreated();
expect($database->tags()->count())->toBe(1);
});
});
@@ -299,7 +312,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}");
- $response->assertStatus(200);
+ $response->assertOk();
expect($database->tags()->count())->toBe(0);
});
});
@@ -319,7 +332,7 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->getJson("/api/v1/services/{$service->uuid}/tags");
- $response->assertStatus(200);
+ $response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'service-tag']);
});
@@ -339,7 +352,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'service-tag',
]);
- $response->assertStatus(201);
+ $response->assertCreated();
expect($service->tags()->count())->toBe(1);
});
});
@@ -359,11 +372,91 @@ function tagApiAuthHeaders($bearerToken): array
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}");
- $response->assertStatus(200);
+ $response->assertOk();
expect($service->tags()->count())->toBe(0);
});
});
+describe('Resource creation tags', function () {
+ test('adds tags while creating a public application', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson('/api/v1/applications/public', [
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => $this->environment->uuid,
+ 'server_uuid' => $this->server->uuid,
+ 'git_repository' => 'https://gitlab.com/coolify/test-static-app',
+ 'git_branch' => 'main',
+ 'build_pack' => 'static',
+ 'ports_exposes' => '80',
+ 'autogenerate_domain' => false,
+ 'tags' => ['production', 'frontend'],
+ ]);
+
+ $response->assertSuccessful();
+
+ $application = Application::whereUuid($response->json('uuid'))->firstOrFail();
+
+ expect($application->tags()->pluck('name')->sort()->values()->all())
+ ->toBe(['frontend', 'production']);
+ });
+
+ test('adds tags while creating a postgresql database', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson('/api/v1/databases/postgresql', [
+ 'server_uuid' => $this->server->uuid,
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => $this->environment->uuid,
+ 'instant_deploy' => false,
+ 'tags' => ['database', 'production'],
+ ]);
+
+ $response->assertSuccessful();
+
+ $database = StandalonePostgresql::whereUuid($response->json('uuid'))->firstOrFail();
+
+ expect($database->tags()->pluck('name')->sort()->values()->all())
+ ->toBe(['database', 'production']);
+ });
+
+ test('adds tags while creating a docker compose service', function () {
+ $compose = base64_encode(<<<'YAML'
+services:
+ nginx:
+ image: nginx:alpine
+YAML);
+
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson('/api/v1/services', [
+ 'server_uuid' => $this->server->uuid,
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => $this->environment->uuid,
+ 'docker_compose_raw' => $compose,
+ 'instant_deploy' => false,
+ 'tags' => ['service', 'production'],
+ ]);
+
+ $response->assertCreated();
+
+ $service = Service::whereUuid($response->json('uuid'))->firstOrFail();
+
+ expect($service->tags()->pluck('name')->sort()->values()->all())
+ ->toBe(['production', 'service']);
+ });
+
+ test('rejects creation tags that are too short after sanitization', function () {
+ $response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
+ ->postJson('/api/v1/databases/postgresql', [
+ 'server_uuid' => $this->server->uuid,
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => $this->environment->uuid,
+ 'instant_deploy' => false,
+ 'tags' => ['x'],
+ ]);
+
+ $response->assertUnprocessable();
+ });
+});
+
describe('Tag name sanitization', function () {
test('strips HTML tags from tag names', function () {
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
@@ -371,7 +464,7 @@ function tagApiAuthHeaders($bearerToken): array
'tag_name' => 'production',
]);
- $response->assertStatus(201);
+ $response->assertCreated();
$response->assertJsonFragment(['name' => 'alert("xss")production']);
$response->assertJsonMissing(['name' => '