HotupdaterHot Updater
Self Hosting (Custom)

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-sqlite3
npm install --save-dev hot-updater @types/better-sqlite3 tsx

In 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-native
npm install --save-dev @hot-updater/bare @hot-updater/aws @hot-updater/standalone hot-updater

Database setup

Create the database connection file.

mkdir -p data
src/kysely.ts
import { 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.

src/hotUpdater.ts
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; /version stays 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 answer 404.

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.

src/index.ts
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-token

Bootstrap 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.ts

db 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.ts

Copy 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.ts

The 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.

hot-updater.config.ts
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:

SettingValue
Client base URLYour reachable HTTPS endpoint ending in /hot-updater
Client headerx-api-key
Client API keyThe key created by api-key create above
CLI/admin URLhttp://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":

src/hotUpdater.ts (excerpt)
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

  1. Verify the CLI connection to the protected admin handler.
  2. 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.
  3. Test an OTA update in a native release build against that HTTPS endpoint, including application and restart of the changed code.

On this page