OPAL on Ubuntu 24.04 on Azure User Guide
Overview
OPAL (Open Policy Administration Layer) keeps authorization policy in sync, in real time. You keep your policy in a git repository; OPAL server watches that repository, and the moment you commit a change OPAL pushes it out to every Open Policy Agent in your fleet. Applications then ask Open Policy Agent for authorization decisions and always get an answer based on the current policy — no redeploy, no restart, no polling loop of your own.
OPAL on its own is infrastructure rather than a finished system, so this image ships a complete single node appliance: OPAL server, OPAL client and Open Policy Agent, installed, wired together and working from first boot.
What is included:
- OPAL 0.9.6 (server and client) installed into an isolated Python virtualenv at
/opt/opal/venv, run by an unprivilegedopalsystem account - Open Policy Agent 1.18.2 at
/usr/local/bin/opa, run by an unprivilegedopasystem account, bound to loopback - A working default policy repository seeded on the VM at
/var/lib/opal/policy-repo.git, containing an example Rego v1 policy — repointable at your own repository with a single configuration change - nginx on TCP 443 with a per-VM TLS certificate and per-VM HTTP basic-auth credential, exposing only Open Policy Agent's decision API
- Every secret — master token, RS256 signing keypair, client JWT, basic-auth password, TLS certificate — generated uniquely on the first boot of every VM into a root-only file
- A shipped self-test at
/usr/local/sbin/opal-selftest.shthat proves the whole round trip end to end - 24/7 cloudimg support

