Skip to content
← Blog Blog

Delete the setup guide

Every repository ships instructions nobody keeps up to date, and a .env.example somebody has to fill in from a Slack message. Two tools — mise for the toolchain, fnox for the secrets — take both jobs out of the README and put them in the repo, with a fair account of what that costs and what it doesn't fix.

A cap beside a service hatch set into a fairway

Think about the last time you cloned a repository you’d never worked in before.

You read the README. It told you to install a JDK, and you had the wrong one, so you went and found the right one. It told you to install Node, and the version file said 18 while the lockfile clearly wanted 20. Somewhere near the bottom there was a line saying cp .env.example .env and then, in a smaller font, fill in the values. Fill them in from where? From a colleague. So you asked in Slack, someone pasted you a block of KEY=value lines, and now a production-adjacent credential lives in a chat history forever: searchable, backed up, exported to whatever compliance tool the company bought last year.

Nobody set out to build that. It’s what happens when the instructions live in a document instead of in the repository.

I’m the tech lead at GolfNext, a Danish company that builds software for golf clubs, and my first post on this blog was an inventory of what I’d walked into. Sixty-eight repositories, four product lines, thirteen years of accumulated technology choices: Java and Maven and Spring, Kotlin and Gradle, Node, Python, PHP, JavaFX, React Native. Starting work in one of them cold took somewhere between two and six hours depending which one you picked, and I set myself a target of five minutes from clone to running code.

This is the first serious attempt at that. Two tools, both by the same person, jdx. mise puts the right tools on your PATH, fnox puts the right secrets in your environment, and those are the two halves of one problem. mise is boring and widely used. fnox is neither of those things yet, and I’ll come back to that. What’s interesting is what falls away once both are in place, and what it does to the coding agents I have running in these repositories all day.

It’s mise as in mise en place, the kitchen thing. Pronounced “meez”, /miːz/, one syllable, no -uh on the end. You want that settled before you say it out loud in a meeting. fnox is easier: Fort Knox with most of the letters knocked out, so “eff-nox”, and the name tells you what it’s for.

Half one: the tools

If you’ve used nvm or pyenv you know the shape: declare a version, cd in, get that version. The difference is that mise doesn’t care what the version is of.

# .mise.toml
[tools]
java = "21"
maven = "latest"
node = "lts"
python = "latest"
gitleaks = "latest"

That’s a JVM, a build tool, a JavaScript runtime, a Python and a secret scanner (gitleaks) in one file, installed by one command, on your PATH only inside this directory tree. Add mise activate to your shell profile and even the command goes away: cd into the repo and the tools are there, cd out and they aren’t. It also sets the environment variables that go with them, so JAVA_HOME points at the JDK this project pinned rather than whatever your operating system decided last.

“Version manager” undersells this, and I want to be specific about why. The expensive part of a polyglot estate is the switching, not any single stack. You spend a morning in a Spring service, then move to an Android repo, then a small Python tool, and each hop is a chance for your shell to be wrong in a way that produces a confusing error rather than an obvious one. In one of our repositories a newer system JDK doesn’t fail loudly. It breaks a mocking library during tests, several layers down, with a message that has nothing to do with Java versions. That’s an hour of somebody’s life, and you pay it again every time a new person joins or a laptop gets replaced.

A per-language tool can’t help, because the problem is between languages.

One nuance you may as well steal instead of discovering. We deliberately let some of those versions float: latest for the scanner, lts for Node. What a build is made of is pinned in the CI workflow, and that pin is the one dependency automation watches and raises pull requests against. If we pinned the same versions in .mise.toml as well, we’d have two places to keep in step, and one of them would quietly fall behind. We know this because it had already happened, a .mise.toml claiming Node 22 while all four workflows said 24. Only one file gets to be authoritative, and it’s the workflow.

The JDK is the exception, pinned hard in both places on purpose. A wrong major version there is exactly the mocking-library failure above, and I’d rather keep two files in step than rediscover that error at four in the afternoon. Everything else floats.

