How to Protect Self-Hosted Services With mTLS Client Certificates in Caddy
If you run a self-hosted service behind Caddy, you can require each connecting device to prove its identity with a certificate. Mutual…
When you make a self-hosted website or service available over the internet, a reverse proxy can provide one controlled entry point. A reverse proxy is a server that receives visitors’ requests and forwards them to the service that should handle each request. It can manage HTTPS and route traffic by domain name, but it does not secure the backend service by itself.
Expose only the proxy to the public internet. Keep each service behind it, called a backend service, unreachable from the internet. Require appropriate authentication, and configure the proxy and service to handle client information safely. Choose whether your router will accept inbound connections or a tunnel client will connect outbound, then check that the network rules work as intended.
Forward public web traffic from your router or firewall to the reverse proxy, and let the proxy reach backend services over a private network while keeping their ports unpublished whenever possible. If a backend port must be published on the host, bind it to loopback or restrict access with firewall rules. Loopback refers to the host’s local network interface, which other devices cannot normally reach directly.
When you publish a container port, Docker makes it available through a host address. Docker's traffic handling can interact with host firewall rules. In particular, Docker-published traffic may not be blocked by ufw rules as an operator expects. Check the actual Docker port publishing behavior rather than assuming that a container’s EXPOSE instruction or a host firewall prevents public access.
Test from outside your home network over both IPv4 and IPv6. Then inspect router forwarding rules and container port mappings, and check firewall behavior. Docker also documents a version-specific caveat: before Docker Engine 28.0.0, other devices on the same layer-2 network could reach ports published to localhost. The safety of that binding therefore depends on the Docker version and network layout.
Use HTTPS between visitors and the proxy. HTTPS uses Transport Layer Security (TLS) to encrypt traffic and let a browser check the server’s identity through its certificate. Configure TLS 1.3 by default and TLS 1.2 for compatibility; disable TLS 1.0 and 1.1, following the OWASP TLS guidance.
The proxy usually decrypts the visitor’s request before forwarding it. If the proxy sends that request to a backend over plain HTTP, the proxy-to-backend connection is unencrypted. That may be acceptable on a network you trust, but use HTTPS with certificate verification when the connection crosses a network you do not trust. Do not disable certificate verification to make an HTTPS connection work; configure the proxy to trust the backend certificate and use the correct server name.
Certificate renewal also depends on how the proxy proves control of a domain. Let’s Encrypt’s HTTP-01 challenge requires its validation server to retrieve a token from a known web path. DNS-01 uses a DNS TXT record, a text entry in the domain name system. It supports wildcard certificates and works when HTTP validation is unavailable. DNS-01 requires access to DNS-provider credentials, so limit their permissions and protect where you store them. See Let’s Encrypt’s challenge descriptions.
A proxy often adds HTTP headers, extra fields in a web request, to tell a backend the visitor’s original IP address, hostname, or connection protocol. Headers such as X-Forwarded-For and X-Forwarded-Proto are ordinary request data; a visitor can send them too. The backend must not treat them as trustworthy unless the request came through a proxy it trusts.
Configure the backend to trust only the proxy’s address or network, and configure the proxy to remove or replace client-supplied forwarding headers. Express warns that trusting forwarded headers without controlling what the final proxy sends can let clients spoof their IP address, hostname, or protocol. Its guidance on running behind proxies also explains why a fixed proxy-hop count can be unsafe when requests can follow paths with different numbers of proxies.
If a content delivery network or tunnel sits in front of your proxy, include that service’s addresses in the trusted-proxy design. Test by sending a request with forged X-Forwarded-* headers and confirming that the backend and access rules use the values supplied by the trusted proxy, not the visitor.
HTTPS protects traffic and helps the browser verify the server. It does not decide which users may access a service. Keep the service’s own authentication and authorization controls enabled, including controls for administrative functions. Authentication checks a user's identity; authorization determines what that user is allowed to do; OWASP’s authorization guidance treats those permissions as part of application security.
A proxy-based login gateway can add a shared access check, but it should not become the only barrier if the backend remains directly reachable. When a backend accepts identity headers from a gateway, restrict backend access to that gateway and trust those headers only on that path. Otherwise, a direct request could bypass the gateway or supply false identity information.
For services that should remain private, use a VPN or an authenticated access gateway instead of making the service publicly available. An outbound tunnel is another option: the tunnel client makes outbound connections, so the server hosting the service, called the origin, does not need publicly open inbound ports. That changes the trust arrangement by placing the tunnel provider and tunnel client in the access path; it does not replace application authentication. Cloudflare’s tunnel documentation describes that connection model.
Security headers can strengthen browser protections, but each service needs compatible settings. HTTP Strict Transport Security (HSTS) tells browsers to use HTTPS for a site. A long HSTS duration can also prevent access if the site later has a certificate or HTTPS configuration problem. Content Security Policy (CSP) restricts which resources a page can load, but a restrictive policy can break pages that rely on other scripts or content. Test headers against each service rather than applying one blanket configuration. OWASP explains the purpose and trade-offs of these HTTP security headers.
Set proxy timeouts, upload limits, and rate limits according to how the service works. A limit suited to ordinary web pages, for example, could interfere with large uploads or long-running requests. Check the service’s documented requirements and test its normal functions after changing these settings.
With router port forwarding, the router sends selected inbound traffic to your proxy. This gives you direct control over the internet-facing endpoint, but you must maintain the proxy, certificates, firewall rules, and origin exposure.
With an outbound tunnel, the tunnel client connects to a provider, which routes requests through that connection. This avoids opening inbound ports at the origin, but makes the provider and tunnel client part of the request path. Neither arrangement removes the need to secure the proxy and backend. Choose based on whether you need public access, whether you can safely maintain inbound services, and which parties you are willing to trust.
After configuring the proxy, verify what an internet visitor can reach and what the backend trusts. A useful check is to:
Log useful security events, such as authentication and authorization failures, but do not routinely record every request header or body. HTTP headers and bodies can contain credentials and other sensitive information. OWASP’s logging guidance recommends recording relevant events while protecting log data and limiting sensitive content.
Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.
Take a look at vroni.com