eXist-db 6.4 on Ubuntu 24.04 on Azure User Guide
Overview
eXist-db is an open source native XML database and application platform. It stores XML, JSON and binary resources in hierarchical collections with their structure intact, so XQuery 3.1, XPath and XSLT can address any element or attribute directly, and range, full text and n-gram indexes keep those lookups fast over large corpora. It is also an application runtime: XQuery and XSLT execute inside the database, RESTXQ turns annotated functions into a REST API, and a plain REST interface stores and retrieves documents over HTTP.
The cloudimg image installs eXist-db 6.4.1 from the official upstream distribution, pinned by both byte length and SHA-256 (upstream publishes no checksum file, so the digest is recorded in the build recipe), alongside OpenJDK 17. The bundled application catalogue — Dashboard, the eXide XQuery editor, the package manager, monex and the documentation apps — is deployed at first boot with no network access required.
Security is the headline difference in this image. A stock eXist-db install creates its admin account with an empty password and its guest account with the password guest, and eXist also creates one account per bundled application package whose password upstream sets equal to the account name. This image ships with no database at all, so none of those credentials exist in any state. On first boot the VM builds its own database, sets a random per-VM administrator password, rotates every other account to a value it discards, and proves each published default is rejected — and the public listener stays shut until all of that has succeeded.
What is included:
- eXist-db 6.4.1 at
/opt/existdb, database at/var/lib/existdb - OpenJDK 17 JRE headless from Ubuntu 24.04 noble main
- The bundled application catalogue: Dashboard, eXide, package manager, monex, documentation
existdb.servicerunning as the unprivilegedexistdbuser, bound to127.0.0.1:8080- An nginx reverse proxy on port 80 with an unauthenticated
/healthendpoint - A per-VM administrator password generated at first boot, in
/root/existdb-credentials.txt(mode 0600) - Every upstream default credential rotated and proven rejected before port 80 opens
- The JVM heap capped explicitly at 1 GiB and no swap anywhere in the image
- 24/7 cloudimg support
Prerequisites
Active Azure subscription, SSH key, VNet and subnet. Standard_B2ms (2 vCPU, 8 GiB) is the recommended size: the JVM heap is capped at 1 GiB and the remaining memory is useful operating-system page cache for a disk-backed document store. The image also runs within 4 GiB (Standard_B2s) with no swap. For large corpora raise the VM size and the heap together, as shown in Step 12.
NSG inbound: allow 22/tcp from your management CIDR and 80/tcp from the client CIDRs that need the database. eXist-db's own listener is bound to loopback and is never reachable from the network; nginx on port 80 is the only public listener. Port 80 serves plain HTTP — terminate TLS upstream (Azure Application Gateway, Front Door, or a TLS-terminating reverse proxy) with your own certificate and domain before production use.
Step 1-3: Deploy + SSH (standard pattern)
Deploy the VM from the Azure Marketplace offer, then connect over SSH as azureuser:
ssh azureuser@<vm-ip>
First boot builds the database and deploys the bundled application catalogue. This takes about 45 seconds; port 80 deliberately stays closed until it has finished and every credential check has passed, so a connection refused on port 80 immediately after deployment simply means first boot is still running.
Step 4: Service Status and Health
Confirm both services are active, that the unauthenticated health endpoint answers, and that the database itself is bound to loopback only:
sudo systemctl is-active existdb.service nginx.service
curl -s -o /dev/null -w 'health: HTTP %{http_code}\n' http://127.0.0.1/health
curl -s -o /dev/null -w 'redirect: HTTP %{http_code}\n' http://127.0.0.1/
ss -tlnH | awk '{print $4}' | grep -E ':(80|8080)$' | sort
Both units report active. /health returns HTTP 200 — this is served by nginx itself, so it is a true public-listener liveness probe that never depends on the database's auth state. A request to / returns HTTP 302, redirecting to eXist-db's /exist/ context path. The listener list shows nginx on 0.0.0.0:80 and [::]:80, and eXist-db on [::ffff:127.0.0.1]:8080 — that IPv4-mapped form is still loopback, so the database is not reachable from the network.

