SpiceDB on Ubuntu 24.04 on Azure User Guide
Overview
SpiceDB is an open source permissions database, inspired by the system Google described in its Zanzibar paper. Instead of scattering permission checks through your application code, you store relationships — alice is a reader of the roadmap document — describe in a schema how those relations compose into permissions, and then ask the server questions at request time: may this user do this thing to this object?
It answers CheckPermission for a single yes/no decision, and LookupResources or LookupSubjects when you need to filter — "which documents may alice view?" is a single call rather than a fan-out of checks. Official SDKs are published for Go, Java, JavaScript, Python and Ruby, and the zed command line client is installed in this image.
The cloudimg image delivers SpiceDB 1.56.2 on Ubuntu 24.04, serving TLS on both of its APIs, backed by PostgreSQL, with a unique pre-shared key generated on the first boot of your VM. Backed by 24/7 cloudimg support.
What is included:
- SpiceDB 1.56.2 as a single Go binary at
/usr/local/bin/spicedb, run by systemd as the unprivilegedspicedbuser - The gRPC API on port 50051 — the primary interface, spoken by every official SDK and by
zed— serving TLS with a certificate generated for your VM - The HTTP and JSON gateway on port 8443, also over TLS, for callers that prefer REST
zed1.2.1, the official command line client, at/usr/local/bin/zed- PostgreSQL 16 on the loopback address only, so your schema and relationships survive restarts
- A pre-shared key is mandatory, and yours is unique. SpiceDB will not start without one, and this image never bakes one in: the key, the database password and the TLS certificate are all generated on your VM's first boot and written to a root-only file
- The Prometheus metrics and pprof endpoint bound to the loopback interface, and telemetry reporting disabled
- A starter schema, written on first boot so there is something to explore immediately
postgresqlandspicedbsystemd services, enabled and active
SpiceDB and AuthZed are trademarks of their respective owners. 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 SpiceDB 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, 50051/tcp for the gRPC API, and 8443/tcp if you intend to use the HTTP gateway.
Step 1: Deploy from the Azure Marketplace
Sign in to the Azure Portal, choose Create a resource, search the Marketplace for SpiceDB 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). Then select Review + create and Create. Open 50051 and 8443 afterwards, as shown in the next step.
Step 2: Deploy from the Azure CLI
az vm create \
--resource-group my-resource-group \
--name my-spicedb \
--image cloudimg:spicedb-ubuntu-24-04:default:latest \
--size Standard_B2s \
--admin-username azureuser \
--generate-ssh-keys \
--public-ip-sku Standard
Then open the ports you need. Treat these as you would a database port — restrict the source to the application subnets that will call SpiceDB, not the whole internet:
az vm open-port --resource-group my-resource-group --name my-spicedb --port 50051 --priority 1001
az vm open-port --resource-group my-resource-group --name my-spicedb --port 8443 --priority 1002
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 SpiceDB is running
systemctl is-active postgresql spicedb
active
active
Check the versions that are installed:
spicedb version
zed version --skip-version-check
spicedb v1.56.2
client: zed v1.2.1
service: (unknown)
(zed version reports service: (unknown) until you give it an endpoint and a token, which the next steps do.)
Step 5: Check what the network can reach
This is worth doing deliberately on a permissions database. Only three TCP ports are reachable from outside the VM — SSH, the gRPC API and the HTTP gateway. PostgreSQL and the Prometheus/pprof endpoint are bound to the loopback address and cannot be reached from the network at all:
sudo ss -Hltn | awk '{print $4}' | sort -u
*:50051
*:8443
0.0.0.0:22
127.0.0.1:5432
127.0.0.1:9090
127.0.0.53%lo:53
127.0.0.54:53
[::]:22
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/spicedb-port-check.sh
off-box TCP exactly [22 8443 50051]; Prometheus/pprof 9090 and PostgreSQL 5432 loopback-only
dispatch-cluster 50053 not listening on any interface
SPICEDB_PORTS_OK
Two of those deserve a note, because both are changes from SpiceDB's own defaults:
- Port 9090 serves Prometheus metrics and
/debug/pprof/. Upstream enables it on every interface by default. This image binds it to127.0.0.1, so it is available to anything running on the VM and to nobody else. Reach it through an SSH tunnel if you want to scrape it remotely. - Port 50053 is the dispatch cluster, used when SpiceDB runs as a multi-node cluster that distributes and caches sub-problems between peers. A single VM does not need it, so it is disabled and not listening.
Step 6: Retrieve the first-boot pre-shared key
No credential ships in this image, and SpiceDB could not run if one did not exist: --grpc-preshared-key is a required flag with no default, and the server exits rather than starting without it. On its first boot your VM generates its own 256-bit key, database password and TLS certificate, and writes the details to a root-only file:
sudo cat /root/spicedb-credentials.txt
# SpiceDB on Ubuntu 24.04 LTS by cloudimg
# Generated on this VM's first boot — unique to this VM. Keep this file secret.
#
# SpiceDB cannot run without a pre-shared key: --grpc-preshared-key is a
# required flag with no default. The key below is this VM's own, it is the only
# credential that reaches either API, and no other VM has it.
#
# zed --endpoint <vm-public-ip>:50051 --token "$SPICEDB_API_TOKEN" --certificate-path /etc/ssl/spicedb/spicedb.crt schema read
#
SPICEDB_API_TOKEN=<your-token>
SPICEDB_GRPC_ENDPOINT=<vm-public-ip>:50051
SPICEDB_HTTP_ENDPOINT=https://<vm-public-ip>:8443
SPICEDB_HOST=<vm-public-ip>
SPICEDB_TLS_CERT=/etc/ssl/spicedb/spicedb.crt
Both that file and the environment file the service reads the key from are 0600 root:root:
sudo stat -c '%A %U:%G %n' /root/spicedb-credentials.txt /etc/spicedb/spicedb.env
-rw------- root:root /root/spicedb-credentials.txt
-rw------- root:root /etc/spicedb/spicedb.env

