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.
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.
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}'
| Value | Behavior |
|---|---|
false (default) | Old actors keep running. New actors go to the new version. Versions coexist. |
true | Old 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.
SQLite (recommended for complex schemas)
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
onSleephook 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
| Timeout | Default | Description | Configuration |
|---|---|---|---|
sleepGracePeriod | 15s | Total graceful sleep budget for onSleep, waitUntil, keepAwake, and async raw WebSocket handlers | Actor options |
envoy_lost_threshold | 40s | How long the control plane waits before rescheduling the actors of a node that disappeared without stopping | Control plane config (pegboard.envoy_lost_threshold) |
Related
- Nodes & Node Pools: Pool settings and connecting nodes
- Lifecycle: Actor lifecycle hooks including
onSleep