Ondemand
On-demand services
With on-demand services, your app starts on its first request and stops again after it has been idle for a while (scale-to-zero). Its database, cache and other services stop with it. This is useful on a development machine or a homelab with several apps: an app nobody uses doesn't hold on to memory.
On-demand is on by default for the development and production presets. It only uses systemd and Quadlet. Nothing else runs on the host.
Installing
Render the presets and install the socket as well as the app:
php artisan podman:generate development # or production
php artisan podman:generate ondemand
lpod install development/app.quadlets --replace
lpod install ondemand/my-app-ondemand.socket --replace
lpod idle enable my-app
lpod installcopies the socket and its proxy service to~/.config/systemd/user/and enables the socket.lpod idle enableturns on the idle check, which lets the database and cache sleep too. It needslpodv2.2.0 or later.- On a server, run
loginctl enable-lingeronce, so the socket keeps listening after a reboot without logging in. Thelpodinstaller offers to do this. - If you use the
proxypreset, regenerate and reinstall it too. Caddy then sends app traffic to the socket instead of the container.
How it works
proxy ──▶ my-app-ondemand.socket (always listening, no process)
│ first connection
▼
my-app-ondemand.service (systemd-socket-proxyd, exits when idle)
│ starts, and waits until healthy
▼
my-app.container (published on 127.0.0.1:18000, stops when unneeded)
- A systemd socket listens on port
8000. Nothing runs yet. - The first connection starts
systemd-socket-proxyd, which starts the app container. - The app reports ready once
/upanswers. The first request waits for this, so it doesn't fail while Octane boots. - After
idle_timeoutwithout connections, the proxy exits. Nothing needs the app anymore, so systemd stops it, along with its sidecars.
Configuration
| Config key | Env variable | Default | Description |
|---|---|---|---|
ondemand.enabled |
PODMAN_ONDEMAND_ENABLED |
true |
Let the app and its services sleep |
ondemand.listen |
PODMAN_ONDEMAND_LISTEN |
8000 |
Where the socket listens, e.g. 8000 or 192.168.1.10:8000 |
ondemand.port |
PODMAN_ONDEMAND_PORT |
18000 |
The loopback port the app is published on, for the socket proxy |
ondemand.idle_timeout |
PODMAN_ONDEMAND_IDLE_TIMEOUT |
10min |
How long the app may be idle before it stops |
If you run several apps on one host, give each its own listen and port.
To keep the app and all its services running all the time, turn on-demand off. The proxy preset then sends traffic straight to the app container:
PODMAN_ONDEMAND_ENABLED=false
What keeps running
| Service | development |
production |
|---|---|---|
| Vite, Inertia SSR | Stop with the app | Stop with the app |
| Queue worker, Horizon | Stop with the app (or keep running) | Start at boot, stop once no jobs are left |
| Scheduler | Stops with the app | Runs schedule:run every minute while the app is awake |
| Database, cache, Reverb, Mailpit and other services | Sleep once nothing needs them | Sleep once nothing needs them |
Sleeping services
The database, cache and other services stop once nothing needs them: the app is asleep and no jobs are left. Nothing runs until the next request.
Each of these services has StopWhenUnneeded=yes. systemd stops such a service as soon as no running unit Requires= or Wants= it. The order is:
- The app goes idle and stops, along with its sidecars.
- The idle check stops the queue workers once no jobs are left, and the
productionscheduler timer. - Nothing needs the database and cache anymore, so they stop too.
- The next request starts the app. The app starts the services, workers and scheduler timer it needs, and waits until the database and cache are healthy before it connects.
Containers talk to each other directly, not through the on-demand socket. A service only stops once nothing needs it, so a query from the app or a worker never reaches a stopped database.
Every service must be needed by something
A service with StopWhenUnneeded=yes that nothing Requires= or Wants= stops right after it starts. The app Requires= the database and cache. For any other service you use, such as rustfs, typesense or meilisearch, publish the preset and add it to the app's Wants= line (see Customizing):
Wants={{application}}-mailpit.container {{application}}-queue.container {{application}}-schedule.container {{application}}-rustfs.container
Wants= starts the service with the app, but the app doesn't wait for it. If the first request needs it, such as media from rustfs or search results from typesense, add it to the app's After= line too. The app then waits until the service is healthy, which makes waking up a bit slower:
After={{application}}-pgsql.container {{application}}-valkey.container {{application}}-rustfs.container
Add the service to a kept-running worker's Wants= and After= lines too if its jobs use it.
Keeping one service running
To keep a single service running, such as the database for an external client, publish the preset and set StopWhenUnneeded=no in that service's quadlet. To keep everything running, set PODMAN_ONDEMAND_ENABLED=false.
The idle check
Queue workers that keep running while the app sleeps would keep the database and cache awake too. The idle check stops them once there's no work left. There are two ways to run it. Pick one, not both:
- With
lpodv2.2.0 or later, turn it on per app withlpod idle enable. See below. - Without lpod, install the
ondemandpreset's timer. See Without lpod. laravel-installer does this for you.
With lpod:
lpod idle enable my-app # run the check every minute
lpod idle my-app # run it once
lpod idle disable my-app
journalctl --user -u lpod-idle@my-app # see what it did
Every minute, while the app is asleep, the check:
- Skips this minute if a scheduled
schedule:runis still running, so the jobs it dispatches still find a worker. - Runs
php artisan podman:idlein each running queue worker or Horizon container. If the app has work in progress, it stops here and keeps everything running. A worker that isn't running is skipped. - Checks that the app is still asleep, in case a request woke it during the check.
- Stops the workers, and the scheduler timer in
production.
The next request starts the workers again through the app's Wants= line. That line lists queue by default. If you use Horizon, put horizon there instead.
The check does nothing when the app's on-demand socket isn't installed. If podman:idle fails or doesn't exist, the workers keep running. This happens, for example, when the package was installed with --dev and the production image leaves it out.
Stopping another worker
To have the check look at another worker too, such as my-app-imports, open a drop-in with systemctl --user edit lpod-idle@my-app.service and add:
[Service]
Environment=LPOD_IDLE_WORKERS=imports
Without lpod
The ondemand preset renders the same check as a timer, my-app-idle.timer, with its my-app-idle.service. They only use systemctl and podman exec, so they work on hosts without lpod. Copy both to ~/.config/systemd/user, then enable the timer:
cp podman/ondemand/my-app-idle.timer podman/ondemand/my-app-idle.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now my-app-idle.timer
It checks the app's queue and horizon workers. To check another one, publish the ondemand preset and add its lines to systemd/idle.service, next to the ones for queue.
Don't run it next to lpod idle enable. To move to lpod later, run systemctl --user disable --now my-app-idle.timer, then lpod idle enable my-app.
What counts as work in progress
podman:idle decides whether the app is busy. It runs a check for each thing your app uses, based on its config:
| Check | Runs when | Busy while |
|---|---|---|
queue |
QUEUE_CONNECTION isn't sync or null |
Jobs are waiting, running or delayed, on the default queue or any Horizon supervisor's queues |
database |
DB_CONNECTION is set |
Another client runs a query or holds a transaction open (PostgreSQL, MySQL, MariaDB), or an operation runs on the app's MongoDB database. Idle connections, like a worker waiting for jobs, don't count |
scout |
SCOUT_DRIVER is a search engine, like typesense or meilisearch |
Indexing jobs are queued, or Meilisearch is still indexing in the background |
A long job keeps the whole stack awake until it's done. Delayed jobs count too, so they run on time.
Running specific checks
Pass --services to run only some checks. An unknown name fails, so a typo keeps the workers running rather than stopping them:
php artisan podman:idle --services=queue,database
Choosing the checks
The checks are listed in config/podman.php. Publish the config with php artisan vendor:publish --tag="podman-config" to add or remove checks:
'idle' => [
'checks' => [
QueueCheck::class,
DatabaseCheck::class,
ScoutCheck::class,
],
],
To change how a check works, such as which queues it watches, register the checks in a service provider's boot() method instead. These replace the config list, so include every check you want:
use Foxws\Podman\Support\Idle\Checks\DatabaseCheck;
use Foxws\Podman\Support\Idle\Checks\QueueCheck;
use Foxws\Podman\Support\Idle\PodmanIdle;
app(PodmanIdle::class)->checks([
QueueCheck::new()->connection('redis')->queues(['default', 'media']),
DatabaseCheck::new(),
]);
Writing your own check
Extend IdleCheck, give it a name(), and return an IdleResult from run(). Override isEnabled() to skip the check when your app doesn't need it:
use Foxws\Podman\Support\Idle\IdleCheck;
use Foxws\Podman\Support\Idle\IdleResult;
class ImportCheck extends IdleCheck
{
public function name(): string
{
return 'imports';
}
public function run(): IdleResult
{
$running = Import::query()->whereNull('finished_at')->count();
return $running === 0
? IdleResult::idle()
: IdleResult::busy("{$running} imports running");
}
}
Keeping the queue worker running
In development, the queue worker and Horizon stop with the app. A job that's still running gets 60 seconds (TimeoutStopSec=) to finish before it's killed, and is retried on the next start. In production, the workers already keep running.
To keep the development worker running while the app sleeps, for example for long imports or media processing:
-
Publish the preset:
php artisan podman:publish development -
In
containers/stubs/development/quadlets/queue.quadlets(orhorizon.quadlets), replace the app dependency under[Unit]with the database and cache your app uses:# Remove: After={{application}}.container PartOf={{application}}.container # Add: Requires={{application}}-pgsql.container {{application}}-valkey.container After={{application}}-pgsql.container {{application}}-valkey.container -
Add the services its jobs use, such as
rustfsfor uploads,typesensefor search indexing,reverbfor broadcasts ormailpitfor queued mail. Otherwise they sleep while jobs still need them:Wants={{application}}-rustfs.container {{application}}-typesense.container -
Regenerate, reinstall the worker, and turn on the idle check so the worker stops once its jobs are done:
php artisan podman:generate development lpod install development/queue.quadlets --replace # or horizon.quadlets lpod idle enable my-app
The app still starts the worker through its Wants= line, but no longer stops it.
External proxies
The socket is a plain TCP port on the host, so any reverse proxy can sit in front of it, such as a Synology NAS, or Nginx or Traefik on another machine. You don't need the proxy preset then:
- Listen on the LAN:
PODMAN_ONDEMAND_LISTEN=0.0.0.0:8000, or the host's LAN IP. - Point the proxy at
http://your-host:8000and let it handle HTTPS. - Set
APP_URLto the publichttps://URL, and make sure Laravel trusts the proxy'sX-Forwarded-*headers (trustProxies). - Only allow the proxy to reach port
8000, for example with a firewalld rich rule. Traffic between the proxy and the host is plain HTTP.
The proxy doesn't need to retry. The connection is held until the app is ready, which takes a few seconds, well within most proxies' default timeouts.
Caveats
- Cold starts. The first request after idling takes about 1–3 seconds with
production, and longer with thedevelopmentpreset's file watcher. When the services were asleep too, it also waits for them to start. - The scheduler doesn't run while the stack sleeps. Tasks that must run on time, like nightly backups or reports, need
PODMAN_ONDEMAND_ENABLED=false. - Open connections keep the app awake. WebSockets or SSE through the app, and uptime monitors that request it more often than
idle_timeout, keep it from stopping. Point monitors at the proxy instead. - Host ports don't wake anything. A database client, or a request to RustFS or Mailpit through the
proxypreset, can't start a sleeping service. Open the app first, e.g. withlpod my-app open. Starting the service by hand doesn't help, because nothing needs it and systemd stops it again. lpod my-app updoesn't keep the app running, for the same reason. Send a request instead, e.g. withlpod my-app open, and runlpod my-app artisan ...while it's awake.- Health checks. While the app starts,
/upis checked every second until it answers. After that, it's checked once a minute. Every service has a health check too, except Memcached and Reverb, which count as started as soon as their container runs.
Under the hood
- The presets are the same with on-demand on or off.
{{ondemand}}rendersStopWhenUnneeded=yesornoon the app and services, and theproxypreset points at the socket or the container. - The socket and its proxy service are plain systemd units, because Quadlet has no unit type for sockets. They're always rendered, but only take effect once installed.
- Sidecars use
PartOf=the app, notBindsTo=.BindsTo=would count as needing the app, which would keep it running.
