← Back

An Authenticated TLS Forward Proxy with Caddy and acme.sh

An Authenticated TLS Forward Proxy with Caddy and acme.sh cover image

Why Caddy

The usual proxy options (Squid, Nginx) are fiddly to configure, and TLS support usually takes extra work. Caddy supports HTTPS forward proxying (CONNECT tunnels) natively through the forwardproxy plugin, and with Basic Auth on top, the whole setup stays short and cheap to maintain.

I use acme.sh instead of Caddy's built-in ACME because some situations call for more control over how the certificate gets issued: DNS-01 validation, picking a specific CA (ZeroSSL / Let's Encrypt / Buypass), or getting a certificate on a machine whose firewall does not open 80/443.

Prerequisites

  • A server with a public IP (this post assumes Debian/Ubuntu)
  • A domain with an A record already pointing at the server's IP
  • A DNS provider with an API (needed for DNS-01 validation)

One-Shot Script

If you would rather not do this by hand, there is an automated install script:

Bash
sudo DOMAIN=proxy.example.com \
     [email protected] \
     PROXY_USER=myuser \
     PROXY_PASS=mypassword \
     CF_Token=xxx \
     CF_Zone_ID=xxx \
     bash <(curl -fsSL https://gist.githubusercontent.com/madeye/9a578ad8c9b8166f999719aa7784aa6f/raw/setup-caddy-proxy.sh)

The manual steps are spelled out below.

1. Install acme.sh and Issue a Certificate

Bash
# Install acme.sh
curl https://get.acme.sh | sh -s [email protected]

# Using Cloudflare DNS as the example, set the API token
export CF_Token="your_cloudflare_api_token"
export CF_Zone_ID="your_zone_id"

# Issue the certificate (DNS-01 validation)
~/.acme.sh/acme.sh --issue --dns dns_cf -d proxy.example.com

# Install the certificate into the target directory
mkdir -p /etc/caddy/certs
~/.acme.sh/acme.sh --install-cert -d proxy.example.com \
  --cert-file /etc/caddy/certs/cert.pem \
  --key-file /etc/caddy/certs/key.pem \
  --fullchain-file /etc/caddy/certs/fullchain.pem \
  --reloadcmd "systemctl restart caddy"

If you use a different DNS provider, acme.sh supports dozens of DNS APIs. Check its wiki and swap out dns_cf and the matching environment variables.

2. Build Caddy with forwardproxy

The official Caddy binaries do not include the forwardproxy plugin, so you need to build your own with xcaddy.

Bash
# Install Go (if you don't have it)
sudo apt install -y golang

# Install xcaddy
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

# Build Caddy with forwardproxy
~/go/bin/xcaddy build --with github.com/caddyserver/forwardproxy=github.com/klzgrad/forwardproxy@naive

# Move it into the system path
sudo mv caddy /usr/bin/caddy
sudo chmod +x /usr/bin/caddy

This uses the forwardproxy fork maintained by klzgrad, which supports naive-protocol traffic obfuscation and holds up better against probing. If you don't need that, the official github.com/caddyserver/forwardproxy works fine.

3. Configure Caddy

Create the Caddyfile:

Bash
sudo mkdir -p /etc/caddy

Edit /etc/caddy/Caddyfile:

CADDYFILE
{
  order forward_proxy before file_server
  admin off
}

:443, proxy.example.com {
  tls /etc/caddy/certs/fullchain.pem /etc/caddy/certs/key.pem

  forward_proxy {
    basic_auth user password123  # replace with your own username and password
    hide_ip
    hide_via
    probe_resistance secret.localhost  # resist active probing
  }

  file_server {
    root * /var/www/html  # pose as an ordinary website
  }
}

A few of these settings are worth explaining:

  • basic_auth: sets the proxy's username and password, blocking unauthorized use
  • hide_ip / hide_via: hides the client's real IP and the proxy signature
  • probe_resistance: when a non-proxy request comes in, serve ordinary web content instead, so active probes learn nothing
  • file_server: goes with probe_resistance, serving a plain web page as the cover

4. Set Up the systemd Service

Create /etc/systemd/system/caddy.service:

INI
[Unit]
Description=Caddy
After=network.target network-online.target
Requires=network-online.target

[Service]
Type=notify
User=root
ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile
ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile
TimeoutStopSec=5s
LimitNOFILE=1048576
LimitNPROC=512

[Install]
WantedBy=multi-user.target

Start the service:

Bash
sudo systemctl daemon-reload
sudo systemctl enable --now caddy

5. Using It from a Client

Once everything is set up, point your client at the HTTPS proxy:

TEXT
https://user:[email protected]:443

Browsers

Most browsers can pick up an HTTPS proxy from the system proxy settings. You can also use an extension like SwitchyOmega: pick HTTPS as the protocol and fill in the domain, port, username and password.

Command Line

Bash
export https_proxy=https://user:[email protected]:443
export http_proxy=https://user:[email protected]:443
curl -I https://www.google.com

NaiveProxy Client

If you built the server against klzgrad's forwardproxy fork, the NaiveProxy client is the better choice for traffic obfuscation:

JSON
{
  "listen": "socks://127.0.0.1:1080",
  "proxy": "https://user:[email protected]"
}

6. Checks and Troubleshooting

Bash
# Check that Caddy is running
sudo systemctl status caddy

# Follow the logs
sudo journalctl -u caddy -f

# Test the certificate
openssl s_client -connect proxy.example.com:443 -servername proxy.example.com

# Test the proxy
curl -x https://user:[email protected]:443 https://httpbin.org/ip

Automatic Renewal

acme.sh installs a cron job for you. Before the certificate expires it renews automatically and restarts Caddy through --reloadcmd to pick up the new one, with nothing left to do by hand.

You can check that the cron job is there:

Bash
crontab -l | grep acme

Wrap-Up

What this setup gets you:

  1. Simple config: a dozen or so lines of Caddyfile, far less hassle than an Nginx + Squid setup
  2. TLS encryption: all proxy traffic goes over HTTPS, so nobody in the middle can look inside
  3. Authentication: Basic Auth keeps the proxy from being abused
  4. Probe resistance: probe_resistance plus a cover site means unauthorized visitors only see an ordinary web page
  5. Auto-renewal: acme.sh's cron job keeps the certificate valid