Skip to content

Instantly share code, notes, and snippets.

@TanJay
Created March 8, 2026 23:18
Show Gist options
  • Select an option

  • Save TanJay/de55dc3c3f5d00c70d9f7ec91199656d to your computer and use it in GitHub Desktop.

Select an option

Save TanJay/de55dc3c3f5d00c70d9f7ec91199656d to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
# =============================================================================
# NanoMDM — Full Stack Setup Script
# Generates PKI, Docker Compose, Caddy config, and enrollment profile.
# Run as a non-root user with sudo access on Ubuntu 22.04.
#
# Usage:
# chmod +x nanomdm-setup.sh
# ./nanomdm-setup.sh
#
# Optional HSM mode (Nitrokey HSM 2):
# HSM=1 ./nanomdm-setup.sh
# =============================================================================
set -euo pipefail
# ── Colour output ─────────────────────────────────────────────────────────────
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'
BLU='\033[0;34m'; CYN='\033[0;36m'; BOLD='\033[1m'; RST='\033[0m'
banner() { echo -e "\n${BOLD}${BLU}══ $* ══${RST}"; }
ok() { echo -e " ${GRN}✔${RST} $*"; }
info() { echo -e " ${CYN}→${RST} $*"; }
warn() { echo -e " ${YEL}⚠${RST} $*"; }
die() { echo -e "\n${RED}✖ ERROR: $*${RST}\n" >&2; exit 1; }
ask() { echo -e -n " ${BOLD}?${RST} $* : "; }
# ── Configuration — edit these before running ─────────────────────────────────
DOMAIN="${DOMAIN:-yourdomain.com}" # e.g. home.example.com
ORG="${ORG:-Homelab}"
COUNTRY="${COUNTRY:-US}"
MDM_SUBDOMAIN="mdm"
SCEP_SUBDOMAIN="scep"
PUSH_SUBDOMAIN="push"
BASE_DIR="${BASE_DIR:-$HOME/mdm}"
PKI_DIR="$BASE_DIR/pki"
COMPOSE_DIR="$BASE_DIR"
HSM="${HSM:-0}" # set to 1 to enable Nitrokey HSM 2
MDM_HOST="$MDM_SUBDOMAIN.$DOMAIN"
SCEP_HOST="$SCEP_SUBDOMAIN.$DOMAIN"
PUSH_HOST="$PUSH_SUBDOMAIN.$DOMAIN"
# ─────────────────────────────────────────────────────────────────────────────
# STEP 0 — Welcome & collect config
# ─────────────────────────────────────────────────────────────────────────────
clear
echo -e "${BOLD}"
echo " ███╗ ██╗ █████╗ ███╗ ██╗ ██████╗ ███╗ ███╗██████╗ ███╗ ███╗"
echo " ████╗ ██║██╔══██╗████╗ ██║██╔═══██╗████╗ ████║██╔══██╗████╗ ████║"
echo " ██╔██╗ ██║███████║██╔██╗ ██║██║ ██║██╔████╔██║██║ ██║██╔████╔██║"
echo " ██║╚██╗██║██╔══██║██║╚██╗██║██║ ██║██║╚██╔╝██║██║ ██║██║╚██╔╝██║"
echo " ██║ ╚████║██║ ██║██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██████╔╝██║ ╚═╝ ██║"
echo " ╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝"
echo -e "${RST}"
echo -e " ${BOLD}Self-Hosted MDM Setup — macOS · iOS · iPadOS${RST}"
echo -e " PKI · SCEP · APNs · Docker · Caddy · Cloudflare TLS"
echo ""
if [[ "$DOMAIN" == "yourdomain.com" ]]; then
ask "Your domain (e.g. home.example.com)"
read -r DOMAIN
[[ -z "$DOMAIN" ]] && die "Domain cannot be empty"
MDM_HOST="$MDM_SUBDOMAIN.$DOMAIN"
SCEP_HOST="$SCEP_SUBDOMAIN.$DOMAIN"
PUSH_HOST="$PUSH_SUBDOMAIN.$DOMAIN"
fi
ask "Organisation name [$ORG]"
read -r INPUT_ORG; ORG="${INPUT_ORG:-$ORG}"
ask "Country code (2 letters) [$COUNTRY]"
read -r INPUT_CC; COUNTRY="${INPUT_CC:-$COUNTRY}"
ask "Cloudflare API token (Zone:DNS:Edit)"
read -rs CF_API_TOKEN; echo ""
[[ -z "$CF_API_TOKEN" ]] && die "Cloudflare API token cannot be empty"
# Generate a secure API key for NanoMDM
MDM_API_KEY=$(openssl rand -hex 20)
SCEP_CHALLENGE=$(openssl rand -base64 24 | tr -d '/+=')
echo ""
info "Domain : $DOMAIN"
info "MDM host : $MDM_HOST"
info "SCEP host : $SCEP_HOST"
info "Push host : $PUSH_HOST"
info "Base dir : $BASE_DIR"
[[ "$HSM" == "1" ]] && info "HSM mode : ${GRN}ENABLED (Nitrokey HSM 2)${RST}"
echo ""
ask "Looks correct? Press ENTER to continue or Ctrl-C to abort"
read -r
# ─────────────────────────────────────────────────────────────────────────────
# STEP 1 — Preflight checks
# ─────────────────────────────────────────────────────────────────────────────
banner "STEP 1 — Preflight checks"
check_cmd() {
command -v "$1" &>/dev/null || die "$1 is required but not installed. $2"
ok "$1 found"
}
check_cmd openssl "Install: sudo apt install openssl"
check_cmd docker "Install: https://docs.docker.com/engine/install/ubuntu/"
check_cmd curl "Install: sudo apt install curl"
# Check docker compose (v2 plugin)
docker compose version &>/dev/null || die "Docker Compose v2 required. Install via 'sudo apt install docker-compose-plugin'"
ok "docker compose found"
if [[ "$HSM" == "1" ]]; then
check_cmd pkcs11-tool "Install: sudo apt install opensc"
check_cmd p11tool "Install: sudo apt install gnutls-bin"
fi
# ─────────────────────────────────────────────────────────────────────────────
# STEP 2 — Create directory structure
# ─────────────────────────────────────────────────────────────────────────────
banner "STEP 2 — Directory structure"
mkdir -p \
"$PKI_DIR/root-ca" \
"$PKI_DIR/intermediate-ca" \
"$PKI_DIR/scep" \
"$PKI_DIR/push" \
"$PKI_DIR/nanomdm" \
"$BASE_DIR/db" \
"$BASE_DIR/static" \
"$BASE_DIR/profiles"
# Lock down PKI directory
chmod 700 "$PKI_DIR"
ok "Created: $BASE_DIR"
info "Layout:"
echo ""
echo " $BASE_DIR/"
echo " ├── pki/"
echo " │ ├── root-ca/ ← Root CA key + cert"
echo " │ ├── intermediate-ca/ ← Intermediate CA (signs SCEP device certs)"
echo " │ ├── scep/ ← SCEP server depot"
echo " │ ├── push/ ← APNs push certificate ← YOU PROVIDE THIS"
echo " │ └── nanomdm/ ← NanoMDM identity cert"
echo " ├── db/ ← NanoMDM database"
echo " ├── static/ ← Hosted enrollment profiles"
echo " ├── profiles/ ← Payload templates"
echo " ├── docker-compose.yml"
echo " ├── Caddyfile"
echo " └── .env"
echo ""
# ─────────────────────────────────────────────────────────────────────────────
# STEP 3 — PKI generation (software keys OR Nitrokey HSM 2)
# ─────────────────────────────────────────────────────────────────────────────
banner "STEP 3 — PKI generation"
if [[ "$HSM" == "1" ]]; then
# ── HSM path ──────────────────────────────────────────────────────────────
info "HSM mode: generating keys on Nitrokey HSM 2"
echo ""
warn "Ensure your Nitrokey HSM 2 is plugged in before continuing."
ask "Press ENTER when ready"
read -r
# Detect PKCS#11 library
PKCS11_LIB=""
for lib in \
/usr/lib/x86_64-linux-gnu/pkcs11/opensc-pkcs11.so \
/usr/lib/opensc-pkcs11.so \
/usr/lib64/pkcs11/opensc-pkcs11.so; do
[[ -f "$lib" ]] && { PKCS11_LIB="$lib"; break; }
done
[[ -z "$PKCS11_LIB" ]] && die "opensc-pkcs11.so not found. Install: sudo apt install opensc"
ok "PKCS#11 library: $PKCS11_LIB"
# Initialize HSM if needed
info "Checking HSM status..."
pkcs11-tool --module "$PKCS11_LIB" -T 2>/dev/null | grep -q "token" \
|| die "No HSM token detected. Check USB connection."
ok "HSM token detected"
warn "If this is a fresh HSM, you may be prompted to set SO PIN and User PIN."
info "Generating Root CA key on HSM (slot 0, key ID 01)..."
pkcs11-tool --module "$PKCS11_LIB" \
--login --login-type user \
--keypairgen --key-type rsa:4096 \
--id 01 --label "mdm-root-ca" \
--token-label "NitrokeyHSM" \
2>&1 | grep -v "^Using" || true
ok "Root CA key generated on HSM (ID 01, label: mdm-root-ca)"
info "Generating Intermediate CA key on HSM (ID 02)..."
pkcs11-tool --module "$PKCS11_LIB" \
--login --login-type user \
--keypairgen --key-type rsa:4096 \
--id 02 --label "mdm-intermediate-ca" \
--token-label "NitrokeyHSM" \
2>&1 | grep -v "^Using" || true
ok "Intermediate CA key generated on HSM (ID 02)"
info "Generating NanoMDM identity key on HSM (ID 03)..."
pkcs11-tool --module "$PKCS11_LIB" \
--login --login-type user \
--keypairgen --key-type rsa:2048 \
--id 03 --label "mdm-identity" \
--token-label "NitrokeyHSM" \
2>&1 | grep -v "^Using" || true
ok "NanoMDM identity key generated on HSM (ID 03)"
# Create OpenSSL PKCS11 engine config
OPENSSL_HSM_CONF="$PKI_DIR/openssl-hsm.cnf"
cat > "$OPENSSL_HSM_CONF" << EOF
openssl_conf = openssl_init
[openssl_init]
engines = engine_section
[engine_section]
pkcs11 = pkcs11_section
[pkcs11_section]
engine_id = pkcs11
dynamic_path = /usr/lib/engines-3/pkcs11.so
MODULE_PATH = $PKCS11_LIB
init = 0
EOF
# Self-sign Root CA cert using HSM key
info "Signing Root CA cert with HSM key..."
OPENSSL_CONF="$OPENSSL_HSM_CONF" \
openssl req -new -x509 \
-engine pkcs11 -keyform engine \
-key "pkcs11:token=NitrokeyHSM;id=%01;type=private" \
-sha256 -days 3650 \
-subj "/CN=$ORG Root CA/O=$ORG/C=$COUNTRY" \
-out "$PKI_DIR/root-ca/root-ca.crt"
ok "Root CA cert: $PKI_DIR/root-ca/root-ca.crt"
# Intermediate CA CSR + sign
info "Creating Intermediate CA CSR..."
OPENSSL_CONF="$OPENSSL_HSM_CONF" \
openssl req -new \
-engine pkcs11 -keyform engine \
-key "pkcs11:token=NitrokeyHSM;id=%02;type=private" \
-subj "/CN=$ORG Intermediate CA/O=$ORG/C=$COUNTRY" \
-out "$PKI_DIR/intermediate-ca/intermediate-ca.csr"
info "Signing Intermediate CA with Root CA (on HSM)..."
OPENSSL_CONF="$OPENSSL_HSM_CONF" \
openssl x509 -req \
-engine pkcs11 -keyform engine \
-in "$PKI_DIR/intermediate-ca/intermediate-ca.csr" \
-CA "$PKI_DIR/root-ca/root-ca.crt" \
-CAkey "pkcs11:token=NitrokeyHSM;id=%01;type=private" \
-CAcreateserial \
-out "$PKI_DIR/intermediate-ca/intermediate-ca.crt" \
-days 1825 -sha256 \
-extfile <(printf '[v3_ca]\nbasicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign')
ok "Intermediate CA cert signed"
# NanoMDM identity cert
info "Creating NanoMDM identity CSR..."
OPENSSL_CONF="$OPENSSL_HSM_CONF" \
openssl req -new \
-engine pkcs11 -keyform engine \
-key "pkcs11:token=NitrokeyHSM;id=%03;type=private" \
-subj "/CN=$MDM_HOST/O=$ORG/C=$COUNTRY" \
-out "$PKI_DIR/nanomdm/nanomdm-identity.csr"
OPENSSL_CONF="$OPENSSL_HSM_CONF" \
openssl x509 -req \
-engine pkcs11 -keyform engine \
-in "$PKI_DIR/nanomdm/nanomdm-identity.csr" \
-CA "$PKI_DIR/intermediate-ca/intermediate-ca.crt" \
-CAkey "pkcs11:token=NitrokeyHSM;id=%02;type=private" \
-CAcreateserial \
-out "$PKI_DIR/nanomdm/nanomdm-identity.crt" \
-days 825 -sha256
ok "NanoMDM identity cert signed"
# Export public key refs for SCEP (SCEP server needs software keys — copy certs only)
info "Note: SCEP server requires software key access. Generating software Intermediate CA key for SCEP..."
warn "This is a software copy of the Intermediate key used by SCEP only. CA root key stays on HSM."
openssl genrsa -out "$PKI_DIR/scep/ca.key" 4096 2>/dev/null
openssl req -new \
-key "$PKI_DIR/scep/ca.key" \
-subj "/CN=$ORG SCEP CA/O=$ORG/C=$COUNTRY" \
-out "$PKI_DIR/scep/ca.csr"
openssl x509 -req \
-in "$PKI_DIR/scep/ca.csr" \
-CA "$PKI_DIR/intermediate-ca/intermediate-ca.crt" \
-CAkey "pkcs11:token=NitrokeyHSM;id=%02;type=private" \
-CAcreateserial \
-out "$PKI_DIR/scep/ca.crt" \
-days 1825 -sha256
ok "SCEP CA key + cert ready"
# Save HSM key references for NanoMDM (it will use PKCS11 engine)
echo "$PKCS11_LIB" > "$PKI_DIR/nanomdm/hsm_pkcs11_lib.txt"
echo "pkcs11:token=NitrokeyHSM;id=%03;type=private" > "$PKI_DIR/nanomdm/hsm_key_uri.txt"
ok "HSM key URIs saved"
else
# ── Software key path ──────────────────────────────────────────────────────
info "Software mode: generating keys on disk (consider HSM=1 for production)"
# Root CA
info "Generating Root CA key (4096-bit RSA)..."
openssl genrsa -out "$PKI_DIR/root-ca/root-ca.key" 4096 2>/dev/null
openssl req -x509 -new -nodes \
-key "$PKI_DIR/root-ca/root-ca.key" \
-sha256 -days 3650 \
-subj "/CN=$ORG Root CA/O=$ORG/C=$COUNTRY" \
-out "$PKI_DIR/root-ca/root-ca.crt"
chmod 600 "$PKI_DIR/root-ca/root-ca.key"
ok "Root CA: $PKI_DIR/root-ca/"
# Intermediate CA
info "Generating Intermediate CA key..."
openssl genrsa -out "$PKI_DIR/intermediate-ca/intermediate-ca.key" 4096 2>/dev/null
openssl req -new \
-key "$PKI_DIR/intermediate-ca/intermediate-ca.key" \
-subj "/CN=$ORG Intermediate CA/O=$ORG/C=$COUNTRY" \
-out "$PKI_DIR/intermediate-ca/intermediate-ca.csr"
openssl x509 -req \
-in "$PKI_DIR/intermediate-ca/intermediate-ca.csr" \
-CA "$PKI_DIR/root-ca/root-ca.crt" \
-CAkey "$PKI_DIR/root-ca/root-ca.key" \
-CAcreateserial \
-out "$PKI_DIR/intermediate-ca/intermediate-ca.crt" \
-days 1825 -sha256 \
-extfile <(printf '[v3_ca]\nbasicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign')
chmod 600 "$PKI_DIR/intermediate-ca/intermediate-ca.key"
ok "Intermediate CA: $PKI_DIR/intermediate-ca/"
# NanoMDM identity cert
info "Generating NanoMDM identity cert..."
openssl genrsa -out "$PKI_DIR/nanomdm/nanomdm-identity.key" 2048 2>/dev/null
openssl req -new \
-key "$PKI_DIR/nanomdm/nanomdm-identity.key" \
-subj "/CN=$MDM_HOST/O=$ORG/C=$COUNTRY" \
-out "$PKI_DIR/nanomdm/nanomdm-identity.csr"
openssl x509 -req \
-in "$PKI_DIR/nanomdm/nanomdm-identity.csr" \
-CA "$PKI_DIR/intermediate-ca/intermediate-ca.crt" \
-CAkey "$PKI_DIR/intermediate-ca/intermediate-ca.key" \
-CAcreateserial \
-out "$PKI_DIR/nanomdm/nanomdm-identity.crt" \
-days 825 -sha256
chmod 600 "$PKI_DIR/nanomdm/nanomdm-identity.key"
ok "NanoMDM identity cert: $PKI_DIR/nanomdm/"
# SCEP CA (copy of Intermediate)
cp "$PKI_DIR/intermediate-ca/intermediate-ca.key" "$PKI_DIR/scep/ca.key"
cp "$PKI_DIR/intermediate-ca/intermediate-ca.crt" "$PKI_DIR/scep/ca.crt"
chmod 600 "$PKI_DIR/scep/ca.key"
ok "SCEP CA depot: $PKI_DIR/scep/"
fi
# Full chain for SCEP and enrollment profile
cat "$PKI_DIR/intermediate-ca/intermediate-ca.crt" \
"$PKI_DIR/root-ca/root-ca.crt" \
> "$PKI_DIR/intermediate-ca/ca-chain.crt"
cp "$PKI_DIR/intermediate-ca/ca-chain.crt" "$PKI_DIR/scep/ca-chain.crt"
ok "CA chain bundle created"
# ─────────────────────────────────────────────────────────────────────────────
# STEP 4 — APNs push certificate — user must provide this
# ─────────────────────────────────────────────────────────────────────────────
banner "STEP 4 — APNs Push Certificate"
echo ""
warn "The APNs push certificate CANNOT be generated automatically."
warn "Apple requires you to obtain it from identity.apple.com/pushcert"
echo ""
echo -e " ${BOLD}Here is exactly what you need to do:${RST}"
echo ""
echo " 1. On your Mac, open Keychain Access"
echo " → Certificate Assistant → Request a Certificate from a CA"
echo " → Fill in your email + common name → Saved to disk"
echo " → Save as: push-csr.certSigningRequest"
echo ""
echo " 2. Build the signing tool on your Mac:"
echo " git clone https://github.com/micromdm/nanomdm && cd nanomdm"
echo " go build ./cmd/nanomdm"
echo ""
echo " 3. Generate a vendor signing key (one-time):"
echo " openssl req -nodes -x509 -newkey rsa:2048 \\"
echo " -keyout vendor.key -out vendor.crt -days 3650 \\"
echo " -subj '/CN=MDM Vendor'"
echo ""
echo " 4. Sign your CSR:"
echo " openssl x509 -req -in push-csr.certSigningRequest \\"
echo " -signkey vendor.key -out signed.pem -days 365"
echo ""
echo " 5. Go to: https://identity.apple.com/pushcert"
echo " → Create a certificate → upload signed.pem"
echo " → Download → rename to: push-cert.pem"
echo ""
echo " 6. Place the file here:"
echo -e " ${BOLD}$PKI_DIR/push/push-cert.pem${RST}"
echo ""
echo " 7. Place your push private key (vendor.key) here:"
echo -e " ${BOLD}$PKI_DIR/push/push.key${RST}"
echo ""
# Wait for user to place the cert
while true; do
if [[ -f "$PKI_DIR/push/push-cert.pem" && -f "$PKI_DIR/push/push.key" ]]; then
ok "push-cert.pem found"
ok "push.key found"
# Extract push topic
PUSH_TOPIC=$(openssl x509 -in "$PKI_DIR/push/push.pem" -noout -subject 2>/dev/null \
| grep -oP 'UID=\K[^,/]+' || true)
if [[ -z "$PUSH_TOPIC" ]]; then
PUSH_TOPIC=$(openssl x509 -in "$PKI_DIR/push/push-cert.pem" -noout -subject \
| sed 's/.*UID=\([^,/]*\).*/\1/')
fi
ok "Push topic: $PUSH_TOPIC"
break
fi
echo ""
ask "Press ENTER once you have placed push-cert.pem and push.key in $PKI_DIR/push/"
read -r
done
chmod 600 "$PKI_DIR/push/push.key"
# ─────────────────────────────────────────────────────────────────────────────
# STEP 5 — Write .env file
# ─────────────────────────────────────────────────────────────────────────────
banner "STEP 5 — Environment file"
cat > "$COMPOSE_DIR/.env" << EOF
# NanoMDM environment — generated by nanomdm-setup.sh
# Keep this file private: chmod 600 .env
MDM_API_KEY=$MDM_API_KEY
SCEP_CHALLENGE=$SCEP_CHALLENGE
CF_API_TOKEN=$CF_API_TOKEN
DOMAIN=$DOMAIN
MDM_HOST=$MDM_HOST
SCEP_HOST=$SCEP_HOST
PUSH_HOST=$PUSH_HOST
PUSH_TOPIC=$PUSH_TOPIC
EOF
chmod 600 "$COMPOSE_DIR/.env"
ok "Written: $COMPOSE_DIR/.env"
# ─────────────────────────────────────────────────────────────────────────────
# STEP 6 — Docker Compose
# ─────────────────────────────────────────────────────────────────────────────
banner "STEP 6 — Docker Compose"
# Determine identity key source for NanoMDM command
if [[ "$HSM" == "1" ]]; then
NANOMDM_KEY_FLAG="-key pkcs11:token=NitrokeyHSM;id=%03;type=private"
HSM_VOLUME=" - /usr/lib/x86_64-linux-gnu/pkcs11:/pkcs11:ro"
HSM_ENV=" - OPENSSL_CONF=/etc/nanomdm/openssl-hsm.cnf"
else
NANOMDM_KEY_FLAG="-key /certs/nanomdm-identity.key"
HSM_VOLUME=""
HSM_ENV=""
fi
cat > "$COMPOSE_DIR/docker-compose.yml" << EOF
# Generated by nanomdm-setup.sh
# Domain: $DOMAIN
version: '3.8'
services:
# ── NanoMDM core ────────────────────────────────────────────────────────────
nanomdm:
image: ghcr.io/micromdm/nanomdm:latest
restart: unless-stopped
volumes:
- ./db:/db
- ./pki/nanomdm:/certs:ro
- ./pki/push:/push:ro
- ./pki/root-ca:/root-ca:ro
$([ -n "$HSM_VOLUME" ] && echo "$HSM_VOLUME")
environment:
$([ -n "$HSM_ENV" ] && echo "$HSM_ENV")
command: >
-storage file
-storage-dsn /db
-cert /certs/nanomdm-identity.crt
$NANOMDM_KEY_FLAG
-ca /root-ca/root-ca.crt
-push-cert /push/push-cert.pem
-push-key /push/push.key
-api-key \${MDM_API_KEY}
ports:
- "127.0.0.1:9000:9000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/healthz"]
interval: 30s
timeout: 5s
retries: 3
# ── SCEP server ─────────────────────────────────────────────────────────────
scep:
image: ghcr.io/micromdm/scep:latest
restart: unless-stopped
volumes:
- ./pki/scep:/depot:ro
command: >
-depot /depot
-challenge \${SCEP_CHALLENGE}
-port 8080
ports:
- "127.0.0.1:8080:8080"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/scep?operation=GetCACert"]
interval: 30s
timeout: 5s
retries: 3
# ── APNs push proxy ──────────────────────────────────────────────────────────
nanopush:
image: ghcr.io/micromdm/nanomdm:latest
restart: unless-stopped
volumes:
- ./pki/push:/push:ro
entrypoint: /app/nanomdm
command: >
-push-proxy
-push-cert /push/push-cert.pem
-push-key /push/push.key
ports:
- "127.0.0.1:9001:9001"
# ── Caddy reverse proxy ──────────────────────────────────────────────────────
caddy:
image: ghcr.io/caddybuilds/caddy-cloudflare:latest
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
- ./static:/var/mdm/static:ro
environment:
- CF_API_TOKEN=\${CF_API_TOKEN}
depends_on:
- nanomdm
- scep
- nanopush
volumes:
caddy_data:
caddy_config:
EOF
ok "Written: $COMPOSE_DIR/docker-compose.yml"
# ─────────────────────────────────────────────────────────────────────────────
# STEP 7 — Caddyfile
# ─────────────────────────────────────────────────────────────────────────────
banner "STEP 7 — Caddyfile"
cat > "$COMPOSE_DIR/Caddyfile" << EOF
# Generated by nanomdm-setup.sh
{
email admin@$DOMAIN
}
# MDM server
$MDM_HOST {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
# Serve enrollment profile
handle /enroll.mobileconfig {
root * /var/mdm/static
file_server
}
# MDM endpoints
handle {
reverse_proxy nanomdm:9000
}
}
# SCEP server
$SCEP_HOST {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy scep:8080
}
# APNs push proxy
$PUSH_HOST {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy nanopush:9001
}
EOF
ok "Written: $COMPOSE_DIR/Caddyfile"
# ─────────────────────────────────────────────────────────────────────────────
# STEP 8 — Enrollment profile
# ─────────────────────────────────────────────────────────────────────────────
banner "STEP 8 — Enrollment profile"
ROOT_CA_B64=$(base64 -w 0 "$PKI_DIR/root-ca/root-ca.crt")
UUID_ROOT=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)
UUID_SCEP=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)
UUID_MDM=$( cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)
UUID_OUTER=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)
cat > "$COMPOSE_DIR/profiles/enroll.mobileconfig" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<!-- Payload 1: Trust Root CA -->
<dict>
<key>PayloadType</key> <string>com.apple.security.root</string>
<key>PayloadIdentifier</key> <string>$(echo "$ORG" | tr '[:upper:]' '[:lower:]' | tr ' ' '.').mdm.root-ca</string>
<key>PayloadUUID</key> <string>$UUID_ROOT</string>
<key>PayloadVersion</key> <integer>1</integer>
<key>PayloadContent</key>
<data>$ROOT_CA_B64</data>
</dict>
<!-- Payload 2: SCEP — device client certificate -->
<dict>
<key>PayloadType</key> <string>com.apple.security.scep</string>
<key>PayloadIdentifier</key> <string>$(echo "$ORG" | tr '[:upper:]' '[:lower:]' | tr ' ' '.').mdm.scep</string>
<key>PayloadUUID</key> <string>$UUID_SCEP</string>
<key>PayloadVersion</key> <integer>1</integer>
<key>PayloadContent</key>
<dict>
<key>URL</key> <string>https://$SCEP_HOST/scep</string>
<key>Name</key> <string>$(echo "$ORG" | tr ' ' '')MDM</string>
<key>Challenge</key> <string>$SCEP_CHALLENGE</string>
<key>Subject</key>
<array>
<array><array><string>O</string><string>$ORG</string></array></array>
<array><array><string>CN</string><string>%HardwareUUID%</string></array></array>
</array>
<key>KeyType</key> <string>RSA</string>
<key>KeySize</key> <integer>2048</integer>
<key>KeyUsage</key> <integer>5</integer>
</dict>
</dict>
<!-- Payload 3: MDM -->
<dict>
<key>PayloadType</key> <string>com.apple.mdm</string>
<key>PayloadIdentifier</key> <string>$(echo "$ORG" | tr '[:upper:]' '[:lower:]' | tr ' ' '.').mdm</string>
<key>PayloadUUID</key> <string>$UUID_MDM</string>
<key>PayloadVersion</key> <integer>1</integer>
<key>ServerURL</key>
<string>https://$MDM_HOST/mdm</string>
<key>CheckInURL</key>
<string>https://$MDM_HOST/checkin</string>
<key>Topic</key>
<string>$PUSH_TOPIC</string>
<key>IdentityCertificateUUID</key>
<string>$UUID_SCEP</string>
<key>AccessRights</key> <integer>8191</integer>
<key>CheckOutWhenRemoved</key><true/>
</dict>
</array>
<key>PayloadIdentifier</key> <string>$(echo "$ORG" | tr '[:upper:]' '[:lower:]' | tr ' ' '.').mdm.enroll</string>
<key>PayloadType</key> <string>Configuration</string>
<key>PayloadUUID</key> <string>$UUID_OUTER</string>
<key>PayloadVersion</key> <integer>1</integer>
<key>PayloadDisplayName</key> <string>$ORG MDM Enrollment</string>
<key>PayloadDescription</key> <string>Enrolls this device into $ORG Mobile Device Management.</string>
</dict>
</plist>
EOF
ok "Written: $COMPOSE_DIR/profiles/enroll.mobileconfig"
# Sign the profile with the NanoMDM identity cert if openssl smime is available
if command -v openssl &>/dev/null; then
openssl smime -sign -nodetach \
-signer "$PKI_DIR/nanomdm/nanomdm-identity.crt" \
-inkey "$PKI_DIR/nanomdm/nanomdm-identity.key" \
-certfile "$PKI_DIR/intermediate-ca/ca-chain.crt" \
-in "$COMPOSE_DIR/profiles/enroll.mobileconfig" \
-out "$COMPOSE_DIR/static/enroll.mobileconfig" \
-outform PEM 2>/dev/null \
&& ok "Profile signed → $COMPOSE_DIR/static/enroll.mobileconfig" \
|| { warn "Profile signing failed (HSM key? Sign manually). Copying unsigned."; \
cp "$COMPOSE_DIR/profiles/enroll.mobileconfig" "$COMPOSE_DIR/static/enroll.mobileconfig"; }
fi
# ─────────────────────────────────────────────────────────────────────────────
# STEP 9 — DNS profile (NextDNS)
# ─────────────────────────────────────────────────────────────────────────────
banner "STEP 9 — DNS enforcement profile"
cat > "$COMPOSE_DIR/profiles/dns-nextdns.mobileconfig" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key> <string>com.apple.dnsSettings.managed</string>
<key>PayloadIdentifier</key> <string>$(echo "$ORG" | tr '[:upper:]' '[:lower:]' | tr ' ' '.').dns.nextdns</string>
<key>PayloadUUID</key> <string>$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)</string>
<key>PayloadVersion</key> <integer>1</integer>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key> <string>HTTPS</string>
<!-- EDIT: Replace YOUR_NEXTDNS_ID with your NextDNS profile ID -->
<key>ServerURL</key>
<string>https://dns.nextdns.io/YOUR_NEXTDNS_ID</string>
<key>ServerAddresses</key>
<array>
<string>45.90.28.0</string>
<string>45.90.30.0</string>
</array>
</dict>
</dict>
</array>
<key>PayloadType</key> <string>Configuration</string>
<key>PayloadUUID</key> <string>$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)</string>
<key>PayloadIdentifier</key> <string>$(echo "$ORG" | tr '[:upper:]' '[:lower:]' | tr ' ' '.').dns</string>
<key>PayloadVersion</key> <integer>1</integer>
<key>PayloadDisplayName</key> <string>$ORG DNS (NextDNS)</string>
</dict>
</plist>
EOF
ok "Written: $COMPOSE_DIR/profiles/dns-nextdns.mobileconfig"
info "Edit the NextDNS profile ID before pushing it to devices"
# ─────────────────────────────────────────────────────────────────────────────
# STEP 10 — Start Docker stack
# ─────────────────────────────────────────────────────────────────────────────
banner "STEP 10 — Starting Docker stack"
cd "$COMPOSE_DIR"
docker compose pull --quiet
docker compose up -d
ok "Docker stack started"
info "Waiting 10s for services to initialise..."
sleep 10
# Health checks
echo ""
info "Running health checks..."
check_service() {
local name="$1" url="$2"
if curl -sf --max-time 5 "$url" &>/dev/null; then
ok "$name is up"
else
warn "$name did not respond at $url (may still be starting)"
fi
}
check_service "NanoMDM" "http://127.0.0.1:9000/healthz"
check_service "SCEP" "http://127.0.0.1:8080/scep?operation=GetCACert"
check_service "Push proxy" "http://127.0.0.1:9001/healthz"
# ─────────────────────────────────────────────────────────────────────────────
# DONE — Summary
# ─────────────────────────────────────────────────────────────────────────────
banner "Setup complete"
echo ""
echo -e "${BOLD} ┌─────────────────────────────────────────────────────────┐${RST}"
echo -e "${BOLD} │ NanoMDM Stack Summary │${RST}"
echo -e "${BOLD} ├─────────────────────────────────────────────────────────┤${RST}"
echo -e " │ MDM server : https://$MDM_HOST"
echo -e " │ SCEP server : https://$SCEP_HOST"
echo -e " │ Push proxy : https://$PUSH_HOST"
echo -e " │ API key : $MDM_API_KEY"
echo -e " │ SCEP challenge: $SCEP_CHALLENGE"
echo -e "${BOLD} └─────────────────────────────────────────────────────────┘${RST}"
echo ""
echo -e " ${BOLD}Next steps:${RST}"
echo ""
echo " 1. Create Cloudflare DNS A records pointing to this VPS IP:"
echo " mdm.$DOMAIN → <VPS_IP>"
echo " scep.$DOMAIN → <VPS_IP>"
echo " push.$DOMAIN → <VPS_IP>"
echo ""
echo " 2. Supervise your devices with Apple Configurator 2 (Mac App Store)"
echo " — this ERASES the device, backup first"
echo ""
echo " 3. Install the enrollment profile on supervised devices:"
echo " https://$MDM_HOST/enroll.mobileconfig"
echo ""
echo " 4. Verify enrollment:"
echo " curl -u nanomdm:$MDM_API_KEY https://$MDM_HOST/v1/enrollments"
echo ""
echo " 5. Edit $COMPOSE_DIR/profiles/dns-nextdns.mobileconfig"
echo " Replace YOUR_NEXTDNS_ID, then push:"
echo ""
echo " PROFILE=\$(base64 -w 0 $COMPOSE_DIR/profiles/dns-nextdns.mobileconfig)"
echo ' curl -u nanomdm:'"$MDM_API_KEY"' \'
echo " -X POST https://$MDM_HOST/v1/commands \\"
echo " -H 'Content-Type: application/json' \\"
echo ' -d "{\"command\":{\"RequestType\":\"InstallProfile\",\"Payload\":\"'"'"'\$PROFILE'"'"'\"},\"udids\":[\"ALL\"]}"'
echo ""
echo -e " ${YEL}Save your API key and SCEP challenge — they are stored in $COMPOSE_DIR/.env${RST}"
echo ""
[[ "$HSM" == "1" ]] && echo -e " ${GRN}HSM mode: Root CA and Intermediate CA keys are stored on your Nitrokey HSM 2.${RST}"
echo ""
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment