Post

Securing my homelab services with Caddy Reverse Proxy

This weekend, I tackled a project I’d been meaning to do for a while: centralizing my home services behind a single reverse proxy. Instead of juggling ports like homeserver:3001 or wrestling with certificate errors, I now have all my services running securely over HTTPS - accessible both locally on my LAN and remotely through Tailscale - using clean, memorable hostnames like git.home.arpa.

The Problem

Before this setup, accessing my services was inconsistent:

  • Multiple services running on different ports (homeserver:3000, homeserver:3001, etc.)
  • Certificate errors on services like UniFi that didn’t have proper SSL
  • No consistent encryption across all services
  • Awkward remote access without proper HTTPS
  • Manual certificate management for each service
  • Different bookmarks or mental overhead depending on whether I was at home or on Tailscale

The Solution: Caddy

I chose Caddy as my reverse proxy for several reasons:

  • Automatic HTTPS with its own internal CA (no Let’s Encrypt dependency for local services)
  • Simple, readable configuration language
  • Built-in support for reverse proxying
  • Works seamlessly with Docker
  • Easy integration with Tailscale
  • Minimal overhead while providing maximum security

What is a Reverse Proxy?

Before diving into the setup, here’s a simple diagram showing how Caddy works as a reverse proxy:

Reverse proxy architecture diagram showing Caddy routing requests to backend services

Caddy sits in front of your services, accepts encrypted HTTPS connections from clients, and forwards the traffic to the appropriate backend service. This means your services don’t need to handle SSL/TLS themselves - Caddy does all the heavy lifting.

My Setup

Services Behind Caddy

I configured my key self-hosted services behind the Caddy reverse proxy, each with a clean home.arpa hostname:

HostnameService
git.home.arpaGitea - self-hosted Git
vault.home.arpaVaultwarden - password manager
rss.home.arpaMiniflux - RSS reader
archive.home.arpaKiwix - offline Wikipedia
hub.home.arpaHome Assistant
dns.home.arpaPi-hole

The naming convention is intentional: hostnames describe what a service does, not what software runs it. If you ever swap Gitea for Forgejo, or Miniflux for FreshRSS, the hostname stays the same and nothing breaks.

Why home.arpa Instead of Ports?

The old approach was keeping ports and using the server’s hostname directly (homeserver:3001 for Gitea, homeserver:3002 for UniFi, etc.). It worked, but it was clunky.

The new approach uses the officially reserved home.arpa domain for home network use, with one hostname per service - no ports to remember. Caddy listens on standard HTTPS port 443 and routes each incoming hostname to the right backend container. Clean, predictable, and works exactly the same whether you’re on the LAN or connected through Tailscale.

Split-Horizon DNS: The Key to Seamless Access

The magic that makes the same hostname work from both your LAN and Tailscale is split-horizon DNS - two DNS servers that resolve the same hostname to different IPs depending on where you’re connecting from.

DNS ServerResolves git.home.arpa toUsed by
UniFi local DNS192.168.y.x (LAN IP)Devices on your home network
Pi-hole (on Tailscale)100.x.x.x (Tailscale IP)Devices connected via Tailscale
  • On the LAN: your UniFi router handles DNS and points git.home.arpa to the server’s local IP. Fast, no VPN needed.
  • On Tailscale: your DNS is set to Pi-hole (which runs on the Tailscale network). Pi-hole resolves git.home.arpa to the server’s Tailscale IP, routing through the VPN.

Whenever you add a new service, you register it in both DNS servers with the appropriate IP. It’s a small amount of duplication, but it gives you flawless, identical access from any network.

Tip: You don’t need BIND or any fancy DNS view configuration. Two simple static DNS entries - one in UniFi, one in Pi-hole - is all it takes.

Network Architecture

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  LAN Device                    Tailscale Device
       │                               │
       ▼                               ▼
  UniFi DNS                      Pi-hole DNS
  git.home.arpa                  git.home.arpa
  → 192.168.y.x                  → 100.x.x.x
       │                               │
       └──────────────┬────────────────┘
                      ▼
              Caddy (port 443)
             git.home.arpa → gitea:3000
             rss.home.arpa  → miniflux:80
             vault.home.arpa → vaultwarden:80
                      │
           ┌──────────┴──────────┐
           ▼                     ▼
        Gitea              Vaultwarden
     (Docker container)  (Docker container)

Technical Details

Setting Up the Docker Network

First, create a shared Docker network that all your services will use to communicate:

1
docker network create homelab

This network needs to be created only once. All your docker-compose files reference this network, allowing Caddy to reach the backend services by their container names.

Docker Compose Setup

Caddy Reverse Proxy - docker-compose.caddy.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
services:
  caddy:
    image: caddy:latest
    container_name: caddy
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - /DATA/AppData/caddy/data:/data
      - /DATA/AppData/caddy/config:/config
    networks:
      - homelab
    restart: unless-stopped

networks:
  homelab:
    external: true

Using explicit host paths like /DATA/AppData/caddy/data instead of named Docker volumes makes it much easier to locate, back up, and migrate Caddy’s data (including certificates) across servers.

Example service - Gitea (docker-compose.gitea.yml):

1
2
3
4
5
6
7
8
9
10
11
12
13
services:
  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    expose:
      - "3000"
    networks:
      - homelab
    restart: unless-stopped

networks:
  homelab:
    external: true

Key points:

  • Caddy is the only container exposing ports to the host (80 and 443)
  • All backend services only expose ports internally to the Docker network via expose:
  • Caddy routes to each service by its Docker container name

Caddyfile Configuration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
git.home.arpa {
    tls internal
    reverse_proxy gitea:3000
}

vault.home.arpa {
    tls internal
    reverse_proxy vaultwarden:80
}

rss.home.arpa {
    tls internal
    reverse_proxy miniflux:8080
}

archive.home.arpa {
    tls internal
    reverse_proxy kiwix:8080
}

# UniFi - skip cert verification since it uses its own self-signed cert
unifi.home.arpa {
    tls internal
    reverse_proxy https://10.0.0.1:443 {
        transport http {
            tls_insecure_skip_verify
        }
    }
}

Configuration breakdown:

  • Each block maps one home.arpa hostname to one backend container
  • tls internal tells Caddy to use its own internal CA - no external certificate authority needed
  • The UniFi block proxies to the router’s IP directly and skips certificate verification since UniFi uses its own self-signed cert

Trusting Caddy’s Root Certificate

Since Caddy uses an internal CA, you need to trust that CA on your devices. You only need to do this once per device, and all current and future home.arpa hostnames will be trusted automatically.

Finding the Certificate

Caddy stores its root CA in the data volume. To find it:

1
find /DATA/AppData/caddy -type f

This lists every file under your Caddy data directory. Look for a file ending in .crt under a path like:

1
/DATA/AppData/caddy/data/caddy/pki/authorities/local/root.crt

Extracting and Trusting on macOS

Copy the root CA to a readable location, then SCP it to your Mac:

1
2
3
# On the server - make it readable
cp /DATA/AppData/caddy/data/caddy/pki/authorities/local/root.crt /tmp/caddy-root.crt
chmod 644 /tmp/caddy-root.crt
1
2
3
4
5
6
# On your Mac - pull the cert over
scp user@192.168.y.x:/tmp/caddy-root.crt ~/Downloads/caddy-root.crt

# Trust it system-wide
sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain ~/Downloads/caddy-root.crt

For individual service certs (e.g. to trust budget.home.arpa directly if needed):

1
2
3
4
5
6
7
8
# On the server
cp /DATA/AppData/caddy/data/caddy/certificates/local/budget.home.arpa/budget.home.arpa.crt /tmp/budget.crt
chmod 644 /tmp/budget.crt

# On your Mac
scp user@192.168.y.x:/tmp/budget.crt ~/Downloads/budget.home.arpa.crt
sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain ~/Downloads/budget.home.arpa.crt

Tip: Always import and trust the root CA (root.crt) rather than individual service certificates. One trust action covers every hostname Caddy manages now and in the future.

Trusting on Other Devices

PlatformMethod
LinuxCopy to /usr/local/share/ca-certificates/ and run sudo update-ca-certificates
WindowsImport via Certificate Manager or PowerShell Import-Certificate
iOS/AndroidEmail the .crt to yourself, open it, and trust it in Settings → Certificate Trust

Migrating Caddy to a New Server

One of the best benefits of using Caddy with a known data path is how easy it is to replicate the same setup - and the same Root CA - to a second server. This means devices that already trust your original CA won’t need to re-trust anything.

Copy Caddy Data Between Servers

1
2
3
# From your local machine, copy the Caddy data directory to the new server
scp -r user@192.168.y.x:/DATA/AppData/caddy/ \
        user@server2:/tmp/caddy-backup/

Then SSH into the target server and move the data into place:

1
2
3
ssh user@server2

sudo mv /tmp/caddy-backup/caddy /DATA/AppData/caddy

Fix Permissions After Copy

After copying, Docker (running as root) may have created files owned by root. Fix ownership so user/Caddy can read and write the data correctly:

1
ssh user@server2 "sudo chown -R user:user /DATA/AppData/caddy/"

Now point your new server’s docker-compose.caddy.yml to /DATA/AppData/caddy/ and start Caddy. It will reuse the exact same Root CA, meaning every device that already trusts it will connect without any warnings - no additional manual steps needed on any device.

Benefits

No More Certificate Errors

The biggest win was eliminating certificate warnings entirely. Browsers would still complain about self-signed certs even after manually adding them to some stores. With Caddy’s internal CA trusted once at the root level, everything just works.

Clean, Memorable URLs

Going from homeserver:3001 to git.home.arpa is a real quality-of-life improvement. Bookmarks are cleaner, shareable with anyone on your home network, and don’t require remembering port numbers.

Unified Experience

The same URL works everywhere:

  • At home on your LAN? https://git.home.arpa
  • Remote on Tailscale? https://git.home.arpa
  • On your iPhone? https://git.home.arpa

Split-horizon DNS handles the routing transparently - you never think about which network you’re on.

Easy to Scale

Adding a new service is three steps:

  1. Add a block to the Caddyfile
  2. Add the hostname in both DNS servers (UniFi + Pi-hole) with the appropriate IPs
  3. Done - HTTPS just works, same hostname from everywhere

Portable Certificates

Because Caddy data lives at a known path on disk (not an opaque Docker volume), migrating or replicating Caddy to a new server is a simple file copy - and all your previously trusted devices stay trusted.

Next Steps

Now that the foundation is in place, I’m planning to:

  • Migrate additional services behind Caddy (Jellyfin, Paperless, Yamtrack)
  • Set up better monitoring and logging through Caddy’s access logs
  • Explore Caddy’s metrics and observability features
  • Document the full Caddyfile in my Git repository for easy replication across nodes

Additional Resources

This post is licensed under CC BY 4.0 by the author.