Skip to content

Configuring Single-Port SNI Routing for PuppyGraph Behind Nginx TLS Proxy

Summary

In this tutorial, you will:

  • Deploy PuppyGraph with Nginx as a TLS termination proxy using Docker Compose

  • Configure Nginx to route both HTTPS and Bolt protocol traffic through a single port (443)

  • Secure your PuppyGraph deployment with TLS/SSL certificates

  • Access PuppyGraph Web UI and Bolt endpoint 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 Nginx as a reverse proxy with TLS termination.

All external traffic enters through port 443, where Nginx terminates the TLS connection and routes traffic based on the hostname (SNI - Server Name Indication).

Traffic Flow

When a client connects to port 443, Nginx examines the hostname in the TLS handshake:

  • If the hostname is bolt.puppygraph.local, traffic is routed to the Bolt protocol endpoint (PuppyGraph port 7687)

  • If the hostname is puppygraph.local, traffic is routed to the HTTP API endpoint (PuppyGraph port 8081)

After TLS termination, Nginx forwards unencrypted traffic to PuppyGraph over the private Docker network.

Key Features

  • Single port (443) for all traffic

  • SNI-based routing to distinguish HTTP and Bolt protocols

  • TLS encryption for all external connections

  • Backend communication on private unencrypted network

Setup Instructions

Step 1: Create Project Directory

Create a directory for this tutorial deployment:

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

Step 2: Create Docker Compose Configuration

Create a file named docker-compose.yml:

services:
  nginx_proxy:
    image: nginx:alpine
    container_name: nginx-proxy
    ports:
      - "443:443"
    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_PASSWORD=puppygraph123
      - QUERY_TIMEOUT=5m
    ports:
      - "8081:8081"
      - "8182:8182"
      - "7687:7687"
    volumes:
      - puppygraph-data:/data/storage
    networks:
      puppygraph_net:

networks:
  puppygraph_net:

volumes:
  puppygraph-data:

Step 3: Create Nginx 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.puppygraph.local      127.0.0.1:7688;  # Route to bolt TLS terminator
        ~^bolt\.                   127.0.0.1:7688;
        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;
    }
}

# 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;
    }

    # 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 4: Generate SSL Certificates

For development and testing, generate self-signed certificates:

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=bolt.puppygraph.local" -addext "subjectAltName=DNS:puppygraph.local,DNS:bolt.puppygraph.local"

Production Note: In production environments, replace self-signed certificates with valid certificates from a Certificate Authority (CA).

Step 5: 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 puppygraph.local
127.0.0.1 bolt.puppygraph.local

This allows SNI-based routing to work correctly with different hostnames.

Step 6: Start the Services

Start the docker compose stack:

docker compose up -d

By using docker compose ps, you should be able to see both nginx-proxy and puppygraph containers in the Up state.

Access the PuppyGraph

Access the Web UI

Open your browser and navigate to: https://puppygraph.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 PuppyGraph using the Bolt protocol with TLS:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import neo4j
import argparse
import json
import time

# Default connection settings
DEFAULT_URI = "bolt+ssc://bolt.puppygraph.local:443"
DEFAULT_USERNAME = "puppygraph"
DEFAULT_PASSWORD = "puppygraph123"

# Test query
QUERY = "RETURN 1 AS ok"

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("--uri", default=DEFAULT_URI, help="Bolt URI (bolt:// or bolt+ssc://)")
    ap.add_argument("--user", default=DEFAULT_USERNAME, help="Username")
    ap.add_argument("--password", default=DEFAULT_PASSWORD, help="Password")
    args = ap.parse_args()

    rows, timings = run_query(args.uri, args.user, args.password)

    print(json.dumps({
        "params": {"uri": args.uri, "user": args.user},
        "count": len(rows),
        "results": rows,
        "timings": timings
    }, ensure_ascii=False, indent=2))

if __name__ == "__main__":
    main()

Important:

  • You must use bolt.puppygraph.local as the hostname for Bolt connections. This allows Nginx to route the traffic correctly via SNI.

  • You must use bolt+ssc protocol. or use bolt protocol with ssl context as demonstrated below:

# Create an SSL context that trusts the self-signed cert
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE

driver = neo4j.GraphDatabase.driver(uri, auth=(user, password), encrypted=True, ssl_context=ssl_context)

Cleaning Up

Stop Services

To stop services while preserving data:

docker compose stop

To wipe everything:

docker compose down --volumes --remove-orphans