Apache AGE on Ubuntu 24.04 on Azure User Guide
Overview
Apache AGE (A Graph Extension) is an Apache-2.0 licensed PostgreSQL extension that adds graph database functionality and the openCypher query language to PostgreSQL. A single engine serves both your relational tables and your graph workloads, with no separate database to operate. The cloudimg image installs PostgreSQL 16 (from the official PGDG repository) and builds Apache AGE 1.6.0 from the official Apache source release against the PostgreSQL 16 server headers, runs PostgreSQL as the dedicated postgres system user, stores the cluster (PGDATA) on a dedicated Azure data disk, seeds a small example graph named demo, and rotates a unique password into the image on first boot. Backed by 24/7 cloudimg support.
What is included:
- PostgreSQL 16 (from the official PGDG apt repository) with Apache AGE 1.6.0 built from the Apache source release
- The
ageextension created and loaded viashared_preload_librariesin ademodatabase - A seeded example graph (
demo) with nodes and relationships so Cypher queries return data immediately - A unique per-VM password generated on first boot for a dedicated non-superuser application role (
age) - PGDATA on a dedicated 30 GiB Azure data disk at
/var/lib/age-postgres - Loopback-only listener on
5432withscram-sha-256password authentication (not exposed publicly) - 24/7 cloudimg support
This is a datastore product: PostgreSQL listens on 127.0.0.1:5432 only. The port is not opened to the internet - connect locally on the VM or over an SSH tunnel.
Prerequisites
An active Azure subscription, an SSH key pair, and a VNet + subnet in the target region. Standard_B2s (2 vCPU / 4 GiB RAM) is a good starting point. NSG inbound: allow 22/tcp from your management network. No inbound application ports are needed because PostgreSQL is reached over the SSH tunnel.
Step 1 - Deploy from the Azure Marketplace
Sign in to the Azure Portal, choose Create a resource, search the Marketplace for Apache AGE 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) only. Then Review + create -> Create.
Step 2 - Deploy from the Azure CLI
az vm create \
--resource-group <your-rg> \
--name apache-age \
--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
Step 3 - Connect to your VM
ssh azureuser@<vm-public-ip>
Step 4 - Confirm PostgreSQL and Apache AGE are installed and running
psql --version
sudo systemctl is-active postgresql@16-main.service
You should see PostgreSQL 16 and active:
psql (PostgreSQL) 16.14 (Ubuntu 16.14-1.pgdg24.04+1)
active
PostgreSQL listens on loopback only - confirm the listener is bound to 127.0.0.1:
ss -tln | grep 5432
LISTEN 0 200 127.0.0.1:5432 0.0.0.0:*

Step 5 - Retrieve your per-VM password
Each VM generates its own unique password on first boot for the age application role and writes it to a root-only credentials file:
sudo cat /root/age-credentials.txt
The file contains AGE_PASSWORD, the username (age), the database (demo), the connection details and the SSH-tunnel instructions. Store the password in your secrets manager. In the commands below, <AGE_PASSWORD> stands for the value of AGE_PASSWORD.
Step 6 - Confirm the AGE extension is loaded
The age extension is created in the demo database and loaded automatically via shared_preload_libraries, so the openCypher functions are available to the application role with no LOAD required. List the installed extension:
PGPASSWORD=<AGE_PASSWORD> psql -h 127.0.0.1 -U age -d demo -c "\dx age"
List of installed extensions
Name | Version | Schema | Description
------+---------+------------+------------------------
age | 1.6.0 | ag_catalog | AGE database extension
(1 row)
A wrong password is rejected by scram-sha-256 authentication, so the database is never reachable without the per-VM credential.
Step 7 - Inspect and extend the seeded graph
The image ships a seeded graph named demo. List the graphs, then add a node with a Cypher CREATE:
PGOPTIONS='-c search_path=ag_catalog,public' PGPASSWORD=<AGE_PASSWORD> psql -h 127.0.0.1 -U age -d demo -c "SELECT graphid, name FROM ag_catalog.ag_graph;"
graphid | name
---------+------
16986 | demo
(1 row)
PGOPTIONS='-c search_path=ag_catalog,public' PGPASSWORD=<AGE_PASSWORD> psql -h 127.0.0.1 -U age -d demo -c "SELECT * FROM cypher('demo', \$\$ CREATE (p:Person {name:'Grace Hopper', role:'Computer Scientist'}) RETURN p \$\$) as (p agtype);"
p
--------------------------------------------------------------------------------------------------------------------------
{"id": 844424930131971, "label": "Person", "properties": {"name": "Grace Hopper", "role": "Computer Scientist"}}::vertex
(1 row)

