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 theuserline in the Redis config<generated password>: The password generated belownon_root: Your non-root usernamemy-project: Your cloned project directory
Install Redis
sudo apt update
sudo apt install -y redis-serverRedis 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):
openssl rand -hex 32Edit 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
# 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 +infoWARNING
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:
sudo chown redis:redis /etc/redis/redis.conf
sudo chmod 640 /etc/redis/redis.confStart Redis
sudo systemctl enable redis-server # Start automatically after reboot
sudo systemctl restart redis-serverIMPORTANT
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.
# 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:
uv add django-redis django-rqOn the server, add the Redis credentials to the project's .env file (~/my-project/.env):
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:
| Database | Used for | Safe to clear? |
|---|---|---|
/1 | default cache: disposable data | Yes |
/2 | django-rq queues (Optional - if you don't need Redis queues) | No |
/3 | auth cache: OTPs, reset tokens, rate limits | No |
/4 | Channels layer (WebSockets), if used | No |
/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:
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:
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
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:
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:
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:
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, notuser. The arguments are serialized into Redis, so aUserobject 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 withUser.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:
cd ~/my-project
git pull
uv sync --locked --no-devRun a worker to process the jobs. --with-scheduler is needed for the retry intervals. For a quick test, run it in the foreground:
# `emails` and `default` are the queues from RQ_QUEUES, in priority order: emails first
uv run --no-dev manage.py rqworker emails default --with-schedulerIn production, run it as a service, as the same user (non_root) and from the same directory as Django:
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 rqworkerRestart the worker after every deploy, along with Django, so it runs the new code:
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 queueYour Redis instance is now running alongside Django, reachable only from this server.
