Secure Home Server Access with Caddy, Tailscale, and Cloudflare
Table of Contents
To serve multiple services on a node on my Tailnet, I’m going to use Caddy as a reverse proxy. I’ve rented a domain name, now I just need to connect that domain name to my server in a secure manner that allows me to access my self-hosted services.
I ended up on a relatively simple solution that matched all my criteria, but it was not without a few investigations in leads that didn’t pan out
To keep this home server secure, I’m only exposing the IP of the server via my Tailnet. Only those machines logged into the Tailnet will be able to access that IP address.
Initial attempt: tailscale serve
- Tailscale only allows one domain per node, and subdomains aren’t supported
- I could
tailscale serve <port>to broadcast the service- But this will only work for one service
Proposed solution: Expose Caddy, use URI paths to resolve to different services
Limitation: Most web servers assume they’re on a root path <domain>/ instead
of <domain>/<name-of-app>. It’s frustrating and sometimes impossible to
configure this for each service.
Regardless of how much fiddling with the reverse_proxy to strip paths, there was always some caveat that was a total pain to debug.
No matter how far you’ve gone down the wrong path: turn back now.
Container communication with docker network
I’ve created the dockernet network so my hosted containers can
communicate with each other
docker network create dockernetMake sure to run before spinning using the compose files throughout this post up so we don’t accidentally create a network for each service.
I’m using this docker compose to create a container for syncthing:
services:
syncthing:
image: syncthing/syncthing:latest
container_name: syncthing
restart: unless-stopped
hostname: syncthing
environment:
- TZ=${TZ:-Etc/UTC}
ports: # These ports are for local LAN traffic
- "22000:22000/tcp"
- "22000:22000/udp"
- "21027:21027/udp"
volumes:
- /opt/syncthing/config:/var/syncthing/config
- /mnt/path/to/drive:/storage
networks:
- dockernet
networks:
dockernet:
external: trueSome notes on this:
- The Syncthing web UI (port
8384) doesn’t need to be published — Containers can reach it over dockernet /mnt/path/to/driveis where the actual synced data lives
Accepted solution: subdomains and the Cloudflare DNS
Using subdomains allows each service to have it’s own root path, so no more fiddling with paths thankfully!
So now: I map each subdomain to the same IP address on the Tailnet, and I’m going to use a reverse proxy to direct traffic to each requested service.
On the Cloudflare dashboard under domains then DNS, I’ve added type A records
for syncthing that point to the Tailscale IP address (found on the Tailscale
dashboard) with Proxy type: DNS only - reserved IP and TTL: Auto
Note that MacgicDNS should be enabled on the Tailscale dashboard!
Caddy is a wonderfully simple reverse
proxy. It’s configured for automatic HTTPS but using the default HTTP-01
challenge from “Let’s Encrypt”. But since the server sits behind Tailscale (a
private network IP), Let’s Encrypt can’t reach
http://syncthing.<domain>/.well-known/acme-challenge/... to verify domain
ownership.
Instead of HTTP-01, we’ll switch Caddy to the DNS-01 challenge using the
Cloudflare DNS plugin (for which there’s a docker image for). This lets Caddy
prove domain ownership by creating a TXT record in Cloudflare (which “Let’s
Encrypt” can verify without ever hitting the server)
services:
caddy:
image: ghcr.io/serfriz/caddy-cloudflare:latest
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
dns:
- 1.1.1.1
- 8.8.8.8
env_file: .env # This should have CLOUDFLARE_API_TOKEN=... and DOMAIN=...
volumes:
- opt/caddy/config:/etc/caddy
networks_advanced:
name: dockernet
networks:
dockernet:
external: trueMake sure to create your .env file:
CLOUDFLARE_API_TOKEN=your_token_here
DOMAIN=example.comNow let’s create and edit the config at /opt/caddy/config/Caddyfile:
syncthing.{env.DOMAIN} {
reverse_proxy syncthing:8384
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
}Now start up the caddy service:
docker compose up -d
# After changes to Caddyfile, use
# `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`We can double check the certificates have been handled correctly with inspecting the logs:
docker logs caddy 2>&1 | grep -i "certificate"Once the DNS changes have propagated (which you can test with nslookup syncthing.<domain>, or test Cloudflare DNS specifically with nslookup syncthing.<domain> 1.1.1.1), you should be able to test it with curl:
curl https://syncthing.<domain>And now, we can access https://syncthing.<domain> in the browser from a device connected to the Tailnet!
If this error appears in the browser:
no valid A records found for `syncthing.<domain>`Double check the SSL/Cloudflare plugin instructions above.
Reply to this post by email blZake@proZbableodyssey.blog (remove Z characters) ↪