Setup recipe: Caddy as a reverse proxy with HTTPS

A ready-made first-boot script that puts Caddy in front of your app, with a free HTTPS certificate for your domain that renews by itself.

This is a setup script that installs Caddy on a new server's first boot and has it pass your domain's traffic on to an app listening on the server itself. Caddy gets a certificate for the domain from Let's Encrypt and renews it by itself. It works on Ubuntu, Debian, AlmaLinux, Rocky Linux and Fedora.

Settings

Two lines at the top:

  • DOMAIN: the name people will use, such as app.example.com. Point the domain's DNS A record (and AAAA, for IPv6) at the server's addresses, or the certificate can't be issued. Caddy keeps trying, so fixing DNS after the first boot also works. Left empty, Caddy serves plain HTTP on port 80 for any name.
  • UPSTREAM: where your app listens, 127.0.0.1:3000 by default. Keep the app on 127.0.0.1 so the only way in is through Caddy.

The script writes /etc/caddy/Caddyfile with exactly that. If a Caddyfile is already there, the first one it finds is kept as /etc/caddy/Caddyfile.before-vps9. If a host firewall is on (ufw or firewalld), ports 80 and 443 are opened in it; the server firewall in the panel is yours to change.

Deploy it

Deploy with VPSNine: this opens the order form with the script in the setup script field (you log in first). Set DOMAIN and UPSTREAM there, then order.

With the CLI:

bash
vps9 servers create --plan budget --image ubuntu-24.04 --hostname app-1 --key key_… --setup-script ./caddy-reverse-proxy.sh

The script

bash
#!/bin/bash
# VPSNine setup recipe: Caddy reverse proxy
# https://vpsnine.com/help/caddy-reverse-proxy-setup-script
# Caddy in front of an app on this server: HTTPS with a free certificate for your domain, renewed
# automatically, passed on to the app's local port. Opens ports 80 and 443 in the host firewall if one runs.
set -euo pipefail
umask 022

# ---- settings: change these before you run it ----
DOMAIN=""                 # e.g. app.example.com, its DNS pointing at this server. Empty: plain HTTP on port 80
UPSTREAM="127.0.0.1:3000"  # where your app listens

# Which family of distributions this is: Debian and Ubuntu, or AlmaLinux, Rocky Linux and Fedora.
. /etc/os-release
case " ${ID:-} ${ID_LIKE:-} " in
  *" debian "*|*" ubuntu "*) family=debian ;;
  *" rhel "*|*" fedora "*|*" centos "*) family=rhel ;;
  *) echo "This recipe supports Ubuntu, Debian, AlmaLinux, Rocky Linux and Fedora, not ${PRETTY_NAME:-this system}" >&2; exit 1 ;;
esac

case "$DOMAIN" in
  *[!a-z0-9.-]*) echo "DOMAIN must be a lower-case host name such as app.example.com" >&2; exit 1 ;;
esac
case "$UPSTREAM" in
  ""|*[!a-zA-Z0-9.:-]*) echo "UPSTREAM must be host:port, such as 127.0.0.1:3000" >&2; exit 1 ;;
esac

echo "== Installing Caddy"
if [ "$family" = debian ]; then
export DEBIAN_FRONTEND=noninteractive
apt-get -o DPkg::Lock::Timeout=300 update
apt-get -o DPkg::Lock::Timeout=300 install -y ca-certificates curl gnupg
if [ ! -f /etc/apt/sources.list.d/caddy-stable.list ]; then
  curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
  curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' -o /etc/apt/sources.list.d/caddy-stable.list
  chmod a+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list
  apt-get -o DPkg::Lock::Timeout=300 update
fi
apt-get -o DPkg::Lock::Timeout=300 install -y caddy
systemctl enable --now caddy
else
dnf -y install 'dnf-command(copr)'
if ! dnf -y copr enable @caddy/caddy; then
  echo "Caddy's COPR has no build for this release: using the distribution's (or EPEL's) caddy"
  . /etc/os-release
  if [ "$ID" != fedora ]; then dnf -y install epel-release; fi
fi
dnf -y install caddy
systemctl enable --now caddy
if systemctl is-active --quiet firewalld; then
  firewall-cmd --permanent --add-port=80/tcp
  firewall-cmd --permanent --add-port=443/tcp
  firewall-cmd --reload
fi
fi

echo "== Writing /etc/caddy/Caddyfile"
# Keep the first Caddyfile found (the package's example, or yours) once, as Caddyfile.before-vps9.
if [ -f /etc/caddy/Caddyfile ] && [ ! -f /etc/caddy/Caddyfile.before-vps9 ]; then
  cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-vps9
fi
site="${DOMAIN:-:80}"
cat > /etc/caddy/Caddyfile <<EOF
# VPSNine setup recipe: Caddy reverse proxy
$site {
	encode zstd gzip
	reverse_proxy $UPSTREAM
}
EOF
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
systemctl reload-or-restart caddy

if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
  ufw allow 80/tcp
  ufw allow 443/tcp
  ufw allow 443/udp
fi
if systemctl is-active --quiet firewalld; then
  firewall-cmd --permanent --add-service=http --add-service=https --add-port=443/udp
  firewall-cmd --reload
fi
if [ -n "$DOMAIN" ]; then echo "== Done: https://$DOMAIN"; else echo "== Done: http://<your server's address>/"; fi

Check the result

bash
tail -n 20 /var/log/vps9-setup.log
systemctl status caddy --no-pager
curl -I https://app.example.com

Until your app is running on the upstream port, Caddy answers 502 Bad Gateway: Caddy works and is waiting for the app. Caddy's own log is in journalctl -u caddy; a certificate problem shows up there, usually DNS that doesn't point at the server yet. nginx and HTTPS covers the same job with nginx. To put several apps or domains behind one Caddy, see Caddy as a reverse proxy for several apps.

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee