An Android device management platform built on Google's Android Management API (AMAPI). It lets you manage Android devices - set policies, deploy apps, track locations, run automated workflows, and more - all from a web dashboard.
Flash MDM runs on Netlify with a React frontend and a serverless Node.js backend backed by Postgres.
Before you start, you'll need accounts on the following services (all have free tiers):
| Service | What it's for | Sign up |
|---|---|---|
| Netlify | Hosts the app, runs the backend, provides the database | netlify.com |
| Google Cloud | Connects to Android devices via the Management API | console.cloud.google.com |
| Resend | Sends emails (login links, invitations, alerts) | resend.com |
| Stripe | Handles billing and licence management (optional) | stripe.com |
You'll also need Node.js 20 or newer installed on your computer. You can download it from nodejs.org.
Open a terminal and run:
git clone https://github.com/jasonbayton/flash_mdm.git
cd flash_mdm
npm installThis downloads the code and installs all dependencies.
The app needs several configuration values (API keys, secrets, etc.) to run. These are stored in a file called .env which is never committed to the repository.
cp .env.example .envNow open .env in a text editor and fill in the values. Each variable is explained in the file, and they're also listed in the Environment Variables section below.
On Netlify (recommended): The database is provided automatically when you enable Netlify DB on your site. Migrations (database setup scripts) run automatically on each deploy.
For local development: If you have a local Postgres database, start the app with npx netlify dev and then run migrations via the built-in endpoint:
curl http://localhost:8888/api/migrate -H "x-migration-secret: $MIGRATION_SECRET"# Full app (frontend + backend) - requires the Netlify CLI
npx netlify dev
# Or frontend only (useful for UI work)
npm run devThe app will be available at http://localhost:8888 (full stack) or http://localhost:5173 (frontend only).
- Open the app and register a new account - the first user to register is automatically granted superadmin access.
- After registering, you can optionally enable invite-only registration in superadmin settings to prevent anyone else from self-registering.
Optional: If you want to protect bootstrap registration with a secret (e.g. on a publicly accessible server), set
BOOTSTRAP_SECRETin your.envbefore registering. The first registration will then require anx-bootstrap-secretHTTP header matching that value - which means you'll need to register viacurlor an API client rather than the web UI. RemoveBOOTSTRAP_SECRETafter the first user is created.
- Push your fork to GitHub.
- In Netlify, click "Add new site" > "Import an existing project" and select your repository.
- Netlify will detect
netlify.tomland configure the build automatically. - Go to Site Settings > Environment Variables and add all the variables from your
.envfile. - Enable Netlify DB on your site (Site Settings > Database).
- Trigger a deploy - the database migrations will run automatically.
For a more detailed walkthrough, see the step-by-step deployment guide.
Copy .env.example to .env for local development. On Netlify, set these in Site Settings > Environment Variables.
| Variable | Required | What it does |
|---|---|---|
DATABASE_URL |
Automatic on Netlify | Connection string for your Postgres database |
ENCRYPTION_MASTER_KEY |
Yes | A secret key used to encrypt sensitive data in the database. Generate one by running: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" |
RESEND_API_KEY |
Yes | Your API key from Resend (for sending emails) |
RESEND_FROM_EMAIL |
No | Custom "from" email address (e.g. noreply@yourdomain.com) |
STRIPE_SECRET_KEY |
For billing | Your Stripe secret key (starts with sk_) |
STRIPE_WEBHOOK_SECRET |
For billing | Stripe webhook signing secret (starts with whsec_) |
MIGRATION_SECRET |
Yes | Secret to authenticate the migration endpoint. Generate one: node -e "console.log(require('crypto').randomBytes(16).toString('hex'))" |
INTERNAL_FUNCTION_SECRET |
Yes | Secret for internal function-to-function calls. Generate one the same way as MIGRATION_SECRET |
PUBSUB_SHARED_SECRET |
Recommended | A secret string to authenticate incoming device notifications from Google. Pick any strong random string |
VITE_GOOGLE_MAPS_API_KEY |
For geofencing | A Google Maps JavaScript API key (for the map views) |
URL |
For non-Netlify | The public URL of your deployment (e.g. https://mdm.example.com). Set automatically on Netlify |
BOOTSTRAP_SECRET |
First run only | Temporary secret to create the first admin account (remove after setup) |
This is what connects Flash MDM to your Android devices.
- Go to Google Cloud Console and create a new project (or use an existing one).
- Enable the Android Management API - search for it in the API Library.
- Apply for AMAPI access - after enabling the API, visit the Permissible Usage page and submit the quota request form. You'll need to describe your business case (what you're managing and why). Google reviews these manually and will grant enrolment quotas once approved. You can't enrol devices until this is done.
- Go to IAM & Admin > Service Accounts and create a new service account.
- Give it the Android Management User role.
- Create a JSON key for the service account and download it.
- In Flash MDM, go to Settings and upload the JSON key file. It will be encrypted and stored securely in the database.
Note: The JSON key file is sensitive. Don't place it inside the repository folder - it's listed in
.gitignoreas a safety net, but it's best practice to keep it elsewhere.
If you want to charge for device licences:
- In your Stripe Dashboard, create products and prices for your licensing tiers.
- Go to Developers > Webhooks and add an endpoint pointing to
https://flash-mdm.bayton.org/api/stripe/webhook. - Select the events:
checkout.session.completedand allcustomer.subscription.*events. - Copy the webhook signing secret and set it as
STRIPE_WEBHOOK_SECRET.
PubSub lets Flash MDM receive real-time updates when devices change state (e.g. a device enrolls, a policy is applied, compliance changes).
- In Google Cloud Console, go to Pub/Sub and create a new topic.
- Create a push subscription pointing to
https://flash-mdm.bayton.org/api/pubsub/webhook. - Under the subscription's authentication settings, add an
Authorizationheader with the valueBearer <your PUBSUB_SHARED_SECRET>(using the same secret you set in your environment variables). - In Flash MDM, when creating an environment, paste the full topic name (e.g.
projects/my-project/topics/my-topic) into the PubSub topic field.
flash_mdm/
src/ # Frontend (what users see in the browser)
api/ # Functions that talk to the backend
components/ # UI building blocks
common/ # Shared components (tables, filters, etc.)
dashboard/ # Dashboard widgets
devices/ # Device detail views
policy/ # Policy editor
geofencing/ # Map and geofence management
workflows/ # Workflow builder
licensing/ # Licence management
layouts/ # Page layouts (logged in vs logged out)
pages/ # One file per page/route
stores/ # App state (logged-in user, selected workspace, etc.)
netlify/
functions/ # Backend API endpoints (serverless functions)
_lib/ # Shared backend code (auth, database, encryption, etc.)
migrations/ # Database setup scripts (run in order)
website/ # Optional marketing / landing page (Astro)
Run these from the project root:
npm run dev # Start the frontend dev server
npm run build # Build for production
npm run test # Run all tests
npm run test:watch # Run tests and re-run when files change
npm run test:coverage # Run tests with coverage report
npm run lint # Check code style
npm run openapi:check # Confirm generated API documentation is currentFlash MDM is organised around workspaces, environments, and groups:
Workspace (your organisation)
└── Environment (a Google Cloud project with AMAPI enabled)
└── Group (folders for organising devices - can be nested)
├── Devices
└── Policies (rules applied to devices)
Key concepts:
- Policy components - reusable policy building blocks that can be assigned at any level in the hierarchy and are combined automatically by priority.
- Roles - four permission levels (workspace owner, admin, operator, viewer) that control what each user can do.
- Authentication - password login with optional two-factor authentication (TOTP), plus email magic links.
- Encryption - credentials, API keys, and other secrets are encrypted before database storage. Public Wi-Fi CA certificates are stored in the configured blob store and never include private keys.
All API endpoints live under /api/ and are documented with Swagger. Once the app is running, visit /api-docs for interactive API documentation.
| Path prefix | What it does |
|---|---|
/api/health |
Public readiness and deployed-version check |
/api/auth/* |
Login, registration, sessions, two-factor auth |
/api/workspaces/* |
Workspace management and user invitations |
/api/environments/* |
Environment setup and AMAPI connection |
/api/groups/* |
Device group hierarchy |
/api/devices/* |
Device listing, details, and commands |
/api/policies/* |
Policy creation, editing, and version history |
/api/components/* |
Policy component management |
/api/apps/* |
App search, details, and deployment |
/api/enrollment/* |
Enrolment token and QR code generation |
/api/certificates/* |
Wi-Fi trusted CA library used by network policy generation |
/api/workflows/* |
Automated workflow configuration |
/api/geofences/* |
Geofence boundaries and triggers |
/api/licenses/* |
Licence status and assignment |
/api/stripe/* |
Billing checkout and webhooks |
/api/dashboard/* |
Dashboard statistics |
/api/audit/* |
Activity audit log |
/api/superadmin/* |
Platform administration |
The website/ folder contains a standalone Astro site - a marketing / landing page for Flash MDM. It's entirely optional and isn't required to run the platform.
If you'd like to deploy it:
- In Netlify, create a separate site (don't add it to the main Flash MDM site).
- Set the base directory to
website/in the site's build settings. - Netlify will pick up
website/netlify.tomlautomatically - build command and publish directory are already configured. - Deploy. That's it.
For local development:
cd website
npm install
npm run devIf you don't need a landing page, you can safely ignore or delete the website/ folder - nothing else in the project depends on it.
Flash MDM is built on Netlify, but the backend code is largely platform-agnostic. Every API handler uses the standard web Request/Response API rather than a Netlify- or Express-specific format, which means the core logic runs on any Node.js-compatible runtime with relatively little adaptation.
The fastest way to deploy on a VPS is the one-line installer. It installs all system dependencies, sets up PostgreSQL, builds the frontend, configures Caddy with auto-TLS, creates a systemd service, runs migrations, and sets up cron jobs:
curl -fsSL https://raw.githubusercontent.com/baytonorg/flash_mdm/main/install.sh | bashThe script will interactively ask for your domain, database credentials, Resend API key (required for email delivery), and optional service keys (Stripe, Google Maps, etc.).
On an existing installation, the installer preserves .env and its encryption/database credentials, validates database connectivity, builds a versioned candidate release, and switches the current symlink only after the candidate is ready. Failed activation restores the previous code release. Database migrations are forward-only, so production upgrades should still be preceded by a database and blob backup.
For non-interactive/scripted deployments, set environment variables before running:
export FLASH_DOMAIN=mdm.example.com
export FLASH_DB_PASS=your-db-password
export FLASH_RESEND_API_KEY=re_xxxxx
export FLASH_RESEND_FROM="Flash MDM <noreply@example.com>"
export FLASH_REPO_URL=https://github.com/baytonorg/flash_mdm.git
curl -fsSL https://raw.githubusercontent.com/baytonorg/flash_mdm/main/install.sh | bashAll FLASH_* variables are optional - the script will prompt for any that aren't provided. See the full list of supported variables in install.sh.
For optional deploy-key-backed webhook VPS releases, see VPS webhook deployment. It is disabled unless FLASH_AUTO_DEPLOY=true and FLASH_AUTO_DEPLOY_WEBHOOK_SECRET are set.
If you prefer to deploy manually, or want to understand what the installer does, read on.
Only three things tie the backend to Netlify:
| Dependency | Where | What it does |
|---|---|---|
@netlify/functions |
Handler type imports | Provides the Context type - most handlers ignore it (_context) |
@netlify/blobs |
netlify/functions/_lib/blobs.ts |
Key-value file storage used for report exports |
netlify.toml |
Project root | Routing rules, security headers, and redirect configuration |
Everything else - database access, encryption, authentication, RBAC, rate limiting - uses standard Node.js libraries (pg, crypto, etc.) with no platform lock-in.
On Ubuntu/Debian:
# Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
# PostgreSQL
sudo apt-get install -y postgresql postgresql-contrib
# Caddy (or nginx)
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update && sudo apt-get install -y caddy
# Build tools (needed for native npm packages)
sudo apt-get install -y build-essentialEach file in netlify/functions/ maps to one API route. On a VPS you need a lightweight server to wire them up. Install Hono and tsx (for running TypeScript directly):
npm install hono @hono/node-server dotenv tsxCreate a server.ts in the project root. Each handler exports a default async function that accepts (Request, Context) and returns a Response.
Important: The routes must match the frontend-facing URLs defined in the [[redirects]] section of netlify.toml, not the function filenames. For example, the frontend calls /api/auth/register (with slashes), which Netlify rewrites to /.netlify/functions/auth-register. Your Hono routes must use the slash-based paths:
// server.ts
import 'dotenv/config';
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import authLogin from './netlify/functions/auth-login.js';
import authRegister from './netlify/functions/auth-register.js';
import deviceList from './netlify/functions/device-list.js';
import deviceGet from './netlify/functions/device-get.js';
// ... import each handler (~90 files)
// Wrap each Netlify handler for Hono. Catches Response objects thrown by
// auth/RBAC guards and returns them as normal responses.
const h = (handler: Function) => async (c: any) => {
try {
return await handler(c.req.raw, {} as any);
} catch (e) {
if (e instanceof Response) return e;
throw e;
}
};
const app = new Hono();
// Routes must match netlify.toml [[redirects]] "from" paths exactly
app.all('/api/auth/login', h(authLogin));
app.all('/api/auth/register', h(authRegister));
app.all('/api/devices/list', h(deviceList));
app.all('/api/devices/*', h(deviceGet)); // catch-all after specific routes
// ... one line per redirect rule
// SPA fallback - serve the built frontend
app.use('/*', serveStatic({ root: './dist' }));
app.get('*', serveStatic({ root: './dist', path: '/index.html' }));
serve({ fetch: app.fetch, port: 3000 });There are roughly 120 redirect rules in netlify.toml to replicate. Order matters - specific routes must come before catch-all wildcards (e.g. /api/devices/list before /api/devices/*). Use app.all() since the handlers themselves check HTTP methods internally.
Important: The
URLenvironment variable must be set to your public URL (e.g.https://mdm.example.com). Behind a reverse proxy, the request URL seen by handlers ishttp://localhost:3000, but the browser sendsOrigin: https://mdm.example.com. The origin-check middleware usesURLto validate same-origin requests - without it, all mutating requests will fail with a 500.
The file netlify/functions/_lib/blobs.ts wraps @netlify/blobs in five simple functions (storeBlob, getBlob, getBlobJson, deleteBlob, listBlobs). Replace this single file with an equivalent backed by:
- Local filesystem - simplest for a single VPS
- S3-compatible storage - MinIO (self-hosted) or AWS S3
- Any key-value store - Redis, SQLite, etc.
The interface is small enough to swap in an afternoon.
The netlify.toml file sets Content-Security-Policy, HSTS, X-Frame-Options, and other headers. On a VPS, configure these in nginx or Caddy instead.
With Caddy, API requests are reverse-proxied to the Hono server, and everything else is served as static files from the frontend build. Save this as /etc/caddy/Caddyfile:
mdm.example.com {
handle /api/* {
reverse_proxy localhost:3000
}
handle {
root * /path/to/flash_mdm/dist
try_files {path} /index.html
file_server
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self'; frame-ancestors 'none'; object-src 'none';"
}
}
Replace mdm.example.com with your actual domain. Caddy will automatically provision a Let's Encrypt TLS certificate - no extra configuration needed. If you're testing without a domain, use :80 instead (and set NODE_ENV=development in your .env so session cookies work over HTTP).
Note: Caddy runs as its own user and needs to traverse the path to your
dist/directory. If you cloned into a home directory, ensure the parent directories are world-executable:chmod o+x /home/youruser /home/youruser/flash_mdm
npm run buildThis creates the dist/ folder. The Caddy config above serves it with a catch-all fallback to index.html for client-side routing. If you're using the Hono serveStatic middleware instead of Caddy for static files, the server.ts example above includes that too.
Five handlers run on a schedule on Netlify. On a VPS, trigger them with cron by calling each endpoint or invoking the function directly:
| Handler | Schedule | What it does |
|---|---|---|
cleanup-scheduled |
Daily at 03:00 | Purges expired sessions and stale data |
geofence-check-scheduled |
Every 10 minutes | Evaluates geofence boundary triggers |
licensing-reconcile-scheduled |
Every hour | Reconciles licence state with Stripe |
sync-reconcile-scheduled |
Every 15 minutes | Syncs device state with AMAPI |
workflow-cron-scheduled |
Every 5 minutes | Runs pending workflow automations |
Example crontab entry: */15 * * * * curl -s http://localhost:3000/api/sync-reconcile-scheduled
Some handlers (named *-background.ts) run as long-lived background tasks on Netlify. On a VPS these can run as normal handlers (there's no execution time limit), or you can push them onto a job queue (e.g. BullMQ with Redis) if you want async processing.
On Netlify, environment variables are set in Site Settings > Environment Variables. On a VPS, you have several options:
| Method | How | Best for |
|---|---|---|
.env file |
Copy .env.example to .env and fill in values. Load with dotenv in your server entry point (import 'dotenv/config') |
Local development, simple deployments |
| System environment | export DATABASE_URL=postgres://... in your shell profile, or set them in your systemd service file under Environment= |
Single-server production |
| Docker | Pass with docker run -e DATABASE_URL=... or use env_file in docker-compose.yml |
Containerised deployments |
| Secret manager | Store in AWS Secrets Manager, HashiCorp Vault, or similar, and inject at startup | Teams and regulated environments |
The variables themselves are identical regardless of platform - see the Environment Variables table and .env.example for the full list. A few variables behave differently on a VPS:
| Variable | Why it's needed on a VPS |
|---|---|
DATABASE_URL |
On Netlify this is provided automatically by Netlify DB. On a VPS, point it at your own Postgres instance (append ?sslmode=disable for local Postgres without SSL) |
URL |
The public URL of your deployment (e.g. https://mdm.example.com). Used to generate magic-link emails and other absolute URLs. On Netlify this is set automatically |
RESEND_FROM_EMAIL |
The "from" address for outbound emails (e.g. App Name <noreply@yourdomain.com>). The domain must be verified in your Resend account. If not set, the app falls back to the Flash MDM sender configured in netlify/functions/_lib/brand.ts |
NODE_ENV |
Set to production when running behind HTTPS. If you're testing over plain HTTP (no TLS), set to development - otherwise session cookies will include the Secure flag and browsers will silently reject them over HTTP |
Flash MDM uses PostgreSQL. On a VPS, install Postgres (or use a managed service like Supabase, Neon, or AWS RDS), create a database and user, then run the migrations:
# Create database and user
sudo -u postgres psql -c "CREATE ROLE flashmdm WITH LOGIN PASSWORD 'your-strong-password';"
sudo -u postgres psql -c "CREATE DATABASE flash_mdm OWNER flashmdm;"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE flash_mdm TO flashmdm;"
# Set your connection string
export DATABASE_URL="postgresql://flashmdm:your-strong-password@localhost:5432/flash_mdm?sslmode=disable"Then start the server and run migrations via the built-in migration endpoint:
# Start the server (if not already running)
npx tsx server.ts &
# Run all migrations (requires MIGRATION_SECRET from your .env)
curl http://localhost:3000/api/migrate -H "x-migration-secret: $MIGRATION_SECRET"The migration endpoint is idempotent - it skips migrations that have already been applied, so it's safe to run on every deploy.
Note: Add
?sslmode=disabletoDATABASE_URLwhen connecting to a local Postgres instance that doesn't have SSL configured. Without it, the Node.jspgdriver will fail with aDEPTH_ZERO_SELF_SIGNED_CERTerror.
This is the same process as local development - no Netlify-specific database features are used.
You can run the server directly with:
npx tsx server.tsFor production, set it up as a systemd service so it starts automatically and restarts on failure:
# /etc/systemd/system/flashmdm.service
[Unit]
Description=Flash MDM Server
After=network.target postgresql.service
[Service]
Type=simple
User=youruser
WorkingDirectory=/path/to/flash_mdm
ExecStart=/path/to/flash_mdm/node_modules/.bin/tsx server.ts
Restart=on-failure
RestartSec=5
SuccessExitStatus=143 SIGTERM
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.targetThen enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable flashmdm
sudo systemctl start flashmdmTo rename the product or change branding, edit these two files:
src/lib/brand.ts(frontend)netlify/functions/_lib/brand.ts(backend)
All UI text, email templates, and authenticator app labels pull from these files.
GitHub Actions validates pull requests and pushes to main with lint, build, generated OpenAPI parity, and the full test suite. The lint command blocks configured errors; existing legacy warnings are retained as a visible cleanup backlog.
The docs/ directory has detailed documentation on every aspect of the platform:
- Deployment step-by-step - full walkthrough from zero to running instance
- Security overview - authentication, access control, encryption, and hardening
- API endpoints reference - full endpoint inventory
- Environment variables - complete env var reference
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the LICENSE file for the full text.
If you'd like to use Flash MDM under different terms - for example, to include it in a proprietary product or run a modified version as a hosted service without the AGPL's source-sharing requirements - commercial licences are available. Contact jason@bayton.org for details.