# 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.

Source: https://vpsnine.com/help/caddy-reverse-proxy-setup-script · Updated: 2026-10-21

This is a [setup script](https://vpsnine.com/help/setup-scripts) that installs [Caddy](https://caddyserver.com) 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](https://vpsnine.com/help/server-firewall) is yours to change.

## Deploy it

[Deploy with VPSNine](https://my.vpsnine.com/servers/new?recipe=caddy-reverse-proxy): 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](https://vpsnine.com/help/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](https://vpsnine.com/tutorials/caddy-reverse-proxy-multiple-apps).