Step 8 - Query the graph with openCypher
Run a MATCH to traverse the seeded relationships - for example, everyone who works at cloudimg, then every relationship in the graph:
PGOPTIONS='-c search_path=ag_catalog,public' PGPASSWORD=<AGE_PASSWORD> psql -h 127.0.0.1 -U age -d demo -c "SELECT * FROM cypher('demo', \$\$ MATCH (p:Person)-[:WORKS_AT]->(c:Company) RETURN p.name, c.name \$\$) as (person agtype, company agtype);"
person | company
-------------------+------------
"Ada Lovelace" | "cloudimg"
"Charles Babbage" | "cloudimg"
(2 rows)
PGOPTIONS='-c search_path=ag_catalog,public' PGPASSWORD=<AGE_PASSWORD> psql -h 127.0.0.1 -U age -d demo -c "SELECT * FROM cypher('demo', \$\$ MATCH (a)-[r]->(b) RETURN a.name, type(r), b.name \$\$) as (src agtype, rel agtype, dst agtype);"
src | rel | dst
-------------------+------------+-------------------
"Ada Lovelace" | "KNOWS" | "Charles Babbage"
"Ada Lovelace" | "WORKS_AT" | "cloudimg"
"Charles Babbage" | "WORKS_AT" | "cloudimg"
(3 rows)

Step 9 - Confirm authentication and loopback-only binding
The age role authenticates with scram-sha-256. A wrong password is rejected; the per-VM password is accepted:
PGPASSWORD=<AGE_PASSWORD> psql -h 127.0.0.1 -U age -d demo -c "SELECT current_user, current_database();"
current_user | current_database
--------------+------------------
age | demo
(1 row)
Because the listener is bound to 127.0.0.1 only, a connection to the VM's private or public IP on 5432 is refused - the database is never open to the network.

Step 10 - Connect remotely over an SSH tunnel
PostgreSQL listens on 127.0.0.1:5432 only and is not exposed publicly - this is the secure default. To reach it from your workstation, open an SSH tunnel and point a local client at localhost:5432:
# On your workstation:
ssh -L 5432:127.0.0.1:5432 azureuser@<vm-public-ip>
# In another terminal, with a local psql:
PGPASSWORD=<the password from /root/age-credentials.txt> psql -h 127.0.0.1 -p 5432 -U age -d demo
For production remote access without a tunnel, attach a private NIC and require TLS on the PostgreSQL listener. Never expose 5432 to the internet without transport encryption.
Maintenance
- Persistence: PGDATA lives on the dedicated data disk at
/var/lib/age-postgres/16/main, so your graphs survive reboots and the volume is independently resizable. - Password: rotate the application role password with
sudo -u postgres psql -c "ALTER USER age WITH PASSWORD '<new-password>';". - Graphs: create more graphs with
SELECT create_graph('<name>');and query them withcypher('<name>', $$ ... $$). - Tuning: edit
/etc/postgresql/16/main/conf.d/10-cloudimg-age.conf(and the rest of/etc/postgresql/16/main/) and runsudo systemctl restart postgresql@16-main.service. - Security patches: unattended-upgrades remains enabled so the OS continues to receive security updates automatically.
- Compatibility: Apache AGE 1.6.0 supports PostgreSQL 14-17; this image pins PostgreSQL 16. Standard PostgreSQL clients, drivers and tooling work unchanged against
5432.
Support
cloudimg provides 24/7 expert support for this image. Contact support@cloudimg.co.uk.
Apache AGE, Apache and the Apache feather logo are trademarks of the Apache Software Foundation. PostgreSQL is a trademark of the PostgreSQL Community Association. This image is provided by cloudimg and is not affiliated with or endorsed by the Apache Software Foundation or the PostgreSQL Global Development Group.