Step 5: Read the Per-VM Administrator Credentials
The administrator password is unique to this VM and is written to a root-only file at first boot:
sudo cat /root/existdb-credentials.txt
This prints EXISTDB_URL, EXISTDB_ADMIN_USER (admin) and EXISTDB_ADMIN_PASSWORD (the per-VM password). Keep it safe: it unlocks the Dashboard, the eXide editor, the package manager and the full REST API. No two cloudimg deployments share this password.
Every command below reads the password straight out of that file into a shell variable, so nothing needs to be pasted:
PW=$(sudo grep '^EXISTDB_ADMIN_PASSWORD=' /root/existdb-credentials.txt | cut -d= -f2-)
curl -s -u "admin:$PW" -G http://127.0.0.1/exist/rest/db \
--data-urlencode '_query=system:get-version()' --data-urlencode '_wrap=no'; echo
java -version 2>&1 | head -1
The database reports version 6.4.1 and the JVM reports openjdk version "17.0.19".
Step 6: The Authentication Wall
This is the check worth running on a fresh deployment, because it is exactly what a stock eXist-db install fails. Each probe asks the database to evaluate the trivial XQuery 1 and prints only the HTTP status:
PW=$(sudo grep '^EXISTDB_ADMIN_PASSWORD=' /root/existdb-credentials.txt | cut -d= -f2-)
Q() { curl -s -o /dev/null -w '%{http_code}' -u "$1:$2" -G http://127.0.0.1/exist/rest/db \
--data-urlencode '_query=1' --data-urlencode '_wrap=no'; }
printf 'wrong password: HTTP %s\n' "$(Q admin definitely-wrong)"
printf 'upstream empty admin: HTTP %s\n' "$(Q admin '')"
printf 'upstream guest/guest: HTTP %s\n' "$(Q guest guest)"
printf 'per-VM admin password: HTTP %s\n' "$(Q admin "$PW")"
A wrong password, the upstream empty admin password and the upstream guest/guest pair all return HTTP 401. Only the per-VM password returns HTTP 200. The same holds for the accounts eXist-db creates for each bundled application package — eXide, monex and packageservice, whose upstream password is the account name — and for the internal SYSTEM and nobody accounts. You can list every account and re-run the probe against any of them:
PW=$(sudo grep '^EXISTDB_ADMIN_PASSWORD=' /root/existdb-credentials.txt | cut -d= -f2-)
curl -s -u "admin:$PW" -G http://127.0.0.1/exist/rest/db \
--data-urlencode '_query=string-join(sm:list-users(),", ")' --data-urlencode '_wrap=no'; echo
The accounts present are SYSTEM, admin, eXide, guest, monex, nobody, packageservice.

Step 7: Store a Document and Query It
This is what the database is for. Store an XML document with a REST PUT, then query it with XQuery. Collections are created implicitly by the PUT:
PW=$(sudo grep '^EXISTDB_ADMIN_PASSWORD=' /root/existdb-credentials.txt | cut -d= -f2-)
curl -s -o /dev/null -w 'PUT catalog.xml: HTTP %{http_code}\n' -u "admin:$PW" \
-X PUT -H 'Content-Type: application/xml' \
--data-binary '<catalog><book id="b1"><title>Native XML storage</title></book><book id="b2"><title>XQuery in practice</title></book></catalog>' \
http://127.0.0.1/exist/rest/db/demo/catalog.xml
curl -s -u "admin:$PW" -G http://127.0.0.1/exist/rest/db \
--data-urlencode "_query=string-join(for \$b in doc('/db/demo/catalog.xml')//book order by \$b/@id return string(\$b/title),' | ')" \
--data-urlencode '_wrap=no'; echo
curl -s -u "admin:$PW" -G http://127.0.0.1/exist/rest/db \
--data-urlencode "_query=count(collection('/db/demo')//book)" --data-urlencode '_wrap=no'; echo
The PUT returns HTTP 201. The FLWOR expression returns Native XML storage | XQuery in practice, reading the titles back in document order straight out of the stored XML. The collection() count returns 2, querying across every resource in the collection rather than a single document.

Step 8: Disk, Memory and the Deployed Catalogue
Confirm where the database lives, that the image runs with no swap, and which applications were deployed at first boot:
PW=$(sudo grep '^EXISTDB_ADMIN_PASSWORD=' /root/existdb-credentials.txt | cut -d= -f2-)
df -h /var/lib/existdb | awk 'NR==1 || /\/$/'
free -m | head -3
curl -s -u "admin:$PW" -G http://127.0.0.1/exist/rest/db \
--data-urlencode '_query=string-join(xmldb:get-child-collections("/db/apps"),", ")' --data-urlencode '_wrap=no'; echo
The database directory is /var/lib/existdb. free -m shows the Swap: row at zero across the board — Azure manages swap on the ephemeral resource disk, and a swap file baked into an OS disk fails image certification, so this image ships without one. The deployed applications are packageservice, dashboard, eXide, doc, fundocs, monex.

Step 9: The eXist-db Dashboard
Open http://<vm-ip>/ in a browser. The redirect takes you to the eXist-db launcher, which reports the running version and lists the applications deployed at first boot: eXide (the XQuery IDE), eXist-db Documentation, Monex (monitoring) and XQuery Function Documentation. Use Login in the top-right to sign in as admin with the per-VM password from Step 5 — the launcher itself is browsable without signing in, but saving and administration are not.

Step 10: Run an XQuery in eXide
Open eXide from the Dashboard. eXide is eXist-db's in-browser XQuery editor: it edits resources stored in the database, runs queries against live data and shows the results underneath. Paste a query and run it with Eval (or Ctrl-Enter):
xquery version "3.1";
for $b in doc('/db/demo/catalog.xml')//book
order by $b/@id
return <result id="{$b/@id}">{string($b/title)}</result>
The results panel renders the constructed <result> elements for both stored books, and the status toast reports Query returned 2 item(s) with the evaluation time — the query ran inside the database against the document stored in Step 7, with no export step. eXide's directory and outline panes on the left browse the database hierarchy, so you can open, edit and save any stored resource in place.

Step 11: Monitor the Instance with Monex
Open Monex from the launcher. It asks you to sign in as a dba user: enter admin and the per-VM password from Step 5. That sign-in is the most direct proof the per-VM credential works in a real browser session, and it lands on the monitoring dashboard: active database brokers, uptime, running queries, waiting threads, and a live Java memory chart.
The memory panel is a good place to confirm the heap. This image caps the JVM heap explicitly at 1 GiB rather than letting the JVM take a quarter of system memory, so Monex reports the maximum as roughly 989 MiB, with only a small fraction of it in use on an idle instance. Monex also exposes index and profiling views for tuning queries.

Step 11b: Look Up XQuery Functions
The XQuery Function Documentation app indexes every function module available in this instance, including the extension modules eXist-db adds on top of the W3C standard library. Type a name and press Search to get the module, the exact arity-qualified signature and its description. Searching passwd, for example, returns the securitymanager module and sm:passwd#2 — the function this image's first-boot script uses to set the per-VM administrator password.

Step 12: Tune the JVM Heap
The heap is capped explicitly in /etc/existdb.env (-Xms256m -Xmx1024m) rather than left to the JVM's default of a quarter of system memory, and eXist-db's own caches are sized to fit inside it (cacheSize="192M", collectionCache="48M" in /opt/existdb/etc/conf.xml). Raise all three together for large corpora, then restart. eXist-db's own guidance is to keep cacheSize at roughly a third of the maximum heap:
sudo sed -i 's/-Xmx1024m/-Xmx3072m/' /etc/existdb.env
sudo sed -i 's/cacheSize="192M"/cacheSize="768M"/' /opt/existdb/etc/conf.xml
sudo systemctl restart existdb.service
Do not add a swap file to make a larger heap fit — a swap file on the OS disk fails Azure image certification. Move to a larger VM size instead.
Step 13: REST, RESTXQ and XML-RPC
The REST interface at /exist/rest/db/... stores and retrieves resources with PUT, GET and DELETE, and evaluates XQuery via the _query parameter, as used throughout this guide. RESTXQ lets you publish your own HTTP endpoints by annotating XQuery functions with %rest:GET and %rest:path, stored in the database and served under /exist/restxq/. XML-RPC is available at /exist/xmlrpc for the Java and Python client libraries. All three honour the same accounts and permissions, so the per-VM administrator password authenticates every one of them.
Step 14: Backups
eXist-db ships a consistency-checking backup tool that writes a full or incremental backup of the database to a directory. Run it as the existdb user so the output is owned correctly, and copy the result off the VM (Azure Files, Blob storage, or your own backup target). A scheduled backup can also be configured from the Dashboard's backup application.
sudo -u existdb EXIST_HOME=/opt/existdb /opt/existdb/bin/backup.sh \
-u admin -p '<new-password>' -b /db -d <backup-dir>
Step 15: Logs and Troubleshooting
eXist-db logs to /opt/existdb/logs and the service journal:
sudo journalctl -u existdb.service --no-pager | tail -20
sudo journalctl -u exist-db-firstboot.service --no-pager | tail -20
sudo ls -1 /opt/existdb/logs | head
If port 80 refuses connections, check the first-boot journal above: nginx is gated on the marker /var/lib/cloudimg/existdb-bootstrap-ready, which first boot creates only after the per-VM password is set and every default credential has been proven rejected. That is deliberate — the listener never opens onto an unconfigured database. If first boot failed, the journal names the failing step; nginx stays down until it succeeds.
existdb.service reads its environment from /etc/existdb.env and its main configuration is /opt/existdb/etc/conf.xml. The Jetty connector configuration is under /opt/existdb/etc/jetty/; the HTTPS connector is disabled in this image and the distribution's bundled keystore removed, because TLS terminates at nginx or upstream.
Security
eXist-db's own listener is bound to 127.0.0.1:8080 and is never reachable from the network; nginx on port 80 is the only public listener, and it cannot start until first boot has written its bootstrap-ready marker. The HTTPS connector shipped in the upstream distribution is disabled and its bundled keystore.p12 deleted, because that keystore is a publicly downloadable private key.
Every credential eXist-db creates is dealt with before anything is reachable. The image contains no database, so it ships with no accounts at all. First boot generates a random per-VM admin password into /root/existdb-credentials.txt (mode 0600, root-owned), rotates guest and every package-owner account to random values that are immediately discarded, and then proves that the empty admin password, guest/guest, each package account's own name, and the internal SYSTEM and nobody accounts are all rejected. Only then is the marker written and port 80 allowed to open. If any of those checks fail, the marker is never created and the instance stays closed rather than exposing an unconfigured database.
Port 80 serves plain HTTP. Terminate TLS upstream with your own certificate and domain, and restrict the NSG to known client CIDRs, before exposing the instance to the internet. Create per-person accounts in the user manager instead of sharing the admin password.
Support
cloudimg provides 24/7 support for this image. Email support@cloudimg.co.uk. The $0.04 per vCPU hour cloudimg charge covers packaging, security patching, image maintenance, and expert support. eXist-db is licensed under the GNU Lesser General Public License v2.1 and is free; the cloudimg charge is for the managed, patched, support-backed image.