Caddy and Local DNS for Network-Wide Dev Access
Your development servers run on localhost:4000, localhost:5173, and localhost:8080. Testing on your phone means typing 192.168.1.51:5173 from memory. Your tablet needs a different IP. A colleague on your network asks “what’s the URL again?” and you have to look up your workstation’s IP and remember which port is which service.
Caddy and local DNS solve this: configure .lan domains that work network-wide. Navigate to blog.lan from any device on your network—phone, tablet, another laptop—and reach your Jekyll blog without remembering IPs or ports. Caddy reverse-proxies to the correct localhost port, local DNS resolves .lan to your workstation, and HTTPS works automatically.
This guide covers the complete setup: Caddy configuration for reverse proxy and automatic HTTPS, local DNS with pfSense/Unbound for network-wide .lan resolution, certificate management for trusted HTTPS, and integration with the dev services dashboard from the previous post. You’ll access all local services via clean domain names from any device.
Problem Statement
Development servers running on a workstation:
1
2
3
4
localhost:4000 - Jekyll blog
localhost:5173 - Vite frontend
localhost:8080 - API docs
localhost:9000 - Dashboard
This configuration presents limitations:
- Inaccessible from laptops, phones, or tablets
- Port numbers must be memorized
- No HTTPS (required by certain APIs)
Proposed Solution
- Caddy as reverse proxy - routes
blog.lantolocalhost:4000 - Local DNS - resolves
*.lanto the workstation IP - Firewall rules - permits traffic from LAN
Part 1: Caddy Reverse Proxy
Installation
1
2
3
4
5
6
7
8
# Arch Linux
sudo pacman -S caddy
# Ubuntu/Debian
sudo apt install caddy
# macOS
brew install caddy
Basic Configuration
Caddy configuration resides at /etc/caddy/Caddyfile. For modularity, use includes:
1
2
3
4
5
6
# /etc/caddy/Caddyfile
{
admin "unix//run/caddy/admin.socket"
}
import /etc/caddy/conf.d/*
Create the conf.d directory:
1
sudo mkdir -p /etc/caddy/conf.d
Local Development Configuration
Create /etc/caddy/conf.d/local-dev.caddyfile:
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
30
31
32
# .localhost domains (local machine only)
# These receive automatic HTTPS with self-signed certs
dashboard.localhost {
reverse_proxy localhost:9000
}
blog.localhost {
reverse_proxy localhost:4000
}
app.localhost {
reverse_proxy localhost:5173
}
# .lan domains (network-wide access)
# Use internal TLS since Let's Encrypt does not issue certs for .lan
dashboard.lan {
tls internal
reverse_proxy localhost:9000
}
blog.lan {
tls internal
reverse_proxy localhost:4000
}
app.lan {
tls internal
reverse_proxy localhost:5173
}
Domain Types
.localhost domains:
- Resolved by browsers automatically (RFC 6761)
- Always point to 127.0.0.1
- Function only on the local machine
- Caddy generates self-signed certificates automatically
.lan domains:
- Require DNS configuration (covered below)
- Function from any device on the network
tls internalinstructs Caddy to use self-signed certificates (Let’s Encrypt does not issue certificates for private TLDs)
Starting Caddy
1
2
3
4
5
6
7
8
# Validate config
sudo caddy validate --config /etc/caddy/Caddyfile
# Enable and start
sudo systemctl enable --now caddy
# Check status
sudo systemctl status caddy
Testing Local Access
1
2
# Should work immediately
curl -sk https://blog.localhost/
Part 2: Local DNS with pfSense/Unbound
For .lan domains to function from other devices, DNS resolution is required. pfSense (or OPNsense) includes Unbound DNS.
Creating a Custom Configuration File
SSH into the pfSense system:
1
ssh root@192.168.1.1
Create a configuration file that will not be overwritten by the GUI:
1
2
3
4
5
6
7
8
9
10
11
12
cat > /var/unbound/local_services.conf << 'EOF'
# Local development services
# This file is NOT managed by pfSense GUI
server:
local-zone: "lan." static
local-data: "dashboard.lan. A 192.168.1.51"
local-data: "blog.lan. A 192.168.1.51"
local-data: "app.lan. A 192.168.1.51"
local-data: "api.lan. A 192.168.1.51"
local-data: "docs.lan. A 192.168.1.51"
EOF
Replace 192.168.1.51 with the workstation IP.
Including the Configuration
Note: pfSense regenerates unbound.conf from its XML configuration. To persist custom includes, add them via the GUI’s “Custom options” field, which is stored base64-encoded.
Option A: Via pfSense GUI
- Navigate to Services -> DNS Resolver
- Scroll to “Custom options”
- Add:
include: /var/unbound/local_services.conf - Save and Apply
Option B: Via config.xml
1
2
3
4
5
6
7
8
9
# Get current custom_options
grep 'custom_options' /cf/conf/config.xml
# Create new value with include added
NEW_OPTS=$(echo -n 'server:include: /var/unbound/pfb_dnsbl.*conf
include: /var/unbound/local_services.conf' | base64 -w0)
# Update config.xml
sed -i '' "s|<custom_options>.*</custom_options>|<custom_options>${NEW_OPTS}</custom_options>|" /cf/conf/config.xml
Restarting Unbound
1
2
# On pfSense
pfSsh.php playback svc restart unbound
Testing DNS
From any device on the network:
1
2
dig +short dashboard.lan @192.168.1.1
# Should return: 192.168.1.51
Part 3: Firewall Configuration
The workstation firewall likely blocks incoming connections. Allow HTTP/HTTPS from the LAN:
UFW (Ubuntu/Debian)
1
2
sudo ufw allow from 192.168.1.0/24 to any port 80 proto tcp comment "Caddy HTTP"
sudo ufw allow from 192.168.1.0/24 to any port 443 proto tcp comment "Caddy HTTPS"
firewalld (Fedora/RHEL)
1
2
3
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port port="80" protocol="tcp" accept'
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port port="443" protocol="tcp" accept'
sudo firewall-cmd --reload
iptables
1
2
sudo iptables -A INPUT -p tcp -s 192.168.1.0/24 --dport 80 -j ACCEPT
sudo iptables -A INPUT -p tcp -s 192.168.1.0/24 --dport 443 -j ACCEPT
Part 4: Testing from Another Device
From a laptop or phone (connected to the same network):
1
2
3
4
5
# Test DNS
nslookup dashboard.lan
# Test HTTPS (will warn about self-signed cert)
curl -sk https://dashboard.lan/
In a browser, navigate to https://dashboard.lan. A certificate warning will appear - this is expected for self-signed certificates. Accept to proceed.
Adding New Services
When adding a new development server:
- Add DNS entry (on pfSense):
1 2 3
ssh root@192.168.1.1 echo 'local-data: "newapp.lan. A 192.168.1.51"' >> /var/unbound/local_services.conf pfSsh.php playback svc restart unbound
- Add Caddy config:
1 2 3 4 5 6 7 8
newapp.localhost { reverse_proxy localhost:3000 } newapp.lan { tls internal reverse_proxy localhost:3000 } - Reload Caddy:
1
sudo systemctl reload caddy
TLD Selection Rationale
.localis reserved for mDNS (Bonjour/Avahi) - causes conflicts.localhostis reserved for loopback - does not resolve to other IPs.lanis commonly used for private networks and functions reliably.home.arpais the IETF-recommended TLD for home networks (RFC 8375)
Troubleshooting
Caddy Startup Failure
1
2
3
4
5
# Check config syntax
sudo caddy validate --config /etc/caddy/Caddyfile
# Check logs
journalctl -u caddy -f
DNS Resolution Failure
1
2
3
4
5
6
7
8
# Test direct query to DNS server
dig dashboard.lan @192.168.1.1
# Check if Unbound loaded the config
ssh root@192.168.1.1 "unbound-checkconf /var/unbound/unbound.conf"
# Check if include is present
ssh root@192.168.1.1 "grep 'local_services' /var/unbound/unbound.conf"
Connection Refused from LAN
1
2
3
4
5
6
7
# Check if Caddy is listening on all interfaces
ss -tlnp | grep caddy
# Should show *:80 and *:443, not 127.0.0.1:80
# Check firewall
sudo ufw status
sudo iptables -L INPUT -n | grep -E "80|443"
Certificate Warnings
Caddy uses self-signed certificates for .lan domains since Let’s Encrypt does not issue certificates for private TLDs. To eliminate recurring warnings, trust Caddy’s root CA.
Part 5: Trusting Caddy’s Root CA
Caddy generates a local Certificate Authority. Trusting it once enables automatic trust for all .lan certificates.
Locating the Root CA
1
2
3
4
5
6
# Caddy stores its CA here
sudo ls /var/lib/caddy/pki/authorities/local/
# root.crt root.key intermediate.crt intermediate.key
# Check validity (should be ~10 years)
sudo openssl x509 -in /var/lib/caddy/pki/authorities/local/root.crt -noout -dates
Copying for Import
1
2
sudo cp /var/lib/caddy/pki/authorities/local/root.crt ~/caddy-root-ca.crt
sudo chown $USER:$USER ~/caddy-root-ca.crt
Linux System-Wide Trust
1
2
3
4
5
6
7
8
9
10
# Arch Linux
sudo cp ~/caddy-root-ca.crt /etc/ca-certificates/trust-source/anchors/
sudo update-ca-trust
# Ubuntu/Debian
sudo cp ~/caddy-root-ca.crt /usr/local/share/ca-certificates/caddy-root-ca.crt
sudo update-ca-certificates
# Verify
curl https://dashboard.lan/ # No -k needed
Firefox
Firefox uses its own certificate store:
- Settings -> Privacy & Security -> Certificates -> View Certificates
- Authorities tab -> Import
- Select
~/caddy-root-ca.crt - Check “Trust this CA to identify websites”
- OK
Chrome/Chromium
Chrome uses the system store on Linux, but manual addition is also possible:
- Settings -> Privacy and security -> Security -> Manage certificates
- Authorities tab -> Import
- Select
~/caddy-root-ca.crt - Check “Trust this certificate for identifying websites”
Other Network Devices
Copy the root CA to each device and import:
1
2
3
4
5
6
# Copy to laptop
scp ~/caddy-root-ca.crt user@laptop:~/
# Copy to phone (via web server)
python3 -m http.server 8888
# Then download http://192.168.1.51:8888/caddy-root-ca.crt on phone
iOS: Download the certificate, navigate to Settings -> Profile Downloaded -> Install, then Settings -> General -> About -> Certificate Trust Settings -> Enable.
Android: Download the certificate, navigate to Settings -> Security -> Install certificate -> CA certificate.
Windows: Double-click the .crt file -> Install Certificate -> Local Machine -> Place in “Trusted Root Certification Authorities”.
macOS: Double-click the .crt file -> Add to Keychain -> Trust -> “Always Trust”.
Trust Verification
After importing, restart the browser and visit https://dashboard.lan. A green padlock should appear with no warnings.
Summary
The configuration provides:
- Caddy routing domain names to development servers
- Local DNS resolving
.landomains network-wide - Firewall rules permitting LAN access
Development servers become accessible from any device:
https://dashboard.lan- Services dashboardhttps://blog.lan- Jekyll bloghttps://app.lan- Frontend dev server
Port numbers and IP addresses are no longer required.