caddy-docker-proxy: Caddy as a reverse proxy for Docker, driven by labels
Caddy as a reverse proxy for Docker
At a glance
- What is it?
- The plugin turns Docker labels into an in-memory Caddyfile and reloads Caddy on every Docker change. It fits teams already running Caddy who want per-service routing without editing config files by hand.
- Who is it for?
- Adopt it if you already run Caddy and want routing declared next to the service in its compose file; skip it if you need a proxy that manages configuration without a Docker socket, or if you cannot accept that a label typo becomes a silently wrong route. Before rolling it out, verify that the Caddy version bundled in the image matches the Caddyfile directives you plan to use, and confirm the CADDY_INGRESS_NETWORKS value matches the network your services actually join.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 10 days ago.
- What is it written in?
- Mainly Go, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 30, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem: routing declared in two places
A conventional reverse proxy setup keeps two files in sync that have no awareness of each other. The compose file says a service exists and joins a network. The proxy config says a hostname maps to that service. When a container is renamed, moved to another network, or scaled, the proxy config is the part that goes stale, and nothing in Docker tells you.
caddy-docker-proxy removes the second file. According to the README, the plugin scans Docker metadata for labels indicating a service or container should be served by Caddy, then generates an in-memory Caddyfile with site entries and proxies pointing at each service by its DNS name or container IP. The audience is teams already committed to Caddy who run containers on a single host or a small cluster and want the route to live beside the service definition rather than in a separate repository.
How labels become a Caddyfile
The conversion is mechanical and worth understanding before you write labels, because the rules are stricter than they look. Any label prefixed with caddy is treated as configuration. The key is the directive name and the value is whitespace-separated arguments, so caddy.respond: / "Hello World" 200 becomes a respond directive with three arguments. Dots represent nesting, and the plugin groups subdirectives under their parent automatically, which is why caddy.reverse_proxy plus a nested key produces a block rather than two flat lines.
Two details decide whether your config behaves as expected. First, directives from labels are ordered alphabetically, then Caddy re-sorts them according to its own default directive order when it parses the generated Caddyfile. Second, the _<number> suffix isolates directives that would otherwise be grouped into one block, and a <number>_ prefix sets an explicit order, with unprefixed directives going last. That prefix matters inside route blocks, where order is the whole point.
A label with no value produces a directive with no arguments, and a bare caddy label with no value defines global options. Sites and snippets both come from the caddy label itself: a hostname produces a site block, and a parenthesized name produces a snippet.
The reload path is the part that makes this usable in production. The README states that every time a Docker object changes, the plugin updates the Caddyfile and triggers Caddy to gracefully reload, with zero downtime. There is no polling loop you configure and no separate reload command to run.
Installing it and serving your first container
There is no standalone binary to fetch. The README's basic example creates a Docker network and runs the proxy image with the Docker socket mounted and CADDY_INGRESS_NETWORKS set, which tells the plugin which networks it should watch.
docker network create caddy --ipv6The --ipv6 flag is not cosmetic. The README notes that without it, Caddy and any upstream services see Docker's gateway IP address instead of the actual client IP addresses for IPv6 clients, which breaks anything downstream that reads the client address.
The proxy service itself is declared in compose, with ports 80, 443/tcp and 443/udp published, the socket mounted read-write at /var/run/docker.sock, and a named volume for /data so certificates survive a restart.
services:
caddy:
image: lucaslorentz/caddy-docker-proxy:ci-alpine
ports:
- 80:80
- 443:443/tcp
- 443:443/udp
environment:
- CADDY_INGRESS_NETWORKS=caddy
networks:
- caddy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- caddy_data:/data
restart: unless-stoppedThen a second compose file joins the same external network and carries two labels: caddy sets the hostname, and caddy.reverse_proxy uses the upstreams template function to resolve the service's address on port 80.
services:
whoami:
image: traefik/whoami
networks:
- caddy
labels:
caddy: whoami.example.com
caddy.reverse_proxy: "{{upstreams 80}}"After docker compose up -d on both files, visiting the hostname serves the container over HTTPS with a certificate issued by Let's Encrypt or ZeroSSL. The upstreams template function is the piece that keeps the route correct when the container restarts with a new IP, since the value is resolved from Docker rather than hardcoded.
Execution modes and what each one costs you
The repository lists three modes: server, controller, and standalone, which is the default. Standalone is the compose example above, where the same process both watches Docker and serves traffic. Server and controller split those responsibilities, which matters when you want more than one Caddy instance serving traffic while a single process owns the Docker socket.
The trade-off is operational rather than functional. In standalone mode, a crash of the Caddy process takes the discovery loop with it, and the socket mount sits in the container that faces the internet. Splitting the roles means the serving instances do not need socket access, but you now run two kinds of process and have to reason about how configuration reaches the servers. The README documents the modes and the options that select them; it does not document rollback if a generated Caddyfile fails to parse, which is the gap I would want closed before putting this in front of production traffic.
Where label-driven configuration gets awkward
Labels are flat strings attached to Docker objects. A Caddyfile is a nested, ordered configuration language. The whole plugin is the impedance mismatch between those two things, and the _<number> suffixes are the evidence: they exist because grouping and ordering cannot be expressed naturally in a label key.
That produces a real failure mode. A typo in a label key does not raise an error you will see; it produces a directive that Caddy either rejects or, worse, accepts as something you did not intend. There is no schema for labels, so nothing validates caddy.reverse_proxy against the directive it is supposed to be. The same applies to the ordering rules: if you forget that unprefixed directives sort alphabetically and then get reordered by Caddy's directive order, you can spend an afternoon on a routing bug that is really a naming bug.
It is also the wrong tool when the proxy config is not derived from containers. If you terminate TLS for hostnames that point at machines outside Docker, or you need a configuration review process with diffs and approvals, a checked-in Caddyfile is easier to reason about than labels scattered across a dozen compose files. And if you cannot mount the Docker socket, the plugin has nothing to read.
How it differs from Traefik
Traefik solves the same discovery problem from the opposite direction. It is a proxy that was built around dynamic providers, with Docker as one of them, and its routing model is expressed as routers, services and middlewares configured through labels or its own file format. caddy-docker-proxy is a plugin that extends an existing proxy, and its label vocabulary is Caddyfile syntax: the label keys are Caddy directives, and the thing being generated is a Caddyfile.
The practical difference is what you already know. If your team writes Caddyfiles and uses Caddy's automatic HTTPS, its certificate management and its directive set, the plugin keeps that knowledge and adds discovery. Adopting Traefik instead means learning a second configuration model and giving up direct control over the generated configuration, since the Caddyfile is an artifact you can read and reason about while Traefik's internal routing state is not. If you are starting from nothing and want a proxy whose primary design goal is dynamic service discovery, Traefik is the more direct fit.
Licence and upgrade cost
The project is MIT licensed, which permits commercial use and modification with the usual requirement to preserve the copyright notice and permission text. The Dockerfile is a multi-stage build from Alpine into a scratch image, exposing ports 80, 443 and 2019 and setting XDG_CONFIG_HOME to /config and XDG_DATA_HOME to /data, so the certificate and state volume you mount should be /data as in the README example. Note that the image is built with Caddy as a dependency, so upgrading caddy-docker-proxy can move the Caddy version underneath you. The go.mod pins github.com/caddyserver/caddy/v2 at v2.11.4, and the module path carries a /v2 suffix, which means the Go module major version tracks the project's own v2 line rather than Caddy's.
Upgrade cost is dominated by that coupling. A Caddyfile directive that changed between Caddy releases can break a generated config even when the plugin's own code is unchanged, and the plugin's release notes are the place to check. The README's Docker images section covers choosing version numbers and the difference between default, alpine, CI, ARM and Windows images, which is where you decide whether to pin a tag or follow a moving one.
Editorial conclusion
Adopt it if you already run Caddy and want routing declared next to the service in its compose file; skip it if you need a proxy that manages configuration without a Docker socket, or if you cannot accept that a label typo becomes a silently wrong route. Before rolling it out, verify that the Caddy version bundled in the image matches the Caddyfile directives you plan to use, and confirm the CADDY_INGRESS_NETWORKS value matches the network your services actually join.
Frequently asked questions
Can I run caddy-docker-proxy in Docker?
Yes. The README's basic example runs the image lucaslorentz/caddy-docker-proxy:ci-alpine in compose with ports 80, 443/tcp and 443/udp published, the Docker socket mounted at /var/run/docker.sock, and a volume at /data for Caddy state.
How does caddy-docker-proxy compare to Traefik?
Traefik is a proxy built around dynamic providers, while caddy-docker-proxy is a plugin that generates a Caddyfile from Docker labels and reloads Caddy. The plugin keeps Caddy's directive set and automatic HTTPS; Traefik means adopting a separate routing model.
What is the difference between caddy-docker-proxy and plain Caddy?
Plain Caddy reads a Caddyfile you write and maintain. The plugin scans Docker metadata for caddy-prefixed labels and generates that Caddyfile in memory, updating it and triggering a graceful reload whenever a Docker object changes.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/lucaslorentz-caddy-docker-proxy)