Caddy Client Certificates: The Cheap Private Gate I Put in Front of My Homelab

Caddy Client Certificates: The Cheap Private Gate I Put in Front of My Homelab

Password prompts are fine. VPNs are better. But for a few private web apps, Caddy mutual TLS is the tiny security layer I wish I had set up earlier.

đź’ˇ Disclosure: This article contains affiliate links. If you make a purchase through these links, we may earn a small commission at no extra cost to you. This helps support the site and keeps the content free.

I used to expose a couple of “private but not that private” homelab apps behind plain login screens.

You know the ones. Uptime dashboards. A tiny admin panel. A bookmark app. Stuff that should not be public, but also did not feel dramatic enough to justify a full SSO rollout on a quiet Sunday afternoon.

That was lazy thinking. Not catastrophic, just lazy.

The internet does not care that your dashboard is boring. Bots will still find it, poke it, screenshot it, and try every dusty exploit from 2019. After watching my reverse proxy logs fill with nonsense requests for /wp-admin on a server that has never run WordPress, I finally put a harder gate in front of the apps I rarely access.

For that job, Caddy client certificates are excellent.

The short version

Caddy can require a browser or device to present a trusted client certificate before it even proxies traffic to your app. This is called mutual TLS, or mTLS.

Normal HTTPS proves the server is legit to your browser. mTLS also asks your browser to prove you are legit to the server.

No certificate? You do not see the app. You do not see the login page. You do not even get to annoy the container behind Caddy.

Honestly, for private homelab web apps, this is better than relying on app passwords alone. Not instead of good passwords. In front of them.

When I use this instead of a VPN

I still like VPNs. If I am doing SSH, database admin, file transfers, or anything that should feel like a private network, I want WireGuard, Tailscale, or a commercial VPN in the path.

But sometimes I just want to open status.example.com from my laptop and check a page. I do not want to connect a tunnel first. I do not want to explain Tailscale to a family member who only needs to access one recipe app twice a month.

That is the sweet spot for client certificates:

  • one or two private HTTPS apps
  • a small number of trusted devices
  • no public signup page
  • no need for a full identity provider
  • you already use Caddy as your reverse proxy

If you travel a lot or administer servers from random networks, keep a proper VPN in your kit too. mTLS protects the app gate, but it does not magically make hotel Wi-Fi trustworthy.

🚀NordVPN

Secure your server with a reliable VPN.

Get NordVPN →

Affiliate link — we may earn a commission at no extra cost to you.

What we are building

We will create a tiny certificate authority, issue one client certificate, install it in a browser, and tell Caddy to only trust requests signed by that authority.

The flow looks like this:

  1. Browser connects to private.example.com
  2. Caddy asks for a client certificate
  3. Browser presents your certificate
  4. Caddy checks it against your trusted CA
  5. Only then does Caddy reverse proxy to your app

Your app still keeps its own login. That matters. Client certificates are a gate, not an excuse to disable authentication.

Step 1: Create a local certificate authority

Do this on a machine you trust. I keep mine in an encrypted folder because this CA can issue keys that open the front door.

mkdir -p ~/homelab-mtls
cd ~/homelab-mtls

openssl genrsa -out homelab-ca.key 4096

openssl req -x509 -new -nodes \
  -key homelab-ca.key \
  -sha256 \
  -days 3650 \
  -out homelab-ca.crt \
  -subj "/CN=homelab-client-ca"

The homelab-ca.key file is the sensitive one. Do not copy it to your VPS. Do not commit it. Do not leave it in your downloads folder like a raccoon with sudo.

The homelab-ca.crt file is public-ish. Caddy needs that one so it knows which client certificates to trust.

Step 2: Issue a client certificate

Now create a certificate for your laptop.

openssl genrsa -out laptop.key 4096

openssl req -new \
  -key laptop.key \
  -out laptop.csr \
  -subj "/CN=my-laptop"

openssl x509 -req \
  -in laptop.csr \
  -CA homelab-ca.crt \
  -CAkey homelab-ca.key \
  -CAcreateserial \
  -out laptop.crt \
  -days 825 \
  -sha256

Browsers usually want a .p12 bundle, not a loose key and certificate.

openssl pkcs12 -export \
  -out laptop.p12 \
  -inkey laptop.key \
  -in laptop.crt \
  -certfile homelab-ca.crt

Use a real export password. You will type it when importing the certificate into your browser or operating system keychain.

This is where I messed up the first time: I imported the CA certificate instead of the client bundle. The browser happily accepted it, then Caddy kept rejecting me. If your file does not contain the private key, it cannot authenticate you.

Step 3: Copy the CA certificate to your server

Only copy homelab-ca.crt to the server running Caddy.

scp homelab-ca.crt deploy@your-server:/opt/caddy/certs/homelab-ca.crt

If you run Caddy in Docker, mount that path read-only:

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./certs:/etc/caddy/certs:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  caddy_data:
  caddy_config:

Adjust paths to match your setup. The important bit is that Caddy can read the CA certificate, and nothing on the server has your CA private key.

Step 4: Require the client certificate in Caddy

Here is the Caddyfile pattern I use:

private.example.com {
  tls {
    client_auth {
      mode require_and_verify
      trust_pool file /etc/caddy/certs/homelab-ca.crt
    }
  }

  reverse_proxy app:3000
}

Reload Caddy:

docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

Or with a system package install:

sudo systemctl reload caddy

Now open the site from a browser without the client certificate. You should get blocked before the app loads.

Import laptop.p12, refresh, and your browser should prompt you to pick a certificate. Choose it, and you are in.

Step 5: Do not lock yourself out like I did

Before enabling this on something important, keep a second terminal open on the server. Seriously.

My first test was on an admin dashboard I actually needed that day. I typoed the certificate path, reloaded Caddy, and then spent ten minutes muttering at my own cleverness. The fix was easy over SSH, but it would have been annoying if SSH had also depended on that web panel.

Test with a throwaway subdomain first:

mtls-test.example.com {
  tls {
    client_auth {
      mode require_and_verify
      trust_pool file /etc/caddy/certs/homelab-ca.crt
    }
  }

  respond "you have a valid client certificate"
}

Once that works, move the pattern to real apps.

Where this works really well

I like mTLS for apps that are useful to me but boring to everyone else:

  • private dashboards
  • internal documentation
  • lightweight admin tools
  • staging environments
  • personal bookmark or RSS apps
  • webhook receivers that should only be hit by your own devices

It is also nice for apps with weak authentication. Some self-hosted tools are fantastic but still ship with login systems that feel like they were built during a long lunch break. Putting Caddy in front buys you a strong outer layer.

Where I would not use it

Do not use client certificates for public apps where random users need access. The UX is too weird. People do not want to install certificates just to see your blog or download a file.

I also would not use this as the only protection for critical infrastructure. For SSH, Proxmox, databases, and server management, I still prefer VPN-only access plus keys and MFA where possible.

For shared family apps, think carefully. Importing a certificate on one laptop is easy. Doing it across five phones, two tablets, and a mystery iPad with 9% battery is how a weekend disappears.

Revoking access

The boring strategy works: issue short-lived client certificates and rotate them.

If a device is lost, create a new CA, issue fresh certs, update Caddy, and remove trust for the old CA. For three homelab devices, that is still faster than debugging a broken SSO flow.

If you need per-user revocation, audit trails, groups, and policy, use Authentik, Authelia, or a VPN mesh. mTLS is a sharp little tool, not an identity platform.

My current rule

If an app is public, it gets normal HTTPS, app auth, updates, and monitoring.

If an app is private and browser-based, it gets Caddy client certificates.

If an app controls servers or sensitive data, it goes behind a VPN first.

That split has made my setup calmer. Fewer exposed login pages. Fewer bot requests reaching containers. Fewer “why is this random IP trying Laravel paths on my Grafana box?” moments.

And the best part: once the certificate is installed, day-to-day use feels invisible. You open the site, the browser presents the cert, and Caddy lets you through.

That is exactly the kind of security I like. Annoying for attackers, boring for me.

FAQ

Is mTLS better than a VPN?

No. It solves a different problem. mTLS protects access to a specific HTTPS site. A VPN protects network access more broadly. For serious server administration, use a VPN.

Can I use this with Nginx or Traefik?

Yes, both can do client certificate authentication. I use Caddy here because the config is wonderfully small and automatic HTTPS is built in.

Should I remove the app login after adding client certificates?

Absolutely not. Keep the app login. Layers are the point.

What should I do next?

Pick one low-risk private subdomain and test mTLS there. Once it works, move your boring-but-private dashboards behind it. Then decide which services deserve VPN-only access.

Stay in the loop 📬

Get self-hosting tutorials, tool reviews, and infrastructure tips delivered to your inbox. No spam, unsubscribe anytime.

Join 0 self-hosters. Free forever.