Move sessions and cached data from Upstash's serverless Redis to ElastiCache in your own AWS account, keeping remaining TTLs and leaving short-lived keys behind.

Upstash is a serverless Redis that bills per request and can be called over HTTP, which made it a common pick for apps on Vercel and other edge platforms where a long-lived TCP connection is awkward. When the backend moves to containers in your own AWS account, the trade looks different: every cache read crosses the public internet to another provider, traffic that is steady all day is billed request by request, and session data sits outside the account where the rest of your data lives.
This guide moves a TypeScript backend's Redis data from Upstash to Amazon ElastiCache: inventorying keys, deciding what to copy, copying sessions with their remaining TTLs, and cutting over. It uses Encore to provision ElastiCache from the TypeScript code and covers the same steps for an ElastiCache cluster you build yourself.
Before copying anything, find out what the keys are for. Running SCAN with COUNT 1000 over the whole keyspace and grouping by prefix usually shows three kinds of data: rate limit counters, such as the keys a rate limiting library writes, which live for seconds or minutes; cached responses that can be rebuilt from the database; and sessions or tokens that users would notice losing. Only the last group needs copying, and checking a sample with PTTL tells you how long it would take to expire on its own if you waited.
Also check for commands that behave differently: Lua scripts, pub/sub channels and streams need their own plan, while plain strings, hashes, sets and counters move over without changes.
With Encore, the cache becomes infrastructure from code: you declare a CacheCluster and typed keyspaces in TypeScript, which Encore runs in memory locally and provisions as ElastiCache in your own AWS account. The copy job lives in the same service as a private endpoint, so it uses the same keyspace definition as the code that reads sessions afterwards.
Run in your terminal to get started locally.
The service declares the cluster and the session keyspace, and copySessions reads one SCAN page from Upstash's REST API per call, copying each session with its remaining TTL. setIfNotExists means a session the new app already wrote is never overwritten, so the endpoint can be run any number of times. It is public only for the length of the migration and rejects calls without the X-Migration-Token header:
// sessions/sessions.ts
import { api, APIError, Header } from "encore.dev/api";
import { timingSafeEqual } from "node:crypto";
import { secret } from "encore.dev/config";
import {
CacheCluster,
StructKeyspace,
CacheKeyExists,
expireInHours,
expireIn,
} from "encore.dev/storage/cache";
// In-memory Redis with `encore run`, ElastiCache on AWS when deployed.
const cluster = new CacheCluster("app", { evictionPolicy: "noeviction" });
interface Session {
userId: string;
createdAt: string;
}
export const sessions = new StructKeyspace<{ id: string }, Session>(cluster, {
keyPattern: "session/:id",
defaultExpiry: expireInHours(24 * 7),
});
// Session lookup used by the rest of the app after the switch.
export const getSession = api(
{ expose: false, method: "GET", path: "/sessions/:id" },
async ({ id }: { id: string }): Promise<Session> => {
const s = await sessions.get({ id });
if (!s) throw APIError.notFound("session not found");
return s;
},
);
// Upstash credentials and a one-off token for calling the copy endpoint,
// only needed until the migration is finished.
const upstashUrl = secret("UpstashRestUrl");
const upstashToken = secret("UpstashRestToken");
const migrationToken = secret("MigrationToken");
function checkToken(given: string) {
const a = Buffer.from(given ?? "");
const b = Buffer.from(migrationToken());
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw APIError.permissionDenied("invalid migration token");
}
}
async function upstash<T>(command: (string | number)[]): Promise<T> {
const res = await fetch(upstashUrl(), {
method: "POST",
headers: { Authorization: `Bearer ${upstashToken()}` },
body: JSON.stringify(command),
});
if (!res.ok) throw new Error(`upstash ${command[0]}: ${res.status}`);
const body = (await res.json()) as { result: T };
return body.result;
}
interface CopyRequest {
token: Header<"X-Migration-Token">;
cursor: string; // "0" to start
}
interface CopyResponse {
nextCursor: string; // "0" when done
copied: number;
skipped: number;
}
// copySessions copies one SCAN page of sessions from Upstash, keeping each
// key's remaining TTL. setIfNotExists never overwrites a session the new
// app already wrote, so the endpoint can be run again before cutover.
export const copySessions = api(
{ expose: true, method: "POST", path: "/migrate/sessions" },
async ({ token, cursor }: CopyRequest): Promise<CopyResponse> => {
checkToken(token);
const [nextCursor, keys] = await upstash<[string, string[]]>([
"SCAN", cursor, "MATCH", "session:*", "COUNT", 200,
]);
let copied = 0;
let skipped = 0;
for (const key of keys) {
const [raw, ttlMs] = await Promise.all([
upstash<string | null>(["GET", key]),
upstash<number>(["PTTL", key]),
]);
if (raw === null || ttlMs === -2) {
skipped++; // expired between SCAN and GET
continue;
}
const id = key.slice("session:".length);
const expiry = ttlMs > 0 ? { expiry: expireIn(ttlMs) } : undefined;
try {
await sessions.setIfNotExists({ id }, JSON.parse(raw) as Session, expiry);
copied++;
} catch (err) {
if (err instanceof CacheKeyExists) skipped++;
else throw err;
}
}
return { nextCursor, copied, skipped };
},
);
Locally, encore run starts an in-memory Redis, which keeps only a small number of keys, so test the copy against a staging Upstash database with a handful of sessions and check the counts it returns. The caching docs list the keyspace types for hashes of counters, lists and sets if your data uses them. The Upstash credentials are secrets set per environment and kept out of the repository, and they are deleted along with the copy endpoint and the migration token once the migration is done.
Deploy the service to an AWS environment, then call POST /migrate/sessions with the token header and {"cursor": "0"}, passing each nextCursor back in until it returns "0". Switch the frontend or API to read sessions from the new service, run the copy once more to pick up sessions created in between, and stop writing to Upstash. Rate limit windows start over at the switch, which most apps accept for a few minutes.
ElastiCache runs in private subnets in your account with Multi-AZ replication, as the AWS infrastructure docs describe, and the service gets the connection without a REDIS_URL in an environment file. A coding agent working on the session code sees the keyspace, its key pattern and expiry in one file, and every cache call it writes is checked against those types.
Best fit: TypeScript or Go backends moving to their own AWS account that want the cache declared in code, running locally and provisioned as ElastiCache alongside the rest of the app.
Without Encore, create an ElastiCache replication group, or a serverless cache, in private subnets with a subnet group, a security group that allows port 6379 from the app, encryption in transit, and an auth token or RBAC users stored in Secrets Manager. The app switches from the Upstash REST client to ioredis with tls: {} and the token, and every redis.get call that returned parsed JSON from the REST client now returns a string you parse yourself.
The copy script runs somewhere that can reach both sides, which in practice means a task inside the VPC, since the cache has no public endpoint. It connects to Upstash with a TCP Redis client over TLS, pages through SCAN 0 MATCH session:* COUNT 200, and for each key runs GET and PTTL on Upstash and SET key value PX ttl NX on ElastiCache, where NX plays the same role as setIfNotExists. Run it, switch the app's connection settings, run it again and remove the Upstash credentials. Locally, developers point ioredis at a Redis container without TLS, which is one more difference between laptops and production to keep in mind.
Best fit: teams that already manage ElastiCache in Terraform and want the cache configuration in their own modules.
Use Encore when the backend is moving into your own AWS account anyway and you want ElastiCache declared next to the code that uses it, with a local Redis from encore run and the copy job as a temporary endpoint in the same service. Build ElastiCache by hand when your team keeps caches in Terraform. In both cases, copy only what users would miss, keep TTLs, and let counters expire.
Inventory the keys with SCAN and group them by prefix, decide which groups are worth copying, and copy those with GET and PTTL so each key keeps its remaining lifetime. Rate limit counters and short-lived caches are usually left to expire, and the copy is run again before the cutover to pick up keys created in between.
ElastiCache speaks the Redis protocol over TCP inside your VPC, so code using an HTTP client such as @upstash/redis has to move to a TCP Redis client such as ioredis or node-redis. The commands map one to one for typical cache, session and counter use.
Read the remaining time with PTTL, which returns milliseconds, -1 for keys without an expiry and -2 for keys that no longer exist, and write the copy with SET and a PX or EXPIRE of that remaining lifetime. Skip keys whose PTTL is -2, because they have already expired.
Declare a cluster with new CacheCluster("app", { evictionPolicy: "noeviction" }) and a keyspace such as new StructKeyspace<{ id: string }, Session>(cluster, { keyPattern: "session/:id" }). encore run provides an in-memory Redis locally, and deploys to AWS provision ElastiCache in your account with the connection wired into the service.
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.