Configuring PuppyGraph instances Behind Nginx with SNI-Based TLS Routing
Summary
In this tutorial, you will:
-
Deploy two PuppyGraph instances with two Nginx containers using Docker Compose
-
Demonstrate two TLS strategies: TLS passthrough with backend termination (instance 1) versus direct TLS termination at the central Nginx (instance 2)
-
Route Bolt, Gremlin, and HTTP/UI protocol traffic through a single port (443) using SNI-based routing
-
Access both PuppyGraph instances' Web UI, Bolt, and Gremlin endpoints through secure connections
Prerequisites
Docker
Please ensure that docker and docker-compose are available. The installation can be verified by running:
See https://www.docker.com/get-started/ for more details on Docker installation.
OpenSSL
OpenSSL is required to generate SSL certificates. Verify installation:
Most systems have OpenSSL pre-installed. If not, install it using your system's package manager.
Hardware
Please see System Requirements for the minimum hardware requirements.
Architecture Overview
This deployment uses two Nginx containers to route and terminate TLS traffic for two PuppyGraph instances, demonstrating two different strategies.
All external traffic enters through port 443 on central-nginx, which reads the SNI hostname and decides how to handle TLS.
Two TLS Strategies
Instance 1 (*.puppygraph1.local): central-nginx passes TLS traffic through to nginx-proxy without terminating it. nginx-proxy then terminates TLS and routes the decrypted traffic to the correct PuppyGraph port based on the SNI hostname.
Instance 2 (*.puppygraph2.local): central-nginx terminates TLS directly and forwards plain TCP traffic to puppygraph2
Key Features
-
Single port (443) for all traffic across both instances
-
SNI-based routing to distinguish instances, protocols, and TLS handling
-
Two TLS termination strategies demonstrated side by side
-
Backend communication on private unencrypted Docker network
Setup Instructions
Step 1: Create Project Directory
Create a directory for this tutorial deployment:
The final directory structure will be:
puppygraph-nginx-proxy/
├── docker-compose.yml
├── central-nginx/
│ └── nginx.conf
├── nginx/
│ └── nginx.conf
└── certs/
Step 2: Create Docker Compose Configuration
Create a file named docker-compose.yml:
services:
central_nginx:
image: nginx:alpine
container_name: central-nginx
ports:
- "443:443"
networks:
puppygraph_net:
volumes:
- ./certs:/etc/nginx/ssl:ro
- ./central-nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- nginx_proxy
- puppygraph2
nginx_proxy:
image: nginx:alpine
container_name: nginx-proxy
networks:
puppygraph_net:
volumes:
- ./certs:/etc/nginx/ssl:ro
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- puppygraph
puppygraph:
image: puppygraph/puppygraph:stable
container_name: puppygraph
pull_policy: always
environment:
- PUPPYGRAPH_USERNAME=puppygraph
- PUPPYGRAPH_PASSWORD=puppygraph123
- QUERY_TIMEOUT=5m
- BOLTSERVER_AUTHENTICATION_ENABLED=true
- GREMLINSERVER_AUTHENTICATION_ENABLED=true
ports:
- "8081:8081"
- "8182:8182"
- "7687:7687"
volumes:
- puppygraph-data:/data/storage
networks:
puppygraph_net:
puppygraph2:
image: puppygraph/puppygraph:stable
container_name: puppygraph2
pull_policy: always
environment:
- PUPPYGRAPH_USERNAME=puppygraph
- PUPPYGRAPH_PASSWORD=puppygraph123
- QUERY_TIMEOUT=5m
- BOLTSERVER_AUTHENTICATION_ENABLED=true
- GREMLINSERVER_AUTHENTICATION_ENABLED=true
volumes:
- puppygraph2-data:/data/storage
networks:
puppygraph_net:
networks:
puppygraph_net:
volumes:
puppygraph-data:
puppygraph2-data:
Step 3: Create Central Nginx Configuration
Create a file named nginx.conf and put it under folder central-nginx:
events {
worker_connections 1024;
}
# Central nginx: reads SNI to route traffic
# *.puppygraph1.local -> TLS passthrough to nginx_proxy (which terminates TLS)
# *.puppygraph2.local -> TLS terminated here, plain TCP forwarded to puppygraph2
stream {
map $ssl_preread_server_name $backend {
# puppygraph1: route to local passthrough port
~\.puppygraph1\.local$ 127.0.0.1:4430;
# puppygraph2: terminate TLS here, forward plain to pg2
gremlin.puppygraph2.local 127.0.0.1:8183;
bolt.puppygraph2.local 127.0.0.1:7688;
~\.puppygraph2\.local$ 127.0.0.1:8443;
default 127.0.0.1:4430;
}
# Main entry point - reads SNI and routes to local ports
server {
listen 443;
proxy_pass $backend;
ssl_preread on;
proxy_timeout 3600s;
}
# puppygraph1: TLS passthrough to nginx_proxy
server {
listen 127.0.0.1:4430;
proxy_pass nginx_proxy:443;
proxy_timeout 3600s;
}
# puppygraph2: TLS termination + plain proxy
# Gremlin (WebSocket) for puppygraph2
server {
listen 127.0.0.1:8183 ssl;
ssl_certificate /etc/nginx/ssl/puppygraph.crt;
ssl_certificate_key /etc/nginx/ssl/puppygraph.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
proxy_pass puppygraph2:8182;
proxy_timeout 3600s;
}
# Bolt for puppygraph2
server {
listen 127.0.0.1:7688 ssl;
ssl_certificate /etc/nginx/ssl/puppygraph.crt;
ssl_certificate_key /etc/nginx/ssl/puppygraph.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
proxy_pass puppygraph2:7687;
proxy_timeout 3600s;
}
# UI/HTTP for puppygraph2
server {
listen 127.0.0.1:8443 ssl;
ssl_certificate /etc/nginx/ssl/puppygraph.crt;
ssl_certificate_key /etc/nginx/ssl/puppygraph.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
proxy_pass puppygraph2:8081;
proxy_timeout 3600s;
}
}
Step 4: Create Nginx Proxy Configuration
Create a file named nginx.conf and put it under folder nginx:
events {
worker_connections 1024;
}
# Stream module - Routes based on SNI WITHOUT terminating TLS
stream {
map $ssl_preread_server_name $backend {
~^bolt\. 127.0.0.1:7688; # Route to Bolt TLS terminator
~^gremlin\. 127.0.0.1:8183; # Route to Gremlin TLS terminator
default 127.0.0.1:8443; # Route to HTTP TLS terminator
}
# Main entry point - reads SNI and routes
server {
listen 443;
proxy_pass $backend;
ssl_preread on;
proxy_timeout 3600s;
}
# Bolt TLS termination endpoint (internal)
upstream bolt_backend {
server puppygraph:7687;
}
server {
listen 127.0.0.1:7688 ssl;
proxy_pass bolt_backend;
proxy_timeout 3600s;
# Terminate TLS here for Bolt traffic
ssl_certificate /etc/nginx/ssl/puppygraph.crt;
ssl_certificate_key /etc/nginx/ssl/puppygraph.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
}
# Gremlin TLS termination endpoint (internal)
server {
listen 127.0.0.1:8183 ssl;
proxy_pass 127.0.0.1:8184;
proxy_timeout 3600s;
ssl_certificate /etc/nginx/ssl/puppygraph.crt;
ssl_certificate_key /etc/nginx/ssl/puppygraph.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
}
}
# HTTP module - Terminates TLS for HTTP traffic
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
upstream http_backend {
server puppygraph:8081;
}
upstream gremlin_http_backend {
server puppygraph:8182;
}
# Gremlin WebSocket proxy (internal)
server {
listen 127.0.0.1:8184;
location / {
proxy_pass http://gremlin_http_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
}
# HTTP TLS termination endpoint (internal)
server {
listen 127.0.0.1:8443 ssl http2;
server_name _;
# Terminate TLS here for HTTP traffic
ssl_certificate /etc/nginx/ssl/puppygraph.crt;
ssl_certificate_key /etc/nginx/ssl/puppygraph.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass http://http_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}
Step 5: Generate SSL Certificates
For development and testing, generate a single self-signed certificate that covers all hostnames for both instances using Subject Alternative Names (SANs):
mkdir certs
openssl req -x509 -nodes -sha256 -newkey rsa:4096 -keyout certs/puppygraph.key -out certs/puppygraph.crt -days 3650 -subj "/C=US/ST=CA/L=Redwood/O=PuppyGraph/CN=puppygraph.local" -addext "subjectAltName=DNS:*.puppygraph1.local,DNS:bolt.puppygraph1.local,DNS:gremlin.puppygraph1.local,DNS:ui.puppygraph1.local,DNS:*.puppygraph2.local,DNS:bolt.puppygraph2.local,DNS:gremlin.puppygraph2.local,DNS:ui.puppygraph2.local"
Production Note: In production environments, replace self-signed certificates with valid certificates from a Certificate Authority (CA).
Step 6: Configure Local DNS
For local testing, add entries to /etc/hosts (Linux/Mac) or C:\Windows\System32\drivers\etc\hosts (Windows):
127.0.0.1 ui.puppygraph1.local
127.0.0.1 bolt.puppygraph1.local
127.0.0.1 gremlin.puppygraph1.local
127.0.0.1 ui.puppygraph2.local
127.0.0.1 bolt.puppygraph2.local
127.0.0.1 gremlin.puppygraph2.local
This allows SNI-based routing to work correctly with the instance-specific hostnames.
Step 7: Start the Services
Start the docker compose stack:
By using docker compose ps, you should be able to see central-nginx, nginx-proxy, puppygraph, and puppygraph2 containers in the Up state.
Access the PuppyGraph
Access the Web UI
Open your browser and navigate to either instance:
- Instance 1: https://ui.puppygraph1.local
- Instance 2: https://ui.puppygraph2.local
Note: Since we're using a self-signed certificate, your browser will show a security warning. This is expected for development environments. Click "Advanced" and proceed to the site.
In this tutorial, we'll be utilizing the demo data supplied by PuppyGraph. Click on Use example schema/data, and the UI will show that loading is underway.
Once the schema is loaded, the page visualizes the schema of the graph.
Access via Bolt Protocol
After we've set up the schema, we can use the following Python test script to connect to both PuppyGraph instances using the Bolt protocol with TLS:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import neo4j
import argparse
import json
import time
# Default connection settings
URIS = [
"bolt+ssc://bolt.puppygraph1.local:443",
"bolt+ssc://bolt.puppygraph2.local:443",
]
DEFAULT_USERNAME = "puppygraph"
DEFAULT_PASSWORD = "puppygraph123"
# Test query
QUERY = "MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 1"
def run_query(uri: str, user: str, password: str):
"""Execute a simple Cypher query and return results + timings."""
driver = neo4j.GraphDatabase.driver(uri, auth=(user, password))
try:
with driver.session() as session:
t0 = time.perf_counter()
result = session.run(QUERY)
rows = [dict(record) for record in result]
summary = result.consume()
t1 = time.perf_counter()
timings = {
"server_result_available_ms": summary.result_available_after,
"server_result_consumed_ms": summary.result_consumed_after,
"client_wall_ms": round((t1 - t0) * 1000, 2),
}
return rows, timings
finally:
driver.close()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--user", default=DEFAULT_USERNAME, help="Username")
ap.add_argument("--password", default=DEFAULT_PASSWORD, help="Password")
args = ap.parse_args()
for uri in URIS:
print(f"\n{'='*60}")
print(f"Testing: {uri}")
print(f"{'='*60}")
try:
rows, timings = run_query(uri, args.user, args.password)
print(json.dumps({
"params": {"uri": uri, "user": args.user},
"count": len(rows),
"results": rows,
"timings": timings,
}, ensure_ascii=False, indent=2))
except Exception as e:
print(f"FAILED: {e}")
if __name__ == "__main__":
main()
Important:
-
You must use the correct hostname (
bolt.puppygraph1.localorbolt.puppygraph2.local) for Bolt connections. This allows the nginx containers to route traffic correctly via SNI. -
You must use the
bolt+sscprotocol for connections with self-signed certificates.
Access via Gremlin Protocol
Use the following Python test script to connect to both PuppyGraph instances using the Gremlin protocol over WebSocket Secure (WSS):
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import ssl
from gremlin_python.driver.client import Client
import json
import time
import argparse
URIS = [
"wss://gremlin.puppygraph1.local:443/gremlin",
"wss://gremlin.puppygraph2.local:443/gremlin",
]
DEFAULT_TRAVERSAL = "g.V().limit(1)"
DEFAULT_USERNAME = "puppygraph"
DEFAULT_PASSWORD = "puppygraph123"
def run_query(uri: str, traversal: str, user: str, password: str):
"""Execute a simple Gremlin traversal and return results + timings."""
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
client = Client(uri, 'g', ssl_context=ssl_ctx, username=user, password=password)
try:
t0 = time.perf_counter()
result_set = client.submit(traversal)
results = result_set.all().result()
t1 = time.perf_counter()
return {
"uri": uri,
"traversal": traversal,
"results": results,
"client_wall_ms": round((t1 - t0) * 1000, 2),
}
finally:
client.close()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--user", default=DEFAULT_USERNAME, help="Username")
ap.add_argument("--password", default=DEFAULT_PASSWORD, help="Password")
args = ap.parse_args()
for uri in URIS:
print(f"\n{'='*60}")
print(f"Testing: {uri}")
print(f"{'='*60}")
try:
output = run_query(uri, DEFAULT_TRAVERSAL, args.user, args.password)
print(json.dumps(output, indent=2))
except Exception as e:
print(f"FAILED: {e}")
if __name__ == "__main__":
main()
Important:
-
SSL certificate verification is disabled (
ssl.CERT_NONE) because we are using a self-signed certificate. In production, use a CA-signed certificate and remove the SSL verification bypass. -
You must use the correct hostname (
gremlin.puppygraph1.localorgremlin.puppygraph2.local) for Gremlin connections, and (bolt.puppygraph1.localorbolt.puppygraph2.local) for Bolt connections. This allows the nginx containers to route traffic correctly via SNI.
Cleaning Up
Stop Services
To stop services while preserving data:
To wipe everything: