Wallarm API Firewall on Ubuntu 24.04 on Azure User Guide
Overview
This guide covers the deployment and configuration of Wallarm API Firewall on Ubuntu 24.04 on Azure using cloudimg Azure Marketplace images.
API Firewall is a reverse proxy with a built in OpenAPI 3.0 validator. You give it the specification your API already publishes, and it sits in front of that API checking traffic in both directions. Every incoming request is checked against the declared paths, parameters, types and body schemas. Every response your service sends back is checked against the declared response schemas. Anything that does not match the contract is refused.
That is a positive security model, and it is the opposite of how most API protection works. Instead of maintaining a list of attacks to recognise, you allow only the traffic your specification describes and reject everything else by default. A wrongly typed parameter, a body with a field you never declared, a call to an endpoint that exists but was never documented, or a response that suddenly carries a field it should not, are all refused without anybody having to anticipate them.
API Firewall is written in Go, runs as a single process with no database, and is built for near zero added latency in front of production traffic.
API Firewall has no accounts, no datastore and no interface of its own. A bare install therefore demonstrates nothing. This cloudimg image ships a complete, working reference deployment instead: the firewall, a demo upstream API, the real OpenAPI 3.0 specification that describes it, and a browser console that fires live conforming and non conforming requests through the firewall so the model is visible rather than merely described. When you are ready, you replace two files and it protects your own service.
Wallarm API Firewall is an independent open source project. This image is produced by cloudimg and is not affiliated with, endorsed by, or sponsored by Wallarm. It ships the free and open source Mozilla Public License 2.0 licensed software, unmodified.

What is included:
- Wallarm API Firewall v0.9.6 — the official upstream
linux/amd64release binary, pinned by sha256 content hash (566db1cc…d37b44), not by a moving tag - The upstream Mozilla Public License 2.0, shipped on the image at
/usr/share/api-firewall/LICENSE.upstream - A cloudimg appliance console that shows what the firewall is enforcing and runs six live validation scenarios against it
- A demo upstream API and the OpenAPI 3.0 specification that describes it, so the appliance is provably working on first boot
- nginx as the single hardened front door, with an HTTP Basic gate whose credential is generated per VM on first boot
- Ubuntu 24.04 LTS, fully patched at build time, with unattended security updates enabled
Key facts:
| Item | Value |
|---|---|
| Platform | Ubuntu 24.04 LTS |
| Default SSH user | azureuser |
| Console and protected API | http://<vm-ip>/ (HTTP Basic) |
| Firewall listener | 127.0.0.1:8282 |
| Firewall health API | 127.0.0.1:9667 |
| Demo upstream API | 127.0.0.1:8080 |
| Active specification | /etc/api-firewall/openapi.yaml |
| Firewall configuration | /etc/api-firewall/api-firewall.env |
| Per instance credentials | /root/api-firewall-credentials.txt |
Prerequisites
- An active Azure subscription with permission to create virtual machines
- An SSH key pair for
azureuser - A network security group allowing inbound TCP 22 (SSH) and 80 (console and protected API) from your own address ranges
- The Azure CLI, if you prefer to deploy from a terminal
Recommended VM size: Standard_B2s (2 vCPU, 4 GB). API Firewall is a single lightweight Go process and adds very little overhead. Scale up only if the API you place behind it is itself demanding.
Step 1: Deploy from the Azure Portal
- In the Azure Portal, choose Create a resource and search the Marketplace for Wallarm API Firewall on Ubuntu 24.04 LTS by cloudimg
- Select the offer and choose Create
- Pick your subscription, resource group and region
- Set the VM size to Standard_B2s
- Leave the administrator username as azureuser and upload or generate your SSH public key
- Under Inbound port rules, allow SSH (22) and HTTP (80)
- On the Disks tab, choose Standard SSD for the OS disk
- Select Review and create, then Create
Restrict ports 22 and 80 to your own address ranges under Networking. The console is protected by an HTTP Basic gate, but there is no reason to expose it to the whole internet.
Step 2: Deploy from the Azure CLI
az vm create \
--resource-group my-resource-group \
--name api-firewall-01 \
--image cloudimg:api-firewall-ubuntu-24-04:default:latest \
--size Standard_B2s \
--storage-sku StandardSSD_LRS \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
# Then open the console port, scoped to the ranges you control
az vm open-port --resource-group my-resource-group --name api-firewall-01 --port 80 --priority 900
Step 3: Connect to your VM
# If you do not have the address to hand, ask Azure for it first:
az vm show --resource-group my-resource-group --name api-firewall-01 -d --query publicIps -o tsv
ssh azureuser@<vm-ip>
Step 4: Confirm the services are running
Four units make up the appliance. All four should report active.
for U in api-firewall.service cloudimg-demo-api.service nginx.service api-firewall-firstboot.service; do
printf '%-34s %s\n' "$U" "$(systemctl is-active $U)"
done
Expected output:
api-firewall.service active
cloudimg-demo-api.service active
nginx.service active
api-firewall-firstboot.service active
api-firewall-firstboot.service is a one shot unit that runs once, on the very first boot of this VM. It reports active (exited) afterwards, which is how you can tell it completed rather than never ran.
Ask the firewall itself what it is:
curl -s --max-time 5 http://127.0.0.1:9667/v1/liveness
Expected output:
{"status":"up","build":"0.9.6","host":"api-firewall-01"}
Only ports 22 and 80 are reachable from outside the VM. The firewall, its health API and the demo upstream are all bound to loopback, which is what makes the front door the only way in:
ss -lntH | awk '{print $4}' | sort
Step 5: Read the per instance credentials
Nothing is baked into the image. API Firewall ships no accounts at all, and the one credential this appliance uses, the HTTP Basic gate in front of the console and the protected API, was generated on this virtual machine during its first boot. It exists nowhere else.
sudo cat /root/api-firewall-credentials.txt

The file is 0600 root:root, so only root can read it. It contains:
| Key | Meaning |
|---|---|
APIFW_CONSOLE_USERNAME |
The HTTP Basic username, always apifw |
APIFW_CONSOLE_PASSWORD |
A 32 character password generated on this VM |
APIFW_CONSOLE_URL |
The appliance console |
APIFW_LIVENESS_URL |
The firewall liveness probe, as JSON |
APIFW_READINESS_URL |
The firewall readiness probe, as JSON |
APIFW_SPEC_URL |
The specification currently being enforced |
Every URL in that file is dereferenced during first boot and must answer 200 before the boot is allowed to complete, so the appliance can never hand you a link it does not serve.
Step 6: Prove the appliance is closed by default
Before opening anything, confirm the gate is doing its job. Every route refuses an anonymous caller:
for R in / /openapi.yaml /v1/products /firewall/liveness; do
printf ' %-22s HTTP %s\n' "$R" \
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1$R)"
done
Expected output:
/ HTTP 401
/openapi.yaml HTTP 401
/v1/products HTTP 401
/firewall/liveness HTTP 401
And so does every credential an attacker would try first:
for D in admin:admin admin:password apifw:apifw wallarm:wallarm root:root guest:guest; do
printf ' %-22s HTTP %s\n' "$D" \
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 -u $D http://127.0.0.1/)"
done

This is not merely how the appliance happens to behave. During first boot the appliance stands up a throwaway loopback only listener running this VM's real credential file, proves against it that an unauthenticated request is answered with exactly 401, and only then writes the marker that permits nginx to start at all. If that proof fails, port 80 is never opened.
There is exactly one deliberate exception, a two byte liveness probe for a load balancer, which reveals nothing:
curl -s -o /dev/null -w 'nginx-health: HTTP %{http_code}\n' http://127.0.0.1/nginx-health
Step 7: Open the console
Browse to your VM over HTTP and sign in with the username and password from Step 5.
http://<vm-ip>/

The status cards are read from the running process and from the live configuration file, not from anything hard coded, so they always describe what the firewall is actually enforcing right now.
Step 8: Watch the firewall allow and block real traffic
Press Run all scenarios. Each one sends a real HTTP request through the firewall on this VM and shows exactly what came back.
A request the specification describes is proxied to the upstream, and the response is validated on the way back:

