blog
Point a domain at a Docker container with Caddy
Published
Caddy gets a certificate for your domain on its own. That leaves two things for you to get right: DNS that already points at the server, and a network on which the proxy can reach your container by name.
DNS comes first
Create an A record for the domain that points at the server’s IP address, and wait until it resolves before you start Caddy. The certificate authority verifies that you control the domain by connecting to it over the internet; if the name does not point at your server yet, that check reaches someone else or nothing at all.
Check from your own machine, not only in your DNS provider’s panel:
dig +short app.example.com A # must print your server's IPv4 address dig +short app.example.com AAAA # must print nothing, or your server's IPv6 address
The AAAA line is the one people miss. The certificate authority also uses IPv6 when a record exists, so a stale AAAA record pointing at another machine can make validation fail while the A record looks perfect.
The smallest Caddyfile
One site block per domain. The domain name is the entire HTTPS configuration: Caddy sees a public hostname, obtains a certificate for it, and redirects HTTP to HTTPS.
conf/Caddyfile
app.example.com {
reverse_proxy app:3000
}app:3000 is the container’s name and the port the app listens on inside the container — not a port published on the host.
Run Caddy next to the app
Caddy and the application have to share a Docker network so the proxy can reach the app by name. Inside one Compose project that happens automatically: services join the project’s default network.
If the app belongs to a different Compose project, create a network once with docker network create web and list it under networks: with external: true in both files.
compose.yml
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./conf:/etc/caddy
- caddy_data:/data
- caddy_config:/config
app:
image: ghcr.io/example/app:1.4.0
restart: unless-stopped
volumes:
caddy_data:
caddy_config:The app publishes no ports: only Caddy faces the internet. The caddy_data volume holds the certificates and account keys. Keep it — otherwise every recreate asks for new certificates, and sooner or later you hit the certificate authority’s rate limits.
How the certificate is issued, and why it fails
On start, Caddy asks an ACME certificate authority such as Let’s Encrypt for a certificate, proves control of the domain with a challenge on port 80 or 443, and stores the result in /data. When it works, it takes seconds.
When it does not, the cause is nearly always one of these, and docker compose logs caddy names it:
- 01The domain does not resolve to this server yet, or an AAAA record points somewhere else.
- 02Ports 80 and 443 are blocked — by the cloud provider’s firewall as often as by the server’s own.
- 03Something else already listens on port 80 or 443, typically an nginx or Apache that came with the server image.
Renewal is not your job
Caddy renews certificates by itself, well before they expire, and keeps retrying if an attempt fails. There is no cron job to write and no expiry date to put in a calendar.
It does need the conditions it had on day one: DNS still pointing here, the ports still open, and /data still persistent.
Adding a second site
Add another block and reload. A reload swaps the configuration in place: existing sites keep serving and open connections are not dropped. A Caddyfile with a mistake in it is rejected, and the running configuration stays as it was.
Reload from the directory that holds the compose file with docker compose exec -w /etc/caddy caddy caddy reload.
app.example.com {
reverse_proxy app:3000
}
status.example.com {
reverse_proxy uptime:3001
}Mount the configuration directory, not the single file. Many editors save by writing a new file and renaming it over the old one, and a container with only that one file bind-mounted keeps seeing the old version.
The automated version
MihraOps runs this same setup for you: a Caddy container named mihraops-caddy, with its configuration in /opt/mihraops/caddy. You add the domain in the app; it writes the site block over SSH, validates the new configuration before it replaces the running one, and keeps the previous version, so a mistake in one domain cannot take the other sites down.
DNS is still yours to set up first. The docs page covers the in-app steps.