Deploying your app is the common case, but it is not the only thing you do to a live app. Every so often you need to run a single command against it: apply a database migration, seed some data, kick off a cleanup, run a maintenance script. These one off tasks are awkward on a lot of setups, because they need your app's exact environment, its config, its secrets, its database connection, but they are not a deploy. Burrow gives you a clean way to run them: burrow app run. This guide covers when to reach for it and how to use it well.

The basic shape

You run a one off command in your app's own current image and environment:

burrow app run web -- ./migrate up

Everything after the -- is the command to run. Burrow launches it using the same image your app is currently running and the same environment that app has: its config, its secrets, its wired in database connection. The command runs to completion, and then it cleans itself up.

That "same image and environment" part is the whole value. Your migration tool needs the same DATABASE_URL your app uses. Your seed script needs the same config. Because run uses the app's own setup, all of that is simply present. You are not copying credentials into a script or pointing a local tool at production by hand. The task runs where the app lives, as the app would.

Why not just do it another way?

Consider the alternatives people usually reach for. You could run the migration from your laptop, which means giving your laptop the production database credentials and hoping your local tool version matches. You could bake the migration into your app's startup, which means it runs on every deploy and every restart, which is not what you want. You could shell into a running container, which is fiddly and leaves you operating by hand inside a live process.

burrow app run avoids all of that. It uses the exact image and environment the app runs with, so there is no version drift and no credential shuffling. It is a separate, deliberate action, so it does not ride along on every deploy. And it is one command, so there is nothing to shell into and nothing to clean up afterward, because it cleans up after itself.

The tasks it is built for

Anything that is a run once operation against your app fits. The common ones:

Database migrations. The headline use. Apply your schema changes against the attached database, using the same connection the app has:

burrow app run web -- ./migrate up

Seeding data. Populate a fresh database, load reference data, set up a new environment:

burrow app run web -- ./seed --env prod

Maintenance and cleanup. Run a script that clears stale records, recomputes a cache, or fixes up data after an incident:

burrow app run web -- ./scripts/cleanup-stale-sessions

Ad hoc checks. Run a diagnostic command that needs the app's real environment to be meaningful.

The common thread is that these all want the app's environment and none of them are a deploy. That is exactly the gap run fills.

What run is not for

It is worth naming the boundary, because using run for the wrong thing leads to confusion. run is for tasks that start, do their work, and finish. It is not for anything that is meant to run continuously.

Your web server, a background worker that processes a queue forever, a scheduled job that should fire on a timer: none of those are one off tasks. Long running processes are your app, and they belong in a deploy, where Burrow keeps them healthy, restarts them if they fall over, and rolls them cleanly on the next version. A one off task, by contrast, is expected to end. Burrow launches it, waits for it to complete, and cleans it up.

So the test is simple: if the command is supposed to finish on its own, run is the right home for it. If it is supposed to keep going, it is part of your app, and it belongs in the image you deploy.

Which image it uses, and why that matters

run uses your app's current image, the one it is running right now. That is almost always what you want for a migration, because the migration that ships with a release should run against the version of the code that release expects.

The ordering follows from that. When a change includes both a schema migration and code that depends on the new schema, deploy the image first so it becomes the app's current image, then run the migration against that image:

burrow app deploy web --image ghcr.io/you/web:2.1.0
burrow app run web -- ./migrate up

Now the migration runs from the exact code you just shipped, using the exact environment that code runs in. There is no drift between "the version I tested the migration against" and "the version that ran it," because they are the same image. This is the same reproducibility benefit that deploying by image reference gives you, extended to your one off tasks.

Controlling how long the finished task sticks around

By default, a finished task hangs around for a little while before it is cleaned up, so you can look at how it went. You can tune that. To keep it around longer, or to have it removed the moment it finishes, set a lifetime:

burrow app run web --ttl 30m -- ./migrate up

That keeps the finished task for thirty minutes, then it is garbage collected. Set it to zero to delete it immediately on completion, or leave it off to take the default. Small knob, occasionally useful when you want to come back and check a task's output after the fact.

Running one off tasks through your agent

Like everything else in Burrow, this works the same when an agent drives it. Ask your agent to run your migrations after a deploy, and it runs the one off task in the app's environment:

burrow-agent run web -- ./migrate up

This is genuinely handy in an agent workflow. The agent ships a change that needs a migration, and instead of telling you to go run something by hand, it runs the migration itself, in the right place, as part of getting the change live. It reports whether the task succeeded, so you know the schema is ready before the new code depends on it.

And running a one off command is one of the actions Burrow can gate. Because a task runs real code in your app's real environment, potentially against production data, you can require it to hold for your sign off, per environment, just like a deploy. So in prod, an agent's migration can pause for your approval before it touches anything, while in staging it just runs. You decide where running a task deserves a human nod.

A clean migration workflow, end to end

Here is how the pieces fit for the most common case, shipping a change that needs a schema migration:

burrow app deploy web --image ghcr.io/you/web:2.1.0   # ship the new code
burrow app run web -- ./migrate up                    # apply the migration in the app's environment
burrow app status web                                 # confirm it is healthy

If your migration changes a lot of data and you want a safety net, take a backup first, so you have a way back if the migration goes wrong:

burrow addon backup postgres web
burrow app run web -- ./migrate up

Keeping tasks safe in prod

Because a one off task runs real code against your app's real environment, it deserves the same care as a deploy, and Burrow treats it that way. Running a task is one of the actions a guardrail can gate, per environment. So you can leave it wide open in staging, where a migration or a seed against throwaway data should just run, and require a sign off in prod, where the same command touches data that matters.

burrow guard set --env prod app.run confirm
burrow guard set --env staging app.run allow

With that in place, when an agent proposes running your migration against prod, it holds for your approval and comes back to you first, exactly like a prod deploy would. In staging it just runs. This is the same asymmetry that runs through everything in Burrow: fast where mistakes are cheap, a human nod where they are not. A one off task is not a lesser operation just because it is small, and pairing it with a guardrail means an agent can handle your routine tasks without you worrying that it will run something consequential against production on its own.

Watching a task and reading its result

A one off task is not fire and forget: you want to know it finished and whether it worked. run waits for the command to complete and reports how it ended, so the exit status of your migration or seed comes back to you rather than disappearing into the cluster. A task that exits cleanly reports success; one that fails reports the failure, which is exactly the signal you want before letting new code depend on a schema that did not actually migrate.

Because the finished task lingers for a little while by default, you can go back and read its output after the fact. That is useful when a migration prints a summary you want to eyeball, or when a task failed and you need the error it left behind. If you know up front you will want to inspect a task, that default lingering window is your friend; if you are running something noisy and routine, set the lifetime to zero so it is cleaned up the moment it finishes and does not pile up.

The important habit is to actually read the result, especially in a migration workflow. The whole point of running the migration as a deliberate, separate step (rather than baking it into startup) is that you get to see it succeed before the new code goes live against the changed schema. Deploy, run the migration, confirm it reported success, then confirm the app is healthy with burrow app status web. Each step tells you it worked before you lean on the next one.

The takeaway

What can go wrong, and how to think about it

A few rough edges are worth naming so they do not surprise you.

The task ran against the wrong version of the code. run uses the app's current image, the one running right now. If you ran a migration before deploying the code that expects the new schema, the migration ran against the old image. The fix is the ordering: deploy first so the new image becomes current, then run the migration against it. When code and schema change together, deploy, then run, every time.

The task failed halfway through. A one off task is real code doing real work, and it can fail like any other program: a bad migration, a network blip, a script that hits an unexpected row. run reports the failure rather than hiding it, so you know. For anything that changes a lot of data, take a backup first (burrow addon backup postgres web) so a failed migration has a clean way back. A backup before a risky migration is cheap insurance.

You reached for run for something long lived. If the command is meant to keep going (a worker, a server, a timer job), run is the wrong tool: it waits for completion and cleans up, so a process that never ends will just sit there. Long running work is your app and belongs in a deploy, where Burrow keeps it healthy and restarts it if it falls over. The test is simple: does the command finish on its own? If yes, run. If no, deploy.

The agent tried to run something in prod without asking. It cannot, if you have gated it. Running a task is one of the operations a guardrail covers (app.run), scoped per environment. With prod set to confirm, an agent's proposed task holds for your approval and comes back to you first; it never self approves. If a prod task ran that you did not expect, the guardrail was set to allow, not bypassed.

A short FAQ

Can a task use a different image than the deployed one? No, and that is the point. run uses the app's current image on purpose, so the task you run matches the code that is live. The reproducibility that deploying by image reference gives you extends to one off tasks: no drift between the version you tested a migration against and the version that ran it.

Does the agent run tasks the same way I do? Yes. The agent drives burrow-agent run web -- ./migrate up, which runs the same one off task in the same image and environment, subject to the same app.run guardrail. It reports whether the task succeeded, so an agent shipping a change can run its migration and confirm it landed before the new code depends on it.

Where do secrets and the database connection come from? From the app itself. A task inherits the app's exact environment: its config, its secrets, its wired in DATABASE_URL. You are not copying credentials into a script or pointing a local tool at production. The task runs where the app lives, as the app would.

One off tasks are a small thing that is annoying out of proportion to their size, mostly because they need your app's exact environment but are not a deploy. burrow app run gives them a proper home: they run in the app's own image and environment, so credentials and config are just there, they are deliberate rather than riding along on every restart, and they clean up after themselves. Migrations, seeds, cleanups, the occasional maintenance script, all one command, run in the right place, gated where it matters.