Secure external access to your Homelab with mTLS
Introduction #
Usually, I secure my homelab services using Tailscale, and installing clients on all my devices as well as my families devices. This has worked great for a long time, but I wanted a solution that allowed me to give access to extended family members without needing to install Tailscale on all their devices.
Mutual TLS (mTLS) is a great way to secure services by requiring both server and client certificates during the TLS handshake, but it can be a pain to manage client certificates for devices on your local network, especially on obscure devices like smart TVs, game consoles and IoT devices.
I wanted a setup where:
- My services on
cloud.example.com(e.g. Nextcloud) are:- Easy to use on my LAN (no client cert prompts).
- Strictly protected by mutual TLS (mTLS) from the WAN.
- It should be seamless. Everything uses the same hostname and same port (443).
The design I ended up with uses:
- Caddy as the reverse proxy and for TLS termination.
- HAProxy as a tiny L4 TCP router that:
- Sends LAN traffic to a plain HTTPS listener in Caddy.
- Sends WAN traffic to a strict mTLS listener in Caddy.
- A small private CA called “Homelab CA” which issues client certificates for users.
In this post I’ll walk through:
- Why you need two TLS listeners.
- The complete Caddy + HAProxy configs.
- How to generate a “Homelab CA” and a client certs.
- How to import the client cert on macOS.
Replace cloud.example.com with your real hostname and adjust ports as needed.
Why you can’t “require mTLS only for WAN” on one listener #
Initially, I tried to do this with a single Caddy HTTPS listener on cloud.example.com:443, using Caddy’s tls directive with client_auth options.
However, it turned out that this wasn’t possible in Caddy (or Traefik) as the TLS (and mTLS) configuration is executed before any HTTP routing, including:
- Source IP checks
- Path‑based rules
- Headers
So you cannot configure Caddy or Traefik to only allow mTLS based on source IP as we do not have the source IP information at the TLS handshake stage.
The workaround is:
- Define two HTTPS listeners in Caddy:
- One without mTLS (for LAN).
- One with strict mTLS (for WAN).
- Put a small TCP‑level proxy (HAProxy) in front that:
- Routes LAN IPs to the non‑mTLS port.
- Routes everyone else to the mTLS port.
Outside of the LAN, clients still just use:
https://cloud.example.com
on port 443. HAProxy quietly chooses which backend port to hit based on the source IP.
Overall architecture #
- Caddy:
- Listens on
:8443for normal HTTPS (no mTLS). - Listens on
:9443for strict mTLS, then loops back into:8443.
- Listens on
- HAProxy:
- Listens on
:443(public). - Sends:
192.168.1.0/24→ Caddy:8443.- All other IPs → Caddy
:9443.
- Listens on
Caddy configuration #
Caddy serves both the plain and mTLS entrypoints in a single process.
Global ports #
{
https_port 8443
}
This tells Caddy:
- Any site like
cloud.example.com { ... }serves HTTPS on:8443.
Plain HTTPS site (8443, no enforced mTLS) #
tls block below.
This is required because Caddy is not listening on port 80 to serve HTTP challenges. You could serve HTTP challenges on 80 if
you wanted to, but I prefer to use the DNS‑01 challenge and not exposing anything publicly.
Example: Nextcloud on cloud.example.com:
cloud.example.com {
# Use Let's Encrypt with DNS-01 challenge for the server certificate
tls {
dns cloudflare <YOUR_CLOUDFLARE_API_TOKEN>
}
# Proxy to your Nextcloud upstream (HTTP)
reverse_proxy http://nextcloud:80
}
You can add more domains similarly (they’ll also use 8443 internally):
# Another internal service
immich.example.com {
tls {
dns cloudflare <YOUR_CLOUDFLARE_API_TOKEN>
}
# Proxy to your Immich upstream (HTTP)
reverse_proxy http://immich:2283
}
All of these live on Caddy’s internal port 8443, which HAProxy routes LAN traffic on port 443 to.
Global mTLS ingress (9443, host‑agnostic) #
Now we define a single, generic mTLS entrypoint on :9443:
:9443 {
# Require and verify client certs signed by Homelab CA
tls {
client_auth {
mode require_and_verify
trusted_ca_cert_file /mtls/homelab-ca.crt
}
}
# Host-agnostic mTLS ingress:
# - Terminates mTLS on 9443,
# - Forwards to 8443 on the same Caddy instance,
# preserving the original Host/SNI.
handle {
reverse_proxy 127.0.0.1:8443 {
header_up Host {host}
transport http {
tls
tls_insecure_skip_verify
tls_server_name {host}
}
}
}
}
How this works:
- A WAN client hits
cloud.example.com(on default HTTPS port 443):- HAProxy checks the source IP routes TCP to Caddy
:9443.
- HAProxy checks the source IP routes TCP to Caddy
- HAProxy proxies the request to port
9443:- Caddy requests and verifies a client cert against
homelab-ca.crt. - If the cert is valid, Caddy opens an HTTPS connection to
127.0.0.1:8443:- SNI =
{host}(e.g.cloud.example.com) - Host header =
{host}
- SNI =
- Caddy requests and verifies a client cert against
- The 8443 listener sees SNI/Host
cloud.example.comand routes into the normalcloud.example.comsite block.
The :9443 block has no host knowledge; it just passes the request into whatever host is already configured on 8443.
I did this intentionally so I don’t have to duplicate the config of all my site blocks for mTLS, and to prevent accidentally
allowing any sites to be reachable without mTLS.
Docker example (Caddy service) #
In docker-compose.yml:
services:
caddy:
image: caddy:2
container_name: caddy
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
- ./mtls:/mtls:ro # CA cert and keys for mTLS
ports:
- "8443:8443" # plain HTTPS on host 8443
- "9443:9443" # mTLS on host 9443
networks:
- web
networks:
web:
In production, HAProxy will serve port 443, but having 9443 mapped can be handy for debugging.
HAProxy configuration #
192.168.1.0/24 subnet. Adjust the
is_lan ACL in haproxy.cfg to match your actual LAN, or you can allow all RFC 1918 private IP ranges.
HAProxy’s job: route by source IP.
- LAN:
192.168.1.0/24→ Caddy:8443(no mTLS). - WAN: everything else → Caddy
:9443(strict mTLS).
I also added a simple HTTP frontend on port 80 that redirects to HTTPS. You could do this in Caddy instead, but I wanted to prevent any public traffic from reaching Caddy without mTLS.
Here’s an example haproxy.cfg file:
global
log stdout format raw daemon
defaults
log global
mode tcp
option tcplog
timeout client 30s
timeout server 30s
timeout connect 5s
frontend http-in
bind *:80
mode http
http-request redirect scheme https code 301
frontend https-in
bind *:443
mode tcp
tcp-request inspect-delay 5s
tcp-request content accept if { req.ssl_hello_type 1 }
# LAN: 192.168.1.0/24
acl is_lan src 192.168.1.0/24
# LAN → plain HTTPS
use_backend caddy-plain if is_lan
# Everyone else → strict mTLS
default_backend caddy-mtls
backend caddy-plain
mode tcp
# Caddy's plain HTTPS listener (8443)
server caddy_plain caddy:8443
backend caddy-mtls
mode tcp
# Caddy's mTLS listener (9443)
server caddy_mtls caddy:9443
And the HAProxy service in docker-compose.yml:
services:
haproxy:
image: haproxy:2.9
container_name: haproxy
ports:
- "443:443" # expose HAProxy directly to WAN
volumes:
- ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
depends_on:
- caddy
networks:
- web
caddy:
# as above
networks:
- web
networks:
web:
Now:
- When the client is on the LAN at, say,
192.168.1.50, his connections tocloud.example.com:443are routed to Caddy 8443. - When the client is outside of the LAN, their public IP does not match
192.168.1.0/24, so their connections are routed to Caddy 9443, and Caddy enforces mTLS.
Generating the Homelab CA and client cert #
I use a small private CA (Homelab CA) and generate a client certificate per user/device.
Step 1: Create Homelab CA #
# CA private key
openssl ecparam -name prime256v1 -genkey -noout -out homelab-ca.key
# CA certificate
openssl req -x509 -new \
-key homelab-ca.key \
-sha256 -days 3650 \
-out homelab-ca.crt \
-subj "/CN=Homelab CA"
Copy homelab-ca.crt into your ./mtls directory so Caddy can read /mtls/homelab-ca.crt.
Step 2: Create the client certificate signing request (CSR) #
For this example, we’ll create a client cert for user “Dave”.
Typically, Dave should generate his private key and CSR on his own machine, so the private key never leaves his device. The CA (on the server) only ever sees the CSR, not the key. However, you can generate the key and CSR on the server for testing or convenience.
# Dave's private key (keep this on Dave's machine)
openssl ecparam -name prime256v1 -genkey -noout -out dave.key
# Dave's CSR (Certificate Signing Request)
openssl req -new \
-key dave.key \
-out dave.csr \
-subj "/CN=Dave"
Step 3: Sign the client CSR with Homelab CA #
Now that we have Dave’s CSR, we can sign it with our Homelab CA to generate Dave’s client certificate.
This step should be done on the server where the CA private key resides, and the server should only see Dave’s CSR, not his private key.
# Client cert extensions for TLS client auth
cat > dave-ext.cnf <<EOF
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyAgreement
extendedKeyUsage = clientAuth
EOF
# Sign Dave's CSR with Homelab CA
openssl x509 -req \
-in dave.csr \
-CA homelab-ca.crt \
-CAkey homelab-ca.key \
-CAcreateserial \
-out dave.crt \
-days 365 \
-sha256 \
-extfile dave-ext.cnf
Now you have:
-
On the server (Homelab CA host):
homelab-ca.key— Homelab CA private key (never leaves the server).homelab-ca.crt— Homelab CA certificate.dave.csr— Dave’s CSR (can be kept or deleted).dave.crt— Dave’s signed client certificate.
-
On Dave’s machine:
dave.key— Dave’s private key.dave.csr— (original CSR, can be kept or deleted).
You send only dave.crt and homelab-ca.crt back to Dave.
Dave then has:
dave.key(already there)dave.crt(from the server)homelab-ca.crt(from the server)
Step 4: Package the client cert as .p12 #
On the client side, you may want to package Dave’s client cert and key into a PKCS#12 file (.p12 or .pfx), which is widely supported by browsers and OS keychains.
A PKCS#12 file is convenient as it bundles the client cert, client private key, and the CA cert together so they can be imported from a single file.
openssl pkcs12 -export \
-inkey dave.key \
-in dave.crt \
-certfile homelab-ca.crt \
-out dave.p12
Set an export password when prompted. This will be requested when importing the PKCS#12 file on the client devices.
At the end:
- Server keeps:
homelab-ca.key,homelab-ca.crt, and may archivedave.csr/dave.crt. - Client (Dave) keeps:
dave.key,dave.crt,homelab-ca.crt, anddave.p12for import into browsers/OS. - Transferred from client → server:
dave.csronly. - Transferred from server → client:
dave.crtandhomelab-ca.crt.
Importing on macOS #
On the client machine (Dave’s Mac), import the PKCS#12 file into the login keychain:
- Double‑click
dave.p12. - Choose the
loginkeychain. - Enter the export password.
- In Keychain Access, you should now see:
- A certificate “Dave”.
- A corresponding private key entry.
Browsers can now present Dave’s identity when a site requests a client certificate.
How it behaves #
From the LAN (192.168.1.0/24):
curl -vk https://cloud.example.com
- HAProxy sees source IP is in the 192.168.1.0/24 subnet.
- Sends the TCP connection to Caddy
:8443. - Caddy terminates plain HTTPS and proxies to Nextcloud.
- No client cert required; Dave browses normally.
From the WAN (any public IP not in 192.168.1.0/24):
# No client cert, TLS handshake fails (mTLS required)
curl -vk https://cloud.example.com
# With Dave's client cert
curl -vk https://cloud.example.com \
--cert dave.crt --key dave.key
- HAProxy routes WAN TCP to Caddy
:9443. - Caddy enforces
require_and_verifyagainst Homelab CA. - After successful mTLS, Caddy forwards internally to
127.0.0.1:8443and serves the site as usual.
Security considerations #
A few points to keep in mind:
- Exposing HAProxy to the public is acceptable here because:
- Only RFC 1918 addresses (your LAN subnet) bypass mTLS.
- Everyone else must present a Homelab CA signed client cert.
- If you want to be extra cautious, you can:
- Restrict the
is_lanACL to just a few trusted IPs (192.168.1.10 192.168.1.11). - Run mTLS everywhere and give LAN devices client certs as well.
- Restrict the
- Keep your Homelab CA private key secure. If compromised, revoke all client certs and issue new ones.
- IPv6: If your LAN uses IPv6, adjust HAProxy ACLs accordingly, and note that Docker may NAT IPv6 to a private IPv4 address (potentially bypassing your mTLS ACL).
For my environment, I’ve found this setup to be a good balance of security and usability. I also disabled IPV6 on my homelab services to avoid any unexpected access and extra complexity.
Conclusion #
Setting up mTLS for WAN access to your homelab services while keeping LAN access simple is achievable with a combination of Caddy (or another modern reverse proxy like Traefik or nginx) and HAProxy.
A summary of the setup:
- Caddy:
- 8443: plain HTTPS with per‑domain site blocks and reverse_proxy to your apps.
- 9443: strict mTLS ingress that forwards to 8443 on
127.0.0.1in a host agnostic way.
- HAProxy:
- 443: accepts all incoming HTTPS.
- Routes LAN IPs to Caddy 8443.
- Routes WAN IPs to Caddy 9443.
By clearly separating the “plain” and “mTLS” listeners and letting HAProxy decide which one a client hits, you get:
- A single hostname (
cloud.example.com). - A single public port (443).
- No cert prompts on LAN.
- Strong mTLS enforcement from WAN, backed by your own Homelab CA and per‑user client certs.