RE:NODE

Databases13 min read

Sessions in Valkey: Express, Django, Laravel, PHP and .NET

Move web sessions into Valkey: working config for Express, Django, Laravel, plain PHP and ASP.NET Core, plus TTLs, locking, eviction and what breaks.

0 readers

A session store has one job: let any process that serves a user find that user's session, quickly, and forget it when it expires. The moment you run a second application process, a second container or a deploy that replaces the first, in-process sessions stop doing that job - a user logs in on one worker and is anonymous on the next. Valkey fixes it with almost no code: every framework covered here has a session backend that speaks the Redis protocol, and Valkey speaks it unchanged.

The configuration is the short part. What decides whether it works in production is four settings people skip: a key prefix so two apps do not share sessions, a TTL that matches the cookie, an eviction policy that will not throw logged-in users away, and persistence so a restart does not log everybody out. This post gives the working setup for Express, Django, Laravel, plain PHP and ASP.NET Core, and then those four settings.

Why sessions belong in a shared store#

There are three places a session can live, and each is right somewhere.

WhereWorks across processesSurvives a restartGood for
In-process memoryNoNoOne process, development
Signed or encrypted cookieYesYesSmall sessions, a user id and a flag or two
Database tableYesYesLow traffic, sessions you must audit
ValkeyYesYes, with persistenceMost web apps with more than one process

The cookie option is underrated. If the whole session is a user id and a CSRF token, a signed cookie needs no server state at all. It stops being a good idea when the session grows - cookies are capped around 4 KB, travel on every request including requests for images, and cannot be revoked from the server side. "Log out every device" is impossible with pure cookie sessions, because there is nothing on the server to delete.

A database table works, and at a few hundred active users it is fine. The cost is a write on every request that touches the session, and a cleanup job for expired rows. Valkey does the same thing with the expiry built in: each session is one key with a TTL, and the server deletes it when the TTL runs out. No garbage collection job, no table growing in the background.

What you trade for that is a dependency. If Valkey is unreachable, nobody can log in. That is the right trade for most applications, because Valkey is about as simple a service as exists, but it is worth knowing - and it is the reason the connection timeouts later in this post matter.

The connection details every framework needs#

Every example below needs the same four values: host, port, password and, optionally, a database number. Most libraries accept them as a single URL:

code
redis://:a-long-generated-password@203.0.113.20:6380/0redis://default:a-long-generated-password@203.0.113.20:6380/0

The scheme stays redis:// - client libraries do not know or care that the server is Valkey. The empty username before the colon means "the default user", which is how a server protected by requirepass alone expects to be authenticated. Writing default explicitly does the same thing with clients that support ACL usernames. The trailing number selects a logical database; 0 is the default.

Put that URL in an environment variable, never in the repository. Environment variables and secrets covers where it should live on each kind of host. Before you wire a framework to it, prove the credentials work from the machine that will use them:

bash
$ valkey-cli -h 203.0.113.20 -p 6380 --askpassPlease input password: ****203.0.113.20:6380> PINGPONG

redis-cli works identically if that is what your system has. If PING fails here, no framework configuration will fix it - getting started with Valkey goes through the connection errors and what each one means.

On RE:NODE a Valkey plan comes with the host, port and a password generated for that server, and it saves to disk with both AOF and snapshots, so a restart does not empty it. There is no proxy slot on database plans: your application connects straight to the host and port, which is exactly what a session library wants.

Express and Node.js#

express-session handles the cookie and the session object; connect-redis is the store. Current versions of connect-redis export a named RedisStore class and expect a connected client from the redis package (node-redis).

bash
$ npm install express-session connect-redis redis
session.js
import session from "express-session";import { RedisStore } from "connect-redis";import { createClient } from "redis";const client = createClient({ url: process.env.VALKEY_URL });client.on("error", (err) => console.error("valkey", err));await client.connect();export const sessions = session({  store: new RedisStore({ client, prefix: "shop:sess:" }),  secret: process.env.SESSION_SECRET,  name: "sid",  resave: false,  saveUninitialized: false,  cookie: { httpOnly: true, secure: true, sameSite: "lax", maxAge: 1000 * 60 * 60 * 24 * 7 },});

