👍 What we like
- ✓Automatic HTTPS with Let's Encrypt without manual ACME config
- ✓Significantly simpler configuration than Nginx or Certbot
- ✓Built-in security headers and HTTP to HTTPS redirection
- ✓Clean service exposure via Docker network internal DNS
👎 What to watch
- ✕Requires DNS records to propagate before certificate issuance
- ✕Needs ports 80 and 443 open on the host firewall
- ✕Requires pre-installed Docker and Docker Compose
- ✕Dependent on external domain name ownership for validation
📑 Contents ▾
- 01 Prerequisites
- 02 Step 1: Install Docker and Docker Compose
- 03 Step 2: Configure DNS Records
- 04 Step 3: Create the Shared Docker Network
- 05 Step 4: Write the Caddyfile
- 06 Step 5: The Caddy docker-compose.yml
- 07 Step 6: Connect Your Services to Caddy’s Network
- 08 Step 7: Reload Configuration Without Downtime
- 09 Final Verification
- 10 Best Practices and Advanced Options
- 11 FAQ
- · Why choose Caddy over Nginx or Traefik?
- · What happens if Let’s Encrypt fails to issue the certificate?
- · Are my certificates renewed automatically?
- · Can I use Caddy behind Cloudflare?
- · Does Caddy consume many resources?
- 17 Related Topics
You host several services on your VPS (a Nextcloud, an n8n, a website, an admin panel) and end up with URLs like http://ip:8080, http://ip:5678, without HTTPS and without proper domain names. It’s time to install a reverse proxy. And in 2026, if you want HTTPS that “just works,” Caddy is unbeatable: it automatically obtains and renews Let’s Encrypt certificates, without writing a single line of ACME configuration.
Where Nginx requires dozens of lines of config and a cron job for certbot, Caddy does the same thing in two or three lines. In this tutorial, we deploy Caddy via Docker, use it to cleanly expose multiple services behind a single entry point, with automatic HTTPS, security headers, and HTTP → HTTPS redirection. Everything is in versioned, reproducible files.
Prerequisites
-
A secure VPS running Ubuntu 24.04 (or Debian 12). If not already done, follow our guide to install and secure an Ubuntu VPS first.
-
Docker and Docker Compose installed (the installation command is in Step 1).
-
A domain name whose DNS you control (with your registrar, or via Cloudflare). We will use
exemple.fras the example domain. -
Ports 80 and 443 open on your UFW firewall: this is essential for Let’s Encrypt validation and HTTPS traffic.
-
One or more already containerized services to expose (we will use Nextcloud and a demo web service as examples).
Step 1: Install Docker and Docker Compose
If Docker is not yet present, install it from the official repository (Ubuntu repository versions are often outdated):
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Add your user to the docker group to avoid typing sudo for every command (log out and back in afterwards to apply changes):
sudo usermod -aG docker $USER
Verify the installation:
docker --version && docker compose version
Step 2: Configure DNS Records
Caddy needs your subdomains to point to your VPS’s IP address to validate certificates. With your DNS provider, create A records (and AAAA if you have IPv6):
| Type | Name | Value |
| :--- | :--- | :--- |
| A | cloud.exemple.fr | 203.0.113.10 |
| A | app.exemple.fr | 203.0.113.10 |
Wait for propagation (usually a few minutes, sometimes up to an hour). Verify from your machine:
dig +short cloud.exemple.fr
The command should return your VPS’s IP. Until it does, Let’s Encrypt will fail to issue the certificate.
Step 3: Create the Shared Docker Network
For Caddy to reach your containers by their name (rather than a changing IP), all services must share a dedicated Docker network. Create it once:
docker network create web
This web network serves as the “bus” between the reverse proxy and all your services. Caddy will resolve nextcloud:80 or demo:80 automatically via Docker’s internal DNS.
Step 4: Write the Caddyfile
The core of the configuration lies in a single file: the Caddyfile. Create a working directory and the file:
mkdir -p ~/caddy && cd ~/caddy
nano Caddyfile
Here is a complete Caddyfile exposing two services over HTTPS with security headers:
{
# Email for Let's Encrypt notifications (expiration, etc.)
email admin@exemple.fr
}
# Reusable configuration block for security headers
(securite) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
# Hide the server signature
-Server
}
}
cloud.exemple.fr {
import securite
reverse_proxy nextcloud:80
}
app.exemple.fr {
import securite
reverse_proxy demo:80
}
A few explanations:
-
Each block starting with a domain name automatically triggers the issuance of a Let’s Encrypt certificate and HTTP → HTTPS redirection. You don’t need to configure anything else.
-
reverse_proxy nextcloud:80redirects traffic to the container namednextcloud, on its internal port 80. Docker handles the name resolution. -
The
(securite)snippet, imported into each block, factors out recommended HTTP headers. Useful for maintaining consistency.
Common pitfall: Never put
http://orhttps://before the domain name in a site block. Caddy handles the protocol automatically. Writinghttps://cloud.exemple.frdisables automatic certificate issuance.
Step 5: The Caddy docker-compose.yml
In the same ~/caddy directory, create the composition file:
nano docker-compose.yml
services:
caddy:
image: caddy:2-alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3 (QUIC)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- web
volumes:
caddy_data:
caddy_config:
networks:
web:
external: true
Important points:
-
The
caddy_datavolume is critical: it stores your certificates and ACME keys. Without it, you would request a certificate every time you restart and quickly hit Let’s Encrypt rate limits (5 identical certificates per week). -
The network is declared
external: truebecause we created it manually in Step 3. -
The 443/udp port enables HTTP/3, natively supported by Caddy.
Start Caddy:
docker compose up -d
Follow the logs to watch certificate issuance in real-time:
docker compose logs -f caddy
You should see lines like certificate obtained successfully for each of your domains.
Step 6: Connect Your Services to Caddy’s Network
For a service to be reachable by Caddy, it must be on the web network and bear the name used in the Caddyfile. Here is an example of a demo service to expose. In another directory:
services:
demo:
image: nginxdemos/hello
container_name: demo
restart: unless-stopped
networks:
- web
networks:
web:
external: true
Note the absence of a ports: section: this is the whole point of the reverse proxy. The service is never exposed directly to the Internet; it is only accessible through Caddy via the internal web network. This is safer and cleaner.
Start it:
docker compose up -d
Visit https://app.exemple.fr: you get the demo service over HTTPS, with a valid padlock. For Nextcloud, simply ensure its container is named nextcloud and joins the web network (see our tutorial hosting Nextcloud on a VPS).
Step 7: Reload Configuration Without Downtime
When you add a service or modify the Caddyfile, reload without restarting the container (zero downtime, existing certificates are preserved):
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
First validate the syntax to avoid breaking production:
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
Final Verification
Check that everything is in place:
# Caddy is running
docker compose ps
# HTTP correctly redirects to HTTPS (code 308)
curl -sI http://cloud.exemple.fr | head -1
# The certificate is valid and the TLS chain is correct
curl -sI https://cloud.exemple.fr | head -1
For a complete TLS configuration audit, test your domain on SSL Labs: you should aim for an A or A+ grade thanks to HSTS headers and modern protocols enabled by default in Caddy.
Best Practices and Advanced Options
-
DNS Challenge Validation. If you want wildcard certificates (
*.exemple.fr) or your server is not reachable on port 80, use the DNS challenge. This requires a Caddy image compiled with your DNS provider’s module (Cloudflare, OVH…) and an API token. -
Frontend Authentication. To protect a sensitive service, Caddy handles
basic_auth(bcrypt-hashed password) directly in the Caddyfile, or you can couple it with an SSO provider like Authelia. -
Compression and Cache. Add
encode gzip zstdin a site block to automatically compress responses. -
Structured Logs. The
logdirective allows writing JSON logs usable by your monitoring stack. -
Do not mix reverse proxies. Never expose a service directly (via
ports:) and behind Caddy in parallel: this creates unencrypted access paths. Everything must go through the proxy.
FAQ
Why choose Caddy over Nginx or Traefik?
For the simplicity of automatic HTTPS. Caddy obtains, installs, and renews Let’s Encrypt certificates without any manual configuration or cron jobs, whereas Nginx requires certbot and verbose config blocks. Traefik is excellent for auto-discovery via Docker labels, but its learning curve is steeper. For a detailed comparison of the three, see Caddy vs Nginx vs Traefik in 2026.
What happens if Let’s Encrypt fails to issue the certificate?
In 90% of cases, it’s a DNS issue (the domain hasn’t pointed to the VPS yet) or a firewall issue (port 80 closed). Check dig +short your-domain and sudo ufw status. Caddy logs (docker compose logs caddy) indicate the exact cause of the ACME rejection. While debugging, use Let’s Encrypt’s staging environment to avoid hitting rate limits.
Are my certificates renewed automatically?
Yes, completely. Caddy continuously checks expiration and renews each certificate approximately 30 days before it expires, without any intervention. This is precisely what makes this solution “set and forget.” The only condition: the caddy_data volume must be properly persistent.
Can I use Caddy behind Cloudflare?
Yes. Enable “Full (strict)” mode in Cloudflare so that Cloudflare → VPS traffic remains encrypted with Caddy’s Let’s Encrypt certificate. If you enable Cloudflare’s orange proxy, the HTTP-01 challenge may fail; then switch to Cloudflare’s DNS challenge for certificate issuance.
Does Caddy consume many resources?
No, it’s one of its strengths. The caddy:2-alpine image weighs only a few dozen MB, and the container runs quietly with less than 50 MB of RAM for moderate traffic. It is perfectly suited for a small entry-level VPS.
Related Topics
With this reverse proxy in place, you can now stack as many services as you want, each with its own HTTPS subdomain, without ever touching certificate management again. The next step to sleep soundly: encrypting and automating backups of this server. To follow new vulnerabilities and self-hosted tools, subscribe to our Telegram watch bot.