| 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