Security Azure

Wallarm API Firewall on Ubuntu 24.04 on Azure User Guide

| Product: Wallarm API Firewall on Ubuntu 24.04 LTS on Azure

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.

The four appliance services active after first boot, the firewall reporting build 0.9.6 on its health API, and the listening sockets showing only ports 22 and 80 routable

What is included:

  • Wallarm API Firewall v0.9.6 — the official upstream linux/amd64 release 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

  1. An active Azure subscription with permission to create virtual machines
  2. An SSH key pair for azureuser
  3. A network security group allowing inbound TCP 22 (SSH) and 80 (console and protected API) from your own address ranges
  4. 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

  1. In the Azure Portal, choose Create a resource and search the Marketplace for Wallarm API Firewall on Ubuntu 24.04 LTS by cloudimg
  2. Select the offer and choose Create
  3. Pick your subscription, resource group and region
  4. Set the VM size to Standard_B2s
  5. Leave the administrator username as azureuser and upload or generate your SSH public key
  6. Under Inbound port rules, allow SSH (22) and HTTP (80)
  7. On the Disks tab, choose Standard SSD for the OS disk
  8. 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 per instance credentials file, owned by root with mode 0600, listing the console username, the generated password, and the console, liveness, readiness and specification URLs for this VM

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

Every route on the appliance answering 401 to an anonymous caller, six well known credentials all refused, and only this VM's own generated credential admitted with 200

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 appliance console showing the firewall live, build 0.9.6, upstream readiness ok, request and response validation both set to BLOCK, the protected upstream address, the configured block status code and the active specification path

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 conforming GET request for three products returning ALLOWED, HTTP 200, with the upstream JSON payload rendered in full

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

An undocumented shadow endpoint blocked with HTTP 403 and the reason customRoute not found, and a malformed upstream response blocked with HTTP 403 because the response body does not match the Product schema

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 full command line transcript, showing a conforming GET returning 200 with the upstream payload, a conforming POST returning 201, four classes of violation all returning 403, and the Apifw-Validation-Status header naming the parameter that failed

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:

The endpoints table listing the four documented paths with the constraints enforced on each, including parameter types and ranges, required body properties, and the response codes that are validated

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

  1. Restrict the network security group. Allow ports 22 and 80 only from address ranges you control.
  2. 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.
  3. Change the console password to one your team manages, with htpasswd -B as above.
  4. 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.
  5. Retire the demo upstream once your own service is behind the firewall, with sudo systemctl disable --now cloudimg-demo-api.service.
  6. 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/.