What you will set up
Redis is an in-memory key-value store: apps use it as a cache, for sessions, for job queues, counters and rate limits. It answers in well under a millisecond because it keeps everything in memory, and it saves to disk so the data survives a restart. Since Redis 8, the official image also has JSON documents, a search and query engine, time series, Bloom filters and vector sets built in.
Redis was created by Salvatore Sanfilippo in 2009 and is developed by Redis Ltd., a company in the United States. Its licence has changed twice. Up to 7.2 it was open source under the BSD licence. Redis 7.4, in 2024, moved to two licences that are not open source, the RSALv2 and the SSPLv1, which is when Valkey was forked from 7.2. Since Redis 8.0, in 2025, you can also choose the AGPLv3, an open-source licence. Under the AGPLv3, if you change Redis's own code and let others use it over a network, you must share your changes. Running Redis unchanged for your own apps, as here, asks nothing of you. If you want a permissive licence, use Valkey instead: apps that work with one work with the other.
Redis contacts no online service: in a capture of our test server's traffic while Redis started and served an app, the only outgoing traffic was the server's own time sync.
Here Redis runs from the official image, in Podman under a user of its own called redis, with a login for each app and the built-in default user switched off. It listens only on the server itself and on its WireGuard address, never on the public address.
Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:
- Redis 8.10.2 started with its users from a Podman secret, and with the JSON, search, time series and Bloom modules loaded. A connection without a user name got
NOAUTH Authentication required., and thedefaultuser was refused. - The app's login could read and write, store a JSON document and find it with a search index, but not run
CONFIG,SHUTDOWNorACL. A second app's login, limited to keys starting withshop:, gotNOPERMon the first app's keys. - An app server on WireGuard connected with
redis-cliand a Python script, and our computer through an SSH tunnel. From the internet, port 6379 timed out on the public address. - With 1 GB of test data, we measured what each kind of persistence costs, and checked that
maxmemoryrefuses writes, or evicts old keys, when it is full. - After a reboot, 10,000 test records were all back. A nightly snapshot was restored into an empty volume, and an update from 8.8 to 8.10 kept the data.
After a reboot, the whole server used about 470 MB of memory, and Redis 11 MB of it with our 10,000 records. Redis grows with your data, up to the 4 GB limit set in step 3.
Before you start
You need:
- a server set up as in the Podman guide, with ufw on as in Secure a new Debian server;
- for apps on other servers: WireGuard on this server with the tunnel address
10.8.0.1, and each app server added as a device. If an app server is another Melonslab server, read Connect two servers first.
You do not need a DNS name: apps connect to an IP address that only they can reach.
The examples use 203.0.113.10 for the server's public address, 10.8.0.1 for its WireGuard address, 10.8.0.2 for an app server on WireGuard, and app for the app's login. Replace them throughout.
1. Prepare the server
As root, create /etc/sysctl.d/60-redis.conf:
# Let Redis save snapshots without failing when memory is tight
vm.overcommit_memory = 1
# Let Redis listen on the WireGuard address before the tunnel is up
net.ipv4.ip_nonlocal_bind = 1
sysctl --system
- To save to disk, Redis copies itself in memory with
fork. Linux's default overcommit setting can refuse that copy when memory looks tight, and the save fails. Redis's documentation asks for1, and without it Redis logsWARNING Memory overcommit must be enabled!at every start. - At boot, nothing makes the container wait for WireGuard, and Podman cannot listen on
10.8.0.1before that address exists. The second line lets it, and connections work as soon as the tunnel is up. Without it, Redis fails to start when it is quicker than WireGuard (see Troubleshooting).
Then create the user:
useradd -m -s /bin/bash redis
loginctl enable-linger redis
machinectl shell redis@
Everything up to step 6 runs as redis.
2. Create the logins
Redis's built-in default user has no password and may run every command. Switch it off, and give each app a login of its own. Redis reads them from an ACL file, which you store as a Podman secret, so no password is ever written in a file of its own:
admin=$(openssl rand -hex 32)
app=$(openssl rand -hex 32)
printf %s "$admin" | podman secret create redis-admin-password -
printf '%s\n' 'user default off' "user admin on >$admin ~* &* +@all" "user app on >$app ~* &* +@all -@admin" | podman secret create redis-users -
unset admin app
That makes three users:
defaultis off, so nobody can use Redis without a user name and a password.adminmay do everything. You use it for backups and changes, from the server itself.appmay use every key (~*) and every channel (&*), and every command except those in the@admingroup:CONFIG,ACL,SHUTDOWN,SAVE,MONITOR,DEBUG, replication and the like. Commands such asKEYS,INFOandFLUSHDBstay allowed, because many apps and libraries use them, and so do the JSON and search commands.
The admin password is also stored on its own, so redis-cli in the container can log in as admin without you typing it. To see the passwords, for your app's settings:
podman secret inspect --showsecret --format '{{.SecretData}}' redis-users
The password comes after >. Redis keeps only a SHA-256 hash of it in memory.
3. Configure memory and persistence
Create ~/redis.conf:
# Users and passwords come from the redis-users secret
aclfile /run/secrets/redis-users
# Memory: at most 4 GB of data, and refuse writes when it is full
maxmemory 4gb
maxmemory-policy noeviction
# Persistence: a log of every write, flushed to disk every second,
# and a snapshot every hour, or sooner after many changes
appendonly yes
appendfsync everysec
save 3600 1 300 100 60 10000
dir /data
Memory. Redis keeps all its data in memory, and without a limit it takes what it needs until Linux stops it. Each time it saves, the copy it makes with fork shares memory with Redis, but every key that changes while the save runs takes memory twice. In our test with 1 GB of data, an AOF rewrite during heavy writes copied 774 MB, most of it, while a snapshot on a quiet server copied 2 MB. So keep as much memory free as Redis may use: on an 8 GB server that is 4 GB for Redis. If other apps run on the same server, lower it.
When it is full, maxmemory-policy decides:
noeviction, used here, refuses writes withOOM command not allowed when used memory > 'maxmemory', and keeps everything. Use it when Redis holds sessions, queues or anything else your app cannot lose.allkeys-lrudeletes the keys used least recently to make room. Use it when Redis is only a cache, where a missing key is fetched again. We saw it evict old keys and accept the new one at the limit.volatile-lrudeletes only keys that have an expiry time, for a mix of the two.
Persistence. Redis has two ways to save, and this configuration uses both:
- Snapshots (RDB): the whole dataset written to one compact file, here every hour if anything changed, every 5 minutes after 100 changes, and every minute after 10,000. After a crash you lose what changed since the last snapshot. Our 1 GB snapshot took 22 to 28 seconds to write and became a 990 MB file. They are what the backup in step 8 copies.
- The append-only file (AOF): every write is also appended to a log, which
appendfsync everysecflushes to disk once a second. After a crash you lose at most about a second. The log grows until Redis rewrites it as a snapshot plus new writes, automatically when it has doubled. During our heavy writes the AOF directory reached 2.5 GB for 1 GB of data before the rewrite, so leave room on the disk.
We measured the cost with redis-benchmark, 50 clients writing 100-byte values:
| Persistence | Writes per second |
|---|---|
| Snapshots only | 49,400 |
AOF, everysec | 48,800 |
AOF, always | 9,800 |
With everysec we saw no difference worth counting. appendfsync always waits for the disk at every write and loses nothing, at a fifth of the speed. For a pure cache that can be rebuilt, appendonly no saves the disk writes. A restart takes longer with more data: Redis answers LOADING Redis is loading the dataset in memory until it has read everything. For our 1 GB of small keys, 8.6 million of them, that took 19 seconds right after the append-only file was rewritten, and 26 seconds when it held many writes since then.
4. Define the container
mkdir -p ~/.config/containers/systemd
Create ~/.config/containers/systemd/redis.container:
[Unit]
Description=Redis
[Container]
ContainerName=redis
Image=docker.io/library/redis:8.10
Exec=redis-server /etc/redis/redis.conf
Volume=%h/redis.conf:/etc/redis/redis.conf:ro
Volume=redis-data:/data
Secret=redis-users
Secret=redis-admin-password,type=env,target=REDISCLI_AUTH
# Only this server and WireGuard devices can reach Redis: the port is not open to the internet.
PublishPort=127.0.0.1:6379:6379
PublishPort=10.8.0.1:6379:6379
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
docker.io/library/redisis the official image on Docker Hub. It loads the JSON, search, time series and Bloom modules by itself;podman exec redis redis-cli --user admin module listlists them.Secret=redis-usersputs the ACL file at/run/secrets/redis-usersin the container, whereredis.confreads it.REDISCLI_AUTHis the variableredis-clitakes its password from.- The two
PublishPortlines decide who can connect: the server itself on127.0.0.1, and WireGuard devices on10.8.0.1. Redis does not listen on the public address at all. Without WireGuard, leave out the second line. - The
8.10tag gets every bug and security fix of Redis 8.10, and nothing more. Step 9 has the details.
Start it:
systemctl --user daemon-reload
systemctl --user start redis
systemctl --user enable --now podman-auto-update.timer
podman exec redis redis-cli --user admin ping
It answers PONG. Without a user name, you get the default user, which is off:
podman exec redis redis-cli ping
AUTH failed: WRONGPASS invalid username-password pair or user is disabled.
NOAUTH Authentication required.
5. Let your apps in
Over WireGuard. Go back to root with exit, and open port 6379 on the tunnel only:
ufw allow in on wg0 to any port 6379 proto tcp
On the app server, which has the tunnel address 10.8.0.2, Debian's redis-tools package has redis-cli:
apt install -y redis-tools
redis-cli -h 10.8.0.1 --user app --askpass
--askpass asks for the password, so it is not left in your shell history. Then:
10.8.0.1:6379> set greeting hello
OK
10.8.0.1:6379> get greeting
"hello"
10.8.0.1:6379> json.set doc:1 $ '{"name":"Anna","city":"Malmö"}'
OK
10.8.0.1:6379> config get maxmemory
(error) NOPERM User app has no permissions to run the 'config|get' command
In an app, the connection URL is redis://app:PASSWORD@10.8.0.1:6379/0. The traffic is encrypted by WireGuard.
Apps in containers on this server. Inside a container, 127.0.0.1 is the container itself. Use 10.8.0.1 instead: the server's WireGuard address can be reached from rootless containers, as we checked from a container on the same server.
From your own computer, through SSH. An SSH tunnel needs no change on the server:
ssh -N -L 6380:localhost:6379 root@203.0.113.10
While that runs, connect in another terminal to port 6380 on your own computer:
redis-cli -p 6380 --user app --askpass
Port 6380 is used so it does not clash with a Redis on your own computer.
About TLS. Redis can encrypt connections itself, with tls-port and a certificate. This guide does not use it: WireGuard and SSH already encrypt everything that leaves the server, and connections on 127.0.0.1 never leave it. Do not open port 6379 to the internet instead, with or without TLS.
6. Check what others see
On the server, as root:
ss -tlnp | grep 6379
LISTEN 0 128 127.0.0.1:6379 0.0.0.0:* users:(("pasta.avx2",pid=1158,fd=6))
LISTEN 0 128 10.8.0.1:6379 0.0.0.0:* users:(("pasta.avx2",pid=1158,fd=7))
pasta is the program that passes connections into the container. Only those two addresses: nothing on 0.0.0.0, [::] or the public address. From any computer that is not on the VPN:
nc -zv -w 10 203.0.113.10 6379
nc: connect to 203.0.113.10 port 6379 (tcp) timed out: Operation now in progress
ufw drops the attempt without an answer, and on the server journalctl -k | grep 'DPT=6379' shows it as [UFW BLOCK]. Redis is not listening there either, so it stays closed even if the firewall is switched off.
7. Connect from an app
An example in Python, with redis-py, the client Redis maintains. On the app server:
python3 -m venv ~/venv
~/venv/bin/pip install redis
Create app.py:
import os
import redis
r = redis.Redis(
host=os.environ.get("REDIS_HOST", "10.8.0.1"),
port=6379,
username="app",
password=os.environ["REDIS_PASSWORD"],
decode_responses=True,
)
r.set("greeting", "Hello from the app")
print(r.get("greeting"))
# A counter, and a list used as a simple queue
print("visits:", r.incr("visits"))
r.lpush("jobs", "send-invoice:1042")
print("next job:", r.rpop("jobs"))
read -rs REDIS_PASSWORD && export REDIS_PASSWORD
~/venv/bin/python app.py
Hello from the app
visits: 1
next job: send-invoice:1042
A login for each app
Give a second app a login of its own, limited to keys that start with its own prefix. As redis, add a line to the secret and restart:
{ podman secret inspect --showsecret --format '{{.SecretData}}' redis-users; echo "user shop on >$(openssl rand -hex 32) ~shop:* &shop:* +@all -@admin -flushall -flushdb"; } | podman secret create --replace redis-users -
systemctl --user restart redis
shop can now read and write only keys such as shop:cart:1, and a GET user:42 gets NOPERM No permissions to access a key. -flushall -flushdb matter here: those two commands empty the whole database, whatever the key limits say. KEYS and SCAN still list the names of every key, though not their values. Apps that must not even see each other's key names need a Redis of their own.
8. Back up every night
A snapshot can be taken from the running Redis, while apps keep using it. Create ~/redis-backup:
#!/bin/sh
# Save a snapshot of Redis, and keep 14 days of them
set -e
umask 077
mkdir -p ~/backup
podman exec redis redis-cli --user admin --rdb /tmp/backup.rdb
podman cp redis:/tmp/backup.rdb ~/backup/redis-$(date +%F).rdb
podman exec redis rm /tmp/backup.rdb
find ~/backup -name 'redis-*.rdb' -mtime +13 -delete
redis-cli --rdb asks Redis for a fresh snapshot, as a replica would. It writes it to a file in the container first: written straight to the screen, with -, the snapshot came out with 40 extra bytes at the end. Run the script every night with a timer of the user's own. Create ~/.config/systemd/user/redis-backup.service:
[Unit]
Description=Back up Redis
[Service]
Type=oneshot
ExecStart=%h/redis-backup
And ~/.config/systemd/user/redis-backup.timer:
[Unit]
Description=Back up Redis every night
[Timer]
OnCalendar=*-*-* 02:30
Persistent=true
[Install]
WantedBy=timers.target
chmod 755 ~/redis-backup
systemctl --user daemon-reload
systemctl --user enable --now redis-backup.timer
systemctl --user start redis-backup.service
ls -l ~/backup
-rw-r--r-- 1 redis redis 646902 Oct 14 02:02 redis-2026-10-14.rdb
Save the logins once, too, since a new server needs them and your apps know the passwords:
podman secret inspect --showsecret --format '{{.SecretData}}' redis-users > ~/backup/users.acl
Copy ~/backup to another machine, for example with restic, and keep it private: it holds all your data and the passwords.
Test a restore. When the AOF is on and its directory is missing, Redis starts empty and ignores a snapshot next to it. We saw exactly that: a restore with the AOF on came up with no keys. So switch the AOF off for the first start, and back on once the data is in. As redis, with the snapshot in ~/backup:
systemctl --user stop redis
podman volume rm redis-data
podman volume create redis-data
podman run --rm -v redis-data:/data -v ~/backup:/backup:ro docker.io/library/redis:8.10 cp /backup/redis-2026-10-14.rdb /data/dump.rdb
sed -i 's/^appendonly yes/appendonly no/' ~/redis.conf
systemctl --user start redis
podman exec redis redis-cli --user admin dbsize
podman exec redis redis-cli --user admin config set appendonly yes
sed -i 's/^appendonly no/appendonly yes/' ~/redis.conf
dbsize shows how many keys came back: all 10,002 of ours. config set appendonly yes writes a new AOF from the data in memory. Wait until podman exec redis redis-cli --user admin info persistence | grep aof_rewrite_in_progress shows 0 before you restart Redis. On a new server, create redis-users from users.acl in step 2, with podman secret create redis-users ~/backup/users.acl, and redis-admin-password from the admin password in it.
9. Update
podman auto-update checks once a day for a new image under the 8.10 tag, and restarts Redis when there is one: the patch releases with bug and security fixes. To see what it would do right now:
podman auto-update --dry-run
A new minor version, such as 8.12, or a new major version needs a change of tag. Take a backup, read the release notes on GitHub, change Image= in redis.container, and restart:
systemctl --user daemon-reload
systemctl --user restart redis
podman exec redis redis-cli --user admin info server | grep redis_version
We tested an update from 8.8.3 to 8.10.2 this way, and the data came along, a JSON document too. Going back to an older version may not work, since a newer Redis can write snapshots an older one cannot read: that is what the backup is for. The 8 and latest tags would also move you to each new minor version by themselves, so we use 8.10. Before a major version, check its licence too: it changed with 7.4 and 8.0.
Troubleshooting
Redis does not start after a reboot, and its log says Failed to bind port 6379 (Cannot assign requested address) for option '-t 10.8.0.1/6379-6379:6379-6379'. Redis started before WireGuard, and net.ipv4.ip_nonlocal_bind is not set. Check that sysctl net.ipv4.ip_nonlocal_bind shows 1 (step 1), then run systemctl --user reset-failed redis and systemctl --user start redis as redis.
NOAUTH Authentication required. or WRONGPASS invalid username-password pair or user is disabled. The client connects without a user name, as default, or with a wrong password. Set the user name app as well as the password.
NOPERM User app has no permissions to run the '...' command. The command is in the @admin group. Run it as admin on the server, or if the app really needs it, add it to the app's line in the secret, such as +config|get, and restart.
OOM command not allowed when used memory > 'maxmemory'. Redis is full. Raise maxmemory if the server has memory to spare, delete data, or use allkeys-lru if Redis is only a cache (step 3).
LOADING Redis is loading the dataset in memory. Redis has just started and is still reading its data from disk. Wait: it took 19 to 26 seconds for 1 GB on our server.
The data is gone after a restore. The AOF was on at the first start. Start again from the restore steps, with appendonly no for the first start.
An app server gets Connection timed out to 10.8.0.1. Check that WireGuard is up on both ends with wg show, and that ufw has the rule from step 5.