Batfish on Ubuntu 24.04 on Azure User Guide
Overview
Batfish builds a vendor independent model of your network from the configuration files your devices already have, then answers questions about it before you deploy anything. Give it a snapshot of router, switch and firewall configurations and it will tell you which BGP sessions will actually establish, which flows can reach which destinations, what an ACL really permits, which structures are defined but never used, and what would change if you pushed a candidate configuration. It reads Cisco IOS, IOS XE, IOS XR, NX OS, Arista EOS, Juniper Junos, Palo Alto PAN OS, Cumulus, SONiC, F5 BIG IP, Fortinet, Check Point and more, so a mixed estate is modelled as one network rather than a pile of syntaxes.
The cloudimg image runs the official Batfish service build 2025.07.07.2423 as a plain JVM systemd unit on OpenJDK 17, bound to loopback 127.0.0.1:19996, behind nginx on port 9996.
There is no web interface, and this guide does not pretend otherwise. Batfish is a service you query from a client, not a dashboard you log into. The supported client is pybatfish, the official Python SDK, and every example below is a real pybatfish session against the appliance. Port 9996 is the port pybatfish talks to by default, so this appliance is a drop in for a stock client with no configuration beyond an address and a key.
Secure by default, no default credential. Batfish ships with no authentication anyone should rely on: its API key header defaults to the published constant 00000000000000000000000000000000 and, under its default authorizer, is not checked at all. An exposed Batfish is an open relay that accepts arbitrary snapshot uploads and burns your CPU on anyone's analysis. This image never exposes it. The JVM binds loopback only, nginx is the sole ingress and is default deny at server scope, and a batfish-firstboot.service oneshot generates a unique per VM API key and HTTP Basic password on each VM's first boot. Before it will allow nginx to start at all, first boot proves against a throwaway listener that an anonymous request, the published default key, and a one character mutation of your own key are each answered with exactly 401. As a second independent layer, Batfish's own file authorizer is switched on against a users file holding only this VM's key, so even a caller who reached the loopback port directly is refused.
What is included:
- Batfish
2025.07.07.2423, pinned by registry digest and tied to upstream commit7a0efda8257058576ae3787925e8c720dc918820 batfish.serviceon OpenJDK 17, bound to127.0.0.1:19996, with the JVM heap bounded per VM- nginx on port
9996with a per VM gate, ready for TLS - Batfish's own file authorizer enabled with a per VM key, as defence in depth
pybatfish 2025.7.7.2423preinstalled in a virtualenv at/opt/batfish/pybatfish-venv, version matched to the server- A bundled 15 device example network and a
batfish-democommand that analyses it end to end - 75 upstream question templates
- A full dependency licence inventory at
/usr/share/batfish/dependency-licences.txtand upstream licences at/usr/share/batfish/THIRD-PARTY-NOTICES - An unauthenticated
/nginx-healthendpoint for load balancer probes, answered by nginx itself - Ubuntu 24.04 LTS base, fully patched, with unattended security updates left enabled
Prerequisites
An active Azure subscription, an SSH key pair, and a VNet with a subnet.
Batfish is a JVM service and it is memory hungry on real networks. Recommended VM size: Standard_B2ms (2 vCPU, 8 GB RAM), which gives roughly a 4.3 GB heap and comfortably handles the bundled example plus small to medium snapshots of a few dozen devices. For hundreds of devices, or heavy dataplane and reachability work, use a Standard_D4s_v5 or Standard_E4s_v5 or larger. The heap is sized from the machine's real memory on first boot, so moving to a bigger VM size is all that is required; there is nothing to tune by hand. This appliance deliberately ships no swap, because Azure image certification rejects swap on the captured OS disk, so the heap bound is what keeps a large analysis from being killed.
Step 1: Deploy from the Azure Portal
Search the Marketplace for Batfish on Ubuntu 24.04, choose your VM size, and attach an NSG that allows TCP 22 (SSH) from your management network and TCP 9996 (the Batfish API) from the workstations, CI runners and automation that will query it. Batfish holds your device configurations, so treat 9996 as an administrative port and do not open it to the internet. Front it with TLS in production (see the HTTPS section below).
Step 2: Deploy from the Azure CLI
az vm create \
--resource-group my-rg \
--name batfish-01 \
--image cloudimg:batfish-ubuntu-24-04:default:latest \
--size Standard_B2ms \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
az network nsg rule create \
--resource-group my-rg \
--nsg-name batfish-01NSG \
--name allow-batfish \
--priority 1010 \
--protocol Tcp --destination-port-ranges 9996 \
--source-address-prefixes <your-mgmt-cidr> \
--access Allow
Step 3: Connect via SSH
ssh azureuser@<vm-ip>
Step 4: Verify the services and the whole port surface
First boot generates this VM's secrets, proves the gate, starts Batfish and runs the bundled analysis. It completes in well under a minute. Check that everything is up, and confirm for yourself that the only externally reachable ports are SSH and the gate:
systemctl is-active batfish.service nginx.service batfish-firstboot.service
ss -lntp
grep BATFISH_HEAP_OPTS /etc/default/batfish
curl -s http://127.0.0.1:9996/nginx-health
You should see all three services active, and in the socket list exactly one routable Batfish related listener — nginx on 0.0.0.0:9996 — with the JVM itself on 127.0.0.1:19996 and nothing else. The heap line shows the bound first boot computed for this machine.

