Reverse-Proxying Self-Hosted Services with Caddy
Problem Statement#
I have a whole mess of services that I host internally. I have them remotely available via Tailscale, but only via hostname and ports, and without SSL. I previously tried solving this problem with Nginx (huge pain, completely manual config), Traefik (way way way too magical), and Caddy (honestly cannot recall what my problem was), but couldn’t get it working reliably, so I stuck with hostname and ports. But that still left me with no SSL and certain services have to run over SSL to work, like local text-based assistants in Home Assistant, and various rich notifications on iOS, so… it’s time to fix this.
Acceptance Criteria#
- Services must be available both locally and remotely via the same FQDNs.
- No “internal” bookmarks vs “external” bookmarks!
- Services should not be remotely addressable whatsoever unless authenticated via Tailscale.
- Services must be available over SSL, with as-real of a cert as possible.
- No self-signed certs!
- Everything needs to continue working locally even if DNS or Internet access at the house have failed.
- Obviously if my ISP barfs I’m not going to get remote access to services, but I should still be able to turn my lights on with Home Assistant.
- As few new services as possible should be introduced.
- Ideally it should be a 1:1 swap, drop Nginx in favor of Something Else.
References#
This video and its accompanying article from Alex Kretzschmar were the impetus for me trying again. His article goes way further into the fundamentals of how/why everything works, I’m going to focus on just the specifics it took to get this working, if ony so that if I have to re-configure this from scratch in the future I’ll have my own notes to refer to.
Alternatives Considered#
As with anything there are obviously an infinite number of different alternative ways this could be achieved. Here’s what I didn’t use.
- linuxserver-nginx && proxy-confs – This is the solution I was previously using. It works, but it still requires a ton of manual configuration, and doesn’t solve the problem of SSL termination.
- Traefik – Traefik is great, but I found it was TOO magical, and alone it has no solution to serve static HTML content because it’s just a proxy not a webserver. I need the ability to serve static HTML because I have a plain HTML+CSS service directory.
- Nginx Proxy Manager – NPM is a great solution, and if I had any kind of problems with Caddy I’d be looking at it instead, but Caddy solves the same problem with a far more lightweight solution. However, I must note that the acronym collision with npm is absurd and potentially absurd enough for me to pass.
- Just Using Tailscale – I think I could have gotten away with just using Tailscale all the time and relying on my Tailnet’s MagicDNS stuff and auto-assigned Tailscale hostname. But I don’t want to be entirely beholden to outside services for this to work. If my internet access chokes for an extended period of time, or if Tailscale mysteriously flakes out, my local services need to continue working.
Prerequisites#
- Docker and some services to expose – Docker is where all our stuff is going to run.
- A local DNS Cache – I use highly-available AdGuard Home for both my local DHCP, DNS Caching and DNS-based ad blacklisting. You could also probably use Pi-hole just as well.
- A Domain – Great news, I’ve got plenty on hand.
- A public DNS Host – I use Cloudflare for everything (even hosting this article). You could probably accomplish this with other DNS hosts but Cloudflare makes it so easy.
- A VPN System – I use Tailscale, but you could probably do this with Headscale with substantially more effort.
Overview#
- We’re going to set up Caddy as a reverse proxy in front of our services.
- That will translate arbitrary subdomains to arbitrary hosts and ports on the local network.
- We’ll rig up Caddy to Cloudflare so it can terminate SSL with real certs.
- Then we’ll rig up Cloudflare with a wildcard DNS record to point to Tailscale, and set up a corresponding wildcard DNS record in our local DNS server.
The Domain Structure#
Before jumping into config, you need to sort out your domain structure. We’re going to be wildcarding a whole hostname to our server, so think about how you’re going to do it. You can configure hostnames of ARBITRARY DEPTH so Alex recommends service.server.location.domain.com. That’s overkill for my purposes, I’ve only got the one server at one location, so I went with just service.svcs.domain.com. That means we’ll be setting up an eventual record for *.svcs.domain.com. You don’t have to use svcs as your prefix, it just has to be something.
Caddy#
Caddy was incredibly simple to set up. The only weird bit I encountered is that “normal” Caddy no longer comes with Cloudflare support out of the box, you have to either roll your own Docker container and add their plugin or use caddy-cloudflare. I went with the latter because I am lazy.
services:
caddy:
image: ghcr.io/caddybuilds/caddy-cloudflare:latest
restart: unless-stopped
cap_add:
- NET_ADMIN
ports:
- 80:80
- 443:443
- 443:443/udp
volumes:
- ~/docker/caddy/Caddyfile:/etc/caddy/Caddyfile
- ~/docker/caddy/sites:/srv
- ~/docker/caddy/data:/data
- ~/docker/caddy/config:/config
environment:
- CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
We’re passing in a CLOUDFLARE_API_TOKEN as an environment variable. Specifically, it needs to be an API Token (not an API Key) with the Zone.Zone and Zone.DNS roles on the specific zone you’re going to use. You can generate one here.
You’ll also need a Caddyfile, which is an incredibly simple config file.
{
# You may find this unbelievable, but this one line does _all_ of our SSL config
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
# This is a test static host.
# ~/docker/caddy/sites/test/index.html just has hello world in it.
test.svcs.domain.com {
root * /srv/test
file_server
encode gzip
}
# This is a static host with my service directory
dir.svcs.domain.com {
root * /srv/service_directory
file_server
encode gzip
}
# And this is an example reverse-proxied service running on port 1234
service.svcs.domain.com {
# This can be _basically anything_ this server can reach.
# It doesn't have to be the server itself.
# I used my server's static IP.
reverse_proxy 192.168.1.50:1234
}
That’s it. If you don’t have static stuff to host, it’s literally 3 lines to configure SSL for everything, and 3 for each service you’re proxying, and the result is something you can safely chuck in git.
Local DNS#
In AdGuard Home, under Filters -> DNS Rewrites you just add a rewrite for the wildcard domain to the ip of the server you will be running Caddy on. Ex:
*.svcs.domain.com -> 192.168.1.50
Tailscale#
Real quick setup in Tailscale. Head to the DNS tab. Make sure MagicDNS is enabled (I think it is by default), and under Nameservers make sure you’ve configured Cloudflare with “overwrite nameservers”. The nameservers bit might not even be necessary, but it shouldn’t hurt.
Back at your devices list, grab the Tailscale Domain Name of the server you’re running Caddy on – ex: server-name.some-random-prefix.ts.net – you’ll need it for the next step.
Remote DNS#
In Cloudflare, head to your domain’s DNS config and add a new CNAME record:
*.svcs -> server-name.some-random-prefix.ts.net
Quick Test#
Locally you should now be able to test with dig.
First, without Tailscale enabled:
> dig test.svcs.domain.com +nocmd +nocomments +nostats
; <<>> DiG 9.10.6 <<>> test.svcs.domain.com +nocmd +nocomments +nostats
;; global options: +cmd
;test.svcs.domain.com. IN A
test.svcs.domain.com. 10 IN A 192.168.1.50
;; Query time: 4 msec
;; SERVER: 192.168.1.1#53(192.168.1.1)
;; WHEN: Sun Nov 09 20:45:35 EST 2025
;; MSG SIZE rcvd: 50
Note that the SERVER is your local IP of your DNS server, and you’re getting a whole A-record pointing at your local server IP.
Next, with Tailscale enabled:
❯ dig test.svcs.domain.com +nocmd +nocomments
; <<>> DiG 9.10.6 <<>> test.svcs.domain.com +nocmd +nocomments
;; global options: +cmd
;test.svcs.domain.com. IN A
test.svcs.domain.com. 300 IN CNAME server-name.some-random-prefix.ts.net.
;; Query time: 108 msec
;; SERVER: 100.100.100.100#53(100.100.100.100)
;; WHEN: Sun Nov 09 20:48:24 EST 2025
;; MSG SIZE rcvd: 141
Note that the SERVER is now 100.100.100.100 (Tailscale’s internal DNS server), and you’re now getting back a CNAME record pointing to your server’s ts.net domain name.
Also, note that we never made an explicit DNS record for test.svcs.domain.com, so the wildcard record is working properly.
Start Caddy#
Run docker compose up -d caddy, or maybe drop the -d on your first run and watch it configure all your SSL certs.
Test It!#
Assuming Caddy came up and didn’t freak out, you’re done! Go load up test.svcs.example.com and service.svcs.example.com in your browser and it should Just Work. Both HTTP and HTTPS should work perfectly and your browser shouldn’t complain about the cert at all. Similarly you should be able to load the same hostnames from outside your local network if you’re connected to Tailscale, and it should fail hard if Tailscale is disabled.
Further Config and Ongoing Maintenance#
For most normal services it should just be a matter of exposing it by port in Docker and then mapping that port to a hostname with a reverse_proxy entry. I had exactly one service that I had to add extra config to, which was my Unifi control plane. You need to add tls_insecure_skip_verify, because this container comes with a baked-in self-signed cert.
unifi.svcs.example.com {
reverse_proxy 192.168.1.50:8443 {
transport http {
tls_insecure_skip_verify # skip attempting to verify the self-signed cert
}
header_up - Authorization # sets header to be passed to the controller
}
}
And that’s it! You now have full SSL configured for all your local services, and adding a new service consists of adding 3 lines to a config file. Everything works locally without any hard dependencies on external services, and if you turn on Tailscale it’ll also work remotely. And it all cost $0.