
SSL isn’t just a box to tick. On a busy VPS, TLS handshakes, renewals, and “just one more redirect” create real operational friction.
This TLS termination setup guide tutorial shows how to terminate HTTPS on HAProxy and forward plain HTTP to Nginx or Apache. You get centralized certificates, simpler renewals, and safer reloads with predictable routing.
This pattern matches a common hosting layout. One VPS (or dedicated server) runs HAProxy on ports 80/443. Your web server listens privately on 127.0.0.1.
You’ll end with HTTP/2 on the edge, sane TLS defaults, and a repeatable way to add sites. You won’t need to rework every virtual host.
What you’ll build (and why it’s worth it)
Target end state:
- HAProxy listens on
:80and:443, terminates TLS, and routes requests by Host header (SNI/HTTP Host). - Nginx or Apache listens on
127.0.0.1:8080(or similar) and serves the site as plain HTTP. - ACME challenges for Let’s Encrypt work without opening extra ports or embedding cert tooling into your web server config.
- Zero-surprise reloads: HAProxy supports no-downtime reloads, while your backend stays stable and boring.
This setup is ideal for multiple sites, reseller-style client projects, or staging environments.
You can run “real” HTTPS without duplicating certificate work across servers and vhosts.
If you’re building a fresh box, leave enough CPU headroom for TLS and caching.
Small VPS plans can handle it, but handshake spikes are real.
For production hosting, consider a HostMyCode VPS. Or step up to managed VPS hosting if you want help with updates, monitoring, and hardening.
Prerequisites and baseline checks
You’ll need:
- Ubuntu 24.04/24.10 or Debian 12/13 on a VPS/dedicated server
- Root or sudo access
- One or more domains pointed to the server IP
- An existing Nginx or Apache site (or you’ll create a simple backend)
Before you change any ports, confirm what’s already listening:
sudo ss -lntp | awk 'NR==1 || /:80 |:443 |:8080 /'
If Nginx/Apache already binds to :80 or :443, HAProxy can’t take those ports.
Move the backend to loopback first.
Also verify DNS is pointing where you think it is:
dig +short A example.com
dig +short AAAA example.com
If you’re cutting over from another host, drop TTL (for example, 300 seconds) at least a day ahead.
For a clean migration sequence, see this near-zero downtime server migration tutorial.
Step 1: Install HAProxy and enable the service
On Ubuntu/Debian:
sudo apt update
sudo apt install -y haproxy
Check the version and service status:
haproxy -v
sudo systemctl enable --now haproxy
sudo systemctl status haproxy --no-pager
If your distro ships an older HAProxy build, you may miss newer TLS features.
Stick to vendor-recommended repos for your OS release.
Avoid random PPAs on hosting servers unless you control patching and rollback.
Step 2: Move Nginx or Apache to a loopback port
You want HAProxy on :80/:443. Put the backend on a private port (8080/8081 are common).
Pick one port and use it consistently.
Option A: Nginx backend on 127.0.0.1:8080
Edit your Nginx site (or main config) so it listens on loopback only:
sudo nano /etc/nginx/sites-available/example.com
Minimal backend server block:
server {
listen 127.0.0.1:8080;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.html index.php;
location / {
try_files $uri $uri/ =404;
}
}
Validate and reload:
sudo nginx -t
sudo systemctl reload nginx
Option B: Apache backend on 127.0.0.1:8080
Change Apache’s ports:
sudo nano /etc/apache2/ports.conf
Replace/ensure:
Listen 127.0.0.1:8080
Update your VirtualHost blocks from <VirtualHost *:80> to:
<VirtualHost 127.0.0.1:8080>
ServerName example.com
ServerAlias www.example.com
DocumentRoot /var/www/example.com/public
# If you use PHP-FPM via ProxyPassMatch or SetHandler, keep it as-is.
</VirtualHost>
Then reload:
sudo apachectl -t
sudo systemctl reload apache2
Sanity test locally:
curl -I http://127.0.0.1:8080/
If you need help structuring a multi-domain Apache layout, reference the Apache virtual hosts tutorial.
Then come back to place HAProxy in front.
Step 3: Open firewall ports (and avoid locking yourself out)
At minimum you need:
- SSH: 22 (or your custom port)
- HTTP: 80
- HTTPS: 443
With UFW:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw status verbose
If you’re mid-change and SSH disappears or the site drops, work through this firewall troubleshooting tutorial.
It’s aimed at the exact “I changed rules and now everything is broken” scenario.
Step 4: Issue Let’s Encrypt certificates without coupling to your web server
You have two practical options in 2026:
- HTTP-01 via a local ACME challenge backend (simple, reliable)
- DNS-01 (best if you can automate DNS, useful for wildcards)
For most admins, HTTP-01 fits best here.
HAProxy routes /.well-known/acme-challenge/ to a tiny local service. Everything else keeps flowing to your normal backend.
Install Certbot (HTTP-01) and prepare a webroot
Create a dedicated ACME webroot directory:
sudo mkdir -p /var/lib/acme-challenge
sudo chown -R root:root /var/lib/acme-challenge
sudo chmod 755 /var/lib/acme-challenge
Install Certbot:
sudo apt install -y certbot
Next, bind a simple HTTP responder on localhost to serve that directory.
Python’s built-in server is fine for this job:
sudo tee /etc/systemd/system/acme-http.service >/dev/null <<'EOF'
[Unit]
Description=Local ACME HTTP challenge server
After=network.target
[Service]
Type=simple
WorkingDirectory=/var/lib/acme-challenge
ExecStart=/usr/bin/python3 -m http.server 8888 --bind 127.0.0.1
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now acme-http.service
sudo ss -lntp | grep 8888
HAProxy will send only the ACME path to 127.0.0.1:8888.
Everything else goes to Nginx/Apache on 127.0.0.1:8080.
Step 5: Configure HAProxy for TLS termination + routing
Edit HAProxy’s config:
sudo cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.bak.$(date +%F)
sudo nano /etc/haproxy/haproxy.cfg
Start with a clean baseline that’s easy to maintain.
The example below assumes one backend web server on 127.0.0.1:8080.
global
log /dev/log local0
log /dev/log local1 notice
user haproxy
group haproxy
daemon
# Tune for your box. Start conservative.
maxconn 5000
# TLS settings (Mozilla-style modern baseline, adjusted for compatibility)
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5s
timeout client 60s
timeout server 60s
frontend fe_http
bind :80
# Route ACME challenges to local service
acl is_acme path_beg /.well-known/acme-challenge/
use_backend be_acme if is_acme
# Everything else redirects to HTTPS
http-request redirect scheme https code 301 unless is_acme
backend be_acme
server acme1 127.0.0.1:8888 check
frontend fe_https
bind :443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1
# Forward useful headers to your backend
http-request set-header X-Forwarded-Proto https
http-request set-header X-Forwarded-Port 443
http-request set-header X-Forwarded-For %[src]
# Basic hardening headers (edge-level)
http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Host-based routing (example)
acl host_example hdr(host) -i example.com www.example.com
use_backend be_web_example if host_example
default_backend be_web_example
backend be_web_example
option httpchk GET /healthz
http-check expect status 200
server web1 127.0.0.1:8080 check
Create a simple health endpoint on your backend (recommended).
If you don’t have one, change the health check to GET / and expect 200.
Or remove httpchk for now.
Now create the cert directory HAProxy expects:
sudo mkdir -p /etc/haproxy/certs
sudo chown -R root:haproxy /etc/haproxy/certs
sudo chmod 750 /etc/haproxy/certs
Validate HAProxy config syntax:
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
Hold off on restarting.
Get the certificate files into HAProxy’s expected format first.
Step 6: Obtain certificates and build HAProxy PEM files
Run Certbot using the webroot your local ACME service is serving:
sudo certbot certonly --webroot -w /var/lib/acme-challenge \
-d example.com -d www.example.com
Let’s Encrypt stores the cert material here:
/etc/letsencrypt/live/example.com/fullchain.pem/etc/letsencrypt/live/example.com/privkey.pem
HAProxy usually expects a single PEM containing certificate + key (and chain).
Build it like this:
sudo bash -c 'cat /etc/letsencrypt/live/example.com/fullchain.pem /etc/letsencrypt/live/example.com/privkey.pem > /etc/haproxy/certs/example.com.pem'
sudo chown root:haproxy /etc/haproxy/certs/example.com.pem
sudo chmod 640 /etc/haproxy/certs/example.com.pem
If you host multiple domains, create one PEM per domain under /etc/haproxy/certs/.
HAProxy picks the correct certificate via SNI.
Reload HAProxy:
sudo systemctl reload haproxy
sudo systemctl status haproxy --no-pager
Test end-to-end:
curl -I http://example.com
curl -I https://example.com
curl -I --http2 https://example.com
If you hit redirect loops, your backend app is likely enforcing HTTPS.
It can’t tell the client connection was already HTTPS.
Fix that by honoring X-Forwarded-Proto in your app/framework.
Or configure the app/web server to trust proxy headers.
Step 7: Make renewals hands-off (and reload HAProxy safely)
Certbot usually installs a systemd timer. Verify it’s running:
systemctl list-timers | grep certbot || true
sudo systemctl status certbot.timer --no-pager
After each renewal, rebuild the HAProxy PEM and reload HAProxy.
Use a deploy hook to automate that:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/haproxy-pem.sh >/dev/null <<'EOF'
#!/bin/sh
set -eu
DOMAIN="$RENEWED_LINEAGE"
NAME="$(basename "$DOMAIN")"
# Build PEM for HAProxy (cert + key)
cat "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" > "/etc/haproxy/certs/${NAME}.pem"
chown root:haproxy "/etc/haproxy/certs/${NAME}.pem"
chmod 640 "/etc/haproxy/certs/${NAME}.pem"
# Validate config before reload
haproxy -c -f /etc/haproxy/haproxy.cfg
systemctl reload haproxy
EOF
sudo chmod 750 /etc/letsencrypt/renewal-hooks/deploy/haproxy-pem.sh
Run a dry-run renewal test:
sudo certbot renew --dry-run
If that succeeds, you’ve eliminated a classic failure mode.
The cert renews, but the edge proxy keeps serving the old one.
Step 8: Update your backend to log real client IPs
Once HAProxy sits in front, your backend will see every request as coming from 127.0.0.1.
Fix that so your access logs show real visitor IPs.
Nginx: trust HAProxy on loopback
Edit your Nginx config:
sudo nano /etc/nginx/nginx.conf
Inside the http block:
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Reload:
sudo nginx -t
sudo systemctl reload nginx
Apache: use mod_remoteip
sudo a2enmod remoteip
sudo tee /etc/apache2/conf-available/remoteip.conf >/dev/null <<'EOF'
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 127.0.0.1
EOF
sudo a2enconf remoteip
sudo apachectl -t
sudo systemctl reload apache2
Step 9: Add a second site (repeatable pattern)
Most servers don’t stop at a single domain.
Use this workflow each time:
- Create a backend vhost (Nginx/Apache) listening on loopback, matching the new
server_name/ServerName. - Issue a new cert with Certbot for the new domain.
- Concatenate
fullchain.pem+privkey.peminto/etc/haproxy/certs/newdomain.tld.pem. - Add an ACL + backend mapping in HAProxy (or point multiple hosts at the same backend when that’s what you want).
Example HAProxy snippet for another domain on the same backend port:
acl host_blog hdr(host) -i blog.example.net
use_backend be_web_blog if host_blog
backend be_web_blog
server webblog1 127.0.0.1:8080 check
If you want stronger isolation, put each site on its own backend port (8081, 8082).
You can also separate users with tighter file permissions.
Step 10: Quick diagnostics for the problems you’ll actually hit
- Redirect loop (HTTP→HTTPS→HTTP): your app forces HTTPS but doesn’t trust
X-Forwarded-Proto. Fix the app’s proxy settings, or make redirects happen only at HAProxy. - Wrong certificate served: confirm the PEM filename ends with
.pemand contains the correct cert + key. Also confirm SNI by testing:openssl s_client -connect IP:443 -servername example.com. - 502/503 from HAProxy: the backend isn’t listening on 127.0.0.1:8080, the health check is failing, or (rarely) local firewall rules are interfering. Validate with
curl -I http://127.0.0.1:8080and check/var/log/haproxy.logif syslog is configured. - ACME challenge failing: verify routing with
curl http://example.com/.well-known/acme-challenge/testafter placing a file in/var/lib/acme-challenge/. - Real IP missing in logs: real-ip/remoteip isn’t enabled, or your header chain isn’t what you think it is.
If performance is your main reason for doing this, measure before and after.
Most big wins still come from caching and backend tuning (especially with WordPress).
For the backend layer, start here: PHP-FPM performance tuning. For Nginx page caching, see the Nginx caching configuration tutorial.
Security checklist (edge proxy edition)
Run through this after you confirm traffic flows:
- Disable weak TLS protocols: keep minimum at TLS 1.2 (or TLS 1.3 only if you control client compatibility).
- Enable HSTS only after you’re confident all subdomains support HTTPS.
- Keep the backend bound to
127.0.0.1so it’s not reachable from the public internet. - Log and alert on spikes: 4xx/5xx rates and backend health flaps.
- Harden SSH access (keys, allowlists, 2FA if possible). Use this SSH key setup tutorial if your server still uses password auth.
For a broader header baseline (CSP, frame-ancestors, and so on), set them where they belong.
That’s often in the app or web server.
If you do add headers at the edge, keep them minimal and test carefully.
Use this security headers configuration tutorial as a reference.
Summary: a cleaner HTTPS edge that’s easier to operate
TLS termination on HAProxy gives you one place to manage certificates and enforce TLS policy.
It also keeps your backend simpler.
You avoid duplicated TLS directives, renewal glue inside vhosts, and extra moving parts per site.
If you want a stable home for this architecture—especially when you’re consolidating multiple sites—start with a HostMyCode VPS.
If you’d rather not babysit patch windows and renewal hooks, managed VPS hosting is the practical production option.
If you’re standardizing HTTPS across multiple client sites, run HAProxy + Nginx/Apache on a VPS sized for your traffic and TLS overhead. A HostMyCode VPS is a good fit if you manage everything yourself; managed VPS hosting gives you the same architecture with less day-to-day maintenance.
FAQ
Do I still need SSL enabled on Nginx/Apache if HAProxy terminates TLS?
No. With this layout, Nginx/Apache serves plain HTTP on loopback.
Keep it bound to 127.0.0.1 so it isn’t exposed publicly.
Can I use one HAProxy instance for dozens of domains?
Yes. Store one PEM per domain under /etc/haproxy/certs/ and route by Host header.
As you scale, keep an eye on RAM usage and file descriptor limits.
Will this help performance?
It can. You standardize TLS behavior, enable HTTP/2 at the edge, and remove duplicated TLS config across sites.
The largest gains still come from caching and backend tuning.
Offloading TLS mainly keeps edge behavior consistent.
How do I handle WebSockets behind HAProxy?
HAProxy supports WebSockets in HTTP mode, but you must preserve upgrade headers.
If your app uses WebSockets behind an HTTP proxy layer, confirm your Nginx/WebSocket behavior too.
This guide can help: WebSocket setup guide tutorial.
What’s the safest way to test changes?
Validate configs before reload (haproxy -c, nginx -t, apachectl -t).
Keep an SSH session open.
Do a staged DNS cutover with low TTL.