← Blog

Move off Heroku Key-Value Store (Redis)

Railyard

May 31, 2026 · 11 min read

Follow this on your own server. Start free, no card needed

If you provisioned Redis on Heroku any time in the last few years, the add-on you’re looking at today is called Heroku Key-Value Store, not Heroku Redis. Heroku renamed the product and moved it onto Valkey, a Redis-compatible fork, while keeping the same heroku-redis CLI plugin and config var names. Nothing about your app broke, but it’s a sign of where the product is headed, and it’s a good moment to decide whether you’d rather run Redis yourself, on a server you control, for a fraction of the add-on’s price.

This guide covers both parts of that move: standing up your own Redis, and actually getting the data from Heroku’s instance into it without losing anything your app depends on.

What you’ll need

  • A server. A Hetzner CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, €5.99/month excl. VAT) has plenty of headroom to run Redis next to a small to medium web app. If you already have a server running your app, you can add Redis to it instead of provisioning a new one.
  • Docker and the Docker Compose plugin installed on that server (or your distribution’s redis-server package, if you’d rather skip containers, covered in Step 3).
  • SSH access to the server and a non-root user that can run docker or sudo.
  • The Heroku CLI and heroku redis access to your current instance, to pull the data across.

You don’t need a domain for this. Redis stays on the server’s private network and your app talks to it directly, never over the public internet.

Step 1: Decide what you actually need Redis for

Before you touch a config file, check what your app is storing in Redis, because that decides how careful you need to be with the migration and how you configure persistence afterward.

  • Pure cache (Rails cache_store, Django’s cache framework, a sessions store you can afford to lose): the new instance can start empty. Nothing to migrate, the cache just refills as requests come in.
  • Job queues (Sidekiq, Bull, Celery, RQ): jobs sitting in Redis when you cut over are real work that hasn’t run yet. You want to either drain the queue to zero before switching, or copy the data across (Step 4).
  • Anything else you’d be upset to lose (rate limiter counters you care about, a leaderboard, a Lua-scripted lock): copy the data.

On Heroku, you can check what’s actually pointed at Redis with:

heroku config -a your-app-name | grep -i redis

Note every variable name you see. Heroku add-ons have historically used names like REDIS_URL, REDISCLOUD_URL, REDISTOGO_URL or OPENREDIS_URL depending on which add-on provider you started with, and your app’s code may be reading one of those specifically rather than a generic REDIS_URL.

Step 2: Run Redis with Docker Compose

This guide uses redis:8-alpine, the current stable major version of the official Redis image, on Alpine for a small footprint. If you’re already running other services with Compose, add this as another service in the same file; otherwise this is enough on its own.

services:
  redis:
    image: redis:8-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
    environment:
      REDISCLI_AUTH: ${REDIS_PASSWORD}
    volumes:
      - redis-data:/data
    ports:
      - "127.0.0.1:6379:6379"
    restart: unless-stopped

volumes:
  redis-data:

A few choices here are worth explaining:

  • 127.0.0.1:6379:6379 publishes the port on the server’s loopback interface only. A bare "6379:6379" would publish it on every interface, and Docker’s port publishing goes around host firewalls like ufw, so Redis would be reachable from the internet. If your app runs as another service in the same Compose file, you can drop ports entirely and connect to the host name redis.
  • --requirepass sets a password from day one. The official image ships with no password configured. A password is one line and protects you if a port ever ends up exposed by mistake.
  • REDISCLI_AUTH is the variable redis-cli reads its password from, so commands you run inside the container authenticate without putting the password on the command line.
  • --appendonly yes turns on AOF persistence, so a container restart doesn’t wipe your data. If you decided in Step 1 that this instance is a pure cache, you can drop this and the volume entirely; losing a cache on restart is harmless and you save disk I/O.
  • --maxmemory and --maxmemory-policy allkeys-lru cap Redis’s memory and tell it to evict the least-recently-used keys once it hits that cap, instead of refusing writes. Size this to a fraction of your server’s RAM, not all of it; leave room for your app and the OS.

Save the file as compose.yaml, then generate a password into a .env file next to it and bring Redis up:

echo "REDIS_PASSWORD=$(openssl rand -hex 24)" >> .env
docker compose up -d redis

Check that the container is up:

docker compose ps

You should see the redis service with a status of Up. Then confirm it answers and the password works:

docker compose exec redis redis-cli ping

You should see PONG. If you see NOAUTH Authentication required, the REDISCLI_AUTH variable didn’t reach the container; check that .env sits next to your compose.yaml.

