Skip to content

Redis on Debian 13 (Linode) ​

This guide explains how to install Redis on the same Nanode 1 GB Linode as your Django application, with Redis listening on localhost only. Django uses Redis for its cache as well as for data that must not be lost, such as auth keys and queued jobs (e.g. account emails), so Redis is configured to persist to disk and never evict keys.

NOTE

This setup assumes you have already completed the Django guide, with either Nginx & Gunicorn or Apache. The Linode, non-root user, SSH hardening, firewall, and Django project are all in place. Only the Redis-specific steps appear here.

NOTE

Replace the following placeholders with your own values:

  • your-redis-username: A name you choose for the Redis ACL user, created by the user line in the Redis config
  • <generated password>: The password generated below
  • non_root: Your non-root username
  • my-project: Your cloned project directory

Install Redis ​

shell
sudo apt update
sudo apt install -y redis-server

Redis listens on localhost only, so the Linode's firewall needs no new rule.

Configure Redis ​

Generate a password (hex, so it's safe to use in a connection URL):

shell
openssl rand -hex 32

Edit the config (sudo vi /etc/redis/redis.conf) and set the following. Redis doesn't allow comments after a value, so keep every comment on its own line:

TIP

There are many comment lines (starts with #) and blank lines in this file, so to just see what all are set, use this command: sudo grep -vE '^\s*(#|$)' /etc/redis/redis.conf

txt
# Listen on localhost only
bind 127.0.0.1 -::1
protected-mode yes

# Sized for a Nanode 1 GB, which also runs Django and the web server
maxmemory 128mb
# Never delete keys to free memory: auth keys and queued jobs must survive.
# When full, writes fail with an error instead.
maxmemory-policy noeviction

# Persist every write to disk; at most ~1s of writes is lost on a crash
appendonly yes
appendfsync everysec
# No periodic snapshots; the append-only file is enough
save ""

# Disable the default (passwordless) user
user default off

# Application user: full access to keys, no admin or dangerous commands, except
# FLUSHDB (Django's `cache.clear()` needs it) and INFO (to check memory usage)
user your-redis-username on ><generated password> ~* &* +@all -@admin -@dangerous +flushdb +info

WARNING

With noeviction, Redis refuses writes once it reaches maxmemory, and the application gets errors instead. Give every cache entry a timeout (Django's default is 300 seconds) so the cache can't fill Redis, and raise maxmemory if you move to a larger plan.

The password is stored in cleartext in this file, so lock it down:

shell
sudo chown redis:redis /etc/redis/redis.conf
sudo chmod 640 /etc/redis/redis.conf

Start Redis ​

shell
sudo systemctl enable redis-server # Start automatically after reboot
sudo systemctl restart redis-server

IMPORTANT

Always manage Redis through systemctl. Running redis-server by hand skips /etc/redis/redis.conf, so that instance has no password, no memory limit and no persistence.

Verify ​

Enter Redis Password when prompted.

shell
# PONG - the ACL user only exists in redis.conf, so this also confirms the config was loaded
redis-cli --user your-redis-username --askpass ping

# NOPERM - the application user can't run admin commands
redis-cli --user your-redis-username --askpass config get maxmemory

# Only 127.0.0.1 / [::1], never 0.0.0.0
sudo ss -ltnp | grep 6379

# Persistence: the key must survive a restart
redis-cli --user your-redis-username --askpass set setup-check ok
sudo systemctl restart redis-server
redis-cli --user your-redis-username --askpass getdel setup-check   # "ok"

# Memory usage, well below maxmemory
redis-cli --user your-redis-username --askpass info memory | grep -E 'used_memory_human|maxmemory_human'

Connect Django ​

On your local machine, add the packages as regular (not development) dependencies, so they're installed on the server with uv sync --no-dev:

shell
uv add django-redis django-rq

On the server, add the Redis credentials to the project's .env file (~/my-project/.env):

txt
REDIS_USERNAME=your-redis-username
REDIS_PASSWORD=<generated password>

Each kind of data gets its own Redis database. cache.clear() runs FLUSHDB, which empties only the database of the cache it's called on, so clearing the disposable cache can't wipe auth keys or queued jobs:

DatabaseUsed forSafe to clear?
/1default cache: disposable dataYes
/2django-rq queues (Optional - if you don't need Redis queues)No
/3auth cache: OTPs, reset tokens, rate limitsNo
/4Channels layer (WebSockets), if usedNo

/4 is used only if you add Django Channels. Its channel layer is configured below; the Django guide's WebSockets section covers the rest (ASGI routing and the Uvicorn worker).

TIP

Redis queues are useful for things like account-related emails, which may slow down API response times. With a queue in place, the email job is enqueued in Redis, the API returns immediately, and a worker process sends the email moments later. Because the job is persisted before the response is sent, it survives restarts and can be retried if the mail server is down.

Settings ​

Update settings.py:

python
import os
from urllib.parse import quote

INSTALLED_APPS = [
    # ...
    "django_rq",
]

REDIS_URL = "redis://{}:{}@127.0.0.1:6379".format(
    quote(os.getenv("REDIS_USERNAME", ""), safe=""),
    quote(os.getenv("REDIS_PASSWORD", ""), safe=""),
)

# /2 is not a cache: it's reserved for the django-rq queues. Skip it if not needed.
RQ_QUEUES = {
    "default": {"URL": f"{REDIS_URL}/2", "DEFAULT_TIMEOUT": 360},
    "emails": {"URL": f"{REDIS_URL}/2", "DEFAULT_TIMEOUT": 60},
}

# Only if you use Django Channels (see the Django guide's WebSockets
# section for the rest of the setup)
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {"hosts": [f"{REDIS_URL}/4"]},
    },
}

CACHES = {
    # Disposable data, safe to clear at any time
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": f"{REDIS_URL}/1",
        # Every entry expires, so the cache can't fill Redis under noeviction
        "TIMEOUT": 300,
    },
    # Auth keys that must not be cleared along with the cache
    "auth": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": f"{REDIS_URL}/3",
        "TIMEOUT": 600,
    },
}

