OpenFGA on Ubuntu 24.04 on Azure User Guide
Overview
OpenFGA is an open source fine-grained authorization server, built on the relationship-based model described in Google's Zanzibar paper. Instead of scattering permission checks through your application code, you store relationships — alice is a viewer of the roadmap document — describe the types and relations that exist in an authorization model, and then ask the server questions at request time: may this user do this thing to this object?
It is a CNCF project, and official SDKs are published for JavaScript, Go, Python, Java and .NET. Everything is reachable over an HTTP (REST) API and a gRPC API.
The cloudimg image delivers OpenFGA 1.20.0 on Ubuntu 24.04, served over HTTPS, backed by PostgreSQL, with a unique API token generated on the first boot of your VM. Backed by 24/7 cloudimg support.
What is included:
- OpenFGA 1.20.0 as a single Go binary at
/usr/local/bin/openfga, run by systemd as the unprivilegedopenfgauser and bound to127.0.0.1:8080only - nginx terminating TLS on port 443 with a certificate generated for your VM; port 80 only redirects to HTTPS (plus a plain
/healthzfor load balancer probes) - PostgreSQL 16 on the loopback address only, so stores, authorization models and relationship tuples survive restarts
- Pre-shared key authentication enforced. OpenFGA's own default is no authentication at all; this image never runs in that posture, and the development Playground console is disabled
- No shared credential: the API token, the database password and the TLS key are all generated on your VM's first boot and written to a root-only file
- A starter store and a sample authorization model, created on first boot so there is something to explore immediately
postgresql,openfgaandnginxsystemd services, enabled and active
OpenFGA is a trademark of its respective owner. All product and company names are trademarks or registered trademarks of their respective holders. Use of them does not imply any affiliation with or endorsement by them. This image packages the unmodified open source software, which is distributed under the Apache License 2.0.

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) runs OpenFGA comfortably for typical application authorization traffic; choose a larger size for high check volumes or very large relationship graphs. Network security group inbound rules: 22/tcp from your management network, 443/tcp for the API, and optionally 80/tcp, which only redirects to HTTPS.
Step 1: Deploy from the Azure Marketplace
Sign in to the Azure Portal, choose Create a resource, search the Marketplace for OpenFGA 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 select Review + create and Create.
Step 2: Deploy from the Azure CLI
az vm create \
--resource-group my-resource-group \
--name my-openfga \
--image cloudimg:openfga-ubuntu-24-04:default:latest \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
Then open the ports you need:
az vm open-port --resource-group my-resource-group --name my-openfga --port 443 --priority 1001
Step 3: Connect to your VM
ssh azureuser@<vm-public-ip>
The image has no other login account. The build account used to create the image is removed before capture, so azureuser (or whichever name you chose at deployment) is the only way in.
Step 4: Confirm the OpenFGA stack is running
systemctl is-active postgresql openfga nginx
active
active
active
Check the version that is installed:
sudo /usr/local/bin/openfga version 2>&1 | sed 's#^[0-9/]* [0-9:]* ##'
OpenFGA version `v1.20.0` build from `73591ef16ce508623920d5b706286ffcdfb6841b` on `2026-09-08T20:19:22Z`
(OpenFGA writes that line to standard error and prefixes it with the current time, which is why it is redirected and the timestamp trimmed.)
And confirm the API answers through nginx:
curl -sk https://127.0.0.1/healthz
ok
Step 5: Check what the network can reach
This is worth doing deliberately on an authorization server. Only three TCP ports are reachable from outside the VM — SSH, the HTTPS redirect, and the API itself. The OpenFGA HTTP API, its gRPC API, its Prometheus metrics endpoint and PostgreSQL are all bound to the loopback address and cannot be reached from the network at all:
sudo ss -Hltn | awk '{print $4}' | sort -u
0.0.0.0:22
0.0.0.0:443
0.0.0.0:80
127.0.0.1:2112
127.0.0.1:5432
127.0.0.1:8080
127.0.0.1:8081
127.0.0.53%lo:53
127.0.0.54:53
[::]:22
[::]:443
[::]:80
The image ships an executable assertion of that exact set, so you can re-check it at any time — after your own configuration changes, for example:
sudo /usr/local/sbin/openfga-port-check.sh
off-box TCP exactly [22 80 443]; API 8080, gRPC 8081, metrics 2112, PostgreSQL 5432 all loopback
no Playground on 3000, no pprof on 3001
OPENFGA_PORTS_OK
Step 6: Retrieve the first-boot API token
No credential ships in this image. On the first boot your VM generates its own 256-bit API token, database password and TLS certificate, and writes the credentials to a root-only file:
sudo cat /root/openfga-credentials.txt
# OpenFGA on Ubuntu 24.04 LTS by cloudimg
# Generated on this VM's first boot — unique to this VM. Keep this file secret.
#
# OpenFGA ships with NO authentication by default. This image runs it with
# pre-shared key authentication, and the key below is the ONLY credential that
# reaches the API. Every request must carry it:
#
# curl -sk -H "Authorization: Bearer $OPENFGA_API_TOKEN" https://<vm-public-ip>/stores
#
OPENFGA_API_TOKEN=<your-token>
OPENFGA_API_URL=https://<vm-public-ip>
OPENFGA_HOST=<vm-public-ip>
Both that file and the environment file the service reads the token from are 0600 root:root:
sudo stat -c '%A %U:%G %n' /root/openfga-credentials.txt /etc/openfga/openfga.env
-rw------- root:root /root/openfga-credentials.txt
-rw------- root:root /etc/openfga/openfga.env

Copy the token somewhere safe — a secret manager rather than a file on disk — and treat it as you would a database password.
Step 7: Confirm the API refuses everything but your token
This image exists to make the following true. OpenFGA's upstream default is no authentication: run it with no flags and the entire management and query API is open to anyone who can reach the port. Here, a request with no credential is refused:
curl -sk -o /dev/null -w '%{http_code}\n' https://127.0.0.1/stores
401
So is a request carrying the wrong token:
curl -sk -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer not-the-right-token" https://127.0.0.1/stores
401
And your own token is accepted:
curl -sk -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer <OPENFGA_API_TOKEN>" https://127.0.0.1/stores
200
The image ships that whole proof as one command, which also confirms the Playground is not listening:
sudo /usr/local/sbin/openfga-verify-auth.sh
unauthenticated 401; wrong key 401; empty bearer 401; per-VM key 200; no Playground
OPENFGA_AUTH_OK

Why the Playground is disabled
OpenFGA ships a browser console called the Playground. It is a development tool, it is unauthenticated, and upstream has deprecated it. This image disables it, and OpenFGA itself refuses to start if the Playground is enabled alongside any authentication method other than none — so the unsafe combination cannot be configured by accident. Use the fga CLI, the SDKs or the REST API instead; everything below does.
Step 8: List your stores
A store is an isolated container for one application's authorization data — its model, its relationships and its history. Your VM created one on first boot so there is something to work with:
curl -sk -H "Authorization: Bearer <OPENFGA_API_TOKEN>" https://127.0.0.1/stores | jq '.stores[]? | {id, name}'
{
"id": "01M2YQSEKDA6CZJMW2P74XP6SE",
"name": "cloudimg-starter"
}
Keep that store id to hand — most calls are scoped to a store:
STORE_ID=$(curl -sk -H "Authorization: Bearer <OPENFGA_API_TOKEN>" https://127.0.0.1/stores | jq -r '.stores[0].id')
echo "${STORE_ID}"
Step 9: Write an authorization model
An authorization model declares the object types in your application and the relations they support. This one says there are users and documents, a document has an owner and viewers, and every owner is implicitly also a viewer:
STORE_ID=$(curl -sk -H "Authorization: Bearer <OPENFGA_API_TOKEN>" https://127.0.0.1/stores | jq -r '.stores[0].id')
curl -sk -X POST -H "Authorization: Bearer <OPENFGA_API_TOKEN>" -H 'Content-Type: application/json' \
-d '{
"schema_version": "1.1",
"type_definitions": [
{ "type": "user" },
{
"type": "document",
"relations": {
"owner": { "this": {} },
"viewer": { "union": { "child": [ { "this": {} }, { "computedUserset": { "relation": "owner" } } ] } }
},
"metadata": {
"relations": {
"owner": { "directly_related_user_types": [ { "type": "user" } ] },
"viewer": { "directly_related_user_types": [ { "type": "user" } ] }
}
}
}
]
}' "https://127.0.0.1/stores/${STORE_ID}/authorization-models" | jq -c .
{"authorization_model_id":"01M2YQSEKDA6CZJMW2P74XP6SF"}
The same model is far more readable in OpenFGA's own DSL, which the fga CLI and the VS Code extension accept:
model
schema 1.1
type user
type document
relations
define owner: [user]
define viewer: [user] or owner
Step 10: Write relationships and ask questions
A relationship tuple is a single fact: this user has this relation to this object. Write one:
STORE_ID=$(curl -sk -H "Authorization: Bearer <OPENFGA_API_TOKEN>" https://127.0.0.1/stores | jq -r '.stores[0].id')
MODEL_ID=$(curl -sk -H "Authorization: Bearer <OPENFGA_API_TOKEN>" "https://127.0.0.1/stores/${STORE_ID}/authorization-models" | jq -r '.authorization_models[0].id')
curl -sk -X POST -H "Authorization: Bearer <OPENFGA_API_TOKEN>" -H 'Content-Type: application/json' \
-d "{\"writes\":{\"tuple_keys\":[{\"user\":\"user:alice\",\"relation\":\"owner\",\"object\":\"document:roadmap\"}]},\"authorization_model_id\":\"${MODEL_ID}\"}" \
"https://127.0.0.1/stores/${STORE_ID}/write" | jq -c .
{}
Now ask the question the whole server exists to answer. Alice is the owner, and the model says owners are also viewers, so this is allowed:
STORE_ID=$(curl -sk -H "Authorization: Bearer <OPENFGA_API_TOKEN>" https://127.0.0.1/stores | jq -r '.stores[0].id')
MODEL_ID=$(curl -sk -H "Authorization: Bearer <OPENFGA_API_TOKEN>" "https://127.0.0.1/stores/${STORE_ID}/authorization-models" | jq -r '.authorization_models[0].id')
curl -sk -X POST -H "Authorization: Bearer <OPENFGA_API_TOKEN>" -H 'Content-Type: application/json' \
-d "{\"tuple_key\":{\"user\":\"user:alice\",\"relation\":\"viewer\",\"object\":\"document:roadmap\"},\"authorization_model_id\":\"${MODEL_ID}\"}" \
"https://127.0.0.1/stores/${STORE_ID}/check" | jq -c .
{"allowed":true,"resolution":""}
Bob was never granted anything, so the same question about him is refused:
STORE_ID=$(curl -sk -H "Authorization: Bearer <OPENFGA_API_TOKEN>" https://127.0.0.1/stores | jq -r '.stores[0].id')
MODEL_ID=$(curl -sk -H "Authorization: Bearer <OPENFGA_API_TOKEN>" "https://127.0.0.1/stores/${STORE_ID}/authorization-models" | jq -r '.authorization_models[0].id')
curl -sk -X POST -H "Authorization: Bearer <OPENFGA_API_TOKEN>" -H 'Content-Type: application/json' \
-d "{\"tuple_key\":{\"user\":\"user:bob\",\"relation\":\"viewer\",\"object\":\"document:roadmap\"},\"authorization_model_id\":\"${MODEL_ID}\"}" \
"https://127.0.0.1/stores/${STORE_ID}/check" | jq -c .
{"allowed":false,"resolution":""}

You can also ask the reverse question — which documents may alice view? — which is what you use to filter a list page:
STORE_ID=$(curl -sk -H "Authorization: Bearer <OPENFGA_API_TOKEN>" https://127.0.0.1/stores | jq -r '.stores[0].id')
MODEL_ID=$(curl -sk -H "Authorization: Bearer <OPENFGA_API_TOKEN>" "https://127.0.0.1/stores/${STORE_ID}/authorization-models" | jq -r '.authorization_models[0].id')
curl -sk -X POST -H "Authorization: Bearer <OPENFGA_API_TOKEN>" -H 'Content-Type: application/json' \
-d "{\"type\":\"document\",\"relation\":\"viewer\",\"user\":\"user:alice\",\"authorization_model_id\":\"${MODEL_ID}\"}" \
"https://127.0.0.1/stores/${STORE_ID}/list-objects" | jq -c .
{"objects":["document:roadmap"]}
Step 11: Connect an application
Point an SDK at your VM using the token from Step 6. In JavaScript:
import { OpenFgaClient } from '@openfga/sdk';
const fga = new OpenFgaClient({
apiUrl: 'https://your-openfga-host', // your VM's address
storeId: process.env.FGA_STORE_ID,
credentials: {
method: 'api_token',
config: { token: process.env.FGA_API_TOKEN },
},
});
const { allowed } = await fga.check({
user: 'user:alice',
relation: 'viewer',
object: 'document:roadmap',
});
In Python:
from openfga_sdk import ClientConfiguration, OpenFgaClient
from openfga_sdk.credentials import CredentialConfiguration, Credentials
config = ClientConfiguration(
api_url="https://your-openfga-host",
store_id=os.environ["FGA_STORE_ID"],
credentials=Credentials(
method="api_token",
configuration=CredentialConfiguration(
api_token=os.environ["FGA_API_TOKEN"]
),
),
)
Equivalent SDKs are published for Go, Java and .NET. Every one of them takes the same API token.
The gRPC API
OpenFGA also serves gRPC, on 127.0.0.1:8081. It is deliberately bound to the loopback address in this image, because the official SDKs and the fga CLI all use the HTTP API — so gRPC is not exposed to the network unless you choose to expose it. If you need it off-box, change grpc.addr in /etc/openfga/config.yaml to 0.0.0.0:8081, put TLS in front of it, restart the service, and open the port in your network security group. Do not expose it unprotected: it carries the same authority as the REST API.
Step 12: Use a domain name and your own certificate
Point a DNS A record at your VM's public IP. Then replace the self-signed certificate generated on first boot with one for your domain. With certbot:
sudo apt-get install -y certbot
sudo certbot certonly --standalone -d openfga.your-domain.com --agree-tos -m admin@your-domain.com -n
sudo sed -i 's#/etc/ssl/openfga/openfga.crt#/etc/letsencrypt/live/openfga.your-domain.com/fullchain.pem#; s#/etc/ssl/openfga/openfga.key#/etc/letsencrypt/live/openfga.your-domain.com/privkey.pem#' /etc/nginx/sites-available/openfga
sudo nginx -t && sudo systemctl reload nginx
Nothing in OpenFGA's own configuration records the VM's address, so there is no application setting to update — only nginx terminates TLS.
Step 13: Security model
The posture this image ships, and the reasoning behind each part:
- Pre-shared key authentication is enforced. OpenFGA's default of
authn.method: noneis never used. The key is 256 bits, generated on your VM's first boot, and stored only in/etc/openfga/openfga.env(0600 root:root) and/root/openfga-credentials.txt(0600 root:root). No key is baked into the image, so no two instances share one. - The Playground is disabled. It is an unauthenticated development console. It is not listening on any interface.
- Only ports 22, 80 and 443 are reachable off-box. The HTTP API, the gRPC API, the Prometheus metrics endpoint and PostgreSQL are all loopback-only. Note that OpenFGA's upstream default would have published metrics on
0.0.0.0:2112. - nginx overwrites client-controlled address headers.
X-Forwarded-ForandX-Real-IPare set from the real peer address rather than appended to, so a caller cannot forge the address that appears in your audit trail.ForwardedandX-Forwarded-Prefixare cleared. - The service is unprivileged and confined. It runs as
openfgawithNoNewPrivileges,ProtectSystem=strict, an empty capability bounding set and a system-call filter. - The API does not serve until first boot has finished.
openfga.serviceandnginx.serviceare gated on a marker that only the first-boot unit creates, so there is no window in which the server is up without a key.
To rotate the API token, replace it in the environment file and restart:
sudo bash -c 'NEW=$(openssl rand -hex 32); sed -i "s/^OPENFGA_AUTHN_PRESHARED_KEYS=.*/OPENFGA_AUTHN_PRESHARED_KEYS=${NEW}/" /etc/openfga/openfga.env; sed -i "s/^OPENFGA_API_TOKEN=.*/OPENFGA_API_TOKEN=${NEW}/" /root/openfga-credentials.txt; systemctl restart openfga'
OpenFGA accepts a comma-separated list of keys, so you can add a second key, migrate your applications to it, and then remove the first — a rotation with no downtime.
Step 14: Back up your data
Everything — stores, authorization models and relationship tuples — lives in PostgreSQL. A logical dump is the simplest backup:
sudo -u postgres pg_dump openfga | gzip > /var/backups/openfga-$(date +%F).sql.gz
Restore into an empty database with gunzip -c <backup-dir>/openfga-YYYY-MM-DD.sql.gz | sudo -u postgres psql openfga.
Back up /root/openfga-credentials.txt separately and securely; without the API token your applications cannot reach the server.
Step 15: Logs, upgrades and maintenance
OpenFGA logs as structured JSON to the journal:
sudo journalctl -u openfga.service -n 5 --no-pager -o cat | cut -c1-140
Ubuntu security updates are applied automatically by unattended-upgrades. To apply them immediately:
sudo apt-get update && sudo apt-get install --only-upgrade -y
To upgrade OpenFGA itself, download the release you want, verify its checksum against the checksums.txt published with that release, replace the binary and restart. The migrate step is idempotent and safe to re-run:
sudo systemctl stop openfga
# download and verify the new openfga binary, then:
sudo install -o root -g root -m 0755 ./openfga /usr/local/bin/openfga
sudo -u openfga /usr/local/bin/openfga migrate
sudo systemctl start openfga
Always check the OpenFGA release notes for schema changes before upgrading a production instance.
Support
cloudimg provides 24/7 support for this image: deployment, upgrades, TLS certificates and custom domains, SDK and API integration, authorization model design, and scaling. Contact support@cloudimg.co.uk.
Upstream documentation lives at openfga.dev/docs, and the source is at github.com/openfga/openfga.