BFE on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and configuration of BFE on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images. BFE (originally Baidu Front End) is an open source layer 7 load balancer and reverse proxy, and a Cloud Native Computing Foundation sandbox project.
BFE sits at the edge of your estate, terminates client traffic and forwards it to the right backend. Routing decisions are made on the content of the request rather than just the port it arrived on: host, path, header, cookie, query string and client address are all first class matching conditions, expressed in a small condition language. Matched traffic is balanced across named clusters and sub clusters with health checking, weighting and configurable retries, so an unhealthy backend drops out of rotation without you touching anything.
BFE speaks HTTP, HTTPS, HTTP/2, WebSocket, gRPC and FastCGI, carries a modular plugin framework for rewriting, redirecting, blocking, tagging and rate limiting traffic, and exposes rich built in counters so you can see exactly what your edge is doing.
The image ships the official upstream BFE 1.8.4 release binary, fetched from the project's own GitHub release and pinned by SHA-256. The exact artifact, its digest and its provenance are recorded on the image at /opt/bfe/VERSION. BFE runs under systemd as bfe.service as an unprivileged bfe account.
A load balancer that is actually load balancing the moment it boots. A proxy with no backends is an empty daemon, and on a fresh VM you have not configured any backends yet. So the image ships one: a tiny demo backend bound to 127.0.0.1:8081 that BFE's default configuration routes every request to. On first boot a real request travels through BFE end to end and comes back with the backend's page. Because that backend is reachable only over loopback, a response carrying its marker can only have arrived by being proxied — and BFE stamps its own X-Proxied-By: bfe header onto the way out, so both ends of the journey are visible in a single curl -i. You then repoint BFE at your own servers and remove the demo backend.
A per instance TLS certificate, and no key in the image. The HTTPS listener needs a certificate. Rather than baking one in, which would put the same private key on every customer VM, the image ships no certificate and no key at all. Both the certificate and the TLS session ticket key are generated on your VM at first boot, and bfe.service is gated so it physically cannot start before that bootstrap has completed.
The metrics endpoint is not on the public interface. BFE's monitor port is more than metrics: alongside the counters it also serves an unauthenticated /reload (live configuration reload) and a /debug surface, and upstream offers no bind address setting for it. The image therefore keeps port 8421 — and the demo backend on 8081 — reachable only through a loopback address, enforced in the kernel with an nftables rule. The proxy listeners on 8080 and 8443 stay open, because those are the product.
No default login of any kind. BFE has no credential to leak: it is a stateless traffic router with no console, no account and no password. The upstream release does ship sample credentials — two seeded HTTP basic accounts whose passwords are written in the file, a JWT signing key, a published TLS session ticket key and four private keys — and every one of them is removed at build time, with the build failing closed if any survives.
What is included:
-
The official BFE 1.8.4 release binary, pinned by SHA-256 and redistributed unmodified, run under systemd as
bfe.service -
A working routing configuration out of the box: a default product, a route rule and a health checked cluster pointing at a bundled loopback demo backend
-
HTTP on 8080 and HTTPS on 8443 (HTTP/2 negotiated via ALPN), with a per instance self signed certificate generated at first boot
-
A hardened
bfe.conf: RC4 and 3DES cipher suites removed, SSLv2 ClientHello compatibility disabled, and the loaded module set trimmed to nine, leaving the WebAssembly plugin and AI gateway modules unloaded -
An nftables rule keeping the monitor and demo backend ports on loopback only
-
bfe-selftest, a shipped command that proves in one line that a real request traversed BFE, that the metrics port is not exposed, and that BFE's own counters moved -
The Apache-2.0 licence text retained on the image at
/opt/bfe/LICENSE -
Ubuntu 24.04 LTS base with latest security patches applied at build time
-
Azure Linux Agent for seamless cloud integration and SSH key injection
-
24/7 cloudimg support with guaranteed 24 hour response SLA
cloudimg is not affiliated with or endorsed by the BFE project, the CNCF or Baidu.
Prerequisites
-
Active Azure subscription, SSH public key, VNet + subnet in target region
-
Subscription to the BFE listing on Azure Marketplace
-
The backend servers you intend to put behind BFE, reachable from the VM (you can deploy first and add them later — the bundled demo backend means the appliance works standalone)
Recommended virtual machine size: Standard_B2s (2 vCPU, 4 GB RAM) is a sensible starting point; BFE idles at around 12 MB of memory. Load balancers are bound by connection and request rate rather than by storage, so for busy edges move to a compute optimised size such as Standard_F4s_v2 and give BFE more cores via MaxCpus in bfe.conf.
Step 1: Deploy from the Azure Portal
Search BFE in Marketplace, select the cloudimg publisher, click Create. NSG rules: TCP 8080 (the HTTP proxy listener), TCP 8443 (the HTTPS proxy listener) and TCP 22 (admin SSH), each from the networks you serve or connect from. Do not open 8421 or 8081 — those are the monitor endpoint and the demo backend, and the image deliberately keeps them private.
Step 2: Deploy from the Azure CLI
az vm create \
--resource-group my-bfe-rg \
--name my-bfe-vm \
--image cloudimg:bfe:default:latest \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
# Then open the two proxy listeners to the networks that need them
az vm open-port --resource-group my-bfe-rg --name my-bfe-vm --port 8080 --priority 1010
az vm open-port --resource-group my-bfe-rg --name my-bfe-vm --port 8443 --priority 1020
Step 3: Connect and confirm BFE is running
Log in over SSH as azureuser. First boot generates this VM's TLS material and starts the load balancer, which takes a few seconds after the VM reports ready.
sudo systemctl is-active bfe.service
sudo systemctl is-active nginx.service
/opt/bfe/bin/bfe -v 2>&1 | grep -o 'bfe version: .*'
Expected output:
active
active
bfe version: 1.8.4
The nginx.service above is not a second web server for your traffic. It is the bundled demo backend, bound to 127.0.0.1:8081, that gives BFE something real to route to before you have added your own servers.
Confirm which process owns which port. BFE holds the two public listeners plus the loopback-restricted monitor; the demo backend is bound to the loopback address only:
sudo ss -lntp | grep -E 'bfe|nginx'

