Security Azure

Headwind MDM on Ubuntu 24.04 on Azure User Guide

| Product: Headwind MDM on Ubuntu 24.04 LTS on Azure

Overview

Headwind MDM is an open source Mobile Device Management server for Android. It gives you one admin console and a REST API to enrol company devices, push configurations, applications and policies over the air, and monitor the state of your estate. The cloudimg image installs the official open source Headwind MDM 5.40.1 web application on Apache Tomcat 9 with a bundled PostgreSQL 16 database, both on a private container network, fronted by an nginx reverse proxy on port 80.

The image is built to remove the two things that make a self deployed Headwind MDM risky. Upstream seeds a default admin / admin administrator account and a static device shared secret when it first creates its database. On this image both are replaced with unique random values generated on the first boot of your VM, and the console is deliberately refused by the reverse proxy until that replacement has completed, so the default credential is never reachable over the network.

What is included:

  • The official open source Headwind MDM 5.40.1 web application on Apache Tomcat 9, pinned so the appliance never silently upgrades
  • A bundled PostgreSQL 16 database, pinned by digest and published to no host port at all, holding your fleet, configurations and application catalogue
  • An nginx reverse proxy on port 80 serving the admin console, the REST API and an unauthenticated /healthz endpoint for load balancer probes
  • A unique administrator password, device shared secret and database password generated on the first boot of every VM and stored in a root only file
  • A dedicated, independently resizable Azure data disk mounted at /var/lib/hmdm-data holding the database and your uploaded application files
  • The public enrolment base URL resolved to your VM's own address at first boot, and fully customer configurable afterwards
  • The whole stack managed as one hmdm.service systemd unit alongside docker.service and nginx.service, all enabled
  • 24/7 cloudimg support

Key facts:

Item Value
Platform Ubuntu 24.04 LTS
Default login user azureuser
Admin console http://<vm-public-ip>/
Health endpoint http://<vm-public-ip>/healthz
Credentials file /root/headwind-mdm-credentials.txt
Per VM configuration /etc/hmdm/hmdm.env
Data disk /var/lib/hmdm-data

Prerequisites

  1. An active Azure subscription with permission to create virtual machines.
  2. An SSH key pair for the azureuser account.
  3. A virtual network and subnet in your target region.
  4. A network security group allowing inbound 22/tcp from your management network and 80/tcp from the clients and Android devices that will reach the server.

Recommended VM size: Standard_B2s (2 vCPU / 4 GiB RAM). The Tomcat application, the bundled PostgreSQL database and the nginx proxy run comfortably in that footprint with roughly 2.8 GiB free. Choose a larger size such as Standard_D2s_v3 if you plan to manage a large estate or upload a big application catalogue.

Step 1 - Deploy from the Azure Marketplace

Sign in to the Azure Portal, choose Create a resource, search the Marketplace for Headwind MDM by cloudimg, and select Create. On Basics pick your subscription, resource group, region and size. Under Administrator account choose SSH public key and paste your key. Under Inbound port rules allow SSH (22), then add 80 after deployment. Select Review + create, then Create.

Step 2 - Deploy from the Azure CLI

az vm create \
  --resource-group <your-rg> \
  --name headwind-mdm \
  --image <marketplace-image-urn> \
  --size Standard_B2s \
  --admin-username azureuser \
  --ssh-key-values ~/.ssh/id_ed25519.pub \
  --vnet-name <your-vnet> --subnet <your-subnet> \
  --public-ip-sku Standard

az vm open-port --resource-group <your-rg> --name headwind-mdm --port 80 --priority 1010

Step 3 - Connect to your VM

ssh azureuser@<vm-public-ip>

You can look up the address at any time:

az vm show --resource-group <your-rg> --name headwind-mdm --show-details --query publicIps --output tsv

Step 4 - Confirm the stack is running