Step 3: The non-Docker alternative, a package install with systemd

If you’d rather not run Docker at all, Redis installs cleanly as a system package and ships its own systemd unit.

On Debian or Ubuntu:

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

Edit /etc/redis/redis.conf and set the same choices as above directly in the config file:

requirepass your-generated-password-here
appendonly yes
maxmemory 512mb
maxmemory-policy allkeys-lru
bind 127.0.0.1 ::1

The bind line matters here in a way it didn’t in the Compose version: without Docker’s network isolation, Redis listens on the host directly, so bind it to loopback (or your private network interface) rather than 0.0.0.0 unless you specifically need another server to reach it.

Restart the service so it picks up the new settings, and make sure it starts at boot:

sudo systemctl enable redis-server
sudo systemctl restart redis-server
sudo systemctl status redis-server

You should see active (running) in the status output, and redis-cli -a your-generated-password-here ping should answer PONG.

Step 4: Move the data across

If Step 1 told you this instance only needs to start empty, skip to Step 5.

Otherwise, you need a way to copy the keys. Heroku’s own help center says it doesn’t offer a way to download RDB dumps, so the snapshot copy you’d use between two servers you control isn’t a path Heroku supports. What does work against any Redis or Valkey server is reading each key with ordinary commands and writing it to the new instance.

First, stop whatever writes to Redis (your web dynos and workers), so nothing changes while you copy:

heroku ps:scale web=0 worker=0 -a your-app-name

This takes the app offline for the length of the copy, so pick a quiet moment. Use your own process names; heroku ps -a your-app-name lists them. For a job queue, let the workers finish what’s queued before you scale them down if you can, so there’s less to carry over.

Then save this script as copy_redis.py. It walks the source with SCAN, copies strings, hashes, lists, sets and sorted sets, keeps each key’s remaining time to live, and prints any key of another type (streams, for example) so you can deal with it by hand:

import os
import redis

source_url = os.environ["SOURCE_URL"]
if source_url.startswith("rediss://"):
    src = redis.from_url(source_url, ssl_cert_reqs=None)
else:
    src = redis.from_url(source_url)
dst = redis.from_url(os.environ["TARGET_URL"])

copied = 0
for key in src.scan_iter(count=500):
    kind = src.type(key).decode()
    ttl = src.pttl(key)
    pipe = dst.pipeline()
    pipe.delete(key)
    if kind == "string":
        pipe.set(key, src.get(key))
    elif kind == "hash":
        pipe.hset(key, mapping=src.hgetall(key))
    elif kind == "list":
        pipe.rpush(key, *src.lrange(key, 0, -1))
    elif kind == "set":
        pipe.sadd(key, *src.smembers(key))
    elif kind == "zset":
        pipe.zadd(key, dict(src.zrange(key, 0, -1, withscores=True)))
    else:
        print(f"skipped {key!r} (type {kind})")
        continue
    if ttl > 0:
        pipe.pexpire(key, ttl)
    pipe.execute()
    copied += 1

print(f"copied {copied} of {src.dbsize()} keys")

ssl_cert_reqs=None is the setting Heroku’s own Python example uses, because Key-Value Store serves TLS with self-signed certificates. Run the script on your new server, where the target Redis listens on loopback:

sudo apt-get install -y python3-venv
python3 -m venv venv
venv/bin/pip install redis
SOURCE_URL="$(heroku config:get REDIS_URL -a your-app-name)" \
TARGET_URL="redis://:your-generated-password@127.0.0.1:6379/0" \
venv/bin/python copy_redis.py

If the Heroku CLI isn’t installed on that server, run heroku config:get REDIS_URL -a your-app-name on your own machine and paste the value into SOURCE_URL instead.

You should see a line like copied 1520 of 1520 keys at the end. The two numbers differ only by keys the script listed as skipped or keys that expired while it ran. Check the new instance from its side too:

docker compose exec redis redis-cli dbsize

Step 5: Point your app at the new instance

Update whichever variable your app actually reads (the one you found in Step 1) to the new connection string:

REDIS_URL=redis://:your-generated-password@127.0.0.1:6379/0

