BloodHound CE on Ubuntu 24.04 on Azure User Guide
Overview
BloodHound Community Edition uses graph theory to reveal the hidden and often unintended privilege relationships inside an Active Directory, Entra ID or Azure environment. Attackers use it to find complex attack paths that would be impractical to spot by hand; defenders use it to find and cut those same paths. You feed it collector output from SharpHound or AzureHound, and it computes the reachability graph — who can ultimately reach Domain Admin, which principals hold DCSync rights, which computers expose LAPS passwords, and so on.
The cloudimg image installs BloodHound CE 9.4.0 as a pinned Docker Compose stack managed by systemd, so a working attack path analysis platform is online within minutes of launch.
What is included:
- BloodHound Community Edition 9.4.0 (Apache-2.0), from the official published image
docker.io/specterops/bloodhound:9.4.0, pinned so the appliance never silently upgrades - Docker Engine (Docker CE) and the Docker Compose plugin, from the official Docker package repository
- PostgreSQL 18 (
docker.io/library/postgres:18) holding both the application state and the attack path graph — BloodHound's first party PostgreSQL graph driver is used, so there is no Neo4j service to run, tune or license - The BloodHound web UI and REST API published on port 80; the database publishes no host port at all and is reachable only on the private container network
- All stateful data on a dedicated 64 GiB data disk mounted at
/var/lib/bloodhound(Docker's data-root is relocated there), independently resizable and re-provisioned on every VM - A per-VM
adminpassword generated at first boot, which BloodHound requires you to change the first time you sign in - A genuine SharpHound sample dataset staged at
/opt/bloodhound/sample-data/so you can explore real attack paths immediately - Two systemd units:
bloodhound.service(a oneshot wrapper arounddocker compose up -d) andbloodhound-firstboot.service - 24/7 cloudimg support
Why there is no Neo4j
Upstream's example Compose file runs BloodHound against Neo4j. This image does not. BloodHound CE ships a first party PostgreSQL graph backend — the same one SpecterOps' own Debian package uses — so the appliance runs the application and the graph on a single PostgreSQL 18 instance. That means one less database to size and secure, a smaller memory footprint with no JVM, and a stack whose components are Apache-2.0 and the PostgreSQL License. Nothing about attack path analysis is reduced: the built in query library, path finding and the Cypher API all work exactly as upstream intends.
Prerequisites
An active Azure subscription, an SSH key, and a VNet and subnet. Standard_B4ms (4 vCPU / 16 GB RAM) is the recommended size — SpecterOps documents a floor of 8 GB RAM and 4 cores for a standard BloodHound environment and warns about out of memory errors during startup below that, so a 2 vCPU / 4 GB size is not sufficient. NSG inbound rules: allow 22/tcp from your management CIDR for SSH, and 80/tcp from the CIDR that needs the BloodHound web UI and API (add 443/tcp if you put your own TLS in front).
Step 1: Connect over SSH
Replace <vm-ip> with the public IP of your VM. The default login user is azureuser.
ssh azureuser@<vm-ip>
Step 2: Confirm the services are active
sudo systemctl is-active docker.service bloodhound.service
docker --version
docker compose version
You should see active printed twice, the Docker Engine version, and the Docker Compose v2 plugin version.

Step 3: Confirm the BloodHound containers are running
cd /opt/bloodhound && sudo docker compose -f docker-compose.yml --env-file .env ps
You should see exactly two services in the Up state: bloodhound (the API server and UI, published as 0.0.0.0:80->8080/tcp) and app-db (PostgreSQL 18, reported as 5432/tcp with no host mapping, because the database is deliberately not published).

Step 4: Confirm the data disk is mounted
Every piece of BloodHound state — the application database and the whole attack path graph — lives on a dedicated data disk mounted at /var/lib/bloodhound, which is where Docker's data-root points.
df -h /var/lib/bloodhound
findmnt -no SOURCE,TARGET,FSTYPE /var/lib/bloodhound
sudo docker info --format 'Docker data-root: {{.DockerRootDir}}'

To grow the volume later, resize the disk in the Azure portal and then run sudo resize2fs /dev/disk/azure/scsi1/lun0.
Step 5: Retrieve your per-VM admin password
The image ships with no default or shared credential. On the first boot, bloodhound-firstboot.service generates a fresh PostgreSQL password, a fresh JWT signing key and a unique admin password for this VM, then writes them to a root-only file.
sudo cat /root/bloodhound-credentials.txt

This password is deliberately single use. The appliance marks the initial credential as expired, so BloodHound will require you to choose a new password the moment you sign in. Keep the new password safe — this file is not updated when you change it.
Step 6: Sign in to the BloodHound web UI
Browse to http://<vm-ip>/ui/login and sign in as admin with the password from Step 5.

Because the initial credential is issued already expired, BloodHound immediately asks you to set a new one. Enter the password from Step 5 as the Expired password, then choose and confirm your new password.

BloodHound enforces its own complexity policy on the new password: it must contain at least one uppercase character and at least one special character.
Step 7: Check the API is healthy
BloodHound exposes an unauthenticated health endpoint, which is the right thing to point a load balancer or Azure health probe at.
curl -s -o /dev/null -w 'health: HTTP %{http_code}\n' http://127.0.0.1/health
curl -s -o /dev/null -w 'ui: HTTP %{http_code}\n' http://127.0.0.1/ui/login
Both should report HTTP 200.
Step 8: Authenticate against the REST API
Everything the UI does is available over the REST API. This block reads the initial per-VM password straight from the credentials file, so it works as-is on a freshly launched VM. Once you have changed your password in Step 6, set BH_PASS to your new password instead — the credentials file is not updated when you rotate.
BH_PASS="$(sudo grep '^BLOODHOUND_ADMIN_PASSWORD=' /root/bloodhound-credentials.txt | cut -d= -f2-)"
curl -s -X POST http://127.0.0.1/api/v2/login \
-H 'Content-Type: application/json' \
-d "{\"login_method\":\"secret\",\"username\":\"admin\",\"secret\":\"${BH_PASS}\"}" \
| python3 -c 'import json,sys; d=json.load(sys.stdin).get("data"); print("login OK auth_expired:", d["auth_expired"], " token length:", len(d["session_token"])) if d else sys.exit("login failed")'
auth_expired reports whether BloodHound still wants a password change. While it is true, the session token is deliberately refused by the rest of the API — that is the forced rotation doing its job. Rotate your password (Step 6) and re-authenticate to get a token that can drive ingest and queries.
Step 9: Ingest the sample dataset and explore real attack paths
A genuine SharpHound collection is staged on the image so you can see BloodHound working against real data before you point it at your own environment.
ls -la /opt/bloodhound/sample-data/
Upload it through the UI: sign in, choose Quick Upload in the left hand navigation, and drop in the JSON files from /opt/bloodhound/sample-data/ad_sampledata.zip (unzip it locally first). BloodHound ingests the files and then runs its analysis pass to compute derived edges.
Once analysis finishes, open Explore, switch to the CYPHER tab and expand Saved Queries. BloodHound CE ships a library of prebuilt attack path queries.

Selecting All Domain Admins runs it and renders the graph — every principal with a transitive MemberOf path into a Domain Admins group, across each domain in the dataset.

To collect from your own environment, use Download Collectors in the left hand navigation to get the SharpHound (Active Directory) or AzureHound (Entra ID and Azure) collector that matches this server version, run it against your environment, and upload the output the same way.
Step 10: Managing the service
sudo systemctl restart bloodhound.service
sudo systemctl status bloodhound.service --no-pager | head -12
The unit is a oneshot wrapper around Docker Compose, so restart brings the whole stack down and back up. To read application logs:
cd /opt/bloodhound && sudo docker compose -f docker-compose.yml --env-file .env logs --tail 20 bloodhound
Step 11: Back up the database
Because PostgreSQL holds both the application state and the graph, a single dump captures everything.
cd /opt/bloodhound
sudo docker compose -f docker-compose.yml --env-file .env exec -T app-db \
sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' | gzip > /tmp/bloodhound-backup.sql.gz
ls -lh /tmp/bloodhound-backup.sql.gz
Copy the resulting file off the VM and keep it with your other backups.
Security notes
- No shared credential ships in the image. Upstream's example Compose file defaults both the PostgreSQL and Neo4j passwords to the published literal
bloodhoundcommunityedition, and the upstream Debian package defaults the admin password toadmin. None of those values exists anywhere in this image. Every secret — the database password, the JWT signing key and the admin password — is generated on your VM at first boot. - The initial admin password is single use. BloodHound issues it already expired, so it must be changed at first sign in before the account can do anything.
- The database is not exposed. PostgreSQL declares no host port, so it is reachable only from the BloodHound container over the private Docker network, and authentication there is
scram-sha-256. - Only port 80 is published. Put the VM behind an Azure Application Gateway, a load balancer with TLS termination, or your own reverse proxy before exposing it beyond a trusted network, and restrict
80/tcpin the NSG to the CIDRs that need it. - BloodHound holds a complete map of privilege relationships in your directory. Treat the VM, its backups and its NSG rules as you would a tier zero asset.
Support
cloudimg provides 24/7 support for this image. For BloodHound itself, see the SpecterOps documentation.