TerminusDB 12.0.8 on AWS User Guide
Overview
This guide covers launching and using the TerminusDB 12.0.8 AMI from cloudimg on Amazon EC2. It is one version of the cloudimg Hardened Neo4j & Graph and RDF Databases AMIs listing on AWS Marketplace; you pick it as the software version when you subscribe and launch. Every product in the listing is described at /guides/graph-databases-aws/.
TerminusDB is an open source document graph database that treats your data the way git treats code. You store JSON documents against a schema that describes how they relate, and the database keeps the full history: every change is a commit, you can branch a database, diff two branches, reset to any earlier state and merge work back together. Queries are written in WOQL, TerminusDB's query language, and a complete HTTP API covers documents, schemas, branches, diffs and history. Client libraries are published for Python and JavaScript. TerminusDB is licensed under the Apache License, Version 2.0.
What cloudimg adds to the upstream build:
- No default login, ever. TerminusDB's published default is the user
adminwith the passwordroot. This image never creates it: no database is initialised while the image is built and the whole store is removed before capture, so the image contains no accounts at all. On first boot the store is created and a unique 32-characteradminpassword is set for your instance alone, through standard input only (never a command line or a container environment variable). - Nothing answers without a login. Left alone, TerminusDB serves callers who send no credential as a built-in
anonymousidentity and reports its version and build hash to them. This image refuses any request without credentials at the HTTPS front door; only the information-free health check/api/okstays open for load balancers and uptime monitors. - HTTPS from the first boot. TerminusDB has no TLS listener of its own and uses HTTP Basic authentication, so nginx serves the API on port 443 with a certificate generated on the instance for its own address. Port 80 only redirects to HTTPS, and requests for any other hostname are dropped.
- The API is loopback only. TerminusDB listens on
127.0.0.1:6363; only SSH, 80 and 443 are reachable from the network. - A locked-down container. TerminusDB runs from the official
terminusdb/terminusdb-server:v12.0.8image, pinned by tag and manifest digest, as an unprivileged account, with a read-only root filesystem, every Linux capability dropped andno-new-privileges. - Instance metadata is blocked. TerminusDB can fetch remote URLs (a WOQL
getof a CSV by URL, cloning a remote database). The container can never reach the instance metadata service: a firewall rule drops its traffic to 169.254.169.254, and the nginx, Docker and TerminusDB services are denied it too. - Fully patched Ubuntu 24.04 at build time, the cloudimg security defaults and on-instance self-tests.
What is on the image: Ubuntu 24.04 LTS, Docker CE, the pinned TerminusDB 12.0.8 server image, nginx, and the cloudimg first-boot service. The TerminusDB store lives on a dedicated 20 GiB data volume mounted at /var/lib/terminusdb.
A note on the dashboard
Upstream discontinued the TerminusDB Dashboard component. The server still serves a page at /dashboard/, but in 12.0.8 that page is a notice to that effect, not a working console. This image is documented as what it is: a database you drive from the HTTP API, the command line and the client libraries. Everything in this guide uses curl.
Connecting to your instance
| Version | SSH user | API |
|---|---|---|
| TerminusDB 12.0.8 on Ubuntu 24.04 | ubuntu |
https://<public-dns>/api/ |
Prerequisites
- An AWS account and permission to launch EC2 instances
- An EC2 key pair in the Region you launch in
- A security group that allows TCP 22 from your own address, and TCP 443 (and optionally 80, which only redirects) from the clients that will use the API. TerminusDB's own port 6363 is never exposed: it listens on loopback only.
Recommended instance type: m5.large (2 vCPU, 8 GiB). Choose a larger instance for big databases or heavy WOQL queries.
Step 1: Launch the instance
Option A: AWS Marketplace console
- Open the cloudimg Hardened Neo4j & Graph and RDF Databases AMIs listing on AWS Marketplace and choose View purchase options, then Subscribe. One subscription covers every version in the listing.
- Choose Launch your software, then in Version choose TerminusDB 12.0.8 on Ubuntu 24.04 - YYYY-MM-DD and your Region.
- Instance type:
m5.largeis recommended. - Key pair: choose your EC2 key pair.
- Security group: TCP 22 from your own address; TCP 443 (and 80) from your clients.
- Launch. First boot takes under a minute after the instance is reachable, while it generates this instance's certificate and admin password and creates the store.
Option B: AWS CLI
Run this on your own workstation. Replace the AMI ID with the one shown for your Region on the Marketplace launch page, and the key pair, security group and subnet with your own:
aws ec2 run-instances \
--image-id ami-0123456789abcdef0 \
--instance-type m5.large \
--key-name my-key-pair \
--security-group-ids sg-0123456789abcdef0 \
--subnet-id subnet-0123456789abcdef0 \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=terminusdb}]'
The AMI already requires IMDSv2, so you do not need --metadata-options. The data volume is created from the AMI automatically.
Step 2: Connect via SSH
ssh -i my-key-pair.pem ubuntu@<vm-ip>
The login banner points to the credentials file, the self-test and this guide.
Step 3: Check the services and health
Four units make up the appliance: docker runs the database container, cloudimg-docker-imds-deny installs the metadata firewall rule for containers, terminusdb is the database, and nginx terminates TLS in front of it.
systemctl is-active docker cloudimg-docker-imds-deny terminusdb nginx
test -f /var/lib/cloudimg/terminusdb-firstboot.done && echo first-boot-done
curl -sk -o /dev/null -w 'health check /api/ok: HTTP %{http_code}\n' https://localhost/api/ok
sudo docker images terminusdb/terminusdb-server --digests --format '{{.Repository}}:{{.Tag}} {{.Digest}}'
All four units report active, the first-boot marker is present, /api/ok returns HTTP 200 without a credential, and the image is terminusdb/terminusdb-server:v12.0.8 with digest sha256:43061044d1857bb6f1a2f41d7a59ad06ba5f91c03cbc90128d7c0415f32a0916.
TerminusDB listens on loopback only; nginx owns the public ports:
sudo ss -Hltnp | grep -E ':(6363|80|443) ' | awk '{print $4, $6}' | sed -E 's/users:\(\("([a-z-]+)".*/\1/' | sort -u
You see docker-proxy on 127.0.0.1:6363 and nginx on ports 80 and 443. The appliance can check its own listening set for you:
sudo /usr/local/sbin/terminusdb-port-check.sh