Headwind MDM runs as a two container Docker Compose stack managed by a single systemd unit. On the first boot of your VM the unit generates every secret, re rolls the database and rotates the administrator password before the console is opened up, so allow a few minutes after the VM is created.

systemctl is-active hmdm.service

It reports active. List the containers to see the application and its bundled database:

sudo docker compose --env-file /etc/hmdm/hmdm.env -f /etc/hmdm/compose.yaml ps --format 'table {{.Service}}\t{{.Status}}'

Expected output:

SERVICE      STATUS
hmdm         Up 12 minutes (healthy)
postgresql   Up 13 minutes (healthy)

The five Headwind MDM services active, both containers healthy, and the pinned application and database images baked into the image

The whole stack is controlled as one unit with sudo systemctl status hmdm and sudo systemctl restart hmdm.

Step 5 - Retrieve your per VM administrator credentials

The administrator password is generated uniquely on the first boot of your VM and written to a root only file. Confirm the file exists and is 0600:

sudo ls -l /root/headwind-mdm-credentials.txt

Expected output:

-rw------- 1 root root 1058 Jul 25 15:18 /root/headwind-mdm-credentials.txt

Then read it to get your credentials:

sudo cat /root/headwind-mdm-credentials.txt

The file contains HMDM_ADMIN_USERNAME (admin), HMDM_ADMIN_PASSWORD (your unique password), HMDM_URL (the console address) and HMDM_ENROLLMENT_BASE_URL (the address your Android devices will enrol against). Store them somewhere safe.

The per VM credentials file is root only, both first boot units have completed, and the administrator password, device shared secret and database password are all unique to this VM

Step 6 - Confirm the endpoints are healthy

The nginx proxy serves an unauthenticated health endpoint suitable for a load balancer probe:

curl -s -o /dev/null -w 'health  -> HTTP %{http_code}\n' http://127.0.0.1/healthz

And the admin console itself:

curl -s -o /dev/null -w 'console -> HTTP %{http_code}\n' http://127.0.0.1/

Both return HTTP 200. Confirm which ports actually face the network. Only SSH and the nginx proxy are exposed; the Tomcat application is bound to loopback and the bundled PostgreSQL is on no host port at all:

sudo ss -tln | awk 'NR==1 || /:80 |:8080 /{print $1, $4}'

Expected output:

State Local
LISTEN 0.0.0.0:80
LISTEN 127.0.0.1:8080
LISTEN [::]:80

Your database and uploaded application files live on the dedicated data disk:

df -h /var/lib/hmdm-data

The health endpoint and admin console both returning HTTP 200, only SSH and nginx facing the network, and the dedicated data disk mounted at /var/lib/hmdm-data

Step 7 - Confirm the pinned release

The application and database images are baked into the image at build time and pinned, so your VM never downloads anything at first boot and never silently upgrades:

sudo docker compose --env-file /etc/hmdm/hmdm.env -f /etc/hmdm/compose.yaml images

The hmdm service reports tag 5.40.1. The postgresql service is pinned by image digest rather than a tag, so its tag column shows <none> by design.

Step 8 - Confirm the default credential is gone

This is the security property that makes the image safe to expose. Upstream Headwind MDM seeds an admin / admin account when it creates its database. Your VM rotated it on first boot. The console sends the uppercase MD5 of the password, so reproduce that and try the default:

DEFAULT_MD5=$(printf 'admin' | md5sum | awk '{print toupper($1)}')
curl -s -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d "{\"login\":\"admin\",\"password\":\"$DEFAULT_MD5\"}" \
  http://127.0.0.1/rest/public/auth/login

Expected output, the default credential is rejected:

{"status":"ERROR","message":null,"data":null}

Note that this endpoint answers HTTP 200 even when authentication fails, so the status field is what tells you the result, never the HTTP code. A private endpoint reached without a session is refused outright:

curl -s -o /dev/null -w 'private (no session) -> HTTP %{http_code}\n' http://127.0.0.1/rest/private/configurations/search

It returns HTTP 403.

The upstream default admin credential rejected, an unauthenticated private endpoint refused with HTTP 403, and the full credential round trip proving the per VM password authenticates against Tomcat and PostgreSQL

Step 9 - Sign in to the admin console

Browse to http://<vm-public-ip>/ and sign in with the username admin and the password from Step 5.

The Headwind MDM sign in page served through the nginx reverse proxy on port 80

The Devices tab is the console's main view. It lists every enrolled Android device with its status, the configuration applied to it, its permission and installation state, and the per device actions you can take.

The Devices tab listing an enrolled Android device with its status, applied configuration and per device actions

Step 10 - Review configurations and applications

A configuration is the policy and application set applied to a group of devices. The image ships two ready made configurations you can edit, copy or extend.

The Configurations tab listing the ready made device configurations supplied with the image

The Applications tab is your application catalogue. Add your own APK files or web links here and assign them to a configuration so every device in that group installs and updates them over the air.

The Applications tab showing the application catalogue that configurations draw from

Step 11 - Set your public enrolment URL

Android devices enrol against a public base URL. On first boot your VM sets that to its own address so the server works immediately:

sudo grep -E '^BASE_DOMAIN|^PROTOCOL' /etc/hmdm/hmdm.env

To enrol devices against your own domain name instead, edit BASE_DOMAIN in /etc/hmdm/hmdm.env, then restart the stack so the application picks it up:

sudo sed -i 's/^BASE_DOMAIN=.*/BASE_DOMAIN=mdm.example.com/' /etc/hmdm/hmdm.env
sudo systemctl restart hmdm

The application regenerates its Tomcat context from that file on every start, so nothing else needs editing. Point the DNS name at your VM before you enrol devices against it, because enrolled devices keep using the URL they were enrolled with.

Step 12 - Enrol your first Android device

Install the Headwind MDM launcher on the device and point it at your server, then approve the device in the console:

  1. In the console open Devices and select Add to create a device entry, or allow automatic creation from Settings.
  2. Assign the device to a configuration, for example Common - Minimal.
  3. On the Android device install the Headwind MDM launcher from the Headwind MDM downloads page and enter your enrolment URL from Step 11.
  4. The device appears in the Devices list and receives its configuration and applications over the air.

See the Headwind MDM documentation for the full enrolment, kiosk and REST API reference.

Server components

Component Version Role
Headwind MDM 5.40.1 Open source Android Mobile Device Management server, admin console and REST API
Apache Tomcat 9 Servlet container running the application, bound to 127.0.0.1:8080
PostgreSQL 16 Bundled database holding devices, configurations and the application catalogue
nginx 1.24 Reverse proxy on port 80 and the /healthz endpoint
Docker Engine Docker CE Container runtime for the two application containers

Filesystem layout

Path Purpose
/ Root filesystem on the OS disk
/boot Operating system kernel files
/boot/efi UEFI boot partition (Gen2 Hyper V)
/mnt Azure temporary resource disk
/var/lib/hmdm-data Dedicated data disk, 30 GiB, holding the database and uploaded files

Key directories:

Path Purpose
/etc/hmdm/hmdm.env Per VM configuration and secrets, 0600 root only
/etc/hmdm/compose.yaml The Docker Compose definition of the stack
/var/lib/hmdm-data/pgdata PostgreSQL data directory
/var/lib/hmdm-data/hmdm-work Application working directory and uploaded application files
/root/headwind-mdm-credentials.txt Your per VM administrator credentials, 0600 root only
/etc/nginx/sites-available/hmdm The reverse proxy configuration

Managing the service

Task Command
Status sudo systemctl status hmdm
Restart the whole stack sudo systemctl restart hmdm
Stop the stack sudo systemctl stop hmdm
Container status sudo docker compose --env-file /etc/hmdm/hmdm.env -f /etc/hmdm/compose.yaml ps
Application logs sudo docker compose --env-file /etc/hmdm/hmdm.env -f /etc/hmdm/compose.yaml logs --tail 100 hmdm
Database logs sudo docker compose --env-file /etc/hmdm/hmdm.env -f /etc/hmdm/compose.yaml logs --tail 100 postgresql
Proxy status sudo systemctl status nginx

