If you have ever set up a web server, you know the routine: install the server, write a config file, point your domain at the box, then go fight with TLS certificates. You request a certificate, wire it into the config, set up a renewal timer, and hope nothing breaks in 90 days. It works, but it is a lot of moving parts for what should be a solved problem.
Caddy takes a different approach. It is a modern web server written in Go that obtains and renews TLS certificates for you automatically, with no extra tooling and almost no configuration. Point a domain at your server, tell Caddy the domain name, and you get a valid HTTPS site within seconds. No cron jobs, no certificate paths, no manual renewals.
In this tutorial you will install Caddy on Ubuntu, serve a static site over HTTPS, and then use Caddy as a reverse proxy in front of a backend application. This is aimed at developers, sysadmins, and DevOps engineers who want a fast path to a secure web server without the usual TLS busywork. If you have previously set up TLS the manual way with Certbot and Nginx, this will feel refreshingly short.
Conceptual Overview
Before we touch the terminal, a few concepts make everything that follows easier to understand.
What a web server and reverse proxy actually do
A web server listens for HTTP requests and responds, often by sending back files (HTML, CSS, images) from a folder on disk. That is the classic “serve a static site” job.
A reverse proxy sits in front of one or more backend applications. Instead of serving files, it forwards incoming requests to an app running somewhere else (for example a Node.js or Python process on localhost:3000) and passes the app’s response back to the visitor. The visitor never talks to your app directly. This is the standard pattern for putting any application behind a real domain with TLS, and it is the same role Nginx or Traefik usually plays.
Caddy does both jobs well, and switching between them is a one-line change.
Automatic HTTPS
This is Caddy’s headline feature. When you give Caddy a domain name, it automatically contacts a certificate authority (Let’s Encrypt by default, with ZeroSSL as a fallback), proves you control the domain through the ACME protocol, downloads a certificate, and configures TLS for you. It also redirects HTTP to HTTPS and renews certificates well before they expire.
For this to work, two things must be true: the domain’s DNS must point to your server’s public IP, and ports 80 and 443 must be reachable from the internet. Port 80 is used for the certificate challenge, and port 443 serves the encrypted traffic.
The Caddyfile
Caddy is configured through a single, readable file called the Caddyfile. Its syntax is intentionally minimal. A working config can be as short as two lines, which is a big part of why people reach for Caddy in the first place.
Prerequisites
To follow along you will need:
- An Ubuntu 22.04 or 24.04 server with
sudoaccess - A domain name you control, with an A record pointing to your server’s public IP (I will use
example.comandapp.example.comthroughout, so substitute your own) - Ports 80 and 443 open to the internet
- Basic comfort with the Linux command line and editing text files
A domain is genuinely required here. Automatic HTTPS from a public certificate authority will not issue a certificate for a bare IP address or a made-up hostname. If you only want to experiment locally, Caddy can issue its own self-signed certificates for localhost, which I mention near the end.
Step 1: Install Caddy from the Official Repository
Ubuntu’s default repositories sometimes carry an older Caddy, so we will use the official Caddy APT repository to get a current, supported build. Run the following commands one block at a time.
First, install the helper packages needed to add a third-party repository:
sudo apt update
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
Next, add Caddy’s signing key and repository:
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| sudo tee /etc/apt/sources.list.d/caddy-stable.list
Now install Caddy itself:
sudo apt update
sudo apt install -y caddy
The package installs the caddy binary, registers a systemd service, and creates a caddy system user to run the process safely without root privileges. Confirm the install:
caddy version
You should see output similar to this:
v2.8.4 h1:q3pe0wpBj1OcHFZ3n/1nl4V4bxBrYoSoab7rL9BMYNk=
The service is already running. Check it:
systemctl status caddy
You should see active (running). By default Caddy serves a welcome page, so if you open your server’s IP in a browser on port 80 you will see a Caddy placeholder. We will replace that next.
Step 2: Open the Firewall
If you run a firewall (and you should), allow HTTP and HTTPS traffic. On Ubuntu with UFW:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload
If you have not set up a firewall yet, my guide to UFW on Ubuntu walks through it. Port 80 must stay open even though your final site is HTTPS only, because Caddy uses it for the certificate challenge and for redirecting visitors to HTTPS.
Step 3: Serve a Static Site with Automatic HTTPS
Let’s start with the simplest useful case: serving a folder of files over HTTPS.
Create a directory for your site and a basic page:
sudo mkdir -p /var/www/example.com
echo '<h1>Hello from Caddy</h1>' | sudo tee /var/www/example.com/index.html
Now open the Caddyfile, which lives at /etc/caddy/Caddyfile:
sudo nano /etc/caddy/Caddyfile
Replace its entire contents with this:
example.com {
root * /var/www/example.com
file_server
encode gzip
}
Here is what each line does, and why:
example.com {opens a site block for your domain. Because this is a real domain name and notlocalhost, Caddy will automatically request a TLS certificate for it.root * /var/www/example.comsets the folder Caddy serves files from. The*means “for all request paths”.file_serverenables the static file server, so Caddy actually sends files from that folder.encode gzipcompresses responses on the fly, which speeds up text assets like HTML, CSS, and JavaScript.
Save the file (in nano, press Ctrl+O, Enter, then Ctrl+X).
Before reloading, validate the syntax so you do not restart into a broken config:
caddy validate --config /etc/caddy/Caddyfile
If it reports Valid configuration, apply it:
sudo systemctl reload caddy
The reload command tells Caddy to pick up the new config without dropping active connections. Within a few seconds Caddy will reach out to Let’s Encrypt, complete the challenge, and install a certificate. Open https://example.com in your browser and you should see your page served over HTTPS with a valid padlock. You did not run a single certificate command.
To watch the certificate process as it happens, tail the logs:
sudo journalctl -u caddy --no-pager -f
You will see lines mentioning obtaining certificate and certificate obtained successfully. Press Ctrl+C to stop following.
Step 4: Use Caddy as a Reverse Proxy
Serving files is nice, but the more common production task is putting Caddy in front of an application. Let’s say you have an app listening on localhost:3000. If you do not have one handy, here is a tiny test server using Python’s built-in HTTP server:
mkdir -p ~/testapp && cd ~/testapp
echo 'It works behind Caddy' > index.html
python3 -m http.server 3000
Leave that running in one terminal. It now answers on http://localhost:3000. In a real deployment this would be your Node.js, Go, or Python application instead.
Open the Caddyfile again and add a second site block for a subdomain:
sudo nano /etc/caddy/Caddyfile
Make the file look like this:
example.com {
root * /var/www/example.com
file_server
encode gzip
}
app.example.com {
reverse_proxy localhost:3000
}
The new block is the whole point. reverse_proxy localhost:3000 tells Caddy to forward every request for app.example.com to the app on port 3000 and return its response to the visitor. Caddy automatically sets the right proxy headers (such as X-Forwarded-For and X-Forwarded-Proto), so your app knows the original client IP and that the connection was HTTPS.
Make sure app.example.com also has a DNS A record pointing to your server, then validate and reload:
caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
Visit https://app.example.com. You will see your app’s response, now served over HTTPS on a clean domain, with a second certificate that Caddy obtained on its own. Notice that you defined two completely separate sites with their own certificates in one short file.
Adding common reverse proxy touches
Real apps usually need a couple of extra directives. Here is a slightly more complete proxy block:
app.example.com {
encode gzip
reverse_proxy localhost:3000 {
header_up Host {host}
}
log {
output file /var/log/caddy/app.log
}
}
header_up Host {host}passes the originalHostheader to the backend, which some frameworks need to build correct URLs.- The
logblock writes access logs to a dedicated file. Create the directory first withsudo mkdir -p /var/log/caddy && sudo chown caddy:caddy /var/log/caddyso thecaddyuser can write to it.
Common Mistakes and Troubleshooting
A few problems trip up almost everyone the first time. Here is how to recognize and fix them.
Certificate fails with a challenge or timeout error. This is the most common issue and it is almost always DNS or firewall related. Confirm your domain points to the server with dig +short example.com and check that the result matches your public IP. Then make sure ports 80 and 443 are open and not blocked upstream by a cloud provider security group. Caddy cannot get a certificate if Let’s Encrypt cannot reach your server on port 80.
Hitting Let’s Encrypt rate limits while testing. Let’s Encrypt limits how many certificates you can request for a domain per week. If you reload repeatedly while debugging, you can get temporarily blocked. While testing, point Caddy at the staging endpoint by adding a global options block at the very top of the Caddyfile:
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
Staging certificates are not trusted by browsers (you will see a warning), but they prove your setup works without burning your real quota. Remove this block once everything is working, then reload.
The reverse proxy returns 502 Bad Gateway. This means Caddy reached your config fine but could not talk to the backend. Confirm the app is actually running and listening on the address you specified with ss -tlnp | grep 3000. A mismatched port or an app bound only to a different interface is the usual cause.
Config changes do not take effect. Always run caddy validate --config /etc/caddy/Caddyfile before reloading. A syntax error will cause the reload to fail and Caddy keeps running the old config. Read the error message; the Caddyfile parser points to the exact line.
Permission denied writing logs. If logging silently does nothing, the caddy user probably cannot write to your log directory. Fix ownership with sudo chown -R caddy:caddy /var/log/caddy.
Best Practices
A few habits will keep your Caddy setup healthy in production.
Keep the Caddyfile under version control. It is a single readable file, which makes it perfect for storing in Git alongside the rest of your infrastructure config. You then have a history of every change and an easy rollback.
Validate before every reload. Make caddy validate a reflex. It costs nothing and prevents the classic “I edited the config and now the site is down” situation.
Let Caddy redirect HTTP to HTTPS for you. It already does this automatically for any site with a domain name, so do not write manual redirect rules. Removing that boilerplate is part of the point.
Add security headers. A small header block hardens your site at no cost:
example.com {
root * /var/www/example.com
file_server
encode gzip
header {
Strict-Transport-Security "max-age=31536000;"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
}
}
These tell browsers to always use HTTPS, to not guess content types, and to refuse being embedded in frames.
Run Caddy as the unprivileged service user. The APT package already does this, so prefer managing Caddy through systemctl rather than running caddy run by hand as root.
Test locally without burning rate limits. For local development you can use a localhost site block, and Caddy issues its own locally trusted certificate instead of contacting Let’s Encrypt:
localhost {
respond "Local HTTPS works"
}
Conclusion
You have installed Caddy from its official repository, served a static site over HTTPS, and put a backend application behind Caddy as a reverse proxy, all with automatic certificate management and a Caddyfile that fits on a screen. The thing worth appreciating is everything you did not have to do: no manual certificate requests, no renewal timers, no separate HTTP-to-HTTPS redirect rules.
From here, a few natural next steps are worth exploring. You can host multiple sites by adding more site blocks, load balance across several backends by listing more than one upstream in reverse_proxy, and serve a single-page app with the try_files directive for client-side routing. If you want to compare approaches, setting up the same site with Nginx and Certbot or with Traefik and Docker is a good way to feel the trade-offs between control and simplicity.
Caddy will not replace every web server in every situation, but for getting a secure site or a proxied app online quickly and keeping it that way, it is hard to beat.