Stateless in practice: sessions, uploads and caches on two servers
Two servers log users out and lose uploads. Sessions go to Redis or a shared database, uploads to object storage, caches to Redis. Sticky sessions are no fix.
- Author
- Hetscale
- Published
- Last updated
- Tags
- guide, stateless, sessions, object-storage, hetzner-load-balancer
A stateless application keeps nothing a user needs on one server’s disk or in its memory. Put a second server behind a load balancer before that is true, and three things break on the first day: sessions are lost and users logged out at random, because each session lives on one server; uploaded files answer 404 about half the time, because they sit on the other disk; and the cache disagrees with itself, because each server keeps its own. Each has to move to a place multiple servers share: sessions to Redis or to a database server they all reach, uploads to S3-compatible object storage such as Hetzner Object Storage, and shared cache entries to Redis. Sticky sessions on the load balancer only hide the problem until a server leaves. Below: the settings for five stacks, how to find what is still on the disk, and a test with two servers.
Why does a second server log users out and lose uploads?
A load balancer sends each request to any healthy server. A session written to server A’s disk or memory is not on server B, so the next request that lands on B arrives without it, and the user is logged out. A file uploaded to A is a 404 on B (uploads break on the second server). A cache entry cleared on A is still served by B.
The defaults decide whether you see it. Express (one process’s memory), plain PHP (files) and
Laravel 10 (storage/framework/sessions) break on the first request to the second server. Django,
Laravel 11 and later, and the logins of Rails 8’s authentication generator use “the database”, but
each ships with SQLite, a file on the server’s own disk: the database is only shared once it is a
server that every app server reaches. Rails’ default cookie store needs only the same secret on
every server. The table in
step 1 of our autoscaling guide
lists where each kind of state goes; this guide is the how.
Where should sessions go?
In Redis or in a database server that every app server reaches. On Hetzner Cloud that is a server
of your own in the private network (Hetzner Cloud has no managed Redis or database, October 2026).
Keep it off the internet with the firewall from
step 7 of our load balancer guide,
which leaves the private network open. Give Redis a password with requirepass, made of letters
and digits (openssl rand -hex 32) since it goes into URLs: the official Redis Docker image turns
protected mode off.
Sessions kept only in Redis last as long as Redis keeps them. Give them a Redis that does not evict
keys (maxmemory-policy noeviction, the default), not a cache Redis set to allkeys-lru.
Easy to miss: every server needs the same secret. A server with a different application key cannot read the session or its cookie and logs the user out, even with the session in a shared Redis. Set the key once, and never generate it per server in a provisioning script.
Laravel (Redis needs the phpredis extension, or predis/predis with REDIS_CLIENT=predis).
Every cookie is encrypted with APP_KEY; from Laravel 11, APP_PREVIOUS_KEYS lets you rotate it
without logging everyone out:
SESSION_DRIVER=redis
REDIS_HOST=10.0.1.5
REDIS_PASSWORD=<password>
APP_KEY=base64:<the same key on every server>
SESSION_DRIVER=database works as well, once DB_CONNECTION points at a database server instead
of the default SQLite file.
Django (4.1 and later; pip install redis for the Redis cache). The default db engine is
fine on a shared database; cached_db adds Redis in front of it. SECRET_KEY signs the session
data, so it must match, and SECRET_KEY_FALLBACKS holds old keys during a rotation:
import os
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # the same on every server
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://:<password>@10.0.1.5:6379",
}
}
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
Avoid the pure cache engine on the default cache: an eviction or a cache.clear() there logs
users out.
Rails (8.1). The default cookie store keeps the whole session in an encrypted cookie, so it is
already shared once every server has the same secret_key_base: the same RAILS_MASTER_KEY, or
the same SECRET_KEY_BASE. Logins made with Rails 8’s authentication generator are different: they
are rows in a sessions table, so that table needs a shared database. For server-side sessions in
Redis, add gem "redis" and give them their own database on a Redis that does not evict, such as
redis://:<password>@10.0.1.5:6379/1, because Rails.cache.clear empties the whole database the
cache uses:
# config/initializers/session_store.rb
Rails.application.config.session_store :cache_store,
key: "_myapp_session",
expire_after: 2.weeks,
cache: ActiveSupport::Cache::RedisCacheStore.new(url: ENV["SESSION_REDIS_URL"])
Express (express-session 1.19, connect-redis 10 on Node 22 or later; connect-redis 9 takes the
same code). The default MemoryStore is not meant for production and does not leave one process.
The secret must match on every server; given an array, express-session signs with the first
secret and still accepts the others. Listen for Redis errors, or a Redis restart ends the process
on every server at once:
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL }).on('error', console.error);
await redisClient.connect();
app.use(
session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // the same on every server
resave: false,
saveUninitialized: false,
}),
);
Plain PHP with the phpredis extension. The session cookie carries only an ID, so a shared store is enough. phpredis does not lock sessions unless you ask; the file handler does:
session.save_handler = redis
session.save_path = "tcp://10.0.1.5:6379?auth=<password>"
redis.session.locking_enabled = 1
Keep the health check out of all of this: a check that passes through the session middleware can start a session on every call, and one that waits on Redis fails on every server when Redis stumbles (what a health endpoint should return).
Where should uploads go?
In S3-compatible object storage, through the storage driver your framework already has. Hetzner
Object Storage speaks the S3 API, with a list of unsupported features in its docs. It runs in
Falkenstein, Nuremberg and Helsinki (October 2026), at https://fsn1.your-objectstorage.com and
its nbg1 and hel1 twins. Buckets are private by default, so a plain file URL answers 403: sign
links with Laravel’s
Storage::temporaryUrl() or @aws-sdk/s3-request-presigner in Node (django-storages and Active
Storage sign theirs already), or open only a public folder with a bucket policy.
It costs €6.49 a month, including 1 TB of storage and 1 TB of egress (Hetzner list price without VAT, checked October 2026). Uploads are free, and so are downloads by servers in the same network zone; files your visitors download from the bucket count as egress.
Laravel: install the S3 adapter with
composer require league/flysystem-aws-s3-v3 "^3.0" --with-all-dependencies, then:
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=<access-key>
AWS_SECRET_ACCESS_KEY=<secret-key>
AWS_DEFAULT_REGION=fsn1
AWS_BUCKET=<bucket>
AWS_ENDPOINT=https://fsn1.your-objectstorage.com
Code that names Storage::disk('public') keeps writing to the server until that disk changes too.
Livewire, and Filament with it, then sends temporary uploads from the browser to the bucket
directly, which needs a CORS rule for your domain; Filament 3 keeps the saved files on its own disk
until you also set FILAMENT_FILESYSTEM_DISK=s3.
Django with django-storages 1.14 (pip install "django-storages[s3]"). STORAGES replaces
its default instead of merging with it, so keep the staticfiles entry:
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"bucket_name": "<bucket>",
"endpoint_url": "https://fsn1.your-objectstorage.com",
"region_name": "fsn1",
"addressing_style": "virtual",
"access_key": os.environ["S3_ACCESS_KEY"],
"secret_key": os.environ["S3_SECRET_KEY"],
},
},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
}
Rails Active Storage: add gem "aws-sdk-s3", require: false, a service in
config/storage.yml, and config.active_storage.service = :hetzner in production.rb:
hetzner:
service: S3
endpoint: https://fsn1.your-objectstorage.com
access_key_id: <%= Rails.application.credentials.dig(:hetzner, :access_key_id) %>
secret_access_key: <%= Rails.application.credentials.dig(:hetzner, :secret_access_key) %>
region: fsn1
bucket: <bucket>
Node: keep the file in memory with multer’s memoryStorage() and a limits.fileSize instead
of dest: 'uploads/', and send it to the bucket under a key you generate and store in your
database, never the user’s file name:
import { randomUUID } from 'node:crypto';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({
region: 'fsn1',
endpoint: 'https://fsn1.your-objectstorage.com',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
});
await s3.send(
new PutObjectCommand({
Bucket: '<bucket>',
Key: `avatars/${randomUUID()}`,
Body: req.file.buffer,
ContentType: req.file.mimetype,
}),
);
AWS SDKs released since January 2025 send checksums by default, and Hetzner’s docs do not mention
them. If an upload fails with a checksum or header error after an SDK upgrade, set
AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED and
AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED in the app’s environment (the container’s, under
Docker).
Existing files go across once. With rclone 1.72 or later, create a remote named hetzner with
rclone config (storage s3, provider Hetzner, your keys, region fsn1, endpoint
fsn1.your-objectstorage.com); with the AWS CLI, put the bucket’s keys in a profile of their own
(aws configure --profile hetzner):
rclone copy -P storage/app/private hetzner:<bucket>
aws s3 sync storage/app/private s3://<bucket> --profile hetzner --endpoint-url https://fsn1.your-objectstorage.com
Copy each moved disk’s folder to the bucket root, so the paths your database holds stay the same:
for Laravel’s default disk, the root of local in config/filesystems.php (storage/app/private
in new apps, storage/app in older ones), storage/app/public for its public disk, MEDIA_ROOT
in Django. Rails is the exception: Active Storage nests files on disk differently and records each
blob’s service, so copy each existing blob with a mirror service’s mirror method, the disk
service as primary, then change each blob’s service_name.
A Hetzner Volume is not the answer. It attaches to one server at a time, only in its own location, and server snapshots do not include it, so a new server starts without its files (Hetzner docs, October 2026). Hetzner Cloud has no shared file system either.
Where should the cache go?
In Redis, or in a database server, if the servers must agree; a cache per server is fine only for data that may be stale until it expires. Rendered fragments and API responses with a short lifetime can stay local. These cannot:
- Locks. Laravel’s
Cache::lockandwithoutOverlapping()lock on the cache. On a file or local cache, each server holds its own lock, and both run the job. - Rate limiters. Laravel’s rate limiter, Rails’
rate_limitandexpress-rate-limitcount in the cache or in memory by default, so each server allows the full limit on its own. express-rate-limit takes astore, such as rate-limit-redis. - “Run once” schedules. Laravel’s
onOneServer()needs the database, memcached, dynamodb or redis cache, shared by every server. - Anything you invalidate. A change cleared from one server’s cache is still served by the other until it expires.
The defaults are per server: Django caches in each process’s memory, Laravel 11 and later in
SQLite, and new Rails 8 apps in storage/production_cache.sqlite3. The fix is one line each:
CACHE_STORE=redis in Laravel (CACHE_DRIVER in 10), the RedisCache backend in Django’s
CACHES, and config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] } in Rails, or
Solid Cache on a database server.
How do you tell what is still on the disk?
Start with Docker, which shows where each container writes:
docker volume ls
docker inspect --format '{{.Name}}{{range .Mounts}} {{.Type}}:{{.Source}}->{{.Destination}}{{end}}' $(docker ps -q)
docker compose config | grep -E -A2 'type: (volume|bind)'
docker diff <container>
docker volume ls misses bind mounts; the inspect line shows both, docker compose config what
the next deploy mounts, and docker diff the files written inside a container, lost when it is
recreated.
Then grep the code and the settings for local paths and local drivers:
grep -rnE --exclude-dir=vendor --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=.venv \
"storage_path|MEDIA_ROOT|FileSystemStorage|FileBasedCache|LocMemCache|backends\.file|service: Disk|file_store|dest: |/tmp|\.sqlite3?|uploads/" .
grep -nE '^(DB_CONNECTION|SESSION_DRIVER|CACHE_STORE|CACHE_DRIVER|FILESYSTEM_DISK|QUEUE_CONNECTION|LOG_CHANNEL|LOG_STACK|APP_MAINTENANCE_DRIVER)=' .env
Laravel prints its drivers with php artisan about --only=drivers, and
python manage.py diffsettings --all marks every Django setting still at its default with ###.
Any file, local, locmem, sqlite, single or daily value stays on one server.
Four more things that are easy to miss:
- Scheduled jobs run on every server. A crontab in the snapshot,
schedule:run, Celery beat or a cron container runs on each copy. Use Laravel’sonOneServer()(orSchedule::alwaysOnOneServer()from Laravel 13.35), run one Celery beat only, and checkcrontab -las root and as the app user,/etc/cron.dandsystemctl list-timers. - A local queue is a local queue. Laravel’s
databasequeue on SQLite keeps jobs on the server that queued them; point it at Redis or a database server.syncruns each job inside the request and keeps nothing, so it works on two servers as it is. - File locks hold on one server. A
flockaround a cron job does not stop the same job on the other server. - Local logs leave with the server. Write to stdout, Rails’ default, or ship them; a
laravel.logon the disk is gone with the server that wrote it.
How do you test it with two servers?
Log in and upload a file through the load balancer, take one server out, and check that you are still logged in and the file still opens:
hcloud load-balancer remove-target web-lb --server web-1
# check, then put it back:
hcloud load-balancer add-target web-lb --server web-1 --use-private-ip
A few seconds after the removal, check over a new connection that carries your session cookie
(session_id with Rails 8’s authentication generator): copy it from the browser’s developer tools
and run curl -s -o /dev/null -w '%{http_code}\n' -b '<name>=<value>' <page> on a page that needs a
login; 200 means still logged in. A browser tab proves nothing, because it can reuse its old
connection: the load balancer keeps connections already open to a removed server for up to about
five minutes, and a new request can still reach it one to two seconds after the removal (we
measured both in September 2026).
Once the server is back, wait until hcloud load-balancer describe web-lb shows it healthy before
you test the other one; removing the second server before that is an outage. If the load balancer
selects its targets by label, take the server out with hcloud server remove-label web-1 role and
put it back with hcloud server add-label web-1 role=web. A firewall that applies by that label
comes off too, so apply it to the server directly first.
Why are sticky sessions the wrong fix?
Hetzner’s load balancer offers them: an HTTP or HTTPS service can set a cookie, HCLBSTICKY by
default, that sends a visitor back to the same server. The cookie lasts 300 seconds by default, and
anything from 30 seconds to 24 hours can be set (Hetzner API reference, October 2026). The option is
off by default, and our
load balancer guide
leaves it off. With it on:
- A server that leaves takes its sessions with it. Scale-in, a failed server, a deploy that replaces it: every user pinned to it is logged out, which is the original problem on a delay.
- Uploads still live on one disk. The uploader returns to the server that holds the file; visitors pinned to the other server get a 404.
- The cache still disagrees. Each visitor sees one server’s view of it.
- Load stops spreading. While the cookie lasts, visitors stay where they first landed, so a new server gets mostly new visitors.
Hetzner does not document where a visitor goes when the server in the cookie is gone. Keep the option for connections that genuinely need the same server, not for state that should not be on it.
When not to do this
- You will only ever run one server. Moving state out means running a database or Redis server and a bucket. A single box sized for its peak needs neither.
- The database is still on the app server. Not yet: move it first (step 1 of the Coolify guide); sessions and caches kept in it then move with it.
- Your servers run outside Europe. Hetzner Object Storage is only in Falkenstein, Nuremberg and Helsinki (October 2026), so servers in Ashburn, Hillsboro or Singapore would fetch every file across an ocean; use a bucket near them.
Where Hetscale fits
Hetscale adds servers by copying one, so it works only with servers that can be copied: a node group is a set of identical, stateless servers behind one load balancer. It cannot see what is on your disk. When you start observing, it asks what no read of your Hetzner project can show, beginning with “What runs on your server?” and “Where is the database?” (what Hetscale asks); an app with its database and files on the server is not a fit. Before the first release, you confirm that no uploads, sessions or SQLite files stay on the server and that cron and queue jobs can run on every copy. The onboarding case for a Docker Compose app starts with this check, and the calculator estimates, as a range, what autoscaling would change on your own numbers.
Frequently asked questions
Do I need Redis? Not for sessions, locks or most rate limits: a shared database server handles them, with more load on it. Some tools need Redis itself, such as Laravel Horizon, Laravel Reverb on several servers and django-ratelimit, which needs Redis or Memcached.
Can I sync uploads between the servers with rsync or NFS instead? A sync runs behind: a file uploaded to one server is missing on the other until the next run, and two servers writing the same name conflict. NFS works, but it is one more server to run and back up, and a new server has to mount it before it starts. Hetzner’s Storage Box can be mounted by several servers, but it is a backup product, limited to 10 simultaneous connections per Storage Box account (Hetzner docs, October 2026).
Why are users logged out right after I moved HTTPS to the load balancer? Usually not the session store: the application no longer sees HTTPS, so it may withhold its secure cookie or reject the login form. Tell it to trust the load balancer’s forwarded headers (serve plain HTTP on the server).
Do WebSockets need sticky sessions? A WebSocket stays on the server it opened on. Socket.IO is
the exception: by default it opens each connection over HTTP long-polling, and its docs require
sticky sessions for that on several servers. Set the client to transports: ["websocket"], or turn
sticky sessions on, which then applies to every request on that service. Broadcasts need a shared
channel too: Socket.IO’s Redis adapter, Laravel Reverb with REVERB_SCALING_ENABLED=true and one
Redis, or Action Cable on Redis.