Step 4: Read the per-instance credentials
The admin password is unique to this instance and is written to a root-only file at first boot. Print only the field names, never the password itself:
sudo grep -oE '^[A-Z_]+=' /root/terminusdb-credentials.txt
This lists TERMINUSDB_ADMIN_USER (admin), TERMINUSDB_ADMIN_PASSWORD, TERMINUSDB_HOST, TERMINUSDB_PUBLIC_IP, TERMINUSDB_URL and TERMINUSDB_API. Read the password with sudo sed -n 's/^TERMINUSDB_ADMIN_PASSWORD=//p' /root/terminusdb-credentials.txt when you need it, and keep it safe.
The examples below use a small shell function, tdb, that hands the password to curl on standard input, so it never appears on a command line or in your shell history. Each block defines it again so you can paste any block on its own.
Step 5: The authentication gate
A request with no credential, the upstream default admin/root, and a blank password are all refused:
curl -sk -o /dev/null -w 'no credential: HTTP %{http_code}\n' https://localhost/api/info
curl -sk -o /dev/null -w 'admin/root: HTTP %{http_code}\n' -u 'admin:root' https://localhost/api/info
curl -sk -o /dev/null -w 'blank password: HTTP %{http_code}\n' -u 'admin:' https://localhost/api/info
Each returns HTTP 401, with a WWW-Authenticate: Basic challenge so browsers and HTTP clients know to ask for a login. Your own password is accepted, and the response names the admin user and the version:
tdb() { printf 'user = "admin:%s"\n' "$(sudo sed -n 's/^TERMINUSDB_ADMIN_PASSWORD=//p' /root/terminusdb-credentials.txt)" | curl -sk -K - "$@"; }
tdb https://localhost/api/info; echo
The reply is api:success with "authority":"terminusdb://system/data/User/admin" and "terminusdb":{"version":"12.0.8"}.
The whole gate, including a check that each test is itself proven against a known-bad input first:
sudo /usr/local/sbin/terminusdb-verify-auth.sh
sudo /usr/local/sbin/terminusdb-gate-selftest.sh

