Querying Nessie and MinIO Data as a Graph with TLS
Summary
In this tutorial, you will:
- Generate a local Root CA plus server certificates for MinIO and Nessie.
- Start a PuppyGraph container alongside a Nessie-backed Iceberg lakehouse where every service speaks HTTPS to every other service.
- Connect Nessie to PuppyGraph over TLS and define a graph schema.
- Run Cypher and Gremlin queries against the Nessie-managed Iceberg data as a graph.
Self-contained TLS demo
This tutorial bundles cert generation, a TLS-enabled Nessie + MinIO + Spark stack, and PuppyGraph configured to trust the local CA. The non-TLS variant is in Querying Nessie Data as a Graph.
In real deployments, replace the self-signed certs with your organization's CA-signed pair and mount the trust material into the PuppyGraph container. See Connecting to Iceberg for the connection reference.
Prerequisites
-
docker composeavailable: -
opensslandkeytoolavailable for generating certificates and PKCS12 stores. On Debian/Ubuntu:
Accessing the PuppyGraph Web UI requires a browser. The schema upload and query steps also have CLI alternatives via curl and the bundled Gremlin console.
Setup
TLS certificate generation
Create a file
generate_crt.sh with the following content. It mints a Root CA, then server certs for MinIO and Nessie signed by that CA, plus a PKCS12 truststore that Java services use to trust the CA:
generate_crt.sh
#!/usr/bin/env bash
set -euo pipefail
CERT_ROOT="${CERT_ROOT:-./certs}"
CA_DIR="${CA_DIR:-${CERT_ROOT}/ca}"
MINIO_DIR="${MINIO_DIR:-${CERT_ROOT}/minio}"
NESSIE_DIR="${NESSIE_DIR:-${CERT_ROOT}/nessie}"
CA_DAYS="${CA_DAYS:-3650}"
SERVER_DAYS="${SERVER_DAYS:-365}"
CA_PASSWORD="${CA_PASSWORD:-ca_password}"
NESSIE_PASSWORD="${NESSIE_PASSWORD:-nessie_password}"
CA_CN="${CA_CN:-MyRootCA}"
MINIO_CN="${MINIO_CN:-minio}"
NESSIE_CN="${NESSIE_CN:-nessie}"
MINIO_SANS=( ${MINIO_SANS:-minio localhost 127.0.0.1} )
NESSIE_SANS=( ${NESSIE_SANS:-nessie localhost 127.0.0.1} )
build_san() {
local sans=("$@")
local idx=1
for san in "${sans[@]}"; do
if [[ $san =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "IP.$idx = $san"
else
echo "DNS.$idx = $san"
fi
((idx++))
done
}
rm -rf "$CERT_ROOT"
mkdir -p "$CA_DIR" "$MINIO_DIR" "$NESSIE_DIR"
echo "[1/4] Generating Root CA..."
openssl genrsa -out "$CA_DIR/ca.key" 2048
cat > "$CA_DIR/ca.cnf" <<EOL
[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_ca
prompt = no
[req_distinguished_name]
C = US
ST = State
L = City
O = Organization
OU = Unit
CN = $CA_CN
[v3_ca]
basicConstraints = critical, CA:TRUE
keyUsage = critical, keyCertSign, cRLSign
EOL
openssl req -new -x509 -days "$CA_DAYS" \
-key "$CA_DIR/ca.key" \
-out "$CA_DIR/ca.crt" \
-config "$CA_DIR/ca.cnf"
echo "[2/4] Generating MinIO certificate..."
cat > "$MINIO_DIR/minio.cnf" <<EOL
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
C = US
ST = State
L = City
O = Organization
OU = Unit
CN = $MINIO_CN
[v3_req]
subjectAltName = @alt_names
[alt_names]
$(build_san "${MINIO_SANS[@]}")
EOL
openssl genrsa -out "$MINIO_DIR/private.key" 2048
openssl req -new -key "$MINIO_DIR/private.key" \
-out "$MINIO_DIR/minio.csr" \
-config "$MINIO_DIR/minio.cnf"
openssl x509 -req -days "$SERVER_DAYS" \
-in "$MINIO_DIR/minio.csr" \
-CA "$CA_DIR/ca.crt" \
-CAkey "$CA_DIR/ca.key" \
-CAcreateserial \
-out "$MINIO_DIR/public.crt" \
-extfile "$MINIO_DIR/minio.cnf" \
-extensions v3_req
echo "[3/4] Creating CA truststore (PKCS12)..."
keytool -import -trustcacerts -noprompt \
-alias root-ca \
-file "$CA_DIR/ca.crt" \
-keystore "$CA_DIR/ca-truststore.p12" \
-storetype PKCS12 \
-storepass "$CA_PASSWORD"
echo "[4/4] Generating Nessie certificate..."
cat > "$NESSIE_DIR/nessie.cnf" <<EOL
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
C = US
ST = State
L = City
O = Organization
OU = Unit
CN = $NESSIE_CN
[v3_req]
subjectAltName = @alt_names
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
[alt_names]
$(build_san "${NESSIE_SANS[@]}")
EOL
openssl genrsa -out "$NESSIE_DIR/private.key" 2048
openssl req -new -key "$NESSIE_DIR/private.key" \
-out "$NESSIE_DIR/nessie.csr" \
-config "$NESSIE_DIR/nessie.cnf"
openssl x509 -req -days "$SERVER_DAYS" \
-in "$NESSIE_DIR/nessie.csr" \
-CA "$CA_DIR/ca.crt" \
-CAkey "$CA_DIR/ca.key" \
-CAcreateserial \
-out "$NESSIE_DIR/nessie.crt" \
-extfile "$NESSIE_DIR/nessie.cnf" \
-extensions v3_req
openssl pkcs12 -export \
-in "$NESSIE_DIR/nessie.crt" \
-inkey "$NESSIE_DIR/private.key" \
-name "nessie-cert" \
-out "$NESSIE_DIR/nessie.p12" \
-passout "pass:$NESSIE_PASSWORD"
chmod 600 "$CA_DIR/ca.key" "$MINIO_DIR/private.key" "$NESSIE_DIR/private.key"
chmod 644 "$NESSIE_DIR/nessie.p12"
echo "Certificates generated under $CERT_ROOT"
Run the script:
Stage the cert material into per-service mount directories so each container only sees what it needs:
mkdir -p ./minio_certs && cp ./certs/minio/public.crt ./certs/minio/private.key ./minio_certs/
mkdir -p ./nessie_certs && cp ./certs/nessie/nessie.p12 ./certs/ca/ca-truststore.p12 ./nessie_certs/
mkdir -p ./spark_certs && cp ./certs/ca/ca-truststore.p12 ./spark_certs/
mkdir -p ./puppygraph && cp ./certs/ca/ca.crt ./puppygraph/MyRootCA.crt && \
cp ./certs/minio/public.crt ./puppygraph/minio.crt && \
cp ./certs/nessie/nessie.crt ./puppygraph/nessie.crt
Default credentials
The compose file ships with MinIO root credentials admin / password, Nessie keystore password nessie_password, and CA truststore password ca_password. Change them before running on a publicly accessible machine, and regenerate the certificates accordingly.
Deployment
Create a file
docker-compose.yaml with the following content. TLS settings are baked into MinIO, Nessie, Spark, and PuppyGraph:
docker-compose.yaml
version: "3"
services:
spark-iceberg:
image: tabulario/spark-iceberg
container_name: spark-iceberg
networks:
- nessie_tls_net
depends_on:
- minio
- nessie
volumes:
- ./spark_certs:/spark_certs
environment:
- AWS_ACCESS_KEY_ID=admin
- AWS_SECRET_ACCESS_KEY=password
- AWS_REGION=us-east-1
- JAVA_TOOL_OPTIONS=-Djavax.net.ssl.trustStore=/spark_certs/ca-truststore.p12 -Djavax.net.ssl.trustStorePassword=ca_password
ports:
- "8888:8888"
- "8180:8080"
- "10000:10000"
- "10001:10001"
minio:
image: quay.io/minio/minio:latest
container_name: minio
networks:
- nessie_tls_net
ports:
- "9000:9000"
- "9001:9001"
volumes:
- ./minio_certs:/minio_certs
environment:
- MINIO_ROOT_USER=admin
- MINIO_ROOT_PASSWORD=password
- MINIO_REGION=us-east-1
entrypoint: >
/bin/sh -c "
minio server /data --certs-dir /minio_certs --console-address ':9001' &
sleep 5;
mc alias set myminio https://localhost:9000 admin password --insecure;
mc mb myminio/my-bucket --ignore-existing --insecure;
tail -f /dev/null"
nessie:
image: ghcr.io/projectnessie/nessie:0.104.1
container_name: nessie
networks:
- nessie_tls_net
ports:
- "19121:19121"
volumes:
- ./nessie_certs:/nessie_certs
environment:
- nessie.version.store.type=IN_MEMORY
- nessie.catalog.default-warehouse=warehouse
- nessie.catalog.warehouses.warehouse.location=s3a://my-bucket/
- nessie.catalog.service.s3.default-options.region=us-east-1
- nessie.catalog.service.s3.default-options.endpoint=https://minio:9000
- nessie.catalog.service.s3.default-options.path-style-access=true
- nessie.catalog.service.s3.default-options.access-key=urn:nessie-secret:quarkus:nessie.catalog.secrets.access-key
- nessie.catalog.secrets.access-key.name=admin
- nessie.catalog.secrets.access-key.secret=password
- nessie.server.authentication.enabled=false
- quarkus.http.ssl-port=19121
- quarkus.http.ssl.certificate.key-store-file=/nessie_certs/nessie.p12
- quarkus.http.ssl.certificate.key-store-password=nessie_password
- quarkus.http.ssl.certificate.key-store-type=PKCS12
- JAVA_TOOL_OPTIONS=-Djavax.net.ssl.trustStore=/nessie_certs/ca-truststore.p12 -Djavax.net.ssl.trustStorePassword=ca_password
puppygraph:
image: puppygraph/puppygraph:latest
pull_policy: always
container_name: puppygraph
networks:
- nessie_tls_net
environment:
- PUPPYGRAPH_USERNAME=puppygraph
- PUPPYGRAPH_PASSWORD=puppygraph123
- CERTIFICATE_BASEPATH=/home/ubuntu/tls-certs
ports:
- "8081:8081"
- "8182:8182"
- "7687:7687"
volumes:
- ./puppygraph:/home/ubuntu/tls-certs
depends_on:
- spark-iceberg
networks:
nessie_tls_net:
name: puppy-nessie-tls
The CERTIFICATE_BASEPATH env var tells PuppyGraph to load every *.crt and *.pem file in that directory into its trust store at startup, so it can verify both the MinIO and Nessie certificates without further config.
Start the stack:
[+] Running 5/5
✔ Network puppy-nessie-tls Created
✔ Container minio Started
✔ Container nessie Started
✔ Container spark-iceberg Started
✔ Container puppygraph Started
Data Preparation
Open a Spark-SQL shell connected to Nessie over HTTPS:
docker exec -it spark-iceberg spark-sql \
--conf spark.sql.catalog.demo=org.apache.iceberg.spark.SparkCatalog \
--conf spark.sql.catalog.demo.uri=https://nessie:19121/iceberg/ \
--conf spark.sql.catalog.demo.warehouse=s3a://my-bucket/ \
--conf spark.sql.catalog.demo.type=rest
Paste the following SQL into the
spark-sql ()> prompt to create the schema and insert data:
modern.sql
CREATE DATABASE demo.modern;
CREATE EXTERNAL TABLE demo.modern.software (
id string,
name string,
lang string
) USING iceberg;
INSERT INTO demo.modern.software VALUES
('v3', 'lop', 'java'),
('v5', 'ripple', 'java');
CREATE EXTERNAL TABLE demo.modern.person (
id string,
name string,
age int
) USING iceberg;
INSERT INTO demo.modern.person VALUES
('v1', 'marko', 29),
('v2', 'vadas', 27),
('v4', 'josh', 32),
('v6', 'peter', 35);
CREATE EXTERNAL TABLE demo.modern.created (
id string,
from_id string,
to_id string,
weight double
) USING iceberg;
INSERT INTO demo.modern.created VALUES
('e9', 'v1', 'v3', 0.4),
('e10', 'v4', 'v5', 1.0),
('e11', 'v4', 'v3', 0.4),
('e12', 'v6', 'v3', 0.2);
CREATE EXTERNAL TABLE demo.modern.knows (
id string,
from_id string,
to_id string,
weight double
) USING iceberg;
INSERT INTO demo.modern.knows VALUES
('e7', 'v1', 'v2', 0.5),
('e8', 'v1', 'v4', 1.0);
| id | name | age |
|---|---|---|
| v1 | marko | 29 |
| v2 | vadas | 27 |
| v4 | josh | 32 |
| v6 | peter | 35 |
| id | name | lang |
|---|---|---|
| v3 | lop | java |
| v5 | ripple | java |
| id | from_id | to_id | weight |
|---|---|---|---|
| e9 | v1 | v3 | 0.4 |
| e10 | v4 | v5 | 1.0 |
| e11 | v4 | v3 | 0.4 |
| e12 | v6 | v3 | 0.2 |
| id | from_id | to_id | weight |
|---|---|---|---|
| e7 | v1 | v2 | 0.5 |
| e8 | v1 | v4 | 1.0 |
Exit the shell with
quit;.
Modeling a Graph
We model the data as the TinkerPop modern graph: two node types (person, software) and two edge types (knows, created).

First, log into the PuppyGraph Web UI at http://localhost:8081 with the credentials configured above:
| Field | Value |
|---|---|
| Username | puppygraph |
| Password | puppygraph123 |
There are two ways to define the schema in PuppyGraph: build it interactively in the Schema Builder, or upload a JSON file directly. Pick whichever you prefer; both produce the same graph.
Build the graph in the Schema Builder
The Schema Builder is the visual editor in the PuppyGraph Web UI for adding catalogs, nodes, and edges step by step. It's the recommended path when you're modeling a graph for the first time or want to inspect what each click produces. For a deeper visual walkthrough of every dialog and field, see Modeling a Graph through the Schema Builder. The summary below covers what's needed to build the modern graph against this tutorial's TLS-secured Nessie.
Connecting to Nessie
Click Create Catalog, then expand Data Lakes and pick Apache Iceberg.
Fill in the connection form. Note the HTTPS URLs and Enable SSL toggle:
| Field | Value |
|---|---|
| Catalog name | nessie_tls_data |
| Metastore type | Iceberg REST |
| Endpoint URL | https://nessie:19121/iceberg |
| REST Warehouse | s3a://my-bucket/ |
| S3 Endpoint | https://minio:9000 |
| Access Key | admin |
| Secret Key | password |
| Path-style access | enabled |
| Enable SSL | enabled |
Click Create Catalog. PuppyGraph uses the CA cert mounted at
CERTIFICATE_BASEPATH to verify the Nessie and MinIO server certificates.
Adding nodes and edges
Add the
software and person nodes, then the created and knows edges, the same way as in the Iceberg tutorial. For each edge, set the From / To Node, map from_id and to_id as the FROM / TO Select Column, and assign id as the edge identifier.
Upload a schema file
If you've already built the graph in the Schema Builder above, you can skip this section. The resulting schema is the same.
This method writes the full schema to a JSON file and uploads it directly. It's useful when you already have a schema for an environment and want to recreate it elsewhere (e.g. for CI, scripted setup, or copy-pasting between PuppyGraph instances).
Create a file
schema.json with the following content:
schema.json
{
"catalog": [
{
"name": "nessie_tls_data",
"type": "iceberg",
"metastore": {
"type": "rest",
"uri": "https://nessie:19121/iceberg",
"warehouse": "s3a://my-bucket/"
},
"storage": {
"useInstanceProfile": "false",
"accessKey": "admin",
"secretKey": "password",
"enableSsl": "true",
"endpoint": "https://minio:9000",
"enablePathStyleAccess": "true"
}
}
],
"node": [
{
"label": "software",
"dataSourceGroup": {
"externalDataSource": {
"enabled": true,
"catalog": "nessie_tls_data",
"schema": "modern",
"table": "software",
"mappedField": [
{ "sourceFieldName": "id", "targetFieldName": "id" },
{ "sourceFieldName": "name", "targetFieldName": "name" },
{ "sourceFieldName": "lang", "targetFieldName": "lang" }
]
}
},
"id": [{ "name": "id", "type": "STRING" }],
"attribute": [
{ "name": "name", "type": "STRING" },
{ "name": "lang", "type": "STRING" }
]
},
{
"label": "person",
"dataSourceGroup": {
"externalDataSource": {
"enabled": true,
"catalog": "nessie_tls_data",
"schema": "modern",
"table": "person",
"mappedField": [
{ "sourceFieldName": "id", "targetFieldName": "id" },
{ "sourceFieldName": "name", "targetFieldName": "name" },
{ "sourceFieldName": "age", "targetFieldName": "age" }
]
}
},
"id": [{ "name": "id", "type": "STRING" }],
"attribute": [
{ "name": "name", "type": "STRING" },
{ "name": "age", "type": "INT" }
]
}
],
"edge": [
{
"label": "created",
"fromNodeLabel": "person",
"toNodeLabel": "software",
"dataSourceGroup": {
"externalDataSource": {
"enabled": true,
"catalog": "nessie_tls_data",
"schema": "modern",
"table": "created",
"mappedField": [
{ "sourceFieldName": "id", "targetFieldName": "id" },
{ "sourceFieldName": "from_id", "targetFieldName": "from_id" },
{ "sourceFieldName": "to_id", "targetFieldName": "to_id" },
{ "sourceFieldName": "weight", "targetFieldName": "weight" }
]
}
},
"id": [{ "name": "id", "type": "STRING" }],
"fromKey": [{ "name": "from_id", "type": "STRING" }],
"toKey": [{ "name": "to_id", "type": "STRING" }],
"attribute": [
{ "name": "from_id", "type": "STRING" },
{ "name": "to_id", "type": "STRING" },
{ "name": "weight", "type": "DOUBLE" }
]
},
{
"label": "knows",
"fromNodeLabel": "person",
"toNodeLabel": "person",
"dataSourceGroup": {
"externalDataSource": {
"enabled": true,
"catalog": "nessie_tls_data",
"schema": "modern",
"table": "knows",
"mappedField": [
{ "sourceFieldName": "id", "targetFieldName": "id" },
{ "sourceFieldName": "from_id", "targetFieldName": "from_id" },
{ "sourceFieldName": "to_id", "targetFieldName": "to_id" },
{ "sourceFieldName": "weight", "targetFieldName": "weight" }
]
}
},
"id": [{ "name": "id", "type": "STRING" }],
"fromKey": [{ "name": "from_id", "type": "STRING" }],
"toKey": [{ "name": "to_id", "type": "STRING" }],
"attribute": [
{ "name": "from_id", "type": "STRING" },
{ "name": "to_id", "type": "STRING" },
{ "name": "weight", "type": "DOUBLE" }
]
}
]
}
In the Web UI, click Graph in the sidebar, then Upload Schema, and select
schema.json.
Upload via CLI
You can also POST the schema directly:
Querying the Graph
In the PuppyGraph Web UI, click Query in the sidebar. You can run graph queries in either Cypher or Gremlin.
The following query answers "What software was created by people that marko knows?"
There are two paths in the result: marko knows josh, who created lop and ripple.
Cleanup
Shut down the stack and remove volumes and the generated certs: