TL;DR — I spent the day teaching a deployment control plane to build apps natively on a plain VM — no Docker, just systemd units and a release directory. The tempting shortcut is to reuse the Dockerfile presets. Don't. A container image build and a native release answer different questions, and the honest move is a second, parallel abstraction — a recipe — plus a test that refuses to let the UI offer a preset the native runtime can't actually build.
Why not just reuse the Dockerfile?
Every preset in the system already knew how to synthesize a Dockerfile: base image, COPY, RUN, EXPOSE, CMD. It's tempting to parse that back out and replay it over SSH.
The shapes don't line up. A Dockerfile describes an image build: a base image gives you the toolchain, the filesystem is disposable, and the process is PID 1 in its own namespace. A native release needs a different set of facts:
- Which toolchains must already exist on the node — and at what version.
- What to run in the release directory, as the workload's own unix user.
- How to start the result as a plain long-lived process under systemd.
- What must survive a release swap, because the filesystem is not disposable anymore.
That last one has no analogue in a Dockerfile at all. So instead of contorting one abstraction, I made a second one that says exactly those things:
final readonly class NativeBuildRecipe
{
public function __construct(
/** @var list<Toolchain> every toolchain the build AND start command need on the node */
public array $toolchains,
/** @var list<string> run in order, in the release dir, as the workload user */
public array $buildCommands,
public string $startCommand,
/** @var array<string,string> defaults the operator's own values override */
public array $env = [],
public ?Toolchain $versionedToolchain = null,
/** @var list<string> env keys minted once and reused for every later release */
public array $persistentKeys = [],
/** @var list<string> release-relative dirs that must survive a release swap */
public array $sharedDirs = [],
/** @var list<string> release-relative FILES that must survive a release swap */
public array $sharedFiles = [],
public ?string $envSeedFile = null,
public string $envFile = '.env',
public ?string $fpmDocroot = null,
public ?string $preStartCommand = null,
public ?string $workerCommand = null,
public ?string $schedulerCommand = null,
) {}
}
A framework preset is then a data entry, not a code path:
'laravel' => new NativeBuildRecipe(
toolchains: [Toolchain::Php, Toolchain::Node],
buildCommands: [
'composer install --no-dev --no-interaction --no-scripts --prefer-dist --optimize-autoloader',
'if [ -f package.json ]; then npm ci --no-audit --no-fund && npm run build; fi',
'[ -e public/storage ] || php artisan storage:link',
],
startCommand: 'php artisan migrate --force && exec php artisan serve --host=0.0.0.0 --port=${PORT}',
env: ['APP_ENV' => 'production', 'APP_DEBUG' => 'false', 'LOG_CHANNEL' => 'stderr'],
versionedToolchain: Toolchain::Php,
persistentKeys: ['APP_KEY'],
sharedDirs: ['storage'],
sharedFiles: ['database/database.sqlite'],
workerCommand: 'php artisan queue:work --tries=3 --sleep=3 --max-time=3600',
schedulerCommand: 'php artisan schedule:run',
envSeedFile: '.env.example',
fpmDocroot: 'public',
preStartCommand: 'php artisan migrate --force',
),
Adding a language becomes adding an entry. The runtime's flow never changes. That's the whole point of a driver-style abstraction: variation lives in data, the algorithm stays put.
Every one of those fields, though, is scar tissue. Let me walk the interesting ones.
persistentKeys: the deploy that quietly destroys your data
The container preset ran key:generate as part of every image build. In a container world that's mostly harmless — you're expected to inject APP_KEY from outside anyway, and nobody notices.
On a VM with a persistent database, generating a fresh APP_KEY on every release is a data destruction event. Every value you encrypted with Crypt — access tokens, 2FA secrets, anything cast encrypted — becomes permanently unreadable at the moment release N+1 goes live. The app comes up green. Requests succeed. And a decryption path nobody exercises on the happy path is now silently broken forever.
So the recipe declares which keys are generated once and remembered:
persistentKeys: ['APP_KEY'],
At deploy, any missing persistent key is minted and written back to the workload's stored env, so release N+1 reads the same value release N used. First deploy generates; every deploy after that reuses.
This is the general lesson: anything you generate at build time is a hidden per-release variable. If a stored value elsewhere in your system depends on it, generating it per-release is a correctness bug wearing a convenience hat.
sharedDirs and sharedFiles: the filesystem is not disposable
Atomic-release deploys work by building into releases/<timestamp>/ and flipping a current symlink. Which means anything the app wrote into the previous release directory disappears at the flip.
For Laravel, storage/ is the obvious one — uploads, sessions, cached views. Standard Capistrano-style shared directory, symlinked into every release.
The one that bit me was at file granularity. Laravel's default database connection is SQLite at database/database.sqlite. You cannot share database/ as a directory — that folder holds migrations, which belong to the release. So the shared mechanism needed to work per-file:
sharedDirs: ['storage'],
sharedFiles: ['database/database.sqlite'],
Without that line, an app running on the framework's own default loses its entire database on every deploy — and, because migrations run on boot, comes back up perfectly healthy and completely empty. Silent data loss that looks like a successful deploy is the worst failure mode in this entire category.
Worth saying plainly: a deploy that succeeds while destroying state is more dangerous than one that crashes. A crash pages you. This doesn't.
workerCommand and schedulerCommand: one app is not one process
A container preset trains you to think "one workload, one process". Laravel isn't one process. Ship only the web process and you get an app where:
- Queued mail sits in the
jobstable forever. No error, no bounce — just email that never arrives. - Anything in
routes/console.phpor the scheduler never fires. Reports, cleanups, reminders: gone.
Both fail silently, which is why they're easy to ship. The recipe now declares its companions, and the runtime materialises them as siblings of the app unit — a second long-lived unit for the worker, and a systemd timer for the scheduler:
# scheduler timer — Persistent=false on purpose
[Timer]
OnCalendar=minutely
Persistent=false
Persistent=false matters. With Persistent=true, systemd catches up missed runs after downtime — which means a box that was off for six hours fires the scheduler immediately on boot and stampedes every due job at once. A minutely cron-equivalent should just… wait for the next minute.
The worker gets --max-time=3600 so it retires itself even if nobody deploys for a week, and deploys restart it explicitly — otherwise the worker keeps executing the previous release's code out of a directory the symlink no longer points at. A stale worker is a genuinely nasty ghost to debug.
fpmDocroot: artisan serve is not a web server
The quickest way to get a PHP app answering HTTP is php artisan serve behind a reverse proxy. It works, and for a while I shipped it.
It's also the dev server. Single-threaded unless you set PHP_CLI_SERVER_WORKERS, no opcache story, and it serves your static assets through PHP one request at a time.
The conventional VM shape is different: nginx owns public/ and serves static files directly; only .php goes to a per-workload php-fpm socket. So the recipe declares its document root, and the runtime picks the shape:
$recipe->fpmDocroot !== null && $workload->domain !== null
? $this->serveThroughFpm($workload, $recipe)
: $this->serveAsProcess($workload, $recipe);
Note the && $workload->domain !== null. Without a domain there's no vhost, and nothing could reach an fpm socket — so it falls back to the process shape rather than deploying something unreachable. Capability plus context, not capability alone.
That capability also stayed off the shared proxy contract:
interface PhpServingContract
{
public function addPhpVirtualHost(string $domain, string $documentRoot, string $socketPath): void;
}
A container-oriented reverse proxy routes to ports and has no filesystem to serve from. Putting addPhpVirtualHost() on the shared ReverseProxyContract would force every driver to stub a method only one of them can honour — and stub methods are where NotImplementedException goes to breed. A narrow second interface, implemented only by the driver that genuinely has the capability, and a instanceof check at the one call site. Interface segregation earning its keep.
envSeedFile: seed from the repo, don't invent
Early on, the platform wrote an .env containing only the keys it knew about. Apps then crash-looped on the first setting nobody thought to model.
The fix is to stop guessing: seed from the file the framework's own convention says holds every expected setting.
envSeedFile: '.env.example',
Layering, lowest priority first: repo template → recipe defaults → platform-injected service credentials → operator's own values, always winning.
One trap here. Symfony's .env is a committed file, not a template — writing the merged env there clobbers a tracked file and produces a repo that's dirty before it's even built. Hence:
public string $envFile = '.env', // '.env.local' for Symfony
Two frameworks, same word, opposite meanings. Encoding "where does the merged env go" as recipe data rather than a constant is the only reason that fits without a conditional.
Version guards: check the version, not the presence
The toolchain installer originally guarded with command -v node. A node provisioned months earlier had the distro's Node 18 on PATH, the guard passed, and a build failed on dependencies requiring ≥ 20.18.
A presence guard pins whatever an earlier run installed, forever. Every guard is now a version guard:
__n=$(node -v 2>/dev/null | sed -e "s/^v//" -e "s/\..*//"); [ "${__n:-0}" -ge 20 ]
The ${__n:-0} fallback is the elegant part: a missing tool resolves to 0 and fails the same comparison, so one expression covers both "absent" and "stale". No separate presence branch.
The guards live on the toolchain enum, shared by both OS profiles, so the floor can't drift between Debian- and RHEL-family nodes.
The gate that keeps it honest
Here's the failure mode I actually care about most: the UI offers ten presets, the native runtime supports six, and a user picks one of the missing four. They don't get a refusal — they get a deploy that limps.
So the recipe registry must account for every preset the UI can offer: either a recipe, or an explicit entry in unsupported() with a reason. And a test enforces it:
it('accounts for every build preset the UI can offer', function () {
$offered = app(BuildPresetRegistry::class)->slugs();
$recipes = app(NativeBuildRecipes::class);
$unaccounted = collect($offered)->reject(
fn (string $slug) => $recipes->for($slug) !== null
|| array_key_exists($slug, $recipes->unsupported())
);
expect($unaccounted)->toBeEmpty(
'Presets with no native recipe and no documented reason: '.$unaccounted->implode(', ')
);
});
This is my favourite kind of test. It asserts nothing about behaviour — it asserts that two lists which must stay in sync are in sync. Add a preset without a native story and CI tells you, at the moment you add it, instead of a user telling you three weeks later.
Any time you have a registry on one side and a capability table on the other, write this test. It's five lines and it never stops earning.
Takeaway
Supporting a second deployment target isn't "make the first one more configurable". A container build and a native release genuinely differ, and the honest response is a second abstraction that states the native facts directly: toolchains, build steps, start command, what survives a swap, what companion processes exist.
Then make the two abstractions provably aligned with a coverage test — because the expensive bug here was never a crash. It was the deploy that came up green while quietly dropping a database, or a queue, or an encryption key.
What's next: making the same recipes describe a rollback, not just a release.
Top comments (0)