guest@6admin.io:~$

Scale-to-zero: Sablier wakes Swarm and Kubernetes on demand

A service you consult once a week runs one hundred percent of the time. You installed it for a single need, a QA you open on Monday, a team wiki, an admin tool you only reach for during an incident. It permanently holds its share of RAM, its share of CPU, its ports, its slot in monitoring and its stream of security updates. Shutting it down settles all of it at once, except that you then have to bring it back by hand the moment you need it and nobody remembers the command. Scale-to-zero is the idea of starting a service only when someone asks for it and stopping it when nobody uses it anymore, without the user having to know it was asleep. Sablier is a small API server you place next to the reverse proxy: the proxy intercepts each request, calls the API to know whether the target is asleep, wakes it if needed and shows a waiting page while it starts. Once the target is ready the request goes through and a session opens, closed after sessionDuration, at which point Sablier shuts the workload down.

Scale-to-zero is not autoscaling

The two are neighbours and yet different. Horizontal autoscaling adds or removes replicas according to load and that is the job of the Kubernetes HPA driven by metrics or of KEDA driven by events. Scale-to-zero goes further, it drops to zero replicas and truly shuts the workload down when nobody uses it anymore. The HPA cannot go to zero, it needs at least one replica to have metrics to read. KEDA can do it on events, a queue filling up, a scheduled task and it can even do it on HTTP traffic through a dedicated add-on, but that path is reputed to be finicky, with its false 502s while the target starts and its settings that never end. Sablier takes a different path, it does not watch the load, it watches the front door: the reverse proxy intercepts the request, wakes the target, then serves it. That distinction matters for picking the tool, but it also matters for reading the bill, since a stopped service costs nothing while an idle service at one replica already costs its RAM and its CPU. And where autoscaling is a Kubernetes topic, Sablier’s scale-to-zero works just as well on plain Docker, on Swarm, on Podman or on LXC, which is precisely its appeal when you have no cluster and want none.

Sablier in three sentences

Sablier is a small API server you place next to the reverse proxy. The proxy intercepts each request, calls the API to know whether the target is asleep, wakes it if needed and shows a waiting page while it starts. Once the target is ready the request goes through and a session opens, renewed on every hit and closed after sessionDuration, at which point Sablier shuts the workload down. One point is not negotiable: the target must expose a healthcheck. It is the only way for Sablier to tell a started container from a container ready to receive traffic and without it it wakes into the void or believes ready what is not ready yet. The rest is a matter of strategy and provider.

The two strategies

Two kinds of waiting share the work and it is the consumer that decides, a human in front of a page or a program expecting a response.

The dynamic strategy is the wait with a page: it returns a waiting page right away, a theme that refreshes itself and switches to the service as soon as it answers, that is the behaviour you expect from a site and it is the right choice when a human is browsing, with built-in themes that are largely enough.

The blocking strategy is the wait with no page: it keeps the connection open and makes the client wait until the target is ready, serving nothing in between, which is perfect for an API call, a probe or a client that cannot display a page, provided you set a reasonable timeout.

One setting saves the day, ignoring certain agents: uptime monitors that hit every minute must not wake the stack for nothing. You list the patterns to ignore and their requests get an immediate 200 without starting anything, which keeps an UptimeRobot from holding the service warm around the clock. And for the cases where Sablier itself goes down, the failOpen flag decides whether to let the request through to the backend or return an error, a trade-off between availability and consistency worth choosing knowingly.

Which provider

Sablier speaks to six different engines and the choice is one line of configuration. The column that matters for this house is the last one, what each provider has to be allowed to do on the host machine.

ProviderWhat it drivesAccess needed
dockerlocal Docker containerswrite access to the Docker socket, compose compatible
swarmSwarm services from a managermanager socket, replicas to zero
podmanPodman containersuser socket, works rootless
kubernetesDeployments and StatefulSetsK8s API scoped by RBAC, in-cluster or not
proxmox_lxcProxmox LXC containersProxmox API by token
systemdsystemd unitsD-Bus, system or user units

