Ondemand
On-demand services
On-demand services start your app on its first request and stop it again after it has been idle for a while (scale-to-zero). It's handy on a development machine or a homelab that hosts several apps: an app nobody is using doesn't hold on to Octane workers, SSR or a Vite server.
It's on by default for the development and production presets. It only uses systemd and Quadlet, nothing else runs on the host.
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 container reports ready once
/upanswers (Notify=healthy), so the first request waits for Octane instead of failing. - After
idle_timeoutwithout connections, the proxy exits. Nothing needs the app anymore, so systemd stops it (StopWhenUnneeded=yes) along with its sidecars.
Configuring it
To keep the app running all the time instead, turn it off. The app is then published to the proxy directly, as before:
PODMAN_ONDEMAND_ENABLED=false
| Config key | Env variable | Default | Description |
|---|---|---|---|
ondemand.enabled |
PODMAN_ONDEMAND_ENABLED |
true |
Render presets on-demand, including sleeping services |
ondemand.listen |
PODMAN_ONDEMAND_LISTEN |
8000 |
Where the socket listens (systemd ListenStream=), e.g. 8000 or 192.168.1.10:8000 |
ondemand.port |
PODMAN_ONDEMAND_PORT |
18000 |
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 |
Running several apps on one host? Give each its own listen and port.
Render and install as usual. The socket, its proxy service and the idle check are plain systemd units, because Quadlet has no unit type for them. They live in the ondemand preset, which works with both development and production:
php artisan podman:generate development
php artisan podman:generate ondemand
lpod install development/app.quadlets --replace
lpod install ondemand/my-app-ondemand.socket --replace
lpod install ondemand/my-app-idle.timer --replace
lpod install copies the socket and its proxy service to ~/.config/systemd/user/ and enables the socket. On a server, also run loginctl enable-linger once, so the socket listens after a reboot without logging in.
With the proxy preset, regenerate it too. Caddy then sends app traffic to the socket (host.containers.internal:8000) instead of the container.
What keeps running
| Service | development |
production |
|---|---|---|
| Vite, Inertia SSR | Stop with the app | Stop with the app |
| Queue worker / Horizon | Stops with the app (opt out) | Starts at boot, stops once no jobs are left (idle check) |
| Scheduler | Stops with the app | A timer runs schedule:run every minute while the app is awake |
| Database, cache, Reverb, Mailpit, other services | Sleep once nothing needs them | Sleep once nothing needs them |
The presets are the same with on-demand on or off. Only StopWhenUnneeded= on the app and services changes ({{ondemand}} renders yes or no), along with where the proxy preset sends traffic. The socket and timer units are always rendered, but only take effect once installed. Sidecars use PartOf= the app rather than BindsTo=: BindsTo= would count as needing the app and keep it running.
Keeping the queue worker running
In development, the queue worker and Horizon are PartOf= the app, so they stop when it goes idle. A job still running then gets TimeoutStopSec= (60 seconds) to finish before it's killed, and is retried on the next start.
To keep the worker running while the app is idle, for example for long imports or media processing, publish the preset:
php artisan podman:publish development
In containers/stubs/development/quadlets/queue.quadlets (or horizon.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
The app still starts the worker through its Wants= line, but no longer stops it. Regenerate and reinstall the worker, and install the idle check so it stops once its jobs are done:
php artisan podman:generate development
lpod install development/queue.quadlets --replace # or horizon.quadlets
lpod install ondemand/my-app-idle.timer --replace
In production, the queue worker and Horizon already work this way.
A worker that keeps running also needs the services its jobs use, such as rustfs for uploads, typesense for search indexing, reverb for broadcast events or mailpit for queued mail. Otherwise they sleep with the app while jobs still use them. Add a Wants= line under the worker's [Unit]:
Wants={{application}}-rustfs.container {{application}}-typesense.container
Sleeping services
The database, cache and other services sleep too, once nothing needs them anymore: the app is idle and no jobs are left. Nothing runs until the next request, much like hibernation on a managed platform.
PODMAN_ONDEMAND_ENABLED=false keeps the app and every service running. Do that when something outside the app needs them, such as a database client, a backup job or a public S3 bucket (see caveats).
To keep only one service running, such as the database for a client, publish the preset and set StopWhenUnneeded=no in that service's quadlet.
How it works
{{ondemand}} renders StopWhenUnneeded=yes on the database, cache, search and storage services. systemd then stops a service once no running unit Requires= or Wants= it anymore:
- 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, which starts the services, workers and scheduler timer it
Requires=orWants=. The services report ready through a health check (Notify=healthy). The app waits for the ones on itsAfter=line, the database and cache, so it doesn't connect before they accept connections.
Traffic between containers doesn't pass through the on-demand socket. A service stays up because a running unit needs it, not because of an idle timer, so a query from the app or a worker never hits 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. Using another service, such as rustfs, typesense or meilisearch? Publish the preset and add it to the app's Wants= line (see Customizing), and to a kept-running worker's if its jobs use it:
Wants={{application}}-mailpit.container {{application}}-queue.container {{application}}-schedule.container {{application}}-rustfs.container
Wants= starts the service alongside the app, but the app doesn't wait for it. If the first request after waking needs it, such as media from rustfs or search from typesense, also add it to the app's After= line. The app then starts once the service is healthy, which makes waking slower by the time the service takes to start. Do the same for a kept-running worker, so it doesn't pick up a job before the service is ready:
After={{application}}-pgsql.container {{application}}-valkey.container {{application}}-rustfs.container
The idle check
Queue workers that keep running while the app is idle would keep the database and cache awake. The ondemand preset's idle check (lpod install ondemand/my-app-idle.timer) handles this for both development and production.
Once a minute, while the app is asleep, it runs php artisan podman:idle in a running queue worker or Horizon. If the app has no work in progress, it stops the workers and the scheduler timer, if one is running (production). Before stopping anything, it checks again that the app is still asleep, so a request that wakes the app during the check keeps its workers running. The next request starts them again through the app's Wants= line, which lists queue and, in production, schedule.timer. Using Horizon? Put horizon on that line instead of queue.
A long job keeps the stack awake until it's done. Delayed jobs count too, so they run on time. If podman:idle fails or doesn't exist, for example because the package was installed with --dev and the production image leaves it out, the workers keep running. The check does nothing when PODMAN_ONDEMAND_ENABLED=false.
Idle checks
podman:idle runs every check for something your app uses, going by its config, much like Laravel Health checks:
| Check | Used 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 (with mongodb/laravel-mongodb). Idle connections, like a worker waiting for jobs, don't count |
scout |
SCOUT_DRIVER is a search engine, like typesense or meilisearch |
Queued indexing jobs are left (scout.queue), or Meilisearch still processes indexing tasks in the background |
Pick checks with --services. Unknown names fail, so a typo keeps the workers running rather than stopping them:
php artisan podman:idle --services=queue,database
You can change the checks in two ways. To add or remove checks, edit the list in config/podman.php (publish it with php artisan vendor:publish --tag="podman-config"). The list takes class names, so each check runs with its defaults:
'idle' => [
'checks' => [
QueueCheck::class,
DatabaseCheck::class,
ScoutCheck::class,
],
],
To configure a check, like the queues it watches, register the checks from a service provider's boot() method instead. Registered checks 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(),
]);
Write your own by extending IdleCheck. Give it a name(), return an IdleResult from run(), and optionally override isEnabled():
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");
}
}
Caveats
- Cold start. The first request after idling also waits for the services, which adds a few seconds.
- 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. - 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: nothing needs it, so systemd stops it again. - Health checks. Every service reports ready through a health check, except Memcached (it starts right away) and Reverb. Those count as started as soon as their container runs.
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, 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, e.g. with a firewalld rich rule. Traffic between the proxy and the host is plain HTTP.
A cold start takes a few seconds, well within the default timeouts of most proxies. The proxy doesn't need to retry: the connection is held until the app is ready.
Caveats
- Cold start. The first request after idling takes about 1–3 seconds with
production, and longer with thedevelopmentpreset's file watcher. - Connections keep the app awake. Open WebSockets or SSE through the app, and uptime monitors that request it more often than
idle_timeout, prevent it from stopping. Point monitors at the proxy instead. lpod my-app updoesn't keep it running. Nothing needs the app, so systemd stops it again. Send a request instead, e.g.lpod my-app open. Runlpod my-app artisan ...while it's awake.- Health checks. While the app starts, a startup check polls
/upevery second, and the app counts as started as soon as it answers. After that,/upis checked once a minute. Publish the preset to change the path or interval.