The image records exactly which upstream artifact it shipped:
cat /opt/bfe/VERSION
Step 4: Prove a real request travels through BFE
This is the check worth running first, because it exercises the whole path rather than just a listening socket.
curl -si http://127.0.0.1:8080/ | head -12
Expected output:
HTTP/1.1 200 OK
Accept-Ranges: bytes
Connection: keep-alive
Content-Length: 1245
Content-Type: text/html
Date: Fri, 07 Aug 2026 17:26:53 GMT
Etag: "6a7614f1-4dd"
Last-Modified: Fri, 07 Aug 2026 17:25:05 GMT
Server: nginx
X-Cloudimg-Backend: bfe-demo-backend
X-Proxied-By: bfe
Two things in that response together prove the traversal:
-
X-Cloudimg-Backend: bfe-demo-backendand the page body come from the demo backend, which listens only on127.0.0.1:8081and is firewalled off every other address. Something really did reach it. -
X-Proxied-By: bfewas added on the way out by BFE's ownmod_header. Only BFE's module chain can produce that header.
Neither header alone would be enough. A listening socket, or an HTTP 200, would not be enough either — that is why the shipped self test asserts on both.
The same journey works over TLS, where ALPN negotiates HTTP/2:
curl -sik https://127.0.0.1:8443/ | head -5
Expected output:
HTTP/2 200
accept-ranges: bytes
content-type: text/html
date: Fri, 07 Aug 2026 17:27:03 GMT
etag: "6a7614f1-4dd"
-k is expected here: first boot generates a self signed certificate for this VM. Replace it with a certificate for your own domain in Step 7.
Run the shipped self test for a single line covering all of it:
sudo /usr/local/sbin/bfe-selftest
Expected output:
bfe-selftest OK: a real request traversed BFE on :8080 and :8443 and returned the loopback-only backend's response (marker present, X-Proxied-By: bfe present); monitor :8421 and backend :8081 answer on loopback and are refused off-box; per-VM certificate in use; request counter 9 -> 11; no seeded credentials in /etc/bfe