[edit: 2026] An official Helm chart has since come to install the server in a cluster, with the ServiceAccount and ClusterRole that go with it.

Traefik, the real config

On the Traefik side there are two pieces: the module to load in the static config and the middleware to define in the dynamic config. At the time the module still lived under the author’s account and loads in three lines.

experimental:
  plugins:
    sablier:
      moduleName: "github.com/acouvreur/sablier"
      version: "v1.2.0"

The middleware tells Traefik where to reach Sablier, which group the target belongs to, how long to keep the session open and which agents to ignore.

http:
  middlewares:
    my-sablier:
      plugin:
        sablier:
          sablierUrl: http://sablier:10000
          group: my-app-group
          sessionDuration: 1m
          ignoreUserAgent:
            - curl
            - "(?i)uptimerobot"
          dynamic:
            displayName: My Services
            theme: hacker-terminal
            refreshFrequency: 5s

The trap that gives the article its teeth is right there: when a target drops to zero, Traefik no longer sees its container, hence no longer its labels, hence no longer its routing rules and the request never reaches the middleware meant to wake it. At the time the only way out was to move routing out of the labels and into a file provider, a file loaded separately that Traefik always keeps at hand. The problem remains whole on the Swarm and Kubernetes sides, where you must respectively centralise the middleware on a service that never stops and enable support for empty services.

[edit: 2026] Since Traefik v3.6 the Docker provider can register stopped containers with the relevant announcement option, which makes the labels usable even at zero replicas, but the problem remains whole on the Swarm and Kubernetes sides.

Swarm

On Swarm the trap is nastier: as soon as a service drops to zero replicas, Traefik evicts it from its pool and its routing labels vanish with it. The fix is to define the middleware on a service that never stops, typically Sablier itself and to keep on the target only what it needs to announce itself and be reached.

whoami:
  image: acouvreur/whoami:v1.10.2
  deploy:
    replicas: 0
    labels:
      - sablier.enable=true
      - sablier.group=my-group
      - traefik.enable=true
      - traefik.docker.lbswarm=true
      - traefik.http.routers.whoami.rule=Host(`whoami.example.org`)
      - traefik.http.routers.whoami.middlewares=whoami-sablier@swarm

The blocking strategy is also discouraged here because of how Traefik evicts services with no replicas, so on Swarm you stay dynamic. It is a bit the story of that Swarm hardening: the platform does its job well as long as you do not ask it what it does not know how to do.

Kubernetes

On Kubernetes the contract is more direct: Sablier talks to the API, takes Deployments and StatefulSets from zero to one and back down, provided you give it a scoped role and opt in by labelling the workload.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: whoami
  labels:
    sablier.enable: "true"
    sablier.group: mygroup
spec:
  replicas: 0

[edit: 2026] The keys have since moved to the sablierapp.dev/enable and sablierapp.dev/group domain, following the project’s move under the sablierapp organisation.

One detail matters: the key that enables discovery must be a label, because Sablier filters workloads server-side with a selector, while the other keys can be annotations, which is even necessary when the value contains a comma or a colon, like a time range. Readiness is not configured, Kubernetes already has the Pod probes and Sablier considers the workload ready as soon as the current replicas meet the wanted replicas, so the only thing to do on the Traefik side is to allow services with no endpoints, otherwise the route disappears before the wake even begins. The blocking strategy is not supported here yet, you stay dynamic.

The price of the socket

There is a bill the demo does not show and that this blog is in the habit of presenting: to stop and start containers, Sablier has to talk to the Docker socket and that dialogue is not read-only. Starting a container is a write, stopping a Swarm service is a write, so the access you must give it is access able to act on the engine, which, on a given machine, amounts to de facto access at the host level. It is not fate though. You can hand it a socket proxy that exposes only the bare minimum, the read routes of the containers and the start and stop calls. The read-only Docker socket explains the mechanics and its limit. You can also pick a provider that does not have that power: Kubernetes gives it an API scoped by a ServiceAccount and RBAC, Podman is happy with a user socket because it runs unprivileged, as Docker rootless details. Swarm narrows the surface to a single manager. The topic comes back every time you plug a tool into the engine and the right question is not how to avoid it but what is the smallest privilege that lets the tool do its job.

