Dockflow

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

compose.yml
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: {}
KeyTypeDefaultDescription
domainslist of strings–Required. Host names. A www. variant is a separate entry
portinteger–Required. The container port requests are sent to
pathstring/A path prefix: /api matches /api and /api/…. Starts with /
strip_prefixbooleanfalseRemove path from the request before sending it to the container
tlsbooleantrueWith 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/api goes to api, everything else on shop.example.com to web.
  • 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. is shop.example.com, and /api/ is /api. A second claim fails the deploy with plan.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 builtin edge, a request for a host no route declares is dropped; with the external edge, 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 uses stop-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.

ModeTLSWhat the switch listens onClient IP seen by the switch
builtin (default)Automatic HTTPS (Let's Encrypt)Ports 80 and 443 of the serverThe connection's
cloudflareAs builtin, behind Cloudflare's proxyPorts 80 and 443, open to Cloudflare's ranges onlyCF-Connecting-IP, from Cloudflare's ranges only
externalYour front proxyOne address, 127.0.0.1:8080 by defaultX-Forwarded-For, from trusted proxies only
none–Nothing: no switch is installed–

builtin

dockflow server init production

The 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 cloudflare

The 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-IP header, and trusts it only from those ranges. Your apps see the real client in X-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: set tls: false on the routes.
  • The ranges. Dockflow downloads Cloudflare's published lists at server init. It downloads them again every week with the dockflow-cloudflare systemd timer that server 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 external

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

/etc/nginx/sites-enabled/shop.conf
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/16

The complete setup, with TLS in nginx, is in Behind nginx.

none

dockflow server init production --edge none

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

OptionDefaultDescription
--edge <mode>the server's, or builtinHow traffic reaches the switch: builtin, cloudflare, external or none
--switch-listen <addr:port>127.0.0.1:8080With --edge external: the address the switch listens on. The host must be an IP address
--trusted-proxy <cidr>Private rangesWith --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.yThe 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.yThe image of the server-local registry, pinned by digest when installed
--acme-email <address>noneThe address Let's Encrypt writes to about the switch's certificates. "" removes it
--acme-ca <url>Let's Encrypt, then ZeroSSLAnother 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.

Edit on GitHub

On this page