Scripts and log files

Path Purpose
/usr/local/sbin/hmdm-firstboot.sh Generates the per VM secrets and enrolment base URL on first boot
/usr/local/sbin/hmdm-admininit.sh Rotates the seeded administrator credential on first boot and opens the console
/usr/local/sbin/hmdm-roundtrip.sh Proves the security model and a full database round trip
/var/lib/cloudimg/hmdm-firstboot.done Marker written once the per VM secrets exist
/var/lib/cloudimg/hmdm-admininit.done Marker written once the administrator credential has been rotated
journalctl -u hmdm-firstboot First boot secret generation log
journalctl -u hmdm-admininit Administrator rotation log

On startup

Your VM's first boot runs two one shot units in order. hmdm-firstboot.service generates a unique administrator password, device shared secret and database password, resolves your VM's public address into the enrolment base URL, and writes /etc/hmdm/hmdm.env and /root/headwind-mdm-credentials.txt. Only then does hmdm.service start the application, which creates its database schema. Finally hmdm-admininit.service replaces the seeded admin / admin credential with your unique password, verifies that the default is rejected and that your password works, and writes its marker.

Until that last marker exists, nginx answers every console route with HTTP 503 and a short explanatory message. That is deliberate: it means the upstream default credential is never reachable from the network, not even during the two to five minutes your VM takes to initialise. Both markers are removed before the image is captured, so every VM you launch repeats this from a clean database.

Troubleshooting

Symptom Diagnosis
The console returns 503 with an initialising message First boot has not finished. Wait a few minutes, then check journalctl -u hmdm-admininit -n 50.
The console returns 502 The application container is still starting. Check sudo docker compose --env-file /etc/hmdm/hmdm.env -f /etc/hmdm/compose.yaml logs --tail 100 hmdm.
You cannot reach the console from your browser Confirm the network security group allows inbound 80/tcp, then confirm curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/ returns 200 on the VM.
Your password is rejected Confirm you are using the value from /root/headwind-mdm-credentials.txt. Headwind MDM applies a brief lockout after repeated failures, so wait a minute before retrying.
Devices enrol against the wrong address Check BASE_DOMAIN in /etc/hmdm/hmdm.env, correct it, and restart the stack. Devices keep the URL they were enrolled with.
The stack will not start Confirm the data disk is mounted with df -h /var/lib/hmdm-data, then check journalctl -u hmdm -n 80.

Security recommendations

  • Change the administrator password from Settings in the console after your first sign in, and remove /root/headwind-mdm-credentials.txt once you have stored the value in your password manager.
  • Terminate TLS in front of the server. The image serves plain HTTP on port 80. Put an Azure Application Gateway, an Azure Front Door or your own reverse proxy with a certificate in front of it before you enrol production devices, and set BASE_DOMAIN to that public name.
  • Restrict the network security group. Allow 22/tcp only from your management network, and 80/tcp only from the networks your devices and administrators use.
  • Keep the device shared secret private. It is generated per VM and lives in /etc/hmdm/hmdm.env, which is root only. Anything that can read it can impersonate a device.
  • Back up the data disk. Your fleet, configurations and uploaded applications live on /var/lib/hmdm-data. Take Azure snapshots of that disk, or run pg_dump against the database container.
  • Security patches continue to arrive automatically, unattended upgrades remains enabled on the image.

Support

cloudimg provides 24/7 expert support for this image. Contact support@cloudimg.co.uk.

Headwind MDM is open source software licensed under the Apache License 2.0. cloudimg is not affiliated with or endorsed by Headwind MDM or h-mdm.com; the name is used only to identify the software included in this image.