Work in progressThese docs are still being written and are currently mostly AI-generated. Some details may be inaccurate or incomplete.

Infrafile

One file at your repo root that describes how your project is built and deployed — the environments it supports, an interactive plan, a Dockerfile, and a deploy script.

An Infrafile is a single TypeScript file at the root of your repository that describes how that project gets built and shipped. It declares the environments you can deploy to, asks you anything it needs to know, renders a Dockerfile, and then deploys — all in one run you can watch.

It is not a workflow. It has no trigger, it is never stored in Infrawrench, and it is read fresh from your repository every single time it runs. The CLI reads it from disk; the web app pulls it from git.

defineInfra({
  envs: ["staging", "production"],

  async plan({ env, git, select }) {
    return {
      tag: `${env}-${git.sha.slice(0, 7)}`,
      replicas: env === "production" ? 3 : 1,
    };
  },

  dockerfile({ env, plan }) {
    return `FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci ${env === "production" ? "--omit=dev" : ""}
CMD ["node", "server.js"]`;
  },

  async deploy({ env, plan, image, push, notes }) {
    await push(image);
    const cluster = infra.accounts.kubernetes.getByName("prod");
    await cluster.importYaml(manifestFor(env, image, plan.replicas));
    await notes(`deployed ${image} — ${plan.replicas} replicas`);
  },
});

The three stages

plan(ctx) runs first. It has the full infra surface — every account, resource and output a workflow can reach — plus select and git. Whatever it returns is handed to the later stages as plan.

dockerfile(ctx) is pure: no await, no infra. It takes the environment and the plan and returns Dockerfile text. Splitting by environment is just an if.

deploy(ctx) runs after the image is built, with the full infra surface again. Applying a Kubernetes manifest, writing a DNS record, SSHing to a box — all ordinary calls.

There is also an optional fourth stage, destroy(ctx), which is not part of a deploy at all — it tears one down. See Tearing down.

Asking questions with select

const host = await select("build-host", "Build on", await droplets.list());

The first argument is a key, and it is what makes a deploy scriptable. Pass --set build-host=web-01 and the choice resolves with no prompt, so the same Infrafile runs unattended in CI. Without an answer in a non-interactive run the deploy fails and tells you which key it needed.

select returns the item you picked, not a label — so selecting a resource gives you back a usable resource.

Asking for anything else

select covers “pick one of these”. ask covers everything else — a version string, a replica count, a date, a yes/no:

async plan({ ask }) {
  const tag      = await ask("tag", "Image tag", { pattern: "^v[0-9]" });
  const replicas = await ask("replicas", "Replicas", { kind: "number", min: 1, max: 20, default: 2 });
  const when     = await ask("cutover", "Cut over on", { kind: "date" });
  const drain    = await ask("drain", "Drain connections first?", { kind: "boolean" });
  return { tag, replicas, when, drain };
}

Kinds are text (the default), password, number, date and boolean. Answers come back already typed — a number is a number, a date is an ISO YYYY-MM-DD string — so there is nothing to parse and no NaN to guard against.

Same keyed contract as select: --set replicas=4 answers it with no prompt, and a trigger’s stored answers do the same for a deploy nobody is watching.

Validation runs server-side rather than in the input box, which matters because --set never sees an input box. --set replicas=lots fails naming the key, instead of quietly becoming NaN and deploying nothing:

ask("replicas"): "lots" is not a number.

min/max bound numbers and dates, pattern constrains text, and required: false allows an empty answer. A default fills in a blank one.

Two reserved keys

Everything plan() returns is yours except two keys the host reads:

  • buildOn — where to build. An SSH-reachable resource, or the string "local" to use the Docker daemon on the machine running the deploy.
  • registry{ host, username, password }, used for docker login before a push. It is never written to the run log.

tag and buildArgs are honoured when present.

When the image is a build environment, not the artifact

Plenty of targets aren’t containers. A Cloudflare Worker, a static site, a Lambda bundle — none of them want your image deployed. They want a reproducible environment to be published from.

So build a toolchain and run inside it. run(command) executes in the image that was just built, with your project mounted at /workspace, and resolves with the command’s stdout:

defineInfra({
  envs: ["production"],

  async plan({ env }) {
    return { tag: env };
  },

  // The image is never pushed anywhere. It exists to hold wrangler.
  dockerfile() {
    return `FROM node:22-slim
WORKDIR /workspace
RUN npm install -g wrangler`;
  },

  async deploy({ run, notes }) {
    const token = await infra.accounts.cloudflare
      .getByName("main")
      .workers.get("my-worker")
      .resolveOutput("apiToken");

    const out = await run("wrangler deploy", {
      env: { CLOUDFLARE_API_TOKEN: token },
    });
    await notes(out.trim());
  },
});

run takes any command, so pre-deploy steps are just earlier calls:

async deploy({ run, image, push, notes }) {
  const url = await infra.accounts.neon.getByName("main")
    .databases.get("app").resolveOutput("connectionString");

  await run("npm run db:migrate", { env: { DATABASE_URL: url } });
  await push(image);
  await notes("migrated and shipped");
}

They run in order, and because a failure stops the deploy, a broken migration means nothing gets shipped.

A non-zero exit fails the deploy and surfaces the tail of stderr — a failed wrangler deploy should stop the run, not scroll past. Pass { allowFailure: true } to handle it yourself, and you get { exitCode, stdout, stderr } back instead of stdout.

Anything in env is passed to the container out of band and never appears in the run log or in the machine’s process list, so credentials are safe to put there. Do not interpolate them into the command string.

On a web deploy, run() executes on a Cloud Build worker with your project mounted at /workspace, exactly as it does locally — the image is staged so the worker can pull it. Each call is its own step, so combine work with && when round-trips matter.

Commands run through sh by default, so &&, pipes and npm run scripts all behave. That entrypoint is set explicitly rather than inherited from the image — a container’s arguments are appended to whatever ENTRYPOINT the Dockerfile declared, so an image that sets one would otherwise mangle every command. Pass entrypoint to use a different binary, or "" to clear it.

Other options: workdir (defaults to /workspace), mountSource: false for an image that already carries everything, and image to run something other than the one just built.

A deploy run in the web app mid-build, showing the stage indicator on "build" and Docker build output streaming into the live log panel

Deploying from the CLI

infrawrench deploy                      # one env declared? just go
infrawrench deploy --env production
infrawrench deploy --plan               # dry run: planned changes, plan and Dockerfile — nothing is touched
infrawrench deploy --env staging --set build-host=local --json
infrawrench deploy destroy -e staging   # run the destroy() stage — tear the env down

The CLI reads ./Infrafile (walking up to your repo root), builds with your local Docker daemon so you get your warm layer cache and need no VM, and prompts in the terminal for anything select asks about.

infra.accounts merges your local workspace’s accounts with your organization’s cloud accounts. The scope is settled before the run starts: at a terminal the CLI asks once (“local + org, or local only?”); --org <id|name> or --local answers it up front, and non-interactive runs default to local + your default org. Cloud accounts support listing, outputs, create and delete; surfaces that need a live connection from your machine (SSH, storage, SQL) still want a local account.

--plan is the safe first move, and it is a true dry run: resource creates, updates and deletes made through infra.accounts are intercepted, and nothing touches your providers. Each intercepted call is reported as a planned change — +, ~ and - lines with an “N to create, M to update, K to delete” summary — alongside the plan itself and the Dockerfile it rendered.

An intercepted create returns a synthetic resource whose id starts with planned:, and any output resolved on it comes back as the string (known after apply) — so a plan that threads a connection string from a just-created database into later decisions still runs to completion. The one thing that can’t work on a synthetic resource is waiting for it: plan() now receives a dryRun: boolean flag on its context, so a readiness wait — polling a provider until a branch or an endpoint really appears — should be skipped when ctx.dryRun is true.

--plan also prints a diff of the new plan against the plan recorded by this environment’s last successful deploy: top-level keys, marked +, - or ~, with secret-looking values redacted. The diff is best-effort — with no previous successful deploy there is nothing to compare against.

Because the build is local, the CLI can deploy a working tree with uncommitted changes — it will tell you when it spots them.

Deploying from the desktop app

Open Deploy in the sidebar. What it shows follows the org switcher:

  • With an organization selected it is the web app’s Deploy screen — the same repository and environment pickers, plan-then-deploy flow, live build output, deploy-on-push triggers and rollbacks, driven by the same hosted builds.
  • In local mode it is the history of what infrawrench deploy did on this machine: when each run happened, which environment and project directory, the commit (flagged when the tree was dirty), the image it produced, how far a failed run got, and how long it took. Expanding a run shows its notes and the error that stopped it.