The part I didn’t expect to care about

Every repository has a handful of things you do to it: build it, test it, run it, format it. In a Maven project that’s mvn spring-boot:run -Dspring-boot.run.profiles=local. In a Gradle project it’s ./gradlew and a task name with four words glued together. In a Node project it’s whatever the scripts block says this week. None of it is hard. All of it is something you have to look up when you’re in an unfamiliar repo, which is exactly when you have the least patience for looking things up.

mise lets you put those verbs in the same file as the tools:

[tasks.build]
description = "Package the artifact"
run = "mvn clean package -B"

[tasks.test]
description = "Full test suite (integration tests need Docker)"
run = "mvn clean test -B"

[tasks.serve]
description = "Run the API on http://127.0.0.1:5001 (starts the stack first)"
depends = ["stack:up"]
run = "mvn spring-boot:run -Dspring-boot.run.profiles=local"

[tasks."stack:up"]
description = "Start the local database and the mocks. Idempotent"
run = "docker compose up -d --wait"

So the interface becomes mise run build, mise run test, mise run serve. In the Android repository next door the same verbs sit on top of Gradle instead, and one of them wraps a four-word task name I still can’t type from memory. Doesn’t matter. mise tasks lists what a repository can do, with descriptions, and the answer is in the repo rather than in a wiki page that stopped being true in 2023.

Two small conventions have earned their place. A bare verb is the common case and verb:qualifier narrows it: test and test:signer, stack:up and stack:down and stack:clean. There’s also always an escape hatch task that wraps the underlying build tool directly, so anything the curated list doesn’t cover is still one command away:

[tasks.mvn]
description = "Any Maven command, with this repo's JDK and environment"
run = "mvn"

That escape hatch matters for the second half of the story.

Half two: the secrets

This is the part I actually wanted to fix.

The .env.example pattern is so common that it reads as a best practice. It’s a convention that gave up. Look at what it asks of a team.

A new developer copies a file of empty keys and then has to source real values from somewhere the repository can’t tell them about. In practice that means a person, which means a chat message, and now a secret exists in a second system with different retention rules and different access control than the one you chose on purpose. When a value rotates, every laptop holding the old one is silently wrong until someone notices, and the failure usually shows up as a confusing 401 rather than a message saying “your credential is four months stale”. When a new secret is added, everyone else’s .env is incomplete and nobody is told. And the file itself sits in your working directory in plaintext, one bad git add -A away from being committed, which is why every repository that uses this pattern also has a .gitignore line that somebody could delete.

The alternative isn’t complicated. Put the values in a secret store you already trust, put references to them in the repository, and let each developer’s own identity decide whether they resolve.

That’s fnox. Here’s the stage config for a service, committed, with no values anywhere in it:

# fnox.stage.toml
env = "exec"

[providers]
store = { type = "…" }

[secrets]
DB_PASSWORD    = { provider = "store", value = "DB_PASSWORD",    if_missing = "error" }
PAYMENT_API_KEY = { provider = "store", value = "PAYMENT_API_KEY", if_missing = "error" }
SIGNING_KEY    = { provider = "store", value = "SIGNING_KEY",    if_missing = "error" }

The type is the only line that’s specific to you, and I’ve left it blank on purpose, because which store you use is the least interesting decision in the whole setup. fnox speaks to Vault, 1Password, SOPS, and the managed secret stores of the big clouds. We took the one our provider already gave us, for the boring reason that we were paying for it and it already had access control we trusted. Everything else in the file is the same whichever way you go.

What that file buys:

The repository documents its own secrets. Every name a service needs is listed in one place, in git, with review history. New secret? It’s in the file, it arrives with git pull, and the next run either finds it or fails loudly because of that if_missing = "error". Nobody has to be told anything.

Rotation stops being an event. Change the value in the store; every machine picks it up on its next run. There’s no fleet of stale copies.

