Skip to content
In this postPostgreSQLValkeyRedislibSQL

Deno Deploy is shutting down. Where should your Deno KV data go?

10 min readDenoDatabasesPostgresCloudflare

Moving an application off Deno Deploy starts with a hosting decision. If that application uses Deno KV, it also needs a data migration plan. A cache, a rate limiter, and a username reservation may all use keys and values, but they need different guarantees from their replacement.

Start by identifying those guarantees. Then move a small, representative dataset and test the application against it. You can choose the database separately from where you host the application.

Layerbase Cloud hosts Postgres, Valkey, Redis, and LibSQL. Those are options below, alongside Workers KV, Durable Objects, and self-hosted denokv. Layerbase does not host denokv or provide a Deno KV importer. The worked migration here is a script you adapt to your own data.

What changes, and when?

Facts checked on October 16, 2026. In its October 9 announcement, Deno says the team is joining Cloudflare and sets out these changes:

  • Deno Deploy will continue operating for six months before shutting down. That puts the planning window around April 2027; the announcement does not give an exact shutdown date.
  • The team will maintain the Deno runtime for another year, with monthly bug fixes and security updates, then end its development of the runtime. The code remains open source.
  • JSR will continue operating, with its infrastructure moving to Cloudflare.
  • Paying Deploy customers will receive migration support for moving to Workers.

The announcement does not separately describe what happens to hosted KV data, its export arrangements, or its retention period. It is not evidence that the Deno KV API or every self-hosted KV database is shutting down. It is enough reason to inventory your hosted data and rehearse a migration while you still have access.

Sort your KV usage before choosing a replacement

Search your application for openKv, get, list, atomic, expireIn, watch, and queue operations. Group the results by what would break if a value were stale, missing, or written twice.

What you storeWhere to startWhat to preserve
Rebuildable cache, read-mostly configurationWorkers KV or ValkeyAcceptable staleness, expiration, cache misses
Counters, rate limits, short-lived stateValkey or RedisAtomic updates, expiration, numeric limits
Users, purchases, unique claims, related recordsPostgresUniqueness, transactions, durable records
A small application suited to SQLiteLibSQLSchema, SQL transactions, HTTP access
Extensive dependence on Deno KV APIsEvaluate self-hosted denokvAPI behavior, backups, operational ownership

Not every key needs moving. A cache you can rebuild may be safer to warm from its source. An expired rate-limit window has little value in the destination. User records, ownership claims, and financial state need a verified migration.

Also inventory tuple-key prefixes and secondary indexes. A key such as ["users", "123"] is not automatically interchangeable with the string users:123. If you use non-string key parts, choose an encoding that preserves types and cannot collide. Queues and watchers need their own replacement design; exporting stored keys does not migrate pending jobs or subscriptions.

An honest look at the alternatives

Workers KV: useful when stale reads are acceptable

Workers KV fits globally distributed, read-heavy data. It is eventually consistent and does not provide Deno KV's check-and-set transactions. Moving an atomic().check().set() sequence into separate reads and writes can introduce races even when the happy-path result looks identical. Cloudflare's consistency documentation explicitly directs workloads needing stronger guarantees toward Durable Objects.

Durable Objects: coordinated state within an object

Each Durable Object has private, transactional, strongly consistent storage. That can suit a room, account, reservation system, or another natural unit of coordination. The migration includes deciding which state belongs in which object and rewriting access through that model. Do not assume one transaction spans unrelated objects. See Cloudflare's storage guide.

Self-hosted denokv: preserve more of the API

The denokv project provides a self-hosted backend that Deno can access through Deno.openKv(), with a documented SQLite-backed setup. It is worth evaluating when replacing API calls would dominate the migration.

You take responsibility for hosting, backups, restores, upgrades, and availability. Check current maintenance activity and test the API features you rely on. The existence of open-source code is not a managed-service continuity guarantee.

Postgres, Valkey/Redis, or LibSQL: choose by behavior

I would start with Postgres for durable application records and relationships, and Valkey for caches, counters, and expiring state. Redis is another option for the latter using the commands below. LibSQL is worth considering when SQLite's model fits and you want a network database: its TypeScript client supports HTTP access. It still requires translating KV operations into SQL.

D1 also belongs on a shortlist if you want a Cloudflare-managed SQLite option. Neither D1 nor LibSQL is a drop-in Deno KV API replacement.

With any candidate, compare persistence and recovery guarantees separately from command compatibility. Layerbase's Redis service uses disk snapshots with AOF disabled; an acknowledged write and a completed snapshot are different events. Do not move irreplaceable records into a cache-oriented persistence setup just because both systems accept keys and values. Review the Redis persistence details against your acceptable recovery window.

Map the behavior, not just the method name

Counters and expiration in Valkey or Redis

In a scratch database, these commands increment a counter and create a separate expiring value:

text
INCR pageviews:home
SET session:example user-123 EX 900
TTL session:example