Local deploys are still driven from the terminal, because a local deploy needs the two things the terminal already has: your working tree on disk and your own Docker daemon. The app is where you go afterwards to see what happened.

A run recorded on this machine is kept locally whether or not it was also reported to an organization, so a --local deploy — which never talks to an org at all — still leaves a trace you can read. The same list is available as infrawrench deploy log --local.

<insert [The desktop app’s Deploy tab in local mode, showing a table of past infrawrench deploy runs with one failed run expanded to reveal its error] here>

Deploying from the web app

Who can deploy

Three permissions, because previewing and shipping are different risks:

PermissionGrantsDefault for
deployments:readDeploy history, and a repo’s declared envsMembers
deployments:planRunning plan() — code execution, ships nothingMembers
deployments:writeBuilding, deploying, rolling backAdmins, owners

plan is separate from read because running plan() evaluates the repository’s Infrafile against your organization’s accounts — inert as far as your infrastructure goes, but still code execution. Members get it so they can see what a deploy would do; a custom role can withhold it without also taking away the history.

Plans

The Deploy page in the web app needs a paid plan (or complimentary access). On the free tier the page shows what the feature includes and an upgrade link instead. infrawrench deploy from the CLI builds on your own machine and is not gated — you can plan and deploy from there on any tier.

If a subscription lapses the message says so and points at Settings → Billing. A payment that is merely retrying (past_due) does not block deploys — losing the ability to ship should follow a cancellation, not a bounced card.

Open Deploy in the sidebar. Pick the repository and branch, load the Infrafile to populate the environment list, then Plan and Deploy. The same three stages run server-side.

Builds are hosted. You don’t need a build machine — your paid plan includes the build. Infrawrench sends your repository at the chosen commit, plus the Dockerfile your Infrafile rendered, to an isolated build worker, and pushes the result to the registry your plan() returned. That’s why it goes to your registry: your cluster can then pull it with credentials it already has.

Builds are capped at 20 minutes and metered, so a runaway RUN stops on its own.

A hosted build is quiet while it runs. The log gets Uploading source (12.4 MB), then Building <image> on Cloud Build, and then nothing until the build finishes and Built <image> in 84s appears — the worker’s per-layer output goes to storage rather than back down the wire, so there is no Step 3/9 to watch. A failure names the build id so you can read the full log in Google Cloud Build. Hosted run() steps do get their output, but in one burst once the step ends rather than as it happens.

Set buildOn to a resource if you’d rather build somewhere specific — a machine with a warm layer cache, or one inside a private network. It’s an override, not a requirement, and it is also the way to watch a build happen: that path streams every line of docker build and docker push into the log as it is printed.

The Deploy screen mid-build with the stage indicator on "build", showing the "Uploading source" and "Building … on Cloud Build" lines in the live log panel while the hosted build runs

Deploy on push

In the Deploy screen, add a trigger: when owner/repo main moves, deploy production. The watcher notices within 30 seconds and deploys the commit that moved it.

Two things worth knowing:

  • Adding a trigger does not deploy right now. It records where the branch is and fires on the next push. Arming a trigger shouldn’t ship whatever happened to be at HEAD when you clicked save.
  • A triggered deploy has nobody to ask. Any select(...) in your Infrafile must be pre-answered on the trigger, the same way --set answers it in CI. A key without an answer fails the run and names it.

The environment is checked against your Infrafile when you create the trigger, so a typo is caught then rather than silently never firing.

https://app.infrawrench.com/deploy/github.com/owner/name opens the deploy screen with that repository already selected — handy in a README, a runbook, or a chat message.

The link tolerates what people actually paste: a full https:// URL, a bare owner/name, a .git suffix, a trailing slash. If you belong to one organization it goes straight there; if you belong to several it asks which one, carrying the repository through either way.

If that organization’s GitHub App cannot see the repository, the screen says so rather than presenting a repo no deploy could read.

Editor support

Infrafile has no extension on purpose — it is a well-known filename like Dockerfile or Makefile. To get types and autocomplete, tell your editor it is TypeScript and drop the generated declarations next to it:

infrawrench deploy typings > Infrafile.d.ts

In VS Code:

{ "files.associations": { "Infrafile": "typescript" } }

The declarations are generated from your connected accounts, exactly like the ones a workflow gets, so infra.accounts. autocompletes with real account names.

Keep the file extensionless. If you name it Infrafile.ts, TypeScript treats Infrafile.d.ts as that file’s declaration output and silently excludes it from the program — you get “Cannot find name ‘defineInfra’” and no obvious reason why.

Rolling back

A rollback ships a previous deploy’s exact image again. It does not rebuild:

infrawrench deploy rollback --env production
infrawrench deploy rollback --to-run <runId>

or the Roll back button on any successful row in the web app’s history.

With no --to-run, the CLI picks the last successful deploy before the one currently live — which is what “roll back” usually means. Naming a run is how you go further back.

What actually happens: the Infrafile is re-read at the commit that run deployed — not at the branch head, so you get the deploy logic that shipped alongside those bytes — and its deploy() runs with the plan and image the run recorded. plan(), dockerfile() and the build are all skipped. The point of rolling back is to get the bytes that were known good, not to reconstruct something that ought to resemble them.

Two consequences worth knowing:

  • plan arrives as the recorded JSON, so anything in it that was a resource is now plain data rather than a live handle. A deploy() that needs handles should take them from infra.*, which works normally.
  • Only a successful run that produced an image can be rolled back to. A CLI run from a directory with no git remote has no repository recorded, so the web app cannot re-read its Infrafile.

run(...) still works during a rollback and targets the recorded image, so a npm run db:rollback step reaches the same toolchain the deploy originally used.

Undoing what a deploy created

Every run keeps a ledger of the resources it created through infra.accounts.*.create(...) — a self-provisioning Infrafile that spun up a database or a cluster records exactly what it spun up. A plain rollback never touches those resources: it re-ships the image and nothing else.

To also undo the provisioning, pass --delete-created:

infrawrench deploy rollback -e production --to-run <runId> --delete-created

Once the known-good image is confirmed live, every resource that runs after the target created is deleted — newest run first, children before their parents. The target run’s own resources survive; so does anything the rollback’s own deploy() just created. Deletions are best-effort and reported in the run’s notes: a resource somebody already removed by hand is noted, not fatal.

This is opt-in for a reason: a database created by the bad deploy is still a database, and it may hold data written since. Reach for --delete-created when the deploy’s provisioning was the mistake — not as a routine part of rolling back.

Tearing down

An Infrafile can declare a destroy(ctx) stage — the inverse of deploy():

async destroy({ env, git, notes }) {
  const namespace = git.pullRequest ? `app-pr-${git.pullRequest.number}` : `app-${env}`;
  // delete the namespace, the DNS record, the preview database…
  await notes(`tore down ${namespace}`);
}

Run it from the CLI:

infrawrench deploy destroy -e staging

At a terminal the CLI asks once before proceeding; non-interactive runs (CI tearing down a preview when its pull request closes) proceed without asking — that is the use case.

destroy gets no image and never prompts: by the time a preview closes its image is usually gone and nobody is at a terminal. What it does get is plan — the env’s last successful deploy’s recorded plan, when one exists — so a choice env and git cannot settle (which account, which region) should be written into the plan by the deploy that created it and read back here:

async destroy({ env, plan, notes }) {
  const account = infra.accounts.neon
    .list()
    .find((a) => a.displayName === plan?.neonAccount);
  // …
}

Treat plan as possibly absent — a teardown can outlive its deploy history — and fall back to naming by env/git. It arrives as recorded JSON (plain data, not live handles; take handles from infra.* as usual). destroy has the full infra surface, so deleting resources, applying manifests and SSHing all work normally, and the run is recorded in the deploy history with stage destroy.

An Infrafile with a "preview" environment should always declare destroy — without it a closed pull request leaves its environment running forever, and the only symptom is a bill.

Tearing down from the ledger

destroy() reconstructs what to delete from env and git. The other route is to delete exactly what the record says was created:

infrawrench deploy destroy --created -e staging

Instead of running the destroy() stage, this reads the environment’s recorded runs and deletes every resource they created — newest run first, children before their parents, the same order rollback --delete-created uses. Deletions are best-effort, reported with a or ! line per resource, so something already removed by hand is noted rather than fatal. --created requires an explicit -e: a ledger-driven teardown is too sweeping to aim at a defaulted environment.

An Infrafile with no destroy() stage no longer leaves a plain deploy destroy with nowhere to go — the message now points at --created, which needs no stage at all.

Outputs

notes() records prose for humans. infra.output(...) is its structured counterpart — call it from deploy() with whatever a script will want later, and the value is persisted on the run:

async deploy({ env, plan, image, push, notes }) {
  await push(image);
  const ip = await applyManifestAndWait(env, plan, image);
  await notes(`deployed ${image} behind ${ip}`);
  await infra.output({ url: `http://${ip}`, image });
}

infra.output already existed in workflows; calling it from a deploy works the same way, and the value lands on the deploy’s record. Read it back with:

infrawrench deploy outputs -e staging
infrawrench deploy outputs -e staging --json

which prints the latest successful deploy’s output as JSON — the service URL, the load-balancer IP, whatever deploy() recorded. --json is the mode for scripting; a jq '.url' away from a smoke test. When prose is what you want — “migrated and shipped” — that is still notes(); infra.output is for values another program reads.

Drift

Every run records the resources it created (the same ledger --delete-created and destroy --created read). deploy status checks that record against reality:

infrawrench deploy status -e staging
infrawrench deploy status -e staging --json

Each resource past runs created is reported as ok (still exists), missing (deleted out-of-band — somebody removed it at the provider), or unknown (the check could not be completed), followed by a summary. From the CLI the ledger consulted is this machine’s deploy history, so the report covers what this machine deployed.

What a deploy records

The Infrafile itself is never stored — not on your machine’s behalf, not in your organization. It is read from your repository on every run and discarded.

What is stored is the record of a run: env, commit, image, logs, the plan as JSON, the rendered Dockerfile, notes, the structured output recorded with infra.output(...) — and the created-resource ledger that --delete-created reads (see above).

The web app streams a run’s logs live and shows the plan and rendered Dockerfile before anything is built.

Stopping a run

Stop sits next to Deploy while a run is in flight, in the web app and in the desktop app’s Deploy tab. It is a request rather than a kill switch: the run unwinds at its next checkpoint, and a select(...) still waiting on an answer is resolved so the isolate can exit instead of sitting there until the execution budget expires. Clicking it before the connection is up is fine — the request is held and sent the moment the run is reachable — and the button reads “Stopping…” until the run ends.

A stopped run is recorded like any other unsuccessful one: it appears in the history as failed, at the stage it reached, with the reason attached. Anything it had already provisioned stays provisioned, and stays in that run’s created-resource ledger, so destroy --created can clean it up.

What a deploy cost

Every successful run carries a Cost impact button in the history: what the deploy did to your run rate, broken down over the resources it provisioned, from the provider’s own collected billing either side of it. The created-resource ledger above is what makes that attribution possible — a run that only re-shipped an image links to no resources and says so, rather than blaming whatever else drifted in the same hour.

See Cost per change & cost per deploy for how the comparison works, what “unknown” means (it is never “$0”), and why the figure keeps moving as provider billing arrives.

Asking an AI client about a deploy

The AI chat and the MCP server share a small set of deployment tools: list_deployments and get_deployment for the history and one run in full (logs, plan, rendered Dockerfile, error), list_deployable_repos for what your GitHub App can see, plan_deployment to run a repo’s plan() and render its Dockerfile without building anything, and rollback_deployment.

There is deliberately no tool that deploys. Building and shipping a release is slow, expensive, and lands bytes on real infrastructure that nothing can take back, so a human starts it — from the Deploy tab or infrawrench deploy. What a model gets is everything needed to reason about a deploy (“why did last night’s staging deploy fail?”, “what would deploying this branch do?”) plus rollback, which is the one deploy-shaped action that makes things safer.

plan_deployment is a destructive tool in chat despite building nothing: it executes your repository’s plan() against your organization’s infra surface, which is arbitrary code that can create or delete resources on its way to returning a plan. It waits for your approval, as does rollback_deployment. Both are audit-logged. They need deployments:plan and deployments:write permissions respectively; the read tools need deployments:read.

plan_deployment cannot answer a select(...) prompt — pass the choices up front as answers, keyed by the same key the Infrafile used.

See also

Supported providers

44 providers · 340+ resource types across cloud, infrastructure, databases, and more.