Scale Idle Docker Containers to Zero with Sablier

Sablier stops containers nobody is using and starts them again on the next request. Here is the recorded Docker Compose install of Sablier 1.19.1, with a demo app that wakes from cold in about four seconds.

Scale Idle Docker Containers to Zero with Sablier

Sablier is a small Go server that stops idle containers and starts them again when someone asks for them. While a container wakes up, Sablier serves a themed waiting page and then hands the request off. It normally runs behind a reverse proxy (Traefik, Caddy, Nginx) or on Kubernetes, but the core loop is easy to see on plain Docker: a container that uses no RAM until the first request, and goes back to sleep after a set idle time.

This guide follows the exact path we recorded and checked: Sablier 1.19.1 on Docker Compose (Docker 29.9.2, Compose 5.6.1), using the project's own examples/docker demo with a small test app called whoami. No reverse proxy is involved, so you open Sablier's waiting page directly.

Source: github.com/sablierapp/sablier

Prerequisites

  • Docker Engine with the Compose plugin, version 2.24 or newer. The override file below uses the !override tag, which older Compose versions reject.
  • git, curl and ss (iproute2)
  • Free TCP port 10000 on 127.0.0.1. The steps below show how to check it.
  • Be aware that Sablier mounts /var/run/docker.sock. That gives it root-level control of the Docker host. It's fine for a local test. For anything longer-lived, look at examples/docker/socket-proxy.yml in the repo.

Install

Run every block in the same shell, because the variables from step 1 are used throughout.

1. Get the example files

git clone --depth 1 https://github.com/sablierapp/sablier.git ~/sablier
export EX=~/sablier/examples/docker
export DEPLOY=~/sablier-deploy
export PROJ=sablier

The two files we use from the repo are short. compose.yml runs Sablier with the Docker provider:

name: demo

services:
  sablier:
    image: sablierapp/sablier:1.19.1
    restart: unless-stopped
    command:
      - start
      - --provider.name=docker
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    ports:
      - "10000:10000"

whoami.yml adds the app Sablier will manage. The two sablier.* labels opt it in and put it in a group called whoami. The health check matters, because Sablier only reports the app as ready once the container is healthy.

services:
  whoami:
    image: sablierapp/mimic:v0.3.3
    command: ["-running-after=1s", "-healthy=true", "-healthy-after=2s"]
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "/mimic", "healthcheck"]
      interval: 1s
      timeout: 3s
      retries: 10
    labels:
      - sablier.enable=true
      - sablier.group=whoami

2. Preflight

docker info >/dev/null && echo docker_ok
docker compose version
ss -ltn | grep -q ':10000 ' && echo "10000 BUSY" || echo "10000 free"

You should see docker_ok, a Compose version of 2.24 or newer, and 10000 free. If the port is busy, change 10000 on the left side of the port mapping in the next step and use that port in every URL below.

3. Add an override file