Each line is doing something specific:

  • `prefix` namespaces the keys. Without it every app pointed at the same Valkey uses sess: and they read each other's sessions. Give every application its own.
  • `resave: false` stops a write on every request when nothing changed. The store's touch method refreshes the TTL instead.
  • `saveUninitialized: false` means a visitor who never logs in creates no key. Leave it true and every crawler, bot and health check creates a session, which is how a session store reaches a million keys on a site with forty users.
  • `maxAge` sets the cookie lifetime, and connect-redis uses it as the key TTL, so the two cannot drift apart.
  • `secure: true` only sends the cookie over HTTPS. Behind a reverse proxy that terminates TLS, Express sees plain HTTP and refuses to set a secure cookie unless you add app.set("trust proxy", 1). That single missing line is the most common "sessions do not stick in production" bug. Deploying an Express API covers the rest of the proxy setup.

If you are on an older codebase you will see const RedisStore = require("connect-redis")(session). That factory form belongs to old major versions; match the import style to the version in your package.json.

Django#

Django 4.0 and later ship a Redis cache backend, so no third-party package is needed beyond the redis client library. Sessions then use the cache.

bash
$ pip install "redis>=5" hiredis
settings.py
import osCACHES = {    "default": {        "BACKEND": "django.core.cache.backends.redis.RedisCache",        "LOCATION": os.environ["VALKEY_URL"],        "KEY_PREFIX": "shop",        "TIMEOUT": 300,    }}SESSION_ENGINE = "django.contrib.sessions.backends.cache"SESSION_CACHE_ALIAS = "default"SESSION_COOKIE_AGE = 60 * 60 * 24 * 14SESSION_COOKIE_SECURE = TrueCSRF_COOKIE_SECURE = True

Django offers two cache-based engines, and the choice matters:

  • `backends.cache` stores the session only in the cache. Fastest, and the session is gone if the key is evicted or the server loses it.
  • `backends.cached_db` writes through to the database and reads from the cache. Every session write costs a database write, but a lost cache key is rebuilt from the table instead of logging the user out.

With a persistent Valkey and a sensible eviction policy, backends.cache is the usual choice. If your sessions carry something expensive to lose - a half-completed checkout, say - cached_db is the cautious one. The session TTL comes from SESSION_COOKIE_AGE, not from the cache TIMEOUT, so a short cache timeout does not cut sessions short.

Behind a proxy, set SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") only if the proxy really sets that header; deploying Django to production has the full list.

Laravel#

Laravel talks to Valkey through its redis driver, using the phpredis extension by default or the pure-PHP Predis package if the extension is missing. Everything is in .env:

.env
SESSION_DRIVER=redisSESSION_CONNECTION=defaultSESSION_LIFETIME=120REDIS_CLIENT=phpredisREDIS_HOST=203.0.113.20REDIS_PORT=6380REDIS_PASSWORD=a-long-generated-passwordREDIS_DB=0REDIS_CACHE_DB=1

Check php -m | grep redis first. If the extension is not loaded, either install it or run composer require predis/predis and set REDIS_CLIENT=predis. Predis is slower but needs nothing compiled.

SESSION_LIFETIME is in minutes. Laravel prefixes every key using REDIS_PREFIX, which defaults to a slug of APP_NAME - so two Laravel apps with the same APP_NAME on one Valkey will share session keys. Set REDIS_PREFIX explicitly, or give each app a different APP_NAME.

The REDIS_DB and REDIS_CACHE_DB split is the important line. Laravel puts cache and sessions on different logical databases so that php artisan cache:clear, which flushes the cache database, does not log everyone out. If you collapse them into one number, a routine cache clear becomes a mass logout. After changing .env, run php artisan config:clear, or php artisan config:cache if you cache config in production - deploying Laravel to production explains why that step catches people.