Step 5: Read your per VM API key
Every secret on this appliance is generated on this VM, on its first boot. Nothing is baked into the image and no two VMs share a credential.
sudo ls -l /root/batfish-credentials.txt
sudo cat /root/batfish-credentials.txt
BATFISH_API_KEY is what a pybatfish client presents. BATFISH_USERNAME and BATFISH_PASSWORD are an HTTP Basic credential for curl and any generic HTTP client. Now prove to yourself that the appliance refuses everything else:
BF_URL=http://127.0.0.1:9996/v2/networks
KEY=$(sudo grep -m1 '^BATFISH_API_KEY=' /root/batfish-credentials.txt | cut -d= -f2-)
DEFAULT_KEY=00000000000000000000000000000000
ANON=$(curl -s -o /dev/null -w '%{http_code}' "$BF_URL")
DEF=$(curl -s -o /dev/null -w '%{http_code}' -H "X-Batfish-Apikey: $DEFAULT_KEY" "$BF_URL")
MINE=$(curl -s -o /dev/null -w '%{http_code}' -H "X-Batfish-Apikey: $KEY" "$BF_URL")
printf 'anonymous -> HTTP %s\n' "$ANON"
printf 'published default API key -> HTTP %s\n' "$DEF"
printf 'your own per VM API key -> HTTP %s\n' "$MINE"
test "$ANON" = 401 && test "$DEF" = 401 && test "$MINE" = 200 && echo GATE_PROOF_OK
The first two return 401 and only the third returns 200. The published default key is refused deliberately: a pybatfish client that has not been given a key sends exactly that value, so an unconfigured client cannot reach your network data by accident.

Step 6: Run the bundled example analysis
The appliance ships upstream's own example network — 13 Cisco IOS devices and 2 hosts across three autonomous systems, with external BGP announcements — plus a batfish-demo command that uploads it and analyses it through the gate with a stock pybatfish client. First boot has already run this once; run it again whenever you want to confirm the service is healthy end to end.
sudo batfish-demo
This parses the snapshot, reports any parse or conversion issues, lists the devices Batfish modelled, and evaluates every BGP session in the network. UNIQUE_MATCH means the two ends agree and the session will establish; anything else is a finding you would want to know about before deployment.

