Customizing
You customize the package in two places:
config/podman.php, for names, paths and on/off switches. Publish it withphp artisan vendor:publish --tag="podman-config".- The preset templates, for the services themselves: images, dependencies, memory limits and build files.
Config
| Key | Env variable | Default | Purpose |
|---|---|---|---|
enabled |
PODMAN_ENABLED |
true |
Turns all podman:* commands on or off |
quadlet_prefix |
PODMAN_QUADLET_PREFIX |
APP_NAME (or laravel) |
Prefix for service names, e.g. laravel-pgsql |
container_prefix |
PODMAN_CONTAINER_PREFIX |
systemd- |
What Quadlet puts in front of container and network names. Change it when your units set ContainerName=/NetworkName= |
stubs_path |
PODMAN_STUBS_PATH |
containers/stubs |
Where your own presets live |
base_path |
PODMAN_BASE_PATH |
Laravel's base_path() |
The project folder presets are read from and rendered into. Relative stubs_path/publish_path values resolve against it. Set it to render another project, e.g. from an installer container |
working_path |
PODMAN_WORKING_PATH |
base_path |
Host path for {{workingPath}}/{{runtimePath}}. Override per run with podman:generate --working-path= |
config_path |
PODMAN_CONFIG_PATH |
working_path |
Host path for {{configPath}}, for service config kept outside the project |
quadlet_uid/quadlet_gid |
PODMAN_QUADLET_UID/_GID |
Your UID/GID | Written into the Quadlet files |
binary |
PODMAN_BINARY |
podman |
The Podman binary to run, such as for creating secrets with PodmanSecrets |
publish_path |
PODMAN_PUBLISH_PATH |
podman |
Where rendered presets go. Don't commit this folder |
selinux_volume_mapping |
PODMAN_SELINUX_VOLUME_MAPPING |
true |
Keeps the Z/z/U volume flags. Turn off on hosts without SELinux |
presets |
PODMAN_DEFAULT_PRESETS |
see config/podman.php |
Presets that podman:setup renders |
s3_buckets |
PODMAN_S3_BUCKETS |
see config/podman.php |
Buckets podman:s3-setup creates. See S3 Buckets |
s3_cors_buckets |
PODMAN_S3_CORS_BUCKETS |
see config/podman.php |
Which of those buckets get the CORS policy |
substitutions |
(none) | [] |
Your own {{placeholder}} values. See Your own placeholders |
ondemand.* |
PODMAN_ONDEMAND_* |
enabled | Start the app on its first request and stop it when idle. See On-demand services |
presets, s3_buckets and s3_cors_buckets take a PHP array or a comma-separated string.
Presets and templates
A preset is a folder with two directories: quadlets/ for the *.quadlets files, and runtimes/ for build files such as the Containerfile, entrypoint.sh, PHP ini and Caddy templates.
To change a preset, publish it to stubs_path (default containers/stubs) and edit the copy:
php artisan podman:publish production
# edit containers/stubs/production/quadlets/pgsql.quadlets
php artisan podman:generate production
lpod install production/pgsql.quadlets --replace
A published preset replaces the bundled one completely. Files aren't merged one by one, so the published folder must hold every file the preset needs.
- Add a service: create
containers/stubs/production/quadlets/my-service.quadletsin the same# FileName=.../---format as the others. Then generate the preset and runlpod install production/my-service.quadlets. - Create a new preset: create
containers/stubs/my-preset/quadlets/andcontainers/stubs/my-preset/runtimes/.
Placeholders
Templates contain {{placeholder}} tokens. podman:publish leaves them as they are, and podman:generate fills them in:
| Placeholder | Value |
|---|---|
{{application}} |
quadlet_prefix, kebab-cased |
{{containerPrefix}} |
container_prefix (default systemd-), so {{containerPrefix}}{{application}}-pgsql is the database container |
{{proxy}} |
Name of the proxy service, network and volumes: proxy, or the proxy substitution (PODMAN_PROXY_PREFIX) |
{{appEnv}} |
app.env |
{{appName}} |
app.name |
{{appUrl}} |
app.url |
{{appHost}} |
The host part of app.url |
{{appUid}}/{{appGid}} |
quadlet_uid/quadlet_gid |
{{workingPath}} |
working_path |
{{configPath}} |
config_path (the same as working_path unless set) |
{{runtimePath}} |
The preset's rendered runtimes/ folder, e.g. podman/production/runtimes |
{{systemctl}} |
systemctl --user, or systemctl when quadlet_uid is 0 (root) |
{{ondemand}}, {{ondemandListen}}, {{ondemandPort}}, {{ondemandIdleTimeout}}, {{appUpstream}} |
On-demand settings |
{{viteHost}}, {{reverbHost}}, {{s3Host}}, {{s3ConsoleHost}}, {{mailHost}} |
Subdomains of {{appHost}} the proxy preset routes: vite., ws., s3., fs. and mail. |
{{viteUpstream}}, {{reverbUpstream}}, {{s3Upstream}}, {{s3ConsoleUpstream}}, {{mailUpstream}} |
The presets' services on the app's network: systemd-{app}-vite:5173, -reverb:6001, -rustfs:9000, -rustfs:9001 and -mailpit:8025 (web UI). Reverb's port assumes REVERB_SERVER_PORT=6001, as the presets expose. Override one in substitutions when a service runs elsewhere |
{{configPath}} is useful for keeping a service's config outside the project, for example in your own proxy.quadlets:
Volume={{configPath}}/{{proxy}}:/etc/caddy:rw,z,U
Your own placeholders
Add your own placeholders with substitutions in config/podman.php. It's plain PHP, so env() works:
'substitutions' => [
'{{apiEndpoint}}' => env('API_ENDPOINT'),
],
Environment=API_ENDPOINT={{apiEndpoint}}
The braces are optional, so 'apiEndpoint' => ... is {{apiEndpoint}} too. An unset env variable (null) renders as an empty string.
A value can use the built-in placeholders, so you can derive it from the app instead of repeating it:
'substitutions' => [
'viteUrl' => env('VITE_HMR_URL', 'https://vite.{{appHost}}'),
'searchUpstream' => '{{containerPrefix}}{{application}}-typesense:8108',
],
Only built-in placeholders are replaced inside values, not your own. A substitution with the same name as a built-in placeholder, such as {{appHost}}, replaces it.
Services
Every preset except devcontainer and s3 includes app plus these services. Use one per category.
| Category | Services (default first) |
|---|---|
| Database | pgsql, mariadb, mysql, mongodb |
| Cache/queue | valkey, redis, memcached |
| Queue worker | queue, horizon |
| Search | typesense, meilisearch |
| Object storage | rustfs |
| Mail catcher | mailpit |
queue runs a plain php artisan queue:work worker, which works with any queue connection. If your app uses Laravel Horizon (Redis or Valkey queues only), use horizon instead. See Replacing the queue worker with Horizon.
production also includes schedule and inertia-ssr, which run alongside the app. reverb (Laravel Reverb) is included too, but doesn't start by default. See Adding Reverb.
In production, the queue worker and Horizon start at boot, and systemd/schedule.timer runs schedule:run every minute. Install the timer with lpod install production/my-app-schedule.timer.
Swapping the database or cache
app.quadlets names its database and cache in its [Unit] section. To switch, publish the preset:
php artisan podman:publish production # or development
Edit containers/stubs/production/quadlets/app.quadlets:
Requires={{application}}-mysql.container {{application}}-redis.container
After={{application}}-mysql.container {{application}}-redis.container
Regenerate and reinstall both:
php artisan podman:generate production
lpod install production/mysql.quadlets --replace
lpod install production/app.quadlets --replace
Then update .env (DB_CONNECTION, DB_HOST and so on) so Laravel connects to the new service. Quadlet names containers systemd-{unit}, so the host is, for example, systemd-my-app-mysql.
Replacing the queue worker with Horizon
app.quadlets starts queue alongside the app through its Wants= line. To use horizon instead, publish the preset and change queue to horizon on that line (in production, the line ends with schedule.timer):
Wants={{application}}-mailpit.container {{application}}-horizon.container {{application}}-schedule.container
Regenerate, then install horizon and reinstall app:
php artisan podman:generate production
lpod install production/horizon.quadlets --replace
lpod install production/app.quadlets --replace
If queue was already running, stop it with systemctl --user stop {app}-queue. It won't start again, because nothing wants it anymore.
In development, Horizon runs with horizon:listen, which restarts it when a file in the watch list of config/horizon.php changes, like Octane's --watch. It needs Horizon 5.43 or later, the chokidar npm package installed in your app (pnpm add -D chokidar), and a watch key in config/horizon.php. Configs published before 5.43 lack that key, so copy the watch block from Horizon's config. A restart stops the jobs in progress. To keep long jobs running while you edit code, change the command in development/horizon.quadlets back to artisan horizon.
Adding Reverb
reverb isn't started by default, because it needs Laravel Reverb installed in your app. To run it, publish the preset and add it to the Wants= line in app.quadlets:
Wants={{application}}-mailpit.container {{application}}-queue.container {{application}}-reverb.container {{application}}-schedule.container
Regenerate, then install reverb and reinstall app:
php artisan podman:generate production
lpod install production/reverb.quadlets --replace
lpod install production/app.quadlets --replace
How services depend on each other
| Directive | Meaning | Used for |
|---|---|---|
Requires= |
Hard dependency. If the target fails, this unit stops too | app → database and cache |
After= |
Start order only | Together with Requires=/Wants= |
Wants= |
Soft dependency. Tries to start the target, but doesn't fail without it | app → mailpit/reverb/vite, queue/schedule in development, and queue/schedule.timer in production |
PartOf= |
Stopping or restarting the target also stops or restarts this unit | vite/inertia-ssr, and horizon/queue/schedule in development → app |
BindsTo= |
Like Requires=, and also stops when the target stops. Not used: it counts as needing the target, which keeps an on-demand app running |
Other tasks
Increasing a service's memory limit
Publish the preset, add Memory=1G under [Container] in that service's quadlet, for example containers/stubs/production/quadlets/pgsql.quadlets, then regenerate and reinstall it:
php artisan podman:generate production
lpod install production/pgsql.quadlets --replace
Volume flags
The presets only add SELinux and ownership flags to a mount where they're needed:
| Mount | Flags | Why |
|---|---|---|
Named volumes shared by the app's services (/config, /data, /cache) |
none | Podman's SELinux policy already labels named volumes for containers. On first use, Podman also gives the volume the owner of the folder in the image. z and U would walk the whole volume on every start, and the scheduler starts a container every minute. |
Your working copy (/app in development) |
z |
Host folders need a container label. The development app also has U, which gives files back to you if something changed their owner. |
| A volume used by one service, such as the database | Z,U |
Z gives it a label private to that container. U hands it to the user the image runs as. |
Don't use Z on a mount that several containers share: the last container to start would lock the others out. If a named volume ends up with the wrong label, for example after copying a backup into it from the host, run restorecon -R ~/.local/share/containers/storage/volumes (or /var/lib/containers/storage/volumes for system-wide installs).
Keeping more folders writable
When the production image starts, it gives /app/storage, /app/bootstrap/cache, /config, /data and /cache back to the app user if something else owns them, for example after a podman exec without --user, which runs as root. It only checks the top folder of each, so a root-owned folder deeper inside isn't fixed. If your app writes to such a folder, list it in PODMAN_CHOWN_PATHS (space-separated) under [Container] in each quadlet that runs the app image:
[Container]
Environment="PODMAN_CHOWN_PATHS=/cache/temp /cache/temp/media"
Then regenerate and reinstall the services.
Running several apps on one host
Pass --application= to lpod install (needs Podman 6 or later), so each app gets its own install folder. With on-demand services, also give each app its own ondemand.listen and ondemand.port.
Proxying services without the proxy preset
production runs Octane with FrankenPHP, which has Caddy built in. This is separate from the proxy preset's Caddy container (see Proxy).
If you don't use the proxy preset, for example behind an external load balancer or on Laravel Cloud, the built-in Caddy can proxy other services (Reverb, Mailpit, S3 storage) itself. You configure it through Octane's CADDY_EXTRA_CONFIG environment variable.
Foxws\Podman\Support\PodmanCaddySites builds that value from env vars. Use it in config/octane.php:
use Foxws\Podman\Support\PodmanCaddySites;
'caddy' => [
'env' => [
// Port must match the "--port" passed to "octane:frankenphp" in APP_COMMAND.
'CADDY_EXTRA_CONFIG' => PodmanCaddySites::render([
PodmanCaddySites::hostFromUrl((string) env('AWS_URL')) => PodmanCaddySites::hostPortFromUrl((string) env('AWS_ENDPOINT')),
(string) env('VITE_REVERB_HOST', env('REVERB_HOST')) => PodmanCaddySites::hostPort(env('REVERB_HOST'), env('REVERB_PORT', 6001)),
(string) env('MAILPIT_UI_HOST') => PodmanCaddySites::hostPort(env('MAIL_HOST'), 8025),
], (int) env('OCTANE_PORT', 8000)),
],
],
With AWS_URL=https://s3.laravel.test, VITE_REVERB_HOST=ws.laravel.test and MAILPIT_UI_HOST=mail.laravel.test in .env (the same subdomains the proxy preset uses), you get:
| Hostname | Upstream | Env vars |
|---|---|---|
s3.laravel.test |
e.g. minio:9000 |
AWS_URL, AWS_ENDPOINT |
ws.laravel.test |
e.g. reverb:6001 |
VITE_REVERB_HOST (or REVERB_HOST), REVERB_HOST, REVERB_PORT |
mail.laravel.test |
e.g. mailpit:8025 |
MAILPIT_UI_HOST, MAIL_HOST |
Add or remove entries to match the services you use.
A few things to know:
- Use
env()here, notconfig(). Config files can't rely on each other's load order. - An entry with an empty hostname or upstream (unset env var) is skipped.
render()useshttp://by default. With a bare hostname, Caddy would try automatic HTTPS on port 443. Theproductionimage runs as a non-root user without permission to bind that port, so the server would crash. Only pass the third$schemeargument if your Caddy is allowed to bind ports below 1024.
Put your TLS proxy in front of the app's port and send every hostname to it, keeping the original Host header. For example, with Caddy on the host:
laravel.test, s3.laravel.test, ws.laravel.test, mail.laravel.test {
reverse_proxy 127.0.0.1:8000 {
stream_close_delay 5m
}
}
stream_close_delay keeps WebSocket connections open for a while after a Caddy reload. Caddy passes the Host header through by default, so don't override it with header_up Host.
Paths instead of subdomains
Some services can also run under a path of the app's own domain, such as laravel.test/ws instead of ws.laravel.test. That needs no extra DNS entries or certificates, and the browser sees the same origin, so you need no CORS or CSP entries for those services. Subdomains are the default because every service supports them. Use a path only for services that work under one:
| Service | Under a path? | What it needs |
|---|---|---|
| Reverb | Yes | Strip the path. Reverb keeps serving from /, and Echo connects with wsPath |
| Mailpit | Yes | Keep the path, and set MP_WEBROOT to it on the Mailpit container |
| S3 (RustFS, MinIO) | Public URLs only | Signed URLs (temporaryUrl()) are signed over the full path, so a stripped prefix breaks them. Keep a subdomain |
| RustFS console | No | The console expects to run from / |
| Vite dev server | Not reliably | It serves its modules from /. Keep a subdomain |
PodmanCaddyRoutes renders the routes for the app's site block, which FrankenPHP takes from CADDY_SERVER_EXTRA_DIRECTIVES in config/octane.php. Services you keep on subdomains stay in CADDY_EXTRA_CONFIG:
use Foxws\Podman\Support\PodmanCaddyRoutes;
'CADDY_SERVER_EXTRA_DIRECTIVES' => PodmanCaddyRoutes::make()
// Reverb stays at "/", the browser connects to "/ws".
->route(env('VITE_REVERB_PATH'), env('REVERB_HOST'), env('REVERB_PORT', 6001))
// Mailpit serves from "/mail/" itself.
->route(env('MAILPIT_PATH'), env('MAIL_HOST'), 8025, stripPrefix: false)
->render(),
By default the path is stripped before the request reaches the service. Pass stripPrefix: false for services that serve from the path themselves. A route with an empty path, host or port is skipped, so an unset env variable turns it off. upstream() takes a ready host:port instead, and PodmanCaddySites::routes() is a shorthand for a list of routes that all strip or all keep their path. Setting CADDY_SERVER_EXTRA_DIRECTIVES replaces Octane's Mercure directives, which use the same variable.
Then point the services at the path:
-
Reverb: set
VITE_REVERB_HOSTto the app's own host and addVITE_REVERB_PATH=/ws. Pass the path to Echo aswsPath(for example throughconfig/reverb.php, next towsHost), then rebuild the frontend. Laravel keeps broadcasting straight toREVERB_HOST:REVERB_PORT, so the server side doesn't change. -
Mailpit: add
MAILPIT_PATH=/mail, then set the same value on the container with your own placeholder. Regenerate and reinstallmailpit.quadlets:// config/podman.php 'substitutions' => [ 'mailpitPath' => env('MAILPIT_PATH'), ],# mailpit.quadlets Environment=MP_WEBROOT={{mailpitPath}}
Your TLS proxy in front of the app must forward WebSocket upgrades for /ws. With the proxy preset that works out of the box, since it already sends the app's domain to the app.
Rendering presets from code
PodmanPresetGenerator renders a preset without Artisan, for example from an installer. It returns what it rendered:
use Foxws\Podman\Support\PodmanPresetGenerator;
$rendered = (new PodmanPresetGenerator)->generate('production');
$rendered->path; // where it was rendered to
$rendered->units(); // every unit of its .quadlets files
$rendered->systemdUnitsOfType('timer'); // its plain systemd units, by type
$rendered->secrets(); // the Podman secrets its units use, with their type and target
Each unit (PodmanQuadletUnit) has its type (a QuadletUnitType, such as QuadletUnitType::Container), its fileName() (acme.container), the serviceName() systemd generates for it (acme.service, acme-build.service) and its secrets(). $unit->type->isRunnable() is true for containers, pods and kube units, which start the rest. PodmanQuadletUnit::fromFile() splits any rendered .quadlets file the same way.
Each secret (PodmanQuadletSecret) has its name, its type (QuadletSecretType::Env for an environment variable, QuadletSecretType::Mount for a file) and its target, such as POSTGRES_PASSWORD or /app/.env. PodmanSecrets creates them with podman secret, without lpod:
use Foxws\Podman\Support\PodmanSecrets;
$secrets = new PodmanSecrets;
$secrets->exists('acme-env');
$secrets->create('acme-pgsql-password', $password); // the value goes through stdin
$secrets->createFromFile('acme-env', base_path('.env'));
It runs podman by default. Set PODMAN_BINARY=podman-remote when it runs in a container with the host's Podman socket mounted.
