Nov 12, 202610 min read

How to Migrate Railway Redis to Amazon ElastiCache

Move the data in a Railway Redis service to ElastiCache in your own AWS account, either by copying keys lazily on first read while both run, or by seeding a new cluster from an RDB snapshot during a write freeze.

Illustration for How to Migrate Railway Redis to Amazon ElastiCache

A Redis service on Railway is a container built from the official redis image with a volume attached, reachable from the other services in the project over the private network through REDIS_URL. It works well while everything lives in one Railway project, but once the app moves to AWS the data in it, such as shopping carts, saved filters or session state with a 30-day expiry, has to reach Amazon ElastiCache without logging users out or emptying their carts.

This guide covers two ways to move it: copying keys lazily on the first read after the switch, so both stores run side by side with no write freeze, shown with Encore, which provisions ElastiCache in your own AWS account from the Go code, and seeding a new ElastiCache cluster from an RDB snapshot by hand.

Ways to move Railway Redis to ElastiCache at a glance

Encore with lazy copy on readRDB snapshot seed by handStart empty
ElastiCache created byEncore, from cache.NewClusterYou, with --snapshot-arnsYou or Encore
Write freezeNoneFrom snapshot until the app switchesNone
Keys that moveOnly the ones still being readEverything in the snapshotNone
TTL keptYes, read with PTTL per keyYesstored in the RDBNot applicable
Railway access neededDuring the copy windowOnce, for the snapshotNo
FitsCarts, sessions, user stateLarge keyspaces, data with no TTLPure caches and counters

Reaching Railway Redis from outside

Railway deploys databases private by default, so reading the data from AWS or your laptop means opening the Redis service's networking settings and adding Public Access, which creates a TCP proxy and fills in a REDIS_PUBLIC_URL variable. Check what is stored before choosing a strategy:

~
$ redis-cli -u "$REDIS_PUBLIC_URL" INFO keyspace$ redis-cli -u "$REDIS_PUBLIC_URL" --scan --pattern 'cart:*' | head$ redis-cli -u "$REDIS_PUBLIC_URL" PTTL cart:8b1f0c$ redis-cli -u "$REDIS_PUBLIC_URL" INFO server | grep redis_version

If a prefix is pure cache, let it start empty on ElastiCache. Keys a user would notice losing, like carts, are what the two strategies below are for. Keys with no TTL that hold the only copy of some data belong in Postgres instead of a cache with an eviction policy.

How do you migrate Railway Redis to ElastiCache with Encore?

Encore

Encore brings infrastructure from code to Go: the Redis cluster and a typed keyspace are declared in the service that uses them, and deploying to AWS creates ElastiCache for Redis with Multi-AZ replication and Redis ACLs, as listed in the AWS infrastructure docs. Railway Redis stays online as the source for keys that have not been copied yet, while the new keyspace fills up from live traffic.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create shop-api --example=hello-world$ cd shop-api && go get github.com/redis/go-redis/v9@v9.23.0$ mkdir cart
// cart/cart.go package cart import ( "context" "encoding/json" "errors" "time" "encore.dev/beta/errs" "encore.dev/storage/cache" "github.com/redis/go-redis/v9" ) // ElastiCache on AWS, an in-memory Redis with `encore run`. var cluster = cache.NewCluster("carts", cache.ClusterConfig{ EvictionPolicy: cache.VolatileLRU, }) type Item struct { SKU string `json:"sku"` Qty int `json:"qty"` } type Cart struct { Items []Item `json:"items"` UpdatedAt time.Time `json:"updated_at"` } const cartTTL = 30 * 24 * time.Hour var Carts = cache.NewStructKeyspace[string, Cart](cluster, cache.KeyspaceConfig{ KeyPattern: "cart/:key", DefaultExpiry: cache.ExpireIn(cartTTL), }) var secrets struct { // REDIS_PUBLIC_URL of the Railway Redis service. Remove this field and // the fallback in load once the migration window has passed. RailwayRedisURL string } //encore:service type Service struct { railway *redis.Client } func initService() (*Service, error) { opt, err := redis.ParseURL(secrets.RailwayRedisURL) if err != nil { return nil, err } return &Service{railway: redis.NewClient(opt)}, nil } func (s *Service) Shutdown(force context.Context) { _ = s.railway.Close() } // load returns the user's cart from ElastiCache, moving it over from // Railway Redis the first time it is read after the switch. func (s *Service) load(ctx context.Context, userID string) (Cart, error) { c, err := Carts.Get(ctx, userID) if err == nil || !errors.Is(err, cache.Miss) { return c, err } oldKey := "cart:" + userID raw, err := s.railway.Get(ctx, oldKey).Bytes() if errors.Is(err, redis.Nil) { return Cart{}, nil // no cart in either place } else if err != nil { return Cart{}, err } if err := json.Unmarshal(raw, &c); err != nil { return Cart{}, err } ttl, err := s.railway.PTTL(ctx, oldKey).Result() if err != nil { return Cart{}, err } if ttl == -2 { return Cart{}, nil // expired between GET and PTTL } if ttl == -1 { ttl = cartTTL // no expiry on the old key } // SetIfNotExists lets one of two concurrent requests win the copy. err = Carts.With(cache.ExpireIn(ttl)).SetIfNotExists(ctx, userID, c) if errors.Is(err, cache.KeyExists) { return Carts.Get(ctx, userID) } else if err != nil { return Cart{}, err } // Delete the Railway key only if it still holds the snapshot we copied, // so a concurrent cart update is not discarded. kept, err := s.railway.Eval(ctx, ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) end return 0 `, []string{oldKey}, raw).Int() if err != nil { return Cart{}, err } if kept == 0 { // The Railway key changed. Keep a destination value only when it // already differs from the snapshot we wrote. A still-matching // copy is stale, so refresh from the current source value. dest, err := Carts.Get(ctx, userID) if err == nil && dest != c { return dest, nil } if err != nil && !errors.Is(err, cache.Miss) { return Cart{}, err } raw, err := s.railway.Get(ctx, oldKey).Bytes() if errors.Is(err, redis.Nil) { return Cart{}, nil } else if err != nil { return Cart{}, err } var newer Cart if err := json.Unmarshal(raw, &newer); err != nil { return Cart{}, err } ttl, err := s.railway.PTTL(ctx, oldKey).Result() if err != nil { return Cart{}, err } if ttl == -2 { return Cart{}, nil } if ttl == -1 { ttl = cartTTL } // Re-check the destination immediately before writing, so a cart // update that landed after the first Get is not overwritten. dest, err = Carts.Get(ctx, userID) if err == nil && dest != c { return dest, nil } if err != nil && !errors.Is(err, cache.Miss) { return Cart{}, err } if errors.Is(err, cache.Miss) { err = Carts.With(cache.ExpireIn(ttl)).SetIfNotExists(ctx, userID, newer) if errors.Is(err, cache.KeyExists) { return Carts.Get(ctx, userID) } else if err != nil { return Cart{}, err } } else { // Destination still holds the stale snapshot. Replace it only // after the re-check above; a later Get is the source of truth // if another writer won the race. if err := Carts.With(cache.ExpireIn(ttl)).Set(ctx, userID, newer); err != nil { return Cart{}, err } dest, err = Carts.Get(ctx, userID) if err == nil && dest != newer { return dest, nil } else if err != nil { return Cart{}, err } } kept, err = s.railway.Eval(ctx, ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) end return 0 `, []string{oldKey}, raw).Int() if err != nil { return Cart{}, err } if kept == 0 { dest, err = Carts.Get(ctx, userID) if err == nil && dest != newer { return dest, nil } else if err != nil && !errors.Is(err, cache.Miss) { return Cart{}, err } return Cart{}, errors.New("source changed during copy, retry") } return newer, nil } return c, nil } type GetParams struct { UserID string `query:"user_id"` } // Get is called by the storefront service, never directly by browsers. // //encore:api private method=GET path=/cart func (s *Service) Get(ctx context.Context, p *GetParams) (*Cart, error) { c, err := s.load(ctx, p.UserID) return &c, err } type AddParams struct { UserID string `json:"user_id"` SKU string `json:"sku"` Qty int `json:"qty"` } //encore:api private method=POST path=/cart/items func (s *Service) Add(ctx context.Context, p *AddParams) (*Cart, error) { if p.UserID == "" || p.SKU == "" || p.Qty < 1 || p.Qty > 99 { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "user_id, sku and qty 1-99 are required"} } c, err := s.load(ctx, p.UserID) if err != nil { return nil, err } merged := false for i := range c.Items { if c.Items[i].SKU == p.SKU { c.Items[i].Qty += p.Qty merged = true } } if !merged { c.Items = append(c.Items, Item{SKU: p.SKU, Qty: p.Qty}) } c.UpdatedAt = time.Now() if err := Carts.Set(ctx, p.UserID, c); err != nil { return nil, err } return &c, nil }

