
# Traefik with the http provider

> Traefik v3 polls a node for dynamic configuration Scafo renders from the fleet's routing. No plugin, no Docker labels, no push.

## Static configuration

A Traefik provider is **static** configuration, read once at startup. It cannot be declared from a dynamic directory.

::code-group

```yaml [traefik.yml]
providers:
  http:
    endpoint: "http://scafo-node:7400/v1/ingress/routes/traefik?prefer=self"
    pollInterval: 5s
    headers:
      Authorization: "Bearer <SCAFO_INGRESS_TOKEN>"
```

```sh [CLI arguments]
--providers.http.endpoint=http://scafo-node:7400/v1/ingress/routes/traefik?prefer=self
--providers.http.pollInterval=5s
--providers.http.headers.Authorization=Bearer <SCAFO_INGRESS_TOKEN>
```

::

| Setting | Meaning |
|---|---|
| `endpoint` | The node's Traefik-format routing route. Reach it over loopback, a private network or the tunnel. |
| `pollInterval` | How often Traefik refetches. `5s` matches the node's reconcile cadence. |
| `headers.Authorization` | `Bearer <token>` — the ingress bearer token, the only credential Traefik holds. It never sees bucket credentials. |

::warning
Because the provider is static, the token is written into whatever file or argument list declares it. Treat that file as a secret. On Kubernetes, `$(VAR)` expansion in container arguments keeps the token in a Secret instead.
::

## `?prefer=`

| Query | Effect |
|---|---|
| `?prefer=self` | The node being asked heads every route. Use it for a Traefik colocated with a node. |
| `?prefer=<node-id>` | That node heads every route. |
| *(omitted)* | The fleet's own order; every request is spread over every upstream. Use it for a Traefik in front of the whole fleet. |

With a preferred node that has peers, the renderer emits a **failover** service: the preferred node's upstreams while healthy, the others only when they are not. That is more than an ordering, which is why the format needs the node's name rather than just the order.

## EntryPoints

Scafo's routers must attach to entryPoints Traefik actually defines. A router attached to an undefined entryPoint matches nothing.

| Setting | Flag | Environment | Config key | Default |
|---|---|---|---|---|
| EntryPoints | `--traefik-entrypoints` | `SCAFO_TRAEFIK_ENTRYPOINTS` | `ingress.entryPoints` | `web` |

Repeat `--traefik-entrypoints` for several entryPoints; `SCAFO_TRAEFIK_ENTRYPOINTS` separates them with commas, and the config key holds a JSON string array. An empty list is rejected. The node prints the entryPoints its routers attach to at boot.

## What the node renders

For a fleet serving `shop.apps.example.com` on nodes `vps-a` and `vps-b`, with `prefer=vps-a`:

```json
{
  "http": {
    "routers": {
      "scafo_shop.apps.example.com": {
        "rule": "Host(`shop.apps.example.com`)",
        "service": "scafo_shop.apps.example.com",
        "entryPoints": ["websecure"],
        "middlewares": ["scafo_retry"]
      },
      "scafo_apideny_apps.example.com": {
        "rule": "Host(`apps.example.com`) && (Path(`/v1/storage`) || PathPrefix(`/v1/storage/`) || Path(`/v1/ingress/routes`) || PathPrefix(`/v1/ingress/routes/`))",
        "service": "noop@internal",
        "entryPoints": ["websecure"],
        "priority": 3
      },
      "scafo_api_apps.example.com": {
        "rule": "Host(`apps.example.com`) && !Path(`/v1/storage`) && !PathPrefix(`/v1/storage/`) && !Path(`/v1/ingress/routes`) && !PathPrefix(`/v1/ingress/routes/`)",
        "service": "scafo_api_apps.example.com",
        "entryPoints": ["websecure"],
        "middlewares": ["scafo_retry"],
        "priority": 2
      }
    },
    "services": {
      "scafo_shop.apps.example.com": {
        "failover": {
          "service": "scafo_shop.apps.example.com_local",
          "fallback": "scafo_shop.apps.example.com_peers"
        }
      },
      "scafo_shop.apps.example.com_local": {
        "loadBalancer": {
          "servers": [{ "url": "http://10.83.0.1:24417" }],
          "healthCheck": {
            "path": "/.well-known/celld/health",
            "interval": "10s",
            "timeout": "3s",
            "hostname": "shop.apps.example.com"
          }
        }
      },
      "scafo_shop.apps.example.com_peers": {
        "loadBalancer": {
          "servers": [{ "url": "http://10.83.0.2:24902" }],
          "healthCheck": {
            "path": "/.well-known/celld/health",
            "interval": "10s",
            "timeout": "3s",
            "hostname": "shop.apps.example.com"
          }
        }
      }
    },
    "middlewares": {
      "scafo_retry": { "retry": { "attempts": 3 } }
    }
  }
}
```

### Naming

| Object | Name |
|---|---|
| Application router and service | `scafo_<host>` |
| Subdomains of a custom domain claimed with them | `scafo_wild_<base>` |
| Platform API router and service | `scafo_api_<domain>` |
| Platform API denial router | `scafo_apideny_<domain>` |
| Failover halves | `<key>_local` and `<key>_peers` |
| Retry middleware | `scafo_retry`, `retry.attempts: 3` |

The `scafo_` prefix uses an underscore, which cannot occur in a host, so these names never collide with an operator's own routers in other providers.

### Subdomains of a custom domain

A custom domain [claimed with its subdomains](/docs/custom-domains#claiming-the-subdomains-too) reaches the document as one wildcard entry, and the renderer writes it as a `HostRegexp` router matching exactly one label under the base:

```json
"scafo_wild_shop.example": {
  "rule": "HostRegexp(`^[^.]+\\.shop\\.example$`)",
  "service": "scafo_wild_shop.example",
  "entryPoints": ["websecure"],
  "middlewares": ["scafo_retry"],
  "priority": 1
}
```

The priority is **1**, below every exact `Host` router this document writes and below the API routers too, so a name somebody claimed exactly always wins over the wildcard enclosing it. It has to be stated: Traefik's default priority is the *length* of the rule, which would put a long pattern ahead of a short exact host — precisely backwards for a fallback.

The service is built like any other, and its health check carries the base itself as `hostname`, never the pattern that stands for every name under it.

### Health checks

| Service kind | Path | Interval | Timeout |
|---|---|---|---|
| Application | `/.well-known/celld/health` | `10s` | `3s` |
| Platform API | `/.well-known/scafo/health` | `10s` | `3s` |

Application checks carry `hostname` set to the route's host.

### Denied paths

The denial is a **separate router at priority 3**, matching the denied prefix exactly or followed by `/`, and pointing at Traefik's internal `noop@internal` service so it is active without dialling anything. The API router sits at priority 2 and negates the same rules, so a denied path can never reach it.

Both `/v1/storage` and `/v1/ingress/routes` are rendered this way, at every platform API host.

### An empty fleet

When the document has no routers at all, the node renders `{}` — the `http` key is omitted entirely, which withdraws every route from this provider. Empty maps would instead make Traefik retain a stale configuration, which is why they are never emitted.

Before a node's first reconcile pass completes, the route refuses with `503` and Traefik keeps its previous configuration and logs the error.

## TLS

Scafo's routers carry no TLS block. Termination is Traefik's:

| Setup | How |
|---|---|
| ACME on an entryPoint | Give the entryPoint a default `http.tls.certResolver`; Scafo's routers inherit it. |
| Kubernetes | Point the entryPoint at a certificate held in a Secret, or let cert-manager issue the platform wildcard. |
| Per-router resolvers already in place | Operator routers that set `tls.certresolver` themselves still win; nothing already deployed changes. |

::note
Traefik does not use the node's `/v1/storage` surface. That is Caddy's certificate store; Traefik keeps its own.
::

## Recipes

::read-more{to="/deploy/k3s"}
K3s and RKE2: customise the bundled Traefik with a `HelmChartConfig`.
::

::read-more{to="/deploy/coolify"}
Coolify: merge the provider into the proxy's compose arguments.
::

::read-more{to="/deploy/dokploy"}
Dokploy: add `http` to the existing `providers` mapping in `traefik.yml`.
::
