Recipe: Redis

Cache or datastore: persistence, memory limits and ACL users.

Intermediate14 min · lesson 20 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (4 files, 1 KB): r-redis.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-hard/r-redis.tar.gz && tar -xzf r-redis.tar.gz, which creates ~/lab/r-redis/. SHA-256: ce643084345e741075b65a202a729b3bb926e0646f92c532dc12c04a4f801b15

Start the official Redis image with no config and ask it a question from another container:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.9.2
$ docker network create lab-redis-demo docker run -d --name lab-redis-open --network lab-redis-demo redis:8-alpine sleep 1 docker run --rm --network lab-redis-demo redis:8-alpine redis-cli -h lab-redis-open CONFIG GET protected-mode docker run --rm --network lab-redis-demo redis:8-alpine redis-cli -h lab-redis-open SET written-by anyone
c699e18eb108f45a357e1851bf1f907ea24b36e4218507cf0f6caa600fc6baff dfc981d982ce1be697f1026b76b898555815fcd5254c6005a6669997825dfbc1 protected-mode no OK
$ docker logs lab-redis-open 2>&1 | grep -E 'WARNING: Redis does not require' docker rm -f lab-redis-open && docker network rm lab-redis-demo
1:M 26 Oct 2026 07:26:58.199 # WARNING: Redis does not require authentication and is not protected by network restrictions. Redis will accept connections from any IP address on any network interface. lab-redis-open lab-redis-demo

protected-mode is no, and a client that knows nothing but the container's name wrote a key. Redis itself has protected mode on by default. With no password and no explicit bind, it refuses clients that don't come from loopback. The official image switches that off, because in a container every client arrives over a network interface, and the default would make the image unusable. Redis notices and logs the warning above, which few people read. So docker run -p 6379:6379 redis on a host with a public address is an open database on the internet. Anything that scans for port 6379 can reach it. Whatever you add in this recipe has to replace protected mode, not rely on it.

Everything runs on the main lab VM, secopslog-docker, as ubuntu in ~/lab/r-redis. The lesson files download has redis.conf, compose.yaml, make-acl.sh and rcli.

Cache or datastore

Decide this first, because it sets most of your config:

Eviction is silent. The client that triggers it gets OK, and the keys that made room simply stop existing. That's right for a cache and wrong for anything that exists only in Redis. You'll build the datastore variant here. The memory section at the end shows what changes when you switch the same instance to cache behaviour.

Access control with an ACL file

requirepass sets one password for the default user, and every client you have then gets full power, including FLUSHALL and CONFIG SET. ACL users (Redis 6 and later) are better. Each client gets a name, a password and only the commands and keys it needs. You'll define three users and write them to an ACL file with a small script:

make-acl.sh
#!/bin/sh
# Writes secrets/users.acl from the two password files. The ACL file stores SHA-256 hashes only.
set -eu
hash() { printf %s "$(cat "$1")" | sha256sum | cut -d' ' -f1; }
{
echo "user default off"
echo "user app on #$(hash secrets/app_password.txt) ~app:* -@all +@read +@write +@keyspace +ping -@dangerous"
echo "user ops on #$(hash secrets/ops_password.txt) ~* &* +@all"
} > secrets/users.acl