Access goes back to being an authorization question. Whether you can run against a given environment is decided by your identity in the secret store, not by whether someone remembered to forward you a file. Revoking someone becomes a permissions change instead of a hope that they delete a file.

And there’s a dry run. fnox check tells you whether every declared secret resolves for you, without printing any of them. It’s the fastest possible answer to “is it me or is it broken”, which is the question that eats onboarding days.

Then one deliberate restraint. That env = "exec" line means secrets are never injected into an interactive shell, only into the environment of a child process fnox launches. fnox will happily do the other thing; there’s a mode that puts everything into your shell the moment you enter the directory. We don’t use it. I don’t want every process started from that directory to inherit production-adjacent credentials, and that includes editors, and it includes the coding agents I have running in there most of the day. Scoping secrets to fnox exec -- <command> is the whole difference between “the build can see the signing key” and “everything on my machine can see the signing key”.

That last part is what I’d underestimated. With nothing on disk and nothing in the shell, a secret only exists inside a process that something deliberately started, which turns “what can the agent reach” into an ordinary permission decision. Deny it fnox and the handful of tasks that call it, allow it everything else, and it can read the repository, build, test and format without a credential ever entering its context.

Then the hole, which I’d rather name than have someone else name for me. fnox reads the store as me, using credentials that are sitting on my machine regardless. An agent with a shell doesn’t need fnox at all; it can query the store with the provider’s own CLI. Denying fnox on its own closes the front door of a building with no walls. What does the real work is that the same allowlist doesn’t include that CLI, and that what’s within reach from a dev machine is stage rather than production. An agent that can edit .mise.toml can also write itself a task that shells out to anything. The deny rule is a fence, not a proof.

But hold it against what it replaces: a .env file sitting in the working directory in plaintext, readable by anything that can call cat, which is every agent anyone has ever run. Runtime-only injection plus a deny rule is, as far as I can tell, about as good as this currently gets.

Where the two halves meet

Individually these are two decent tools. Together they remove a category of manual step.

Because mise tasks are just commands, a task can be fnox exec -- something:

[tasks.gradle]
description = "The escape hatch from earlier, now with the build secrets loaded"
run = "fnox exec -- ./gradlew"

[tasks.secrets]
description = "Check that every build secret resolves for you"
run = "fnox check"

[tasks."serve:stage"]
description = "Run locally against stage secrets (needs cloud access)"
depends = ["stack:up"]
run = "fnox -c fnox.stage.toml exec -- mvn spring-boot:run"

Now mise run gradle :app:assembleRelease is a signed release build, and the person running it never types the word fnox, never learns which of the build’s inputs are secret, and never holds a copy of any of them. Nobody has to remember which commands need secrets. The task knows.

There’s one in there for anyone who lives in an IDE:

[tasks.ide]
description = "Launch the IDE with the build secrets in its environment (macOS)"
run = 'fnox exec -- open -na "Android Studio" --args "$PWD"'

An IDE knows nothing about any of this, so a build started from inside it can’t sign. Launch the IDE through fnox and its Gradle inherits the environment. It’s one line, and it’s the difference between a working release build and a signing error nobody can explain, so it stays in the file. I don’t reach for it much myself: most of my day is a terminal, a prompt and an agent doing the typing. That’s a preference, not a recommendation, and the task is there for whoever wants the other thing.

Then there’s the split by environment. One file each, the same variable names in all of them, different values behind those names:

fnox.toml         # local
fnox.stage.toml   # stage
fnox.prod.toml    # production

A task points at the file it needs. Anything that belongs to an environment goes in that environment’s file, so nobody can pair stage credentials with local settings by accident.

The local one is the file I’d point at if you only read one. It declares almost no secrets, because everything a local run needs already has a harmless default committed in the repository: a local database, a mock in front of anything external, obviously fake keys for the paid services. A fresh clone boots and serves for someone with no access to the secret store at all, and anything that would reach a real provider fails authentication rather than doing something expensive. Access buys you stage. It isn’t the price of entry.

