Back

Waking Apps on Demand

By Pablo Fernandez15 min read

My Hetzner box runs thirteen containers on four cores and 7.6 GB of RAM. When I looked at it on a Thursday evening the load average was 0.3. Most of those containers were doing nothing at all, and some of them do nothing for days: a pilot site for a client, a demo, an API that gets a handful of requests when someone is actually testing it.

So the obvious question was whether to turn them off. The less obvious one, and the one this post is about, is how to turn an app off without the next visitor noticing that you did.

The honest answer first

Before building anything I measured what turning things off would buy. In docker stats the five app containers each sat between 0.1 and 0.5 percent CPU, and together they held about 930 MB of memory. A stopped container uses neither, so the ceiling on savings was roughly 0.9 GB of RAM and no CPU worth mentioning, on a machine with 6 GB free.

That is not worth a new moving part, and I said so. What changed my mind is the next tenant. Protomarc's watermarking workers load neural models and grow past a gigabyte each the moment they process something, and they spend most of their lives waiting for an upload. On that workload the arithmetic flips, and I would rather have the mechanism tested on a toy app before it matters.

Why stopping a container breaks it

Turning an app off is one command. The problem is what disappears with it.

Every app on the box is routed by Traefik, which builds its routes from container labels through its Docker provider. That provider only sees running containers. Stop one and its router is gone within seconds, so the hostname no longer matches anything. Behind Cloudflare that surfaces as a 526, because Traefik answers an unknown host with its self-signed default certificate and Cloudflare refuses to trust it.

visitor ──▶ Cloudflare ──▶ Traefik ──▶ ? │ │ docker provider ▼ ┌───────────────────────┐ │ app container │ │ stopped │ no router └───────────────────────┘
Stopping a container also deletes its route: the Docker provider forgets the labels of anything that isn't running.

The two obvious fixes don't work. docker pause keeps the route, because a paused container still counts as running, but it only freezes the processes: their memory stays allocated, which defeats the point. Keeping the Traefik labels somewhere static, a file per app, means the route survives but now points at a container that isn't listening, and the visitor gets a 502 instead of a 526.

What's missing is something that is always up, owns the route, and knows how to start the app when a request arrives.

The route lives in the thing that never sleeps

There is a mature project that does exactly this, Sablier, and if you run Traefik it is the first thing to try. I wrote my own because I wanted it to follow the label conventions the rest of my platform already uses (pfrctl deploys everything by label), and because I wanted to understand every line of code that holds the Docker socket on a machine that also runs other people's databases.

WOC (Wake On Call) is a single Go binary with no dependencies outside the standard library, and it's open source under MIT. It does two jobs. It serves Traefik's HTTP provider, a JSON document of routers that Traefik polls every five seconds, so the routes of sleeping apps come from WOC and not from the apps. And it is the reverse proxy those routes point at: it looks at the Host header, finds the container, starts it if needed, and forwards the request.

visitor ──▶ Cloudflare ──▶ Traefik ──────────▶ WOC :8080 ▲ │ │ routers │ docker start │ every 5 s │ then proxy │ ▼ WOC :8081 ┌───────────────┐ │ app container │ └───────────────┘
Traefik takes the routes from WOC, sends the traffic to WOC, and WOC starts the app container on demand.

Hooking it into Traefik is one block in the static configuration:

traefik.yml
providers:
  http:
    endpoint: http://woc:8081/traefik
    pollInterval: 5s

An app opts in with labels, and with no traefik.* labels at all, since WOC now publishes its route. Everything without woc.enable is invisible to it, which is how this stays an option for some apps rather than a policy for all of them:

docker-compose.yml
labels:
  woc.enable: "true"
  woc.host: woc.pablofr.com
  woc.port: "3000"
  woc.idle: 15m
  woc.middlewares: cloudflare-only@file

Those are the real labels of the demo at woc.pablofr.com, a small Vite app that shows when its container started. Leave it alone for fifteen minutes and the next visit wakes it up.