WARNING

Never pass timeout=None (no expiry) to a cache call unless something else deletes the key, and never call caches["auth"].clear(). Under noeviction, keys that never expire stay in Redis until it's full.

Check the Connection ​

Restart Django (sudo systemctl restart gunicorn, or touch wsgi.py with Apache), then confirm both cache aliases, and the queue if you set one up, from uv run --no-dev manage.py shell:

python
from uuid import uuid4
from django.core.cache import caches

# Check Redis authentication and each cache alias.
# The keys expire in 60s, so a failure here leaves nothing behind.
for alias in ("default", "auth"):
    key = f"setup-check:{uuid4().hex}"
    caches[alias].set(key, "working", timeout=60)
    assert caches[alias].get(key) == "working", f"{alias} cache read/write failed"
    caches[alias].delete(key)

print("Redis cache: OK")

# Confirm the queue's connection. Skip the rest if you're not using django-rq.
from django_rq import get_queue

assert get_queue("emails").connection.ping() is True
print("Redis queue: OK")

# Round-trip a message through the channel layer (/4). Skip if you're not using Channels.
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer

layer = get_channel_layer()
async_to_sync(layer.send)("setup-check", {"type": "ping"})
assert async_to_sync(layer.receive)("setup-check")["type"] == "ping"
print("Redis channel layer: OK")

Manage the Service ​

shell
sudo systemctl status redis-server
sudo systemctl restart redis-server
sudo journalctl -u redis-server -n 50    # recent logs (also /var/log/redis/redis-server.log)
redis-cli --user your-redis-username --askpass info memory | grep used_memory_human # Memory usage, enter Redis password when prompted

(Reference) Usage ​

With this setup in place, you access default database of Redis using Django by default if you don't specify anything explicitly. cache is the default alias, and caches["<alias>"] picks any other. A timeout passed to a call overrides the alias's TIMEOUT:

python
from django.core.cache import cache, caches
from django.views.decorators.cache import cache_page

# Default cache
cache.set("homepage:stats", stats)                 # expires after 300s (TIMEOUT)
cache.set("exchange-rates", rates, timeout=3600)   # expires after 1 hour

# Auth cache: a one-time code, deleted once used
auth_cache = caches["auth"]
auth_cache.set(f"otp:{user.pk}", code, timeout=300)
if auth_cache.get(f"otp:{user.pk}") == submitted_code:
    auth_cache.delete(f"otp:{user.pk}")

# Auth cache: count login attempts per IP over 15 minutes
key = f"login-attempts:{ip}"
auth_cache.add(key, 0, timeout=900)   # creates the key only if it doesn't exist
attempts = auth_cache.incr(key)

# Cache a whole view in a specific alias
@cache_page(60, cache="default")
def pricing(request): ...

# Empties /1 only; auth keys and queued jobs are untouched
cache.clear()

TIP

If sessions are stored in the cache (SESSION_ENGINE = "django.contrib.sessions.backends.cache"), switch to "django.contrib.sessions.backends.cached_db". Sessions are then read from Redis but saved in the database, so users stay logged in even if Redis data is lost.

django-rq usage ​

Define a job, for example in accounts/tasks.py:

python
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django_rq import job
from rq import Retry

@job(
    # Queue this job goes to, one of the names in RQ_QUEUES
    "emails",
    # Retry 3 times, 10s / 1min / 5min apart (e.g. if the SMTP server is briefly down)
    retry=Retry(max=3, interval=[10, 60, 300]),
    # Don't keep finished jobs, and keep failed ones for 7 days (RQ's default is a year)
    result_ttl=0,
    failure_ttl=7 * 24 * 3600,
)
def send_welcome_email(user_id):
    user = get_user_model().objects.get(pk=user_id)
    send_mail("Welcome", "Thanks for signing up.", None, [user.email])

Enqueue it from a view (or a signal) with .delay(), which returns immediately and leaves the work to the worker:

python
from django.db import transaction

from accounts.tasks import send_welcome_email

def signup(request):
    ...
    with transaction.atomic():
        user = form.save()
        # Enqueued only after this block commits successfully
        transaction.on_commit(lambda: send_welcome_email.delay(user.pk))
    return redirect("home")

Two details in that call are worth explaining:

  • Pass user.pk, not user. The arguments are serialized into Redis, so a User object would be sent as a stale copy of the row as it looked at enqueue time. The job re-fetches the row instead, and gets the current data.
  • Wrap it in transaction.on_commit(). A worker is a separate process with its own database connection, so it can pick the job up in the milliseconds before the view's transaction commits. It then queries for a row that isn't visible yet and fails with User.DoesNotExist. on_commit() holds the callback until the transaction commits, and drops it entirely if the transaction rolls back — so no email goes out for a signup that failed.

WARNING

Don't forget the .delay(). The @job decorator doesn't replace the function, it only attaches .delay() to it, so send_welcome_email(user.pk) is an ordinary call, and Redis is never touched, that raises nothing and runs the job inline in the request thread.

Commit and push these changes, then pull them on the server and install the new packages:

shell
cd ~/my-project
git pull
uv sync --locked --no-dev

Run a worker to process the jobs. --with-scheduler is needed for the retry intervals. For a quick test, run it in the foreground:

shell
# `emails` and `default` are the queues from RQ_QUEUES, in priority order: emails first
uv run --no-dev manage.py rqworker emails default --with-scheduler

In production, run it as a service, as the same user (non_root) and from the same directory as Django:

shell
sudo tee /etc/systemd/system/rqworker.service > /dev/null <<'EOF'
[Unit]
Description=RQ worker for Django
After=network.target redis-server.service
Wants=redis-server.service

[Service]
User=non_root
Group=non_root
WorkingDirectory=/home/non_root/my-project
ExecStart=/home/non_root/my-project/.venv/bin/python manage.py rqworker emails default --with-scheduler
# On stop, systemd sends SIGTERM, which lets the current job finish first
TimeoutStopSec=90
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now rqworker

Restart the worker after every deploy, along with Django, so it runs the new code:

shell
sudo systemctl restart rqworker
sudo journalctl -u rqworker -n 50            # worker logs
uv run --no-dev manage.py rqstats            # jobs queued, running and failed per queue

Your Redis instance is now running alongside Django, reachable only from this server.