The first boot run is kept at /var/log/batfish-firstboot-demo.log:
sudo tail -20 /var/log/batfish-firstboot-demo.log
Step 7: Query the appliance from your own workstation
Install the client. Pin it to the same version as the server so the client and service question sets match:
pip install pybatfish==2025.7.7.2423
Then point a Session at the appliance. This is the whole of the client side configuration — an address, the port, and the API key you read in Step 5:
from pybatfish.client.session import Session
bf = Session(
host="<vm-ip>",
port=9996,
api_key="<paste BATFISH_API_KEY from Step 5>",
)
bf.set_network("my-network")
bf.init_snapshot("/path/to/my/configs", name="candidate", overwrite=True)
print(bf.q.initIssues().answer().frame())
print(bf.q.bgpSessionCompatibility().answer().frame())
pybatfish always sends its key in the X-Batfish-Apikey header, which is exactly what the gate checks, so no client side changes, wrappers or proxies are needed. For other HTTP clients the same key is also accepted as Authorization: Bearer <key> or in X-Api-Key, and the HTTP Basic credential from Step 5 works too (run on the appliance, it reads its own credentials file):
BF_USER=$(sudo grep -m1 '^BATFISH_USERNAME=' /root/batfish-credentials.txt | cut -d= -f2-)
BF_PASS=$(sudo grep -m1 '^BATFISH_PASSWORD=' /root/batfish-credentials.txt | cut -d= -f2-)
curl -sf -u "${BF_USER}:${BF_PASS}" http://127.0.0.1:9996/v2/networks && echo BASIC_AUTH_OK
Step 8: Trace every path across the network
Reachability is where Batfish earns its keep. Ask it where a packet would actually go, and it returns every equal cost path, hop by hop, with a disposition for each:
from pybatfish.datamodel.flow import HeaderConstraints
answer = bf.q.traceroute(
startLocation="@enter(as2dept1[GigabitEthernet1/0])",
headers=HeaderConstraints(dstIps="1.0.2.1", srcIps="2.34.201.10"),
).answer().frame()
for trace in answer.iloc[0]["Traces"]:
print(trace.disposition, " -> ".join(hop.node for hop in trace.hops))
Run that against the bundled example on the appliance itself:
sudo /opt/batfish/pybatfish-venv/bin/python - "$(sudo grep -m1 '^BATFISH_API_KEY=' /root/batfish-credentials.txt | cut -d= -f2-)" <<'PYEOF'
import sys, logging
logging.getLogger("pybatfish").setLevel(logging.ERROR)
from pybatfish.client.session import Session
from pybatfish.datamodel.flow import HeaderConstraints
bf = Session(host="127.0.0.1", port=9996, api_key=sys.argv[1])
bf.set_network("cloudimg-example")
bf.set_snapshot("example")
answer = bf.q.traceroute(
startLocation="@enter(as2dept1[GigabitEthernet1/0])",
headers=HeaderConstraints(dstIps="1.0.2.1", srcIps="2.34.201.10"),
).answer().frame()
n = 0
for _, row in answer.iterrows():
for trace in row["Traces"]:
n += 1
print(f"path {n}: {trace.disposition}, {len(trace.hops)} hops")
print(" " + " -> ".join(hop.node for hop in trace.hops))
print(f"{n} equal-cost paths, AS2 access switch to AS1 edge")
print("ipOwners:", len(bf.q.ipOwners().answer().frame()), "addresses inventoried")
print("undefinedReferences:", len(bf.q.undefinedReferences().answer().frame()), "defects found")
PYEOF
Batfish computes the dataplane on demand and returns all four equal cost paths from the AS2 access switch out to the AS1 edge, seven hops each. ipOwners inventories every address in the snapshot and undefinedReferences finds structures a configuration refers to but never defines — a class of latent defect that static analysis catches and a ping never will.