Containers can also share a woc.group, so a web app and the internal API it calls wake and sleep together, even though only the web app receives traffic.

Asleep, waking, awake

The whole lifecycle is four states and two clocks: how long since the last request, and how many requests are still open.

first request┌────────┐ ───────────────▶ ┌──────────┐│ asleep │ │ waking │└────────┘ └──────────┘ ▲ │ │ │ port accepts TCP │ ▼┌──────────┐ 15 min quiet ┌──────────┐│ stopping │ ◀───────────── │ awake │ ◀── requests└──────────┘ none open └──────────┘
A group wakes on its first request and only goes back to sleep after the idle window passes with nothing in flight.

A few details decide whether this feels invisible or flaky.

  • Concurrent wakes share one start: each group has a mutex around start and stop, so fifty requests landing on a sleeping app produce one docker start, and the other forty-nine wait for it.
  • A stop and a request can't race: the reaper flips the group to "not ready" before it calls docker stop, while holding that same mutex, so a request that arrives mid-stop waits for the stop to finish and then wakes the app again.
  • Open connections count as activity: a long download or a websocket keeps the app awake for as long as it stays open, and the idle clock starts when it closes.
  • Apps started from outside still go to sleep: a deploy or a CI run starts the container without any request. WOC notices on its next sync, gives it a full idle window from that moment, and stops it if nobody comes.
  • Restarting WOC doesn't wake anything up for longer: that same rule first applied to WOC's own restarts, since a new WOC sees every running app as just started. Three WOC deploys in twenty-six minutes kept the demo awake for an hour and a half with nobody on it. Each app's last activity now goes to the state file every fifteen seconds and on shutdown, and the next WOC picks the clocks up where the last one left them.
  • An app that dies is woken, not a 502: if a container crashes, or someone runs docker stop by hand, WOC still thinks it's awake, and the next request finds nothing listening. When the proxy can't connect, WOC asks Docker whether the container is still running. If it isn't, it wakes it and sends the request again, so the visitor never sees an error their browser or the app's client would have to retry. That includes a form POST: requests with a body go over a fresh connection, so a stopped app fails at the dial before a byte of the body has been read, and the body is streamed once, never buffered, to whichever attempt gets through.

The third point is where my first version was wrong, and a test caught it before the box did. Releasing an in-flight request looked like this:

main.go
defer g.release(e.now())

Go evaluates a deferred call's arguments when the defer statement runs, not when the function returns (the spec is explicit about it). So a request that started at noon and finished at half past one released itself with a timestamp of noon, and the app was put to sleep the instant a long download completed. The fix is a closure, which reads the clock at the end:

main.go
defer func() { g.release(e.now()) }()

Replaying requests is the kind of feature that turns into a way in, so it only happens when two things are true at once: the connection to the app never opened, and Docker confirms the container has stopped. A failed dial proves the app never saw the request, so a POST can't run twice. Docker's word rules out the case that looks the same from outside, a live app that is overloaded and refusing connections: that one gets a 502 at once, because holding requests for an app that's already up would let anyone pile them up. Writing the test for that turned up an older version of the same problem. Requests queued behind a wake that failed each waited out the whole start timeout again, one after another: fifty requests and a three-second timeout took two and a half minutes. Now they share the failed wake's error, and Docker is asked at most once a second however many requests fail.

Holding the socket without holding the machine

Starting and stopping containers means talking to the Docker API, and the Docker socket is root on the host. Traefik on this box already reaches Docker through docker-socket-proxy, an HAProxy front that allows only the API sections you switch on. I assumed WOC could use one with reads plus ALLOW_START and ALLOW_STOP, and the first real wake came back 403.

Reading the generated HAProxy config explained why. The very first rule denies every non-GET request unless POST=1, before the start and stop rules are even considered. And POST=1 combined with CONTAINERS=1, which listing containers needs, allows every write under /containers: create, delete, and exec. A single proxy can't say "read everything, start and stop only".

Two proxies can, because they split the permissions:

ProxySettingsWhat gets through
ReadCONTAINERS=1, POST=0listing and inspecting containers, no writes
ControlCONTAINERS=0, POST=1, ALLOW_START=1, ALLOW_STOP=1POST to /containers/<id>/start and /stop, and nothing else

The control proxy can't even list containers, so the only writes WOC can make are starts and stops. The read side is still a real permission, and I'd rather say so: inspecting a container returns its environment, so whoever controls WOC can read every container's variables, secrets included. Traefik's own socket proxy has the same exposure. Neither proxy can change anything, but both deserve to be treated as sensitive.

From a second to a quarter of one

The first wake I measured took 981 ms inside WOC. Repeating it on the box with a stopwatch on each step showed where it went:

StepTime
docker start returnsabout 230 ms
The Node app accepts connectionsanother 150 ms
Docker reports the container healthyanother 530 ms

More than half the wake was WOC waiting for Docker's healthcheck, which only runs on its own schedule, every start_interval during startup. The app had been ready for half a second by then. So WOC now treats an app as ready the moment its port accepts a TCP connection, probing every 25 ms, and an app that opens its port before it can really serve (running migrations, filling a cache) can opt back in with woc.ready: healthy.

After that change three wakes logged 301, 258 and 277 ms. From my laptop through Cloudflare, the first request to a sleeping app took between 0.38 and 0.47 seconds, against about 0.1 seconds once it's awake. What's left is mostly docker start itself.

A page for the slow ones

A small Node app wakes faster than anyone notices. A big one won't, so a browser that has waited two seconds gets a page instead of a spinning tab, while the start carries on in the background. It reloads itself every two seconds until the app answers. API calls and asset requests never see it: they just wait.

The page is deliberately plain. One line, set in Geist, light or dark from the system setting, in whichever of 32 languages the browser's Accept-Language asks for first (right to left for Arabic and Hebrew, English for everything else). Underneath, the node and a request id:

┌────────────────────────────────────────────────────────┐│ ││ We're waking up the server ... ││ ││ pablofr Wake On Call | srv01::01a1228f-92ed-76da-… │└────────────────────────────────────────────────────────┘
The waiting page: one localized line, and a footer naming the server and the request.

The id is a UUIDv7, whose first 48 bits are a Unix timestamp in milliseconds, and WOC writes the same id to its log. If someone sends me a screenshot of that page, the footer tells me which box served it and roughly when, and grepping the id finds the exact wake. Because a fast app means you would almost never see it, the page also lives at /.well-known/woc/preview on any host WOC manages, so you can see it on the demo in your own language.

The database sleeps too

A web app that sleeps next to a database that never does only saves half the memory, so the demo has its own Postgres, and WOC puts that to sleep as well. Databases are harder in one specific way: nobody reaches them through Traefik. The app opens a TCP connection straight to them.

So WOC has a TCP mode. A container labelled woc.tcp: "16432:5432" makes WOC listen on its own port 16432 and forward every connection to the container's 5432. The demo's web app connects to woc:16432 instead of the database's own name, which matters: a stopped container drops out of Docker's DNS, and giving WOC the same name would make DNS split connections between the two.

visitor ──▶ Traefik ──▶ WOC :8080 ──▶ ┌─────────┐ │ web │ └─────────┘ │ first query ▼ WOC :16432 ──▶ ┌────────────┐ │ Postgres │ └────────────┘
One visit wakes both: the request wakes the web app, and the web app's first query wakes Postgres.

A connection to a sleeping database doesn't fail, it waits: WOC starts the container and only forwards once Postgres accepts connections. Waiting for that turned out to be the same trap as before. Postgres opens its port before it can take queries, answering "the database system is starting up", and Docker's healthcheck only re-runs every half second. Measured on the box, Postgres was ready about 130 ms after docker start, and Docker called it healthy about 550 ms after. So woc.ready: postgres asks Postgres itself, every 25 ms: WOC sends the protocol's startup message, and an authentication request back means ready, while error 57P03 means not yet. It hangs up before authenticating, so it needs no password. That took a cold database from 750 ms to about 265 ms.

The page now shows its own history from that database: every wake, with how long Node took to listen and how long the first query took, and visits per country from Cloudflare's country header. Visitors never type anything that gets stored. The app writes as a role that can only insert and read, constraints reject anything that isn't a timing or a two-letter country code, and a trigger keeps only the newest 300 wakes, so the table can't grow without bound.

Two rules come with a sleeping database. An open connection counts as activity and WOC never cuts one, so the app's connection pool has to close idle connections well before the idle window (the demo's closes them after ten seconds), or the database never sleeps. And anything that runs docker exec into it, a backup above all, has to wake it first. For that WOC has holds: woc hold db 6h wakes the group and keeps it up until the job releases it or the hold expires, and the backup crons on this box now run through a small wrapper that does exactly that.

Where it isn't worth it

  • The savings are small today: about 0.9 GB of RAM on this box, and no CPU. If your idle apps are small Node or Go processes, the honest move is to leave them running.
  • Public sites wake constantly: crawlers, uptime monitors and link previews all count as visitors. WOC does leave out /robots.txt and /sitemap.xml, which crawlers fetch on their own schedule: they're served, a sleeping app is woken to answer and goes back to sleep a minute later, but they never extend the idle window. Everything else a crawler asks for is a page, and a page is a visit. I would not put pablofr.com behind it, since it would rarely sleep and the first reader after a quiet spell would pay the wake.
  • A sleeping worker finds work late: a container that pulls jobs from a queue never receives an HTTP request, so WOC has to learn about new work another way. The producer can call POST /wake/<group>, a Postgres NOTIFY can wake it, or WOC can ask the queue itself every woc.wake_check_every with a woc.wake_when probe (a SQL query, the length of a Redis list, a URL, or a Redis sorted set of times, for crons), and it won't stop a worker whose woc.busy_url says it's busy. That's how a client's web app, API and worker on this box now sleep as one group: a visit or an API call wakes the three, and while they sleep the worker's scheduler has left the time of its next cron in Redis, which WOC reads every thirty seconds. Its Postgres sleeps on its own, in TCP mode. What's left is the delay: a job that arrives with nobody calling WOC waits up to one check interval, thirty seconds by default.
  • There is a floor: around 230 ms of docker start that no proxy can remove. Getting under it means docker pause, which keeps the memory you were trying to free, or checkpointing processes with CRIU, which I'd trust on a demo and not on a client's app.
  • It's one more component on the critical path: if WOC is down, every app behind it is down too, awake or not. It's small, but it is still a dependency the apps didn't have before.

The trade

A stopped container costs nothing until someone wants it, and then it costs a quarter of a second. On my box today that buys almost nothing, which I knew before writing a line, and I would still build it again: the expensive part was never the start or the stop, it was keeping the route alive while nothing was listening, and that part is now solved for whichever app needs it next.

Sources

  1. WOC source code, MIT
  2. WOC demo app
  3. WOC waiting page preview
  4. Traefik: HTTP provider
  5. Sablier: start containers on demand, stop them when idle
  6. Tecnativa docker-socket-proxy
  7. Docker: HEALTHCHECK and start_interval
  8. The Go spec: defer statements
  9. RFC 9562: UUID version 7
  10. Geist typeface

Related articles

Building a Temporary Email Service

A temporary inbox takes mail from anyone, for anyone, with no account. How Buzontemporal runs on Mailingest: no attachments, a light path, hard limits.

The 21st Visitor Broke Live Mail

Buzontemporal's live inbox held one Mailingest stream per visitor, and Mailingest allows 20 per account. Why raising the cap wasn't the fix, and how Centrifugo was.

A Privacy Filter Below the Prompt

Telling an AI agent not to read an IBAN is only a request. In Sumalos the privacy filter sits in the one module every read passes through.