A note on the upstream project. OPAL is actively maintained but moves slowly, and we would rather you knew that up front. The repository is not archived and recent commits are genuine engineering work by the Permit.io team, but the cadence in 2026 has been roughly one to three commits per month, and there was a four month period between late November 2025 and the end of March 2026 with no commits to the default branch at all. OPAL has also not yet reached a 1.0 release. It is a sound, widely used project — over five thousand GitHub stars — but do not size your plans around rapid upstream fixes.
Prerequisites
An active Azure subscription, an SSH key pair, and a VNet plus subnet in the target region. Standard_B2s (2 vCPU / 4 GiB RAM) is a sensible starting point. NSG inbound: allow 22/tcp from your management network and 443/tcp from the applications that will ask this appliance for authorization decisions.
Port 80 is deliberately not used, and Open Policy Agent is deliberately not exposed — see Security model.
Step 1 — Deploy from the Azure Marketplace
Sign in to the Azure Portal, choose Create a resource, search the Marketplace for OPAL by cloudimg, and select Create. On Basics pick your subscription, resource group, region and size; under Administrator account choose SSH public key and paste your key; under Inbound port rules allow SSH (22) and HTTPS (443). Then Review + create → Create.
Step 2 — Deploy from the Azure CLI
az vm create \
--resource-group <your-rg> \
--name opal \
--image <marketplace-image-urn> \
--size Standard_B2s \
--admin-username azureuser \
--ssh-key-values ~/.ssh/id_ed25519.pub \
--vnet-name <your-vnet> --subnet <your-subnet> \
--public-ip-sku Standard
az vm open-port --resource-group <your-rg> --name opal --port 443 --priority 1010
Step 3 — Connect to your VM
ssh azureuser@<vm-public-ip>
Step 4 — Confirm the appliance is running
Six units make up the appliance. They start in a strict order on every boot: opal-firstboot generates this VM's secrets and seeds its policy repository, then Open Policy Agent and OPAL server start, then opal-issue-client-token mints the client's JWT from the running server, then OPAL client connects and begins syncing, and finally nginx starts.
for s in opal-firstboot opa opal-server opal-issue-client-token opal-client nginx; do
printf '%-34s %s\n' "$s.service" "$(systemctl is-active $s.service)"
done
Expected output:
opal-firstboot.service active
opa.service active
opal-server.service active
opal-issue-client-token.service active
opal-client.service active
nginx.service active
Check what is listening. Only 22 and 443 are reachable from outside the VM; the entire control plane is bound to loopback:
ss -tln | tail -n +2 | awk '{print $4}' | sort -u
0.0.0.0:22
0.0.0.0:443
127.0.0.1:7000
127.0.0.1:7002
127.0.0.1:8181
127.0.0.53%lo:53
127.0.0.54:53
[::]:22
[::]:443
8181 is Open Policy Agent, 7002 is OPAL server and 7000 is OPAL client — all loopback only.
Step 5 — Retrieve this VM's credentials
Every secret is generated on the first boot of every VM and written to a root-only file. Two VMs launched from this same image never share a credential.
sudo cat /root/opal-credentials.txt
# cloudimg OPAL appliance - generated on FIRST BOOT by opal-firstboot.service.
# Every value below is unique to THIS VM. Store them somewhere safe.
#
# Appliance address (HTTPS, self-signed certificate):
OPAL_APPLIANCE_URL=https://20.124.112.113/
OPA_DECISION_URL=https://20.124.112.113/v1/data/cloudimg/authz/allow
# HTTP basic-auth credential for the exposed decision API on port 443:
OPAL_BASIC_AUTH_USER=opal
OPAL_BASIC_AUTH_PASSWORD=****************
# OPAL server master token (administrative - mints client tokens):
OPAL_AUTH_MASTER_TOKEN=****************
The appliance ships with a self-signed TLS certificate, so the examples below pass curl -k. Replace it with your own certificate for production — see Replacing the TLS certificate.
Step 6 — Ask for an authorization decision
The seeded policy lives in package cloudimg.authz, so its allow rule is exposed at /v1/data/cloudimg/authz/allow. Send the facts of the request as input:
curl -sk -u opal:<OPAL_BASIC_AUTH_PASSWORD> \
-X POST https://127.0.0.1/v1/data/cloudimg/authz/allow \
-H 'Content-Type: application/json' \
-d '{"input":{"user":"alice","action":"read"}}'
{"result":true}
Now ask about a user the policy does not permit:
curl -sk -u opal:<OPAL_BASIC_AUTH_PASSWORD> \
-X POST https://127.0.0.1/v1/data/cloudimg/authz/allow \
-H 'Content-Type: application/json' \
-d '{"input":{"user":"bob","action":"read"}}'
{"result":false}
The policy denies by default, so anything it does not explicitly allow comes back false.