On a miss in ElastiCache, load reads the old cart:<id> key and its remaining lifetime from Railway with go-redis, writes the cart into the new keyspace with that TTL through With(cache.ExpireIn(ttl)), and deletes the old key so a later read cannot pick up a stale copy. SetIfNotExists settles the race when two requests for the same cart arrive at once, and every write after that goes to ElastiCache only. Under encore run with a Redis standing in for Railway, a first Get returns the old cart and removes it from the source, and an Add that follows merges into the copied cart. Both endpoints are private, so only other services in the app, such as the storefront API, can call them, as described in the API docs.

Set the Railway URL as a production secret and deploy:

~/shop-api
$ printf '%s' "$REDIS_PUBLIC_URL" | encore secret set --type prod RailwayRedisURL$ git push encore

The value is stored by Encore and injected at runtime, as covered in the secrets docs, so the Railway password never lands in the repository or in a file a coding agent working on the cart code could read. Carts expire after at most 30 days, so after that window every cart that matters has been copied or has expired: remove the secret field, the railway client and the fallback branch in load, deploy, and turn off Public Access on Railway. Carts of users who never return expire on Railway along with the service.

Best fit: apps moving from Railway to their own AWS account whose Redis data has a natural expiry, and who want ElastiCache declared in the Go service with no freeze on writes during the move.

RDB snapshot seed by hand

For a large keyspace, or data without a TTL, a snapshot moves everything at once. Stop the services that write to Redis first, since writes after the snapshot will not reach AWS, then stream an RDB file with redis-cli --rdb, which uses the replication handshake to copy the whole dataset, as the redis-cli docs describe:

~
$ redis-cli -u "$REDIS_PUBLIC_URL" --rdb dump.rdb$ aws s3 cp dump.rdb s3://shop-redis-seed/railway/dump.rdb$ aws elasticache create-replication-group \ --replication-group-id shop-redis \ --replication-group-description "Seeded from Railway" \ --engine redis --cache-node-type cache.t4g.small \ --num-cache-clusters 2 --automatic-failover-enabled \ --transit-encryption-enabled \ --cache-subnet-group-name shop-private --security-group-ids $REDIS_SG \ --snapshot-arns arn:aws:s3:::shop-redis-seed/railway/dump.rdb

The seeding guide requires the bucket to be in the same region as the cluster and a bucket policy that lets the regional elasticache-snapshot.amazonaws.com service principal read the file. The engine version has to be at least as new as the Redis that wrote the RDB, which is what the redis_version check above is for, since Redis does not load files from a newer release. With in-transit encryption on, clients connect with rediss://, and the app needs the security group rule, the subnet group and an auth token or ACL user set up alongside the cluster.

Best fit: large Redis datasets, or keys with no expiry, where a short write freeze is acceptable and the ElastiCache layout is managed by an infrastructure team.

How to choose

Pick Encore with a lazy copy when the data expires on its own and you want ElastiCache created from the service code, with Railway serving as a read-only fallback until the last TTL runs out. Pick the RDB seed when the keyspace is large or permanent and a write freeze is fine, and let pure caches start empty either way.

Frequently asked questions

How do I export data from a Railway Redis service?

Enable Public Access on the Redis service, which adds a TCP proxy and a REDIS_PUBLIC_URL variable, then run redis-cli -u "$REDIS_PUBLIC_URL" --rdb dump.rdb to stream an RDB snapshot to your machine. Turn Public Access off again once the migration is finished.

Can I seed Amazon ElastiCache from a Redis RDB file?

Yes, when you create a new node-based cluster: upload the .rdb file to an S3 bucket in the same region, grant the ElastiCache snapshot service principal read access to it, and pass its ARN with --snapshot-arns to create-replication-group. Load the file into a target engine version that is at least as new as the Redis that wrote it.

How do I migrate Railway Redis to ElastiCache with Encore?

Declare the cluster with cache.NewCluster and a keyspace such as cache.NewStructKeyspace[string, Cart], store REDIS_PUBLIC_URL as an Encore secret, and on a cache.Miss read the old key and its PTTL from Railway with go-redis, write it with Carts.With(cache.ExpireIn(ttl)).SetIfNotExists and delete the old key only if it still holds the snapshot you copied. Encore provisions ElastiCache in your AWS account on deploy.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders