Sign inSign up

bfren/caddy-proxy

By bfren

โ€ขUpdated about 10 hours ago

Image
0

9.0K

bfren/caddy-proxy repository overview

โ Caddy Proxy

GitHub release (latest by date) Docker Pulls Docker Image Size GitHub Workflow Status

Docker Hubโ  - Quay.ioโ  - bfren ecosystemโ 

โ Introduction

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:

  • Caddy, compiled with the Hetzner DNS module
  • bf-proxy, a Nushell module that turns conf.json into Caddy's JSON configuration, with secure defaults
  • helper commands to regenerate configuration, manage basic auth users and check or renew certificates

This is the successor to bfren/nginx-proxyโ  - see Migrating from nginx-proxyโ .

โ Quickstart

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.

โ Features

  • ๐Ÿ”’ Automatic HTTPS - certificates are requested and renewed for every host name, and HTTP is redirected to HTTPS
  • ๐ŸŒ HTTP or DNS challenge per domain, with wildcard certificates and CNAME delegation for domains hosted elsewhere
  • ๐Ÿ“„ One conf.json file, with a JSON schema for validation and autocomplete
  • โš–๏ธ Multiple upstreams with load balancing, extra routes (proxy, redirect or static files) per path
  • ๐Ÿ›ก๏ธ Secure headers, optional TLS 1.3 only, HTTP basic auth, and AI training crawlers blocked by default
  • ๐Ÿ—œ๏ธ zstd / gzip compression, HTTP/3 and WebSockets
  • ๐Ÿ”ง Maintenance page when an upstream is down
  • ๐Ÿ”„ Optional automatic reload when configuration changes, without dropping connections
  • ๐Ÿงฉ Add your own Caddy routes per domain, or take complete control of a domain's configuration
  • ๐Ÿฉบ Health check using a local server that isn't logged
  • ๐Ÿ‘ค Caddy runs as www (UID 1000), not root
  • ๐Ÿงช Built-in tests

โ Tags

Images 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.

TagExampleUse when
latestYou always want the newest image
MAJOR1You want updates, but not breaking changes
MINOR1.0You want fixes only - recommended
VERSION1.0.0You want to pin this image exactly

โ Configuration

โ Ports
PortPurpose
80HTTP challenge, and redirecting HTTP to HTTPS
443HTTPS
443/udpHTTP/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.

โ Volumes
VolumePurpose
/sslconf.json, users.json (basic auth), and Caddy's certificates (/ssl/caddy).
/sitesPer-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.

โ Environment Variables
VariableValuesDescriptionDefault
BF_PROXY_DOMAINdomainRequired. The proxy's domain - unknown hosts redirect here.
BF_PROXY_LETS_ENCRYPT_EMAILemailRequired. Let's Encrypt account email.
BF_PROXY_LETS_ENCRYPT_LIVE0, 1Use the live server - 0 uses staging.0
BF_PROXY_USE_INTERNAL_CA0, 1Use Caddy's internal (untrusted) CA - for testing.0
BF_PROXY_ACME_CHALLENGEhttp, dnsDefault challenge - can be set per domain.http
BF_PROXY_DNS_PROVIDERhetznerDNS provider for the DNS challenge.hetzner
BF_PROXY_HETZNER_TOKENtokenHetzner API token for the DNS challenge.
BF_PROXY_DNS_TTLdurationTTL of DNS challenge records, e.g. 60s.provider's
BF_PROXY_DNS_PROPAGATION_DELAYdurationWait before a DNS challenge record is checked.60s
BF_PROXY_DNS_PROPAGATION_TIMEOUTdurationHow long to wait for DNS propagation, e.g. 5m.
BF_PROXY_DNS_RESOLVERSIPsSpace-separated resolvers to check propagation.
BF_PROXY_DNS_OVERRIDE_ZONEdomainDelegate DNS challenges to <primary>.<zone>.
BF_PROXY_SSL_REDIRECT_TO_CANONICAL0, 1Default for redirectToPrimary.0
BF_PROXY_UPSTREAM_RETRYdurationDefault for retry (0s to disable).5s
BF_PROXY_AUTO_RELOAD0, 1Regenerate and reload when configuration changes.0
BF_PROXY_HARDEN0, 1Allow only TLS 1.3 (older devices may fail).0
BF_PROXY_BLOCK_AI_BOTS0, 1Return 403 to AI training crawlers.1
BF_PROXY_ACCESS_LOG0, 1Write access logs to the container output.0
BF_PROXY_CLEAN_INSTALL0, 1Delete all configuration and certificates on start.0
BF_PROXY_AUTO_PRIMARYdomainWith BF_PROXY_AUTO_UPSTREAM, generate a missing conf.json.
BF_PROXY_AUTO_UPSTREAMURLUpstream for the generated domain.
BF_PROXY_AUTO_ALIASESnamesAliases for the generated domain.
BF_PROXY_AUTO_CUSTOM0, 1Mark the generated domain as custom.0
BF_PROXY_MAINTENANCE_REFRESH_SECONDSintegerMaintenance page refresh interval.6

See Alpine + S6โ  for BF_DEBUG, BF_TZ and the other base image variables.

โ Build Arguments
ArgumentDescriptionDefault
CADDY_VERSIONCaddy release to build.2.11.7
CADDY_HETZNER_VERSIONcaddy-dns/hetzner release.2.0.1
CADDY_BUILDERxcaddy build image.caddy:2.11-builder-alpine
AI_ROBOTS_VERSIONai.robots.txt release.v2.0

โ How It Works

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.

โ conf.json

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.

PropertyDescription
primaryRequired. The primary domain name.
aliasesOther host names - wildcards need the DNS challenge.
upstreamRequired (unless custom). e.g. http://app:5000 - a list load balances.
lbLoad balancing policy (default random) - see below.
retryHow long to retry an unreachable upstream, e.g. 10s.
challengehttp or dns - defaults to BF_PROXY_ACME_CHALLENGE.
dnsOverrideDomainWhere to create DNS challenge records ("" to disable).
redirectToPrimaryRedirect aliases to the primary domain.
authBasic auth: true for all users, or a list of names.
headersResponse headers to add or override - null stops the proxy adding one.
routesExtra routes, before the default upstream - see below.
compressfalse to turn off compression.
clacksfalse to remove the X-Clacks-Overhead header.
tlsSkipVerifytrue to accept self-signed https upstreams.
customSee 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:

ActionDescription
upstreamProxy here (or a list, with lb). stripPrefix removes a prefix.
redirectRedirect here (301, or set status). Caddy placeholders work.
rootServe 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" }
]
โ Defaults

For every domain:

  • HTTP is redirected to HTTPS, and certificates are requested and renewed automatically.
  • Responses are compressed with zstd or gzip. Images and other compressed formats, responses under 512 bytes and responses the upstream has already compressed are left alone.
  • 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โ . To stop one being added, set it to null in the domain's headers - e.g. Vaultwarden needs "X-Frame-Options": null, because it shows its two-factor pages in frames and sets the header itself everywhere else.
  • X-Real-IP and Caddy's X-Forwarded-* headers are sent upstream. WebSockets work automatically.
  • If the upstream can't be reached (after 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.
  • Requests for unknown hosts are redirected to BF_PROXY_DOMAIN, which serves /www/public (static files cached for a year, /favicon.ico returning 204 unless you add one).
โ AI crawlers

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.

โ Custom domain configuration

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.
    • Files ending .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.
    • Files load in natural sort order (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.
    • Nginx *.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.
โ DNS challenge

Use the DNS challenge for wildcard aliases, and for servers that are not reachable from the internet (e.g. only over a VPN).

  1. In the Hetzner Console project that holds your zones, create an API token with read / write permission (the module uses the Cloud DNS API).
  2. Set 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.
  3. Set 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.

โ Domains hosted with another DNS provider

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:

  1. Create a zone in the token's Hetzner project, e.g. 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.
  2. Set 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.
  3. At the domain's own DNS provider, add a CNAME for every host name in the domain (primary and aliases) pointing to the primary's name. For wildcards drop the *.:
RecordTypeValue
_acme-challenge.send.example.comCNAMEsend.example.com.acme.example.net.
_acme-challenge.www.send.example.comCNAMEsend.example.com.acme.example.net.
_acme-challenge.files.example.comCNAMEsend.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
โ Automatic reload

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.

โ Helper commands

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.

CommandArgumentsDescription
proxy-regenerate-d: only domain, -f: forceRegenerate configuration and reload (force includes custom).
proxy-reloadValidate the configuration and reload Caddy.
proxy-validateValidate conf.json and the current Caddy configuration.
proxy-certsList certificates and days left (flagged at 14 or fewer).
proxy-renew-a: all, or -d: domainDelete certificates and restart Caddy to request new ones.
proxy-cleanup-l: live modeRemove domains not in conf.json (dry run unless -l).
proxy-adduser0: username, 1: passwordAdd or update a basic auth user.
proxy-deluser0: usernameRemove a basic auth user.

โ Migrating from nginx-proxy

  1. Copy /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.
  2. Change the image to 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.
  3. Custom Nginx configuration in /sites can't be converted - rewrite it as Caddy JSON in /sites/<primary>.d.
  4. Existing certificates are not reused. Caddy requests one certificate per host name (getssl used one per primary domain), so check Let's Encrypt rate limitsโ  if you have many, and keep BF_PROXY_LETS_ENCRYPT_LIVE=0 until everything works.
  5. nginx-adduser is replaced by proxy-adduser - users are stored in /ssl/users.json and enabled per domain with auth.

โ Testing

./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.

โ Troubleshooting

โ Browsers say the certificate isn't trusted

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 DNS challenge fails with NXDOMAIN

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.

โ I always see the maintenance page

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.

โ My changes don't take effect

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.

โ FAQs

โ How do I proxy to a service running on the host, not in a container?

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.

โ How do I keep the Hetzner token out of my compose file?

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.

โ Can I use a DNS provider other than Hetzner?

Only Hetzner is built in, but domains hosted anywhere can use it by delegating the challengeโ  with CNAME records.

โ Licence

Copyright (c) 2020-2026 bfrenโ  (unless otherwise stated)

Tag summary

Content type

Image

Digest

sha256:decbbc111โ€ฆ

Size

47 MB

Last updated

about 11 hours ago

docker pull bfren/caddy-proxy