default is switched off, so a client that doesn't log in can do nothing. app may read, write and expire keys whose names start with app:, plus PING for the health check. It gets nothing in the @dangerous category (FLUSHALL, CONFIG, KEYS, DEBUG and the like). ops is the administrator you use for maintenance. A # followed by a SHA-256 hash stores the password's hash instead of the password. So anyone can read the ACL file without learning either password. Create the passwords and the ACL file:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.9.2
$ install -d -m 700 secrets backup openssl rand -hex 24 > secrets/app_password.txt openssl rand -hex 24 > secrets/ops_password.txt ./make-acl.sh chmod 644 secrets/* cut -c1-40 secrets/users.acl
user default off user app on #7fb95654cfc72ef24e43f1095fd user ops on #12c439bd3ae27b03833ad1dbc21

Your hashes will differ on every run. The files are 0644 in a 0700 directory. Redis runs as the redis user (UID 999) and reads the ACL file itself. A Compose secret without Swarm is a bind mount that keeps the host file's owner and mode, so 0600 would lock Redis out ("Recipe: PostgreSQL, MySQL and MongoDB" shows that failure for Postgres and Mongo).

The configuration

redis.conf
# Redis as a small datastore: writes are journaled and nothing is evicted silently.
# appendonly is set on the command line in compose.yaml (an RDB restore needs it off for one start).
aclfile /run/secrets/redis_users_acl
appendfsync everysec
maxmemory 64mb
maxmemory-policy noeviction
compose.yaml
name: lab-cache
services:
redis:
image: redis:8-alpine
# set REDIS_APPENDONLY=no only for the first start of an RDB restore
command: ["redis-server", "/usr/local/etc/redis/redis.conf", "--appendonly", "${REDIS_APPENDONLY:-yes}"]
volumes:
- ./redis.conf:/usr/local/etc/redis/redis.conf:ro
- redisdata:/data
secrets:
- redis_users_acl
- redis_app_password
- redis_ops_password
mem_limit: 256m
healthcheck:
test: ["CMD-SHELL", "REDISCLI_AUTH=\"$$(cat /run/secrets/redis_app_password)\" redis-cli --user app ping | grep -q PONG"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
start_interval: 1s
# time to finish the AOF/RDB write on shutdown before Docker sends SIGKILL (default 10s)
stop_grace_period: 1m
restart: unless-stopped
# no ports: services on this project's network reach it as redis:6379
volumes:
redisdata:
secrets:
redis_users_acl:
file: ./secrets/users.acl
redis_app_password:
file: ./secrets/app_password.txt
redis_ops_password:
file: ./secrets/ops_password.txt

aclfile points at the secret. appendfsync everysec is the default, written down because it's the durability promise. The append-only file (AOF) is flushed to disk once a second, so a power loss costs at most about a second of acknowledged writes. always flushes on every write and is much slower; no leaves it to the kernel. maxmemory 64mb with noeviction is the datastore choice you picked from the table. appendonly is passed on the command line instead, so one restore step below can switch it off without editing the file.

The command starts with redis-server. The image's entrypoint drops from root to the redis user (with setpriv, keeping no capabilities) when it starts the server. It also recognises a first argument ending in .conf, so a bare config path works too. A command that starts with sh -c would skip the drop and run the server as root. The entrypoint also fixes ownership of files in /data and of the config file when the redis user can't read them. That fails harmlessly on this read-only mount, and it's one more reason to make the file readable. The health check logs in as app and greps for PONG, because redis-cli exits 0 even when the server answers with an error such as NOAUTH. mem_limit: 256m leaves the server room above maxmemory, for reasons the memory section shows. And there's no ports: entry. Services in this project reach Redis as redis:6379 on the project network, and nothing else can.

Typing a password for every redis-cli call is clumsy, and passing one on a command line leaks it into process listings. So a small wrapper runs redis-cli inside the container as an ACL user, with the password read from the secret file there:

rcli
#!/bin/sh
# usage: ./rcli USER ARGS... runs redis-cli in the redis container as the ACL user USER.
# The password is read from the secret file inside the container, so it never appears in a host
# command line or in redis-cli's arguments.
user=$1; shift
exec docker compose exec -T redis sh -c \
'REDISCLI_AUTH="$(cat "/run/secrets/redis_${0}_password")" exec redis-cli --user "$0" "$@"' "$user" "$@"

Up, and who can do what

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.9.2
$ docker compose up -d --wait
... Container lab-cache-redis-1 Creating Container lab-cache-redis-1 Created Container lab-cache-redis-1 Starting Container lab-cache-redis-1 Started Container lab-cache-redis-1 Waiting Container lab-cache-redis-1 Healthy
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS lab-cache-redis-1 redis:8-alpine "docker-entrypoint.s…" redis 2 seconds ago Up 1 second (healthy) 6379/tcp
$ docker run --rm --network lab-cache_default redis:8-alpine redis-cli -h redis GET app:order:1; echo "exit=$?"
NOAUTH Authentication required. exit=0
$ ./rcli app SET app:order:1 paid ./rcli app GET app:order:1 ./rcli app SET billing:1 x ./rcli app CONFIG GET maxmemory ./rcli app FLUSHALL
OK paid NOPERM No permissions to access a key NOPERM User app has no permissions to run the 'config|get' command NOPERM User app has no permissions to run the 'flushall' command
$ ./rcli ops CONFIG GET maxmemory maxmemory-policy appendonly protected-mode docker compose exec -T redis ps -o user,args
maxmemory 67108864 protected-mode no appendonly yes maxmemory-policy noeviction USER COMMAND redis redis-server *:6379 root ps -o user,args

The PORTS column shows 6379/tcp with no host address or arrow: exposed inside the network, published nowhere. A client container on the project network reaches the server by name and gets NOAUTH. exit=0 shows redis-cli reporting success for it, as promised. The app user writes and reads its own keys. It's refused a key outside app:, and it's refused CONFIG and FLUSHALL. Through ops, you can confirm the settings: 64 MiB, noeviction, AOF on. protected-mode is still no, which no longer matters because nothing's reachable without a password. The server process runs as redis.

If a tool on the host itself needs Redis, publish the port on loopback only, 127.0.0.1:6379:6379, and keep the ACL users. A bare 6379:6379 publishes on every address of the host. The Docker-managed firewall rules for published ports take effect before most host firewall setups ("Publishing ports and the packet path" explains why ufw doesn't help there).

Persistence: AOF and RDB

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.9.2
$ docker compose restart redis ./rcli app GET app:order:1 docker compose exec -T redis ls -l /data /data/appendonlydir
Container lab-cache-redis-1 Restarting Container lab-cache-redis-1 Started paid /data: total 8 drwx------ 2 redis redis 4096 Oct 26 07:27 appendonlydir -rw------- 1 redis redis 112 Oct 26 07:27 dump.rdb /data/appendonlydir: total 12 -rw------- 1 redis redis 89 Oct 26 07:27 appendonly.aof.1.base.rdb -rw------- 1 redis redis 64 Oct 26 07:27 appendonly.aof.1.incr.aof -rw------- 1 redis redis 102 Oct 26 07:27 appendonly.aof.manifest

Your key survived a restart because Redis replayed the AOF on start. Since Redis 7 the AOF is a directory. It holds a base file in RDB format, an incremental file with the writes since, and a manifest that ties them together. dump.rdb is a point-in-time snapshot, written on shutdown and on the default schedule. That's after 1 hour if 1 key changed, 5 minutes if 100 changed, or 1 minute if 10,000 changed. The final write on shutdown is why the Compose file sets stop_grace_period: 1m. With a large dataset it can take longer than Docker's default ten seconds, after which Docker sends SIGKILL and the write is lost. If you've enabled the AOF, restarts load it and ignore dump.rdb. You'll see why that rule matters in a moment.

Backup and restore

redis-cli --rdb asks the server for a fresh snapshot over the replication protocol and writes it out, here to stdout and into a host file. It needs a user allowed to run SYNC, which is why it runs as ops:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.9.2
$ ./rcli ops --rdb - > backup/redis.rdb ls -l backup/redis.rdb head -c 9 backup/redis.rdb; echo
sending REPLCONF capa eof sending REPLCONF rdb-only 1 SYNC sent to master, writing bytes of bulk transfer until EOF marker to '-' Transfer finished with success after 195 bytes -rw-r--r-- 1 ubuntu ubuntu 235 Oct 26 12:57 backup/redis.rdb REDIS0015

The progress messages go to stderr, apart from the snapshot itself. The file starts with the REDIS magic and the RDB format version. Your sizes will vary. Now the trap. Lose the volume, copy the backup into a fresh one as dump.rdb, and start the normal config:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.9.2
$ docker compose down -v docker compose run --rm --no-deps -T -v "$PWD/backup:/backup:ro" redis cp /backup/redis.rdb /data/dump.rdb docker compose up -d --wait ./rcli app GET app:order:1 docker compose logs redis | grep -E 'Creating AOF base|DB loaded'
... Container lab-cache-redis-1 Starting Container lab-cache-redis-1 Started Container lab-cache-redis-1 Waiting Container lab-cache-redis-1 Healthy redis-1 | 1:M 26 Oct 2026 07:27:10.244 * Creating AOF base file appendonly.aof.1.base.rdb on server start

The key's gone (the empty line is the reply for a missing key). With appendonly yes and no AOF in the volume, Redis created a new, empty AOF base on start and loaded that instead of dump.rdb. Nothing failed, and the health check was green. The working procedure starts the server once with AOF off, so it loads the snapshot. Then it turns AOF on at run time, which writes a new AOF from memory:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.9.2
$ docker compose down -v docker compose run --rm --no-deps -T -v "$PWD/backup:/backup:ro" redis cp /backup/redis.rdb /data/dump.rdb REDIS_APPENDONLY=no docker compose up -d --wait ./rcli app GET app:order:1 ./rcli ops CONFIG SET appendonly yes until ./rcli ops INFO persistence | grep -q '^aof_rewrite_in_progress:0'; do sleep 1; done docker compose up -d --wait ./rcli app GET app:order:1 docker compose logs redis | grep -E 'DB loaded from append only file'
... Container lab-cache-redis-1 Waiting Container lab-cache-redis-1 Healthy paid OK ... Container lab-cache-redis-1 Waiting Container lab-cache-redis-1 Healthy paid redis-1 | 1:M 26 Oct 2026 07:27:15.539 * DB loaded from append only file: 0.001 seconds

The first GET shows the snapshot loaded. CONFIG SET appendonly yes started an AOF rewrite, and the loop waited until it finished. Then docker compose up -d recreated the container with the normal command line, which loaded the new AOF. Keep your backup until that last check passes. You can also copy a stopped instance's whole /data (AOF directory included), with the stop-copy-start method from "Volumes, bind mounts and tmpfs in practice". That's the other valid backup.

Memory: maxmemory, the policy and the container limit

Fill the instance past its 64 MiB with 400,000 keys of 200 bytes each. You'll use redis-cli --pipe, which streams commands without waiting for each reply:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.9.2
$ seq 1 400000 | awk '{ printf "SET app:fill:%d %0200d\n", $1, 0 }' | ./rcli ops --pipe 2>&1 | tail -3 ./rcli app SET app:order:2 paid ./rcli ops INFO memory | grep -E '^(used_memory_human|maxmemory_human)'
OOM command not allowed when used memory > 'maxmemory'. Last reply received from server. errors: 164150, replies: 400000 OOM command not allowed when used memory > 'maxmemory'. used_memory_human:64.04M maxmemory_human:64.00M

With noeviction, Redis accepted writes until it reached maxmemory. Then it answered every further write with an OOM error, which --pipe counts in its summary (the exact split varies between runs). The app's next SET failed loudly too. That's the datastore behaviour you want: a failed write reaches someone, but a silently evicted job doesn't. Switch the same instance to cache behaviour:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.9.2
$ ./rcli ops CONFIG SET maxmemory-policy allkeys-lru ./rcli app SET app:order:2 paid ./rcli ops INFO stats | grep '^evicted_keys' docker stats --no-stream --format '{{.Name}}: {{.MemUsage}}' lab-cache-redis-1
OK OK evicted_keys:243 lab-cache-redis-1: 70.34MiB / 256MiB

Now the write succeeds, and evicted_keys counts the keys Redis dropped to make room. Redis samples a few keys per eviction (maxmemory-samples, 5 by default) rather than keeping an exact LRU order, so which keys go is approximate. Watch evicted_keys on a cache. A steady climb means your working set is bigger than maxmemory.

The container limit is separate from maxmemory and must be larger. Redis uses more memory than its data. There's allocator fragmentation, client buffers, and above all the copy-on-write cost of a fork. RDB snapshots and AOF rewrites run in a child process that shares memory with the parent until the parent changes a page. Under heavy writes during a rewrite, the total can approach twice the dataset. Set maxmemory to roughly half to two thirds of the container limit. If the kernel's memory limit is hit first, Redis is killed instead of answering with errors, and you lose everything since the last fsync. Redis also logs a warning when the host has vm.overcommit_memory at 0, because a fork can then fail under memory pressure. That's a host sysctl, set on the Docker host (not in the container), and the lab VM leaves it unchanged.

Clean up:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.9.2
$ docker compose down -v cd ~ && rm -rf ~/lab/r-redis
Container lab-cache-redis-1 Stopping Container lab-cache-redis-1 Stopped Container lab-cache-redis-1 Removing Container lab-cache-redis-1 Removed Volume lab-cache_redisdata Removing Network lab-cache_default Removing Volume lab-cache_redisdata Removed Network lab-cache_default Removed

Try this

Before the cleanup step, run ./rcli app KEYS 'app:*' and ./rcli app GET billing:1 the way the app step ran its commands. Predict each reply from the ACL rules above (KEYS sits in @dangerous, and billing:1 is outside app:), then compare with the NOPERM lines the app step printed. Finish with ./rcli ops INFO stats | grep '^evicted_keys' and check whether the count moved since the lru step.

Bottom line

Give Redis named ACL users and no published port. The stock image ships with protected-mode set to no, and it let a client that knew only the container's name write a key. Decide cache or datastore before anything else. With noeviction a full instance returns OOM errors, while allkeys-lru reported evicted_keys:243 and returned OK, so the wrong policy turns lost data into a silent success.

Quick check
01A team runs docker run -d -p 6379:6379 redis:8-alpine on a cloud VM with a public address and no config file, reasoning that protected mode blocks remote clients when no password is set. What is the actual exposure?
Incorrect — The image disables protected mode, and the published port accepts clients from any address that can reach the host.
Incorrect — There is no such read-only mode; without ACLs or a password the default user can run every command.
Correct — The official image ships with protected-mode no, as the lab showed with CONFIG GET and a SET from a stranger container.
Incorrect — It starts and only logs a warning that it requires no authentication.
02After losing a Redis volume you copy last night's dump.rdb into a new volume and start the usual configuration with appendonly yes. The container is healthy but the keys are missing. Why?
Incorrect — The lab restored that same file successfully with AOF switched off for the first start.
Correct — Start once with appendonly no to load the snapshot, then CONFIG SET appendonly yes to write a new AOF from memory.
Incorrect — The entrypoint fixes ownership of *.rdb files in /data, and the log showed Redis creating an AOF base, not a permission error.
Incorrect — Redis loads the dataset before it accepts commands, and the key was still missing afterwards.
03A Redis instance holds a job queue that exists nowhere else, with maxmemory 64mb and maxmemory-policy allkeys-lru. Traffic grows until memory is full. What happens to the next LPUSH?
Incorrect — That is noeviction; with an LRU policy Redis makes room instead of refusing.
Incorrect — Redis serves data from memory only; the AOF is a log of writes for restarts.
Incorrect — maxmemory is enforced by Redis long before the container limit, which is there for forks and buffers.
Correct — Eviction is silent and returns OK; only evicted_keys shows it. A queue that is the only copy needs noeviction.

Related