A request the specification does not describe never reaches the upstream at all:

The same thing from the command line, using the credentials from Step 5:
U=$(sudo grep '^APIFW_CONSOLE_USERNAME=' /root/api-firewall-credentials.txt | cut -d= -f2-)
P=$(sudo grep '^APIFW_CONSOLE_PASSWORD=' /root/api-firewall-credentials.txt | cut -d= -f2-)
echo "ALLOWED conforming GET : HTTP $(curl -s -o /dev/null -w '%{http_code}' -u "$U:$P" 'http://127.0.0.1/v1/products?limit=3')"
echo "BLOCKED wrong type : HTTP $(curl -s -o /dev/null -w '%{http_code}' -u "$U:$P" 'http://127.0.0.1/v1/products?limit=not-an-integer')"
echo "BLOCKED over the maximum : HTTP $(curl -s -o /dev/null -w '%{http_code}' -u "$U:$P" 'http://127.0.0.1/v1/products?limit=9999')"
echo "BLOCKED shadow endpoint : HTTP $(curl -s -o /dev/null -w '%{http_code}' -u "$U:$P" http://127.0.0.1/v1/leak)"
echo "BLOCKED bad response : HTTP $(curl -s -o /dev/null -w '%{http_code}' -u "$U:$P" http://127.0.0.1/v1/products/0)"
Expected output:
ALLOWED conforming GET : HTTP 200
BLOCKED wrong type : HTTP 403
BLOCKED over the maximum : HTTP 403
BLOCKED shadow endpoint : HTTP 403
BLOCKED bad response : HTTP 403
When the firewall refuses something it says why, in a response header:
U=$(sudo grep '^APIFW_CONSOLE_USERNAME=' /root/api-firewall-credentials.txt | cut -d= -f2-)
P=$(sudo grep '^APIFW_CONSOLE_PASSWORD=' /root/api-firewall-credentials.txt | cut -d= -f2-)
curl -s -D - -o /dev/null -u "$U:$P" 'http://127.0.0.1/v1/products?limit=not-an-integer' \
| tr -d '\r' | grep -iE '^(HTTP/|Apifw-Validation-Status)'
Expected output:
HTTP/1.1 403 Forbidden
Apifw-Validation-Status: request-parameter:unknown:limit

The five things that got blocked, and why
| Request | Why it was refused |
|---|---|
GET /v1/products?limit=not-an-integer |
The specification types limit as an integer |
GET /v1/products?limit=9999 |
The specification caps limit at 50 |
POST /v1/orders with {"productId":"one","quantity":0} |
Wrong type, below the declared minimum, and a required field missing |
GET /v1/leak |
A shadow endpoint: it exists on the upstream but is not in the specification |
GET /v1/products/0 |
Response validation: the upstream answered 200 with a price of "not-a-number" where the schema requires a number |
The last two are the ones worth pausing on. /v1/leak really does exist on the demo upstream and really does return data; the firewall refuses it purely because nobody declared it. And /v1/products/0 is a case where the upstream is the thing behaving badly, and the malformed payload is stopped before the client ever sees it.
Step 9: See what the specification allows
The console renders the enforced contract directly from the file the firewall loaded:

