Skip to content

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:

docker --version
docker compose version

See https://www.docker.com/get-started/ for more details on Docker installation.

OpenSSL

OpenSSL is required to generate SSL certificates. Verify installation:

openssl version

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:

mkdir puppygraph-nginx-proxy
cd puppygraph-nginx-proxy

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:

docker compose up -d

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.local or bolt.puppygraph2.local) for Bolt connections. This allows the nginx containers to route traffic correctly via SNI.

  • You must use the bolt+ssc protocol 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.local or gremlin.puppygraph2.local) for Gremlin connections, and (bolt.puppygraph1.local or bolt.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:

docker compose stop

To wipe everything:

docker compose down --volumes --remove-orphans