LaunchKit

A first-run setup wizard that refuses to run in production

September 17, 2026

The first ten minutes with a boilerplate are spent pasting keys into files you have not read yet. A wizard is the obvious fix, and a wizard that writes secrets is also the most dangerous screen in the application, so the interesting decisions are all about where it is allowed to exist.

The admin hub covers what else the console holds.

Where a Rails secret actually lives

Rails encrypted credentials are a file per environment: config/credentials/production.yml.enc, decrypted by config/credentials/production.key, which is the thing you never commit. The workflow Rails intends is bin/rails credentials:edit --environment production, an editor opens, you type YAML, it re-encrypts on save.

Rails credentials per environment is the whole design, and that workflow is fine. It is also why people give up on the first evening. You have to know the file exists, know the flag, know which keys the application reads, and get the nesting right with no feedback until something boots wrong. A wizard that lists the keys, shows which are missing and writes the file is the same operation with the guesswork removed.

The important part is that the destination does not change. The wizard is a nicer editor for credentials:edit, not a second configuration system. Nothing moves to the database, so nothing about how the application reads its secrets is different because a wizard exists.

Gated to local, every action

class SetupController < BaseController
  before_action :require_local

One before_action, applied to the whole controller rather than to the write actions, and that breadth is the point. A deployed wizard would be readable as well as writable, and a read of that screen is a list of exactly which secrets this installation holds.

Three things follow from the gate. A deployed console cannot rewrite a secret at runtime, so the credentials in a running production app are whatever was committed and deployed, full stop. The live checks, which call Stripe and send mail with a key somebody just typed into a browser, cannot be triggered from the internet. And the wizard's navigation link is hidden rather than shown and refused, so the deployed console does not advertise a door it will not open.

A secret moving left to right through three boxes. Local machine, running bin/rails server with the credentials key present. Git, holding production.yml.enc committed and encrypted. Production, with a read-only filesystem and its key from the environment. A green box under the local machine reads: the wizard runs here, writes the file, tests the keys, masks what exists. A yellow box under production reads: the wizard is not here, every action gated to local, the nav link is hidden. Caption: a deployed wizard is as readable as it is writable.

The read-only filesystem

Beyond the security argument there is one that would break the feature anyway. A production dyno on Heroku, and a container almost anywhere else, has an ephemeral and effectively read-only filesystem. Writing production.yml.enc there would either fail outright or succeed into a filesystem that disappears on the next restart, taking the secrets with it.

Worse than both: a partial write. The encrypted file is rewritten whole, so a failure midway leaves an unparseable file, and the next boot cannot read any credential rather than missing one. The local gate removes the entire class of problem by never attempting the write anywhere it cannot complete.

What a blank field has to mean

The form shows existing secrets masked, because showing them in full would put every key in the browser history and in any screenshot the founder takes. Masked values create an ambiguity that has to be resolved deliberately: when the field displays dots and the founder saves the step without touching it, the submitted value is either the dots or nothing at all.

The rule is that a blank submit leaves the stored value alone. The alternative, treating blank as an instruction to clear, means saving one step of the wizard silently empties every other key on the page, and the founder finds out at the next boot with an application that cannot reach Stripe.

This is not a nicety. It is the difference between a wizard you can revisit to change one thing and a wizard you can only ever fill in once, correctly, in a single pass.

Testing a key without lying about it

The wizard offers a live check: paste the Stripe secret key, press the button, get a yes or a no. The naive implementation calls Stripe::Account.retrieve and reports the result, and it is misleading in exactly one case.

if setup.current? && submitted.present? && Stripe.api_key.to_s.strip != submitted.strip
  return render_test ok: false, message: t("admin.setup.tests.stripe_restart")
end

account = Stripe::Account.retrieve(nil, { api_key: submitted })

Stripe.api_key was read once at boot. When you are configuring the environment the server is currently running as, and the key in the box differs from the one in memory, a green tick would confirm a key the application is not using. The guard asks for a restart instead.

For any other target, configuring production from your laptop, the running key is irrelevant and the submitted key is tested directly. Same button, two meanings, and only one of them is a claim about the running process.

What the wizard does not do

The wizard does not manage secrets over time. There is no rotation, no history, no record of who changed what, because there is one founder and a git history of the encrypted file.

It also does not remove the deploy. Writing production.yml.enc locally is step one of three: the file has to be committed and the application has to be deployed before production sees anything. A wizard that made a change feel live when it was not would be worse than no wizard, so the distinction is worth keeping in front of whoever is using it.

More on A Rails admin panel

← All A Rails admin panel articles