Lab

Custom Caddy with Namecheap Wildcard SSL

Project Log
Caddy
xcaddy
Namecheap
SSL
DNS
DevOps
bFree

Building a custom Caddy binary with the Namecheap DNS plugin so wildcard HTTPS can work for bFree subdomains.

Published
June 20, 2026
Updated
June 20, 2026

Project Log

Incremental progress, implementation notes, and status-oriented updates.

For bFree, I need random public subdomains like:

test123.bfree.bangie.digital
demo-client.bfree.bangie.digital
some-random-id.bfree.bangie.digital

The idea is:

*.bfree.bangie.digital -> Caddy -> bFree server

So every tunnel can get its own public HTTPS URL without me manually creating DNS records every time.

Simple idea.

Naturally, DNS and SSL immediately show up wearing steel boots.

Why I needed this

For normal domains, Caddy can usually get HTTPS certificates with the default HTTP challenge.

For wildcard domains, like:

*.bfree.bangie.digital

that is not enough.

Wildcard SSL needs DNS validation. That means Caddy has to create a temporary DNS TXT record to prove that it controls the domain.

Since my DNS is on Namecheap, Caddy needs to talk to the Namecheap API.

The default Caddy binary does not include the Namecheap DNS module, so I had to build a custom Caddy binary with xcaddy.

What this setup does

PartPurpose
xcaddyBuilds Caddy with extra modules
caddy-dns/namecheapLets Caddy manage Namecheap DNS records
Namecheap APIAllows DNS-01 certificate validation
Wildcard DNSRoutes all *.bfree subdomains to the server
CaddyTerminates HTTPS and proxies requests to the bFree backend

The final goal is:

https://anything.bfree.bangie.digital

going through Caddy and ending up on the local bFree server.

Requirements

On a fresh Debian / Ubuntu-style VM:

sudo apt update
sudo apt install -y golang-go git curl

Check Go:

go version

I used:

go version go1.24.4 linux/amd64

Install xcaddy

go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
export PATH="$PATH:$(go env GOPATH)/bin"

Check it:

xcaddy version

Build Caddy with Namecheap support

Create a build directory:

mkdir -p ~/build/caddy-namecheap
cd ~/build/caddy-namecheap

Build Caddy:

xcaddy build --with github.com/caddy-dns/namecheap

Check that the Namecheap DNS module exists:

./caddy list-modules | grep namecheap

Expected:

dns.providers.namecheap

If that appears, the binary has the plugin.

Good. The tiny dragon has the correct tooth.

Install the custom Caddy binary

On my fresh VM, Caddy was not installed yet, so I simply installed the custom binary:

sudo install -m 0755 ./caddy /usr/bin/caddy

Verify:

caddy version
caddy list-modules | grep namecheap

Expected:

dns.providers.namecheap

Create Caddy user and directories

Caddy should run as its own user, not as a random root process started by hand.

sudo groupadd --system caddy

sudo useradd --system \
  --gid caddy \
  --create-home \
  --home-dir /var/lib/caddy \
  --shell /usr/sbin/nologin \
  --comment "Caddy web server" \
  caddy

Create directories:

sudo mkdir -p /etc/caddy
sudo mkdir -p /var/lib/caddy
sudo mkdir -p /var/log/caddy

sudo chown -R caddy:caddy /var/lib/caddy /var/log/caddy
sudo chmod 755 /etc/caddy

Basic test Caddyfile

Before touching SSL, I tested plain HTTP first.

sudo nano /etc/caddy/Caddyfile
:80 {
	respond "bFree Caddy is alive" 200
}

Validate:

sudo caddy validate --config /etc/caddy/Caddyfile

Run manually for a quick test:

sudo caddy run --config /etc/caddy/Caddyfile

In another terminal:

curl http://localhost

Expected:

bFree Caddy is alive

After the test, stop it with CTRL+C.

Run Caddy with systemd

sudo nano /etc/systemd/system/caddy.service
[Unit]
Description=Caddy
Documentation=https://caddyserver.com/docs/
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
User=caddy
Group=caddy
ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile
ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile --force
TimeoutStopSec=5s
LimitNOFILE=1048576
PrivateTmp=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
Restart=on-failure

[Install]
WantedBy=multi-user.target

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable caddy
sudo systemctl start caddy
sudo systemctl status caddy --no-pager

Confirm it runs as the caddy user:

ps -eo user,pid,cmd | grep '[c]addy'

Namecheap DNS records

In Namecheap, I added:

TypeHostValue
A Recordbfreeserver public IPv4
A Record*.bfreeserver public IPv4

The first record handles:

bfree.bangie.digital

The wildcard handles:

anything.bfree.bangie.digital

Wildcard DNS does not replace the base subdomain, so I created both.

Namecheap API access

In Namecheap, I enabled API access and whitelisted the public IPv4 of the VM.

On the VM, I checked the outgoing public IP with:

curl -4 https://api.ipify.org

That IP needs to be whitelisted in Namecheap.

This IP is also used as client_ip in the Caddy Namecheap DNS config.

Store Namecheap credentials

I stored the credentials in an environment file:

sudo nano /etc/caddy/.env
NAMECHEAP_API_KEY=your_namecheap_api_key
NAMECHEAP_API_USER=your_namecheap_username
NAMECHEAP_CLIENT_IP=your_server_public_ipv4

Secure it:

sudo chmod 600 /etc/caddy/.env
sudo chown root:root /etc/caddy/.env

Attach the env file to the Caddy service:

sudo systemctl edit caddy
[Service]
EnvironmentFile=/etc/caddy/.env

Reload systemd:

sudo systemctl daemon-reload

Wildcard HTTPS Caddyfile

Now replace the test Caddyfile:

sudo nano /etc/caddy/Caddyfile
bfree.bangie.digital {
	tls {
		dns namecheap {
			api_key {env.NAMECHEAP_API_KEY}
			user {env.NAMECHEAP_API_USER}
			api_endpoint https://api.namecheap.com/xml.response
			client_ip {env.NAMECHEAP_CLIENT_IP}
		}
	}

	respond "bFree root is alive" 200
}

*.bfree.bangie.digital {
	tls {
		dns namecheap {
			api_key {env.NAMECHEAP_API_KEY}
			user {env.NAMECHEAP_API_USER}
			api_endpoint https://api.namecheap.com/xml.response
			client_ip {env.NAMECHEAP_CLIENT_IP}
		}
	}

	reverse_proxy 127.0.0.1:7070
}

Format and validate:

sudo caddy fmt --overwrite /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile

Restart Caddy and watch logs:

sudo systemctl restart caddy
sudo journalctl -u caddy -f

Success looks like this:

certificate obtained successfully    identifier="*.bfree.bangie.digital"
certificate obtained successfully    identifier="bfree.bangie.digital"

That means Caddy used Namecheap DNS validation and got certificates successfully.

Test backend

Before writing the real bFree server, I used a tiny Python backend on port 7070.

sudo mkdir -p /opt/bfree-test
cd /opt/bfree-test
sudo nano server.py
from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def send_ok_headers(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()

    def do_HEAD(self):
        self.send_ok_headers()

    def do_GET(self):
        body = f"bFree dummy backend is alive\nHost: {self.headers.get('Host')}\nPath: {self.path}\n"
        encoded = body.encode()

        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", str(len(encoded)))
        self.end_headers()
        self.wfile.write(encoded)

HTTPServer(("127.0.0.1", 7070), Handler).serve_forever()

Run it:

python3 server.py

Then test a random wildcard subdomain:

curl https://test123.bfree.bangie.digital

Expected:

bFree dummy backend is alive
Host: test123.bfree.bangie.digital
Path: /

I also tested another random subdomain:

curl https://test1234.bfree.bangie.digital

Same result.

That proves the important chain works:

random bFree subdomain
  -> wildcard DNS
  -> Caddy HTTPS
  -> wildcard certificate
  -> reverse proxy
  -> local backend on 127.0.0.1:7070

Debug notes

If Caddy says:

module not registered: dns.providers.namecheap

then the running Caddy binary does not include the Namecheap plugin.

Check:

caddy list-modules | grep namecheap

If Namecheap says:

Parameter APIUser is missing

check the Caddyfile syntax:

user {env.NAMECHEAP_API_USER}

Also check that the service actually received the env values:

pid=$(pidof caddy)

sudo tr '\0' '\n' < /proc/$pid/environ \
  | grep NAMECHEAP \
  | sed 's/=.*/=<set>/'

Expected:

NAMECHEAP_API_KEY=<set>
NAMECHEAP_API_USER=<set>
NAMECHEAP_CLIENT_IP=<set>

If Namecheap complains about IP access, check the VM public IP:

curl -4 https://api.ipify.org

Then make sure that exact IP is whitelisted in Namecheap API settings.

Notes

  • client_ip is the public IPv4 of the VM running Caddy.
  • It is not 127.0.0.1.
  • It is not a private LAN IP.
  • It must match the IP whitelisted in Namecheap.
  • curl -I sends a HEAD request. If your dummy backend only implements GET, Python will return 501.
  • If the response contains via: 1.0 Caddy, your request is passing through Caddy.
  • For real usage, keep port 443 open.
  • Port 80 is still useful for redirects and normal non-wildcard HTTP challenge cases.

What this gives bFree

This does not build the tunnel yet.

It builds the runway.

Now any temporary bFree hostname can terminate HTTPS at Caddy:

abc123.bfree.bangie.digital
quiet-wolf.bfree.bangie.digital
demo-client.bfree.bangie.digital

The next step is the real bFree server.

That server will decide:

Which subdomain belongs to which client?
Is the tunnel alive?
Where should this request be forwarded?
Should this free tunnel expire?

For now, the important part is done:

*.bfree.bangie.digital works over HTTPS.