RedisServerWrapper
Manage redis-server processes from Elixir -- single instances, clusters, and
sentinel topologies with GenServer lifecycle management.
No Docker required. Just redis-server and redis-cli on your PATH.
Installation
Add redis_server_wrapper to your list of dependencies in mix.exs:
def deps do
[
{:redis_server_wrapper, "~> 0.7"}
]
end
Documentation
- Support policy — Redis, Elixir/OTP, OS, distribution, transport, and external-module compatibility.
- Configuration reference — complete Config/Server/Cluster/Sentinel/Manager option matrices.
- Security and data safety — credentials, generated files, network exposure, process arguments, and destructive operations.
- Testing and troubleshooting — ExUnit fixtures, parallel port allocation, topology port ranges, custom modules, and failure diagnosis.
Prerequisites
You need redis-server and redis-cli installed and available on your PATH.
The wrapper uses kill and lsof for process inspection and management
when they are present, but normal server, cluster, and sentinel lifecycle
operations degrade safely when minimal runtime images omit them. Occupied ports
fail startup; the wrapper does not shut down or signal an existing listener to
claim a port. Signal-based functions in RedisServerWrapper.Chaos still require
kill.
# macOS
brew install redis
# Ubuntu/Debian
sudo apt-get install redis-server
# Verify
redis-server --version
Supported Redis versions and distributions
The supported Redis lines are 7.2, 7.4, 8.2, and the latest Redis 8.x release. CI runs the full standalone, Cluster, and Sentinel suite against a current patch release from each line, including a custom-module startup test. New patch releases within those lines are supported; older releases may work but are not part of the compatibility contract.
CI verifies Linux, Elixir 1.19 / OTP 27, and Elixir 1.20 / OTP 29. macOS is supported for local development and maintainer-tested; native Windows is not supported. See the support policy for the complete compatibility and platform contract.
Startup uses the core redis-server on PATH by default:
# Redis Open Source / core (default)
RedisServerWrapper.start_server(distribution: :core)
# Redis 8 full distribution; modules are managed by Redis itself
RedisServerWrapper.start_server(distribution: :full)
# Legacy Redis Stack only; enables sibling-module discovery
RedisServerWrapper.start_server(
distribution: :legacy_stack,
redis_server_bin: "/path/to/redis-stack/bin/redis-server"
)
An explicit :redis_server_bin always wins. Legacy Stack selection can also use
the REDIS_LEGACY_STACK_SERVER_BIN environment variable. The wrapper never
silently switches distributions, and it only discovers sibling Stack modules
when distribution: :legacy_stack is explicit. Caller-provided :loadmodule
entries work with every distribution.
Quick Start
Single Server
{:ok, server} = RedisServerWrapper.start_server(port: 6400, password: "secret")
RedisServerWrapper.Server.ping(server)
#=> true
RedisServerWrapper.Server.run(server, ["SET", "key", "value"])
#=> {:ok, "OK"}
RedisServerWrapper.Server.run(server, ["GET", "key"])
#=> {:ok, "value"}
RedisServerWrapper.Server.stop(server)
Cluster
{:ok, cluster} = RedisServerWrapper.start_cluster(masters: 3, base_port: 7100)
RedisServerWrapper.Cluster.healthy?(cluster)
#=> true
RedisServerWrapper.Cluster.node_addrs(cluster)
#=> ["127.0.0.1:7100", "127.0.0.1:7101", "127.0.0.1:7102"]
RedisServerWrapper.Cluster.stop(cluster)
Sentinel
{:ok, sentinel} = RedisServerWrapper.start_sentinel(
master_port: 6390,
replicas: 2,
sentinels: 3
)
RedisServerWrapper.Sentinel.healthy?(sentinel)
#=> true
RedisServerWrapper.Sentinel.master_addr(sentinel)
#=> "127.0.0.1:6390"
RedisServerWrapper.Sentinel.stop(sentinel)
Persistent Instances (Manager)
The Manager tracks instances across IEx sessions using a JSON state file:
RedisServerWrapper.Manager.start_basic(name: "dev-redis", port: 6400)
#=> {:ok, %{name: "dev-redis", url: "redis://:password@127.0.0.1:6400", ...}}
RedisServerWrapper.Manager.list()
#=> [%{name: "dev-redis", type: :basic, ...}]
RedisServerWrapper.Manager.credentials("dev-redis")
#=> {:ok, %{password: "password", url: "redis://:password@127.0.0.1:6400"}}
RedisServerWrapper.Manager.stop("dev-redis")
Manager-generated passwords come from the operating system's cryptographic
random source. Console output redacts credentials; use Manager.credentials/1
when plaintext access is intentional. The Manager state directory and
credential-bearing Redis configuration directories are created with mode
0700, while state and configuration files use 0600. State updates are
atomic and serialized across connected BEAM nodes. Independent, unconnected
BEAM instances must not write to the same Manager state file concurrently.
Invalid state is moved aside as an instances.json.corrupt-* recovery copy
instead of being overwritten.
Manager state contains plaintext credentials. Review security and data safety before sharing a state path or binding an instance beyond loopback.
Managed Process Lifecycle
Server.start_link/1 accepts a :managed option controlling how the
redis-server OS process is tied to the BEAM:
managed: true(default) -redis-serverruns as a foreground Port. Teardown runs interminate/2, which OTP skips on a:brutal_killsupervisor shutdown or a hard BEAM death (SIGKILL, OOM), so the OS process can be stranded on those paths, keeping its port bound.managed: :forcola-redis-serverruns in the foreground under aForcola.Daemon, whose Rust shim guarantees the OS process group is killed and confirmed dead on owner death or supervisor shutdown, including the paths whereterminate/2never runs. This requires the optional dependency and is opt-in:# mix.exs{:forcola, "~> 0.3"}{:ok, server} = RedisServerWrapper.Server.start_link(port: 6400, managed: :forcola)Without
:forcolaon the path,start_linkreturns{:error, :forcola_not_available}. Forcola is POSIX-only and ships precompiled, SHA256-verified shim binaries, so it needs no Rust toolchain at build time.managed: false-redis-serverdaemonizes independently; the caller owns the OS process lifecycle.
If a managed Redis process exits unexpectedly, its Server GenServer now exits
with an abnormal {:redis_server_exit, backend, reason} reason. A normal OTP
supervisor therefore restarts the complete Server child instead of retaining a
live GenServer with a missing OS process. Port and Forcola backends follow the
same contract.
Cluster and Sentinel make topology loss explicit:
- Cluster removes a dead node from
Cluster.nodes/1, stays available for chaos and failover work, and reports bothall_alive?/1andhealthy?/1as false.healthy?/1validates every remaining node's state, slot coverage, failure counts, known-node count, and master count. - Sentinel uses the selected managed backend for its control processes as well as its data nodes. Loss of a master, replica, or Sentinel control process stops the topology abnormally after cleaning up the remaining children, so a supervisor can restart the complete topology from a coherent state.
Cluster formation, replica linkage, and Sentinel discovery use bounded polling
instead of fixed startup sleeps. Set convergence_timeout: milliseconds to
override the default (the value of :timeout). Timeout errors include the last
observed health state to make transitional or malformed Redis output diagnosable.
Custom Modules
Use :loadmodule to load one or more Redis modules. A plain path is enough for
a module with defaults; use {path, [args]} when it takes load-time arguments.
The structured form quotes every path and argument safely in the generated
redis.conf.
module_path = System.fetch_env!("EVENT_STREAM_MODULE")
{:ok, server} =
RedisServerWrapper.start_server(
port: 6460,
loadmodule: [
{module_path, ["events", "expired,set", "maxlen", "1000"]}
]
)
RedisServerWrapper.Server.run(server, ["MODULE", "LIST"])
For a cluster, the same modules are loaded into every node. For a Sentinel topology, they are loaded into the master and every replica (not the Sentinel control processes):
RedisServerWrapper.start_cluster(
base_port: 7100,
loadmodule: [
{module_path, ["cluster-streams", "per-node"]}
]
)
The original raw directive form remains supported for compatibility, such as
loadmodule: ["/path/module.so events expired"], but the structured form is
recommended when arguments are present.
The repository compiles a minimal module and runs both its integration test and the shipped generic example on every Redis version in CI:
REDIS_MODULE=/absolute/path/to/module.so \
REDIS_MODULE_NAME=my_module \
mix run examples/module_loading.exs
For a complete event-stream demo using
redis-event-stream-module:
EVENT_STREAM_MODULE=/absolute/path/to/libredis_event_stream_module.so \
mix run examples/event_stream.exs
Redis modules are native libraries and must match the target OS, architecture, Redis Module API, and Redis version. Loading is CI-verified with the repository's minimal module; the external event-stream module must be built separately. See external module support.
Configuration
RedisServerWrapper.Config is a curated typed subset of Redis configuration,
not a claim to model every Redis directive. Server, Cluster, and Sentinel add
operational lifecycle/topology options, while :extra is the escape hatch for
untyped directives. The configuration reference
lists every accepted option and calls out fields that are generated, overridden,
or intentionally unavailable on a topology.
All values are encoded as individual Redis tokens, including paths, passwords,
module arguments, and :extra directives. Use a string for a single extra
argument or a list for a multi-argument directive. Extras cannot override
wrapper-owned lifecycle or transport settings such as port, bind,
daemonize, pidfile, or dir.
RedisServerWrapper.start_server(
port: 6400,
password: "secret",
maxmemory: "256mb",
maxmemory_policy: "allkeys-lru",
extra: [
{"notify-keyspace-events", "KEA"},
{"client-output-buffer-limit", ["pubsub", "32mb", "8mb", "60"]},
{"hz", "100"}
]
)
bind may be one address, a whitespace-separated string, or a list of listen
addresses. control_host independently selects the address used by
redis-cli, readiness probes, replication, Cluster, and Sentinel:
RedisServerWrapper.start_server(
port: 6400,
bind: ["127.0.0.1", "::1"],
control_host: "127.0.0.1"
)
TCP, TLS, Unix sockets, and ACL authentication
Lifecycle operations use a shared RedisServerWrapper.Connection, so startup
readiness, commands, health checks, shutdown, Cluster, Sentinel, and Manager
all retain the same endpoint and credentials.
Set username with password to configure and use a named ACL user. Without a
username, password retains its password-only requirepass behavior:
RedisServerWrapper.start_server(
port: 6400,
username: "demo-app",
password: "secret"
)
Disable TCP with port: 0 for a Unix-only server:
RedisServerWrapper.start_server(
port: 0,
unixsocket: "/tmp/demo-redis.sock",
unixsocketperm: "700"
)
For a TLS-only server, set port: 0, a tls_port, and the server identity.
The CA file or directory is also used by redis-cli for verification:
RedisServerWrapper.start_server(
port: 0,
tls_port: 6400,
tls_cert_file: "/certs/server.crt",
tls_key_file: "/certs/server.key",
tls_ca_cert_file: "/certs/ca.crt",
tls_auth_clients: "no",
tls_server_name: "redis.demo.test"
)
Use tls_ca_cert_dir instead of tls_ca_cert_file for a CA directory.
Mutual-TLS clients can set tls_client_cert_file and
tls_client_key_file. tls_insecure: true explicitly disables redis-cli
certificate verification and is intended only for controlled tests.
Cluster and Sentinel use TLS-only nodes when tls: true; pass the same
certificate options:
RedisServerWrapper.start_cluster(
masters: 3,
base_port: 7100,
tls: true,
tls_cert_file: "/certs/server.crt",
tls_key_file: "/certs/server.key",
tls_ca_cert_file: "/certs/ca.crt",
tls_auth_clients: "no"
)
The wrapper enables tls-cluster or tls-replication as required. Sentinel
also configures sentinel auth-user/auth-pass for named ACL credentials.
Unix sockets are a standalone-server transport; Cluster and Sentinel reject
them immediately because their nodes must communicate over network ports.
Chaos / Fault Injection
The Chaos module provides fault injection primitives for testing resilience:
alias RedisServerWrapper.{Chaos, Cluster, Server}
{:ok, cluster} = RedisServerWrapper.start_cluster(
masters: 3,
replicas_per_master: 1,
base_port: 7100
)
# Kill the master owning a key's hash slot
{:ok, killed} = Chaos.kill_master(cluster, "user:123")
# Kill by slot number
{:ok, killed} = Chaos.kill_master(cluster, 5000)
# Freeze a node (SIGSTOP) and resume later
{:ok, os_pid} = Chaos.freeze_node(server)
# ... test timeout/retry behavior ...
Chaos.resume_node(os_pid)
# Freeze with automatic resume after a duration
Chaos.pause_node(server, 5_000)
# Simulate a network partition
nodes = Cluster.nodes(cluster)
{active, isolated} = Enum.split(nodes, 2)
{:ok, frozen_pids} = Chaos.partition(cluster, [active, isolated])
# Recover all frozen nodes
Chaos.recover(frozen_pids)
# Other tools
Chaos.slow_down(server, 2_000) # CLIENT PAUSE
Chaos.flushall(server) # wipe all data
Chaos.fill_memory(server, 10_000) # fill with 1KB dummy keys
Chaos.trigger_save(server) # force BGSAVE
Chaos.random_kill(cluster) # kill a random node
Chaos.trigger_failover(replica) # CLUSTER FAILOVER on a replica
Use Cases
- Testing -- spin up real Redis instances using the ExUnit fixture and port guidance
- Development -- run Redis alongside your app without system services
- CI -- no Docker layer needed, just install redis-server
- Cluster/Sentinel testing -- create full topologies in a single function call
- Resilience testing -- fault injection with the Chaos module (kill nodes, simulate partitions, inject latency)
License
Licensed under either of
- MIT License
- Apache License, Version 2.0
at your option. See LICENSE for details.