Copy the key somewhere safe — a secret manager rather than a file on disk — and treat it as you would a database password. Every command below reads it straight out of that file into a shell variable, so the value never appears in your shell history:
KEY=$(sudo grep '^SPICEDB_API_TOKEN=' /root/spicedb-credentials.txt | cut -d= -f2-)
echo "key length: ${#KEY} characters"
key length: 64 characters
Step 7: Confirm both APIs refuse everything but your key
A request carrying no credential is refused:
curl -sk -o /dev/null -w '%{http_code}\n' -X POST https://127.0.0.1:8443/v1/schema/read -d '{}'
401
So is a request carrying the wrong key — note that SpiceDB answers 403 here rather than 401, because the credential was understood and rejected rather than missing:
curl -sk -o /dev/null -w '%{http_code}\n' -X POST \
-H 'Authorization: Bearer not-the-right-token' \
https://127.0.0.1:8443/v1/schema/read -d '{}'
403
And your own key is accepted:
KEY=$(sudo grep '^SPICEDB_API_TOKEN=' /root/spicedb-credentials.txt | cut -d= -f2-)
curl -sk -o /dev/null -w '%{http_code}\n' -X POST \
-H "Authorization: Bearer ${KEY}" \
https://127.0.0.1:8443/v1/schema/read -d '{}'
200
The image ships that whole proof as one command, which also checks the gRPC door and confirms the metrics and pprof endpoint is not reachable from the network:
sudo /usr/local/sbin/spicedb-verify-auth.sh
HTTP gateway: no credential 401; empty bearer 401; wrong key 403; this VM's key accepted
gRPC: this VM's key accepted, a wrong key refused
Prometheus/pprof: 200 on loopback, unreachable on the external address
SPICEDB_AUTH_OK