Stop or throttle

[edit: 2026] The scale mode described in this section, which throttles the workload instead of stopping it, arrived much later (after v1.10.0, mid-2025); at the time only the stop/restart option existed.

The default behaviour is to stop the workload and restart it on the next call, which means a cold start equal to the target’s startup time. For a light service it is imperceptible, for a large database it is a deal-breaker and Sablier offers an alternative: scale mode keeps the workload alive but throttles it. You declare idle resources and active resources and between the two nothing restarts.

labels:
  - "sablier.enable=true"
  - "sablier.group=myapp"
  - "sablier.idle.replicas=1"
  - "sablier.idle.cpu=0.1"
  - "sablier.idle.memory=64m"
  - "sablier.active.replicas=1"
  - "sablier.active.cpu=2.0"
  - "sablier.active.memory=512m"

There is also plenty to keep a machine on a leash. Anti-affinity makes a workload back off when another group is active, which avoids running two heavy GPU or RAM consumers at the same time. A time range bounds the moments a target can be woken. A flag sets everything to zero at Sablier startup, handy after a reboot. Webhooks notify a monitor, a pipeline or an event bus on every start and stop.

[edit: 2026] The server has since exposed a metrics endpoint for Prometheus and OpenTelemetry traces, which avoids guessing what happens at night.

What it costs and where it bites

The price in use is tiny: the project quotes around 1.5 to 2 ms of added latency at steady state, warm session, for a throughput of several thousand requests per second on a single core. In practice the latency that matters is not Sablier’s but your container’s, since the cold start is the target’s startup time and Sablier can do nothing about it. What bites is the target itself. Without a healthcheck, Sablier does not know when the workload is ready and serves either too early or too late, so a container without a readiness probe is a poor candidate. The waiting page refreshes itself and works everywhere, but mobile browsers sometimes act up with background tab refreshes, nothing blocking but good to know. Finally, sessions and their expiration have their own clock, with a default duration and a check interval you tune to the CPU you are willing to burn, the shorter the interval, the more you pay for nothing.

Where it comes from

The project was not born yesterday. The Sablier repository was created on October 20, 2020, the first commits date from the end of October that same year and the first published release, v1.2.0, arrives on November 18, 2022. That is the base I use for this post.

[edit: 2026] The project has grown a lot since. v1.3.0 followed on March 10, 2023 and the reference post of the time, Docker scale-to-zero with Traefik and Sablier , written on August 20, 2023 with the image acouvreur/sablier:1.3.0 and Traefik v2.10, on a public demo still readable today. The repository moved under the sablierapp organisation, the Traefik plugin was split into its own repository on October 2, 2024 and the current version is v1.18.0. The configuration followed the move and the old post no longer applies as is. Where the 2023 demo listed targets with a names field, we now talk about group and where it loaded a github.com/acouvreur/sablier module, we load github.com/sablierapp/sablier-traefik-plugin. Two strings to change, or nothing starts.

The verdict

Sablier is the right tool when the load is light and intermittent: a QA you open a few hours a week, an admin tool, a RAM-constrained homelab where every sleeping service is a service given back, a demo you do not want to pay by the hour. That is where it earns its keep, because it turns an estate of always-running services into an estate that only consumes when you call on it. It is not the right tool for real, continuous load, for a target that takes minutes to start without a reliable healthcheck, or for a monitor that must see the service always up, in those cases the cold start or the shutdown costs more than the resource saved. Nor is it a pretext to give a container unlimited access to its engine and that is perhaps the real lesson of the story: the elegance of scale-to-zero does not excuse you from deciding what a tool is allowed to touch.