| title | HAProxy Setup | ||||
|---|---|---|---|---|---|
| description | Install and configure HAProxy as the unified TLS entry point for dstack services | ||||
| section | Prerequisites | ||||
| stepNumber | 3 | ||||
| totalSteps | 7 | ||||
| lastUpdated | 2026-01-22 | ||||
| prerequisites |
|
||||
| tags |
|
||||
| difficulty | intermediate | ||||
| estimatedTime | 15 minutes |
This tutorial guides you through installing and configuring HAProxy as the unified TLS entry point for all dstack services. HAProxy provides a critical capability: mixed-mode TLS handling that can terminate TLS for some backends while passing through encrypted traffic for others.
| Capability | Description |
|---|---|
| SNI-based routing | Route requests based on domain without decrypting |
| TLS termination | Handle HTTPS for services without native TLS |
| TLS passthrough | Forward encrypted traffic to services with native TLS |
| Mixed mode | Both modes on the same port (443) |
The dstack gateway has native TLS passthrough capability (the *s. subdomain pattern). HAProxy preserves this by forwarding encrypted traffic directly to the gateway, while terminating TLS for other services like the Docker registry.
Internet
β
βΌ
βββββββββββββββββββ
β HAProxy :443 β
β :80 β
ββββββββββ¬βββββββββ
β
ββββββββββββββββββΌβββββββββββββββββ
β β β
ββββββββββΌββββββββ ββββββββΌβββββββ ββββββββΌβββββββ
β TLS Terminate β βTLS Terminateβ βTLS Passthru β
β registry.* β β vmm.* β β *.dstack.* β
ββββββββββ¬ββββββββ ββββββββ¬βββββββ ββββββββ¬βββββββ
β β β
βΌ βΌ βΌ
βββββββββββββββββ βββββββββββββββββ βββββββββββββββββ
β Registry β β VMM API β β Gateway β
β localhost:5000β β localhost:9080β β localhost:9204β
βββββββββββββββββ βββββββββββββββββ βββββββββββββββββ
Before starting, ensure you have:
- Completed SSL Certificate Setup - Certificates obtained
- SSH access to your TDX server
- Root or sudo privileges
If you prefer to configure manually, follow these steps.
sudo apt update
sudo apt install -y haproxyVerify installation:
haproxy -vHAProxy requires certificates in a combined format (cert + key in one file):
sudo mkdir -p /etc/haproxy/certsCombine Let's Encrypt certificates into HAProxy format:
# Registry certificate
sudo cat /etc/letsencrypt/live/registry.yourdomain.com/fullchain.pem \
/etc/letsencrypt/live/registry.yourdomain.com/privkey.pem \
| sudo tee /etc/haproxy/certs/registry.pem > /dev/null
# Wildcard certificate (for *.dstack.yourdomain.com)
sudo cat /etc/letsencrypt/live/dstack.yourdomain.com/fullchain.pem \
/etc/letsencrypt/live/dstack.yourdomain.com/privkey.pem \
| sudo tee /etc/haproxy/certs/wildcard.pem > /dev/null
# Secure the certificates
sudo chmod 600 /etc/haproxy/certs/*.pemsudo tee /etc/haproxy/haproxy.cfg > /dev/null <<'EOF'
# HAProxy Configuration for dstack Services
# Provides SNI-based routing with mixed TLS termination/passthrough
global
log /dev/log local0
chroot /var/lib/haproxy
stats socket /run/haproxy/admin.sock mode 660 level admin
stats timeout 30s
user haproxy
group haproxy
daemon
# Modern TLS settings
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
defaults
log global
option dontlognull
timeout connect 5000
timeout client 50000
timeout server 50000
errorfile 400 /etc/haproxy/errors/400.http
errorfile 403 /etc/haproxy/errors/403.http
errorfile 408 /etc/haproxy/errors/408.http
errorfile 500 /etc/haproxy/errors/500.http
errorfile 502 /etc/haproxy/errors/502.http
errorfile 503 /etc/haproxy/errors/503.http
errorfile 504 /etc/haproxy/errors/504.http
# =============================================================================
# FRONTEND: HTTP (port 80) - Redirect to HTTPS
# =============================================================================
frontend http_front
bind *:80
mode http
option httplog
# Redirect all HTTP to HTTPS
http-request redirect scheme https code 301
# =============================================================================
# FRONTEND: HTTPS (port 443) - SNI-based routing
# =============================================================================
frontend https_front
bind *:443
mode tcp
option tcplog
# Inspect SNI for routing decisions
tcp-request inspect-delay 5s
tcp-request content accept if { req_ssl_hello_type 1 }
# TLS Termination: VMM management interface (must be before gateway rules)
use_backend local_https_backend if { req_ssl_sni -i vmm.dstack.yourdomain.com }
# TLS Passthrough: Gateway RPC (CVM registration uses port 443 via --gateway-url)
use_backend gateway_rpc_passthrough if { req_ssl_sni -i gateway.dstack.yourdomain.com }
# TLS Passthrough: Gateway proxy handles all other *.dstack.* subdomains (app traffic)
use_backend gateway_passthrough if { req_ssl_sni -m end .dstack.yourdomain.com }
# TLS Termination: Everything else goes to local termination frontend
default_backend local_https_backend
# =============================================================================
# BACKEND: Gateway RPC TLS Passthrough
# When app CVMs use --gateway-url https://gateway.dstack.yourdomain.com (port 443),
# HAProxy must forward that traffic to the gateway RPC port (9202) so CVM
# registration works without requiring clients to specify port 9202 directly.
# =============================================================================
backend gateway_rpc_passthrough
mode tcp
option tcp-check
server gateway-rpc 127.0.0.1:9202 check
# =============================================================================
# BACKEND: Gateway Proxy TLS Passthrough (app traffic)
# =============================================================================
backend gateway_passthrough
mode tcp
option tcp-check
server gateway 127.0.0.1:9204 check
# =============================================================================
# BACKEND: Route to TLS Termination Frontend
# =============================================================================
backend local_https_backend
mode tcp
server loopback 127.0.0.1:8444 send-proxy
# =============================================================================
# FRONTEND: TLS Termination (internal)
# =============================================================================
frontend https_terminate
bind 127.0.0.1:8444 ssl crt /etc/haproxy/certs/ accept-proxy
mode http
option httplog
# Route based on Host header after TLS termination
use_backend registry_backend if { hdr(host) -i registry.yourdomain.com }
use_backend vmm_backend if { hdr(host) -m end .dstack.yourdomain.com }
# Default backend
default_backend vmm_backend
# =============================================================================
# HTTP BACKENDS
# =============================================================================
backend registry_backend
mode http
option httpchk GET /v2/
http-check expect status 200
http-request set-header X-Forwarded-Proto https
server registry 127.0.0.1:5000 check
backend vmm_backend
mode http
option httpchk GET /
http-request set-header X-Forwarded-Proto https
server vmm 127.0.0.1:9080 check
# =============================================================================
# STATS (localhost only)
# =============================================================================
listen stats
bind 127.0.0.1:8404
mode http
stats enable
stats uri /stats
stats refresh 10s
EOFUpdate yourdomain.com throughout the configuration to your actual domain.
# Replace placeholder with your actual domain
sudo sed -i 's/yourdomain\.com/YOUR_ACTUAL_DOMAIN/g' /etc/haproxy/haproxy.cfgsudo haproxy -c -f /etc/haproxy/haproxy.cfgExpected output:
Configuration file is valid
sudo systemctl enable haproxy
sudo systemctl restart haproxysudo systemctl status haproxyCheck HAProxy is listening:
sudo ss -tlnp | grep haproxyExpected output shows ports 80, 443, 8444, and 8404.
When Let's Encrypt renews certificates, HAProxy needs to reload them.
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-haproxy.sh > /dev/null <<'EOF'
#!/bin/bash
# Reload HAProxy certificates after Let's Encrypt renewal
# Combine certificates for HAProxy
cat /etc/letsencrypt/live/registry.yourdomain.com/fullchain.pem \
/etc/letsencrypt/live/registry.yourdomain.com/privkey.pem \
> /etc/haproxy/certs/registry.pem
cat /etc/letsencrypt/live/dstack.yourdomain.com/fullchain.pem \
/etc/letsencrypt/live/dstack.yourdomain.com/privkey.pem \
> /etc/haproxy/certs/wildcard.pem
chmod 600 /etc/haproxy/certs/*.pem
# Reload HAProxy
systemctl reload haproxy
echo "HAProxy certificates updated: $(date)"
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-haproxy.shUpdate the domain names in the script to match your certificates.
sudo /etc/letsencrypt/renewal-hooks/deploy/reload-haproxy.sh/etc/haproxy/
βββ haproxy.cfg # Main configuration
βββ certs/
β βββ registry.pem # Registry cert+key combined
β βββ wildcard.pem # Wildcard cert+key combined
βββ errors/ # Error pages
| Command | Description |
|---|---|
sudo systemctl start haproxy |
Start HAProxy |
sudo systemctl stop haproxy |
Stop HAProxy |
sudo systemctl restart haproxy |
Restart HAProxy |
sudo systemctl reload haproxy |
Reload config without dropping connections |
sudo haproxy -c -f /etc/haproxy/haproxy.cfg |
Test configuration syntax |
# Follow HAProxy logs
sudo journalctl -u haproxy -f
# Check syslog for HAProxy entries
sudo tail -f /var/log/syslog | grep haproxyHAProxy provides a stats page on 127.0.0.1:8404:
curl http://127.0.0.1:8404/statsOr open in browser via SSH tunnel:
ssh -L 8404:127.0.0.1:8404 user@your-server
# Then open http://localhost:8404/stats in browserHAProxy inspects the TLS ClientHello message to read the SNI (Server Name Indication) field without decrypting the traffic:
Client Request: https://app123s.dstack.example.com
β
βΌ
HAProxy sees SNI = "app123s.dstack.example.com"
β
βΌ (matches .dstack.example.com pattern)
β
TCP Passthrough to gateway:9204
β
βΌ
Gateway receives original TLS handshake
β
βΌ (gateway sees "s" suffix = passthrough mode)
β
Gateway passes encrypted stream to CVM:443
For TLS-terminated services:
Client Request: https://registry.example.com
β
βΌ
HAProxy sees SNI = "registry.example.com"
β
βΌ (no .dstack. pattern match, goes to default)
β
Routes to internal TLS termination frontend
β
βΌ
HAProxy terminates TLS using registry.pem
β
βΌ
HTTP proxy to localhost:5000
For detailed solutions, see the Prerequisites Troubleshooting Guide:
- Port 443 Already in Use
- Configuration Test Fails
- Certificate Errors
- Backend Health Check Failing
- Gateway Not Receiving Traffic
With HAProxy installed, proceed to configure services that use it:
- Local Docker Registry - Registry behind HAProxy
- Management Interface Setup - VMM management via HAProxy
- Gateway Service Setup - Gateway with HAProxy passthrough