Step 8: Read the starter schema
A schema declares the object types in your application, the relations they support, and how those relations compose into permissions. Your VM wrote a starter schema on first boot so there is something to work with.
zed speaks the gRPC API. Because the certificate was generated on your VM rather than issued by a public authority, point zed at the certificate the image created, which is world-readable at /etc/ssl/spicedb/spicedb.crt:
KEY=$(sudo grep '^SPICEDB_API_TOKEN=' /root/spicedb-credentials.txt | cut -d= -f2-)
zed --endpoint 127.0.0.1:50051 --token "${KEY}" \
--certificate-path /etc/ssl/spicedb/spicedb.crt --skip-version-check \
schema read
definition document {
relation writer: user
relation reader: user
permission edit = writer
permission view = reader + edit
}
definition user {}
Read that as: a document has writers and readers; edit is granted to writers; view is granted to readers and to anyone who can edit. That last line is the part a permissions table cannot express — view is computed, so granting someone writer automatically grants them view without a second row being written anywhere.
To use your own schema, write it to a file and send it:
KEY=$(sudo grep '^SPICEDB_API_TOKEN=' /root/spicedb-credentials.txt | cut -d= -f2-)
cat > /tmp/my-schema.zed <<'SCHEMA'
definition user {}
definition team {
relation member: user
}
definition document {
relation writer: user | team#member
relation reader: user | team#member
permission edit = writer
permission view = reader + edit
}
SCHEMA
zed --endpoint 127.0.0.1:50051 --token "${KEY}" \
--certificate-path /etc/ssl/spicedb/spicedb.crt --skip-version-check \
schema write /tmp/my-schema.zed
user | team#member means a relation can be filled either by a user directly or by every member of a team — which is how group-based access is expressed without duplicating rows per user.
Step 9: Write relationships and ask questions
A relationship is a single fact: this subject has this relation to this object. Write one:
KEY=$(sudo grep '^SPICEDB_API_TOKEN=' /root/spicedb-credentials.txt | cut -d= -f2-)
zed --endpoint 127.0.0.1:50051 --token "${KEY}" \
--certificate-path /etc/ssl/spicedb/spicedb.crt --skip-version-check \
relationship touch document:roadmap reader user:alice
touch writes the relationship whether or not it already exists. Use create instead if you want the call to fail when the relationship is already there — that is the right choice when a duplicate would mean a bug in your application.
Now ask the question the whole system exists to answer:
KEY=$(sudo grep '^SPICEDB_API_TOKEN=' /root/spicedb-credentials.txt | cut -d= -f2-)
zed --endpoint 127.0.0.1:50051 --token "${KEY}" \
--certificate-path /etc/ssl/spicedb/spicedb.crt --skip-version-check \
permission check --consistency-full document:roadmap view user:alice
true
And a user who was never granted anything:
KEY=$(sudo grep '^SPICEDB_API_TOKEN=' /root/spicedb-credentials.txt | cut -d= -f2-)
zed --endpoint 127.0.0.1:50051 --token "${KEY}" \
--certificate-path /etc/ssl/spicedb/spicedb.crt --skip-version-check \
permission check --consistency-full document:roadmap view user:bob
false
To filter rather than decide, ask which documents alice may view. This is one call, not one call per document:
KEY=$(sudo grep '^SPICEDB_API_TOKEN=' /root/spicedb-credentials.txt | cut -d= -f2-)
zed --endpoint 127.0.0.1:50051 --token "${KEY}" \
--certificate-path /etc/ssl/spicedb/spicedb.crt --skip-version-check \
permission lookup-resources --consistency-full document view user:alice
roadmap