You can read the same thing from the shell:
U=$(sudo grep '^APIFW_CONSOLE_USERNAME=' /root/api-firewall-credentials.txt | cut -d= -f2-)
P=$(sudo grep '^APIFW_CONSOLE_PASSWORD=' /root/api-firewall-credentials.txt | cut -d= -f2-)
curl -s -u "$U:$P" http://127.0.0.1/openapi.json | jq -r '.paths | keys[]'
Expected output:
/v1/orders
/v1/products
/v1/products/{productId}
/v1/status
Anything not in that list is a shadow endpoint and is rejected before it reaches the upstream.
Step 10: Protect your own API
This is the step the whole appliance exists for. Two things change: the specification and the upstream.
Copy your own OpenAPI 3.0 document into place, point the firewall at your service, and restart. Replace <your-domain> with the host your API actually listens on.
# 1. Your own specification becomes the contract the firewall enforces
sudo cp /path/to/my-openapi.yaml /etc/api-firewall/openapi.yaml
sudo chown root:apifw /etc/api-firewall/openapi.yaml
sudo chmod 0640 /etc/api-firewall/openapi.yaml
# 2. Point the firewall at your service
sudo sed -i 's|^APIFW_SERVER_URL=.*|APIFW_SERVER_URL=http://<your-domain>:3000|' \
/etc/api-firewall/api-firewall.env
# 3. Restart, and republish the console's view so it describes the new contract
sudo systemctl restart api-firewall.service
sudo /usr/local/sbin/api-firewall-publish-config.sh
# 4. Once your own API is behind the firewall, retire the demo upstream
sudo systemctl disable --now cloudimg-demo-api.service
The firewall refuses to start on a configuration that does not make sense. A missing specification, an unparseable one, an empty upstream, or an invalid validation mode all fail the preflight check, and systemd reports the start as failed rather than bringing up an appliance that would silently allow everything through. Check the outcome:
systemctl is-active api-firewall.service
A note on your specification. The quality of the protection is exactly the quality of your OpenAPI document. A specification that types a field as a bare string with no constraints allows any string. Declare types, formats, minimums, maximums, required arrays and additionalProperties: false where you can, and describe your response schemas as well as your requests, because that is what turns the response validator on.
Step 11: Onboard an API safely with LOG_ONLY
Switching straight to BLOCK in front of a live API whose specification may be incomplete will reject real traffic. API Firewall has a monitoring mode for exactly this.
sudo sed -i 's|^APIFW_REQUEST_VALIDATION=.*|APIFW_REQUEST_VALIDATION=LOG_ONLY|' /etc/api-firewall/api-firewall.env
sudo sed -i 's|^APIFW_RESPONSE_VALIDATION=.*|APIFW_RESPONSE_VALIDATION=LOG_ONLY|' /etc/api-firewall/api-firewall.env
sudo systemctl restart api-firewall.service
for i in $(seq 1 20); do curl -sf -o /dev/null --max-time 3 http://127.0.0.1:9667/v1/liveness && break; sleep 1; done
grep -E '^APIFW_(REQUEST|RESPONSE)_VALIDATION=' /etc/api-firewall/api-firewall.env
Expected output:
APIFW_REQUEST_VALIDATION=LOG_ONLY
APIFW_RESPONSE_VALIDATION=LOG_ONLY
When you are satisfied your specification is complete, switch both settings back:
sudo sed -i 's|^APIFW_REQUEST_VALIDATION=.*|APIFW_REQUEST_VALIDATION=BLOCK|' /etc/api-firewall/api-firewall.env
sudo sed -i 's|^APIFW_RESPONSE_VALIDATION=.*|APIFW_RESPONSE_VALIDATION=BLOCK|' /etc/api-firewall/api-firewall.env
sudo systemctl restart api-firewall.service
for i in $(seq 1 20); do curl -sf -o /dev/null --max-time 3 http://127.0.0.1:9667/v1/liveness && break; sleep 1; done
grep -E '^APIFW_(REQUEST|RESPONSE)_VALIDATION=' /etc/api-firewall/api-firewall.env
In LOG_ONLY nothing is refused. Every request that would have been blocked is written to the log with the rule it broke, and undocumented endpoints that answer requests are reported as shadow APIs. Run production traffic through it, read the log, fix the gaps in your specification, then switch both settings back to BLOCK.
The three modes are:
| Mode | Behaviour |
|---|---|
BLOCK |
Refuse anything that does not match, with the configured status code. The shipped default |
LOG_ONLY |
Allow everything, record what would have been refused |
DISABLE |
Turn that direction of validation off entirely |
Step 12: Observability
In proxy mode the firewall's observability is its structured JSON log. Every block is recorded with a request id, the path, the method and the exact rule that rejected it.
sudo journalctl -u api-firewall.service --no-pager -n 400 -o cat 2>/dev/null \
| grep 'request blocked' | tail -5 \
| jq -r '"blocked \(.method) \(.path) request_id=\(.request_id)"' || true
Expected output:
blocked GET /v1/products request_id=a614d8e5-c915-48d6-8680-a952e07823f4
blocked POST /v1/orders request_id=1c0f77a2-7bd2-4a05-9a1e-2c0a0b7a3f11
Each of those is one line of JSON in the journal. In full, a single block looks like this, and it names the exact schema rule that rejected the request:
{"level":"error","error":"parameter \"limit\" in query has an error: value not-an-integer:
an invalid integer: invalid syntax","request_id":"a614d8e5-...","host":"127.0.0.1",
"path":"/v1/products","method":"GET","message":"Request validation error: request blocked"}
To follow the log live while you test, run this in a second terminal and leave it running:
sudo journalctl -u api-firewall.service -f -o cat
The liveness and readiness probes are JSON and are suitable for a load balancer or an uptime monitor. Readiness reflects whether the connection pool to your upstream is healthy, so it goes unhealthy when your backend does:
U=$(sudo grep '^APIFW_CONSOLE_USERNAME=' /root/api-firewall-credentials.txt | cut -d= -f2-)
P=$(sudo grep '^APIFW_CONSOLE_PASSWORD=' /root/api-firewall-credentials.txt | cut -d= -f2-)
curl -s -u "$U:$P" http://127.0.0.1/firewall/readiness
Step 13: Optional, expose the firewall port directly
By default the firewall listens on 127.0.0.1:8282 and nginx on port 80 is the only public entrance. That gives you one place to terminate TLS, one place to gate access, and one access log. If you would rather your clients speak to the firewall directly, change its listener and open the port.
# After this, clients speak to http://<vm-ip>:8282/ instead of going through nginx
sudo sed -i 's|^APIFW_URL=.*|APIFW_URL=http://0.0.0.0:8282/|' /etc/api-firewall/api-firewall.env
sudo systemctl restart api-firewall.service
Then allow inbound TCP 8282 in your network security group, scoped to the clients that need it. Note that the firewall itself performs no authentication; it validates shape, not identity. If you expose it directly, authentication is your API's responsibility.
Server components
| Component | Version | Purpose |
|---|---|---|
| Wallarm API Firewall | 0.9.6 | OpenAPI 3.0 request and response validation proxy |
| nginx | Ubuntu 24.04 stock | The single public entrance and the HTTP Basic gate |
| cloudimg demo upstream API | 1.0.0 | The reference service the firewall protects out of the box |
| Ubuntu Server | 24.04 LTS | Operating system |
Filesystem layout
| Path | Purpose |
|---|---|
/usr/local/bin/api-firewall |
The upstream release binary |
/etc/api-firewall/openapi.yaml |
The specification being enforced |
/etc/api-firewall/api-firewall.env |
The firewall configuration |
/usr/share/api-firewall/ |
Shipped templates, licence, artifact hash and build evidence |
/var/www/api-firewall-console/ |
The appliance console and the published specification |
/etc/nginx/api-firewall.htpasswd |
The per VM HTTP Basic credential |
/etc/nginx/sites-available/api-firewall.conf |
The front door configuration |
/root/api-firewall-credentials.txt |
This VM's credentials, mode 0600 |
/var/lib/cloudimg/ |
First boot markers and the sentinel |
Scripts and log files
| Path | Purpose |
|---|---|
/usr/local/sbin/api-firewall-firstboot.sh |
Generates this VM's credential, proves the gate, starts the appliance |
/usr/local/sbin/api-firewall-preflight.sh |
Fail closed guard that runs before the firewall and before nginx |
/usr/local/sbin/api-firewall-publish-config.sh |
Republishes the console's view of the live configuration |
/var/log/nginx/api-firewall.access.log |
Front door access log |
/var/log/nginx/api-firewall.error.log |
Front door error log |
journalctl -u api-firewall |
The firewall's own structured JSON log |
On startup
The first time a VM created from this image boots, api-firewall-firstboot.service runs once. It generates this machine's console password, writes the HTTP Basic file, proves on a throwaway loopback listener that an unauthenticated request is refused with 401, creates the readiness markers, starts the three appliance services, confirms end to end that a conforming request is proxied and a non conforming one is blocked, dereferences every URL it is about to advertise, and finally writes /var/lib/cloudimg/api-firewall-firstboot.done.
Until those markers exist, api-firewall.service, cloudimg-demo-api.service and nginx.service are all skipped by systemd rather than started, because each carries a ConditionPathExists on them. That is what makes the image closed rather than open: it is not that the services start late, it is that they cannot start at all until the appliance has been configured and the gate has been proved on that specific machine.
On every subsequent boot the sentinel already exists, so the first boot unit is skipped and your credential is left alone.
Day to day operation
systemctl status api-firewall.service --no-pager -n 0
Restart the firewall after any configuration or specification change:
sudo systemctl restart api-firewall.service
Change the console password at any time:
sudo htpasswd -bB /etc/nginx/api-firewall.htpasswd apifw '<new-password>'
Confirm the specification the firewall has actually loaded, which is the one it is enforcing:
sudo grep '^APIFW_API_SPECS=' /etc/api-firewall/api-firewall.env
Security hardening
- Restrict the network security group. Allow ports 22 and 80 only from address ranges you control.
- Put TLS in front of it. nginx is already the single entrance, so add a certificate there and redirect port 80 to 443. The appliance ships with plain HTTP because it has no way to know your hostname.
- Change the console password to one your team manages, with
htpasswd -Bas above. - Remember what the firewall does and does not do. It validates the shape of traffic against your contract. It is not an authentication layer and it is not a rate limiter. Keep your API's own authentication in place behind it.
- Retire the demo upstream once your own service is behind the firewall, with
sudo systemctl disable --now cloudimg-demo-api.service. - Keep unattended security updates enabled. They are on by default in this image.
Troubleshooting
The console returns 401 and you are sure the password is right. Confirm you are using the username apifw and the password from /root/api-firewall-credentials.txt on this VM. Every VM has a different one; a password from another instance will never work.
A request you expected to succeed is blocked with 403. The firewall is telling you your specification does not describe it. Read the reason:
sudo journalctl -u api-firewall.service --no-pager -n 400 -o cat 2>/dev/null \
| grep 'request blocked' | tail -3 \
| jq -r '"\(.method) \(.path) request_id=\(.request_id)"' || true
The Apifw-Validation-Status response header carries the same verdict.
api-firewall.service will not start after a configuration change. The preflight guard refused it. It prints the exact reason:
sudo journalctl -u api-firewall.service --no-pager -n 40 \
| grep -i 'REFUSING TO START' || echo '(no preflight refusal recorded, the firewall configuration is valid)'
Common causes are a specification path that does not exist, a document that is not valid OpenAPI 3, an empty APIFW_SERVER_URL, or a validation mode that is not BLOCK, LOG_ONLY or DISABLE.
nginx will not start. It is conditioned on the first boot markers and on the credential file. If any is missing it is skipped rather than started:
ls -l /var/lib/cloudimg/ /etc/nginx/api-firewall.htpasswd
Everything is being blocked after you swapped in your own specification. Check that the paths in your document match the paths your clients actually call, including any prefix. Switch to LOG_ONLY as described in Step 11, run real traffic through it, and read the log before switching back to BLOCK.
The readiness probe reports not ready. The firewall cannot reach the upstream named in APIFW_SERVER_URL. Confirm your backend is listening and reachable from the VM.
Support
This image is packaged and supported by cloudimg with 24/7 support. Wallarm API Firewall is open source software under the Mozilla Public License 2.0; the licence shipped with the binary is on the image at /usr/share/api-firewall/LICENSE.upstream.
For questions about this image or its deployment, contact cloudimg support. For questions about API Firewall itself, see the upstream documentation at https://wallarm.github.io/api-firewall/.