Security Azure

OPAL on Ubuntu 24.04 on Azure User Guide

| Product: OPAL on Ubuntu 24.04 LTS on Azure

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 unprivileged opal system account
  • Open Policy Agent 1.18.2 at /usr/local/bin/opa, run by an unprivileged opa system 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.sh that proves the whole round trip end to end
  • 24/7 cloudimg support

OPAL appliance status and listener posture

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 + createCreate.

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.

The seeded policy and live authorization decisions

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.

A policy change propagating live from git to Open Policy Agent

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:8181 and 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 placeholder THIS_IS_A_DEV_SECRET as 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: GET and POST under /v1/data/ for decision queries, plus /health. Every other path returns 403, and /v1/policies is never proxied at all.
  • The exposed surface cannot change anything. PUT, PATCH and DELETE are 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

Per-VM credentials and the security controls on the exposed listener

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.