Custom server quick start
Set up a self-hosted Hot Updater server and connect a React Native app.
What you will build
This guide builds a server with Hono, SQLite and R2 storage, then connects a React Native app. For an existing backend, use the adapter and framework recipes.
Before you start
Use separate server and React Native project directories. Install a supported Node.js version from Install packages, and prepare an R2 bucket with S3-compatible credentials through your existing Cloudflare tooling. An agent can discover or create these resources within your setup request. Keep credentials in ignored local configuration.
The examples use Bare for the React Native build. For Expo or Rock, install and use the matching build adapter in that project before running Hot Updater commands.
Installation
Install the server dependencies in your server project.
npm install @hot-updater/server @hot-updater/aws hono @hono/node-server kysely better-sqlite3npm install --save-dev hot-updater @types/better-sqlite3 tsxIn your React Native project, install the client and CLI dependencies. The CLI
configuration imports the server's plugins from hot-updater/plugins:
npm install @hot-updater/react-nativenpm install --save-dev @hot-updater/bare @hot-updater/aws @hot-updater/standalone hot-updaterDatabase setup
Create the database connection file.
mkdir -p dataimport { Kysely, SqliteDialect } from "kysely";
import Database from "better-sqlite3";
export const db = new Kysely<object>({
dialect: new SqliteDialect({
database: new Database("./data/database.db"),
}),
});Define the server
Create the Hot Updater instance with your database adapter, storage adapter
(see Storage adapters), and server
plugins. The server mounts its handlers, and the db and api-key commands
below load this file.
import { existsSync } from "node:fs";
import { createHotUpdater } from "@hot-updater/server";
import { kyselyAdapter } from "@hot-updater/server/adapters/kysely";
import { apiKeys, insights } from "@hot-updater/server/plugins";
import { s3Storage } from "@hot-updater/aws";
import { db } from "./kysely";
if (existsSync(".env.hotupdater")) {
process.loadEnvFile(".env.hotupdater");
}
export const hotUpdater = createHotUpdater({
database: kyselyAdapter({ db, provider: "sqlite" }),
plugins: [insights(), apiKeys()],
storage: s3Storage({
region: "auto",
endpoint: process.env.R2_ENDPOINT,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
bucketName: process.env.R2_BUCKET_NAME!,
}),
});createHotUpdater mounts update-check routes and /version. The two
server plugins add the rest of this setup:
apiKeys()requires an API key on update checks, artifact resolution, Insights ingestion, and plugin client routes;/versionstays public, and devices download bundle files from the presigned URLs that artifact resolution returns. It stores each key's SHA-256 digest in the database.insights()records the reports that React Native apps send with the Insights client plugin and serves the Insights reads on the admin handler. Without it, the server has no Insights routes, and they answer404.
Admin routes used by the CLI standaloneRepository live on a separate handler;
mount that handler only behind framework authentication.
Client routes need exactly one access policy: a plugin that authenticates
clients, such as apiKeys(), or clientAccess: "public". createHotUpdater
refuses a configuration with neither or both, so the policy is never selected
implicitly.
Server setup
Create the server entry point.
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { bearerAuth } from "hono/bearer-auth";
import { hotUpdater } from "./hotUpdater";
const app = new Hono();
const adminToken = process.env.HOT_UPDATER_ADMIN_TOKEN;
if (!adminToken) {
throw new Error("HOT_UPDATER_ADMIN_TOKEN is required");
}
app.use(
"/hot-updater/admin/*",
bearerAuth({ token: adminToken }),
);
app.mount("/hot-updater/admin", hotUpdater.handlers.admin);
app.mount("/hot-updater", hotUpdater.handlers.client);
serve({ fetch: app.fetch, port: 3000 }, (info) => {
console.log(`Server running at http://localhost:${info.port}`);
});Security settings
The recommended self-hosted setup runs apiKeys(), which requires an API key
for update-check and Insights ingestion routes. Always protect admin routes
separately before mounting handlers.admin.
Update-check routes are usually exposed for React Native clients, but that is a deployment choice:
/hot-updater/release-catalogs/*/hot-updater/artifacts/*
The admin routes under /hot-updater/admin/* are used by the CLI. They exist
only where handlers.admin is explicitly mounted, and your framework
middleware must protect every request before that mount. The server example
above uses Bearer authentication for /hot-updater/admin/*.
Create .env.hotupdater with the storage credentials and admin token used by
this example. Add this file to .gitignore:
R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=your-access-key-id
R2_SECRET_ACCESS_KEY=your-secret-access-key
R2_BUCKET_NAME=your-bucket-name
HOT_UPDATER_ADMIN_TOKEN=your-secret-auth-tokenBootstrap the schema and API key
Generate the SQL for review, then apply the database schema:
npx hot-updater db generate src/hotUpdater.ts --yes
npx hot-updater db migrate src/hotUpdater.tsdb generate writes the SQL to hot-updater_migrations/. db migrate creates
core's tables and the tables of insights() and apiKeys(), then writes the
settings rows the server checks before its first request. Until they exist,
the handlers answer 503.
Then create the API key for your mobile app through the apiKeys() plugin of
the same createHotUpdater configuration:
npx hot-updater api-key create --name "Mobile app" src/hotUpdater.tsCopy the plaintext API key from the command output now. Hot Updater persists
only its hash and cannot show the plaintext again. Store the key in your
existing React Native build-time configuration, do not commit it to source,
and do not reuse it as an admin credential. The server's .env.hotupdater file
is not loaded into the React Native app automatically.
Run server
Start your server.
npx tsx src/index.tsThe server runs at http://localhost:3000/hot-updater.
CLI configuration
Configure your Hot Updater CLI to connect to this self-hosted server.
Create this configuration in the React Native project. It mirrors the server:
the same storage adapter, the server's admin handler through
standaloneRepository as its database, and the server's plugins. Its
.env.hotupdater must contain the same R2_* storage values and
HOT_UPDATER_ADMIN_TOKEN as the server, and must also be excluded from Git.
import { existsSync } from "node:fs";
import { bare } from "@hot-updater/bare";
import { s3Storage } from "@hot-updater/aws";
import { standaloneRepository } from "@hot-updater/standalone";
import { defineConfig } from "hot-updater";
import { apiKeys, insights } from "hot-updater/plugins";
if (existsSync(".env.hotupdater")) {
process.loadEnvFile(".env.hotupdater");
}
const adminToken = process.env.HOT_UPDATER_ADMIN_TOKEN;
if (!adminToken) {
throw new Error("HOT_UPDATER_ADMIN_TOKEN is required");
}
export default defineConfig({
build: bare({ enableHermes: true }),
storage: s3Storage({
region: "auto",
endpoint: process.env.R2_ENDPOINT,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
bucketName: process.env.R2_BUCKET_NAME!,
}),
database: standaloneRepository({
baseUrl: "http://localhost:3000/hot-updater/admin",
commonHeaders: {
Authorization: `Bearer ${adminToken}`,
},
}),
plugins: [insights(), apiKeys()],
updateStrategy: "appVersion",
});The CLI storage adapter must match one entry in the server's
createHotUpdater({ storage }) list, and plugins must list the server's
plugins. Both use s3Storage, insights(), and apiKeys() here; see
CLI Configuration.
React Native setup
Before a release-build OTA test, expose the server through a reachable HTTPS endpoint using your existing deployment or HTTPS development endpoint. The local HTTP URL above is suitable for server and CLI smoke checks; do not assume a mobile release binary permits cleartext HTTP.
Then follow Set up your app to configure the SDK and native bundle loaders with these values:
| Setting | Value |
|---|---|
| Client base URL | Your reachable HTTPS endpoint ending in /hot-updater |
| Client header | x-api-key |
| Client API key | The key created by api-key create above |
| CLI/admin URL | http://localhost:3000/hot-updater/admin; keep its bearer token out of the app |
For local connectivity checks, the Android Emulator reaches the host at
10.0.2.2 and the iOS Simulator on the same Mac can use localhost. This only
changes host routing; the release-build OTA test uses the HTTPS endpoint.
The server's .env.hotupdater is not loaded into the mobile app automatically.
Unauthenticated alternative
If you intentionally want client update and Insights ingestion routes to be
unauthenticated, remove apiKeys() and set clientAccess: "public":
export const hotUpdater = createHotUpdater({
database: kyselyAdapter({ db, provider: "sqlite" }),
plugins: [insights(), apiKeys()],
plugins: [insights()],
clientAccess: "public",
storage: s3Storage({ /* ... */ }),
});Remove apiKeys() from plugins in the React Native project's
hot-updater.config.ts too. Then omit x-api-key from React Native
requestHeaders, and skip api-key create. This does not make the admin
handler public; its framework authentication remains required.
Verify and deploy
- Verify the CLI connection to the protected admin handler.
- Deploy with your existing process or the Docker or Vercel recipe, or expose your existing HTTPS development endpoint. Update the CLI and mobile app to their respective verified admin and client URLs.
- Test an OTA update in a native release build against that HTTPS endpoint, including application and restart of the changed code.