From your own workstation, the same request against the VM's public address returns the same page. Substitute your VM's IP:
curl -i http://<your-vm-public-ip>:8080/
Step 5: Read this VM's endpoint summary
First boot writes a summary of this VM's endpoints and file locations. It is root only, mode 0600, and it holds no password because BFE has none.
sudo cat /root/bfe-endpoints.txt | head -14
Expected output (your address will differ):
# BFE — layer-7 load balancer. Generated on first boot. Unique to THIS VM.
#
bfe.host=20.25.114.98
bfe.url=http://20.25.114.98:8080/
bfe.url_tls=https://20.25.114.98:8443/
bfe.http_port=8080
bfe.https_port=8443
bfe.monitor=http://127.0.0.1:8421/monitor/proxy_state
bfe.config=/etc/bfe/bfe.conf
bfe.backends=/etc/bfe/cluster_conf/cluster_table.data
bfe.routes=/etc/bfe/server_data_conf/route_rule.data
bfe.tls_cert=/etc/bfe/tls_conf/certs/cloudimg.crt
bfe.selftest=/usr/local/sbin/bfe-selftest
Step 6: Point BFE at your own backends
BFE separates what the request is from where it goes across a few small JSON files under /etc/bfe. The three you will edit are:
| File | Decides |
|---|---|
server_data_conf/host_rule.data |
which product a hostname belongs to |
server_data_conf/route_rule.data |
which cluster a request in that product goes to |
cluster_conf/cluster_table.data |
which backend instances make up that cluster |
The shipped configuration is deliberately the smallest thing that works. Every request falls to a default product, which routes to one cluster, which contains the single loopback demo backend:
cat /etc/bfe/cluster_conf/cluster_table.data
Expected output:
{
"Version": "cloudimg-1",
"Config": {
"cluster_demo": {
"demo.local": [
{
"Addr": "127.0.0.1",
"Name": "cloudimg_demo_backend",
"Port": 8081,
"Weight": 10
}
]
}
}
}
To use your own servers, replace that instance list. Each entry is one backend, and Weight sets its share of traffic within the sub cluster:
{
"Version": "cloudimg-2",
"Config": {
"cluster_demo": {
"demo.local": [
{ "Addr": "10.0.1.10", "Name": "app-1", "Port": 8080, "Weight": 50 },
{ "Addr": "10.0.1.11", "Name": "app-2", "Port": 8080, "Weight": 50 }
]
}
}
}
The cluster's health check lives in server_data_conf/cluster_conf.data. It defaults to GET /healthz expecting a 200, which is what the demo backend serves — point it at whatever path your own application exposes:
grep -A6 '"CheckConf"' /etc/bfe/server_data_conf/cluster_conf.data
To route by hostname, add your domains to host_rule.data and give each product its own rule in route_rule.data. Conditions are written in BFE's condition language — req_host_in(), req_path_prefix_in(), req_header_value_in(), req_cookie_value_in() and so on — and are evaluated in order, so put specific rules above default_t():
{
"Version": "cloudimg-2",
"ProductRule": {
"cloudimg_demo": [
{ "Cond": "req_path_prefix_in(\"/api\", false)", "ClusterName": "cluster_api" },
{ "Cond": "default_t()", "ClusterName": "cluster_demo" }
]
}
}
Any new cluster name you introduce also needs an entry in cluster_conf.data (its timeouts and health check) and in cluster_conf/gslb.data (its traffic split across sub clusters).
Apply configuration changes with a reload, which is graceful and does not drop connections:
sudo systemctl reload bfe