The counter starts at 1 when absent. The session expires after 900 seconds. Deno's expireIn uses milliseconds, so translate units deliberately. INCR uses signed 64-bit integers; Deno KV's KvU64 has unsigned semantics. Check ranges and overflow behavior before moving numeric state. See Valkey INCR and Deno key expiration.

A rate limiter also needs its increment and expiration to be coordinated. For a fixed-window design, use a key that includes the window identifier and execute this on one connection:

text
MULTI
INCR rate:user-123:window-001
EXPIRE rate:user-123:window-001 120
EXEC

Here, window-001 stands for an application-computed minute bucket; the two-minute TTL cleans up old buckets. Read the increment result from EXEC to decide whether to allow the request. Do not reuse one constant key forever: that would change the algorithm. Handle command errors, too; Redis-family transactions do not roll back earlier successful commands after an execution-time error. Valkey transaction semantics

Check-and-set needs a connection and a retry path

For a read-modify-write operation, WATCH detects changes before EXEC. This scratch example succeeds only if settings:revision stays unchanged after the read:

text
SET settings:revision 1
WATCH settings:revision
GET settings:revision
MULTI
SET settings:revision 2
EXEC

Your application computes the replacement from the value it read. If another client changes the watched key, EXEC returns a null result; retry from WATCH with a fresh read and bounded retries. Keep the sequence on one dedicated connection. Independent HTTP requests do not establish that connection state. Redis documents this optimistic-locking pattern.

Unique claims become a database constraint

Suppose your Deno application reserves a username this way, with kv already open:

ts
const key = ['usernames', 'sam']
const result = await kv.atomic()
  .check({ key, versionstamp: null })
  .set(key, 'user-123')
  .commit()

if (!result.ok) throw new Error('Username unavailable. Choose another name.')

In Postgres, the username becomes a primary key. An insert either claims it or reports a conflict. If creating the user and claiming their name must succeed together, put both writes in a SQL transaction and enforce their relationship with a foreign key. The small example below migrates the claims only. Postgres constraints

Export a small dataset while Deploy is still running

Rehearse against a test source and a new destination database first. This example supports one explicit schema: keys of exactly ["usernames", string], with a nonempty string user ID as the value. These claims must not expire. It preserves case exactly; decide separately whether your application treats Sam and sam as the same username.

Save this as export-claims.ts:

ts
const source = Deno.env.get('KV_SOURCE')
if (!source) throw new Error('Set KV_SOURCE to your KV connection URL or test file.')

const kv = await Deno.openKv(source)
const entries: { username: string; userId: string; versionstamp: string }[] = []

try {
  for await (const entry of kv.list({ prefix: ['usernames'] })) {
    if (
      entry.key.length !== 2 ||
      typeof entry.key[1] !== 'string' || entry.key[1].length === 0 ||
      typeof entry.value !== 'string' || entry.value.length === 0
    ) {
      throw new Error('Unsupported claim. Adapt the schema before exporting.')
    }
    if (entries.length >= 10_000) {
      throw new Error('Example limit exceeded. Use a batched migration for this dataset.')
    }
    entries.push({
      username: entry.key[1],
      userId: entry.value,
      versionstamp: entry.versionstamp,
    })
  }
  if (entries.length === 0) throw new Error('No claims found. Check the source and prefix.')
  await Deno.writeTextFile(
    'claims.json',
    JSON.stringify({ format: 'username-claims-v1', entries }, null, 2),
    { createNew: true, mode: 0o600 },
  )
  console.log(`Exported ${entries.length} claims to claims.json`)
} finally {
  kv.close()
}

Run from a private working directory with KV_SOURCE set to https://api.deno.com/v2/databases/<Database ID>/connect, using the ID shown for the intended database in the Deploy console. For hosted access, set DENO_KV_ACCESS_TOKEN securely as described in Deno's KV connection documentation. Confirm the database and environment; do not export a preview database by mistake. For a local rehearsal, KV_SOURCE can be an existing local KV file.

bash
deno run --unstable-kv --allow-env=KV_SOURCE,DENO_KV_ACCESS_TOKEN --allow-net --allow-read --allow-write export-claims.ts

This is a bounded, in-memory export, not a universal backup tool. It rejects unsupported values instead of silently serializing big integers, binary data, dates, or other structured values. Other prefixes and secondary indexes are outside its scope. A production migration must account for them separately.

kv.list() supplies keys, values, and versionstamps, not the remaining TTL. If records expire, reconstruct deadlines from application data or choose an explicit expiration policy. Versionstamps here are audit metadata; they do not become Postgres versions. See the KV API reference.

Most importantly, a multi-batch list is not a whole-database snapshot. Deno guarantees consistency within a batch, not across batches. For the final export, stop all writers to this dataset, including jobs and administrative paths. Strong reads alone do not solve this. Deno list semantics

Import the claims into Postgres and check the result

Create a new test Postgres database, for example through Layerbase Postgres. Set DATABASE_URL securely to its direct connection string, retaining its TLS settings. Keep credentials out of source files and shell history.

