OpenMDM

Installation

Install OpenMDM, set up your database schema, and create your first MDMInstance.

Installation

This page gets you from zero to a working MDMInstance you can call methods on. The next page (Quick Start) wires it into a running HTTP server.

Prerequisites

  • Node.js 20+ (or Bun 1.1+, or Deno 1.40+).
  • A database supported by Drizzle ORM — Postgres, MySQL, or SQLite. All examples here use Postgres.
  • A push provider. For public fleets with Google Play Services, use FCM (a Firebase project and a service-account JSON). For private networks or GMS-less devices, use MQTT with your own broker.

OpenMDM does not need a Firebase or FCM setup during development if you use MQTT or skip push entirely — but a push provider is mandatory for any real fleet, because commands are delivered via push wake-up.

Install the packages

The minimum set:

pnpm add @openmdm/core @openmdm/drizzle-adapter @openmdm/hono @openmdm/push-fcm
pnpm add drizzle-orm postgres hono

drizzle-orm and postgres are peer-like deps that live in your app, not ours. hono is the HTTP framework — if you're already on Hono, you already have it.

If you're using MQTT instead of FCM:

pnpm add @openmdm/push-mqtt

If you want APK hosting via presigned URLs:

pnpm add @openmdm/storage-s3

Generate the database schema

OpenMDM ships its schema as a Drizzle-compatible definition you can generate into your project. The CLI writes it to a file you manage with drizzle-kit:

npx openmdm generate --adapter drizzle --provider pg --output ./src/db/schema.ts

Then run Drizzle's normal migration workflow:

npx drizzle-kit generate
npx drizzle-kit migrate

This creates the mdm_devices, mdm_policies, mdm_commands, mdm_events, mdm_groups, mdm_applications, mdm_push_tokens, and mdm_app_deployments tables in your existing database, alongside your app's own tables.

Why no separate database? That's the whole point. OpenMDM writes into your Postgres so device state, user state, and business data live in one transactional world.

Create the MDM instance

Create one file — convention is src/mdm.ts — that exports a single configured MDMInstance:

src/mdm.ts
import { createMDM } from '@openmdm/core';
import { drizzleAdapter } from '@openmdm/drizzle-adapter';
import { mdmSchema } from '@openmdm/drizzle-adapter/postgres';
import { fcmPushAdapter } from '@openmdm/push-fcm';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

const client = postgres(process.env.DATABASE_URL!);
const db = drizzle(client, { schema: mdmSchema });

export const mdm = createMDM({
  database: drizzleAdapter(db, {
    tables: {
      devices: mdmSchema.mdmDevices,
      policies: mdmSchema.mdmPolicies,
      applications: mdmSchema.mdmApplications,
      commands: mdmSchema.mdmCommands,
      events: mdmSchema.mdmEvents,
      groups: mdmSchema.mdmGroups,
      deviceGroups: mdmSchema.mdmDeviceGroups,
      pushTokens: mdmSchema.mdmPushTokens,
      appVersions: mdmSchema.mdmAppVersions,
      rollbacks: mdmSchema.mdmRollbacks,
      // Required for plugins that need cross-instance persistence
      // (the kiosk plugin's lockout counters are the main example).
      pluginStorage: mdmSchema.mdmPluginStorage,
    },
  }),

  push: fcmPushAdapter({
    credentialPath: './firebase-service-account.json',
    // Data-only messages keep the agent in control of how the
    // notification is surfaced — important for silent commands.
    dataOnly: true,
  }),

  enrollment: {
    // HMAC secret used to sign enrollment payloads. Must match the
    // secret compiled into the Android agent build. Rotate by
    // rebuilding the agent and the server in lockstep.
    deviceSecret: process.env.DEVICE_SECRET!,
    autoEnroll: true,
  },

  auth: {
    // Secret used to sign device-facing JWTs after enrollment.
    deviceTokenSecret: process.env.JWT_SECRET!,
  },

  // Persist plugin state through the database adapter. Required for
  // plugins that track stateful per-device data (kiosk lockout
  // counters, geofence zone dwell, etc.). Without this, plugins fall
  // back to in-memory state that is lost on restart and silently
  // breaks horizontal scaling.
  pluginStorage: { adapter: 'database' },

  serverUrl: process.env.SERVER_URL, // e.g. "https://mdm.example.com"
});

That's the whole SDK boot. mdm is an object with mdm.devices, mdm.policies, mdm.commands, mdm.apps, mdm.groups, mdm.push, mdm.on(...), and more. You import it anywhere in your app that needs to talk to devices.

Environment variables

.env
# Database
DATABASE_URL=postgres://user:password@localhost:5432/yourapp

# Enrollment HMAC secret — must match the Android agent's compiled-in secret
DEVICE_SECRET=<64-char hex; generate with `openssl rand -hex 32`>

# Device token signing secret (separate from DEVICE_SECRET by design)
JWT_SECRET=<64-char hex; generate with `openssl rand -hex 32`>

# Public base URL of your backend
SERVER_URL=https://mdm.example.com

# Firebase (if using FCM)
# OR provide credentialPath to a service-account JSON

Two secrets, not one. DEVICE_SECRET signs enrollment payloads (proves a device is allowed to enroll). JWT_SECRET signs the device tokens returned after successful enrollment (proves a subsequent request is from an enrolled device). Rotating JWT_SECRET invalidates all device sessions but does not force re-enrollment; rotating DEVICE_SECRET requires rebuilding and redeploying the Android agent.

Verify the install

Create a throwaway script to confirm the instance is healthy:

scripts/verify-mdm.ts
import { mdm } from '../src/mdm';

const { devices, total } = await mdm.devices.list({ limit: 1 });
console.log(`OpenMDM is alive. ${total} devices enrolled.`);
process.exit(0);
pnpm tsx scripts/verify-mdm.ts

If it prints a device count (even zero), you're good.

Next

  • Quick Start — mount the HTTP routes, enroll a test device, send your first command.
  • Architecture — how enrollment, heartbeats, and commands actually flow between your server and the device.