Self-host Redis
Your own Redis on a real disk, private by default, with backups you've restored.
Shelley handles the setup. About 10 minutes.
Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/redis/prompt.txt | ssh exe.dev new --prompt=/dev/stdinRedis keeps data in memory and answers in well under a millisecond, which makes it the usual home for caches, background job queues, sessions, rate limits, and leaderboards. Here it gets a VM of its own (or moves in next to your app), a memory limit to match, persistence that fits what you store, nightly backups you've seen restored, and Redis Insight, its web UI, open only to you. Nothing listens on the internet, and nobody gets in without a password: your apps reach Redis on the VM, over SSH, or over your private Tailscale network.
What you get#
- Redis 8, started at boot, listening on localhost only, with a memory limit sized to the VM and every write logged to disk.
- No anonymous access, an admin user for you, and an
appuser whose settings are in~/redis/app.env. - Redis Insight at the VM's private URL, open only to the owner's exe.dev email.
- Nightly snapshot backups, one restored into a scratch Redis and checked.
What's running, and where
https://my-redis.exe.xyz/
Redis Insight, connected as the Redis admin user
Deploy with Shelley#
Click Deploy and Shelley will set up Redis on a new VM, then check that it works. No config-file wrangling required.
Prefer a terminal?
curl -fsSL \
https://exe-staging.dev/deploy/redis/prompt.txt \
| ssh exe.dev new --prompt=/dev/stdinWhen it's done, run this on your computer (only you can):
ssh my-redis.exe.xyz cat redis/app.env
ssh -N -L 6380:localhost:6379 my-redis.exe.xyzWhat Shelley is told
Set up Redis on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/redis.md from top to bottom.
The recipe's example VM name is my-redis. This VM's name is the output of `hostname`: use it wherever the recipe writes my-redis. Skip the step that creates the VM; you are on it.
You are done when all of these are true. Check each one yourself:
- Redis 8, started at boot, listening on localhost only, with a memory limit sized to the VM and every write logged to disk.
- No anonymous access, an admin user for you, and an `app` user whose settings are in `~/redis/app.env`.
- Redis Insight at the VM's private URL, open only to the owner's exe.dev email.
- Nightly snapshot backups, one restored into a scratch Redis and checked.
Read the whole recipe, including the closed details. For initial setup,
follow "Install by hand" from top to bottom: the Redis repository and
server, the users and lock-down, tuning (leave mode=store), the app
user, Redis Insight on port 8000, the backup timer, and the scratch
restore test. The walkthrough sections are for the owner afterwards:
don't import data, switch to cache mode, set up Tailscale or Docker
access, or run the Restore or Update instructions during initial setup.
Never overwrite an existing ~/redis/*.env file or /etc/redis/users.acl.
Allow only the owner's email in Redis Insight, read from the reflection
integration; if that fails, ask the owner for it. Check Redis Insight on
localhost with no email, a wrong email, and the owner's email.
Leave the VM private and do not reboot. Don't paste passwords into the
chat; say they are in ~/redis/app.env.
Finish with the URLs I should open and these commands, which only I can run, from my own computer (with this VM's name in them):
ssh my-redis.exe.xyz cat redis/app.env
ssh -N -L 6380:localhost:6379 my-redis.exe.xyz
Also at /deploy/redis/prompt.txt. The recipe it follows is /deploy/redis.md.
Open Redis Insight#
When Shelley finishes, open the URL it gives you. It'll look like
https://my-redis.exe.xyz/. Sign in to exe.dev and you land in
Redis Insight, already connected as admin,
the Redis admin user. Click the database to browse keys, edit values,
run commands in its CLI and Workbench, or see what uses the most memory.
Only the exe.dev emails listed in ~/redis/insight/allowed-emails get in,
even if you share the VM. To let a teammate in, add a line with their
email and 1; to that file, then reload:
echo 'teammate@example.com 1;' >> ~/redis/insight/allowed-emails
sudo systemctl reload nginx
Anyone on that list can read, change, or delete anything, so keep it short.
Prefer the command line? On the VM, redis-admin is redis-cli, signed
in as admin:
redis-admin INFO keyspace
redis-admin --scan --pattern 'session:*' | head
Connect an app on this VM#
Your app's Redis user is called app. It can use every key and every
data command, but can't change the server's settings, users, or
persistence. Its settings are in ~/redis/app.env, readable only by you:
REDIS_URL=redis://app:<password>@127.0.0.1:6379/0
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_USERNAME=app
REDIS_PASSWORD=<password>
Sidekiq, Rails, Celery, BullMQ, redis-py, ioredis, and go-redis take
the REDIS_URL as is. Laravel reads the separate REDIS_ lines.
The username matters. Clients that send only a password sign in as
Redis's default user, which is switched off here, and get WRONGPASS.
Keep app: in the URL, or set the username where your client asks for
it.
Several apps can share one Redis. Give each its own user, and its own key
prefix (most libraries have a prefix or namespace setting) so their
keys don't collide. On the VM:
~/redis/create-user.sh blog
cat ~/redis/blog.env
The nightly backup picks it up automatically. To keep blog to its own
keys, redis-admin ACL SETUSER blog resetkeys '~blog:*' and then
redis-admin ACL SAVE.
Run it next to your app#
Already have an app on a VM, say from a framework page? Run
Install by hand on that VM, skipping only "Create a VM".
Put Redis Insight on port 8001 instead, as that step explains, because
port 8000 is your app's. Give Redis a smaller share of memory too, such as
percent=25 in "Size it for the VM", so your app has room. Then add
Redis's settings to the app's environment file (remove any older REDIS_
lines from it first):
cat ~/redis/app.env >> ~/app.env
If the app runs under systemd, add After=redis-server.service to its
[Unit] section, so that it starts after Redis at boot.
From a Docker container#
Inside a container, 127.0.0.1 is the container itself. Let Redis listen
beyond localhost, on the VM:
printf 'bind * -::*\nprotected-mode yes\n' | sudo tee /etc/redis/conf.d/20-listen.conf
sudo systemctl restart redis-server
Then start containers with --add-host=host.docker.internal:host-gateway
(in Compose: extra_hosts: ["host.docker.internal:host-gateway"]) and
use host.docker.internal in place of 127.0.0.1. Every connection still
needs a user and password, and nothing outside the VM can reach port 6379.
Cache or data store#
Redis holds everything in memory, so it needs a rule for when memory runs
out, and a choice of how much to write to disk. ~/redis/tune.sh sets
both, in one of two modes.
Store (what Shelley sets up) keeps every key. Redis logs each write to
its append-only file (AOF) and flushes it to disk every second, and also
saves a full snapshot (dump.rdb) every so often. When memory is full,
writes fail with an OOM error and nothing is thrown away. Use it for job
queues (Sidekiq, BullMQ, Celery), sessions, rate limits, and anything you
can't rebuild. BullMQ and Sidekiq insist on it.
Cache evicts the least recently used keys when memory is full, so writes always succeed. It skips the append-only file and keeps only the periodic snapshot, so the cache is warm again after a restart. Use it when every key can be rebuilt from somewhere else. To switch, on the VM:
sed -i 's/^mode=.*/mode=cache/' ~/redis/tune.sh
~/redis/tune.sh
Back to mode=store the same way; the script turns the append-only file
on safely, without losing keys. Either way, Redis gets half the VM's
memory (percent=50 in the script). The rest leaves room for saving to
disk, which briefly needs extra memory, and for everything else on the
VM. After resizing the VM, run ~/redis/tune.sh again.
One Redis for both a cache and a job queue? Use store mode and give cache keys an expiry, or run the cache in a second Redis. A full store refuses new cache entries, but never drops a job.
Connect from your computer#
Port 6379 isn't on the internet, so you bring it to your computer through SSH. exe.dev's SSH forwards ports like any SSH server. On your computer, print the connection settings, then open a tunnel and leave it running:
ssh my-redis.exe.xyz cat redis/app.env
ssh -N -L 6380:localhost:6379 my-redis.exe.xyz
In another terminal, connect to port 6380, so a Redis on your computer doesn't get in the way. It asks for the app's password:
redis-cli -p 6380 --user app --askpass
The desktop version of Redis Insight, and other Redis GUIs, can open the
tunnel themselves. Choose their SSH tunnel option, with SSH host
my-redis.exe.xyz, user exedev and your exe.dev SSH key, then Redis host
127.0.0.1, port 6379, and the app's username and password.
For admin work, skip the tunnel: ssh -t my-redis.exe.xyz redis-admin.
Connect from another VM#
exe.dev VMs don't share a private network, and exe.dev's VM-to-VM integration carries HTTPS only. The dependable way to reach Redis from another VM is to put both VMs on your Tailscale network.
On both VMs, start Tailscale (it's preinstalled) and open the login URL it prints. To join without a login link, use exe.dev's Tailscale integration instead.
sudo systemctl enable --now tailscaled
sudo tailscale up --hostname=$(hostname)
Then, on the Redis VM, let Redis listen beyond localhost:
printf 'bind * -::*\nprotected-mode yes\n' | sudo tee /etc/redis/conf.d/20-listen.conf
sudo systemctl restart redis-server
On the other VM, swap 127.0.0.1 for the Redis VM's name:
redis://app:<password>@my-redis:6379/0. Check it with
nc -zv my-redis 6379. Redis users aren't tied to an address, so the
passwords are what keeps others out; use
Tailscale's access rules if not every
device on your network should reach port 6379.
You can also tunnel over SSH from VM to VM, as from your computer. The
other VM then needs an SSH key added to your exe.dev account
(ssh exe.dev ssh-key add), and that key opens all your VMs.
What doesn't work:
- A public
my-redis.exe.xyz:6379. There's no raw TCP port to open. - exe.dev share links and the VM-to-VM proxy. They speak HTTPS, and Redis's protocol isn't HTTP.
- Hosted tools that only take a host and port. Some can use an SSH tunnel or Tailscale instead; check their docs.
Import data#
Moving from another Redis? Most of the time you don't need to: a cache
refills itself, and a job queue can drain on the old server while the app
sends new jobs here. For data you want to keep, copy the keys, each with
its expiry, with
copy-keys.py, a short
Python script that reads keys with DUMP and writes them with RESTORE.
You need the old server's URL (rediss:// if it uses TLS) from your
current host's dashboard. On the VM:
curl -fsSLO https://exe-staging.dev/deploy/redis/copy-keys.py
. ~/redis/app.env
uv run copy-keys.py 'rediss://default:oldpassword@old.example.com:6379/0' "$REDIS_URL"
It prints how many keys it copied and skips keys that already exist here
(add --replace to overwrite them). Compare redis-admin INFO keyspace
with the old server's. Search indexes aren't keys: create them again with
FT.CREATE and Redis indexes the copied data.
The old server must be the same Redis version or older, or Valkey 8 or
older. If it has more than one database (/1, /2 in the URL), copy each.
Switching over for good? Stop the app's writes first, copy, then move the
app.
Keep your data#
Every night, a snapshot of all keys, plus the users and the connection
files, lands in ~/redis/backups; the newest 14 stay. A backup on the same
disk won't survive losing the VM, so keep a copy off the VM. On your
computer:
scp -r my-redis.exe.xyz:redis/backups redis-backups
These files hold all your data and the apps' passwords; keep them
private. To undo a mistake or move to a new VM, see Back up and
restore. Before any upgrade, run
~/redis/backup.sh on the VM.
Something odd? On the VM, tail -n 50 /var/log/redis/redis-server.log
usually explains it, or ask Shelley to look.
The nuts and bolts#
Happy clicking Deploy? You can stop here. These are the same setup steps Shelley follows, plus the maintenance commands for later.
Install by hand
Create a VM#
On your computer (pick another name if my-redis is taken):
ssh exe.dev new --name=my-redis
ssh my-redis.exe.xyz
Adding Redis next to an app? Skip this step and run the rest on the app's
VM. All remaining commands run on the VM, as exedev.
Install Redis 8#
Ubuntu's own redis-server is Redis 7.0, which Redis stopped supporting
in 2024. Redis's APT repository carries every current release. Add it with
Redis's signing key, and pin it to Redis 8, so that updates bring new 8.x
releases but never a new major version by surprise:
sudo install -d -m 755 /etc/apt/keyrings
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor --yes -o /etc/apt/keyrings/redis.gpg
echo "deb [signed-by=/etc/apt/keyrings/redis.gpg] https://packages.redis.io/deb $(. /etc/os-release; echo $VERSION_CODENAME) main" |
sudo tee /etc/apt/sources.list.d/redis.list
printf 'Package: redis redis-server redis-sentinel redis-tools\nPin: version 6:8.*\nPin-Priority: 1001\n' |
sudo tee /etc/apt/preferences.d/redis
sudo apt-get update
apt-cache policy redis-server | head -n 3
The candidate should be 6:8.x.y-1rl1~noble1, the newest Redis 8. Install
the server, its command-line tools, and jq:
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y redis-server redis-tools jq
redis-server --version
systemctl is-enabled redis-server
Expect Redis server v=8.x.y and enabled: it starts at boot. The
package already keeps Redis on localhost, with the data in
/var/lib/redis and the log in /var/log/redis, but anyone on the VM can
connect without a password.
Lock it down#
Redis's users and their permissions live in an ACL file. This switches
the built-in default user off, so a connection without a username and
password can do nothing, and adds an admin user with a random password,
kept in ~/redis/admin.env. The file keeps only a SHA-256 hash of it.
mkdir -p -m 700 ~/redis
[ -e ~/redis/admin.env ] || (umask 077; printf 'REDIS_USERNAME=admin\nREDIS_PASSWORD=%s\n' "$(openssl rand -hex 24)" > ~/redis/admin.env)
. ~/redis/admin.env
sudo test -e /etc/redis/users.acl || {
printf 'user default off resetchannels -@all\nuser admin on #%s ~* &* +@all\n' \
"$(printf %s "$REDIS_PASSWORD" | sha256sum | cut -d' ' -f1)" | sudo tee /etc/redis/users.acl >/dev/null
sudo chown redis:redis /etc/redis/users.acl && sudo chmod 640 /etc/redis/users.acl
}
Redis reads its settings from /etc/redis/redis.conf, which the package
owns. Leave that file alone, so updates can replace it, and put yours in
/etc/redis/conf.d, loaded after it: the ACL file, and who Redis listens
to. protected-mode is a second guard that refuses connections from
other machines while the default user has no password.
sudo install -d -o redis -g redis -m 750 /etc/redis/conf.d
echo 'aclfile /etc/redis/users.acl' | sudo tee /etc/redis/conf.d/05-users.conf
sudo test -e /etc/redis/conf.d/20-listen.conf ||
printf 'bind 127.0.0.1 -::1\nprotected-mode yes\n' | sudo tee /etc/redis/conf.d/20-listen.conf
sudo mkdir -p /etc/systemd/system/redis-server.service.d
sudo tee /etc/systemd/system/redis-server.service.d/exe-dev.conf >/dev/null <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/bin/redis-server /etc/redis/redis.conf --include /etc/redis/conf.d/*.conf
EOF
sudo systemctl daemon-reload
sudo systemctl restart redis-server
Now a small wrapper, redis-admin: redis-cli signed in as admin, with
the password from ~/redis/admin.env (passed in the environment, not on
the command line).
sudo tee /usr/local/bin/redis-admin >/dev/null <<'EOF'
#!/bin/sh
# redis-cli as the Redis admin user, with the password from ~/redis/admin.env.
. "$HOME/redis/admin.env"
REDISCLI_AUTH=$REDIS_PASSWORD exec redis-cli -h 127.0.0.1 -p 6379 --user "$REDIS_USERNAME" "$@"
EOF
sudo chmod 755 /usr/local/bin/redis-admin
redis-cli PING
redis-admin ACL WHOAMI
ss -ltn | grep 6379
Expect NOAUTH Authentication required. from the anonymous PING, then
admin, and Redis on 127.0.0.1:6379 and [::1]:6379 only.
Size it for the VM#
Out of the box, Redis uses as much memory as it likes and only saves a snapshot now and then. This script caps it at a share of the VM's memory and picks the persistence and eviction policy for store or cache mode. Run it again after resizing the VM or changing a setting at its top.
cat > ~/redis/tune.sh <<'EOF'
#!/usr/bin/env bash
# Size Redis for this VM's memory, choose how it persists, and restart it.
# Run again after resizing the VM or changing a setting below.
set -euo pipefail
# store: keep every key, log each write to disk, and refuse writes when
# memory is full.
# cache: evict the least recently used keys when memory is full, and
# save only a snapshot now and then.
mode=store
# Share of the VM's memory Redis may fill. Saving to disk briefly needs
# extra memory, so keep it at 60 or below.
percent=50
case $mode in
store) policy=noeviction aof=yes ;;
cache) policy=allkeys-lru aof=no ;;
*) echo "mode must be store or cache" >&2; exit 1 ;;
esac
mem=$(awk '/^MemTotal:/ {print int($2 / 1024)}' /proc/meminfo) # MB
# Turning the append-only file on needs care: Redis would start from an
# empty one. So start it on the running server first, then restart.
if [ $aof = yes ] && redis-admin INFO persistence | grep -q '^aof_enabled:0'; then
redis-admin CONFIG SET appendonly yes >/dev/null
sleep 1
until redis-admin INFO persistence | tr -d '\r' | grep -qx 'aof_rewrite_in_progress:0' &&
redis-admin INFO persistence | tr -d '\r' | grep -qx 'aof_rewrite_scheduled:0'; do sleep 1; done
fi
sudo tee /etc/redis/conf.d/10-exe-dev.conf >/dev/null <<CONF
# Written by ~/redis/tune.sh: $mode mode, $percent% of $mem MB. Rerunning
# it replaces this file; put your own settings in 50-local.conf, and don't
# repeat these there. Who Redis listens to is in 20-listen.conf.
maxmemory $((mem * percent / 100))mb
maxmemory-policy $policy
appendonly $aof
appendfsync everysec
save 3600 1 300 100 60 10000
CONF
sudo systemctl restart redis-server
redis-admin INFO memory | grep -E '^maxmemory_(human|policy):'
redis-admin INFO persistence | grep -E '^aof_enabled:'
EOF
chmod +x ~/redis/tune.sh
~/redis/tune.sh
On an 8 GB VM, expect maxmemory_human:3.87G, maxmemory_policy:noeviction,
and aof_enabled:1.
Redis also asks Linux to always grant memory to the child process that saves to disk, rather than fail the save when memory looks tight:
echo 'vm.overcommit_memory = 1' | sudo tee /etc/sysctl.d/60-redis.conf
sudo sysctl -p /etc/sysctl.d/60-redis.conf
Create a user for your app#
This script creates a Redis user that can use every key and data command,
but no admin command (CONFIG, ACL, SHUTDOWN, MONITOR, and the
like), and writes its settings to ~/redis/<name>.env. It never touches an
existing one:
cat > ~/redis/create-user.sh <<'EOF'
#!/usr/bin/env bash
# Usage: create-user.sh NAME
# Create a Redis user called NAME that can use every key but can't change
# the server, and write its settings to ~/redis/NAME.env (mode 600).
set -euo pipefail
name=${1:?usage: create-user.sh NAME}
[[ $name =~ ^[a-z][a-z0-9_-]{0,31}$ ]] || { echo "Use up to 32 lowercase letters, digits, - and _." >&2; exit 1; }
case $name in admin|default) echo "$name is reserved." >&2; exit 1 ;; esac
env="$HOME/redis/$name.env"
if [ -e "$env" ]; then echo "$env already exists; leaving $name alone."; exit 0; fi
pass=$(openssl rand -hex 24)
[ "$(redis-admin ACL SETUSER "$name" reset on ">$pass" '~*' '&*' +@all -@admin)" = OK ] &&
[ "$(redis-admin ACL SAVE)" = OK ] || { echo "Couldn't create $name." >&2; exit 1; }
(umask 077; cat > "$env" <<ENV
REDIS_URL=redis://$name:$pass@127.0.0.1:6379/0
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_USERNAME=$name
REDIS_PASSWORD=$pass
ENV
)
echo "$env"
EOF
chmod +x ~/redis/create-user.sh
~/redis/create-user.sh app
Check that the app's user can write and read, but not change the server:
. ~/redis/app.env
redis-cli -u "$REDIS_URL" SET hello world
redis-cli -u "$REDIS_URL" GET hello
redis-cli -u "$REDIS_URL" CONFIG GET maxmemory
redis-cli -u "$REDIS_URL" DEL hello
stat -c '%a %n' ~/redis/app.env
(redis-cli warns that a password on the command line may be unsafe; on
your own VM it's fine.) Expect OK, world, NOPERM User app has no permissions to run the 'config|get' command, 1, then 600.
Add a web UI#
Redis Insight is Redis's own GUI. It runs
in Docker, on localhost only, already connected as admin. In front of
it, nginx checks exe.dev's login header and lets in only the emails in
allowed-emails. Redis Insight needs about 150 MB of memory of its own.
RI_ACCEPT_TERMS_AND_CONDITIONS accepts Redis Insight's terms for you
(the SSPL,
unless you have an agreement with Redis), skips its welcome dialog, and
leaves its usage analytics off. Leave the line out to read and accept the
terms in the browser instead; your database appears once you have.
sudo systemctl enable --now docker.service
mkdir -p ~/redis/insight/data && cd ~/redis/insight
. ~/redis/admin.env
[ -e insight.env ] || (umask 077; cat > insight.env <<EOF
RI_APP_HOST=127.0.0.1
RI_APP_PORT=5540
RI_ENCRYPTION_KEY=$(openssl rand -hex 32)
RI_REDIS_HOST=127.0.0.1
RI_REDIS_PORT=6379
RI_REDIS_ALIAS=$(hostname)
RI_REDIS_USERNAME=$REDIS_USERNAME
RI_REDIS_PASSWORD=$REDIS_PASSWORD
RI_ACCEPT_TERMS_AND_CONDITIONS=true
EOF
)
cat > compose.yaml <<'EOF'
services:
redisinsight:
image: redis/redisinsight:3.8
restart: unless-stopped
network_mode: host
env_file: insight.env
volumes:
- ./data:/data
EOF
docker compose up -d
Now the allowlist, starting with your exe.dev email:
cd ~/redis/insight
email=$(curl -fsS https://reflection.int.exe.xyz/email | jq -r .email)
[ -s allowed-emails ] || echo "$email 1;" > allowed-emails
cat allowed-emails
It should print your email, then 1;. If it doesn't, put
you@example.com 1; in ~/redis/insight/allowed-emails yourself. Then
nginx, on port 8000. The VM's nginx comes with a placeholder page that
also wants port 8000, so this switches it off. On a VM where an app uses
port 8000, or will, set port=8001 in the first line; Redis Insight is
then at https://<vm>.exe.xyz:8001/.
port=8000
sudo tee /etc/nginx/sites-available/redis-insight >/dev/null <<'EOF'
# Redis Insight behind exe.dev login. exe.dev's proxy sends X-ExeDev-Email
# for signed-in visitors; only emails in allowed-emails get through.
map $http_x_exedev_email $redis_insight_allowed {
default 0;
include /home/exedev/redis/insight/allowed-emails;
}
map $http_upgrade $redis_insight_connection {
default upgrade;
"" close;
}
server {
listen 8000;
listen [::]:8000;
absolute_redirect off;
location / {
default_type text/plain;
if ($http_x_exedev_email = "") {
return 302 /__exe.dev/login?redirect=$request_uri;
}
if ($redis_insight_allowed = 0) {
return 403 "$http_x_exedev_email isn't in ~/redis/insight/allowed-emails on this VM.\n";
}
proxy_pass http://127.0.0.1:5540;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $redis_insight_connection;
proxy_set_header Host $host;
proxy_read_timeout 120s;
}
}
EOF
sudo sed -i "s/8000;/$port;/" /etc/nginx/sites-available/redis-insight
sudo ln -sf ../sites-available/redis-insight /etc/nginx/sites-enabled/redis-insight
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl enable nginx && sudo systemctl restart nginx
Check the three cases: no email (sent to exe.dev login), someone else (refused), and you (Redis Insight):
port=$(grep -o -m1 'listen [0-9]*' /etc/nginx/sites-available/redis-insight | cut -d' ' -f2)
email=$(head -n 1 ~/redis/insight/allowed-emails | cut -d' ' -f1)
timeout 60 sh -c "until curl -sf -o /dev/null http://127.0.0.1:5540/api/health/; do sleep 1; done"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:$port/
curl -s -o /dev/null -w '%{http_code}\n' -H 'X-ExeDev-Email: someone@example.com' http://localhost:$port/
curl -s -H "X-ExeDev-Email: $email" http://localhost:$port/ | grep -o '<title>[^<]*</title>'
curl -s -H "X-ExeDev-Email: $email" http://localhost:$port/api/databases | jq -r '.[].name'
Expect 302, 403, <title>Redis Insight</title>, and the VM's name:
the connection to Redis.
Set up nightly backups#
This script asks Redis for a fresh snapshot of every key with
redis-cli --rdb (Redis writes it from a background process, so clients
aren't held up) and checks it with redis-check-rdb. Beside it, it saves
users.acl, the users with their password hashes, and the .env files.
It keeps the 14 newest backups:
cat > ~/redis/backup.sh <<'EOF'
#!/usr/bin/env bash
# Back up every key (dump.rdb), the users (users.acl), and ~/redis/*.env
# into ~/redis/backups/<UTC time>/. Keeps the 14 newest backups.
set -euo pipefail
umask 077
dest="$HOME/redis/backups"
keep=14
mkdir -p "$dest"
out="$dest/$(date -u +%Y%m%d-%H%M%S)"
while [ -e "$out" ] || [ -e "$out.part" ]; do sleep 1; out="$dest/$(date -u +%Y%m%d-%H%M%S)"; done
trap 'rm -rf "$out.part"' EXIT
mkdir "$out.part"
log=$(redis-admin --rdb "$out.part/dump.rdb" 2>&1) || { echo "$log" >&2; exit 1; }
redis-check-rdb "$out.part/dump.rdb" >/dev/null || { echo "dump.rdb failed its check" >&2; exit 1; }
redis-admin ACL LIST > "$out.part/users.acl"
grep -q '^user admin ' "$out.part/users.acl" || { echo "users.acl looks wrong" >&2; exit 1; }
cp -p "$HOME"/redis/*.env "$out.part/"
mv "$out.part" "$out"
ls -1d "$dest"/2*/ | head -n -"$keep" | xargs -r rm -rf --
echo "$out"
EOF
chmod +x ~/redis/backup.sh
Schedule it, then take a first backup:
sudo tee /etc/systemd/system/redis-backup.service >/dev/null <<EOF
[Unit]
Description=Back up Redis to ~/redis/backups
After=redis-server.service
[Service]
Type=oneshot
User=$USER
ExecStart=$HOME/redis/backup.sh
EOF
sudo tee /etc/systemd/system/redis-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Back up Redis every night
[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now redis-backup.timer
sudo systemctl start redis-backup.service
systemctl list-timers redis-backup.timer
ls -l ~/redis/backups/*/
Expect a next run time, and admin.env, app.env, dump.rdb, and
users.acl. For failures: journalctl -u redis-backup.service.
Test a backup#
This script starts a scratch Redis on port 6380 from a backup, with its data and its users, exactly as a new VM would load it. It's also how you'll fish out keys from an old backup later:
cat > ~/redis/scratch.sh <<'EOF'
#!/usr/bin/env bash
# Usage: scratch.sh BACKUP_DIR start a scratch Redis on 127.0.0.1:6380
# with the data and users in BACKUP_DIR
# scratch.sh stop stop it and delete its files
set -euo pipefail
s=/tmp/redis-scratch
if [ "${1:-}" = stop ]; then
[ -e $s/redis.pid ] && kill "$(cat $s/redis.pid)"
timeout 60 sh -c "while [ -e $s/redis.pid ]; do sleep 1; done"
rm -rf $s
exit
fi
b=$(realpath "${1:?usage: scratch.sh BACKUP_DIR | stop}")
[ ! -e $s ] || { echo "$s exists: is a scratch Redis running? Run: $0 stop" >&2; exit 1; }
mkdir -m 700 $s
cp "$b/dump.rdb" "$b/users.acl" $s/
mods=(); for m in /usr/lib/redis/modules/*.so; do mods+=(--loadmodule "$m"); done
redis-server --port 6380 --bind 127.0.0.1 --dir $s --aclfile $s/users.acl \
--save '' --appendonly no --daemonize yes --pidfile $s/redis.pid --logfile $s/redis.log "${mods[@]}"
timeout 60 sh -c 'until redis-cli -p 6380 PING 2>&1 | grep -q NOAUTH; do sleep 1; done'
echo "Scratch Redis on 127.0.0.1:6380, from $b"
EOF
chmod +x ~/redis/scratch.sh
Put a string with some non-ASCII text and a JSON document in Redis, take a backup, and load it into the scratch Redis:
redis-admin SET restore:check 'Grüße ✓'
redis-admin JSON.SET restore:json '$' '{"ok": true}'
latest=$(~/redis/backup.sh)
~/redis/scratch.sh "$latest"
. "$latest/app.env"
REDISCLI_AUTH=$REDIS_PASSWORD redis-cli -p 6380 --user app GET restore:check
REDISCLI_AUTH=$REDIS_PASSWORD redis-cli -p 6380 --user app JSON.GET restore:json
Expect Grüße ✓ and {"ok":true}: the data came back, and the restored
app user still signs in with the password from the backup. Stop the
scratch Redis and remove the test keys:
~/redis/scratch.sh stop
redis-admin DEL restore:check restore:json
redis-admin INFO keyspace
Expect 2, then an empty # Keyspace. Installation done; continue with
Open Redis Insight.
Back up and restore
On the VM, ~/redis/backup.sh takes an extra backup and prints its
folder. Each folder has dump.rdb (every key), users.acl (the users and
their password hashes), and the .env files.
Undo a mistake#
Deleted or overwrote the wrong keys? Start a scratch Redis from a backup
next to the live one, and copy back just those keys with
copy-keys.py. On the VM,
pick a backup folder from the list:
ls ~/redis/backups/
when=20261031-033012
~/redis/scratch.sh ~/redis/backups/$when
. ~/redis/admin.env
REDISCLI_AUTH=$REDIS_PASSWORD redis-cli -p 6380 --user admin --scan --pattern 'user:42:*'
The last line lists the keys in the backup matching a pattern. To copy them back, overwriting what's there now:
curl -fsSLO https://exe-staging.dev/deploy/redis/copy-keys.py
. ~/redis/admin.env
uv run copy-keys.py "redis://admin:$REDIS_PASSWORD@127.0.0.1:6380/0" \
"redis://admin:$REDIS_PASSWORD@127.0.0.1:6379/0" --match 'user:42:*' --replace
~/redis/scratch.sh stop
Roll everything back#
To put all of Redis back as it was, use a restore script. It saves the
current state as a backup first, then restarts Redis loading the backup's
snapshot in place of the current data (with Redis's preload-file
setting), and brings back the backup's users and their .env files. The
admin user stays as it is. Your apps see a few seconds of errors while
Redis restarts. Create the script once, on the VM:
cat > ~/redis/restore.sh <<'EOF'
#!/usr/bin/env bash
# Usage: restore.sh BACKUP_DIR
# Replace all data in Redis with the backup in BACKUP_DIR, and bring back
# its users and ~/redis/*.env files, except admin. Backs up first.
set -euo pipefail
b=$(realpath "${1:?usage: restore.sh BACKUP_DIR}")
for f in dump.rdb users.acl; do [ -s "$b/$f" ] || { echo "$b/$f is missing" >&2; exit 1; }; done
redis-check-rdb "$b/dump.rdb" >/dev/null || { echo "$b/dump.rdb failed its check" >&2; exit 1; }
echo "Current state saved in $(~/redis/backup.sh)"
acl=$(mktemp)
{ redis-admin ACL LIST | grep -E '^user (default|admin) '; grep -v -E '^user (default|admin) ' "$b/users.acl"; } > "$acl"
sudo install -o redis -g redis -m 640 "$acl" /etc/redis/users.acl && rm "$acl"
sudo install -o redis -g redis -m 640 "$b/dump.rdb" /var/lib/redis/restore.rdb
echo 'preload-file rdb:/var/lib/redis/restore.rdb' | sudo tee /etc/redis/conf.d/90-restore.conf >/dev/null
sudo systemctl restart redis-server || true
sudo rm /etc/redis/conf.d/90-restore.conf
systemctl is-active --quiet redis-server || { echo "Redis didn't start; see /var/log/redis/redis-server.log" >&2; exit 1; }
redis-admin SAVE >/dev/null
sudo rm -f /var/lib/redis/restore.rdb
for f in "$b"/*.env; do [ "$(basename "$f")" = admin.env ] || cp -p "$f" ~/redis/; done
redis-admin INFO keyspace
EOF
chmod +x ~/redis/restore.sh
Then stop your app, restore a backup folder, and start the app again:
~/redis/restore.sh ~/redis/backups/20261031-033012
It prints the number of keys in each database now.
Restore everything on a new VM#
On a new VM, follow Install by hand up to and including
"Set up nightly backups", then create restore.sh as above. Copy a backup
folder over. On your computer:
scp -r redis-backups/20261031-033012 my-redis.exe.xyz:restore
On the VM:
~/redis/restore.sh ~/restore
redis-admin ACL USERS
Every user should be listed. Apps keep their old passwords, so their
.env files work unchanged. rm -r ~/restore when you're done. If the old
VM had Docker or Tailscale access, redo its 20-listen.conf too.
More often#
Nightly backups can lose up to a day of changes if the VM is lost. To back up hourly and keep two days, on the VM:
sudo sed -i 's/^OnCalendar=.*/OnCalendar=hourly/; s/^RandomizedDelaySec=.*/RandomizedDelaySec=5m/' /etc/systemd/system/redis-backup.timer
sed -i 's/^keep=.*/keep=48/' ~/redis/backup.sh
sudo systemctl daemon-reload && sudo systemctl restart redis-backup.timer
systemctl list-timers redis-backup.timer
Each backup is a full snapshot, the size of dump.rdb, so this suits
datasets up to a few GB. exe.dev already
replicates the VM's disk to other machines, which
guards against hardware failure, not against a bad FLUSHALL.
A second Redis#
Want a cache and a store side by side, each with its own memory limit
and policy? The package includes redis-server@.service for more Redis
servers on one VM. Shelley can set one up for you on port 6381, with its
own ACL file, settings, and backups.
Update Redis
Updates don't install themselves on this VM, so put them on your calendar. Redis publishes a new 8.x release every few months, and each one gets fixes, including security fixes, until six months after the next. Redis saves breaking changes for major versions, so moving up within Redis 8 is safe; the release notes list what's new.
Updates within Redis 8#
On the VM, back up, then upgrade just the Redis packages. The upgrade restarts Redis, so apps see a few seconds of errors while it reloads its data:
~/redis/backup.sh
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install --only-upgrade -y -o Dpkg::Options::=--force-confold redis-server redis-tools
redis-admin INFO server | grep -E '^redis_version:'
To update Redis Insight, change the image's tag in
~/redis/insight/compose.yaml to the newest from
its releases, then, in
~/redis/insight, run docker compose pull && docker compose up -d.
A new major version#
When Redis 9 comes out, read its release notes for anything your app relies on that changed. Then, on the VM, point the pin at it and upgrade, with your app stopped:
~/redis/backup.sh
sudo sed -i 's/^Pin: version .*/Pin: version 6:9.*/' /etc/apt/preferences.d/redis
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::=--force-confold redis-server redis-tools
redis-admin INFO server | grep -E '^redis_version:'
A newer Redis reads the older one's files, but not the other way round, so your backup is the way back.
Something not working?
On the VM, Redis's log usually has the answer:
systemctl status redis-server
tail -n 50 /var/log/redis/redis-server.log
-
WRONGPASS invalid username-password pair or user is disabled: the client sent no username, so it tried thedefaultuser, which is off; or the password doesn't match the.envfile. Put the username in the URL (redis://app:...@). For a client that can only send a password, givedefaultthe app's permissions and password:. ~/redis/app.env redis-admin ACL SETUSER default reset on ">$REDIS_PASSWORD" '~*' '&*' +@all -@admin redis-admin ACL SAVE -
NOAUTH Authentication required: the client connected without signing in. Use the URL from the.envfile, orredis-admin. -
NOPERM User app has no permissions to run the 'config|set' command: the app tried an admin command. Some libraries set server options on start (such asnotify-keyspace-events); set them yourself in/etc/redis/conf.d/50-local.conf, restart Redis, and turn the library's option off. -
OOM command not allowed when used memory > 'maxmemory': store mode is full. See what takes the space withredis-admin --bigkeysandredis-admin INFO memory, delete what you don't need, raisepercentin~/redis/tune.sh(up to 60), or resize the VM and run the script again. If every key can be rebuilt, switch to cache mode. -
MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk: Redis stopped taking writes because a save failed. The log says why; usually the disk is full (df -h /) orvm.overcommit_memoryisn't1(sysctl vm.overcommit_memory). -
Redis won't start: the log names the bad line. A typo in
/etc/redis/conf.d/or/etc/redis/users.aclstops it. After shrinking the VM, run~/redis/tune.sh. -
Connection refusedfrom a Docker container or another VM: Redis listens on localhost only. See From a Docker container or Connect from another VM. -
The tunnel says
Address already in use: something on your computer has port 6380. Pick another, such as-L 6381:localhost:6379. -
SSH refuses the tunnel: try the form that always works,
ssh -N -L 6380:localhost:6379 vm+my-redis@vm.exe.xyz. -
copy-keys.pysaysDUMP payload version or checksum are wrong: the old server writes a newer format than this Redis reads. Copy into a Redis or Valkey of the same version first, or ask Shelley to copy the keys with plain reads and writes. -
Redis Insight says your email isn't allowed: add it to
~/redis/insight/allowed-emailsand runsudo systemctl reload nginx. Not loading at all? Checkdocker compose logsin~/redis/insight, andsudo nginx -t. -
Slow commands: Redis logs every command over 10 ms. See the latest with
redis-admin SLOWLOG GET 10.KEYS *on a big database is the usual culprit; useSCANinstead.
Frequently asked questions#
Is Redis open source?
Since Redis 8, yes again: you choose between the AGPLv3, an OSI-approved open source license, and Redis's own RSALv2 or SSPLv1. Running Redis unmodified for your own apps is fine under any of them; the AGPL asks you to share your changes if you modify Redis itself and offer it over a network. Redis 7.2 and older are BSD-licensed, and 7.4 is RSALv2 or SSPLv1 only. Redis Insight is under the SSPL.
Should I use Valkey instead?
Valkey is a fork of Redis 7.2, started at the Linux Foundation in 2024 when Redis left the BSD license, and still BSD-licensed. Pick it if you need a permissive license; apps and clients that speak Redis 7.2 work with both. Most of this page applies to it, but the packages, service names, and newer features differ. Redis 8 has search, JSON, time series, and vector sets built in.
Cache or data store, which do I pick?
This page sets Redis up as a data store: it keeps every key and logs every write, so job queues, sessions, and rate limits survive a restart. If it only holds things you can rebuild, switch it to cache mode, which evicts old keys when memory runs out instead of refusing writes.
How much can I lose if the VM restarts?
About a second of writes. Redis logs each write to its append-only file and flushes it to disk every second. Turn that up to every write with appendfsync always, at a cost in speed.
Can I connect to port 6379 from the internet?
Not directly. exe.dev only lets HTTPS and SSH in, so you connect through an SSH tunnel, or from other machines on your Tailscale network. Redis never faces the open internet.
Why not Ubuntu's own `redis-server` package?
Ubuntu 24.04 ships Redis 7.0, a release Redis stopped supporting in 2024. Redis's own APT repository has the current release, with the search, JSON, and time series features included.
Is Redis free?
Yes, and so is Redis Insight. You pay for the VM; see exe.dev plans.
Updated Oct 31, 2026 · tested end to end Oct 31, 2026 · Markdown · manifest.json · Something wrong? Tell us