The switch and edges
The Caddy container Dockflow runs in front of every app, how routes and TLS work, and the edge modes that decide how traffic reaches it.
Every server that routes traffic runs one switch: a Caddy container managed by Dockflow, in front of every app on the server. It sends each request to the containers currently serving its route. During a deploy, it moves traffic from old containers to new ones without dropping a request.
The edge is how traffic reaches the switch: directly on ports 80 and 443, or through a proxy you already run.
The switch
dockflow server init installs the switch as a container named dockflow-switch. It runs the official Caddy image: by default, the caddy:2.x.y release that this version of Dockflow pins by digest.
- Routes come from your Compose files. A service declares the domains it serves under
x-dockflow.routes. The switch's configuration is the union of the routes of every app on the server. Each app's part changes only with its own deploys. - It reaches your containers on their networks. When an app with routes deploys, the agent connects the switch to the app's network, without restarting it. Your routed services do not need to publish any port.
- Its admin API is not on a network. It listens on a unix socket in a volume that only agent containers mount. App containers share networks with the switch, but cannot reach its configuration.
- It survives a reboot. Caddy saves each configuration it loads and resumes it when it starts. So the switch serves the same routes after a reboot, before any deploy.
Moving traffic without reloading
The switch is not reloaded when traffic moves, because a reload can close connections the proxy accepted shortly before.
Instead, each route's upstreams are small files that Caddy reads on every request. To switch a route to new containers, the agent rewrites those files atomically. The next request goes to the new containers, and requests in flight finish on the old ones while they drain.
The configuration is reloaded only when routes themselves change: a domain, a path or a number of replicas. A deploy that only changes an image does not reload it.
Replicas
A route with several replicas sends requests to each in turn. When a replica refuses a connection, the request is retried on another one, and that replica gets no requests for 10 seconds, so a container that crashed stops taking its share of the traffic while Docker restarts it. Errors the app answers itself, like a 500, never take a replica out.
Maintenance mode
dockflow maintenance on makes the switch answer an environment's routes with a maintenance page, while its services keep running. See maintenance.
Routes
services:
web:
build: .
deploy:
replicas: 2
x-dockflow:
routes:
- domains: ["${DOMAIN}", "www.${DOMAIN}"]
port: 3000
health:
http: { path: /healthz }
api:
build: .
command: ["./api"]
x-dockflow:
routes:
- domains: ["${DOMAIN}"]
path: /api
strip_prefix: true
port: 8000
health:
http: { path: /health }
status:
image: nginx:1.27-alpine
x-dockflow:
routes:
- domains: ["status.${DOMAIN}"]
port: 80
tls: false
health:
http: {}| Key | Type | Default | Description |
|---|---|---|---|
domains | list of strings | – | Required. Host names. A www. variant is a separate entry |
port | integer | – | Required. The container port requests are sent to |
path | string | / | A path prefix: /api matches /api and /api/…. Starts with / |
strip_prefix | boolean | false | Remove path from the request before sending it to the container |
tls | boolean | true | With the builtin edge: serve the route over HTTPS. false serves it over plain HTTP on port 80 |
- Several replicas share the traffic in turn (round robin).
- Longest path wins.
shop.example.com/apigoes toapi, everything else onshop.example.comtoweb. - Unique per server. A domain belongs to one app environment, and a (domain, path) pair to one service. Another app cannot route a path of a domain an app routes.
Shop.example.com.isshop.example.com, and/api/is/api. A second claim fails the deploy withplan.domain_conflict. - Host names only. A domain is a host name, a wildcard (
*.example.com) or an IP address, in ASCII: write internationalised names in punycode. - Unknown host names are refused. With the
builtinedge, a request for a host no route declares is dropped; with theexternaledge, it gets a 404. - WebSockets work without configuration.
- Not for TCP or UDP. A service that is not HTTP, such as a game server, publishes its ports with Compose
ports:instead, and usesstop-first.
A routed service must be on the app's bridge networks: network_mode: host and routes do not go together.
Route options (headers, encode, max_body, timeouts, error_pages, auth, raw caddy directives) are in x-dockflow.routes, and routes that do not target a service (redirect, proxy, files) under routes: of dockflow.yml.
TLS
With the builtin edge, the switch terminates TLS with Caddy's automatic HTTPS. For each domain of a route with tls: true, it:
- obtains a certificate from Let's Encrypt and renews it;
- redirects plain HTTP requests to HTTPS.
Certificates are kept in the dockflow-switch-data volume, so they survive a restart or a reinstall of the switch.
For this to work:
- each domain's DNS record points to the server;
- ports 80 and 443 of the server are reachable from the internet (the HTTP challenge uses port 80).
With the external edge, TLS is your front proxy's job, and the switch serves plain HTTP on one local address.
Edges
The edge is a server setting, chosen with dockflow server init --edge <mode>. It applies to every app on the server.
| Mode | TLS | What the switch listens on | Client IP seen by the switch |
|---|---|---|---|
builtin (default) | Automatic HTTPS (Let's Encrypt) | Ports 80 and 443 of the server | The connection's |
cloudflare | As builtin, behind Cloudflare's proxy | Ports 80 and 443, open to Cloudflare's ranges only | CF-Connecting-IP, from Cloudflare's ranges only |
external | Your front proxy | One address, 127.0.0.1:8080 by default | X-Forwarded-For, from trusted proxies only |
none | – | Nothing: no switch is installed | – |
builtin
dockflow server init productionThe switch publishes ports 80 and 443 (TCP, and UDP for HTTP/3) and is the only proxy on the server. Nothing else may listen on those ports.
cloudflare
For domains proxied by Cloudflare (the orange cloud):
dockflow server init production --edge cloudflareThe switch works as with builtin. In addition:
- The port filter opens ports 80 and 443 to Cloudflare's address ranges only. Nobody can reach your origin directly by its IP address, around Cloudflare.
- The switch takes the client's address from Cloudflare's
CF-Connecting-IPheader, and trusts it only from those ranges. Your apps see the real client inX-Forwarded-For.
Notes:
- SSL/TLS mode. With Cloudflare's Full (strict) mode, keep the routes' default
tls: true: the switch gets certificates from Let's Encrypt, whose HTTP challenge passes through Cloudflare. With Flexible, Cloudflare reaches the origin over plain HTTP: settls: falseon the routes. - The ranges. Dockflow downloads Cloudflare's published lists at
server init. It downloads them again every week with thedockflow-cloudflaresystemd timer thatserver init <admin>@<host>installs. If a download fails, the server keeps the ranges it has, or uses the list this version of Dockflow knows. - The port filter must be on. With
--no-port-filter, ports 80 and 443 are open to everyone.
external
For a server where another proxy already owns ports 80 and 443, such as the host's nginx, Traefik, HAProxy, a Cloudflare Tunnel or a load balancer:
dockflow server init production --edge externalThe switch listens on 127.0.0.1:8080, in plain HTTP. It routes by the Host header and the path, as with the builtin edge.
Configure your front proxy once: send every routed domain to that one address, and keep the Host header. Deploys never touch the front proxy: the switch moves the traffic behind it.
server {
listen 80;
server_name shop.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; # from a map block, see Behind nginx
}
}The switch trusts the X-Forwarded-For header only from the addresses in --trusted-proxy. By default these are loopback and the private ranges (127.0.0.0/8, ::1/128, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fd00::/8). The private ranges are needed because the front proxy reaches the published port through Docker, so the switch sees a bridge address rather than loopback.
--switch-listen changes the address:
dockflow server init production --edge external --switch-listen 127.0.0.1:9080 --trusted-proxy 172.17.0.0/16The complete setup, with TLS in nginx, is in Behind nginx.
none
dockflow server init production --edge noneNo switch is installed. Between deploys, nothing of Dockflow runs on the server except the registry, and not even that with --no-registry.
- Apps publish their own ports with Compose
ports:. - Services that publish fixed host ports use
stop-first, with a brief downtime on each deploy. - A deploy of an app that declares routes is refused with
plan.routes_without_switch.
Moving a server to none is refused while apps on it still have routes (server.routes_in_use).
server init
server init has two forms:
dockflow server init <admin>@<host>prepares a server (Docker, the deploy user and its key), then installs the switch and the server-local registry.dockflow server init [<env>]installs or updates the switch and the registry on the server of an environment, reached as a deploy reaches it.
It is safe to run again: a container whose settings did not change is kept. See the CLI reference.
| Option | Default | Description |
|---|---|---|
--edge <mode> | the server's, or builtin | How traffic reaches the switch: builtin, cloudflare, external or none |
--switch-listen <addr:port> | 127.0.0.1:8080 | With --edge external: the address the switch listens on. The host must be an IP address |
--trusted-proxy <cidr> | Private ranges | With --edge external: CIDRs whose X-Forwarded-For is trusted. Repeat it, or separate CIDRs with commas |
--switch-image <image> | this version's caddy:2.x.y | The Caddy image of the switch, pinned by digest when installed |
--no-registry | – | No server-local registry: every built image goes to registry.url |
--registry-image <image> | this version's registry:3.x.y | The image of the server-local registry, pinned by digest when installed |
--acme-email <address> | none | The address Let's Encrypt writes to about the switch's certificates. "" removes it |
--acme-ca <url> | Let's Encrypt, then ZeroSSL | Another ACME directory, such as Let's Encrypt's staging for a test server. "" goes back to the default |
To change the edge of a server, run dockflow server edge <env> <mode>, or server init again with another --edge. The switch container is replaced, which interrupts traffic for a moment, and every app's routes are loaded into the new one.
dockflow server upgrade moves the switch to the Caddy image of the CLI's version. dockflow server status shows the edge and the switch's state.
Rollouts
How Dockflow replaces the containers of a service, the three strategies and how each service gets one, health gating, draining, timeouts and what happens on failure.
Configuration
How a Dockflow project is configured, with dockflow.yml, Compose files, environments, variables, secrets and the two interpolation syntaxes.