This binds Sablier to localhost only (so the Docker-socket-backed API isn't on your LAN) and sets demo-friendly defaults: the hacker-terminal theme, a 2-second page refresh, and 2-minute sessions.

mkdir -p $DEPLOY && cat > $DEPLOY/override.yml <<'EOF'
services:
  sablier:
    ports: !override
      - "127.0.0.1:10000:10000"
    command: !override
      - start
      - --provider.name=docker
      - --strategy.dynamic.default-theme=hacker-terminal
      - --strategy.dynamic.default-refresh-frequency=2s
      - --sessions.default-duration=2m
      - --logging.level=debug
EOF
export COMPOSE_FILE=$EX/compose.yml:$EX/whoami.yml:$DEPLOY/override.yml
docker compose -p $PROJ config --services

Expected output: sablier and whoami. The extra flags are optional. Without them you get the default theme and 5-minute sessions.

4. Start Sablier, and create the app without starting it

This is the key idea. The app container is created but left off. Sablier starts it when the first request arrives.

docker compose -p $PROJ pull
docker compose -p $PROJ up -d sablier
docker compose -p $PROJ create whoami
docker compose -p $PROJ ps -a --format 'table {{.Service}}\t{{.State}}'

Expected:

SERVICE   STATE
sablier   running
whoami    created

5. Check that Sablier is healthy

curl -fsS http://127.0.0.1:10000/health; echo
curl -fsS http://127.0.0.1:10000/api/themes; echo

Expected:

OK
{"themes":["ghost","hacker-terminal","matrix","retro","shuffle"]}

http://127.0.0.1:10000/ returns 404. That's normal, because Sablier has no dashboard or web UI.

6. Smoke-test wake-on-demand

curl -s -o /dev/null -D - "http://127.0.0.1:10000/api/strategies/dynamic?group=whoami&session_duration=1m" | grep -i x-sablier-session-status
curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:10000/api/strategies/blocking?group=whoami&session_duration=1m&timeout=30s"
docker compose -p $PROJ stop whoami

The first call returns X-Sablier-Session-Status: not-ready, because Sablier has just started the container. The second call uses the *blocking* strategy, which waits until the app is ready, and returns 200 within a few seconds. The last command puts whoami back to sleep so the browser test starts cold.

Browser setup

There's nothing to log in to or click. Everything is set in the URL. Make sure whoami is stopped first (step 6 did that), then open:

http://127.0.0.1:10000/api/strategies/dynamic?group=whoami&display_name=My%20Sleepy%20App&theme=hacker-terminal&show_details=true&refresh_frequency=2s&session_duration=2m

That's the *dynamic* strategy page: the waiting page a reverse proxy would show your users while the app wakes. The query string sets the group to wake, the name to display, the theme, whether to list the container's status (show_details), how often the page refreshes, and how long the session lasts.

On first load you'll see the waiting page with the status line IS STARTING (0/1):

Sablier hacker-terminal waiting page showing the instance starting (0/1)

The page refreshes every 2 seconds. In our run the app went from stopped to healthy in about 4 seconds, and the status line changed to IS READY (1/1):

The same page once the instance is ready (1/1)

The headline still reads "Starting My Sleepy App…" in the ready state. Only the status line changes. Behind a reverse proxy, this is the point where the user is sent on to the app. When you open the page directly, it just shows the ready state.

To try other themes, stop the app again (docker compose -p $PROJ stop whoami) and change theme= in the URL to ghost, matrix, retro or shuffle:

Ghost theme

Matrix theme

Retro theme

If the app is already running when you open the URL, the page loads straight into the ready state and there's no transition to watch. Stop whoami before each try.

Verification

Watch the container while you load the page. In a second terminal:

watch -n1 "docker compose -p sablier ps -a whoami --format '{{.State}} {{.Status}}'"

You should see it go from exited, to running with health starting, to running (healthy).

Then leave it alone. After the session ends (2 minutes with no requests in this setup, and Sablier checks for expired sessions periodically, so allow up to about 20 seconds more), Sablier stops the container on its own. With debug logging on, the logs show it:

docker compose -p $PROJ logs sablier | tail -20

In our run, the last request was at 23:19:13Z. At 23:21:13Z Sablier logged instance expired, then stopping container, then container stopped exit_code=0, and whoami was exited again.

CheckExpected result
curl http://127.0.0.1:10000/healthOK
curl http://127.0.0.1:10000/api/themesghost, hacker-terminal, matrix, retro, shuffle
First load of the demo URL with whoami stoppednot-ready, status line IS STARTING (0/1)
A few seconds laterready, status line IS READY (1/1), container healthy
2 minutes with no requestsLogs show instance expired, container exited
curl http://127.0.0.1:10000/404 (Sablier has no dashboard)

Clean up

docker compose -p $PROJ down --remove-orphans
rm -rf $DEPLOY
# optional, to reclaim disk:
docker image rm sablierapp/sablier:1.19.1 sablierapp/mimic:v0.3.3

Nothing was installed on the host, and the stack uses no volumes, so down removes everything.

Troubleshooting

SymptomCauseFix
docker compose config fails on !overrideCompose is older than 2.24Update the Docker Compose plugin, or edit compose.yml directly instead of using the override file
Port 10000 is already in useAnother service holds the portChange the host side of the mapping in override.yml (for example 127.0.0.1:10010:10000) and use that port in every URL
curl /health is refusedSablier isn't running, or it bound to a different portdocker compose -p sablier ps and docker compose -p sablier logs sablier
Logs say cannot connect to docker hostThe Docker socket isn't mounted or isn't readableCheck that /var/run/docker.sock exists on the host and the volume line in compose.yml is intact
The URL returns 404 and the logs say Group not foundwhoami was never created, or its sablier.group label doesn't match group= in the URLRun docker compose -p sablier create whoami and check the labels in whoami.yml
http://127.0.0.1:10000/ returns 404Sablier has no dashboardUse /health or a /api/strategies/... URL
The page loads already ready, with no transitionwhoami was still running from an earlier requestdocker compose -p sablier stop whoami, then reload
The page stays IS STARTINGThe container never becomes healthydocker compose -p sablier ps whoami and docker compose -p sablier logs whoami. Sablier waits for the health check to pass
The page doesn't send you to the app when readyThere's no reverse proxy in this setupExpected here. Use one of the Traefik, Caddy or Nginx examples in the repo for the real hand-off
A theme name doesn't workThe name is misspelledList valid names with curl http://127.0.0.1:10000/api/themes
The app doesn't stop after 2 minutesRequests are still arriving (an open tab keeps refreshing), or the expiry check hasn't run yetClose the tab and wait up to about 20 seconds past the session duration

Assumptions & decisions

  • Source of truth. The steps follow validated-process.md (verdict PASS). Where it differs from install-plan.md, the validated version wins. One example: the plan expected a JSON error for a bad group, but the live run returned a plain 404 and logged Group not found, so the troubleshooting row describes that.
  • Reader-portable names. The validated run used the pipeline's compose project shortform-sablier-20261029-191103 and paths under runs/. For readers, the guide clones to ~/sablier, writes the override to ~/sablier-deploy, and uses the project name sablier. Commands, flags, file contents and image tags are otherwise identical to what ran. A side effect is that container names will be sablier-whoami-1 instead of the long name in the screenshots.
  • Port check simplified. The plan's loop that falls back through 10010/10020/10030 isn't in the guide. Port 10000 was free in the validated run, so the guide uses the validated one-line check and tells readers how to change the port by hand.
  • No reverse proxy. As in the validated run, readers open the dynamic-strategy page directly. The guide says plainly that the redirect into the woken app isn't shown, and doesn't claim it is.
  • Session expiry timing. The 2-minute stop was measured live. The "up to about 20 seconds more" for the expiry check comes from the install plan and wasn't measured separately.
  • Screenshots point to runs/sablier-20261029-191103/browser/, relative to the project root, which ghost-draft resolves and uploads. I used 01 and 03 for the not-ready and ready states, and the not-ready shots for the ghost, matrix and retro themes. 02-hacker-terminal-refreshing.png and the ready versions of the other themes are left out because they look almost the same as the shots that are in. ghost-draft picks final.png as the feature image by itself.
  • No pipe characters in table cells. The pipeline's Markdown converter splits cells on | even inside code spans, so no table cell contains a piped command.
  • Security note about the Docker socket and socket-proxy.yml comes from the install plan's reading of the repo. The socket proxy path wasn't tested.
  • Previous how-to (Norish) archived to runs/norish-20261029-175902/howto.prev.md.