That’s the right host when your app runs on the same server outside Compose. If the app is another service in the same compose.yaml, use redis as the host instead (redis://:your-generated-password@redis:6379/0). Note the scheme: redis://, not the rediss:// Heroku gave you, because this instance doesn’t serve TLS.

Start your web and worker processes on the new server, watch the logs for successful Redis connections, and only then remove the Heroku add-on. You can name it by its attachment name:

heroku addons:destroy REDIS -a your-app-name

The CLI asks you to type the app name to confirm. If you can, keep the add-on for a day or two after the cutover as a safety net (the Mini plan is capped at $3 a month); it catches anything that still points at the old URL.

The shortcut: do it with Railyard

Everything above is what Railyard sets up when you add Redis from an app’s Resources tab. Redis runs on your own server with a password, only apps on that server can reach it, and each app is assigned a database number on it. The app receives REDIS_URL, plus REDIS_HOST, REDIS_PORT, REDIS_PASSWORD and REDIS_DB if your code reads the parts separately. When one app needs its own memory limit and eviction policy (noeviction for a Sidekiq queue, allkeys-lru for a cache), Give this app its own Redis on the Redis card moves it to a dedicated instance and copies its keys across; Database servers covers the details.

To bring keys over from Heroku, open Trusted addresses on the Redis card and allow your IP, then run the Step 4 script from your own machine with TARGET_URL pointing at the address and password the card shows.

The part that saves the most time if you’re coming straight from Heroku: your app gets the connection string as REDIS_URL, and if your repository reads one of the older add-on names instead (in .env.example or your config files), Railyard fills that variable with the same connection string. That covers REDISCLOUD_URL, REDISTOGO_URL, OPENREDIS_URL, REDISGREEN_URL, REDIS_TLS_URL and names ending in _REDIS_URI, so code written against an old add-on keeps working without a rename.

If you’re moving the whole app over, not just Redis, importing directly from Heroku maps a heroku-redis or rediscloud add-on to Redis automatically as part of the import, alongside your config vars and process formation. The full walkthrough for moving everything is at Migrating from Heroku.

Troubleshooting

Writes start failing once the instance fills up. Check maxmemory-policy. The default Redis policy, noeviction, rejects new writes once memory is full rather than evicting anything, which is correct for a queue you can’t afford to lose data from, but surprising if you copied a cache config expecting old keys to just age out. Set allkeys-lru or volatile-lru for cache-only data, and keep noeviction deliberately for anything you’re treating as a queue or source of truth.

Data disappears after a container restart or server reboot. This means AOF or RDB persistence isn’t actually enabled, usually because --appendonly yes or appendonly yes got left out, or the data directory isn’t mounted to a real volume. Check docker compose config shows the volume mount, and confirm persistence is on with redis-cli CONFIG GET appendonly.

Your app can’t connect at all. The most common cause after a Heroku migration is a leftover rediss:// (TLS) URL scheme pointed at a plain redis:// instance, or the reverse. Heroku’s add-ons use TLS; a self-hosted instance usually doesn’t unless you’ve put it behind stunnel or similar. Match the scheme in the URL to what the server actually speaks.

Out of memory on a small server. If maxmemory isn’t set, Redis will happily try to use all available RAM, and on a 4 GB box shared with your app and the OS, that ends with the kernel’s OOM killer taking out whichever process looks biggest, not necessarily Redis. Set maxmemory explicitly to something well under your total RAM.

FAQ

Do I need a password if Redis is only reachable from my own app server? Not strictly, if your firewall genuinely blocks port 6379 from anywhere else. A password is still close to free to set up and protects you against a firewall rule you forgot to write or a future server added to the same network.

Can I use the same Redis instance for caching and job queues? Yes, logically separated by database number (redis://host:6379/0 for cache, /1 for queues) or by key prefix, but be deliberate about the eviction policy either way; allkeys-lru will happily evict a queued job if the instance fills up.

What’s the difference between Redis and Valkey, the thing Heroku’s add-on now runs? Valkey is a fork of Redis created after Redis changed its license, maintained as open source under the Linux Foundation. It forked from Redis 7.2 and speaks the same protocol, so the usual Redis client libraries connect to either one. That’s why your app’s code didn’t have to change when the add-on’s engine did, and why the same code works against the Redis you run yourself.

How do I back this up? With AOF on, the files under the redis-data volume are your data. For a copy you can ship elsewhere, redis-cli --rdb /data/backup.rdb inside the container writes a point-in-time snapshot of your own instance; run it from cron and copy the file off the server.

Moving off a managed Redis add-on is mostly about being honest with yourself in Step 1 about what’s actually stored in it. Once you know that, the rest is a Compose file, a password, and a cutover you can do without anyone noticing.

Why people pick Railyard for this

  • Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
  • One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
  • One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
  • Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
  • Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.

Attach a managed Redis instead

Add Redis from an app's Resources tab and Railyard sets up a password-protected Redis on your server and wires up REDIS_URL for you.

Free plan, no card, and 14 days of Pro for every new team. One flat price per team.