Networking Azure

Batfish on Ubuntu 24.04 on Azure User Guide

| Product: Batfish on Ubuntu 24.04 LTS on Azure

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 commit 7a0efda8257058576ae3787925e8c720dc918820
  • batfish.service on OpenJDK 17, bound to 127.0.0.1:19996, with the JVM heap bounded per VM
  • nginx on port 9996 with 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.2423 preinstalled in a virtualenv at /opt/batfish/pybatfish-venv, version matched to the server
  • A bundled 15 device example network and a batfish-demo command that analyses it end to end
  • 75 upstream question templates
  • A full dependency licence inventory at /usr/share/batfish/dependency-licences.txt and upstream licences at /usr/share/batfish/THIRD-PARTY-NOTICES
  • An unauthenticated /nginx-health endpoint 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.

Terminal showing batfish, nginx and batfish-firstboot all active, the complete listening socket list with nginx on port 9996 and the Batfish JVM bound to loopback 19996 only, the per VM JVM heap bound, and the unauthenticated health endpoint returning OK

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.

Terminal showing the root only 0600 credentials file, then the API returning HTTP 401 for an anonymous request, for the published default Batfish API key, for a one character mutation of this VM key and for the key sent in a wrong header, returning HTTP 200 only for this VM own key, and Batfish own file authorizer independently returning 401 on the loopback port

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.

Terminal showing batfish-demo connecting with this VM API key, parsing the bundled snapshot with zero parse or conversion issues, listing all 15 modelled devices, and printing a BGP session compatibility table of 37 sessions with UNIQUE_MATCH, NO_LOCAL_IP and UNKNOWN_REMOTE statuses

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.

Terminal showing a stock pybatfish client running a traceroute through the gate and returning four equal cost ACCEPTED paths of seven hops each from as2dept1 out to as1border2 across three autonomous systems, plus 54 addresses inventoried by ipOwners and one configuration defect found by undefinedReferences

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 — the pybatfish idiom
  • 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.