Step 7 — Look at the policy OPAL is serving
OPAL server tracks a git repository. The image seeds a working default one on the VM so the appliance is useful immediately:
grep -E '^OPAL_POLICY_REPO_URL=|^OPAL_POLICY_REPO_MAIN_BRANCH=|^OPAL_POLICY_REPO_POLLING_INTERVAL=' /etc/opal/server.env
OPAL_POLICY_REPO_URL=file:///var/lib/opal/policy-repo.git
OPAL_POLICY_REPO_MAIN_BRANCH=master
OPAL_POLICY_REPO_POLLING_INTERVAL=15
The policy itself:
sed -n '/^package/,$p' /var/lib/opal/policy-work/policies/example.rego
package cloudimg.authz
import rego.v1
# Deny by default. Every allow rule below must match explicitly.
default allow := false
# alice may read.
allow if {
input.user == "alice"
input.action == "read"
}
# Maintainers may read and write.
allow if {
input.role == "maintainer"
input.action in {"read", "write"}
}
/var/lib/opal/policy-work is a working clone you can edit and commit in; /var/lib/opal/policy-repo.git is the bare repository OPAL server actually watches.
Step 8 — Watch a policy change propagate live
This is what OPAL is for. Change the policy in git, and the authorization decision changes — with no restart and no redeploy. The block below adds a rule allowing bob to read, waits for OPAL to distribute it, then rolls the change back:
cd /var/lib/opal/policy-work
GIT="sudo -u opal -H git -c user.email=opal@cloudimg.local -c user.name=cloudimg"
decision() { curl -sk -u opal:<OPAL_BASIC_AUTH_PASSWORD> -X POST https://127.0.0.1/v1/data/cloudimg/authz/allow -H 'Content-Type: application/json' -d '{"input":{"user":"bob","action":"read"}}'; }
echo "before: $(decision)"
cat >> policies/example.rego <<'REGO'
allow if {
input.user == "bob"
input.action == "read"
}
REGO
$GIT commit --quiet -am "allow bob to read"
$GIT push --quiet origin master
for i in $(seq 1 20); do
[ "$(decision)" = '{"result":true}' ] && { echo "after: $(decision) (propagated in ~$((i*2))s)"; break; }
sleep 2
done
$GIT revert --no-edit --quiet HEAD
$GIT push --quiet origin master
for i in $(seq 1 20); do
[ "$(decision)" = '{"result":false}' ] && { echo "reverted: $(decision)"; break; }
sleep 2
done
before: {"result":false}
after: {"result":true} (propagated in ~18s)
reverted: {"result":false}
The change reached Open Policy Agent within one polling interval. OPAL_POLICY_REPO_POLLING_INTERVAL is set to 15 seconds on this image; lower it for faster propagation, or drive updates instantly with a git webhook instead of polling.

Step 9 — Point OPAL at your own policy repository
The seeded repository is a starting point. To serve your own policies, change one setting:
sudo sed -i 's|^OPAL_POLICY_REPO_URL=.*|OPAL_POLICY_REPO_URL=https://github.com/your-org/your-policy-repo.git|' /etc/opal/server.env
sudo systemctl restart opal-server.service
Other settings in /etc/opal/server.env you will likely want:
| Setting | Purpose |
|---|---|
OPAL_POLICY_REPO_MAIN_BRANCH |
Branch to track. Defaults to master — set it to main for most GitHub repositories. |
OPAL_POLICY_REPO_POLLING_INTERVAL |
Seconds between polls. Set to 0 to disable polling and drive updates by webhook instead. |
OPAL_POLICY_REPO_SSH_KEY |
Deploy key for a private repository. |
OPAL_FILTER_FILE_EXTENSIONS |
Which files form the policy bundle. Defaults to .rego,.json. This is a comma separated list, not JSON — a value like [".rego"] is parsed literally and silently matches nothing. |
Your .rego files can live anywhere in the repository; OPAL bundles them all. After restarting OPAL server, confirm the new bundle arrived:
sudo journalctl -u opal-client.service --no-pager | grep -c 'Got policy bundle'
Step 10 — Run the built-in self-test
The image ships the same end-to-end test the build pipeline uses. It loads a known policy, asserts the expected allow and deny decisions, commits a policy change and asserts the decision genuinely changes, reverts it, and then runs the security negative controls:
sudo /usr/local/sbin/opal-selftest.sh
[1/3] policy distributed: alice/read=allow, bob/read=deny, alice/write=deny
[2/3] LIVE UPDATE: a git commit flipped bob/read deny => allow
[3/3] security: unauth=401, dev-secret=401, per-VM cred=200, PUT=403, /v1/policies=403, control plane loopback-only
SELFTEST_OK
Security model
Open Policy Agent's API is unauthenticated by default, and its Data API can overwrite policy. An exposed Open Policy Agent is therefore a complete authorization bypass — anyone who can reach it can rewrite the rules that decide who is allowed to do what. This image is built around that fact:
- Open Policy Agent is bound to
127.0.0.1:8181and is never reachable from outside the VM. - OPAL server (
127.0.0.1:7002) and OPAL client (127.0.0.1:7000) are loopback only. OPAL's own control channels are additionally authenticated with a per-VM master token and an RS256 JWT signed by a per-VM 4096-bit key. Upstream ships the placeholderTHIS_IS_A_DEV_SECRETas the default for these; this image overrides both. - nginx on 443 is the only externally reachable service. It requires HTTP basic auth over TLS and proxies a deliberately narrow slice of Open Policy Agent:
GETandPOSTunder/v1/data/for decision queries, plus/health. Every other path returns 403, and/v1/policiesis never proxied at all. - The exposed surface cannot change anything.
PUT,PATCHandDELETEare rejected before they reach Open Policy Agent, so even a caller holding the basic-auth credential can only ask questions, not rewrite policy or data.
You can confirm all of this yourself:
D='{"input":{"user":"alice","action":"read"}}'
c() { curl -sk -o /dev/null -w '%{http_code}' -m 15 "$@"; }
printf '%-46s HTTP %s\n' 'no credentials' "$(c -X POST https://127.0.0.1/v1/data/cloudimg/authz/allow -H 'Content-Type: application/json' -d "$D")"
printf '%-46s HTTP %s\n' 'this VM credential' "$(c -u opal:<OPAL_BASIC_AUTH_PASSWORD> -X POST https://127.0.0.1/v1/data/cloudimg/authz/allow -H 'Content-Type: application/json' -d "$D")"
printf '%-46s HTTP %s\n' 'PUT (overwrite attempt)' "$(c -u opal:<OPAL_BASIC_AUTH_PASSWORD> -X PUT https://127.0.0.1/v1/data/cloudimg/x -H 'Content-Type: application/json' -d '[1]')"
printf '%-46s HTTP %s\n' 'GET /v1/policies' "$(c -u opal:<OPAL_BASIC_AUTH_PASSWORD> https://127.0.0.1/v1/policies)"
no credentials HTTP 401
this VM credential HTTP 200
PUT (overwrite attempt) HTTP 403
GET /v1/policies HTTP 403

If you need applications on other hosts to query this appliance, allow 443/tcp from just those hosts in the NSG and give them the basic-auth credential. Do not open 8181.
Replacing the TLS certificate
The per-VM certificate is self-signed, which is why the examples use curl -k. To use your own:
sudo cp your-cert.crt /etc/opal/tls/server.crt
sudo cp your-cert.key /etc/opal/tls/server.key
sudo chown root:www-data /etc/opal/tls/server.key
sudo chmod 0640 /etc/opal/tls/server.key
sudo nginx -t && sudo systemctl reload nginx
Changing the basic-auth credential
sudo htpasswd -B /etc/opal/htpasswd opal
sudo systemctl reload nginx
Distributing data as well as policy
OPAL distributes data as well as policy — the facts your policies reason over, such as role assignments or entitlements pulled from your own systems. That channel is driven by OPAL data sources rather than by files in the policy repository, and it is not configured on this image because it depends entirely on where your data lives. See the OPAL data sources documentation to wire it to a database, an API or a message bus.
Maintenance
The image ships fully patched and leaves Ubuntu's unattended-upgrades enabled, so OS security updates continue to apply on your VM. To update the appliance components:
# Open Policy Agent
sudo curl -fsSL -o /usr/local/bin/opa https://github.com/open-policy-agent/opa/releases/download/<tag>/opa_linux_amd64_static
sudo chmod 0755 /usr/local/bin/opa
sudo systemctl restart opa.service
# OPAL
sudo /opt/opal/venv/bin/pip install --upgrade opal-server opal-client
sudo systemctl restart opal-server.service opal-client.service
The exact dependency set this image was built with is recorded at /opt/opal/requirements.lock.txt.
Logs for any component:
sudo journalctl -u opal-server.service --no-pager | tail -20
Licensing
OPAL is licensed under the Apache License 2.0, and Open Policy Agent is licensed under the Apache License 2.0. This image bundles no source-available or non-commercial components; the full resolved dependency set was checked at the artifact level before the image was built.
Support
Backed by 24/7 cloudimg support. Contact support@cloudimg.co.uk.