Step 10: Consistency — why --consistency-full is in every command above
SpiceDB is a distributed system by design, and its default consistency is minimize_latency: it answers from a recent, quantized snapshot of the database (five seconds wide by default) so that checks are fast and cacheable. That is the right default for the overwhelming majority of permission checks, where a permission granted a moment ago being visible a moment later is fine.
It also means a check issued immediately after the write that grants it can legitimately return false. That is not a bug, and it is the single most common surprise for people new to SpiceDB. You have three options:
--consistency-full— evaluate at the newest revision in the database. Always correct, slowest, and what every example above uses so the results are reproducible.- A ZedToken — every write returns one, and passing it back with
--consistency-at-leastguarantees the answer reflects at least that write. This is the option to build applications around: store the ZedToken alongside the resource you just changed, and pass it on subsequent checks for that resource. - The default — for checks not immediately following a related write.
You can watch the difference. Write a relationship and check it at the default consistency, then at full consistency:
KEY=$(sudo grep '^SPICEDB_API_TOKEN=' /root/spicedb-credentials.txt | cut -d= -f2-)
ZED="zed --endpoint 127.0.0.1:50051 --token ${KEY} --certificate-path /etc/ssl/spicedb/spicedb.crt --skip-version-check"
${ZED} relationship touch document:quarterly-plan reader user:dana >/dev/null
echo "minimize-latency: $(${ZED} permission check --consistency-min-latency document:quarterly-plan view user:dana 2>&1 | tail -1)"
echo "full: $(${ZED} permission check --consistency-full document:quarterly-plan view user:dana 2>&1 | tail -1)"
minimize-latency: false
full: true
Wait a few seconds and re-run it, and the minimize-latency answer becomes true as the quantization window rolls forward. If the window happens to have rolled already when you first run it, both lines read true — run the pair a few times and you will see the difference appear and disappear. That variability is the point: it is why a permission check that must reflect a write you have just made needs either --consistency-full or the ZedToken that write returned.
Step 11: The same operations over the HTTP gateway
Everything above is available as JSON over HTTPS on port 8443, for callers that do not have a gRPC stack to hand. The gateway is the same server and the same authentication — it is a translation layer in front of the gRPC API, not a second permission model.
Write a relationship:
KEY=$(sudo grep '^SPICEDB_API_TOKEN=' /root/spicedb-credentials.txt | cut -d= -f2-)
curl -sk -X POST -H "Authorization: Bearer ${KEY}" -H 'Content-Type: application/json' \
https://127.0.0.1:8443/v1/relationships/write \
-d '{"updates":[{"operation":"OPERATION_TOUCH","relationship":{"resource":{"objectType":"document","objectId":"roadmap"},"relation":"writer","subject":{"object":{"objectType":"user","objectId":"carol"}}}}]}'
Then check a permission. Note carol was granted writer, not reader — view is computed through edit, so the answer is still PERMISSIONSHIP_HAS_PERMISSION:
KEY=$(sudo grep '^SPICEDB_API_TOKEN=' /root/spicedb-credentials.txt | cut -d= -f2-)
curl -sk -X POST -H "Authorization: Bearer ${KEY}" -H 'Content-Type: application/json' \
https://127.0.0.1:8443/v1/permissions/check \
-d '{"consistency":{"fullyConsistent":true},"resource":{"objectType":"document","objectId":"roadmap"},"permission":"view","subject":{"object":{"objectType":"user","objectId":"carol"}}}' \
| python3 -m json.tool
{
"checkedAt": {
"token": "GhAKBENNQUoSCDMxMDg0OGVk"
},
"permissionship": "PERMISSIONSHIP_HAS_PERMISSION",
"partialCaveatInfo": null,
"debugTrace": null,
"optionalExpiresAt": null
}
The checkedAt.token is the ZedToken described in Step 10 — this is where you get one to store alongside your resource.
Step 12: Connect from your application
From outside the VM, point your SDK or zed at the VM's address and pass the same key as a bearer token. Because the certificate is generated on the VM, copy /etc/ssl/spicedb/spicedb.crt to the client machine and use it as the certificate authority:
scp azureuser@<vm-public-ip>:/etc/ssl/spicedb/spicedb.crt ./spicedb.crt
zed context set my-spicedb <vm-public-ip>:50051 "<your-token>" --certificate-path ./spicedb.crt
zed permission check --consistency-full document:roadmap view user:alice
zed context set stores the endpoint and token in your local zed configuration, so you do not have to repeat them. Remember that this writes the key to disk on the client machine.
In Go, the equivalent is the authzed-go client with grpcutil.WithCustomCerts and a BearerToken call credential; in Python, authzed.api.v1.Client with bearer_token_credentials. Every official SDK takes the same two inputs — the endpoint and the pre-shared key.
If you would rather not distribute the self-signed certificate, replace it with one from a public authority as shown in the next step.
Step 13: Use your own certificate and domain
Point a DNS record at the VM's public address, then obtain a certificate for it and tell SpiceDB to use it. The certificate and key paths are the only thing that changes:
sudo apt-get install -y certbot
sudo certbot certonly --standalone -d spicedb.your-domain.com
sudo install -o root -g spicedb -m 0644 /etc/letsencrypt/live/spicedb.your-domain.com/fullchain.pem /etc/ssl/spicedb/spicedb.crt
sudo install -o root -g spicedb -m 0640 /etc/letsencrypt/live/spicedb.your-domain.com/privkey.pem /etc/ssl/spicedb/spicedb.key
sudo systemctl restart spicedb
Certbot's standalone mode needs port 80 reachable for the challenge; open it for the duration of the request and close it afterwards. With a publicly trusted certificate in place, clients no longer need --certificate-path and can verify normally against the system trust store.
Step 14: Back up your permissions data
Your schema and relationships live in the co-located PostgreSQL database. zed can export them in SpiceDB's own format, which is the portable option and the one to prefer:
KEY=$(sudo grep '^SPICEDB_API_TOKEN=' /root/spicedb-credentials.txt | cut -d= -f2-)
sudo mkdir -p /var/backups/spicedb
zed --endpoint 127.0.0.1:50051 --token "${KEY}" \
--certificate-path /etc/ssl/spicedb/spicedb.crt --skip-version-check \
backup create /var/backups/spicedb/spicedb-backup.zedbackup
sudo ls -la /var/backups/spicedb/
A database-level dump is the belt-and-braces alternative, and captures the datastore exactly as it is:
sudo -u postgres pg_dump -Fc spicedb -f /var/backups/spicedb/spicedb.dump
Copy either file off the VM — to Azure Blob Storage, for example — on whatever schedule suits your recovery objective.
Step 15: Upgrade SpiceDB
Replace the binary, run any datastore migration the new version needs, and restart. Check the release notes first: SpiceDB occasionally ships a migration that must be applied in a specific phase, and the release notes say so when that is the case.
curl -fsSL -o /tmp/spicedb.tar.gz https://github.com/authzed/spicedb/releases/download/v<version>/spicedb_<version>_linux_amd64.tar.gz
tar -xzf /tmp/spicedb.tar.gz -C /tmp spicedb
sudo install -o root -g root -m 0755 /tmp/spicedb /usr/local/bin/spicedb
sudo -u postgres true && source /etc/spicedb/spicedb.env && sudo /usr/local/bin/spicedb datastore migrate head --datastore-engine postgres --datastore-conn-uri "${SPICEDB_DATASTORE_CONN_URI}"
sudo systemctl restart spicedb
spicedb version
The operating system patches itself: unattended-upgrades is enabled in the image, so security updates are applied automatically.
Step 16: Rotate the pre-shared key
The key is read from /etc/spicedb/spicedb.env. Replace it, restart, and update your clients. SpiceDB accepts more than one key at a time, so you can add the new one alongside the old, migrate your clients, then remove the old one — a rotation with no downtime:
# Generate a replacement with: openssl rand -hex 32
# Then put it in place of <your-token> below.
sudo sed -i 's/^SPICEDB_GRPC_PRESHARED_KEY=.*/SPICEDB_GRPC_PRESHARED_KEY=<your-token>/' /etc/spicedb/spicedb.env
sudo sed -i 's/^SPICEDB_API_TOKEN=.*/SPICEDB_API_TOKEN=<your-token>/' /root/spicedb-credentials.txt
sudo systemctl restart spicedb
Keep /root/spicedb-credentials.txt in step with the environment file, as above — the image's own health checks read the key from the credentials file, and they will report a failure if the two disagree.
Troubleshooting
spicedb.service is not running. Look at the journal first:
sudo systemctl is-active spicedb postgresql
sudo systemctl show -p ExecMainStatus -p NRestarts spicedb
sudo journalctl -u spicedb -p warning -n 20 --no-pager
active
active
ExecMainStatus=0
NRestarts=0
Filtering to -p warning keeps that last command quiet on a healthy VM — anything it prints is worth reading. SpiceDB logs in JSON (--log-format json), so for the full picture use sudo journalctl -u spicedb -n 50 --no-pager | jq -r 'select(.level!="info") | .message', or drop the filter entirely and pipe through jq . to make it readable.
The most common causes are a missing or empty SPICEDB_GRPC_PRESHARED_KEY in /etc/spicedb/spicedb.env (SpiceDB refuses to start without one, by design), a certificate or key file that has been replaced with one the spicedb user cannot read, and PostgreSQL not being up.
A check returns false for a permission you just granted. That is the consistency default described in Step 10. Re-run it with --consistency-full, and use ZedTokens in your application.
A client cannot connect over gRPC. Confirm the port is open in the network security group, and that the client trusts the certificate — either by passing --certificate-path with a copy of /etc/ssl/spicedb/spicedb.crt, or by installing a publicly trusted certificate as in Step 13. A TLS failure and a firewall failure look similar from the client; zed with --log-level debug distinguishes them.
The whole appliance, checked in one command. This runs every posture check the image ships with, including feeding each one a known-bad input to prove the check itself still works:
sudo /usr/local/sbin/spicedb-selftest.sh
SPICEDB_SELFTEST_OK
Support
This image is maintained by cloudimg with 24/7 support for deployment, upgrades, certificates and custom domains, SDK integration, schema design and scaling. Contact support@cloudimg.co.uk.
SpiceDB is free and open source software under the Apache License 2.0; there is no subscription or licence key. The cloudimg charge covers packaging, hardening and support.