T1
Databases AWS

TerminusDB 12.0.8 on AWS User Guide

| Product: TerminusDB 12.0.8 on Ubuntu 24.04

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 admin with the password root. 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-character admin password 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 anonymous identity 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/ok stays 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.8 image, pinned by tag and manifest digest, as an unprivileged account, with a read-only root filesystem, every Linux capability dropped and no-new-privileges.
  • Instance metadata is blocked. TerminusDB can fetch remote URLs (a WOQL get of 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

  1. An AWS account and permission to launch EC2 instances
  2. An EC2 key pair in the Region you launch in
  3. 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

  1. 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.
  2. Choose Launch your software, then in Version choose TerminusDB 12.0.8 on Ubuntu 24.04 - YYYY-MM-DD and your Region.
  3. Instance type: m5.large is recommended.
  4. Key pair: choose your EC2 key pair.
  5. Security group: TCP 22 from your own address; TCP 443 (and 80) from your clients.
  6. 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

Terminal showing docker, the metadata firewall unit, terminusdb and nginx active, the first-boot marker, the /api/ok health check returning HTTP 200, the pinned TerminusDB 12.0.8 image digest, and TerminusDB listening on 127.0.0.1:6363 with nginx on 80 and 443

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

Terminal showing no credential, admin/root and a blank password refused with HTTP 401, the per-instance admin password accepted with the admin authority and version 12.0.8, and the shipped auth gate and gate self-test passing

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"])'

Terminal showing the people database created, the Employee schema and a document inserted, the document read back, a schema violation refused with HTTP 400, the commit history, the trial branch listed next to main, and a WOQL query returning the Employee document

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/root login; 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 anonymous identity, version and build hash are not reachable from the network, and clears the X-User-Forward header that TerminusDB's optional "insecure user header" mode would trust (that mode is off);
  • keeps TerminusDB on 127.0.0.1:6363 and 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 get of 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.

Terminal showing the DOCKER-USER rules dropping 169.254.169.254 and 168.63.129.16, IPAddressDeny on nginx, docker and terminusdb, the container reaching the internet but not the instance metadata service, and the appliance self-test passing

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.