Docker Hubโ - Quay.ioโ - bfren ecosystemโ
A reverse proxy for your other containers, built on Caddyโ and configured using one simple JSON file. Caddy requests and renews Let's Encrypt certificates automatically, using the HTTP challenge or the DNS challenge (via Hetznerโ ), which also supports wildcard certificates and servers that are not reachable from the internet.
It is built on Alpine + S6โ , and adds:
bf-proxy, a Nushell module that turns conf.json into Caddy's JSON configuration, with secure defaultsThis is the successor to bfren/nginx-proxyโ - see Migrating from nginx-proxyโ .
docker-compose.yml:
services:
proxy:
image: quay.io/bfren/caddy-proxy:1.0
restart: unless-stopped
ports:
- "0.0.0.0:80:80"
- "0.0.0.0:443:443"
- "0.0.0.0:443:443/udp"
environment:
- BF_PROXY_DOMAIN=proxy.example.com
- [email protected]
- BF_PROXY_LETS_ENCRYPT_LIVE=0
volumes:
- ./ssl:/ssl
- ./sites:/sites
- ./www:/www
app:
image: traefik/whoami
ssl/conf.json:
{
"$schema": "https://raw.githubusercontent.com/bfren/docker-caddy-proxy/main/proxy-conf-schema.json",
"domains": [
{ "primary": "app.example.com", "aliases": [ "www.app.example.com" ], "upstream": "http://app:80" }
]
}
Point the DNS records for each host name at your server, run docker compose up -d, and check docker compose logs proxy. Certificates come from Let's Encrypt's staging server (which browsers don't trust) until you set BF_PROXY_LETS_ENCRYPT_LIVE=1 - do that once everything works, because the live server is rate limited.
conf.json file, with a JSON schema for validation and autocompletewww (UID 1000), not rootImages are published to Docker Hub (bfren/caddy-proxy) and Quay.io (quay.io/bfren/caddy-proxy) for linux/amd64, linux/arm/v7 and linux/arm64.
| Tag | Example | Use when |
|---|---|---|
latest | You always want the newest image | |
MAJOR | 1 | You want updates, but not breaking changes |
MINOR | 1.0 | You want fixes only - recommended |
VERSION | 1.0.0 | You want to pin this image exactly |
| Port | Purpose |
|---|---|
80 | HTTP challenge, and redirecting HTTP to HTTPS |
443 | HTTPS |
443/udp | HTTP/3 (optional) |
Caddy runs as www and needs no extra capabilities: Docker lets unprivileged users bind to ports below 1024 inside a container. With network_mode: host the host's setting applies instead, so set net.ipv4.ip_unprivileged_port_start=80 (or lower) on the host.
| Volume | Purpose |
|---|---|
/ssl | conf.json, users.json (basic auth), and Caddy's certificates (/ssl/caddy). |
/sites | Per-domain Caddy configuration - see belowโ . |
/www | /www/public is served for BF_PROXY_DOMAIN, with the maintenance page. |
Everything in the volumes is owned by www (UID / GID 1000), and /ssl is readable only by its owner. On the host, edit the files as UID 1000 (usually the first user account) or with sudo.
| Variable | Values | Description | Default |
|---|---|---|---|
BF_PROXY_DOMAIN | domain | Required. The proxy's domain - unknown hosts redirect here. | |
BF_PROXY_LETS_ENCRYPT_EMAIL | Required. Let's Encrypt account email. | ||
BF_PROXY_LETS_ENCRYPT_LIVE | 0, 1 | Use the live server - 0 uses staging. | 0 |
BF_PROXY_USE_INTERNAL_CA | 0, 1 | Use Caddy's internal (untrusted) CA - for testing. | 0 |
BF_PROXY_ACME_CHALLENGE | http, dns | Default challenge - can be set per domain. | http |
BF_PROXY_DNS_PROVIDER | hetzner | DNS provider for the DNS challenge. | hetzner |
BF_PROXY_HETZNER_TOKEN | token | Hetzner API token for the DNS challenge. | |
BF_PROXY_DNS_TTL | duration | TTL of DNS challenge records, e.g. 60s. | provider's |
BF_PROXY_DNS_PROPAGATION_DELAY | duration | Wait before a DNS challenge record is checked. | 60s |
BF_PROXY_DNS_PROPAGATION_TIMEOUT | duration | How long to wait for DNS propagation, e.g. 5m. | |
BF_PROXY_DNS_RESOLVERS | IPs | Space-separated resolvers to check propagation. | |
BF_PROXY_DNS_OVERRIDE_ZONE | domain | Delegate DNS challenges to <primary>.<zone>. | |
BF_PROXY_SSL_REDIRECT_TO_CANONICAL | 0, 1 | Default for redirectToPrimary. | 0 |
BF_PROXY_UPSTREAM_RETRY | duration | Default for retry (0s to disable). | 5s |
BF_PROXY_AUTO_RELOAD | 0, 1 | Regenerate and reload when configuration changes. | 0 |
BF_PROXY_HARDEN | 0, 1 | Allow only TLS 1.3 (older devices may fail). | 0 |
BF_PROXY_BLOCK_AI_BOTS | 0, 1 | Return 403 to AI training crawlers. | 1 |
BF_PROXY_ACCESS_LOG | 0, 1 | Write access logs to the container output. | 0 |
BF_PROXY_CLEAN_INSTALL | 0, 1 | Delete all configuration and certificates on start. | 0 |
BF_PROXY_AUTO_PRIMARY | domain | With BF_PROXY_AUTO_UPSTREAM, generate a missing conf.json. | |
BF_PROXY_AUTO_UPSTREAM | URL | Upstream for the generated domain. | |
BF_PROXY_AUTO_ALIASES | names | Aliases for the generated domain. | |
BF_PROXY_AUTO_CUSTOM | 0, 1 | Mark the generated domain as custom. | 0 |
BF_PROXY_MAINTENANCE_REFRESH_SECONDS | integer | Maintenance page refresh interval. | 6 |
See Alpine + S6โ for BF_DEBUG, BF_TZ and the other base image variables.
| Argument | Description | Default |
|---|---|---|
CADDY_VERSION | Caddy release to build. | 2.11.7 |
CADDY_HETZNER_VERSION | caddy-dns/hetzner release. | 2.0.1 |
CADDY_BUILDER | xcaddy build image. | caddy:2.11-builder-alpine |
AI_ROBOTS_VERSION | ai.robots.txt release. | v2.0 |
On start-up, conf.json is read (or converted from nginx-proxy format, or generated from the BF_PROXY_AUTO_* variables), checked, and turned into one Caddy configuration file. Each domain's part is kept in /sites/<primary>.json. Caddy then runs under S6. The health check requests a server Caddy runs only on 127.0.0.1:2020, which has no access log, so health checks don't fill the container log.
Domains are defined in /ssl/conf.json - see proxy-conf-sample.jsonโ for an example and proxy-conf-schema.jsonโ for the full definition. Add $schema (as in the Quickstart) for validation and autocomplete.
| Property | Description |
|---|---|
primary | Required. The primary domain name. |
aliases | Other host names - wildcards need the DNS challenge. |
upstream | Required (unless custom). e.g. http://app:5000 - a list load balances. |
lb | Load balancing policy (default random) - see below. |
retry | How long to retry an unreachable upstream, e.g. 10s. |
challenge | http or dns - defaults to BF_PROXY_ACME_CHALLENGE. |
dnsOverrideDomain | Where to create DNS challenge records ("" to disable). |
redirectToPrimary | Redirect aliases to the primary domain. |
auth | Basic auth: true for all users, or a list of names. |
headers | Response headers to add or override. |
routes | Extra routes, before the default upstream - see below. |
compress | false to turn off compression. |
clacks | false to remove the X-Clacks-Overhead header. |
tlsSkipVerify | true to accept self-signed https upstreams. |
custom | See belowโ . |
lb can be random, round_robin, least_conn, first, ip_hash, uri_hash or client_ip_hash.
Each route can have a path (e.g. /api/* - without one it matches everything) and exactly one action:
| Action | Description |
|---|---|
upstream | Proxy here (or a list, with lb). stripPrefix removes a prefix. |
redirect | Redirect here (301, or set status). Caddy placeholders work. |
root | Serve static files from this (mounted) directory. |
"routes": [
{ "path": "/api/*", "upstream": [ "http://api1:8080", "http://api2:8080" ], "lb": "round_robin", "stripPrefix": "/api" },
{ "path": "/old/*", "redirect": "https://example.com/new{http.request.uri.path}" },
{ "path": "/files/*", "root": "/www/files" }
]
For every domain:
Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options and X-XSS-Protection are added, and X-Clacks-Overhead: GNU Terry Pratchett - because he was a legendโ .X-Real-IP and Caddy's X-Forwarded-* headers are sent upstream. WebSockets work automatically.retry, so a quick restart isn't noticed), or returns 502, 503 or 504, an auto-refreshing maintenance page is shown. It is regenerated from a template on every start, so changes to /www/public/maintenance.html are lost. index.html is only created when /www/public doesn't exist, so your own is kept.BF_PROXY_DOMAIN, which serves /www/public (static files cached for a year, /favicon.ico returning 204 unless you add one).With BF_PROXY_BLOCK_AI_BOTS=1, crawlers that collect AI training data get 403 Forbidden. The list comes from ai.robots.txtโ when the image is built, keeping only crawlers whose function is training data collection - AI search crawlers, assistants and user-triggered agents (e.g. OAI-SearchBot, ChatGPT-User) are not blocked. It is saved to /usr/share/caddy-proxy/ai-bots.txt.
Each domain's Caddy configuration is stored in /sites/<primary>.json: its route, its errors route (the maintenance page) and its tls policy (including the ACME challenge). What happens to it depends on custom:
false (default) - the file is regenerated on every start and by proxy-regenerate, so don't edit it. To add to it, put *.json files in /sites/<primary>.d, each holding a Caddy routeโ or an array of routes. They are added after the security headers and basic auth (so they can't bypass them), and before routes and the default upstream.
.before.json are added first instead - before alias redirects, AI bot blocking, security headers, compression and basic auth. Use them only for routes that must not be protected, e.g. a public /health endpoint on a site that otherwise needs a login. A warning is logged when they are loaded.2-x.json before 10-x.json, upper case before lower case). Use lower case names with a two-digit prefix, e.g. 10-health.before.json, 20-api.json.*.conf files from nginx-proxy are ignored, with a warning.true - an existing file is left alone, giving you complete control. Run proxy-regenerate -d <primary> -f to regenerate it.Use the DNS challenge for wildcard aliases, and for servers that are not reachable from the internet (e.g. only over a VPN).
BF_PROXY_HETZNER_TOKEN to the token - see keeping the token out of your compose fileโ . It is never written to the generated configuration: Caddy reads it from the environment.BF_PROXY_ACME_CHALLENGE=dns, or "challenge": "dns" on each domain.Caddy waits BF_PROXY_DNS_PROPAGATION_DELAY (60 seconds) after creating each record before Let's Encrypt checks it. Without the wait, Let's Encrypt can ask a nameserver that doesn't have the record yet, and resolvers then cache the missing record (for the zone's SOA negative TTL), so retries fail too. The wait happens once per batch of certificates, and only when they are issued or renewed. Set BF_PROXY_DNS_TTL=60s (Hetzner's lowest) so stale challenge values don't stay cached either.
Delegate the challenge to a zone Hetzner hosts using CNAME records. Caddy doesn't follow the CNAME, so it has to be told where to create the record:
acme.example.net - either a separate domain with its nameservers set to Hetzner's, or a subdomain delegated with NS records at your registrar's DNS.BF_PROXY_DNS_OVERRIDE_ZONE=acme.example.net. Each domain using the DNS challenge now creates its records at <primary>.acme.example.net - including the proxy domain.*.:| Record | Type | Value |
|---|---|---|
_acme-challenge.send.example.com | CNAME | send.example.com.acme.example.net. |
_acme-challenge.www.send.example.com | CNAME | send.example.com.acme.example.net. |
_acme-challenge.files.example.com | CNAME | send.example.com.acme.example.net. |
That example is a domain with primary send.example.com and aliases www.send.example.com and *.files.example.com. Using a separate name for each domain stops requests for different domains overwriting each other's records.
Set "dnsOverrideDomain" on a domain to use a different name, or "" for a domain Hetzner hosts directly, which needs no delegation. Check the records before requesting certificates:
dig +short CNAME _acme-challenge.send.example.com # send.example.com.acme.example.net.
dig +short NS acme.example.net # Hetzner's nameservers
Run proxy-regenerate after changing conf.json, users.json or files in /sites, or set BF_PROXY_AUTO_RELOAD=1 to do it automatically. The files are checked every 5 seconds, and once they have stopped changing for 5 seconds (so saving several files causes one reload) configuration is regenerated and Caddy reloads without dropping connections.
If the new configuration is not valid (e.g. a JSON syntax error or a missing upstream), the errors are written to the container log and Caddy keeps running with the current configuration.
Run these with docker exec <container> <command>. Basic auth passwords are stored as bcrypt hashes, but proxy-adduser takes the password as an argument, so it may be kept in your shell history.
| Command | Arguments | Description |
|---|---|---|
proxy-regenerate | -d: only domain, -f: force | Regenerate configuration and reload (force includes custom). |
proxy-reload | Validate the configuration and reload Caddy. | |
proxy-validate | Validate conf.json and the current Caddy configuration. | |
proxy-certs | List certificates and days left (flagged at 14 or fewer). | |
proxy-renew | -a: all, or -d: domain | Delete certificates and restart Caddy to request new ones. |
proxy-cleanup | -l: live mode | Remove domains not in conf.json (dry run unless -l). |
proxy-adduser | 0: username, 1: password | Add or update a basic auth user. |
proxy-deluser | 0: username | Remove a basic auth user. |
/ssl/conf.json to the new /ssl volume - it is converted on start-up. The original is kept as /ssl/conf.json.nginx-proxy, and anything that can't be converted is reported in the log.bfren/caddy-proxy and rename variables from PROXY_* to BF_PROXY_* (old names are ignored). These are no longer used: PROXY_ENABLE_AUTO_UPDATE (Caddy renews automatically), PROXY_ENABLE_NAXSI, PROXY_SSL_KEY_BITS, PROXY_SSL_DHPARAM_BITS, PROXY_GETSSL_SKIP_HTTP_TOKEN_CHECK and PROXY_UPSTREAM_DNS_RESOLVER./sites can't be converted - rewrite it as Caddy JSON in /sites/<primary>.d.BF_PROXY_LETS_ENCRYPT_LIVE=0 until everything works.nginx-adduser is replaced by proxy-adduser - users are stored in /ssl/users.json and enabled per domain with auth../test.nu # builds the image and runs /test
./run.sh # builds the image and opens a shell, using the internal CA on 127.0.0.1:8080 and :8443
/test runs this image's tests (configuration, generated sites, routes, TLS and DNS delegation, AI bots, certificates, migration and helpers) alongside the base images' tests.
BF_PROXY_LETS_ENCRYPT_LIVE is 0, so certificates come from Let's Encrypt's staging server. Set it to 1 and restart. If staging certificates are still served, run proxy-renew -a.
The record hadn't reached all of Hetzner's nameservers. Increase BF_PROXY_DNS_PROPAGATION_DELAY (e.g. 120s) and / or set BF_PROXY_DNS_RESOLVERS (e.g. 1.1.1.1). If you delegate with CNAME records, check them with dig as shown in DNS challengeโ - a CNAME on the wrong name, or one missing for an alias, fails the same way.
Caddy can't reach the upstream. It must be on a Docker network shared with the proxy, and the URL must use the container's name and its internal port (e.g. http://app:80, not the port published on the host). Test from inside: docker exec <container> wget -qO- http://app:80.
Run proxy-regenerate, or set BF_PROXY_AUTO_RELOAD=1. Domains with "custom": true are only regenerated with proxy-regenerate -d <primary> -f. Errors are written to the container log.
Add extra_hosts so the container can resolve the host, and use that name as the upstream:
extra_hosts:
- "host.docker.internal:host-gateway"
Then use e.g. "upstream": "http://host.docker.internal:8080". The service must listen on an address the Docker bridge can reach (e.g. 0.0.0.0, not 127.0.0.1), and the host's firewall must allow it.
Put it in a .env file next to docker-compose.yml (and out of version control), e.g. HETZNER_TOKEN=..., then add - BF_PROXY_HETZNER_TOKEN=${HETZNER_TOKEN} to environment. Don't add quotes - they become part of the value.
Only Hetzner is built in, but domains hosted anywhere can use it by delegating the challengeโ with CNAME records.
Copyright (c) 2020-2026 bfrenโ (unless otherwise stated)
Content type
Image
Digest
sha256:c18aabf44โฆ
Size
47 MB
Last updated
about 2 hours ago
docker pull bfren/caddy-proxy