<< All versions

Skill v1.0.0

currentLLM-judged scan90/100
ilbertt/nibrun/deploy-to-nibrun
──Details
PublishedOctober 21, 2026 at 11:40 PM
Content Hashsha256:b0a8b5a789f9c26e...
Git SHAf5f5d7c77e5d
──Files
Files (1 file, 21.6 KB)
SKILL.md21.6 KBactive
SKILL.md · 431 lines · 21.6 KB

version: "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:

sh
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.

sh
curl -fsSL https://nibrun.com/install.sh | sh # installs `nib` to ~/.local/bin
nib 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.0 rather than 127.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/data survives 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.example or 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:

sh
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's binary input using

the client's file binding.

  • HTTPS download: pass url to deploy_app when 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_deploy as described

below. This requires an agent that can read the compiled file and upload bytes over HTTP.

For a direct upload:

  1. Call prepare_deploy with the app target, the binary's filename and its actual sizeBytes.

It returns app and upload, creating the app if needed. It does not deploy anything.

  1. PUT the raw binary as the whole request body to upload.url, with Content-Length equal to

sizeBytes. The signed URL authorizes this request; send no nibrun authorization token. Wait for a successful 2xx response.

  1. Call deploy_app with appId: app.id and artifactId: 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:

sh
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:

sh
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:

sh
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:

  1. 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.

  1. 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.

  1. 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.

  1. Check the public app requires the configured login and no longer offers an initial admin claim.
sh
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:

sh
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:

sh
nib run ./my-server --app my-app --env STRIPE_SECRET_KEY=sk_live_...

Arguments for the binary go inside the quotes, not after them:

sh
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:

sh
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:

sh
nib apps update --app my-app --env LOG_LEVEL=debug
nib 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:

sh
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:

yaml
name: Deploy to nibrun
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- 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.

sh
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:

sh
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:

sh
nib apps export ./my-app.tar.gz --app my-app
tar xzf my-app.tar.gz # -> data/ my-server .env
nib 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:

PlatformLinux 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
PortNIBRUN_HTTP_PORT, and PORT beside it; the app must listen on it, on 0.0.0.0
Own hostnameNIBRUN_HOSTNAME is set by the guest to the app's own <slug>.nibrun.app
Second portOnly 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
EphemeralTMPDIR=/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
Resources1 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
URLhttps://<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:

sh
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/server is the uploaded binary. Commands run through /bin/sh as the app user

in /app, inherit deployment environment, and send stdout/stderr to app logs.

  • Registration replaces the entire table. crontab -l reads it; crontab -r removes 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.

All versions