---
title: "Self-host Redis"
description: "Run Redis 8 on an exe.dev VM: password-protected users, persistence for a cache or a data store, nightly tested backups, and Redis Insight."
url: https://exe.dev/deploy/redis
category: "Databases"
updated: 2026-10-31
---

# Self-host Redis

Your own Redis on a real disk, private by default, with backups you've restored.

- **Software:** Redis (https://redis.io)
- **Source:** https://github.com/redis/redis
- **License:** AGPL-3.0, RSALv2 or SSPLv1 (your choice)
- **Time:** 10 minutes
- **Example VM name:** `my-redis` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe.dev/deploy/redis/manifest.json
- **File:** https://exe.dev/deploy/redis/copy-keys.py

## 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 `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.

Running on the VM:

- **Redis 8**: From Redis's APT repository, with search, JSON, and time series built in, port 6379, data in `/var/lib/redis`
- **Redis Insight 3**: Web UI in Docker, behind exe.dev login and an email allowlist, port 8000, data in `~/redis/insight`
- **Nightly backups**: A snapshot of every key, plus the users, 14 nights kept, data in `~/redis/backups`

How it is reached:

- https://my-redis.exe.xyz/ (private): Redis Insight, connected as the Redis admin user

## Deploy with Shelley

Open https://exe.dev/new?prompt=Set%20up%20Redis%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe.dev%2Fdeploy%2Fredis.md%20from%20top%20to%20bottom.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-redis.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-redis.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20Redis%208%2C%20started%20at%20boot%2C%20listening%20on%20localhost%20only%2C%20with%20a%20memory%20limit%20sized%20to%20the%20VM%20and%20every%20write%20logged%20to%20disk.%0A-%20No%20anonymous%20access%2C%20an%20admin%20user%20for%20you%2C%20and%20an%20%60app%60%20user%20whose%20settings%20are%20in%20%60~%2Fredis%2Fapp.env%60.%0A-%20Redis%20Insight%20at%20the%20VM%27s%20private%20URL%2C%20open%20only%20to%20the%20owner%27s%20exe.dev%20email.%0A-%20Nightly%20snapshot%20backups%2C%20one%20restored%20into%20a%20scratch%20Redis%20and%20checked.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details.%20For%20initial%20setup%2C%0Afollow%20%22Install%20by%20hand%22%20from%20top%20to%20bottom%3A%20the%20Redis%20repository%20and%0Aserver%2C%20the%20users%20and%20lock-down%2C%20tuning%20%28leave%20mode%3Dstore%29%2C%20the%20app%0Auser%2C%20Redis%20Insight%20on%20port%208000%2C%20the%20backup%20timer%2C%20and%20the%20scratch%0Arestore%20test.%20The%20walkthrough%20sections%20are%20for%20the%20owner%20afterwards%3A%0Adon%27t%20import%20data%2C%20switch%20to%20cache%20mode%2C%20set%20up%20Tailscale%20or%20Docker%0Aaccess%2C%20or%20run%20the%20Restore%20or%20Update%20instructions%20during%20initial%20setup.%0ANever%20overwrite%20an%20existing%20~%2Fredis%2F%2A.env%20file%20or%20%2Fetc%2Fredis%2Fusers.acl.%0AAllow%20only%20the%20owner%27s%20email%20in%20Redis%20Insight%2C%20read%20from%20the%20reflection%0Aintegration%3B%20if%20that%20fails%2C%20ask%20the%20owner%20for%20it.%20Check%20Redis%20Insight%20on%0Alocalhost%20with%20no%20email%2C%20a%20wrong%20email%2C%20and%20the%20owner%27s%20email.%0ALeave%20the%20VM%20private%20and%20do%20not%20reboot.%20Don%27t%20paste%20passwords%20into%20the%0Achat%3B%20say%20they%20are%20in%20~%2Fredis%2Fapp.env.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open%20and%20these%20commands%2C%20which%20only%20I%20can%20run%2C%20from%20my%20own%20computer%20%28with%20this%20VM%27s%20name%20in%20them%29%3A%0A%20%20ssh%20my-redis.exe.xyz%20cat%20redis%2Fapp.env%0A%20%20ssh%20-N%20-L%206380%3Alocalhost%3A6379%20my-redis.exe.xyz%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe.dev/deploy/redis/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Then, on your own computer:

```sh
ssh my-redis.exe.xyz cat redis/app.env
ssh -N -L 6380:localhost:6379 my-redis.exe.xyz
```

Or follow the steps below by hand.

Redis 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.

## 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](https://redis.io/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:

```bash
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`:

```bash
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:

```text
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:

```bash
~/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](/deploy)? Run
[Install by hand](#create-the-vm) 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):

```bash
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:

```bash
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:

```bash
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-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:

```bash
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:

```bash
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](/docs/integrations-vm-to-vm) carries HTTPS only.
The dependable way to reach Redis from another VM is to put both VMs on
your [Tailscale](https://tailscale.com/) 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](/docs/integrations-tailscale-wif) instead.

```bash
sudo systemctl enable --now tailscaled
sudo tailscale up --hostname=$(hostname)
```

Then, on the **Redis** VM, let Redis listen beyond localhost:

```bash
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](https://tailscale.com/kb/1018/acls) 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`](https://exe.dev/deploy/redis/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:

```bash
curl -fsSLO https://exe.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:

```bash
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](#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.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Install by hand</summary>

### Create a VM

On your computer (pick another name if `my-redis` is taken):

```bash
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:

```bash
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`:

```bash
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.

```bash
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.

```bash
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).

```bash
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](#cache-or-data-store). Run it again after resizing
the VM or changing a setting at its top.

```bash
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:

```bash
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:

```bash
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:

```bash
. ~/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](https://redis.io/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](https://github.com/redis/RedisInsight/blob/main/LICENSE),
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.

```bash
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:

```bash
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/`.

```bash
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):

```bash
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:

```bash
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:

```bash
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](#undo-a-mistake) from an old backup later:

```bash
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:

```bash
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:

```bash
~/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](#open-redis-insight).

</details>

<details id="back-up-and-restore" class="dp-details">
<summary>Back up and restore</summary>

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`](https://exe.dev/deploy/redis/copy-keys.py). On the VM,
pick a backup folder from the list:

```bash
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:

```bash
curl -fsSLO https://exe.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:

```bash
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:

```bash
~/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](#create-the-vm) up to and including
"Set up nightly backups", then create `restore.sh` as above. Copy a backup
folder over. On your computer:

```bash
scp -r redis-backups/20261031-033012 my-redis.exe.xyz:restore
```

On the VM:

```bash
~/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:

```bash
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](/docs/what-is-exe) 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.

</details>

<details id="update" class="dp-details">
<summary>Update Redis</summary>

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](https://github.com/redis/redis/releases) 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:

```bash
~/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](https://github.com/redis/RedisInsight/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:

```bash
~/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.

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

On the VM, Redis's log usually has the answer:

```bash
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 the `default` user, which is off;
  or the password doesn't match the `.env` file. Put the username in the
  URL (`redis://app:...@`). For a client that can only send a password,
  give `default` the app's permissions and password:

  ```bash
  . ~/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 `.env` file, or `redis-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 as `notify-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 with
  `redis-admin --bigkeys` and `redis-admin INFO memory`, delete what you
  don't need, raise `percent` in `~/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](#cache-or-data-store).
- **`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 /`) or
  `vm.overcommit_memory` isn't `1` (`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.acl` stops it. After
  shrinking the VM, run `~/redis/tune.sh`.
- **`Connection refused` from a Docker container or another VM:** Redis
  listens on localhost only. See
  [From a Docker container](#from-a-docker-container) or
  [Connect from another VM](#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.py` says `DUMP 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-emails` and run `sudo systemctl reload nginx`.
  Not loading at all? Check `docker compose logs` in `~/redis/insight`,
  and `sudo 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; use `SCAN` instead.

</details>

## 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](#cache-or-data-store), 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](#connect-from-your-computer), or from other machines on your [Tailscale network](#connect-from-another-vm). 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](https://exe.dev/pricing).


## Related

- [Self-host PostgreSQL](https://exe.dev/deploy/postgres.md): Your own Postgres on a real disk, with backups you've actually restored.
- [Self-host MySQL](https://exe.dev/deploy/mysql.md): Your own MySQL on a real disk, private by default, with backups you've restored.
- [Deploy a Rails app](https://exe.dev/deploy/rails.md): Your Rails app on a server of its own. git push, and it's live.
- [Deploy a Laravel app](https://exe.dev/deploy/laravel.md): Your Laravel app on a server of its own. git push to deploy.
- [Deploy a Django app](https://exe.dev/deploy/django.md): Your Django app on a computer of its own. git push to deploy.
- [Self-host MongoDB](https://exe.dev/deploy/mongodb.md): Your own MongoDB on a real disk, locked down, with backups you've restored.
