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 configuration and ask it a question from another container:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.8.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
2aecde68a76408bd40d67cf6a4924224e1a52692a7f1eca679f896e939136e6d 70317e127bf9f4a9d1701c282c57fff8a9d0a91aff91baf474e69cfeff228de1 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 07 Oct 2026 22:40:05.803 # 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 do not 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, reachable by anything that scans for port 6379. Whatever this recipe adds, it 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 the configuration:

Eviction is silent: the client that triggers it gets OK, and the keys that made room simply stop existing. That is right for a cache and wrong for anything that exists only in Redis. This recipe builds the datastore variant; the memory section at the end shows what changes when the same instance is switched to cache behaviour.

Access control with an ACL file

requirepass sets one password for the default user, and every client then has 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. This recipe defines three users and writes 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 does not log in can do nothing. app may read, write and expire keys whose names start with app: and nothing in the @dangerous category (FLUSHALL, CONFIG, KEYS, DEBUG and the like), plus PING for the health check. ops is the administrator. A # followed by a SHA-256 hash stores the password's hash instead of the password, so the ACL file can be read without revealing either password. Create the passwords and the ACL file:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.8.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 #adb2a4a0991c60008fca611956b user ops on #2e60cbd66caec0eb6df39bf3a28

The hashes 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, and 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 is 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 from the table. appendonly is passed on the command line instead, so that 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, and 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 cannot read them, which fails harmlessly on this read-only mount and is 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 is 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.8.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 maxmemory-policy noeviction protected-mode no appendonly yes 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, and exit=0 shows redis-cli reporting success for it, as promised. The app user writes and reads its own keys and is refused a key outside app:, CONFIG and FLUSHALL. Through ops, the server confirms the settings: 64 MiB, noeviction, AOF on. protected-mode is still no, which no longer matters because nothing is 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, and the Docker-managed firewall rules for published ports take effect before most host firewall setups ("Publishing ports and the packet path" explains why ufw does not help there).

Persistence: AOF and RDB

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.8.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 7 22:40 appendonlydir -rw------- 1 redis redis 112 Oct 7 22:40 dump.rdb /data/appendonlydir: total 12 -rw------- 1 redis redis 89 Oct 7 22:40 appendonly.aof.1.base.rdb -rw------- 1 redis redis 64 Oct 7 22:40 appendonly.aof.1.incr.aof -rw------- 1 redis redis 102 Oct 7 22:40 appendonly.aof.manifest

The key survived a restart because Redis replayed the AOF on start. Since Redis 7 the AOF is a directory: 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 the default schedule (after 1 hour if 1 key changed, 5 minutes if 100 changed, 1 minute if 10,000 changed) and on shutdown. That 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. Restarts load the AOF when it is enabled and ignore dump.rdb. 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.8.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 8 04:10 backup/redis.rdb REDIS0015

The progress messages go to stderr; the file starts with the REDIS magic and the RDB format version. Sizes vary. Now the trap. Lose the volume, copy the backup into a fresh one as dump.rdb, and start the normal configuration:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.8.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 07 Oct 2026 22:40:17.642 * Creating AOF base file appendonly.aof.1.base.rdb on server start

The key is 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 turns AOF on at run time, which writes a new AOF from memory:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.8.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 07 Oct 2026 22:40:23.039 * DB loaded from append only file: 0.001 seconds

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

Memory: maxmemory, the policy and the container limit

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

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.8.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'. OOM command not allowed when used memory > 'maxmemory'. OOM command not allowed when used memory > 'maxmemory'. 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 and then answered every further write with an OOM error, which --pipe counts in its summary (the exact split varies between runs). The application's next SET failed loudly too. That is the datastore behaviour you want: a failed write reaches someone, a silently evicted job does not. Switch the same instance to cache behaviour:

ubuntu@secopslog-docker:~/lab/r-redis · Docker 29.8.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:242 lab-cache-redis-1: 70.5MiB / 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 the working set is bigger than maxmemory.

The container limit is separate from maxmemory and must be larger. Redis's memory use is more than its data: allocator fragmentation, client buffers, and above all the copy-on-write cost of a fork, because 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 everything since the last fsync is lost. Redis also logs a warning when the host has vm.overcommit_memory at 0, because a fork can then fail under memory pressure; that is 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.8.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
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.

Try this

Work through “Memory: maxmemory, the policy and the container limit” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from recipe: redis, keep “Memory: maxmemory, the policy and the container limit”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related