Headwind MDM on Ubuntu 24.04 on Azure User Guide
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
80serving the admin console, the REST API and an unauthenticated/healthzendpoint 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-dataholding 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.servicesystemd unit alongsidedocker.serviceandnginx.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
- An active Azure subscription with permission to create virtual machines.
- An SSH key pair for the
azureuseraccount. - A virtual network and subnet in your target region.
- A network security group allowing inbound
22/tcpfrom your management network and80/tcpfrom 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 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.

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

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.

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

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

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:
- In the console open Devices and select Add to create a device entry, or allow automatic creation from Settings.
- Assign the device to a configuration, for example Common - Minimal.
- On the Android device install the Headwind MDM launcher from the Headwind MDM downloads page and enter your enrolment URL from Step 11.
- 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.txtonce 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_DOMAINto that public name. - Restrict the network security group. Allow
22/tcponly from your management network, and80/tcponly 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 runpg_dumpagainst 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.