diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 27a9064632..6669791fe0 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -108,8 +108,7 @@ class StartRedis 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => $this->database->healthCheckConfiguration([ 'CMD-SHELL', - 'redis-cli', - 'ping', + $this->healthCheckCommand(), ]), 'mem_limit' => $this->database->limits_memory, 'memswap_limit' => $this->database->limits_memory_swap, @@ -340,6 +339,20 @@ class StartRedis return $keepsUnquotedPassword ? $this->resolvedRedisPassword : escapeshellarg($this->resolvedRedisPassword); } + /** + * The healthcheck pings with the server password and requires PONG, because redis-cli exits 0 on + * error replies (NOAUTH, LOADING). A custom redis.conf with its own requirepass has a password that + * Coolify does not know, so that check only requires that the server replies. + */ + private function healthCheckCommand(): string + { + if (str_contains((string) $this->database->redis_conf, 'requirepass')) { + return "redis-cli ping | grep -qE 'PONG|NOAUTH'"; + } + + return 'redis-cli -a '.$this->requirePassArgument().' --no-auth-warning ping | grep -q PONG'; + } + private function add_custom_redis() { if (is_null($this->database->redis_conf) || empty($this->database->redis_conf)) { diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 7bb35ade98..40d0c794eb 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1730,8 +1730,8 @@ class DatabasesController extends Controller } #[OA\Post( - summary: 'Create (Redis)', - description: 'Create a new Redis database.', + summary: 'Create (Redis / Valkey)', + description: 'Create a new Redis or Valkey database. Valkey (valkey/valkey:9-alpine) is the default. Set image to a Redis image (for example redis:8) for Redis.', path: '/databases/redis', operationId: 'create-database-redis', security: [ @@ -1757,7 +1757,7 @@ class DatabasesController extends Controller 'redis_conf' => ['type' => 'string', 'description' => 'Redis conf'], 'name' => ['type' => 'string', 'description' => 'Name of the database'], 'description' => ['type' => 'string', 'description' => 'Description of the database'], - 'image' => ['type' => 'string', 'description' => 'Docker Image of the database'], + 'image' => ['type' => 'string', 'description' => 'Docker image of the database. Default: valkey/valkey:9-alpine. For Redis, use a Redis image, for example redis:8.'], 'is_public' => ['type' => 'boolean', 'description' => 'Is the database public?'], 'public_port' => ['type' => 'integer', 'description' => 'Public port of the database'], 'public_port_timeout' => ['type' => 'integer', 'description' => 'Public port timeout in seconds (default: 3600)'], diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index fd32451bb7..193eff604f 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -207,6 +207,7 @@ class GlobalSearch extends Component 'new mysql' => 'mysql', 'new mariadb' => 'mariadb', 'new redis' => 'redis', + 'new valkey' => 'redis', 'new keydb' => 'keydb', 'new dragonfly' => 'dragonfly', 'new sqlite' => 'sqlite', @@ -477,7 +478,7 @@ class GlobalSearch extends Component 'link' => $db->link(), 'project' => $db->environment->project->name ?? null, 'environment' => $db->environment->name ?? null, - 'search_text' => strtolower($db->name.' '.$db->uuid.' redis '.$db->description.' database databases db'), + 'search_text' => strtolower($db->name.' '.$db->uuid.' redis valkey '.$db->description.' database databases db'), ]; }) ); @@ -1346,12 +1347,12 @@ class GlobalSearch extends Component ]); $items->push([ - 'name' => 'Redis', - 'description' => 'In-memory data structure store', - 'quickcommand' => '(type: new redis)', + 'name' => 'Redis / Valkey', + 'description' => 'In-memory data structure store (Redis or Valkey)', + 'quickcommand' => '(type: new redis or new valkey)', 'type' => 'redis', 'category' => 'Databases', - 'logo' => 'svgs/redis.svg', + 'logo' => 'svgs/resources/redis-valkey.svg', 'resourceType' => 'database', ]); diff --git a/app/Livewire/Project/Database/Redis/StatusInfo.php b/app/Livewire/Project/Database/Redis/StatusInfo.php index 2e784e2c09..ca1079285f 100644 --- a/app/Livewire/Project/Database/Redis/StatusInfo.php +++ b/app/Livewire/Project/Database/Redis/StatusInfo.php @@ -16,6 +16,6 @@ class StatusInfo extends Component protected function databaseLabel(): string { - return 'Redis'; + return 'Redis / Valkey'; } } diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index ecc8ef0dd3..adf1b6cd31 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -38,6 +38,12 @@ class Select extends Component public Collection|array $allServices = []; + /** Database types that ask for an image before the database is created. */ + private const DATABASE_IMAGE_STEPS = [ + 'postgresql' => 'select-postgresql-type', + 'redis' => 'select-redis-type', + ]; + public bool $isDatabase = false; public bool $includeSwarm = true; @@ -50,8 +56,6 @@ class Select extends Component public ?string $selectedEnvironment = null; - public string $postgresql_type = 'postgres:18-alpine'; - public ?string $existingPostgresqlUrl = null; protected $queryString = [ @@ -72,20 +76,21 @@ class Select extends Component $this->environments = $project->environments; $this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name; - // Check if we have all required params for PostgreSQL type selection + // Check if we have all required params for the database image step // This handles navigation from global search $queryType = request()->query('type'); $queryServerId = request()->query('server_id'); $queryDestination = request()->query('destination'); + $imageStep = self::DATABASE_IMAGE_STEPS[$queryType] ?? null; - if ($queryType === 'postgresql' && $queryServerId !== null && $queryDestination) { + if ($imageStep && $queryServerId !== null && $queryDestination) { $server = Server::isUsable()->whereKey($queryServerId)->first(); if ($server && $this->findDestination($server, $queryDestination)) { $this->type = $queryType; $this->server_id = $server->id; $this->destination_uuid = $queryDestination; $this->server = $server; - $this->current_step = 'select-postgresql-type'; + $this->current_step = $imageStep; } } } catch (\Exception $e) { @@ -217,7 +222,7 @@ class Select extends Component [ 'id' => 'postgresql', 'name' => 'PostgreSQL', - 'description' => 'A relational database with strong SQL standards support and extensibility.', + 'description' => 'An extensible relational database with strong SQL support.', 'logo' => resource_logo_url('postgres'), ], [ @@ -235,9 +240,10 @@ class Select extends Component ], [ 'id' => 'redis', - 'name' => 'Redis', - 'description' => 'An in-memory key-value store used as a database, cache, and message broker.', - 'logo' => resource_logo_url('redis'), + 'name' => 'Redis / Valkey', + 'description' => 'An in-memory key-value store for caching, queues, and sessions.', + 'logo' => resource_logo_url('redis-valkey'), + 'logoDark' => resource_logo_url('redis-valkey-dark'), ], [ 'id' => 'keydb', @@ -256,13 +262,13 @@ class Select extends Component [ 'id' => 'mongodb', 'name' => 'MongoDB', - 'description' => 'A document-oriented NoSQL database that stores JSON-like documents.', + 'description' => 'A NoSQL database that stores JSON-like documents.', 'logo' => resource_logo_url('mongodb'), ], [ 'id' => 'clickhouse', 'name' => 'ClickHouse', - 'description' => 'A column-oriented database for real-time analytics over large datasets.', + 'description' => 'A column-oriented database for real-time analytics.', 'logo' => resource_logo_url('clickhouse'), ], [ @@ -484,24 +490,22 @@ class Select extends Component return $server->standaloneDockers->concat($server->swarmDockers)->firstWhere('uuid', $destinationUuid); } - public function setPostgresqlType(string $type) + public function setDatabaseImage(string $image) { - $this->postgresql_type = $type; - return redirect()->route('project.resource.create', [ 'project_uuid' => $this->parameters['project_uuid'], 'environment_uuid' => $this->parameters['environment_uuid'], 'type' => $this->type, 'destination' => $this->destination_uuid, 'server_id' => $this->server_id, - 'database_image' => $this->postgresql_type, + 'database_image' => $image, ]); } public function whatToDoNext() { - if ($this->type === 'postgresql') { - $this->current_step = 'select-postgresql-type'; + if (isset(self::DATABASE_IMAGE_STEPS[$this->type])) { + $this->current_step = self::DATABASE_IMAGE_STEPS[$this->type]; } else { return redirect()->route('project.resource.create', [ 'project_uuid' => $this->parameters['project_uuid'], diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index 12de2808f1..a0951330bd 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -51,9 +51,9 @@ class Create extends Component } $isDatabase = in_array($type, DATABASE_TYPES); - // PostgreSQL requires database_image to be explicitly set - // If not provided, fall through to Select component for version selection - if ($isDatabase && $type->value() === 'postgresql' && ! $database_image) { + // PostgreSQL and Redis (Redis or Valkey) require database_image to be explicitly set + // If not provided, fall through to Select component for image selection + if ($isDatabase && in_array($type->value(), ['postgresql', 'redis'], true) && ! $database_image) { $this->type = $type->value(); return; @@ -98,7 +98,7 @@ class Create extends Component databaseImage: $this->databaseImage ); } elseif ($type->value() === 'redis') { - $database = create_standalone_redis($environment->id, $destination); + $database = create_standalone_redis($environment->id, $destination, ['image' => $this->databaseImage]); } elseif ($type->value() === 'mongodb') { $database = create_standalone_mongodb($environment->id, $destination); } elseif ($type->value() === 'mysql') { diff --git a/app/Services/EnvironmentCanvas.php b/app/Services/EnvironmentCanvas.php index 47ea3e87c4..c4acc84207 100644 --- a/app/Services/EnvironmentCanvas.php +++ b/app/Services/EnvironmentCanvas.php @@ -45,7 +45,7 @@ class EnvironmentCanvas private const DATABASE_ENGINE_NAMES = [ 'postgresql' => 'PostgreSQL', - 'redis' => 'Redis', + 'redis' => 'Redis / Valkey', 'mongodb' => 'MongoDB', 'mysql' => 'MySQL', 'mariadb' => 'MariaDB', diff --git a/database/migrations/2026_11_06_113843_set_valkey_as_standalone_redis_default_image.php b/database/migrations/2026_11_06_113843_set_valkey_as_standalone_redis_default_image.php new file mode 100644 index 0000000000..175d5c80c6 --- /dev/null +++ b/database/migrations/2026_11_06_113843_set_valkey_as_standalone_redis_default_image.php @@ -0,0 +1,25 @@ +string('image')->default('valkey/valkey:9-alpine')->change(); + }); + } + + public function down(): void + { + Schema::table('standalone_redis', function (Blueprint $table) { + $table->string('image')->default('redis:8')->change(); + }); + } +}; diff --git a/openapi.json b/openapi.json index cba7986122..0e44663be1 100644 --- a/openapi.json +++ b/openapi.json @@ -9053,8 +9053,8 @@ "tags": [ "Databases" ], - "summary": "Create (Redis)", - "description": "Create a new Redis database.", + "summary": "Create (Redis \/ Valkey)", + "description": "Create a new Redis or Valkey database. Valkey (valkey\/valkey:9-alpine) is the default. Set image to a Redis image (for example redis:8) for Redis.", "operationId": "create-database-redis", "requestBody": { "description": "Database data", @@ -9105,7 +9105,7 @@ }, "image": { "type": "string", - "description": "Docker Image of the database" + "description": "Docker image of the database. Default: valkey\/valkey:9-alpine. For Redis, use a Redis image, for example redis:8." }, "is_public": { "type": "boolean", diff --git a/openapi.yaml b/openapi.yaml index 8f713c76b4..6fd1432ce3 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5937,8 +5937,8 @@ paths: post: tags: - Databases - summary: 'Create (Redis)' - description: 'Create a new Redis database.' + summary: 'Create (Redis / Valkey)' + description: 'Create a new Redis or Valkey database. Valkey (valkey/valkey:9-alpine) is the default. Set image to a Redis image (for example redis:8) for Redis.' operationId: create-database-redis requestBody: description: 'Database data' @@ -5979,7 +5979,7 @@ paths: description: 'Description of the database' image: type: string - description: 'Docker Image of the database' + description: 'Docker image of the database. Default: valkey/valkey:9-alpine. For Redis, use a Redis image, for example redis:8.' is_public: type: boolean description: 'Is the database public?' diff --git a/public/svgs/resources/redis-valkey-dark.svg b/public/svgs/resources/redis-valkey-dark.svg new file mode 100644 index 0000000000..1b70ab6132 --- /dev/null +++ b/public/svgs/resources/redis-valkey-dark.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/resources/redis-valkey.svg b/public/svgs/resources/redis-valkey.svg new file mode 100644 index 0000000000..4dc437facd --- /dev/null +++ b/public/svgs/resources/redis-valkey.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index ff4acf3d21..3d0e608c5c 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -264,7 +264,7 @@ 'new private github', 'new private gh', 'new private deploy', 'new deploy key', 'new dockerfile', 'new docker compose', 'new compose', 'new docker image', 'new image', 'new postgresql', 'new postgres', 'new mysql', 'new mariadb', - 'new redis', 'new keydb', 'new dragonfly', 'new mongodb', 'new mongo', 'new clickhouse', 'new sqlite' + 'new redis', 'new valkey', 'new keydb', 'new dragonfly', 'new mongodb', 'new mongo', 'new clickhouse', 'new sqlite' ]; if (this.scope === 'global' && exactMatchCommands.includes(trimmed)) { const matchingItem = this.creatableItems.find(item => { diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 2517ba86ee..ee9a78b989 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -3,7 +3,7 @@ + description="Manage the identity, container image, and credentials of this Redis / Valkey database."> @@ -14,10 +14,10 @@
+ helper="Use a published Redis or Valkey image from Docker Hub, for example valkey/valkey:9-alpine or redis:8." />
- + @if ($database->started_at) Changing values here does not update Redis. Update Redis first, then synchronize the values here so @@ -99,15 +99,15 @@ id="customDockerRunOptions" label="Custom Docker options" canGate="update" :canResource="$database" /> @endif - + @if ($isPasswordHiddenForMember) - + @else @endif diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php index 769523b76a..5bda4478bc 100644 --- a/resources/views/livewire/project/new/select.blade.php +++ b/resources/views/livewire/project/new/select.blade.php @@ -107,7 +107,7 @@ {{-- A full page load: the canvas uses the minimal layout. --}} + class="mt-6 flex items-center gap-4 rounded-xl border border-neutral-200 bg-white p-4 shadow-[0_1px_2px_rgb(0_0_0/0.05)] transition-colors hover:border-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent dark:border-white/[0.08] dark:bg-white/[0.05] dark:shadow-none dark:hover:border-white/[0.14]"> @@ -788,7 +788,7 @@ Network: {{ $standaloneDocker->network }} - + @endforeach @endif @@ -803,9 +803,9 @@ needs bundled extensions.

-
PostgreSQL 18
-
PostgreSQL 17
@@ -847,9 +847,9 @@
-
PostgreSQL 16
@@ -868,9 +868,9 @@
-
Supabase PostgreSQL
@@ -889,9 +889,9 @@
-
PostGIS
-
PGVector 18
@@ -933,9 +933,9 @@
-
PGVector 17
@@ -957,6 +957,60 @@
@endif + @if ($current_step === 'select-redis-type') +
+
+

Select a Redis or Valkey image

+

Both work with the same clients, commands, + and configuration.

+
+
+
+
+
Valkey 9 Default
+
+ An open-source (BSD) fork of Redis from the Linux Foundation. It works with all Redis clients. +
+
+ + + + + +
+
+
+
Redis 8
+
+ The original in-memory key-value store. +
+
+ + + + + +
+
+
+ @endif @if ($current_step === 'existing-postgresql') diff --git a/tests/Feature/PendingResourceCreationAuthorizationTest.php b/tests/Feature/PendingResourceCreationAuthorizationTest.php index 947d559ea2..664642e59c 100644 --- a/tests/Feature/PendingResourceCreationAuthorizationTest.php +++ b/tests/Feature/PendingResourceCreationAuthorizationTest.php @@ -38,7 +38,7 @@ beforeEach(function () { }); test('pending creation denies revoked resource team access after switching teams', function (string $role) { - $component = Livewire::withQueryParams(['type' => 'redis', 'destination' => $this->destination->uuid])->test(Create::class); + $component = Livewire::withQueryParams(['type' => 'redis', 'destination' => $this->destination->uuid, 'database_image' => 'valkey/valkey:9-alpine'])->test(Create::class); if ($role === 'removed') { $this->user->teams()->detach($this->team); } else { @@ -51,14 +51,14 @@ test('pending creation denies revoked resource team access after switching teams })->with(['member', 'removed']); test('pending creation still allows the resource team admin after a team switch', function () { - $component = Livewire::withQueryParams(['type' => 'redis', 'destination' => $this->destination->uuid])->test(Create::class); + $component = Livewire::withQueryParams(['type' => 'redis', 'destination' => $this->destination->uuid, 'database_image' => 'valkey/valkey:9-alpine'])->test(Create::class); session(['currentTeam' => $this->otherTeam]); $component->call('createResource')->assertRedirect(); expect(StandaloneRedis::where('environment_id', $this->environment->id)->count())->toBe(1); }); test('pending creation rejects a destination transferred to another team', function () { - $component = Livewire::withQueryParams(['type' => 'redis', 'destination' => $this->destination->uuid])->test(Create::class); + $component = Livewire::withQueryParams(['type' => 'redis', 'destination' => $this->destination->uuid, 'database_image' => 'valkey/valkey:9-alpine'])->test(Create::class); $this->server->update(['team_id' => $this->otherTeam->id]); Server::flushIdentityMap(); $component->call('createResource')->assertForbidden(); @@ -80,7 +80,7 @@ test('pending creation allows a member with a write grant on the environment onl ['scope' => 'environment', 'uuid' => $this->environment->uuid, 'levels' => ['write']], ]); - Livewire::withQueryParams(['type' => 'redis', 'destination' => $this->destination->uuid]) + Livewire::withQueryParams(['type' => 'redis', 'destination' => $this->destination->uuid, 'database_image' => 'valkey/valkey:9-alpine']) ->test(Create::class) ->call('createResource') ->assertRedirect(); @@ -93,7 +93,7 @@ test('pending creation denies a member with only a read grant on the environment ['scope' => 'environment', 'uuid' => $this->environment->uuid, 'levels' => ['read']], ]); - Livewire::withQueryParams(['type' => 'redis', 'destination' => $this->destination->uuid]) + Livewire::withQueryParams(['type' => 'redis', 'destination' => $this->destination->uuid, 'database_image' => 'valkey/valkey:9-alpine']) ->test(Create::class) ->assertForbidden(); @@ -104,7 +104,7 @@ test('pending creation denies an environment write grant that was revoked after $member = pendingCreationMemberWithAccess($this->team, [ ['scope' => 'environment', 'uuid' => $this->environment->uuid, 'levels' => ['write']], ]); - $component = Livewire::withQueryParams(['type' => 'redis', 'destination' => $this->destination->uuid])->test(Create::class); + $component = Livewire::withQueryParams(['type' => 'redis', 'destination' => $this->destination->uuid, 'database_image' => 'valkey/valkey:9-alpine'])->test(Create::class); $member->teams()->updateExistingPivot($this->team->id, ['access' => json_encode([ ['scope' => 'environment', 'uuid' => $this->environment->uuid, 'levels' => ['read']],