SSL Configuration for PostgreSQL: From Nginx Proxy Issues to Direct Database SSL

Sushant PupnejaSushant Pupneja
6 min read

Introduction

When working with PostgreSQL in production environments, SSL encryption is crucial for securing database connections. However, implementing SSL for PostgreSQL through an Nginx proxy can be tricky due to fundamental differences in how database protocols handle SSL compared to HTTP/HTTPS.

In this article, I'll walk you through a real-world scenario where we encountered SSL handshake failures, diagnosed the root cause, and implemented a proper solution using direct PostgreSQL SSL configuration with Nomad orchestration.

The Problem: SSL Handshake Failures

Our setup initially consisted of:

  • PostgreSQL running as a container under Nomad orchestration

  • Nginx as a reverse proxy with SSL termination

  • Let's Encrypt certificates for SSL

However, we kept encountering this error in Nginx logs:

2025/08/21 07:16:40 [info] 23#23: *44368 SSL_do_handshake() failed 
(SSL: error:1408F10B:SSL routines:ssl3_get_record:wrong version number) 
while SSL handshaking, client: 13.69.71.193, server: 0.0.0.0:5432

Multiple clients (PgAdmin, psql terminal, Power Automate PostgreSQL connector) were failing with the same error, indicating a fundamental configuration issue rather than a client-specific problem.

Understanding the Root Cause.

The "wrong version number" error occurs due to a fundamental difference in how PostgreSQL and HTTP handle SSL negotiation:

HTTP/HTTPS SSL Flow:

  1. Client connects to server

  2. Immediate TLS handshake begins

  3. Encrypted communication starts

PostgreSQL SSL Flow:

  1. Client connects to server

  2. Client sends PostgreSQL-specific "SSL Request" packet

  3. Server responds with SSL support confirmation

  4. Only then does the TLS handshake begin

  5. Encrypted communication starts

Our Nginx was configured for HTTP-style SSL termination, expecting an immediate TLS handshake, but PostgreSQL clients were sending their protocol-specific SSL request packets first.

Initial Nginx Configuration (Problematic).

upstream postgre_pool {
    server your-postgres-server-ip:5433;
}

server {
    listen 5432 ssl;  # This expects HTTP-style SSL

    ssl_certificate /path/to/ssl/certs/your-domain.com/fullchain.pem;
    ssl_certificate_key /path/to/ssl/certs/your-domain.com/privkey.pem;

    proxy_pass postgre_pool;
    proxy_connect_timeout 5s;
    proxy_timeout 300s;
}

Solution: Direct PostgreSQL SSL Configuration

Instead of trying to make Nginx handle PostgreSQL's SSL protocol, we implemented SSL directly on PostgreSQL and used Nginx as a simple TCP proxy.

Step 1: Copy SSL Certificates to PostgreSQL Server

First, we copied the existing Let's Encrypt certificates from the Nginx server:

# From PostgreSQL server - copy SSL certificates
scp user@nginx-server:/path/to/nginx/certs/your-domain.com/fullchain.pem /tmp/server.crt
scp user@nginx-server:/path/to/nginx/certs/your-domain.com/privkey.pem /tmp/server.key

# Create SSL directory and move certificates
sudo mkdir -p /opt/nomad/ssl/postgresql
sudo mv /tmp/server.crt /opt/nomad/ssl/postgresql/
sudo mv /tmp/server.key /opt/nomad/ssl/postgresql/

# Set proper permissions for PostgreSQL container
sudo chown -R 999:999 /opt/nomad/ssl/postgresql/
sudo chmod 644 /opt/nomad/ssl/postgresql/server.crt
sudo chmod 600 /opt/nomad/ssl/postgresql/server.key

Step 2: Nomad Job Configuration with SSL

Here's our complete Nomad job file for PostgreSQL with SSL enabled:

job "postgresql-ssl" {
  datacenters = ["dc1"]
  type        = "service"

  group "postgres" {
    count = 1

    task "postgres" {
      driver = "docker"

      config {
        image = "postgres:15"
        ports = ["postgres"]

        # Mount SSL certificates and custom configs
        volumes = [
          "local/postgresql.conf:/etc/postgresql/postgresql.conf:ro",
          "local/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro",
          "/opt/nomad/ssl/postgresql:/var/lib/postgresql/ssl:ro"
        ]

        command = "postgres"
        args = [
          "-c", "config_file=/etc/postgresql/postgresql.conf",
          "-c", "hba_file=/etc/postgresql/pg_hba.conf"
        ]
      }

      env {
        POSTGRES_DB       = "mydb"
        POSTGRES_USER     = "postgres"
        POSTGRES_PASSWORD = "yourpassword"
        PGDATA           = "/var/lib/postgresql/data"
      }

      # PostgreSQL configuration with SSL enabled
      template {
        data = <<-EOH
listen_addresses = '*'
port = 5433

# SSL Configuration
ssl = on
ssl_cert_file = '/var/lib/postgresql/ssl/server.crt'
ssl_key_file = '/var/lib/postgresql/ssl/server.key'

# SSL Security Settings
ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL'
ssl_prefer_server_ciphers = on
ssl_min_protocol_version = 'TLSv1.2'
ssl_max_protocol_version = 'TLSv1.3'

# Performance settings
max_connections = 200
shared_buffers = 256MB
effective_cache_size = 1GB
        EOH

        destination = "local/postgresql.conf"
        change_mode = "restart"
      }

      # Authentication configuration - require SSL for external connections
      template {
        data = <<-EOH
# Local connections
local   all             postgres                                peer
local   all             all                                     md5
host    all             all             127.0.0.1/32            md5

# SSL required for external connections
hostssl all             all             0.0.0.0/0               md5

# Reject non-SSL external connections
hostnossl all           all             0.0.0.0/0               reject
        EOH

        destination = "local/pg_hba.conf"
        change_mode = "restart"
      }

      resources {
        cpu    = 1000
        memory = 1024
      }

      service {
        name = "postgres-ssl"
        port = "postgres"

        check {
          name     = "postgres-ready"
          type     = "script"
          command  = "/bin/sh"
          args     = ["-c", "pg_isready -h localhost -p 5433 -U postgres"]
          interval = "30s"
          timeout  = "10s"
        }
      }

      volume_mount {
        volume      = "postgres_data"
        destination = "/var/lib/postgresql/data"
      }
    }

    volume "postgres_data" {
      type   = "host"
      source = "postgres_data"
    }

    network {
      port "postgres" {
        static = 5433
      }
    }
  }
}

Step 3: Updated Nginx Configuration (Simple TCP Proxy)

With PostgreSQL handling SSL directly, Nginx becomes a simple TCP proxy:

upstream postgre_pool {
    server your-postgres-server-ip:5433;
}

log_format db '$remote_addr:$remote_port -> $upstream_addr status=$status '
               'bytes_sent=$bytes_sent session_time=$session_time';

server {
    listen 5432;  # No SSL here - simple TCP proxy

    proxy_pass postgre_pool;
    proxy_connect_timeout 5s;
    proxy_timeout 300s;
    proxy_responses 1;  # PostgreSQL is request-response protocol
}

Key Configuration Details

PostgreSQL SSL Settings Explained

  • ssl = on: Enables SSL support

  • ssl_cert_file: Path to the SSL certificate (fullchain.pem from Let's Encrypt)

  • ssl_key_file: Path to the private key

  • ssl_min_protocol_version: Enforce modern TLS versions

  • ssl_prefer_server_ciphers: Use the server's cipher preference order

Authentication Security (pg_hba.conf)

  • hostssl: Require SSL for external connections

  • hostnossl...reject: Explicitly reject non-SSL external connections

  • local/host 127.0.0.1: Allow local connections without SSL

Testing the Solution

# Test SSL connection directly to PostgreSQL server
psql "sslmode=require host=your-postgres-server-ip port=5433 user=postgres dbname=mydb"

# Verify SSL certificate
openssl s_client -connect your-postgres-server-ip:5433 -starttls postgres

Lessons Learned

1. Protocol-Specific SSL Handling

Different protocols handle SSL differently. Database protocols like PostgreSQL, MySQL, and others have their own SSL negotiation mechanisms that differ from HTTP.

2. Proxy Layer Considerations

When implementing SSL:

  • SSL Termination: Proxy handles SSL, forwards plain text

  • SSL Passthrough: Proxy forwards encrypted traffic to backend

  • End-to-End SSL: Both proxy and backend handle SSL

For databases, end-to-end SSL or SSL passthrough is often a better choice.

3. Container Permissions

When using containers, pay attention to:

  • File ownership (PostgreSQL container runs as UID 999)

  • File permissions (private keys need 600 permissions)

  • Volume mounts (certificates need to be accessible inside the container)

4. Certificate Management

  • Use existing certificates when possible

  • Implement proper certificate renewal workflows

  • Consider certificate rotation in production

Production Considerations

Monitoring and Logging

  • Monitor SSL certificate expiration

  • Log SSL connection attempts and failures

  • Set up alerts for certificate renewal issues

Performance Impact

  • SSL adds computational overhead

  • Consider connection pooling with SSL

  • Monitor CPU usage for SSL operations

Security Best Practices

  • Use strong cipher suites

  • Implement proper certificate validation

  • Regular security audits of SSL configuration

Conclusion

The "wrong version number" SSL error with PostgreSQL often indicates a mismatch between the proxy SSL configuration and the database protocol's expectations. By implementing SSL directly on PostgreSQL and using Nginx as a simple TCP proxy, we achieved:

  • ✅ Proper SSL handshake handling

  • ✅ End-to-end encryption

  • ✅ Protocol-appropriate SSL negotiation

  • ✅ Simplified proxy configuration

  • ✅ Better security posture

This approach also works well for other database protocols. When in doubt, implement SSL at the database level rather than trying to force HTTP-style SSL termination on database protocols.

The key takeaway: Understand your protocol's SSL negotiation before implementing proxy-level SSL termination.

Additional Resources

Have you encountered similar SSL issues with database proxying? Share your experiences in the comments below!

0
Subscribe to my newsletter

Read articles from Sushant Pupneja directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sushant Pupneja
Sushant Pupneja