Plain PHP with phpredis#

Without a framework, PHP's own session handling can be pointed at Valkey with two php.ini lines, provided the phpredis extension is installed:

php.ini
session.save_handler = redissession.save_path = "tcp://203.0.113.20:6380?auth=a-long-generated-password&database=2&prefix=shop_sess:"session.gc_maxlifetime = 7200redis.session.locking_enabled = 1redis.session.lock_expire = 30redis.session.lock_wait_time = 50000redis.session.lock_retries = 100

session_start() and $_SESSION then work exactly as before. session.gc_maxlifetime becomes the key TTL, refreshed on each request that writes the session. Where you cannot edit php.ini, the same two settings can be applied with ini_set() before session_start().

Locking deserves a paragraph, because it is the one real difference from file sessions. PHP's file handler locks the session file for the length of a request, so two simultaneous requests from one user run one after the other. The phpredis handler does not lock by default: two parallel AJAX calls each read the session, each modify it, and the second write silently overwrites the first. redis.session.locking_enabled = 1 restores the file handler's behaviour. The cost is that a slow request blocks the same user's other requests, which is also what file sessions did - and the habit to learn is calling session_write_close() as soon as a script has finished writing to the session.

ASP.NET Core#

ASP.NET Core's session middleware stores data in whatever IDistributedCache is registered. The Redis implementation lives in Microsoft.Extensions.Caching.StackExchangeRedis:

bash
$ dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
Program.cs
builder.Services.AddStackExchangeRedisCache(options =>{    options.Configuration = builder.Configuration["Valkey:Configuration"];    options.InstanceName = "shop:";});builder.Services.AddSession(options =>{    options.IdleTimeout = TimeSpan.FromMinutes(30);    options.Cookie.HttpOnly = true;    options.Cookie.IsEssential = true;    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;});var app = builder.Build();app.UseSession();

The configuration string is StackExchange.Redis syntax rather than a URL: 203.0.113.20:6380,password=...,abortConnect=false. abortConnect=false lets the app start and keep retrying if Valkey is briefly unreachable, instead of crashing at boot. InstanceName is the key prefix.

Two things trip up .NET developers here. First, ASP.NET Core session is not authentication: the login cookie issued by cookie authentication or Identity is self-contained and encrypted, and does not live in session at all. Second, that encryption uses Data Protection keys, which by default are stored on the local disk of each instance. Run two instances, or redeploy a container, and users are logged out because the new process cannot decrypt the old cookie. The fix is to store the key ring centrally, and Valkey is a natural place for it with Microsoft.AspNetCore.DataProtection.StackExchangeRedis and PersistKeysToStackExchangeRedis(connection, "DataProtection-Keys"). ASP.NET Core authentication basics goes into that in more depth.

TTLs, eviction and persistence: the settings that decide whether users stay logged in#

Every library above sets a TTL on each session key. That is what makes Valkey a good session store, and it is also why two server-side settings matter more than any of the code.

The eviction policy. When Valkey reaches its memory limit, maxmemory-policy decides what happens. With noeviction, writes fail with an out-of-memory error: new logins break loudly, existing sessions survive. With allkeys-lru, the least recently used keys are deleted to make room: everything keeps working and the users who were idle longest are silently logged out. With volatile-lru or volatile-ttl, only keys that have a TTL are candidates - and since every session has one, sessions are exactly what gets evicted.

For a Valkey that holds only sessions, noeviction with enough memory is the honest choice: you find out you need a bigger plan from an error, not from users complaining that they keep being logged out. If the same instance also holds a cache, either separate them (a second small instance is cheap) or accept that sessions compete with cache entries for memory. Valkey memory and eviction policies has the full table.

Check what you have with:

bash
$ valkey-cli -h 203.0.113.20 -p 6380 --askpass CONFIG GET maxmemory-policy$ valkey-cli -h 203.0.113.20 -p 6380 --askpass INFO memory

If CONFIG is refused on a managed instance, INFO memory still shows maxmemory and maxmemory_policy.

Persistence. A session store that loses its contents on restart logs every user out on every restart. With the append-only file at appendfsync everysec, a hard stop loses at most about a second of session writes, which in practice nobody notices. Valkey persistence: AOF and RDB explains the two mechanisms and what each costs.

Sizing. A typical session - a user id, a CSRF token, a flash message and a little framework bookkeeping - is a few hundred bytes to a couple of kilobytes, plus per-key overhead. Ten thousand active sessions at 2 KB each is around 20 MB. The 256 MB plan holds far more sessions than most sites will ever have; what actually fills a session store is either saveUninitialized-style anonymous sessions, or applications that stuff whole objects - a shopping cart with product data, a user record with permissions - into the session instead of an id. Keep sessions small and look things up.

You can see the real numbers rather than guessing:

bash
$ valkey-cli --askpass -h 203.0.113.20 -p 6380 --scan --pattern 'shop:sess:*' | head -3$ valkey-cli --askpass -h 203.0.113.20 -p 6380 MEMORY USAGE shop:sess:Xk2v...$ valkey-cli --askpass -h 203.0.113.20 -p 6380 TTL shop:sess:Xk2v...

Use --scan, never KEYS, on anything that is serving traffic: KEYS walks the entire keyspace in one blocking call.

Troubleshooting#

Users are logged out after every deploy. Either the store is in-process (the configuration never took effect - check the app actually loaded the Valkey store), the session secret changed (Express secret, Laravel APP_KEY, Django SECRET_KEY or .NET Data Protection keys), or Valkey restarted without persistence.

Sessions do not stick at all in production. A secure cookie over a connection the app believes is HTTP. Configure the framework's trust-proxy setting so it reads X-Forwarded-Proto. Open the browser's developer tools and check whether a Set-Cookie header arrives and whether the cookie is stored.

Random logouts under load. Eviction. Look at evicted_keys in INFO stats; if it is rising, sessions are being deleted to make room. Raise memory, move the cache elsewhere, or switch to noeviction.

`NOAUTH Authentication required` or `WRONGPASS`. The password is missing or wrong in the URL. Special characters in a password must be percent-encoded inside a URL - an @ or / in the password breaks the parsing.

Requests from one user are slow, one after another. Session locking. Release the session early with session_write_close() in PHP, or keep long-running work out of requests that hold the session.

Two apps see each other's sessions. Same prefix, same database. Give each a distinct prefix.

FAQ#

Can I use the same Valkey instance for sessions and cache?

Yes, if you separate them by prefix or logical database and accept that they share memory. The risk is a cache that grows until eviction starts removing sessions. For anything beyond a small site, two small instances are cleaner than one large one with a policy that must suit both jobs.

What happens to sessions if Valkey goes down?

New requests cannot load sessions, so users appear logged out or get errors, depending on how the library handles a failed connection. When Valkey comes back with persistence enabled, the sessions it saved are still there. Set sensible connection timeouts so a missing Valkey fails fast instead of hanging every request.

Is it safe to store personal data in a session?

Store as little as possible: an id you look up, not the record itself. Session contents are not encrypted at rest in Valkey by any of these libraries, and anyone with the password can read them. Treat the Valkey password as you treat the database password.

How long should a session last?

As short as your users will tolerate. Two hours idle for an admin area, two weeks for a consumer site with a "remember me" box. The TTL on the key should equal the cookie lifetime; every library above links the two if you set the cookie age and leave the store's own TTL option alone.

Do I need sticky sessions on a load balancer if I use Valkey?

No. That is the point of a shared store: any process can serve any request. Sticky sessions are a workaround for in-process session state, and they become unnecessary once sessions live in Valkey.


Comments

Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.

0/2000