Step 6: Create a database, a schema and a document
Create a database called people:
tdb() { printf 'user = "admin:%s"\n' "$(sudo sed -n 's/^TERMINUSDB_ADMIN_PASSWORD=//p' /root/terminusdb-credentials.txt)" | curl -sk -K - "$@"; }
tdb -X POST https://localhost/api/db/admin/people \
-H 'Content-Type: application/json' \
-d '{"label":"People","comment":"example database"}'; echo
TerminusDB is schema-checked: documents must satisfy a class you define. Add an Employee class, then insert a document:
tdb() { printf 'user = "admin:%s"\n' "$(sudo sed -n 's/^TERMINUSDB_ADMIN_PASSWORD=//p' /root/terminusdb-credentials.txt)" | curl -sk -K - "$@"; }
tdb -X POST 'https://localhost/api/document/admin/people?graph_type=schema&author=guide&message=add+Employee+schema' \
-H 'Content-Type: application/json' \
-d '{"@type":"Class","@id":"Employee","name":"xsd:string","title":"xsd:string"}'; echo
tdb -X POST 'https://localhost/api/document/admin/people?author=guide&message=insert+Ada+Lovelace' \
-H 'Content-Type: application/json' \
-d '{"@type":"Employee","name":"Ada Lovelace","title":"Analyst"}'; echo
Read the document back:
tdb() { printf 'user = "admin:%s"\n' "$(sudo sed -n 's/^TERMINUSDB_ADMIN_PASSWORD=//p' /root/terminusdb-credentials.txt)" | curl -sk -K - "$@"; }
tdb 'https://localhost/api/document/admin/people?type=Employee'; echo
The schema is enforced. A document with a property the class does not declare is rejected rather than quietly stored:
tdb() { printf 'user = "admin:%s"\n' "$(sudo sed -n 's/^TERMINUSDB_ADMIN_PASSWORD=//p' /root/terminusdb-credentials.txt)" | curl -sk -K - "$@"; }
tdb -o /dev/null -w 'schema violation: HTTP %{http_code}\n' -X POST \
'https://localhost/api/document/admin/people?author=guide&message=bad' \
-H 'Content-Type: application/json' \
-d '{"@type":"Employee","name":"Bad Record","nonexistent_property":"x"}'
The insert is refused with HTTP 400.
Step 7: Commit history and branches
Every write above was a commit, with the author and message you supplied:
tdb() { printf 'user = "admin:%s"\n' "$(sudo sed -n 's/^TERMINUSDB_ADMIN_PASSWORD=//p' /root/terminusdb-credentials.txt)" | curl -sk -K - "$@"; }
tdb https://localhost/api/log/admin/people | python3 -c 'import json,sys; [print(c["author"], "|", c["message"]) for c in json.load(sys.stdin)]'
Branching works like git: take a copy of main, change it independently, and compare.
tdb() { printf 'user = "admin:%s"\n' "$(sudo sed -n 's/^TERMINUSDB_ADMIN_PASSWORD=//p' /root/terminusdb-credentials.txt)" | curl -sk -K - "$@"; }
tdb -X POST https://localhost/api/branch/admin/people/local/branch/trial \
-H 'Content-Type: application/json' \
-d '{"origin":"admin/people/local/branch/main"}'; echo
tdb 'https://localhost/api/db/admin/people?branches=true' | python3 -c 'import json,sys; print(json.load(sys.stdin)["branches"])'
The second command lists both main and trial.
Step 8: Query with WOQL
WOQL is TerminusDB's query language. This finds every document of type Employee:
tdb() { printf 'user = "admin:%s"\n' "$(sudo sed -n 's/^TERMINUSDB_ADMIN_PASSWORD=//p' /root/terminusdb-credentials.txt)" | curl -sk -K - "$@"; }
tdb -X POST https://localhost/api/woql/admin/people \
-H 'Content-Type: application/json' \
-d '{"query":{"@type":"Triple","subject":{"@type":"NodeValue","variable":"S"},"predicate":{"@type":"NodeValue","node":"rdf:type"},"object":{"@type":"Value","node":"@schema:Employee"}}}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["bindings"])'

