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 asapp.example.com. Point the domain's DNSArecord (andAAAA, 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:3000by default. Keep the app on127.0.0.1so 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:
vps9 servers create --plan budget --image ubuntu-24.04 --hostname app-1 --key key_… --setup-script ./caddy-reverse-proxy.shThe script
#!/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>/"; fiCheck the result
tail -n 20 /var/log/vps9-setup.log
systemctl status caddy --no-pager
curl -I https://app.example.comUntil 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.