Skip to content

How Flui reads a repository

There are three ways to get from a repository to a running application on Flui, and none of them ranks above the others. You can write flui.yaml yourself. You can bring an image you already built. Or you can ask Flui to read the repository and tell you what it found. That result is called a map, and it is the subject of this chapter.

A tool that reads your code and writes a deploy configuration for you can easily become a black box: it says “deployable” and you either trust it or you don’t. Flui avoids this by never letting the map say more than it can back up. Every fact it reports carries where it was read and how sure the engine is of it. Where it could not tell, it says so and asks instead of picking a default.

What gets read

Flui reads a repository as one in-memory archive of a single commit: no clone, and nothing left on disk once the read ends. Symlinks are refused, never followed. The read runs under ceilings: how much of the archive it will decompress, how large a single file it will look inside, how many paths it will list, and how long the whole read may take. Every ceiling is reported back in the response, together with whether the read hit one. A fact missing because the read stopped at a ceiling is different from a fact that was looked for and not found, and the response says which one happened.

What comes back

Producing a map is read-only: the route is POST /repositories/:id/map, and calling it touches nothing on the repository or on a cluster. A map holds:

  • Units: the directories Flui would deploy, each with the reason it counts as one (its own Dockerfile, a compose service, the repository root).
  • Services: the databases, caches and queues the repository seems to want, each matched against a catalog block where one exists.
  • Inputs: the values you would have to supply, secret or not, before a unit can start.
  • Externals: dependencies on things outside Flui’s catalog, such as a third-party API or a managed service the repository was written to expect.
  • Blockers: things Flui cannot provide, such as a Docker socket mounted in, a build that wants a private-registry secret at build time, or a GPU reservation.
  • Caveats: cases where the unit runs, degraded. For example, an embedded SQLite on what Flui would put on shared storage, or two composition files that might declare the same database twice.
  • Open questions: where the evidence does not settle something on its own.
  • Decisions: a choice made on your behalf where more than one reading was possible, with the reason and the alternative.

For every unit the render could produce a manifest for, the map also holds one rendered flui.yaml: the same kind of file you would write by hand, generated from what was read.

A citation and a confidence, on every fact

Every entry (a port, a service, a blocker) carries an evidence list: the file, the line where there is one, and the exact token that was read. You can open the file at that line and check it yourself.

Every entry also carries a confidence: declared (the repository states it outright, such as an EXPOSE in a Dockerfile or a DATABASE_URL read straight out of docker-compose.yml), derived (inferred from something else that is itself declared), or circumstantial (a weaker signal, such as an import or a naming convention). Corroboration never promotes a reading: three circumstantial signals pointing the same way are still circumstantial.

A verdict, always, from a closed list

Every map ends in a verdict, one of six: deployable, deployable_pending_inputs, partial, blocked, insufficient_capacity, not_assessed. There is no seventh answer.

The verdict depends on the repository and the cluster together, not on the repository alone. Ask for a map with no clusterId and you get only the repository half (each unit’s own readiness, and whatever blockers or caveats apply) with verdict.capacity.assessed: false and the reason. A deployable that was never weighed against a real cluster must never read as one that was. Name a cluster and the map also adds up the CPU and memory every unit and every matched service would request (a catalog block’s own declared resources, or the platform default for a unit that declares none) and compares the total with what that cluster has free, using the same safety margin as the rest of the platform’s capacity gate. Anything that would run but could not be weighed (a service with no catalog block behind it, say) is named in uncounted rather than silently counted as free.

coverage on a map always reads best_effort. The engine reserves supported for accuracy earned by measurement, never for structure alone, so no map claims it, whatever else it reports.

Where it stops instead of guessing

Open questions are expected. Where the evidence does not settle something (which of two declared databases is the real one, or whether a route the repository exposes is a health check), the map says so, names the readings the evidence supports, and leaves the choice to you.

A blocker is something Flui has no way to provide: a Docker socket mounted into the container, a build that needs a secret at build time, a kernel capability the platform does not expose. A blocked unit always carries a remedy.

One blocker applies to the repository as a whole rather than to a unit: more than five deployable applications. Past that number, the reader stops instead of reading each one on its own files, and the verdict says so by name. It lists the roots it found, says why each was promoted, and asks you to name at most five and map them separately, or to split the repository. It does not rank them for you: on a workspace that large, any ranking it could apply would recommend a test harness or a build helper with the same confidence as an application.

Acting on the map

Reading a map changes nothing. Acting on one is a separate call, POST /repositories/:id/map/apply, and its scope is narrow.

Flui cuts its own branch, flui/deploy-<sha7>, at the exact commit the map was read from, not at the tip of your branch, which may have moved since. Flui reads from your branch and cuts from it, but never writes to it. One commit lands on the new branch, carrying every applied unit’s rendered flui.yaml and a build workflow beside it, and that single commit starts every build. That is why the apply lands as one commit and not one per unit: several commits would mean several builds racing each other over the same webhook.

If a unit’s rendered manifest declares deploy.services (a database, say), that service is installed from the catalog and Flui waits for it to reach RUNNING before anything is written to GitHub. The application row and its database both exist before the commit lands; the commit starts the build on top of them. An apply refuses on a verdict of blocked, insufficient_capacity or not_assessed, since spending your repository’s Actions minutes to reach a conclusion the map already reached for free would be pointless. You can also apply only some of the units (--unit, repeated, on the CLI; unitIds on the API and MCP) instead of the whole map.

Because an apply provisions real things (databases, applications) before it touches GitHub, a failure partway through can leave something behind. If one unit of several cannot be prepared, Flui deletes the branch it cut, so your repository stays untouched, but the units prepared before the failure already exist, with whatever they attached already running. Flui names them in the error instead of deleting them, and the next apply of the same repository reuses them instead of creating a second copy.

This is also the one place where the time an apply needs and the time a caller is given can disagree. Waiting for a newly installed database to reach RUNNING takes minutes, which is longer than an MCP tool call lasts. See The Flui assistant and MCP for what happens when the two collide, and why the CLI does not have this problem.

Three paths, at parity

The map is one of three ways to reach a running application, and Flui’s own tools state this: reading the repository is “ONE way to reach a deploy and not the required one,” and bringing an image you already built “carries the same standing as the two routes that go through it.” None of the three is a fallback for the others:

  • The map: Flui reads the repository and renders the manifest for you.
  • Your own manifest: you write flui.yaml by hand; flui deploy or app_deploy_from_yaml takes it from there.
  • An image already built: the dashboard’s Deploy New → From a Docker image, or app_deploy_image over MCP, skips the repository and the build entirely.

See Deploy an application for the map and the hand-written manifest side by side.

A limit: two units, two databases

deploy.services attaches a building block to one application. Ownership is recorded per application: an install belongs to the row that asked for it, and nothing else can claim it.

That works for a single application, but it leaves a gap for a monorepo. Two units that each declare the same database (a shared Postgres that two services in one repository both want to use) are two separate applications once deployed, so each gets its own database. No field in the manifest says “this is the same instance as that other unit’s”: the spec does not carry one, and the render does not emit one. If your repository’s units are meant to share a database, wire that by hand after the apply.

Getting to it

Where this chapter goes from here