ProbableOdyssey | Blake Cook

Secure Home Server Access with Caddy, Tailscale, and Cloudflare

· 4 min read · 701 words

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

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 dockernet

Make 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: true

Some notes on this:

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: true

Make sure to create your .env file:

CLOUDFLARE_API_TOKEN=your_token_here
DOMAIN=example.com

Now 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) ↪