Skill v1.0.0
currentLLM-judged scan90/100version: "1.0.0" name: deploy-to-nibrun description: Deploy a compiled binary to nibrun and run it as an HTTPS service. Use when asked to deploy, ship, host or run a self-contained server binary (Bun, Go, Rust, Zig, C) or a folder of static assets on nibrun, when working in a repo that targets nibrun, or when deciding whether nibrun fits an app.
Deploy to nibrun
nibrun takes one compiled binary and gives it a microVM of its own, a persistent filesystem, and an HTTPS URL. No Dockerfile, no YAML, no cluster.
Build for Linux x86_64, deploy through nibrun's MCP server or CLI, then ask the URL for something. What that path does not need is below it: copying an app, the guest contract, naming a runtime value, a second public port, cron jobs, and what nibrun does not do.
For CI deployments, start at GitHub Actions.
1. Sign in
If using a connected nibrun MCP server, use its authenticated account and skip CLI sign-in. Otherwise, check the CLI:
nib apps list
Both halves in one command: command not found means the CLI is not installed, Not signed in. means it is not signed in.
curl -fsSL https://nibrun.com/install.sh | sh # installs `nib` to ~/.local/binnib login # device flow: approve it in the browser
nib login waits on a human approving it in a browser, so an agent that finds itself signed out asks the user to run it rather than trying to drive it.
2. Build the binary
One self-contained linux-x86_64 file — static, or dynamically linked against glibc, which the rootfs carries. It has to be built for that target: a binary compiled on a Mac, or for arm64, is the most common reason a first deploy never boots.
If the repo already builds one, run its build. A project that ships a binary usually wraps more than a compiler invocation — assets embedded, constants substituted at build time, a frontend compiled first — and a hand-rolled command silently skips all of it, producing something that links and then dies on boot. bun-full-stack-starter is one such: bun run build gives backend/dist/app with the frontend and the migrations inside it, defaulting to PORT 3000 and ./data.
A Bun repo with nothing to inherit compiles one itself with bun build --compile, targeting bun-linux-x64. Embedding an asset directory, bytecode, build-time constants — all flags on that same command, and worth reading Bun's single-file executable docs for rather than recalling: a flag invented from memory is how a binary ends up missing the files it expects to carry.
Three things to read off the binary before deploying rather than after:
- The port it listens on, and that it binds
0.0.0.0rather than127.0.0.1. The guest hands
the number back as NIBRUN_HTTP_PORT and PORT, so an app that reads either needs no configuration here.
- Where it writes. Only
/app/datasurvives a redeploy, and an app that keeps its SQLite file
and its uploads under ./data is already there.
- What it needs from the environment, off a
.env.exampleor whatever it loads config from. It
has to be there on the first deploy: a process that exits over a missing variable never starts serving, and the deploy fails with it.
A folder of static assets has no binary to build: nib is one, and nib serve answers for whatever folder it is given, on the port the guest hands it. So the folder goes up as the app's data/, and the CLI's own Linux build as the binary that serves it:
nib run "https://github.com/ilbertt/nibrun/releases/latest/download/nib-linux-x64 serve /app/data" --name my-site --data-folder ./dist
A path naming a directory answers with its index.html, and a miss with the folder's own 404.html where it has one; serve /app/data --single-page answers every miss with the root index.html instead, for a SPA whose routes exist only in the browser. No --port: nib serve binds whichever port the guest assigns. The assets ride in as data, which goes up only as the app is created — a rebuilt site is a new app, at a new URL, and a custom domain (nib apps domains) is what keeps an address across that.
3. Deploy
With MCP
Choose the app: supply a name and optional initial config to create one, or an appId to redeploy an existing app. Initial configuration includes the environment, arguments and HTTP port needed on first boot. Existing apps reuse their saved configuration. Reuse the returned app.id as appId for subsequent deployments; a name creates a new app.
Include chatId in deploy_app when your current chat ID is available; omit it rather than guessing. Without it, nibrun records the client's _meta["openai/session"] when available. This metadata is an anonymized conversation ID and cannot be assumed to resolve to a chat URL.
Choose one binary source:
- Native file binding: attach the compiled executable to
deploy_app'sbinaryinput using
the client's file binding.
- HTTPS download: pass
urltodeploy_appwhen the binary is already reachable over HTTPS
from nibrun. Signed download URLs work. For binary or url, an optional sha256 verifies the downloaded bytes.
- Direct upload: if native file binding is unavailable, use
prepare_deployas described
below. This requires an agent that can read the compiled file and upload bytes over HTTP.
For a direct upload:
- Call
prepare_deploywith the app target, the binary'sfilenameand its actualsizeBytes.
It returns app and upload, creating the app if needed. It does not deploy anything.
- PUT the raw binary as the whole request body to
upload.url, withContent-Lengthequal to
sizeBytes. The signed URL authorizes this request; send no nibrun authorization token. Wait for a successful 2xx response.
- Call
deploy_appwithappId: app.idandartifactId: upload.artifactId. nibrun finalizes and
validates the uploaded binary, then queues the deployment.
Each deploy_app call creates a deployment and replaces the current release. The result contains app, deployment and the public url; a pending deployment is only queued. Use get_app to check the returned deployment and request the public URL before reporting success.
With the CLI
The first deploy creates the app:
nib run ./my-server --name my-app --port 8080
--port is the HTTP port the binary listens on inside the guest — read it off the app rather than carrying a number over from an example. It is the number the guest hands back as NIBRUN_HTTP_PORT and PORT, and it defaults to 3000.
An app can be created with its data/ already holding something — a seeded SQLite file, a corpus, fixtures a first run would otherwise have to write:
nib run ./my-server --name my-app --data-folder ./seed
--data-folder accepts a local folder, a .tar.gz or .zip file, or an HTTPS URL to either archive format. nibrun fetches remote archives directly, so there is no need to download them locally first. The URL must name the archive in its path and be reachable from nibrun; signed query strings work, so quote URLs that contain them:
nib run ./my-server --name my-app --data-folder 'https://example.com/seed.tar.gz'
For every source, the root of the archive becomes the root of `data/`: a folder is packed from the inside, so ./seed/app.db arrives as /app/data/app.db, and bundling the folder rather than its contents puts every file one directory deeper than the app looks. Up to 1 GiB uploaded or fetched and 2 GiB unpacked, and only as the app is created: this and the app writing its own files are the whole of how anything gets onto the volume. A zip made anywhere but unix carries no permissions, so an executable bit does not survive one.
Try seeding first-run setup before going public
Some apps let the first visitor claim the admin account. Their public URL is reachable as soon as they boot, so check whether that setup can be seeded before deploying. This depends on what the app supports and what can run on the local machine:
- Look for a build for the local OS and architecture, or a supported way to run the app from
source. The Linux binary uploaded to nibrun cannot run directly on macOS. Use the same app version and check that its data can move between the local and Linux builds.
- If supported, seed a dedicated data folder using the app's documented initialization command,
or run the local build bound to 127.0.0.1, complete admin setup, and stop it cleanly.
- Deploy the seeded folder with
--data-folder, using the Linux x86_64 binary. Supply any
encryption keys or other environment values the saved data needs on this first deploy.
- Check the public app requires the configured login and no longer offers an initial admin claim.
nib run ./my-server-linux-x64 --name my-app --data-folder ./configured-data
If seeding succeeds, the app starts with its admin account already claimed. Keep the configured data folder private: it contains the app's account state.
If a compatible local build or initialization method is unavailable, check whether the app can set initial admin credentials through configuration on its first boot. If neither approach is supported, explain that seeding could not be done and that first-visitor setup will be reachable when the public URL goes live.
Redeploying
Every deploy after that must name the app, or a non-interactive shell creates a second one. nib apps list finds the name again when a later session has to redeploy:
nib run ./my-server --app my-app
For a local binary, the CLI skips uploading when that app already has an artifact with the same bytes and filename. It still creates a new deployment with the configuration you supplied.
Environment variables are an edit, not a replacement — anything a deploy does not name is left alone, so secrets are set once:
nib run ./my-server --app my-app --env STRIPE_SECRET_KEY=sk_live_...
Arguments for the binary go inside the quotes, not after them:
nib run "./my-server serve --verbose" --app my-app
The binary may be an https url instead of a path, and nibrun fetches it rather than this machine uploading it:
nib run https://github.com/me/my-app/releases/download/v1/my-server --app my-app
Changing only how the binary starts is nib apps update, which runs the one the app already has rather than asking for it again. What no flag names is left alone:
nib apps update --app my-app --env LOG_LEVEL=debugnib apps update --app my-app --args "serve --verbose"
nib run waits until the deployment is actually serving and prints the URL. Or drag the binary onto app.nibrun.com — same thing, no CLI.
Every option a deploy takes is listed by nib run anything --help — any word will do in place of the binary, because the help does not read it, and nib run --help on its own answers with the subcommand rather than the options. What is below is what --help does not say.
GitHub Actions
Deploy an existing app without an API key or nib login in CI. Use a CLI release with GitHub OIDC support. After signing in locally, configure which workflow may deploy:
nib apps github-workflows set --app my-app --repository OWNER/REPO --workflow deploy.yml
Save this as .github/workflows/deploy.yml. Replace ./build.sh with the repository’s build command, ./my-server with its Linux x86_64 output, and APP_ID with the app ID from the setup command above:
name: Deploy to nibrunon:push:branches: [main]permissions:contents: readid-token: writejobs:deploy:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v7- uses: nibrunhq/setup-cli@v1- run: ./build.sh- run: nib run ./my-server --github-trusted-oidc --app-id APP_ID
Branch and tag runs of this workflow can deploy. The CLI exchanges GitHub identity for a temporary token scoped to this app. Deployments reuse its saved startup arguments and configuration. The command returns once the deployment is created, without waiting for it to serve. If the trust rule requires a GitHub environment, set jobs.deploy.environment to match.
4. Verify
Run the compiled server locally and check its main flows with HTTP/API requests. After deployment, request the public URL too: a serving status only confirms a TCP connection, not a working app. Use a browser for testing only when the user asks for browser or visual testing.
curl -fsS https://my-app.nibrun.app/
Report which checks passed and which remain unverified. With MCP, use read_logs for recent output and list_files to inspect the app's persistent files when diagnosing failures.
nib apps logs --app my-app prints recent output and exits. Add --follow for live output, or --timerange 2h to read further back. It says why an app never came up, and what one that did is complaining about. nib --help lists the rest — status, domains, filesystem, export, delete.
Inspecting SQLite databases
Read SQLite databases in an app's data/ through MCP, the CLI or dashboard without downloading them. With MCP, use query_database.
When a task calls for inspecting an app's database, use nib apps sqlite to discover its subcommands:
nib apps sqlite --help
Explore the subcommands and their --help to list saved connections, create a connection to the database file, and query it. Paths match nib apps files ls: /app.db names the guest's /app/data/app.db. Reuse a saved connection when available, or create one for the file the app actually uses. Pass --app and the saved --connection ID when querying; use --json for structured results. The app needs a running deployment. Queries read the actual database in the guest, including committed changes in its WAL.
In the dashboard's Files tab, choose Mark as SQLite database from the file menu, then click the database file to open the explorer. Both CLI and dashboard connections are read-only: SQLite rejects data and schema writes, so an AI agent can explore without modifying the database. The app can continue writing to its database.
Copying an app
An export is a .tar.gz holding data/, the binary that ran against it, and a .env of the variables it was deployed with — which is everything a second app is rebuilt from, whether that is a staging copy, a restore, or a move to another account:
nib apps export ./my-app.tar.gz --app my-apptar xzf my-app.tar.gz # -> data/ my-server .envnib run ./my-server --name my-app-copy --data-folder ./data --port 8080
--data-folder ./data and not the bundle as a whole: only data/ was the volume, and the binary and the .env unpacked beside it were never on it. Nothing outside the volume comes across either — port, arguments and environment are given to the copy the way any new app is given them, with the exported .env as the record of what the original had.
The guest contract
Apps and persistent storage are hosted in Frankfurt, Germany (AWS eu-central-1). Uploaded binaries, data imports, exports and platform database backups are stored in the same region. HTTPS requests pass through Cloudflare's global network.
Everything the binary can count on, and nothing else:
| Platform | Linux x86_64, glibc (Debian rootfs) | |
| Working directory | /app — a tmpfs the app does not own | |
| Persistent volume | /app/data — 8 GiB, survives every redeploy. NIBRUN_DATA_DIR names it. Not noexec: a file unpacked here can be exec'd in place | |
| Port | NIBRUN_HTTP_PORT, and PORT beside it; the app must listen on it, on 0.0.0.0 | |
| Own hostname | NIBRUN_HOSTNAME is set by the guest to the app's own <slug>.nibrun.app | |
| Second port | Only with --extra-public-port: NIBRUN_EXTRA_PUBLIC_PORT on NIBRUN_PUBLIC_IPV4, TCP and UDP, assigned rather than chosen, and reached at that number and no other | |
| Ephemeral | TMPDIR=/tmp is a tmpfs of 64 MiB — a quarter of the RAM — and is lost on restart. So is everything outside /app/data | |
| Programs | /bin/sh and crontab are available. Ship other tools yourself; there is no tar or unzip | |
| Resources | 1 vCPU, 256 MiB RAM | |
HOME | /app, which the app cannot write: a binary that puts a cache or a config file under ~ dies of EACCES before it ever serves. /app/data is the only path it can write | |
| URL | https://<slug>.nibrun.app, live as soon as it boots |
The guest sets three names of its own — NIBRUN_HTTP_PORT, NIBRUN_HOSTNAME, NIBRUN_DATA_DIR — and any of them you set yourself is ignored, as is PORT, which carries the same number as NIBRUN_HTTP_PORT under the name every other host uses. HOME and TMPDIR are defaults rather than fixed, so one you set yourself is what the binary reads — HOME=${NIBRUN_DATA_DIR} is how a binary that insists on writing under ~ is given a home it owns.
A launcher that carries a runtime and unpacks it at boot — a node and a Next standalone build, say — meets all three at once: it unpacks with its own code rather than tar, into /app/data rather than /tmp (a node alone is larger than /tmp), and execs from there. Copying out to /tmp first is what a noexec volume elsewhere would need, and this one is not.
A binary that needs its own absolute URL — an OAuth redirect, a webhook it registers, a link in an email — builds it from NIBRUN_HOSTNAME rather than being told it, and falls back to whatever it uses when it is not on nibrun.
Naming a runtime value
A binary that insists on a variable name of its own reaches the same values through it — APP_BASE_URL=https://${NIBRUN_HOSTNAME}, DATABASE_URL=file:${NIBRUN_DATA_DIR}/app.db — and the guest expands it before exec. Only complete ${NIBRUN_NAME} references to the names above expand. Bare names such as $NIBRUN_HTTP_PORT, unmatched braces, and other dollar signs remain literal. A complete reference to an unknown NIBRUN_ name is refused when you deploy it; ${PORT} remains literal.
A second public port
An app needing a port HTTP cannot carry — WebRTC media, a game server, anything on UDP — asks for one with --extra-public-port, and is told where it landed as NIBRUN_PUBLIC_IPV4 and NIBRUN_EXTRA_PUBLIC_PORT. Neither is discoverable from inside the guest, so an app that tells a peer where to reach it announces that pair: it is the same number end to end, which is what makes announcing it correct.
Ask for the port in the same change that names it:
nib apps update --app my-app --extra-public-port --env 'ANNOUNCED_IP=${NIBRUN_PUBLIC_IPV4}'
Cron jobs
Register jobs through crontab from the binary's normal server startup. nibrun's agent keeps the schedule and wakes an idle app to run it; in-process timers only run while the app is awake. For Bun apps, see Bun's cron documentation and use crontab-backed scheduling. A task command should do its work and exit without starting the server or registering jobs again.
- Maximum 10 jobs per app, in UTC. Use standard five-field crontab syntax or nicknames such as
@daily; no seconds field or @reboot.
/mnt/artifact/serveris the uploaded binary. Commands run through/bin/shas the app user
in /app, inherit deployment environment, and send stdout/stderr to app logs.
- Registration replaces the entire table.
crontab -lreads it;crontab -rremoves it. - Every new deployment clears schedules, including binary upgrades and settings redeploys
through nib apps update. Register them again at startup. Settings edits that create no deployment preserve schedules.
- Manual suspension disables execution but keeps schedules visible. Runs may overlap; missed
runs are not replayed and failures are not retried.
Inspect with nib apps crons --app my-app (add --json) or the dashboard's Crons tab.
Tradeoffs
Worth saying out loud before recommending it:
- New apps sleep after five minutes without incoming traffic. The next request wakes them.
In-process timers and outbound polling pause while asleep; registered cron jobs can wake the app and keep it awake until they finish. Owners cannot change activation through the CLI, API or dashboard. For an always-on app, email hello@nibrun.com.
- One microVM per app, one size. No horizontal scaling, no load balancing, no resizing.
- A deploy is a replace. The old VM is stopped before the new one starts, because they share
one volume. New HTTP requests wait for up to 60 seconds for the replacement to become healthy; a failed or slower deployment returns an error. In-flight requests and existing WebSockets can still disconnect. No blue/green or canary.
- A local disk, not a distributed one. Ideal for SQLite, uploads, caches. It is not
replicated, so an export (nib apps export) is your backup.
- The binary is the unit. The guest boots yours and nothing else — no sidecar, no cron
container, no managed database next to it.
- 256 MiB and 1 vCPU, sized by nibrun rather than configured by you, and the OOM killer
reaches for the tenant first.
- Health is a TCP connect to that port, and not something you configure. A process that accepts
connections while broken reads as healthy.
- A crash loop is fatal. The guest restarts your process on a fixed budget you do not set;
once it runs out the app is failed rather than restarted forever.
It fits a single-binary app that owns its own state — an internal tool, a small SaaS, a demo, a side project. It does not fit anything that needs to be several machines.