Step 9: Call the API from your workstation
From your own machine, use the instance's address. The certificate generated at first boot names it (public IP, private IP and the EC2 DNS names); fetch it once and trust it, or pass -k while testing:
curl -k -u "admin:<new-password>" https://<public-ip>/api/info
The Python client speaks the same API:
from terminusdb_client import Client
client = Client("https://<public-ip>")
client.connect(user="admin", key="<admin-password>", team="admin", db="people")
print(client.get_all_documents())
Browsers and the JavaScript client must call the API from the same origin: nginx refuses state-changing requests (POST, PUT, DELETE) that carry an Origin header for another site. Scripts and server-side clients send no Origin and are unaffected.
Step 10: Serve under your own DNS name
Point a DNS record at the instance, then regenerate the certificate and pin the served hostname:
sudo terminusdb-set-hostname terminusdb.example.com
This issues a new self-signed certificate for that name (replace /etc/ssl/terminusdb/terminusdb.crt and terminusdb.key with your own CA- or ACM-issued pair for production, then sudo systemctl reload nginx) and makes nginx answer only for it. For a public, browser-trusted certificate, you can also terminate TLS at an Application Load Balancer in front of the instance and keep the security group's 443 open only to it.
Step 11: Back up and restore
The store is a directory on its own EBS volume. The simplest backup is an EBS snapshot of the /var/lib/terminusdb volume. For a file-level copy, stop the database first:
sudo systemctl stop terminusdb
sudo tar -czf /var/tmp/terminusdb-backup.tar.gz -C /var/lib/terminusdb storage
sudo systemctl start terminusdb
Restore by putting the directory back with the database stopped, then correcting ownership:
sudo systemctl stop terminusdb
sudo tar -xzf /var/tmp/terminusdb-backup.tar.gz -C /var/lib/terminusdb
sudo chown -R terminusdb:terminusdb /var/lib/terminusdb/storage
sudo systemctl start terminusdb
Step 12: Change the administrator password
The command reads the new password from standard input, so it never appears in your shell history or the process list. It refuses anything shorter than 16 characters:
read -rsp 'New admin password: ' NEWPW; echo
if [ "${#NEWPW}" -lt 16 ]; then
echo "Refusing: use at least 16 characters. Nothing was changed."
else
printf '%s\n' "$NEWPW" | sudo docker exec -i terminusdb /app/terminusdb/terminusdb user password admin
printf '%s\n' "$NEWPW" | sudo sh -c 'read -r P; sed -i "s|^TERMINUSDB_ADMIN_PASSWORD=.*|TERMINUSDB_ADMIN_PASSWORD=$P|" /root/terminusdb-credentials.txt'
echo "Updated, and /root/terminusdb-credentials.txt now matches."
fi
unset NEWPW
Confirm the change took and that everything else is still refused:
sudo /usr/local/sbin/terminusdb-verify-auth.sh
Security notes
TerminusDB 12.0.8 is the latest release at the time of writing, and no security advisory has been published for it. On top of that, this image:
- never creates the upstream default
admin/rootlogin; the store and its admin password are created on your instance at first boot, through standard input only; - refuses unauthenticated requests at nginx, so the server's built-in
anonymousidentity, version and build hash are not reachable from the network, and clears theX-User-Forwardheader that TerminusDB's optional "insecure user header" mode would trust (that mode is off); - keeps TerminusDB on
127.0.0.1:6363and runs it as an unprivileged account in a read-only container with every capability dropped; - blocks the instance metadata service for the container and for the nginx, Docker and TerminusDB services, so a WOQL
getof a URL or a clone from a remote URL can never reach the instance's credentials; - refuses cross-origin state-changing requests at nginx, and logs the real client address.
The firewall rule and the per-service metadata block are visible on the instance:
sudo iptables -S DOCKER-USER | grep DROP
systemctl show -p IPAddressDeny nginx.service docker.service terminusdb.service
From inside the container's own network namespace, the internet is reachable but the metadata service is not:
CPID=$(sudo docker inspect terminusdb --format '{{.State.Pid}}')
CIP=$(getent ahostsv4 checkip.amazonaws.com | awk 'NR==1{print $1}')
sudo nsenter -t "$CPID" -n curl -s -m 10 -o /dev/null -w 'internet from the container: HTTP %{http_code}\n' --resolve "checkip.amazonaws.com:443:$CIP" https://checkip.amazonaws.com/
sudo nsenter -t "$CPID" -n curl -s -m 5 -o /dev/null -w 'metadata from the container: HTTP %{http_code}\n' http://169.254.169.254/latest/meta-data/ || true
The first line reports HTTP 200, the second HTTP 000: the connection is dropped, so curl gives up after its 5 second limit.

Run the full appliance self-test at any time:
sudo /usr/local/sbin/terminusdb-selftest.sh
Keep the instance patched (sudo apt-get update && sudo apt-get upgrade), keep the admin password safe, snapshot the /var/lib/terminusdb volume regularly, and expose only ports 22 and 443 to the networks that need them.
Troubleshooting
The API returns 401 with the password from the credentials file. Check that the file is the current one; a re-provisioned instance has a new password:
sudo grep -c '^TERMINUSDB_ADMIN_PASSWORD=' /root/terminusdb-credentials.txt
The service will not start. Check the database, the proxy and the first-boot unit:
systemctl status terminusdb nginx --no-pager -l | head -30
sudo journalctl -u terminusdb-firstboot -u terminusdb -n 30 --no-pager
Support
cloudimg provides 24/7 technical support for every version in this listing by email and live chat, 365 days a year, with a 24-hour response SLA: support@cloudimg.co.uk.
Trademarks
TerminusDB is a trademark of its respective owner. Docker is a trademark of Docker, Inc. cloudimg is not affiliated with or endorsed by the TerminusDB project, its maintainers or Docker, Inc.