Skip to main content
Operate

Versions & Upgrades

Version node deployments, route new Rivet Actors to current code, and drain older nodes without downtime.

How Versions Work

Each node has a version number. When you deploy new code with a new version, Rivet handles the transition automatically:

  • New actors go to the newest version: When allocating actors, Rivet always prefers nodes with the highest version number
  • Multiple versions can coexist: Old actors continue running on old versions while new actors are created on the new version
  • Drain old actors: When enabled, a node connecting with a newer version number gracefully stops old actors so they are rescheduled to the new version
  • Versions only go up: Deploying a lower version number never moves allocation back to it. To undo a bad deploy, redeploy the previous code with a new, higher version number.

Versions are not configured by default. See Setting the Version to configure the node version.

Example Scenario

Drain Enabled

When a new version is deployed, existing actors are gracefully stopped on the old node and rescheduled onto the new version.

Node v1Node v2Currently runningDeployedDrain old actorsReschedule actorsShut down when all actors stopped

Drain Disabled

When a new version is deployed, both versions coexist. New actors are created on the new version while existing actors continue running on the old version until they sleep.

Node v1Node v2Currently runningDeployedActor 1 sleeps from inactivityActor 1 wakes up when prompted

Configuration

Setting the Version

Configure the node version with an environment variable or in your registry setup:

The version must be set at build time, not at runtime. Do not use Date.now() or similar runtime values in your registry setup code. This would assign a different version every time the server starts, causing actors to be drained and rescheduled on every restart instead of only on new deployments.

The version must increase on every deployment. Never roll a deployment back to an older version number. New actors keep going to the nodes still on the higher version, so as the rollback replaces them the set of nodes accepting actors shrinks until it reaches zero and nothing can be scheduled. Allocation only recovers once every higher-version node has disconnected and expired. To undo a bad deploy, redeploy the previous code with a new, higher version number.

Example Configurations

We recommend injecting a build-time value that increments with every deployment. Here are concrete examples for common setups:

Dockerfile

Generate the version at build time and bake it into the image as an environment variable:

docker build --build-arg RIVET_ENVOY_VERSION=$(date +%s) .
FROM node:24-slim
ARG RIVET_ENVOY_VERSION
ENV RIVET_ENVOY_VERSION=$RIVET_ENVOY_VERSION
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["node", "dist/server.js"]

All containers from this image will share the same version.

Next.js

Set the version in next.config.ts. Next.js evaluates this file once at build time and inlines the value into the bundle:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  env: {
    RIVET_ENVOY_VERSION: String(Math.floor(Date.now() / 1000)),
  },
};

export default nextConfig;

Vite

Use define in your Vite config. This is evaluated once at build time and inlined into the bundle:

import { defineConfig } from "vite";

export default defineConfig({
  define: {
    "process.env.RIVET_ENVOY_VERSION": JSON.stringify(
      String(Math.floor(Date.now() / 1000))
    ),
  },
});

CI/CD

Set the version from your CI pipeline:

# GitHub Actions
env:
  RIVET_ENVOY_VERSION: ${{ github.run_number }}
# Railway / Render / generic CI
export RIVET_ENVOY_VERSION=$(date +%s)
# Git commit count
export RIVET_ENVOY_VERSION=$(git rev-list --count HEAD)

Build Script

Generate a version file during your build step and import it:

{
  "scripts": {
    "build": "echo \"export const BUILD_VERSION = $(date +%s);\" > src/build-version.ts && tsc"
  }
}
import { actor, setup } from "rivetkit";
import { BUILD_VERSION } from "./build-version";

const myActor = actor({ state: {}, actions: {} });

const registry = setup({
  use: { myActor },
  envoy: {
    version: BUILD_VERSION,
  },
});

Drain on Version Upgrade

The drain_on_version_upgrade setting on a node pool controls whether old actors are stopped when nodes with a newer version connect. Set it when you create or update the pool. See Nodes & Node Pools for every pool setting, including how to rate-limit actor eviction during the drain.

curl -X PUT "$RIVET_API/pools/default?namespace=default" \
  -H "Authorization: Bearer $RIVET_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"drain_on_version_upgrade": true}'
ValueBehavior
false (default)Old actors keep running. New actors go to the new version. Versions coexist.
trueOld actors sleep, saving their state, and wake on a node running the new version.

Upgrading Actor State

When you deploy a new version, existing actors may need to handle schema changes in their persisted data.

Drizzle (recommended)

Use Drizzle for typed schemas with generated migrations. Drizzle generates versioned .sql migration files from your TypeScript schema and applies them in order automatically. This is the recommended approach when your schema evolves frequently.

Raw SQL

For actors using raw SQLite, use migrations to apply numbered SQL steps in order. Give each schema its own migration table. RivetKit runs the returned onMigrate hook inside a SQLite savepoint, so the migration ladder is atomic:

import { actor, setup } from "rivetkit";
import { db } from "rivetkit/db";
import { migrations } from "rivetkit/unstable/migrations";

const migrateTodos = migrations({
	tableName: "todos_schema_version",
	migrations: [
		{
			version: 1,
			sql: `
				CREATE TABLE todos (
					id INTEGER PRIMARY KEY AUTOINCREMENT,
					title TEXT NOT NULL
				)
			`,
		},
		{
			version: 2,
			sql: `
				ALTER TABLE todos
				ADD COLUMN completed INTEGER NOT NULL DEFAULT 0
			`,
		},
	],
});

const todoList = actor({
	db: db({ onMigrate: migrateTodos }),
	actions: {
		addTodo: async (c, title: string) => {
			await c.db.execute("INSERT INTO todos (title) VALUES (?)", title);
		},
	},
});

const registry = setup({ use: { todoList } });
registry.start();

In-memory state (c.state)

If you use c.state for persistence, you are responsible for handling schema changes yourself. If you add, remove, or rename fields between versions, your code must handle the old shape gracefully.

Manual defaults in onWake

Apply defaults for missing fields:

import { actor, setup } from "rivetkit";

const myActor = actor({
	state: { count: 0, label: "" },
	onWake: (c) => {
		// Added in v2. Old actors won't have this field.
		c.state.label ??= "default";
	},
	actions: {
		getLabel: (c) => c.state.label,
	},
});

const registry = setup({ use: { myActor } });
registry.start();

Zod schema coercion

Use Zod to parse persisted state on wake. Zod’s .default() fills in missing fields automatically, so old actor state is coerced to the current schema:

import { actor, setup } from "rivetkit";
import { z } from "zod";

const stateSchema = z.object({
	count: z.number().default(0),
	label: z.string().default("default"), // Added in v2
});

type State = z.infer<typeof stateSchema>;

const myActor = actor({
	state: { count: 0, label: "default" } as State,
	onWake: (c) => {
		Object.assign(c.state, stateSchema.parse(c.state));
	},
	actions: {
		getLabel: (c) => c.state.label,
	},
});

const registry2 = setup({ use: { myActor } });
registry2.start();

For anything beyond simple defaults, consider moving to SQLite where you get proper migration tooling.

Advanced

How Version Upgrade Detection Works

When drain_on_version_upgrade is enabled and a node connects with a newer version number, the control plane drains every older node in the same pool.

SIGTERM Handling

When a node process receives SIGTERM, it gracefully stops all of its actors before exiting:

  • Each actor’s onSleep hook runs, giving it time to save state.
  • Actors are rescheduled to other available nodes.
  • If the process is killed before its actors finish stopping, for example with SIGKILL, the control plane reschedules them once the node is considered lost.

Give each node at least 60 seconds between SIGTERM and SIGKILL.

Shutdown Timeouts

TimeoutDefaultDescriptionConfiguration
sleepGracePeriod15sTotal graceful sleep budget for onSleep, waitUntil, keepAwake, and async raw WebSocket handlersActor options
envoy_lost_threshold40sHow long the control plane waits before rescheduling the actors of a node that disappeared without stoppingControl plane config (pegboard.envoy_lost_threshold)