Once your own backends are serving, remove the demo backend, which exists only so the appliance can prove itself on a bare VM:
sudo systemctl disable --now nginx
sudo rm -f /etc/nginx/sites-enabled/cloudimg-bfe-demo.conf
Step 7: Install your own TLS certificate
First boot generated a self signed certificate for this VM. To serve your own domain, replace the pair and reload — the file names are already referenced by tls_conf/server_cert_conf.data, so nothing else needs editing:
sudo install -o root -g bfe -m 0644 fullchain.pem /etc/bfe/tls_conf/certs/cloudimg.crt
sudo install -o root -g bfe -m 0640 privkey.pem /etc/bfe/tls_conf/certs/cloudimg.key
sudo systemctl reload bfe
Confirm which certificate the listener is actually serving:
echo | openssl s_client -connect 127.0.0.1:8443 2>/dev/null | openssl x509 -noout -subject -dates
To match a certificate to a specific hostname rather than serving one default, add the name to SniConf in /etc/bfe/tls_conf/tls_rule_conf.data and add a second entry under CertConf in server_cert_conf.data.
Step 8: Watch what your edge is doing
BFE keeps detailed counters and exposes them on the monitor port. Ask for the request totals:
curl -s "http://127.0.0.1:8421/monitor/proxy_state?format=json" | jq '{HTTP_CLIENT_REQ_SERVED:.CounterData.HTTP_CLIENT_REQ_SERVED, CLIENT_REQ_SERVED:.CounterData.CLIENT_REQ_SERVED, CLIENT_REQ_FAIL:.CounterData.CLIENT_REQ_FAIL}'
Expected output (your numbers will differ):
{
"HTTP_CLIENT_REQ_SERVED": 11,
"CLIENT_REQ_SERVED": 13,
"CLIENT_REQ_FAIL": 0
}
Check that your backends are registered and in rotation:
curl -s "http://127.0.0.1:8421/monitor/bal_table_status?format=json" | jq .
Expected output:
{
"Balancers": {
"cluster_demo": {
"SubClusters": {
"GSLB_BLACKHOLE": {
"BackendNum": 0
},
"demo.local": {
"BackendNum": 1
}
},
"BackendNum": 1
}
},
"BackendNum": 1
}
Dozens of other endpoints are available — proxy_delay, http_state, bal_state, proxy_mem_stat, per module counters and a _diff variant of most of them for interval sampling. List them:
curl -s http://127.0.0.1:8421/monitor | tr '<' '\n' | grep -oE 'href="/monitor/[a-z_.0-9]*"' | head -12
Per request logging is on by default and lands in /var/log/bfe, rotated hourly:
sudo grep -o 'host: [^ ]* product: [^ ]* .*status: [0-9]*' /var/log/bfe/access.log | tail -2
Expected output:
host: 127.0.0.1:8080 product: cloudimg_demo user_agent: curl/8.5.0 status: 200
host: 127.0.0.1:8443 product: cloudimg_demo user_agent: curl/8.5.0 status: 200
Why the monitor port is not open to the internet
The monitor port is not a read only metrics endpoint. Alongside the counters, BFE serves an unauthenticated /reload that reloads configuration, and a /debug surface — and upstream provides no bind address option to restrict them. Exposing 8421 therefore exposes a control plane.
The image handles this in the kernel rather than by convention. An nftables table accepts these ports only when the destination is a loopback address and drops them otherwise:
sudo nft list table inet cloudimg_bfe
Expected output:
table inet cloudimg_bfe {
chain input {
type filter hook input priority filter; policy accept;
ip daddr 127.0.0.0/8 tcp dport { 8081, 8421 } accept
ip6 daddr ::1 tcp dport { 8081, 8421 } accept
tcp dport { 8081, 8421 } drop
}
}
Matching the destination address rather than the input interface is deliberate: Linux routes traffic addressed to the VM's own routable address over the loopback interface, so an interface based rule would have left the port open on the VM's real addresses while still appearing to pass a 127.0.0.1 test. You can prove the restriction from the VM itself — the metrics answer on loopback and time out on this VM's own private address, while the proxy listener answers on both:
PRIV=$(hostname -I | awk '{print $1}')
echo "monitor via loopback : $(curl -s -o /dev/null -w '%{http_code}' -m 5 http://127.0.0.1:8421/monitor/proxy_state)"
echo "monitor via ${PRIV} : $(curl -s -o /dev/null -w '%{http_code}' -m 5 http://${PRIV}:8421/monitor/proxy_state)"
echo "proxy via ${PRIV} : $(curl -s -o /dev/null -w '%{http_code}' -m 8 http://${PRIV}:8080/)"
Expected output — 000 is curl reporting that the connection never completed, which is the point:
monitor via loopback : 200
monitor via 10.0.0.17 : 000
proxy via 10.0.0.17 : 200
To let a monitoring system scrape the counters, add an accept rule for the addresses you trust above the drop in /etc/nftables.d/cloudimg-bfe.nft, then sudo systemctl reload nftables. Open it to the internet only if you have put your own authentication in front of it.