Step 9: Analyse your own network
Batfish reads a snapshot: a directory of the configuration files your devices already produce. The minimum layout is a configs/ subdirectory containing one file per device, named however you like:
my-snapshot/
configs/
core-sw-01.cfg
edge-rtr-01.cfg
fw-01.conf
Collect those with whatever you already use — RANCID, Oxidized, NAPALM, your vendor's own archive, or a git repository of golden configurations — then pass the directory to init_snapshot(). Optional subdirectories let you model more: hosts/ for host JSON, iptables/ for host firewalls, batfish/isp_config.json to model upstream ISPs, and an external_bgp_announcements.json to inject routes your peers advertise. The bundled example shows all of these:
find /usr/share/batfish/example-snapshot -type f | sort
Browse the questions available to you:
ls /usr/share/batfish/questions/stable/ | head -40
Batfish never touches your live devices. It reads text and reasons about it, so running it against a candidate configuration change is completely safe.
Step 10: Enable HTTPS
The gate speaks plain HTTP so the appliance works immediately on a VM address. For production, terminate TLS in the same nginx server block:
sudo cp /etc/nginx/sites-available/batfish.conf /root/batfish.conf.bak
sudo nginx -t
Add your certificate and key, change listen 9996; to listen 9996 ssl;, and reload. Leave the auth_basic and auth_basic_user_file directives and the proxy_set_header X-Batfish-Apikey line exactly as they are — they are the gate, and nginx refuses to start without them. Then point clients at https:// and set ssl=True on the Session. An Azure Application Gateway or Front Door in front of the VM works equally well.
Ports, the security model, and how clients authenticate
| Port | Bound to | Purpose |
|---|---|---|
| 22 | all interfaces | SSH administration |
| 9996 | all interfaces | The Batfish API, behind the nginx gate |
| 19996 | 127.0.0.1 only |
The Batfish JVM. Not reachable from the network |
Those are the only ports this appliance opens. nginx is default deny at server scope, so every route inherits the gate and there is no exemption for any path that reaches the service. The single unauthenticated route is /nginx-health, which nginx answers itself from a static literal and which proxies nothing, so a load balancer probe reveals nothing about your network data.
A request is admitted if, and only if, it presents one of this VM's own generated secrets, compared as an exact string:
- this VM's API key in
X-Batfish-Apikey— thepybatfishidiom - this VM's API key in
Authorization: Bearer <key> - this VM's API key in
X-Api-Key - HTTP Basic with this VM's username and password
Anything else, including a one character mutation, is answered 401. Because Batfish's own authorizer only ever reads X-Batfish-Apikey, the gate normalises the credential and forwards this VM's key to the service on every request that got past it, so all four idioms work end to end. That variable is empty in the shipped image, so the image cannot authenticate to itself.
Both nginx.service and batfish.service carry an ExecStartPre guard that refuses to start if the gate map, the credential file, the authorizer users file or the heap bound are missing, empty or still in their shipped form, and nginx.service additionally will not even be considered for start until first boot has written a marker proving the gate denies an unauthenticated request. Take the credential file away and nginx stays down rather than coming up open.
Persistence, first boot and updates
Networks and snapshots you upload live under /var/lib/batfish/containers, which is on the OS disk and survives reboots. Back that directory up if the analyses matter to you; it is safe to delete a network through the API to reclaim space.
First boot runs once. It is gated on /var/lib/cloudimg/batfish-firstboot.done, so a reboot does not rotate your key. If you want to rotate credentials, remove the sentinel and the per VM state and restart the unit:
sudo systemctl status batfish-firstboot.service --no-pager
The OS keeps patching itself through unattended-upgrades, which includes the OpenJDK the service runs on — that is exactly why the JRE comes from the Ubuntu archive rather than from inside a container image. To move to a newer Batfish build, deploy a fresh cloudimg image version rather than patching in place, so the artifact stays pinned and verified.
The full dependency licence inventory for everything in the image, with each dependency's resolved licence, is at:
head -20 /usr/share/batfish/dependency-licences.txt
Support
Batfish is licensed Apache 2.0 and this image carries no additional licence restrictions. Upstream licences, including the MIT grant covering the bundled JavaBDD library, are reproduced in full at /usr/share/batfish/THIRD-PARTY-NOTICES.
cloudimg provides 24/7 support for this image with a 24 hour response SLA. Contact support@cloudimg.co.uk.