diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index c31dd05e048..f78595fff9e 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -68,6 +68,7 @@ body:
- ToxiProxy
- Trino
- Typesense
+ - Valkey
- Vault
- Weaviate
- YugabyteDB
diff --git a/.github/ISSUE_TEMPLATE/enhancement.yaml b/.github/ISSUE_TEMPLATE/enhancement.yaml
index 9b9a06ecf6a..b63978775af 100644
--- a/.github/ISSUE_TEMPLATE/enhancement.yaml
+++ b/.github/ISSUE_TEMPLATE/enhancement.yaml
@@ -68,6 +68,7 @@ body:
- ToxiProxy
- Trino
- Typesense
+ - Valkey
- Vault
- Weaviate
- YugabyteDB
diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml
index b655b4ac505..4a26337e90a 100644
--- a/.github/ISSUE_TEMPLATE/feature.yaml
+++ b/.github/ISSUE_TEMPLATE/feature.yaml
@@ -68,6 +68,7 @@ body:
- ToxiProxy
- Trino
- Typesense
+ - Valkey
- Vault
- Weaviate
- YugabyteDB
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 72a6d9110b6..7e84bc07e6e 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -373,6 +373,11 @@ updates:
schedule:
interval: "monthly"
open-pull-requests-limit: 10
+ - package-ecosystem: "gradle"
+ directory: "/modules/valkey"
+ schedule:
+ interval: "monthly"
+ open-pull-requests-limit: 10
- package-ecosystem: "gradle"
directory: "/modules/vault"
schedule:
diff --git a/.github/labeler.yml b/.github/labeler.yml
index f4649bd7f99..02efaf75f14 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -248,6 +248,10 @@
- changed-files:
- any-glob-to-any-file:
- modules/typesense/**/*
+"modules/valkey":
+ - changed-files:
+ - any-glob-to-any-file:
+ - modules/valkey/**/*
"modules/vault":
- changed-files:
- any-glob-to-any-file:
diff --git a/docs/modules/valkey.md b/docs/modules/valkey.md
new file mode 100644
index 00000000000..fd318cbcf3a
--- /dev/null
+++ b/docs/modules/valkey.md
@@ -0,0 +1,34 @@
+# Valkey
+
+!!! note This module is INCUBATING.
+While it is ready for use and operational in the current version of Testcontainers, it is possible that it may receive breaking changes in the future.
+See our [contributing guidelines](../contributing.md#incubating-modules) for more information on our incubating modules policy.
+
+Testcontainers module for [Valkey](https://hub.docker.com/r/valkey/valkey)
+
+## Valkey's usage examples
+
+You can start a Valkey container instance from any Java application by using:
+
+
+[Default Valkey container](../../modules/valkey/src/test/java/org/testcontainers/valkey/ValkeyContainerTest.java) inside_block:container
+
+
+## Adding this module to your project dependencies
+
+Add the following dependency to your `pom.xml`/`build.gradle` file:
+
+=== "Gradle"
+```groovy
+testImplementation "org.testcontainers:valkey:{{latest_version}}"
+```
+
+=== "Maven"
+```xml
+
+org.testcontainers
+valkey
+{{latest_version}}
+test
+
+```
diff --git a/mkdocs.yml b/mkdocs.yml
index 3e39a67f959..8dedfec2ede 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -108,6 +108,7 @@ nav:
- modules/solr.md
- modules/toxiproxy.md
- modules/typesense.md
+ - modules/valkey.md
- modules/vault.md
- modules/weaviate.md
- modules/webdriver_containers.md
diff --git a/modules/pinecone/build.gradle b/modules/pinecone/build.gradle
index 3ad5b97d98f..ad46d3ce9d9 100644
--- a/modules/pinecone/build.gradle
+++ b/modules/pinecone/build.gradle
@@ -1,4 +1,4 @@
-description = "Testcontainers :: ActiveMQ"
+description = "Testcontainers :: Pinecone"
dependencies {
api project(':testcontainers')
diff --git a/modules/valkey/build.gradle b/modules/valkey/build.gradle
new file mode 100644
index 00000000000..497b76a4c3f
--- /dev/null
+++ b/modules/valkey/build.gradle
@@ -0,0 +1,7 @@
+description = "Testcontainers :: Valkey"
+
+dependencies {
+ api project(':testcontainers')
+
+ testImplementation("io.valkey:valkey-java:5.5.0")
+}
diff --git a/modules/valkey/src/main/java/org/testcontainers/valkey/ValkeyContainer.java b/modules/valkey/src/main/java/org/testcontainers/valkey/ValkeyContainer.java
new file mode 100644
index 00000000000..e2ed4136e34
--- /dev/null
+++ b/modules/valkey/src/main/java/org/testcontainers/valkey/ValkeyContainer.java
@@ -0,0 +1,250 @@
+package org.testcontainers.valkey;
+
+import com.google.common.base.Preconditions;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.apache.commons.lang3.StringUtils;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+import org.testcontainers.utility.MountableFile;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Testcontainers implementation for Valkey.
+ *
+ * Supported image: {@code valkey}
+ *
+ * Exposed ports:
+ *
+ */
+public class ValkeyContainer extends GenericContainer {
+
+ @AllArgsConstructor
+ @Getter
+ private static class SnapshottingSettings {
+
+ int seconds;
+
+ int changedKeys;
+ }
+
+ private static final DockerImageName DEFAULT_IMAGE = DockerImageName.parse("valkey/valkey:8.1");
+
+ private static final String DEFAULT_CONFIG_FILE = "/usr/local/valkey.conf";
+
+ private static final int CONTAINER_PORT = 6379;
+
+ private String username;
+
+ private String password;
+
+ private String persistenceVolume;
+
+ private String initialImportScriptFile;
+
+ private String configFile;
+
+ private ValkeyLogLevel logLevel;
+
+ private SnapshottingSettings snapshottingSettings;
+
+ public ValkeyContainer() {
+ this(DEFAULT_IMAGE);
+ }
+
+ public ValkeyContainer(String dockerImageName) {
+ this(DockerImageName.parse(dockerImageName));
+ }
+
+ public ValkeyContainer(DockerImageName dockerImageName) {
+ super(dockerImageName);
+ withExposedPorts(CONTAINER_PORT);
+ withStartupTimeout(Duration.ofMinutes(2));
+ waitingFor(Wait.forLogMessage(".*Ready to accept connections.*", 1));
+ }
+
+ public ValkeyContainer withUsername(String username) {
+ this.username = username;
+ return this;
+ }
+
+ public ValkeyContainer withPassword(String password) {
+ this.password = password;
+ return this;
+ }
+
+ /**
+ * Sets a host path to be mounted as a volume for Valkey persistence. The path must exist on the
+ * host system. Valkey will store its data in this directory.
+ */
+ public ValkeyContainer withPersistenceVolume(String persistenceVolume) {
+ this.persistenceVolume = persistenceVolume;
+ return this;
+ }
+
+ /**
+ * Sets an initial import script file to be executed via the Valkey CLI after startup.
+ *
+ * Example line of an import script file: SET key1 "value1"
+ */
+ public ValkeyContainer withInitialData(String initialImportScriptFile) {
+ this.initialImportScriptFile = initialImportScriptFile;
+ return this;
+ }
+
+ /**
+ * Sets the log level for the valkey server process.
+ */
+ public ValkeyContainer withLogLevel(ValkeyLogLevel logLevel) {
+ this.logLevel = logLevel;
+ return this;
+ }
+
+ /**
+ * Sets the snapshotting configuration for the valkey server process. You can configure Valkey
+ * to have it save the dataset every N seconds if there are at least M changes in the dataset.
+ * This method allows Valkey to benefit from copy-on-write semantics.
+ *
+ * @see
+ */
+ public ValkeyContainer withSnapshotting(int seconds, int changedKeys) {
+ Preconditions.checkArgument(seconds > 0, "seconds must be greater than 0");
+ Preconditions.checkArgument(changedKeys > 0, "changedKeys must be non-negative");
+
+ this.snapshottingSettings = new SnapshottingSettings(seconds, changedKeys);
+ return this;
+ }
+
+ /**
+ * Sets the config file to be used for the Valkey container.
+ */
+ public ValkeyContainer withConfigFile(String configFile) {
+ this.configFile = configFile;
+
+ return this;
+ }
+
+ @Override
+ public void start() {
+ List command = new ArrayList<>();
+ command.add("valkey-server");
+
+ if (StringUtils.isNotEmpty(configFile)) {
+ withCopyToContainer(MountableFile.forHostPath(configFile), DEFAULT_CONFIG_FILE);
+ command.add(DEFAULT_CONFIG_FILE);
+ }
+
+ if (StringUtils.isNotEmpty(password)) {
+ command.add("--requirepass");
+ command.add(password);
+
+ if (StringUtils.isNotEmpty(username)) {
+ command.add("--user " + username + " on >" + password + " ~* +@all");
+ }
+ }
+
+ if (StringUtils.isNotEmpty(persistenceVolume)) {
+ command.addAll(Arrays.asList("--appendonly", "yes"));
+ withFileSystemBind(persistenceVolume, "/data");
+ }
+
+ if (snapshottingSettings != null) {
+ command.addAll(
+ Arrays.asList("--save",
+ snapshottingSettings.getSeconds() + " " + snapshottingSettings.getChangedKeys())
+ );
+ }
+
+ if (logLevel != null) {
+ command.addAll(Arrays.asList("--loglevel", logLevel.getLevel()));
+ }
+
+ if (StringUtils.isNotEmpty(initialImportScriptFile)) {
+ withCopyToContainer(MountableFile.forHostPath(initialImportScriptFile),
+ "/tmp/import.valkey");
+ withCopyToContainer(MountableFile.forClasspathResource("import.sh"), "/tmp/import.sh");
+ }
+
+ withCommand(command.toArray(new String[0]));
+
+ super.start();
+
+ evaluateImportScript();
+ }
+
+ public int getPort() {
+ return getMappedPort(CONTAINER_PORT);
+ }
+
+ /**
+ * Executes a command in the Valkey CLI inside the container.
+ */
+ public String executeCli(String cmd, String... flags) {
+ List args = new ArrayList<>();
+ args.add("redis-cli");
+
+ if (StringUtils.isNotEmpty(password)) {
+ args.addAll(
+ StringUtils.isNotEmpty(username)
+ ? Arrays.asList("--user", username, "--pass", password)
+ : Arrays.asList("--pass", password)
+ );
+ }
+
+ args.add(cmd);
+ args.addAll(Arrays.asList(flags));
+
+ try {
+ ExecResult result = execInContainer(args.toArray(new String[0]));
+ if (result.getExitCode() != 0) {
+ throw new RuntimeException(result.getStdout() + result.getStderr());
+ }
+
+ return result.getStdout();
+ } catch (Exception e) {
+ throw new RuntimeException("failed to execute CLI command", e);
+ }
+ }
+
+ public String createConnectionUrl() {
+ String userInfo = null;
+ if (StringUtils.isNotEmpty(username) && StringUtils.isNotEmpty(password)) {
+ userInfo = username + ":" + password;
+ } else if (StringUtils.isNotEmpty(password)) {
+ userInfo = ":" + password;
+ }
+
+ try {
+ URI uri = new URI("redis", userInfo, getHost(), getPort(), null, null, null);
+ return uri.toString();
+ } catch (URISyntaxException e) {
+ throw new RuntimeException("Failed to build Redis URI", e);
+ }
+ }
+
+ private void evaluateImportScript() {
+ if (StringUtils.isEmpty(initialImportScriptFile)) {
+ return;
+ }
+
+ try {
+ ExecResult result = execInContainer("/bin/sh", "/tmp/import.sh",
+ password != null ? password : "");
+
+ if (result.getExitCode() != 0 || result.getStdout().contains("ERR")) {
+ throw new RuntimeException("Could not import initial data: " + result.getStdout());
+ }
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/modules/valkey/src/main/java/org/testcontainers/valkey/ValkeyLogLevel.java b/modules/valkey/src/main/java/org/testcontainers/valkey/ValkeyLogLevel.java
new file mode 100644
index 00000000000..b24884d66c4
--- /dev/null
+++ b/modules/valkey/src/main/java/org/testcontainers/valkey/ValkeyLogLevel.java
@@ -0,0 +1,18 @@
+package org.testcontainers.valkey;
+
+public enum ValkeyLogLevel {
+ DEBUG("debug"),
+ VERBOSE("verbose"),
+ NOTICE("notice"),
+ WARNING("warning");
+
+ private final String level;
+
+ ValkeyLogLevel(String level) {
+ this.level = level;
+ }
+
+ public String getLevel() {
+ return level;
+ }
+}
diff --git a/modules/valkey/src/main/resources/import.sh b/modules/valkey/src/main/resources/import.sh
new file mode 100644
index 00000000000..bfc76a22606
--- /dev/null
+++ b/modules/valkey/src/main/resources/import.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -e
+valkey-cli $([[ -n "$1" ]] && echo "-a $1") < "/tmp/import.valkey"
+echo "Imported"
diff --git a/modules/valkey/src/test/java/org/testcontainers/valkey/ValkeyContainerTest.java b/modules/valkey/src/test/java/org/testcontainers/valkey/ValkeyContainerTest.java
new file mode 100644
index 00000000000..2a93c677e84
--- /dev/null
+++ b/modules/valkey/src/test/java/org/testcontainers/valkey/ValkeyContainerTest.java
@@ -0,0 +1,144 @@
+package org.testcontainers.valkey;
+
+import io.valkey.Jedis;
+import io.valkey.JedisPool;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ValkeyContainerTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void shouldWriteAndReadEntry() {
+ try (
+ ValkeyContainer valkeyContainer = new ValkeyContainer()
+ .withLogLevel(ValkeyLogLevel.DEBUG)
+ .withSnapshotting(3, 1)
+ ) {
+ valkeyContainer.start();
+ try (JedisPool jedisPool = new JedisPool(valkeyContainer.createConnectionUrl());
+ Jedis jedis = jedisPool.getResource()) {
+ jedis.set("key", "value");
+ assertThat(jedis.get("key")).isEqualTo("value");
+ }
+ }
+ }
+
+ @Test
+ void shouldConfigureServiceWithAuthentication() {
+ try (
+ ValkeyContainer valkeyContainer = new ValkeyContainer().withUsername("testuser")
+ .withPassword("testpass")
+ ) {
+ valkeyContainer.start();
+ String url = valkeyContainer.createConnectionUrl();
+ assertThat(url).contains("testuser:testpass");
+
+ try (JedisPool jedisPool = new JedisPool(url);
+ Jedis jedis = jedisPool.getResource()) {
+ jedis.set("k1", "v2");
+ assertThat(jedis.get("k1")).isEqualTo("v2");
+ }
+ }
+ }
+
+
+ @Test
+ void shouldPersistData() {
+ Path dataDir = tempDir.resolve("valkey-data");
+ dataDir.toFile().mkdirs();
+
+ try (
+ ValkeyContainer valkeyContainer = new ValkeyContainer()
+ .withPersistenceVolume(dataDir.toString())
+ .withSnapshotting(1, 1)
+ ) {
+ valkeyContainer.start();
+
+ String containerConnectionUrl = valkeyContainer.createConnectionUrl();
+ try (JedisPool jedisPool = new JedisPool(containerConnectionUrl);
+ Jedis jedis = jedisPool.getResource()) {
+ jedis.set("persistKey", "persistValue");
+ }
+
+ valkeyContainer.stop();
+ try (ValkeyContainer restarted = new ValkeyContainer().withPersistenceVolume(
+ dataDir.toString())) {
+ restarted.start();
+ String connectionUrl = restarted.createConnectionUrl();
+
+ try (JedisPool restartedPool = new JedisPool(connectionUrl);
+ Jedis jedis = restartedPool.getResource()) {
+ assertThat(jedis.get("persistKey")).isEqualTo("persistValue");
+ }
+ }
+ }
+ }
+
+ @Test
+ void shouldInitializeDatabaseWithPayload() throws Exception {
+ Path importFile = Paths.get(getClass().getResource("/initData.valkey").toURI());
+
+ try (ValkeyContainer valkeyContainer = new ValkeyContainer().withInitialData(
+ importFile.toString())) {
+ valkeyContainer.start();
+ String connectionUrl = valkeyContainer.createConnectionUrl();
+
+ try (JedisPool jedisPool = new JedisPool(
+ connectionUrl); Jedis jedis = jedisPool.getResource()) {
+ assertThat(jedis.get("key1")).isEqualTo("value1");
+ assertThat(jedis.get("key2")).isEqualTo("value2");
+ }
+ }
+ }
+
+ @Test
+ void shouldExecuteContainerCmdAndReturnResult() {
+ try (ValkeyContainer valkeyContainer = new ValkeyContainer()) {
+ valkeyContainer.start();
+
+ String queryResult = valkeyContainer.executeCli("info", "clients");
+
+ assertThat(queryResult).contains("connected_clients:1");
+ }
+ }
+
+ @Test
+ void shouldMountValkeyConfigToContainer() throws Exception {
+ Path configFile = Paths.get(getClass().getResource("/valkey.conf").toURI());
+
+ try (ValkeyContainer valkeyContainer = new ValkeyContainer().withConfigFile(
+ configFile.toString())) {
+ valkeyContainer.start();
+
+ String connectionUrl = valkeyContainer.createConnectionUrl();
+ try (JedisPool jedisPool = new JedisPool(connectionUrl);
+ Jedis jedis = jedisPool.getResource()) {
+ String maxMemory = jedis.configGet("maxmemory").get("maxmemory");
+
+ assertThat(maxMemory).isEqualTo("2097152");
+ }
+ }
+ }
+
+ @Test
+ void shouldValidateSnapshottingConfiguration() {
+ try (ValkeyContainer container = new ValkeyContainer()) {
+ assertThatThrownBy(() -> container.withSnapshotting(0, 10))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("seconds must be greater than 0");
+
+ assertThatThrownBy(() -> container.withSnapshotting(10, 0))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("changedKeys must be non-negative");
+ }
+ }
+}
diff --git a/modules/valkey/src/test/resources/initData.valkey b/modules/valkey/src/test/resources/initData.valkey
new file mode 100644
index 00000000000..e2c4c2c8e7b
--- /dev/null
+++ b/modules/valkey/src/test/resources/initData.valkey
@@ -0,0 +1,2 @@
+SET key1 "value1"
+SET key2 "value2"
diff --git a/modules/valkey/src/test/resources/valkey.conf b/modules/valkey/src/test/resources/valkey.conf
new file mode 100644
index 00000000000..b58609fc4b3
--- /dev/null
+++ b/modules/valkey/src/test/resources/valkey.conf
@@ -0,0 +1 @@
+maxmemory 2mb