Step 9: Enable additional modules
Nine modules are loaded by default: mod_trust_clientip, mod_block, mod_header, mod_rewrite, mod_redirect, mod_logid, mod_tag, mod_access and mod_prison. Their rule files ship empty, ready for your rules.
Many more are compiled in but not loaded. See everything available:
curl -s "http://127.0.0.1:8421/monitor/module_status?format=json" | jq -r '.available[]' | head -20
The image ships configuration only for the modules it loads. That is deliberate: several unloaded modules' upstream sample files hard code third-party addresses (mod_tcp_keepalive's sample names a public Baidu address, mod_key_log's names 10.0.0.x hosts), which have no place on your VM. mod_wasm (WebAssembly plugins) and the AI gateway modules are likewise not loaded, and the prebuilt binary plugin upstream ships with mod_wasm is not on the image at all.
To enable another module: add a Modules = <name> line to /etc/bfe/bfe.conf, create /etc/bfe/<name>/ with that module's configuration (take the template from the upstream release tarball or the module documentation), then reload.
BFE validates module configuration at startup and refuses to run if it is wrong. Importantly, it exits with a success status when it does so, meaning systemctl reports the unit as having deactivated cleanly rather than failed. Always confirm a reload or restart actually took by checking BFE's own warning log, not the exit code:
sudo cat /var/log/bfe/bfe.wf.log
An empty file means no errors. If BFE refused to start, the reason is on the last line there.
Troubleshooting
BFE says it started but nothing is listening. BFE exits with status 0 even on a fatal configuration error, so systemd will report "Deactivated successfully". Read /var/log/bfe/bfe.wf.log — the [EROR] line names the module and the field that was rejected.
The proxy returns 500 or 503. BFE has no healthy backend. Check the balancer with /monitor/bal_table_status as in Step 8. A BackendNum of 0 means your instances are not in cluster_table.data; instances that are listed but failing the health check are being taken out of rotation, so verify the CheckConf path in cluster_conf.data really returns the expected status code from your servers.
A new hostname is not routing. host_rule.data maps hostnames to products, and a request whose Host header matches nothing falls through to DefaultProduct. Add the hostname to a tag under Hosts, and make sure the tag is listed against your product under HostTags.
The service will not start after a reboot. bfe.service will not start until /var/lib/cloudimg/bfe-bootstrap-ready exists, which first boot creates after generating this VM's TLS material. Check sudo systemctl status bfe-firstboot.service and sudo cat /var/log/cloudimg-firstboot.log.
Requests are logged with the wrong client IP. If BFE sits behind another proxy or an Azure Load Balancer, configure mod_trust_clientip with your trusted ranges so the real client address is taken from the forwarded header.
Security Notes
-
No default credentials of any kind. BFE is a stateless traffic router with no console, no account and no password, so there is nothing to rotate. The sample credentials the upstream release ships — two seeded HTTP basic accounts, a JWT signing key, a published TLS session ticket key and four private keys — are all removed at build time, and the build fails closed if any survives.
-
No key material in the image. The TLS certificate, its private key and the TLS session ticket key are generated on your VM at first boot.
bfe.serviceis gated on a marker that first boot creates only after that has happened, so the load balancer cannot serve traffic before it has its own key material. The private key isroot:bfemode0640. -
The monitor port is a control plane. It serves an unauthenticated
/reloadand a/debugsurface as well as metrics, and is restricted to loopback addresses in the kernel. Treat any decision to expose it as you would exposing an admin API. -
Hardened TLS. RC4 and 3DES cipher suites are removed from the upstream default list and SSLv2 ClientHello compatibility is disabled.
-
Reduced module surface. The WebAssembly plugin module and the AI gateway modules are not loaded, and the prebuilt sample WebAssembly plugin the upstream release ships is not present on the image.
-
Restrict the proxy listeners to the networks you actually serve using NSG rules, and keep 8421 and 8081 closed.
-
Keep the OS patched. Unattended security upgrades are enabled; apply BFE updates by replacing
/opt/bfe/bin/bfewith a newer upstream release and restarting the service.
Support
cloudimg provides 24/7 support with a guaranteed 24 hour response SLA for all Azure Marketplace images. Contact support@cloudimg.co.uk.
Upstream project documentation is at bfe-networks.net and the source is at github.com/bfenetworks/bfe.