A Caddy reverse proxy on Ubuntu 24.04 can put a public HTTPS domain in front of an application that listens only on localhost. The production pattern is simple: keep your application on a private listener such as 127.0.0.1:8080, let Caddy accept traffic on ports 80 and 443, and point a Caddyfile reverse_proxy directive at the local upstream.
Caddy handles several details that older reverse-proxy tutorials often configure manually. For qualifying public hostnames, automatic HTTPS obtains and renews certificates and redirects HTTP to HTTPS. The reverse_proxy handler also manages the standard X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host headers by default, and WebSocket upgrades work without adding Nginx-style Connection or Upgrade header rules.
This tutorial installs Caddy from its official Debian/Ubuntu repository, creates a localhost-only demo backend, validates DNS and firewall state without risking SSH lockout, configures the reverse proxy, verifies HTTPS and WebSockets behavior, enables structured access logging, tests a second upstream pattern, and includes a safe rollback path. Raff Technologies is the VM platform used by the original tutorial. The original setup was tested on Ubuntu 24.04 LTS on a Raff 1 vCPU / 2 GB RAM VM; Caddy's current installation, reverse-proxy, automatic HTTPS, and systemd guidance were re-verified on September 4, 2026.
Prerequisites:
- A Raff Linux VM running Ubuntu 24.04
- SSH access with a non-root sudo user
- A domain or subdomain whose DNS can point to the VM
- Ports 80 and 443 available for Caddy
- A backend application that can listen on localhost or a private network address
The examples use app.example.com and a demo backend on 127.0.0.1:8080. Replace them with your real hostname and application port.
Step 1 — Check DNS, existing listeners, and SSH access before changing the firewall
Confirm Ubuntu:
cat /etc/os-release
Check whether another web server already owns ports 80 or 443:
sudo ss -lntp | grep -E ':(80|443)\b' || true
Check the domain's IPv4 and IPv6 DNS records:
dig +short A app.example.com dig +short AAAA app.example.com
If dig is unavailable:
sudo apt update sudo apt install -y dnsutils
An A record should point to the VM's public IPv4 address. If an AAAA record exists, it must also point to working IPv6 service on this server; stale IPv6 DNS can break validation or client access even when IPv4 is correct.
If Nginx, Apache, or another reverse proxy is already serving production traffic on ports 80/443, do not stop it blindly. Plan the migration or move Caddy to a different server before continuing.
Also keep your current SSH session open while making network changes.
Verify: Ubuntu should report 24.04, the intended hostname should resolve to this VM, and you should know which process currently owns ports 80 and 443 before installing Caddy.
Step 2 — Install Caddy from the official Debian/Ubuntu repository
Install the repository prerequisites:
sudo apt update sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
Add Caddy's official stable signing key:
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | \ sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
Add the official stable repository:
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | \ sudo tee /etc/apt/sources.list.d/caddy-stable.list
Make the APT files readable and install Caddy:
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list sudo apt update sudo apt install -y caddy
Caddy's official package automatically installs and starts caddy.service for Caddyfile-based configurations.
Official installation instructions: Caddy — Install.
Verify:
caddy version systemctl is-active caddy systemctl is-enabled caddy
The version command should return a Caddy v2 release, and the service should be active and enabled unless another process is preventing it from binding to the required ports.
Step 3 — Create a localhost-only demo backend
Install Python for a temporary test backend:
sudo apt install -y python3
Create a small document root:
sudo install -d -o root -g root -m 755 /srv/caddy-proxy-demo
Create a test page:
cat <<'EOF' | sudo tee /srv/caddy-proxy-demo/index.html > /dev/null <!doctype html> <html lang="en"> <head><meta charset="utf-8"><title>Caddy Reverse Proxy Test</title></head> <body><h1>Caddy reverse proxy is working</h1></body> </html> EOF
Start the demo server in the foreground from a second SSH session or terminal:
python3 -m http.server 8080 \ --bind 127.0.0.1 \ --directory /srv/caddy-proxy-demo
Python's built-in HTTP server is only a disposable test upstream. Do not use it as the production application server.
Verify: From the VM, run curl -fsS http://127.0.0.1:8080 | grep 'Caddy reverse proxy is working'. The command should return the heading.
Step 4 — Confirm the backend is not exposed publicly
Check the listener:
sudo ss -lntp | grep ':8080'
The local address should be:
127.0.0.1:8080
not:
0.0.0.0:8080
Do not add a UFW or cloud firewall rule for port 8080 when Caddy and the application run on the same VM. The entire point of this topology is that the public client reaches Caddy, while Caddy reaches the application through loopback.
A real Node.js, FastAPI, Django, Go, or other application should follow the same principle unless it intentionally lives on another private host.
Verify: Port 8080 should listen only on 127.0.0.1, and there should be no public firewall rule exposing TCP 8080.
Step 5 — Back up the Caddyfile and configure the reverse proxy
Back up the packaged configuration:
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak
Replace the active configuration with the reverse-proxy site:
sudo tee /etc/caddy/Caddyfile > /dev/null <<'EOF' app.example.com { reverse_proxy 127.0.0.1:8080 } EOF
That is enough for the basic proxy. Caddy's current reverse_proxy documentation says incoming headers are passed through by default, while Caddy sets or augments X-Forwarded-For and sets X-Forwarded-Proto and X-Forwarded-Host itself.
Do not add boilerplate such as:
header_up X-Forwarded-For ... header_up X-Forwarded-Proto ... header_up Host ...
unless your application has a specific, documented requirement that differs from Caddy's defaults.
Verify: sudo cat /etc/caddy/Caddyfile should show your real hostname and the intended localhost upstream.
Step 6 — Format and validate the Caddyfile before reloading
Format the configuration:
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
Validate it:
sudo caddy validate --config /etc/caddy/Caddyfile
caddy validate does more than parse syntax; Caddy documents that it loads and provisions modules as if starting the configuration, without actually starting it. That makes it a useful check before changing the running service.
Do not reload a configuration that fails validation.
Verify: caddy validate should complete successfully without a configuration or provisioning error.
Step 7 — Allow HTTP and HTTPS without enabling UFW blindly
Check UFW first:
sudo ufw status verbose
If UFW is already active and your SSH access is already safely allowed, add web traffic:
sudo ufw allow 80/tcp sudo ufw allow 443/tcp
If UFW is inactive, do not run ufw enable merely because this tutorial uses Caddy. Enabling a firewall on a remote server without first validating your SSH rule can lock you out. Configure SSH deliberately first using Set Up UFW Firewall on Ubuntu 24.04, then enable it if host-level firewalling is part of your plan.
If a Raff-side security group or another cloud firewall protects the VM, allow public TCP 80 and 443 there as well.
For normal public automatic HTTPS, Caddy's documentation expects ports 80 and 443 to be externally reachable. Port 80 is also used for automatic HTTP-to-HTTPS redirects and can be used for ACME HTTP challenge validation; port 443 serves HTTPS and can be used for TLS-ALPN validation.
Verify: If UFW is active, sudo ufw status numbered should preserve your working SSH access and allow TCP 80/443. The external firewall should also permit the intended public web traffic.
Step 8 — Reload Caddy and verify automatic HTTPS
Apply the validated configuration with a graceful reload:
sudo systemctl reload caddy
Caddy recommends reloading rather than stopping the service for Caddyfile configuration changes.
Check recent logs:
sudo journalctl -u caddy -n 100 --no-pager
Test HTTPS:
curl -I https://app.example.com
Test the HTTP redirect:
curl -I http://app.example.com
For a qualifying public hostname with correct DNS and external reachability, Caddy automatically manages a publicly trusted certificate and normally redirects HTTP to HTTPS.
Verify: HTTPS should reach the demo backend successfully, HTTP should redirect to HTTPS, and the Caddy journal should not show repeating certificate, DNS, bind, or upstream errors.
Step 9 — Verify the full client → Caddy → backend path
Check the backend directly from the VM:
curl -fsS http://127.0.0.1:8080 | \ grep 'Caddy reverse proxy is working'
Then check the same content through public HTTPS:
curl -fsS https://app.example.com | \ grep 'Caddy reverse proxy is working'
Inspect the listeners:
sudo ss -lntp | grep -E ':(80|443|8080)\b'
The expected topology is:
Internet | | TCP 80 / 443 v Caddy | | 127.0.0.1:8080 v Application