Committing references means scanning for values

Committing secret references only works if you’re sure nothing real ever slips in beside them. So the same setup carries a scanner, and mise installs it without anybody being asked to:

[tools]
gitleaks = "latest"

[hooks]
postinstall = "git config core.hooksPath .githooks"

mise trust is the first thing anyone runs, and the first mise run after it installs the toolchain, which is also what fires the postinstall hook and points git at a directory holding a pre-commit secret scan. It arrives as a side effect of the step they were taking anyway, rather than a separate instruction that people skip.

The hook scans staged content only, so it’s fast, and if mise isn’t on the PATH it skips with a loud warning instead of failing. A GUI git client’s environment is often minimal, and blocking someone’s commit over a PATH problem is a good way to teach them to pass --no-verify forever. CI scans the same commits again after the push.

And the deployments

People tend to assume this bit works differently than it does.

Our deployed environments don’t run fnox. The platform they run on reads the same values from the same store itself and hands them to the process as ordinary environment variables. No application code knows about any of this. Nothing in the runtime imports a library, and no build has to be told which environment it’s for.

That’s deliberate. The store is the source of truth. fnox is how a human reads that truth, with their own credentials, on their own machine, and the deployment reads the same truth with the machine’s identity. The two never diverge, and the application on both sides just sees environment variables like it always did.

What it doesn’t fix

The honest version, then.

You still have to install mise, and put mise activate in your shell profile. That’s a real setup step and I’m not going to pretend it away. It’s one step, it’s the same step in all sixty-eight repositories, and it never goes stale, which is three things the paragraph it replaced could not say.

Docker is still yours. mise installs tools, not daemons. Our local stacks need containers, and the first-run experience for anyone without Docker configured is still a manual step and a download measured in gigabytes. Same for Xcode, which remains its own weather system.

Getting the tooling right removes the stupid part of a cold start. It does nothing about the hard part, which is understanding what the service actually does. That’s still reading code, and it still takes days.

You’re adding two dependencies to every developer’s day. mise is mature and widely used. fnox is young. We’ve committed real workflows to it, and if it stalls we’d be doing a migration. I think that’s a reasonable bet: the file is declarative, the values live in a store we’d keep either way, and the fallback is a shell script that reads the same parameters. But it is a bet, and pretending otherwise would be dishonest.

One line in that config deserves a careful review. fnox can be told to run a command that logs you in to the store when your session has expired, which is genuinely convenient and also means a committed config file now specifies something that executes on every reviewer’s laptop. Review that line the way you’d review a CI workflow.

There’s also a failure mode with a misleading error. Point the provider at the wrong account and what comes back is “not found”, which reads like a missing secret rather than a wrong identity, and sends people looking in entirely the wrong place. Both of our files carry a comment naming exactly which account the credentials have to resolve to.

Where we actually are

Two repositories fully on fnox. A handful more on mise. Sixty-odd to go, most of which will never need the secrets half at all.

I haven’t put a stopwatch on it and I’m not going to invent a figure I didn’t measure. The shape of the answer is clear enough though. On a machine that’s been through this once already, with mise installed and the toolchain cached and Docker running, a clone to a serving API is under a minute. Comfortably. What’s left of the old two-to-six hours is all first-time-on-this-laptop cost: downloading a JDK, pulling images, installing mise itself. You pay that once, rather than once per repository, and that second part is the whole difference from where we started.

In the repositories where this landed, the setup section of the README went from a list of things to install and a paragraph about asking a colleague down to this:

mise trust          # once, to allow this repository's config
mise run serve

Two lines, and the second one is the one you wanted anyway. The first is mise asking whether you actually trust a config file that’s about to install software and set environment variables, which, given that this file can now run commands on your machine, is a prompt I’m glad exists.

The pattern generalises well past our particular stack, which is why I think it’s worth writing down rather than filing as an internal chore.

Then delete the setup guide, because it’s finally lying about how much work there is.