Save this as import-claims.ts. It uses a pinned Postgres.js client and creates a new table inside one transaction. An existing table, duplicate username, or failed insert aborts the import rather than overwriting destination data.

ts
import postgres from 'npm:postgres@3.4.7'

const destination = Deno.env.get('DATABASE_URL')
if (!destination) throw new Error('Set DATABASE_URL to a new test database.')

const data = JSON.parse(await Deno.readTextFile('claims.json'))
if (
  data?.format !== 'username-claims-v1' ||
  !Array.isArray(data.entries) ||
  data.entries.length === 0 || data.entries.length > 10_000
) {
  throw new Error('Invalid export. Re-run export-claims.ts against the intended source.')
}
for (const row of data.entries) {
  if (
    !row || typeof row.username !== 'string' || !row.username ||
    typeof row.userId !== 'string' || !row.userId ||
    typeof row.versionstamp !== 'string' || !row.versionstamp
  ) {
    throw new Error('Invalid claim. Check the export before importing.')
  }
}

const sql = postgres(destination, { max: 1 })
try {
  await sql.begin(async (transaction) => {
    await transaction`
      CREATE TABLE username_claims (
        username text COLLATE "C" PRIMARY KEY CHECK (username <> ''),
        user_id text NOT NULL CHECK (user_id <> ''),
        source_versionstamp text
      )
    `
    for (const row of data.entries) {
      await transaction`
        INSERT INTO username_claims (username, user_id, source_versionstamp)
        VALUES (${row.username}, ${row.userId}, ${row.versionstamp})
      `
    }
    const rows = await transaction`
      SELECT username, user_id, source_versionstamp FROM username_claims
    `
    const expected = new Map<string, { userId: string; versionstamp: string }>(
      data.entries.map((row: { username: string; userId: string; versionstamp: string }) =>
        [row.username, row] as const),
    )
    if (rows.length !== data.entries.length || rows.some((row) => {
      const source = expected.get(row.username)
      return !source || source.userId !== row.user_id ||
        source.versionstamp !== row.source_versionstamp
    })) {
      throw new Error('Verification failed. Investigate the export before retrying.')
    }
  })
  console.log(`Imported and verified ${data.entries.length} claims`)
} finally {
  await sql.end()
}
bash
deno run --allow-env --allow-net --allow-read import-claims.ts

The first run resolves the pinned npm dependency. Run these scripts outside your application repository if you want their dependency files kept separate. Treat claims.json as private user data; do not commit it. After an interrupted export, discard the incomplete file and export again. The exporter deliberately refuses to overwrite an existing file.

Now use the imported table to claim a new username:

sql
INSERT INTO username_claims (username, user_id)
VALUES ('new-name', 'user-456')
ON CONFLICT (username) DO NOTHING
RETURNING username, user_id;

One returned row means success. Zero rows means the name was already claimed. Use parameters for real application input. Run two concurrent requests for the same unused name with different user IDs and verify that exactly one succeeds. Then read the winning owner through the application's new database code. A row count alone does not prove the application behaves correctly.

Cut over the data separately from the application host

For a small dataset where a maintenance window is acceptable:

  1. Rehearse the export, import, row verification, and application reads in a test environment.
  2. Stop all source writers for the final export. Include scheduled work and background consumers.
  3. Export and import into a fresh destination. Check records, uniqueness, and the application paths that use them.
  4. Switch those application paths to the destination, verify them, then resume writes there.
  5. Retain the old source and protected export for your agreed recovery period. Once the destination accepts new writes, switching back requires reconciling those writes.

For a large dataset or an application that cannot pause writes, this script is not a zero-downtime migration strategy. You need a way to capture changes during the copy and reconcile them before cutover.

If your next app host is Cloudflare Workers, follow our Workers with Postgres guide for Hyperdrive configuration. Hyperdrive handles the connection layer; the database can live at Layerbase or another supported Postgres host.

Workers also support outbound TCP and TLS through connect(), with destination restrictions and request-scoped connection lifetimes. That makes Redis protocol access possible in principle, but it does not establish compatibility for every client or endpoint. Test the deployed connection path you intend to use. Cloudflare TCP documentation

Layerbase also documents an Upstash-compatible HTTP endpoint for Redis and Valkey, with a REST URL and token in the Connect dialog when available. That is another connection option for Workers. Basic HTTP command access does not make the connection-bound WATCH example portable across separate requests; verify the operations your application requires. See serverless and edge access.

Where Layerbase fits

Layerbase hosts Postgres, Valkey, Redis, and LibSQL in the same account and database bill, within the selected plan's engine and capacity limits. Application hosting remains a separate decision and cost. Check current plans, including sleep and always-on capacity, against your workload.

For durable application records, start with managed Postgres and rehearse the claims migration in a new database. For counters, caches, and expiring state, explore managed Valkey and test the commands and persistence behavior your application needs.

The next useful step is small: pick one KV prefix, write down its guarantees, and prove you can read it correctly from the destination. That gives you evidence for the larger move while there is still time to change the plan.