DEV Community

Cover image for Dev Log: 2026-08-21 — Native Deploys, Masked Env Vars, and Two Migration Papercuts
Nasrul Hazim
Nasrul Hazim

Posted on

Dev Log: 2026-08-21 — Native Deploys, Masked Env Vars, and Two Migration Papercuts

TL;DR — A long day across five repos. The theme that kept repeating: the failure that looks like success. A deploy that comes up green while destroying a database. A queue that swallows mail with no error. A migration that works on one driver and quietly can't be foreign-keyed on another. Here's the log.


1. A package view that broke the host app's view:cache

Public: cleaniquecoders/laravel-db-doc

The package ships a Blade view that renders your database schema. That view opened with <x-guest-layout> and rendered <x-jet-authentication-card-logo> — both of which live in the host application, not the package.

In an app without Jetstream the page fatals. Worse, php artisan view:cache fails for the entire application, because Blade's component tag compiler must resolve every <x-...> tag at compile time, and view:cache compiles vendor views too. One unresolvable tag in one vendor file, and someone else's deploy pipeline goes red pointing at a file they've never opened.

Fix: a self-contained HTML document with inline styles, no host components, no utility classes (a package can't assume the app compiled Tailwind either), plus <meta name="robots" content="noindex, nofollow"> because this page is a map of your schema.

A package view may depend on the framework. It may never depend on the application.

Full write-up in the companion post today.


2. Native VM deploys — recipes instead of Dockerfiles

Private project — control plane for deploying apps to Linux nodes.

The bulk of the day. Teaching the platform to build and run apps natively on a plain VM — systemd units, release directories, no containers — across multiple language toolchains.

The Dockerfile presets could not be reused. A container build describes an image (base image, COPY, EXPOSE); a native release needs different facts: which toolchains exist on the node, what runs in the release dir as the workload user, and what must survive a release swap — the last of which has no Dockerfile equivalent at all, because a container filesystem is disposable and a VM's isn't.

Three things I'd have got wrong without hitting them:

  • APP_KEY must be generated once, not per release. The container preset ran key:generate on every build. On a VM with a persistent database, that permanently orphans every Crypt-encrypted value the moment the next release goes live — and the app comes up green.
  • Shared state needs file granularity, not just directories. storage/ as a shared dir is standard. But an app on the framework's default SQLite connection keeps its data at database/database.sqlite, and you can't share database/ — that folder holds migrations, which belong to the release. Without a per-file shared mechanism, every deploy silently emptied the database and then migrated it back to healthy-and-blank.
  • One app is not one process. Ship only the web process and queued mail sits in the jobs table forever with no error, and nothing ever fires the scheduler. Both now materialise as sibling systemd units — a worker service and a Persistent=false timer (Persistent=true would stampede every missed run at boot).

Also swapped artisan serve for the conventional shape where a domain exists: nginx owns public/ and serves static files itself, .php goes to a per-workload php-fpm socket. That capability deliberately stayed off the shared reverse-proxy contract — container proxies route to ports and have no filesystem to serve from, so a narrow second interface beats forcing every driver to stub a method it can't honour.

And the small thing I'll reuse everywhere: guard on version, not presence.

__n=$(node -v 2>/dev/null | sed -e "s/^v//" -e "s/\..*//"); [ "${__n:-0}" -ge 20 ]
Enter fullscreen mode Exit fullscreen mode

command -v node passes forever on whatever an earlier run installed. The :-0 fallback makes one expression cover both "absent" and "stale".

Detailed post today covers the recipe abstraction and the coverage test that keeps the UI from offering presets the native runtime can't build.


3. An inline environment editor that doesn't leak over your shoulder

Same private project.

Editing a workload's env vars used to mean a flyout, a JSON blob, and a lot of hope. Now it's rows edited in place with an explicit batch save — never per-row server writes, which mean N round trips and a partially-applied state if one fails halfway.

The interesting bit was masking. Two independent signals, either one masks a value:

final class EnvSecrecy
{
    private const KEY_PATTERN = '/(KEY|SECRET|PASSWORD|PASS|PWD|TOKEN|DSN|PRIVATE)/';
    private const USERINFO_URL_PATTERN = '#^[a-z][a-z0-9+.\-]*://[^/@\s]+:[^/@\s]+@#i';

    public static function isSecret(string $key, string $value): bool
    {
        if (self::isToken($value)) {
            return false;
        }

        return preg_match(self::KEY_PATTERN, strtoupper($key)) === 1
            || preg_match(self::USERINFO_URL_PATTERN, $value) === 1;
    }
}
Enter fullscreen mode Exit fullscreen mode

The second pattern is why: DATABASE_URL doesn't match any credential-ish keyword, but its value carries a password in the userinfo segment. Key-name heuristics alone would render it in plain text.

Two things I made sure to write down in the class docblock:

  1. Placeholder tokens ({{ … }}) are never masked — they're resolved at deploy and showing them is the entire point.
  2. This is shoulder-surfing protection, not access control. Anyone who can open the editor can hit reveal. Never build a permission on it. Masking that gets mistaken for authorization is how you end up with a "security feature" that secures nothing.

The editor itself lives in a trait with two seams the host component must supply — its own scoped lookup and its own gate:

trait EditsEnvVars
{
    abstract protected function resolveEnvAttachment(string $uuid): ?DeploymentWorkload;
    abstract protected function authorizeEnvEdit(DeploymentWorkload $dw): void;
}
Enter fullscreen mode Exit fullscreen mode

Because different pages scope differently, and the rule I keep re-learning is: never resolve a client-supplied UUID with a bare where('uuid', …). The trait owns the editing mechanics; the host owns "which records are yours".


4. Structured email bodies without breaking the HTML pipeline

Private project — a CRM.

Added a block-based mail builder: emails composed from typed blocks (heading, button, image, quote, signature…) started from a template gallery, with per-tenant branding.

The architectural knot: a builder needs bodies that are structured and re-editable (reorder a block, edit one field, re-render), but the entire existing pipeline — send, sanitisation, open/click tracking, plain-text previews for the activity timeline — consumes a single HTML string. And every existing rich-text email had to keep working byte-identically, with no data migration.

The decision: store block emails as an ordered JSON array of typed blocks in a new nullable column alongside the existing HTML column, discriminated by a composer enum (editor | blocks). Existing rows default to editor and behave exactly as before.

Two details worth stealing:

  • Block content is data; block markup is code. Users author fields, never raw HTML — except one explicit prose block, which goes through the existing sanitiser at both save and render. Escaping happens per-field at render. That's what makes it safe by construction instead of safe by regex.
  • The rendered HTML is cached back into the old column so every downstream consumer that only understands HTML works untouched — but at send time blocks are re-rendered fresh, so a branding change made after the campaign was saved still reaches the recipient. The cached HTML is a derived render, never the source of truth.

Wrote it up as an ADR before writing the code. When a decision has to be defended in six months ("why are there two body columns?"), the ADR is cheaper than the archaeology.


5. uuid vs char(36): the migration that works until it doesn't

Private project — a support desk.

Three migrations added organisation-scoping foreign key columns declared as char(36) where the referenced primary keys were uuid.

On MySQL you'll probably never notice — uuid is a char(36) under the hood. On PostgreSQL it's a distinct native type, and a char(36) column cannot carry a foreign key to a uuid primary key. The migration runs fine; the constraint is the thing that refuses.

Three lines changed:

// before
$table->char('organization_id', 36)->nullable()->index();

// after
$table->uuid('organization_id')->nullable()->index();
Enter fullscreen mode Exit fullscreen mode

The lesson isn't "use uuid()". It's that char(36) and uuid() differ only on the driver you're not testing on. If your local is MySQL and production is Postgres — or the reverse — a whole category of schema bugs is invisible until deploy. Run migrations against your production driver in CI, even if nothing else runs there.


The thread running through all of it

Four of today's five items were failures that look like success: a green deploy that emptied a database, a queue that swallowed mail without an error, a masked field that could be mistaken for a permission, a migration that applied cleanly and left a constraint unenforced.

Crashes are easy — they page you. The work is in noticing the quiet ones, and then encoding the fix somewhere a future deploy has to walk past: a shared-file declaration, a sibling systemd unit, a docblock that says "this is not access control", a CI job on the right database driver.

Tomorrow: rollback semantics for the native release path.

Top comments (0)