An Installer That Locks Itself After First Use

By Lars Nielsen ·

CaRMen didn't have a setup flow for most of its life. Getting a fresh instance running meant running migrations by hand and inserting the first admin user directly through a database client — fine for a one-person project, less fine the moment I wanted to hand someone else a copy of the app without also handing them a shell. In October I added a real first-run installer at /installer: a system check, running pending migrations, creating the first admin account, and optionally loading demo data. The interesting part isn't the happy path — it's what happens the second time anyone tries to run it.

What "installed" actually means

The controller doesn't track installation state in a config flag or a settings file. It asks the database directly:

SELECT COUNT(*) FROM "user"

If that count is greater than zero, the application is installed. Not "an install step has run" — just "at least one user exists," which happens to be the one invariant that's true after a successful install and false before one, without needing anywhere else to record it. Simple enough that a corrupted or half-written config file can't leave the app confused about its own state; the question has exactly one place to be answered, and it's the same table the rest of the app already trusts for everything else about users.

Once that count is non-zero, every mutating endpoint under /api/install — run migrations, create the first admin — returns a flat 403, "Application is already installed." The one exception is /status, a read-only check that reports the same isInstalled() result without going through the locking path at all, so the frontend can always ask "are you installed?" without that question itself being treated as a mutation attempt. If the database can't be reached, /status doesn't error; it answers "not installed," because a missing database isn't something the installer can fix, and the frontend should still be able to show the installer.

Failing open on purpose, in exactly one place

There's a second wrinkle in how that check behaves under failure. If the database is unreachable, isInstalled() can't answer the question at all. For every mutating endpoint, that uncertainty fails closed: a 503, "Database unavailable," rather than risk running an install step while unsure whether one already happened. The single deliberate exception among the locked endpoints is /system-check, the step that checks the PHP version, the required extensions and the database connection. It fails open: if the database is unreachable, it runs anyway and reports the connection error, so the installer can show what's wrong instead of also going dark. It only reads and changes nothing, so running it while unsure costs nothing. Getting this backwards either way would have been worse: failing the mutating endpoints open risks a real install step running twice; failing the system check closed means the one diagnostic you'd reach for when the database is down is the thing that's broken.

The race the lock exists for

A single "is a user already there" check has an obvious hole: two requests to create the first admin, arriving close enough together, can both read zero before either one has written anything. Both would then believe they're the legitimate first admin. The fix wraps the actual creation in a transaction that takes a real database-level lock first:

LOCK TABLE "user" IN EXCLUSIVE MODE

and only then re-checks isInstalled() inside that same transaction, before persisting the new user. Whichever request gets the lock first sees zero users, creates the admin, and commits. The second request, now holding the lock itself, sees one — because the first request's write is now visible — and the function returns false instead of creating anything, which the controller turns into the same 403 an already-installed app would give a normal request. The check-then-act gap that made the race possible in the first place never opens, because the lock is held across both the check and the act, not just wrapped loosely around the eventual write.

Demo data without a second account

The optional demo-data step has its own small deliberate constraint: DemoDataLoader::load() takes the newly-created admin as its only input and attaches every sample account, contact, deal and activity to that one user. It doesn't create any users of its own. That's a direct contrast with the project's existing AppFixtures, which does create its own test users and is explicitly a dev-only tool for exactly that reason. A fixture class that inserts extra accounts is fine in a disposable local database; it's not something you want an installer offering to run against a production instance that's supposed to end up with exactly the admin you just created and nobody else.

Why bother, for an app with one real user

Nothing about this installer defends against anything exotic — there's no attacker model here, just an ordinary web form that two browser tabs or an impatient double-click could hit twice. But "ordinary" is exactly why it was worth getting right: the failure mode isn't a security incident, it's a second admin account nobody intended to create, or a locked-out setup flow after a half-finished first attempt, and both of those are the kind of quiet mess that's much easier to prevent once than to notice and clean up later. The reset-db.sh script that backs up and empties the dev database exists for the same reason the lock does: so the installer can be re-run and re-tested as often as the rest of the app needs to be, without "did the lock actually hold" ever becoming a question answered by trial and error against a real instance.

Filed under

Leave a comment

Comments are reviewed before they appear.

Basic HTML allowed: links, emphasis, code and lists. Web addresses become links automatically.