Command-line interface for bunny.net — manage databases, static sites, apps (Magic Containers), Edge Scripts, and more from your terminal.
# Shell installer (downloads prebuilt binary)
curl -fsSL https://cli.bunny.net/install.sh | sh
# Or via npm
npm install -g @bunny.net/cli# Authenticate with your bunny.net account
bunny login
# Or set up a profile with an API key directly
bunny config init --api-key bny_xxxxxxxxxxxx
# List your databases
bunny db list
# Create a new database
bunny db createAuthenticate with bunny.net via the browser.
# Browser-based login
bunny login
# Login to a specific profile
bunny login --profile staging
# Overwrite existing profile without prompting
bunny login --force
# Skip the browser entirely (remote machines, containers, CI)
bunny login --api-key "$BUNNYNET_API_KEY"The browser flow needs a browser you can actually see, which rules out SSH sessions, CI jobs, containers, and Unix hosts with no display server. bunny login checks for those before it opens anything.
With a terminal, it warns which case it hit, then offers to take an API key at a masked prompt (create one in the dashboard under Account Settings > API) or to print the login URL together with the ssh -L forward that makes the callback reachable. Without one, as with an agent or a CI job, it exits with a hint instead of waiting on a callback nothing can answer.
So for unattended runs, pass the key:
bunny login --api-key "$BUNNYNET_API_KEY"Add --output json to get { "authenticated": true, "profile": "...", "name": "..." } on stdout instead of the greeting.
The CLI checks the key against the API before writing the profile, so a bad one fails here rather than on the next command. You can also skip bunny login entirely and export BUNNYNET_API_KEY; it takes priority over any stored profile.
Remove a stored authentication profile.
bunny logout
bunny logout --forceShow the currently authenticated account, including your name, email, and account ID.
bunny whoami
# Logged in as Jamie Barton (jamie@bunny.net) 🐇
# Account ID: 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d
# Profile: default
bunny whoami --output json
bunny whoami --profile stagingOpen the bunny.net dashboard in your default browser. Uses BUNNYNET_DASHBOARD_URL if set, otherwise https://dash.bunny.net.
bunny open
# Print the URL instead of opening it
bunny open --print
# Print as JSON
bunny open --print --output jsonOpen the bunny.net documentation in your default browser.
bunny docsManage CLI configuration and profiles.
# First-time setup
bunny config init
bunny config init --api-key bny_xxxxxxxxxxxx
# View resolved configuration
bunny config show
bunny config show --output json
# Manage named profiles
bunny config profile create staging
bunny config profile create staging --api-key bny_xxxxxxxxxxxx
bunny config profile delete staging
bunny config profile delete staging --forceDeleting a profile removes its stored API key and asks for confirmation first. delete is also available as rm, as it is on bunny db delete and bunny scripts delete. Pass --force in scripts or with --output json. A missing profile is an error.
Manage databases.
Most db commands accept an optional <database-id> positional argument. When omitted, the CLI resolves the target in this order:
- Explicit
<database-id>argument .bunny/database.jsonmanifest written bybunny db linkBUNNY_DATABASE_URLin a.envfile (walked up from the current directory) matched against your database list- Interactive selection prompt
For db shell, the CLI also reads BUNNY_DATABASE_AUTH_TOKEN from .env to skip token generation. db quickstart prints both variables ready to paste; db create --token --save-env writes them for you.
Create a new database. Interactively prompts for name and region selection (automatic, single region, or manual) when flags are omitted. --mode answers that prompt from the command line: auto lets bunny pick, single takes the closest region, and manual needs a terminal to pick in. It pairs with the prompt, not with --primary: naming the regions yourself leaves --mode nothing to choose, so passing both is an error, and --replicas only means something next to --primary. After creation, prompts to link the directory, generate an auth token, and save credentials to .env.
It also offers a schema to start from. Picking a template writes its SQL to migrations/0001_<template>.sql and applies it to the new database as its first migration, so the schema is versioned in your project and recorded in the migration history from the start. --template answers that prompt: pass a template name, or none for an empty database. A project that already has migrations refuses a template, because the new file would be applied out of order.
Available templates: blog, booking, course-platform, crm, ecommerce, feature-flags, forms, helpdesk, invoicing, saas-starter, video-platform.
# Interactive — prompts for name and region mode
bunny db create
# Let bunny pick the regions instead of asking
bunny db create --name mydb --mode auto
# Single region
bunny db create --name mydb --primary FR
# Multi-region with replicas
bunny db create --name mydb --primary FR,DE --replicas UK,NY
# Start from a schema template, applied as the first migration
bunny db create --name mydb --template blog
# Skip the schema prompt and start empty
bunny db create --name mydb --template none
# Fully non-interactive (CI / scripts)
bunny db create --name mydb --primary FR --link --token --save-env --output json| Flag | Description |
|---|---|
--name |
Database name |
--mode |
Region selection mode, skipping the prompt: auto, single, or manual |
--primary |
Comma-separated primary region IDs (e.g. FR or FR,DE) |
--replicas |
Comma-separated replica region IDs (e.g. UK,NY) |
--storage-region |
Override auto-detected storage region |
--link |
Link the current directory to the new database (skips prompt). Use --no-link to skip. |
--token |
Generate a full-access auth token (skips prompt). Use --no-token to skip. |
--save-env |
Save BUNNY_DATABASE_URL and BUNNY_DATABASE_AUTH_TOKEN to .env. Requires --token. |
--template |
Schema template to start from (skips prompt). none creates an empty database. |
--dir |
Directory the template's migration is written to (default: migrations) |
In --output json mode, prompts are suppressed entirely: flags are the only way to opt in to linking, token creation, and .env writes. The JSON output gains linked, token, saved_to_env, template, and migration fields reflecting what happened.
List all databases. Shows ID, name, status, primary region, and size.
bunny db list
bunny db list --output jsonShow details for a single database.
bunny db show <database-id>
bunny db show
bunny db show --output jsonLink the current directory to a database. Saves { id, name } to .bunny/database.json so subsequent db commands resolve the target without BUNNY_DATABASE_URL in .env. With no argument, lists all databases for interactive selection.
# Interactive selection
bunny db link
# Direct link by ID
bunny db link <database-id>bunny db create offers to link the new database, and bunny db delete removes a stale link automatically when it points at the deleted database.
Permanently delete a database. Requires double confirmation (or --force to skip).
bunny db delete <database-id>
bunny db delete --force| Flag | Description |
|---|---|
--force |
Skip confirmation prompts |
List configured primary and replica regions for a database.
bunny db regions list
bunny db regions list <database-id>Add primary or replica regions to a database.
bunny db regions add --primary FR,DE
bunny db regions add --replicas UK,NY
bunny db regions add --primary FR --replicas UK| Flag | Description |
|---|---|
--primary |
Comma-separated primary region IDs to add |
--replicas |
Comma-separated replica region IDs to add |
Remove primary or replica regions from a database.
bunny db regions remove --primary FR
bunny db regions remove --replicas UK,NY| Flag | Description |
|---|---|
--primary |
Comma-separated primary region IDs to remove |
--replicas |
Comma-separated replica region IDs to remove |
Interactively update region configuration. Shows all available regions with current ones pre-selected — toggle on/off and confirm.
bunny db regions update
bunny db regions update <database-id>Show usage statistics for a database.
bunny db usage <database-id>
bunny db usage --period 7d
bunny db usage --output jsonGenerate a quickstart guide for connecting to a database.
bunny db quickstart
bunny db quickstart <database-id> --lang typescriptLanguages: typescript, go, rust, dotnet. Prints the .env values, the install command, and a ready-to-use snippet; the TypeScript one uses @bunny.net/database-client.
Open an interactive SQL shell for a database. Supports multiple output modes, sensitive column masking, persistent history, and a set of dot-commands for quick introspection.
When no --token is supplied and BUNNY_DATABASE_AUTH_TOKEN is not set, the shell session is active for 30 minutes. Re-run the command to reconnect, or pass --token / set BUNNY_DATABASE_AUTH_TOKEN to use your own credentials.
# Interactive shell (auto-detects database from .env)
bunny db shell
# Specify a database ID
bunny db shell <database-id>
# Execute a query and exit
bunny db shell "SELECT * FROM users"
bunny db shell <database-id> "SELECT * FROM users"
bunny db shell --execute "SELECT COUNT(*) FROM posts"
# Output modes
bunny db shell -m json -e "SELECT * FROM users"
bunny db shell -m csv -e "SELECT * FROM users"
bunny db shell -m markdown -e "SELECT * FROM users"
# Execute a SQL file
bunny db shell -e seed.sql
bunny db shell seed.sql
# Show sensitive columns unmasked
bunny db shell --unmask
# Direct connection (skip API lookup)
bunny db shell --url libsql://01KCHBG8C5KSFGG0VRNFQ7EK7X-my-app.lite.bunnydb.net --token ey...| Flag | Alias | Description |
|---|---|---|
--execute |
-e |
Execute a SQL statement and exit |
--mode |
-m |
Output mode: default, table, json, csv, markdown |
--unmask |
Show sensitive column values unmasked | |
--url |
Database URL (skips API lookup) | |
--token |
Auth token (skips token generation) |
Dot-commands (available in interactive mode):
| Command | Description |
|---|---|
.tables |
List all tables |
.describe TABLE |
Show column details for a table |
.schema [TABLE] |
Show CREATE statements |
.indexes [TABLE] |
List indexes |
.fk TABLE |
Show foreign keys for a table |
.er |
Show entity-relationship overview |
.count TABLE |
Count rows in a table |
.size TABLE |
Show table stats (rows, columns, indexes) |
.truncate TABLE |
Delete all rows from a table |
.dump [TABLE] |
Dump schema and data as SQL |
.read FILE |
Execute SQL statements from a file |
.mode [MODE] |
Set output mode |
.timing |
Toggle query execution timing |
.mask |
Enable sensitive column masking |
.unmask |
Disable sensitive column masking |
.clear-history |
Clear command history |
.help |
Show available commands |
.quit / .exit |
Exit the shell |
Sensitive column masking: Columns matching patterns like password, secret, api_key, auth_token, ssn, etc. are masked by default (********). Email columns are partially masked (a••••e@example.com). Use .unmask or --unmask to reveal values.
Open a read-only table viewer in your browser. Spins up a local server, generates a short-lived auth token if needed, and opens the studio UI.
# Auto-detect database (link, .env, or interactive)
bunny db studio
# Specific database
bunny db studio <database-id>
# Custom port
bunny db studio --port 3000
# Don't auto-open the browser
bunny db studio --no-open
# Use explicit credentials (skips API lookup)
bunny db studio --url libsql://01KCHBG8C5KSFGG0VRNFQ7EK7X-my-app.lite.bunnydb.net --token ey...| Flag | Description |
|---|---|
--port |
Port for the local studio server (default 4488) |
--url |
Database URL (skips API lookup) |
--token |
Auth token (skips token generation) |
--no-open |
Don't automatically open the browser |
Create an empty, numbered migration file. The filename is the migration's identity, so the numeric prefix decides the order apply runs them in.
# Creates migrations/0001_add_users_table.sql
bunny db migrations create add_users_table
# Names are slugified, so quoting works
bunny db migrations create "add users table"
# Custom directory
bunny db migrations create add_index --dir db/migrations| Flag | Description |
|---|---|
--dir |
Migrations directory (default: migrations) |
Aliased as bunny db migrations new. Creation never auto-detects an ORM output directory: writing a hand-authored file there would bypass the ORM's own journal.
The new file holds only a comment, so add SQL before applying it; apply rejects a migration with no statements.
Show which migrations have been applied. Read-only: it never creates the tracking table.
bunny db migrations list
bunny db migrations list <database-id>
bunny db migrations list --output json| Flag | Description |
|---|---|
--dir |
Migrations directory (default: migrations) |
--pattern |
Migration glob relative to --dir (default *.sql) |
--url |
Database URL (skips API lookup) |
--token |
Auth token (skips token generation) |
Aliased as ls and status. Each migration is reported in one of five states:
| State | Meaning |
|---|---|
Applied |
On disk and recorded, checksums match |
Pending |
On disk, not yet applied |
Modified |
Recorded, but the file changed since it was applied |
Missing |
Recorded, but the file is no longer on disk |
Out of order |
Pending, but sorts before an already-applied migration |
Apply every pending migration, in filename order. Each file runs as one atomic batch together with its tracking row, so a migration either lands and is recorded or neither happens. Foreign keys are deferred for the batch, so table rebuilds work. The run stops at the first failure and leaves the remaining migrations pending.
# Apply all pending migrations
bunny db migrations apply
# Show what would run, without writing
bunny db migrations apply --dry-run
# Apply drizzle-kit output
bunny db migrations apply --dir drizzle
# Nested ORM layouts
bunny db migrations apply --dir drizzle --pattern "*/migration.sql"| Flag | Description |
|---|---|
--dir |
Migrations directory (default: migrations) |
--pattern |
Migration glob relative to --dir (default *.sql) |
--url |
Database URL (skips API lookup) |
--token |
Auth token (skips token generation) |
--dry-run |
List the migrations that would run, without applying them |
-f, --force |
Skip confirmation prompts |
--allow-drift |
Apply despite modified, missing, or out-of-order history |
--dry-run and a declined confirmation leave the database untouched, including the tracking table. When the history has drifted, apply refuses and applies nothing unless --allow-drift is passed. The confirmation prompt is skipped automatically when stdin is not an interactive terminal, so CI runs don't hang.
Applied migrations are recorded in a __bunny_migrations table. The __ prefix keeps it out of studio and REST introspection.
When migrations/ doesn't exist and no --dir is given, drizzle/ is used if present, so drizzle-kit generate output works without configuration.
Every migration file is parsed before the first database write, so a malformed later file cannot leave a run half-complete.
Generate an auth token for a database. The database ID can be provided as a positional argument or auto-detected from BUNNY_DATABASE_URL in a .env file.
# Provide database ID explicitly
bunny db tokens create <database-id>
# Auto-detect from .env BUNNY_DATABASE_URL
bunny db tokens create
# Read-only token
bunny db tokens create --read-only
# Token with expiry (duration shorthand or RFC 3339)
bunny db tokens create --expiry 30d
bunny db tokens create --expiry 2026-12-31T23:59:59Z| Flag | Description |
|---|---|
--read-only |
Generate a read-only token (default: full access) |
-e, --expiry |
Token expiry — duration (30d, 12h, 1w, 1m, 1y) or RFC 3339 date |
Invalidate all auth tokens for a database. Prompts for confirmation unless --force is passed.
bunny db tokens invalidate <database-id>
bunny db tokens invalidate --forceExperimental internal use only
Push and inspect images on the bunny.net OCI registry, the platform's own registry available to every account. The endpoint defaults to registry.bunny.net; set the BUNNYNET_REGISTRY_URL environment variable to override it.
This is the registry you push to. Connecting a third-party registry that bunny.net should pull from (GitHub, Docker Hub) is bunny apps registries instead, where this registry also appears as a bunny.net source.
bunny registry push myapp:latest # push, deriving repository/tag from the image
bunny registry push myapp:dev --repository myapp --tag v1
bunny registry list # list repositories (alias: ls)
bunny registry tags myapp # list tags for a repositorypush currently uses Docker to read the local image; list and tags talk to the registry directly. On the registry itself repositories are namespaced by your account id (<account-id>/myapp); the CLI adds and hides that prefix automatically, so you always work with the bare repository name.
Manage DNS through two resource groups: bunny dns record (the entries within a zone) and bunny dns zone (the zone itself — settings, DNSSEC, logging, stats, nameservers). The [domain] argument accepts either the zone's domain name or its numeric zone ID, and is optional everywhere — omit it and you'll be prompted to pick a zone. record update/record remove likewise prompt you to pick a record when the ID is omitted. record aliases to records/rec; zone aliases to zones (and domain/domains).
# Records — list within a zone
bunny dns record list example.com
bunny dns rec ls example.com
# Add records (use '@' for the zone apex)
bunny dns record add example.com api A 198.51.100.1
bunny dns record add example.com '@' MX mail.example.com 10
bunny dns record add example.com '@' SRV 10 0 389 sip.example.com
bunny dns record add example.com '@' CAA '0 issue "letsencrypt.org"'
# Link a record to a pull zone or Edge Script
bunny dns record add example.com cdn PullZone --pull-zone 12345
bunny dns record add example.com fn Script --script 67890
# Interactive wizard — omit the record type (or all args) to be prompted
bunny dns record add
bunny dns record add example.com
# Update / remove a record by its ID
bunny dns record update example.com 123 --value 198.51.100.2 --ttl 3600
bunny dns record remove example.com 123
# Import / export a BIND zone file
bunny dns record import example.com ./zonefile.txt
bunny dns record export example.com # print to stdout
bunny dns record export example.com --file ./my.zone # write to a path
bunny dns record export example.com --save # write to ./example.com.zone
# Zones — lifecycle
bunny dns zone list
bunny dns zone create example.com
bunny dns zone create # prompts for the domain (alias: add)
bunny dns zone show example.com
bunny dns zone delete example.com
# Query statistics (defaults to the last 30 days; text mode draws a bar chart)
bunny dns zone stats example.com
bunny dns zone stats example.com --from 2026-05-01 --to 2026-05-31
# Nameservers to set at your registrar (custom if enabled, else bunny.net defaults)
bunny dns zone nameservers example.com
bunny dns zone ns example.com
# DNSSEC — enable prints the DS record to register at your domain registrar
bunny dns zone dnssec enable example.com
bunny dns zone dnssec disable example.com
# DNS query logging — enable to start collecting logs (optionally anonymize IPs)
bunny dns zone logging enable example.com
bunny dns zone logging enable example.com --anonymize-ip --anonymization drop
bunny dns zone logging disable example.comPositional value ordering for record add follows the record type: A/AAAA/CNAME/TXT/NS take a single value, MX takes <value> <priority>, SRV takes <priority> <weight> <port> <target>, and CAA takes a single quoted '<flags> <tag> "<value>"' string. PullZone and Script records take no positional value — pass --pull-zone <id> or --script <id> instead. Omit the record type (or all arguments) to run an interactive wizard that prompts for the zone, type, and per-type values.
| Flag | Commands | Description |
|---|---|---|
--ttl |
record add, record update |
Time to live in seconds |
--comment |
record add, record update |
Optional comment for the record |
--pull-zone, --script |
record add, record update |
Link a PullZone / Script record by ID |
--name, --value, --type, --priority, --weight, --port, --flags, --tag, --disabled |
record update |
Edit individual record fields (see bunny dns record update --help) |
--file, --save |
record export |
Write to a path, or to <domain>.zone in the current directory |
--from, --to |
zone stats |
Date range (defaults to the last 30 days) |
--anonymize-ip, --anonymization |
zone logging enable |
Anonymize client IPs in logs (onedigit | drop) |
--force |
record remove, zone delete, zone dnssec disable, zone logging disable |
Skip the confirmation prompt |
Manage Edge Storage through two resource groups: bunny storage zones (the zone itself: create, list, inspect, update, delete; alias zone, plus hidden bucket/buckets) and bunny storage files (the files within a zone; alias file). Zone management uses the account API key; file operations use the zone's own password and a region-specific host, both resolved automatically from the zone. zones commands take the zone as an optional [zone] positional; files commands take it as the --zone/-z flag (their positional is the file path). Either accepts the zone name or its numeric ID. When the zone is omitted it resolves from the directory's linked zone (bunny storage link, stored in .bunny/storage.json), then an interactive picker, which offers to link the directory to the picked zone (except on destructive commands). Non-interactive runs (--output json, no TTY, or --force) error instead of prompting; pass a zone or link the directory.
A storage zone only holds files; a pull zone is what serves them on the web. zones create offers to create one (origin set to the new storage zone) and then to add a custom domain, or pass --pull-zone/--domain to do it non-interactively. Custom domains live on the pull zone and are managed with bunny storage zones domains.
The tier (--tier hdd|ssd, Standard or Edge), the main region, and S3 compatibility (--s3) are all fixed at creation, so zones create prompts for each of them when the flag is omitted. Edge (SSD) zones are always primaried in DE, so --tier ssd rejects any other --region rather than letting the API rewrite it silently; replication regions are unaffected. zones list reports the tier and S3 support per zone, and zones show reports both plus the S3 endpoint when it's enabled.
After creating a zone, create offers to link the directory to it (--link/--no-link), to print connection details (--connection http|ftp|s3, optionally as a client config with --format), and to save those details to .env (--save-env). Credentials are shown in full there because they were explicitly asked for; zones credentials masks them by default.
Saving to .env writes BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, and BUNNY_STORAGE_REGION (lowercased, so it matches the S3 endpoint and the SDK region), plus BUNNY_STORAGE_CDN_URL when exactly one pull zone fronts the zone (several pull zones can share a storage origin, and picking one arbitrarily would put the wrong host in .env). An S3 connection writes the AWS_* equivalents instead. zones show reports the same CDN URL alongside the storage hostname, listing every one when a zone has more than one.
# Zones (lifecycle)
bunny storage zones list
bunny storage zones create # interactive: prompts for name and region (alias: add)
bunny storage zones create my-zone --region DE
bunny storage zones create my-zone --region NY --replication LA,SG
bunny storage zones create my-zone --region DE --pull-zone # also create a pull zone to serve it on the web
bunny storage zones create my-zone --region DE --domain cdn.example.com # pull zone + custom domain
bunny storage zones create my-zone --tier ssd --s3 # Edge (SSD) tier (always DE) with S3-compatible access
bunny storage zones create my-zone --region DE --s3 --connection s3 --save-env # print S3 credentials and write them to .env
bunny storage zones show my-zone
bunny storage zones update my-zone # interactive: edit settings, pre-filled with current values
bunny storage zones update my-zone --custom-404-path /404.html
bunny storage zones delete my-zone # confirms twice (yes/no, then type the zone name)
# Link the working directory to a zone so commands can omit it
bunny storage link my-zone
bunny storage unlink
# List the available storage regions
bunny storage regions
# Connection credentials: HTTP API, FTP, or S3 (one zone password, shaped per protocol)
bunny storage zones credentials my-zone # pick a connection type, secret masked
bunny storage zones credentials my-zone --connection ftp --show-secret # FTP host, username, password
bunny storage zones credentials my-zone --connection s3 --read-only # use the read-only password as the secret
bunny storage zones credentials my-zone --format sdk # @bunny.net/storage-sdk snippet (HTTP API)
bunny storage zones credentials my-zone --format rclone >> ~/.config/rclone/rclone.conf
set -a; eval "$(bunny storage zones credentials my-zone --format env)"; set +a # AWS-compatible env vars, exported
bunny storage zones credentials my-zone --connection http --save-env # write the variables to .env
# Files: list, upload, download, delete (paths are relative to the zone root)
bunny storage files list --zone my-zone
bunny storage files list images/ # linked zone
bunny storage files upload ./photo.png --to images/
bunny storage files upload ./photo.png --checksum --content-type image/png
bunny storage files download images/photo.png --out ./local.png
bunny storage files remove images/photo.png
bunny storage files remove images/ --force # trailing slash removes a directory
# Custom domains on the zone's pull zone
bunny storage zones domains list my-zone
bunny storage zones domains add cdn.example.com my-zone
bunny storage zones domains ssl cdn.example.com my-zone
bunny storage zones domains remove cdn.example.com my-zone
# Open the storage documentation
bunny storage docsA trailing slash on a files path denotes a directory: files list images/ lists that directory, and files remove images/ deletes it and its contents recursively. Edge Storage file operations are powered by the @bunny.net/storage-sdk.
bunny.net's S3-compatible API is in preview and is opt-in per zone at creation (zones create --s3); it cannot be enabled on an existing zone. When a zone has it, bunny storage zones show surfaces its S3 endpoint, and bunny storage zones credentials --connection s3 emits the endpoint, region, access key (the zone name), and secret (the zone password) as a table, as JSON (--output json), or as ready-to-use config for rclone, the AWS CLI, s3cmd, or your shell (--format). The table and JSON output mask the secret by default; pass --show-secret to reveal it (the S3 tool formats always emit it in full, since they're meant to be consumed by tools, and under --output json the config rides along in a config field). The access key and secret are the zone's existing name and password, so there's nothing new to rotate beyond the zone's own credentials.
The same command also serves the two protocols every zone has: --connection http (the base URL and AccessKey header, plus a --format sdk snippet for @bunny.net/storage-sdk) and --connection ftp (host, username, password). --format implies its protocol, so a conflicting --connection is an error. --save-env writes the protocol's variables (BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_REGION, or the AWS_* quad for S3) into whichever .env already holds one of them.
| Flag | Commands | Description |
|---|---|---|
--region, --replication |
zones create |
Primary region code, plus optional replication regions (any storage region except the primary; run storage regions to list them) |
--tier (hdd | ssd), --s3 |
zones create |
Storage tier and S3-compatible access; both are create-time only, and --tier ssd forces DE as the main region |
--link, --connection (http | ftp | s3), --format, --save-env |
zones create |
Post-create follow-ups: link the directory, print connection details (or a client config), and save them to .env |
--pull-zone, --pull-zone-name, --domain |
zones create |
Also create a pull zone (what serves the stored files on the web) and optionally a custom domain; interactively, create offers both |
--custom-404-path, --rewrite-404-to-200, --replication |
zones update |
Edit zone settings; replication is additive since replicas can't be removed (see bunny storage zones update --help) |
--connection (http | ftp | s3), --save-env |
zones credentials |
Pick the protocol to print (prompts when omitted); save its variables to .env |
--format (sdk | rclone | aws | s3cmd | env), --read-only, --show-secret |
zones credentials |
Emit a client config (sdk for the HTTP API, the rest for S3); use the read-only password; reveal the masked secret |
--zone, -z |
all files commands |
Storage zone name or ID (defaults to the linked zone) |
--to |
files upload |
Remote path; a trailing slash uploads into that directory |
--checksum, --content-type |
files upload |
Send a SHA256 checksum for server-side verification; set the stored content type |
--out |
files download |
Local destination path (defaults to the file name) |
--force |
zones delete, files remove, unlink |
Skip the confirmation prompts |
Manage Edge Scripts.
Create a new Edge Script project from a template.
# Interactive wizard
bunny scripts init
# Non-interactive, no GitHub Actions workflow
bunny scripts init --name my-script --type standalone --template Empty --no-github-actions --deploy
# Non-interactive, keep the GitHub Actions workflow
bunny scripts init --name my-script --type standalone --template Empty --github-actions --deploy
# Use a custom template repo (GitHub owner/repo shorthand)
bunny scripts init --repo owner/my-template
# Use a custom template repo (full git URL)
bunny scripts init --template-repo https://github.com/owner/my-template| Flag | Description |
|---|---|
--name |
Project directory name |
--type |
Script type: standalone or middleware |
--template |
Template name |
--template-repo, --repo |
Git repository URL or GitHub owner/repo shorthand to use as template |
--github-actions |
Keep the template's GitHub Actions workflow (use --no-github-actions to remove it) |
--deploy |
Create script on bunny.net after scaffolding |
--skip-git |
Skip git initialization |
--skip-install |
Skip dependency installation |
When --repo / --template-repo is given without --type, the script type defaults to standalone.
After creating the script on bunny.net, the interactive wizard also asks for an optional custom domain — the same DNS + HTTPS flow as bunny scripts create and bunny scripts domains add, including the offer to wire up the DNS record for you (after confirmation) when the domain is on Bunny DNS.
With --github-actions, git is initialized automatically, the template's .github/ workflow is kept, and after creating the script you'll be shown the SCRIPT_ID to add as a GitHub repo secret. With --no-github-actions, the .github/ directory is removed and git init is prompted (or skipped via --skip-git).
The .changeset/ directory is always removed from the template — bunny scripts don't use it.
Create a new Edge Script on bunny.net (without scaffolding a project). Use this when you have an existing project — for example, you ran bunny scripts init without --deploy — and need a remote script before running bunny scripts deploy.
# Create using current directory name + link .bunny/script.json
bunny scripts create
# Explicit name and type
bunny scripts create my-script --type middleware
# Skip pull zone creation and directory linking
bunny scripts create my-script --no-pull-zone --no-link
# Create and attach a custom domain
bunny scripts create my-script --domain shop.example.com| Flag | Description |
|---|---|
--type |
Script type: standalone or middleware (defaults to manifest, prompts if interactive) |
--pull-zone |
Create a linked pull zone (default: true). Use --no-pull-zone to skip. |
--pull-zone-name |
Name for the linked pull zone |
--link |
Link this directory to the new script (default: true). Use --no-link to skip. |
--domain |
Add a custom domain to the new script's pull zone (prompted when interactive) |
When run interactively, create also asks for an optional custom domain. If that domain is already in one of your Bunny DNS zones, it offers to add (or repoint) the DNS record for you — declining, or any record it would overwrite, always prompts first, and nothing is changed without confirmation. With the record in place, DNS is live on bunny's resolvers immediately, so it skips straight to issuing the free SSL certificate. Otherwise it prints the CNAME record to create and offers to wait while DNS propagates, issuing the certificate automatically once the domain points at bunny.net — the same flow as bunny scripts domains add --wait.
Deploy code to an Edge Script. Uploads code and publishes by default.
# Deploy and publish
bunny scripts deploy dist/index.js
# Deploy without publishing
bunny scripts deploy dist/index.js --skip-publish
# Deploy to a specific script
bunny scripts deploy dist/index.js 12345| Flag | Description |
|---|---|
--skip-publish |
Upload code without publishing |
After publishing, the live URL and any custom domains are printed.
Note:
bunny scripts deployworks regardless of how the script was created or whether GitHub Actions is configured. The last deployment always wins — whether triggered by a GitHub Action or a manual CLI deploy.
Link the current directory to a remote Edge Script. Creates a .bunny/script.json manifest file.
# Interactive — select from list
bunny scripts link
# Non-interactive
bunny scripts link --id <script-id>List all Edge Scripts.
bunny scripts list
bunny scripts ls
bunny scripts list --output jsonShow details for an Edge Script. Uses the linked script from .bunny/script.json if no ID is provided. Output includes the script's hostnames (system and custom) with their SSL status.
bunny scripts show <script-id>
bunny scripts showShow usage statistics for an Edge Script — request, CPU, and cost totals over the period, plus a per-bucket requests-served bar chart in text mode (buckets are labelled with friendly UTC dates, e.g. May 19, 2026, or date + time with --hourly). Defaults to the last 30 days.
When no ID is given, the command resolves the linked script from .bunny/script.json. If there is no link either, it prompts you to pick a script and offers to link the directory for next time. In --output json mode the picker is skipped and the command errors instead — pass an ID or run bunny scripts link in CI.
bunny scripts stats
bunny scripts stats 12345 --from 2026-05-01 --to 2026-05-31
bunny scripts stats 12345 --hourly
bunny scripts stats 12345 --output json
# Pick interactively without being asked to link (e.g. one-off checks)
bunny scripts stats --no-link| Flag | Description |
|---|---|
--from |
Start date (YYYY-MM-DD); defaults to 30 days ago |
--to |
End date (YYYY-MM-DD); defaults to today |
--hourly |
Group statistics by hour instead of by day |
--link |
After an interactive pick, link the directory (use --no-link to skip the prompt) |
Delete an Edge Script. Uses the linked script if no ID is provided. Requires double confirmation (or --force to skip).
bunny scripts delete <script-id>
bunny scripts delete
bunny scripts delete <script-id> --force| Flag | Description |
|---|---|
--force |
Skip confirmation prompts |
Manage Edge Script deployments.
List deployments for an Edge Script. Uses the linked script if no ID is provided.
bunny scripts deployments list
bunny scripts deployments ls
bunny scripts deployments list <script-id>
bunny scripts deployments list --output jsonPublish (roll back to) a past deployment by its release ID, as shown in deployments list. bunny scripts deploy already uploads and publishes in one step; use this to re-publish an earlier release without touching the current code. Uses the linked script if no ID is provided.
bunny scripts deployments publish <release-id>
bunny scripts deployments publish <release-id> <script-id>
bunny scripts deployments publish <release-id> --force| Flag | Description |
|---|---|
--force |
Skip the confirmation prompt |
Manage environment variables and secrets for an Edge Script. All subcommands default to the linked script; pass --id <script-id> to target another.
List environment variables and secrets.
bunny scripts env list
bunny scripts env ls
bunny scripts env list --output jsonSet an environment variable or secret. Runs interactively when arguments are omitted. The variable name is uppercased. Whenever the value is prompted for, the command also asks whether it is a secret, defaulting to yes for names that look like credentials.
bunny scripts env set MY_VAR value
bunny scripts env set # interactive
bunny scripts env set API_KEY # prompts for the value, and whether it is a secret
bunny scripts env set API_KEY secret-value --secret
bunny scripts env set --from-file .env # same as `scripts env push .env`| Flag | Description |
|---|---|
--secret |
Store as an encrypted secret |
--from-file |
Push a .env file to the script, as env push does |
--id |
Edge Script ID (uses linked if omitted) |
Remove an environment variable or secret. Shows an interactive picker when no name is given; prompts for confirmation unless --force.
bunny scripts env remove MY_VAR
bunny scripts env rm MY_VAR -fPull environment variables to a local .env file.
bunny scripts env pull
bunny scripts env pull <script-id>
bunny scripts env pull --force| Flag | Description |
|---|---|
--force |
Overwrite an existing .env without prompting |
Push a local .env file to an Edge Script. Reads the nearest .env unless a path is given, then asks which variables to push and which of them are secrets. Without a terminal, --all is required rather than assumed, so a pipeline never uploads a whole .env by accident. The secret choice is pre-selected from the variable name (TOKEN, SECRET, PASSWORD, _KEY, and friends), so a credential is not stored as a readable variable by accident. --secrets and --plain override that guess, and a name they don't match in the file is an error rather than a silent fall back to the guess.
A quoted value may span lines, so a PEM key or a service-account blob pushes whole; an unclosed quote is an error, since the alternative is pushing a truncated credential under a success message. Inline # comments are stripped from unquoted values.
Names already held by the opposite type are skipped rather than failing the push: the API cannot convert a variable into a secret, so remove it first. A write the API rejects is reported per variable and exits non-zero, so a half-pushed env can't pass unnoticed.
bunny scripts env push # pick from the nearest .env
bunny scripts env push .env.production --all # push a whole file, no prompts
bunny scripts env push --all --secrets DB_TOKEN,API_KEY
bunny scripts env push --all --plain PUBLIC_URL| Flag | Description |
|---|---|
--all |
Push every variable in the file without prompting |
--secrets |
Names to store as encrypted secrets, overriding the name guess |
--plain |
Names to store as plain variables, overriding the name guess |
--id |
Edge Script ID (uses linked if omitted) |
Manage custom domains for an Edge Script. A script's domains live on its linked pull zone, so these commands operate on that pull zone. All subcommands default to the linked script; pass a trailing [id] positional (or the equivalent --id <script-id> flag) to target another, and --pull-zone <id> when a script has more than one linked pull zone. (bunny scripts hostnames is kept as a hidden alias.)
Add a custom domain. SSL is not requested by default — a free certificate can only be issued once your DNS points at bunny.net, so the command prints the CNAME record to create. When run interactively it then offers to wait while DNS propagates (checking every few seconds, up to 10 minutes) and issues the certificate automatically once the domain is live; pass --wait to do that without the prompt, or --ssl to issue a certificate immediately. HTTP is redirected to HTTPS by default (opt out with --no-force-ssl).
If the domain is already in one of your Bunny DNS zones, it offers to add (or repoint) the DNS record for you instead, then issues the certificate straight away. If the domain's nameservers already point at bunny.net but no zone exists in your account (a deleted zone, or nameservers set at the registrar first), it offers to create the zone before adding the record. Every write is confirmed first.
# Add a domain, get DNS instructions, and optionally wait for DNS + HTTPS
bunny scripts domains add shop.example.com
# Add, wait for DNS to propagate, then enable HTTPS — no prompts
bunny scripts domains add shop.example.com --wait
# Add and request SSL now (DNS must already be pointed at bunny.net) — HTTPS forced
bunny scripts domains add shop.example.com --ssl
# Add and request SSL without forcing HTTPS
bunny scripts domains add shop.example.com --ssl --no-force-ssl
# Target a script other than the linked one
bunny scripts domains add shop.example.com 12345| Flag | Description |
|---|---|
--ssl |
Issue a free SSL certificate now and force HTTPS (requires DNS pointed) |
--wait |
Wait for DNS to point at bunny.net (up to 10 minutes), then issue SSL |
--no-force-ssl |
When issuing SSL, keep serving HTTP instead of redirecting to HTTPS |
[id] / --id |
Edge Script ID (uses linked script if omitted) |
--pull-zone |
Pull zone ID (required if the script has multiple linked zones) |
Request a free SSL certificate for a custom domain. Run this after the domain's DNS points at bunny.net (see the CNAME printed by domains add). HTTP is redirected to HTTPS by default; pass --no-force-ssl to keep plain HTTP.
bunny scripts domains ssl shop.example.com
bunny scripts domains ssl shop.example.com --no-force-sslList the domains on a script's pull zone, with SSL and Force SSL status.
bunny scripts domains list
bunny scripts domains ls
bunny scripts domains list --output jsonRemove a custom domain. System hostnames controlled by bunny.net cannot be removed.
bunny scripts domains remove shop.example.com
bunny scripts domains remove shop.example.com --forceOpen the Edge Scripts documentation in your browser.
bunny scripts docsPreview:
bunny sitesis in public preview; commands and flags may still change.
Host static sites on bunny.net. Each site is two resources provisioned and wired together for you: a storage zone holding the files and a pull zone serving them over the CDN, with edge rules that route requests to the deploy that should answer them. Zones are named sites-<name>-<suffix> (the prefix groups them in the dashboard; the suffix is because zone names are global across bunny.net) while commands take the clean site name.
Deploys are immutable: every sites deploy uploads to its own deploys/<id>/ directory and then goes live. Publishing retargets the pull zone's rewrite rule and purges the cache, so going live and rolling back to any earlier deploy are instant and move no files. HTML is served with max-age=0 so browsers pick up new deploys immediately, while static assets get a one-day browser cache. Deploy IDs are the git short SHA when the working tree is clean and a content hash otherwise, which makes redeploying identical content a no-op.
Commands take the site as an optional positional ([site]), except deploy, ci init, deployments publish, and deployments delete, which use --site. Either accepts the site name or its storage zone ID. When omitted, the site resolves from the directory's linked site (.bunny/site.json, written by sites link or by create/deploy), then sites.name in bunny.jsonc, then an interactive picker that offers to link. Non-interactive runs (--output json, no TTY, or --force on a destructive command) error instead of prompting.
# Provision a site
bunny sites create # interactive: prompts for a name (directory-name suggestion)
bunny sites create my-site # served at sites-my-site-<suffix>.b-cdn.net
bunny sites create my-site --region NY # store the files in New York (default: DE)
bunny sites create my-site --tier ssd # Edge (SSD) storage tier; DE only, fixed at creation
bunny sites create my-site --domain example.com # also attach a custom production domain
bunny sites create my-site --from-zone my-zone # import an existing storage zone + pull zone, keeping its hostnames
# Deploy
bunny sites deploy # detects the framework, offers to build, then deploys
bunny sites deploy ./dist # deploy a directory and publish it as the live site
bunny sites deploy --build # run `sites.build` from bunny.jsonc (else the detected build), then deploy
bunny sites deploy --build "npm run build" --env API_URL=https://api.example.com
bunny sites deploy ./dist --site my-site --force # target a site explicitly; redeploy unchanged content
bunny sites deploy ./catalog --deploy-id 20260827-1433-r42 # your own release ID instead of the git sha / content hash
# Deploys: list, publish (roll back), prune, delete
bunny sites deployments list # ● Live / ○ Previous markers, created, source, files, size
bunny sites deployments publish a1b2c3d4 # promote a past deploy (alias: promote)
bunny sites deployments publish --previous # instant rollback
bunny sites deployments prune --keep 10 # delete old deploys (default keeps 5; never live/previous)
bunny sites deployments delete a1b2c3d4 --force # delete one deploy (never the live or rollback deploy)
# Custom production domains
bunny sites domains list
bunny sites domains add shop.example.com # prints the DNS record to create (or offers to set it in Bunny DNS)
bunny sites domains add shop.example.com --wait # add, wait for DNS, then issue SSL and force HTTPS
bunny sites domains add shop.example.com --ssl --no-force-ssl # issue SSL now, keep HTTP available
bunny sites domains ssl shop.example.com
bunny sites domains remove shop.example.com --force
# Inspect, open, and force HTTPS on the b-cdn.net system host
bunny sites list # alias: ls
bunny sites show # resources, domains, SSL state, current deploy
bunny sites open --print
bunny sites ssl --no-force-ssl
# CI, linking, maintenance
bunny sites ci init # GitHub Actions: push to main goes live
bunny sites ci init --framework astro
bunny sites link my-site
bunny sites unlink
bunny sites delete my-site --keep-storage # typed-name confirmation; keeps the deploy filesClient-side routing and 404s. Single-page apps serve index.html for extensionless paths that aren't files (a refresh on /products/42 returns the app with a 200), while missing assets still 404. This is on automatically when the detected framework is a single-page one (Vite, Create React App, React Router, Angular, Vue CLI, Ember, Preact, Blazor WebAssembly) and the output has a root index.html and no 404.html (a built not-found page wins over the framework heuristic); set sites.spa to true or false in bunny.jsonc, or pass --spa/--no-spa on the deploy, to decide yourself. When neither applies but the output looks client-routed (a single root index.html plus scripts), an interactive deploy asks once and saves the answer to sites.spa; the flags answer it in scripts. Otherwise a root 404.html in the output (as Astro, Eleventy, Hugo, and most static generators emit) becomes the site's not-found page, served with a 404 status. The choice is recorded per deploy and follows rollbacks; sites show prints the live one.
Preconfigure the sites block in bunny.jsonc (name, build, dir, spa) and a deploy needs no arguments: bunny sites deploy --build. sites ci init reads the same block, so the generated workflow builds and deploys exactly what the local command does; without it, the framework is detected from package.json deps, a Blazor WebAssembly .csproj, Gemfile, or a hugo/python/zola config file, with the lockfile picking the package manager. sites create offers to scaffold the workflow on GitHub repos.
Importing an existing zone. sites create <name> --from-zone <zone> turns a storage zone you already serve through a pull zone (for example one deployed by a community storage action) into a site, so custom hostnames, certificates, and DNS stay as they are. The import only writes the site state and the edge rules that block /_bunny/ and /deploys/; nothing else visitors see changes until the first sites deploy, which adds the rest of the site's rules and cache settings and goes live on the existing hostnames (a wildcard hostname never becomes the production domain). Files already at the zone root stay in storage but stop being served after that deploy; delete them yourself once you're happy. The edge blocks _bunny/ and deploys/ at any depth, so a nested path such as docs/_bunny/ or assets/deploys/ stops being served at import. The import refuses a zone that already has a root deploys or _bunny entry, a pull zone running an edge script, or anything but exactly one storage-backed pull zone; --region, --tier and --domain don't apply. Other edge rules on the pull zone are left in place. sites delete treats an imported site like any other and deletes both zones with everything in them.
Every deploy publishes: the files land in an immutable deploys/<id>/ directory and the rewrite rule is pointed at it, so deployments publish rolls back to any earlier deploy by moving that pointer, with no files moving and nothing re-uploaded. The ID is the git short-sha when the tree is clean, a content hash otherwise, or whatever --deploy-id supplies (letters, digits, -, _, .; 4-64 chars; case-sensitive): a custom ID never aliases onto another deploy's content, and reusing one for different content asks before replacing (--force skips the prompt); a replacement clears the old files first, so nothing stale survives. The live deploy and the rollback target are never replaced in place; deploy those under a new ID. Content is root-served, so absolute asset paths work as-is. Direct /deploys/<id>/ URLs are blocked at the edge. Site state lives at _bunny/site.json inside the storage zone (also blocked at the edge); .bunny/site.json is only a local pointer, so a fresh clone can sites link and pick up where the last machine left off.
| Flag | Commands | Description |
|---|---|---|
--region, --domain |
create |
Main storage region code (default DE); custom production domain to attach |
--tier |
create |
Storage tier, hdd or ssd (ssd is DE only); fixed once the site exists |
--from-zone |
create |
Import an existing storage zone (name or ID) and its pull zone instead of creating them |
--site |
deploy, ci init, deployments publish, deployments delete |
Site name or storage zone ID (defaults to the linked site) |
--build [cmd], --env, --env-file |
deploy |
Build before deploying (bare flag uses the configured or detected build); build-time env overrides |
--force |
deploy |
Deploy even when the content is unchanged, and replace an existing --deploy-id without asking |
--deploy-id |
deploy |
Identify the deploy yourself (release tag, catalog ID); case-sensitive, used exactly as given |
--spa, --no-spa |
deploy |
Serve index.html for client-side routes, or the 404 page; beats sites.spa and framework detection, skips the prompt |
--previous |
deployments publish |
Publish the previous deploy (instant rollback) |
--keep |
deployments prune |
Number of recent deploys to keep (default 5; live and previous are always kept) |
--ssl, --wait, --force-ssl |
domains add |
Issue SSL now; wait up to 10 minutes for DNS then issue it; --no-force-ssl keeps HTTP working |
--force-ssl |
ssl |
Force HTTP→HTTPS on the system host; --no-force-ssl allows plain HTTP |
--framework |
ci init |
Framework preset for the workflow's build steps (default: detected) |
--force |
ci init |
Overwrite an existing workflow file |
--print |
open |
Print the URL instead of opening a browser |
--link |
create, deploy, show, ci init, deployments list, deployments publish |
Link the directory to the site; --no-link never links |
--keep-storage |
delete |
Delete the pull zone but keep the storage zone and its deploy files |
--force, -f |
create --from-zone, deployments publish/prune/delete, domains remove, delete |
Skip the confirmation prompts |
Experimental: hidden from
--helpand the landing page while it stabilizes.
Manage bunny.net Stream through four resource groups: bunny stream library (the library itself: create, list, inspect, update, delete, plus its player and security settings; aliases libraries, lib), bunny stream video (the videos inside one library; alias videos), bunny stream collection (groups of videos within a library; alias collections), and bunny stream caption (caption files on one video; alias captions). Three paid operations live alongside them: bunny stream encode, bunny stream transcribe, and bunny stream smart. Library management uses the account API key on the core API; each library also carries its own Stream API key for the video-level API, which the CLI never prints unless you ask for it with credentials. Video commands use that per-library key, resolved automatically from the library, so there is nothing extra to configure. bunny stream import moves an existing video library in from another platform; see below.
library commands take the library as an optional positional (name or numeric ID); every other stream command takes it as the --lib/--library flag, since their positional is the video GUID, the collection ID, or a local file path. When the library is omitted it resolves from the directory's linked library (bunny stream library link, stored in .bunny/stream.json), then an interactive picker, which offers to link the directory to the picked library (destructive commands never offer it). Non-interactive runs (--output json, no TTY, or --force) error with a hint instead of prompting: pass a library or link the directory. A video or collection command with no ID behaves the same way, offering a picker interactively and erroring otherwise. caption is the exception: both of its commands take the video GUID as a required positional, because a language argument follows it.
There is deliberately no video create: a video is added either by uploading a local file with video upload, or by handing bunny.net a URL to download with video fetch.
# Libraries
bunny stream library list # ID, name, videos, storage, traffic, replication regions
bunny stream library create my-library # interactive: prompts for the name when omitted
bunny stream library create --name my-library # the name also takes a flag
bunny stream library create my-library # stored in DE (the fixed main region) and replicated to SG, LA, NY by default
bunny stream library create my-library --replication-regions NY,SG # DE + NY, SG instead; pass DE alone for no replication
bunny stream library create my-library --encoding-tier premium --codecs x264,vp9 # premium encoding with extra codecs
bunny stream library show my-library # details; API keys are never printed here, in any output format
bunny stream library update my-library # interactive: edit name, resolutions, transcribing
bunny stream library update my-library --resolutions 720p,1080p # set just the enabled resolutions
bunny stream library update my-library --transcribing --transcribing-languages en,de
bunny stream library update my-library --keep-original --mp4-fallback --content-tagging # encoding settings
bunny stream library update my-library --add-replication-regions SYD # add-only: regions can't be removed later
bunny stream library credentials my-library # library ID + API key, masked by default
bunny stream library credentials my-library --show-secret --read-only # reveal the read-only key
bunny stream library credentials my-library --rotate # reset the API key (confirms first), then show the new one
# Player and security settings (per library)
bunny stream library player show my-library
bunny stream library player update my-library --color "#FF7755" --speeds 0.5,1.0,1.15,2.0 --heatmap
bunny stream library player update my-library --no-controls # every player control off
bunny stream library player reset my-library # back to the dashboard defaults; confirms first
bunny stream library security show my-library # domains, tokens, direct play, DRM; token key masked
bunny stream library security update my-library --embed-token --block-direct-access --allow-referrer example.com
bunny stream library security update my-library --drm-basic # MediaCage Basic DRM
bunny stream library security reset-token my-library # rotate the token key; signed URLs stop working
bunny stream library delete my-library # confirms with the video count; --force skips it
# Link the working directory to a library so video commands can omit it
bunny stream library link my-library
bunny stream library unlink
# Adding videos: upload a local file, or let bunny.net fetch a URL
bunny stream video upload ./video.mp4 # linked library
bunny stream video upload ./video.mp4 --lib 12345 --title "Launch demo"
bunny stream video upload ./video.mp4 --collection 8a7b6c5d-... # straight into a collection
bunny stream video fetch https://example.com/video.mp4 --lib 12345
bunny stream video fetch https://example.com/video.mp4 --header "Authorization: Bearer abc"
# Videos within a library (GUIDs come from `video list`)
bunny stream video list # ID, title, status, size, length, views, upload date
bunny stream video list --lib 12345 --search launch # a specific library, filtered by title
bunny stream video list --collection 8a7b6c5d-... # only one collection
bunny stream video show 1a2b3c4d-... # details, including the caption languages and the Direct Play URL
bunny stream video update 1a2b3c4d-... --title "Launch demo" # prompts for the title when omitted
bunny stream video update 1a2b3c4d-... --chapters '[{"title":"Intro","start":0,"end":30}]'
bunny stream video thumbnail 1a2b3c4d-... --file ./thumb.jpg # or --url for bunny.net to download it
bunny stream video resolutions 1a2b3c4d-... # what is configured, encoded, and stored
bunny stream video cleanup 1a2b3c4d-... --non-configured --dry-run # preview a rendition cleanup
bunny stream video stats 1a2b3c4d-... # views and watch time (--heatmap, --play-data)
bunny stream video embed 1a2b3c4d-... --expires 1h # player embed URL, signed when embed tokens are on
bunny stream video delete 1a2b3c4d-... # confirms first; --force skips it
# Collections group a library's videos
bunny stream collection list # ID, name, videos, size
bunny stream collection create --name Tutorials # the name also takes a positional
bunny stream collection show 8a7b6c5d-...
bunny stream collection rename 8a7b6c5d-... Guides
bunny stream collection delete 8a7b6c5d-... # deletes the videos inside it too; confirms first
# Caption files on one video (manual captions, not the paid transcription)
bunny stream caption add 1a2b3c4d-... en --file ./captions.vtt --label "English"
bunny stream caption delete 1a2b3c4d-... en # confirms first; --force skips it
# Paid operations
bunny stream encode enable --codecs x264,vp9 # switch the library to premium encoding
bunny stream encode reencode 1a2b3c4d-... # re-encode one video with the current settings
bunny stream transcribe 1a2b3c4d-... --languages en,de # $0.10 per language-minute
bunny stream smart 1a2b3c4d-... --title --chapters # offers to transcribe first if the video has no captionsDeleting a library deletes all of its videos, and deleting the linked library also removes the stale .bunny/stream.json. --force is required for non-interactive deletes, cleanups, and unlinks; without it, a run that cannot prompt exits with an error instead of hanging. On destructive and paid commands --force also disables the pickers, so it can never act on something you did not name.
library create and library update share the encoding and transcribing flags, and update sends only the fields you pass, leaving every other setting untouched. With no flags at all, update opens a small interactive editor (name, enabled resolutions, transcribing) prefilled with the library's current values; unattended runs need at least one flag.
A local upload happens in two steps: the video entry is created first, then the file's bytes are sent to it. If the byte upload fails, the CLI checks the video's status: an entry that never received bytes is deleted so a retry does not leave orphans behind, while anything that may already hold the upload (including a status the CLI could not read) is kept, with a warning naming its GUID, since a lost response does not mean the bytes were rejected. Encoding continues on bunny.net after the command returns, so the reported status is usually Uploaded or Processing rather than Finished.
Files over 2 GB switch to resumable uploads (TUS) automatically, with no extra flag: the file goes up in 64 MiB chunks, and a chunk that fails is retried from the offset the server reports it actually holds, so a blip in the middle of a large upload does not restart the whole transfer. The spinner reports progress as it goes. Resume state lives only in the running command, so a cancelled upload starts over on the next run. Every other flag behaves identically on both paths.
video fetch uses bunny.net's server-side fetch instead: the origin is downloaded by bunny.net, not by the CLI, so nothing is transferred through your machine and the command returns as soon as the fetch is queued. Use --header (repeatable, "Name: value") for an origin that needs authentication. That endpoint answers with a status rather than a video, so fetch reports no video ID or Direct Play URL: the video appears in bunny stream video list once it has been fetched and encoded, titled after the remote file name unless --title says otherwise.
video cleanup deletes encoded renditions, MP4 fallbacks, or the stored original, and needs at least one selector so it can never look like a no-op success. Start with --dry-run, which lists each rendition, MP4 file, or original that would go (worked out from the video's resolutions, since the API's dry run only returns a status), names any --resolutions the video doesn't have, deletes nothing, and skips the confirmation. A real run lists the same items in its confirmation, and a selection that matches nothing deletes nothing. --output json adds a plan with items and notPresent.
A video moves in or out of a collection with video update --collection <id> (an empty value clears it). Creating a collection is free, but deleting one is not just removing a label: the API deletes every video inside it as well, so the confirmation prompt names the count that is about to go. Move the videos you want to keep out of the collection first (video update --collection "" clears it), then delete it. video upload and video fetch can drop a video straight into a collection at ingest, and both also accept --thumbnail-time <ms> to pick the main thumbnail from a frame while the video is being ingested. video thumbnail replaces the image afterwards, from a URL or a local file.
caption add reads a local .vtt or .srt, sends it inline, and reports what the API's validator says: a rejected file lists what is wrong, and an accepted file with non-breaking issues prints them as warnings. These are captions you wrote yourself, unrelated to the paid transcription below.
Three commands cost money and say so before they run. stream encode enable switches a library to the premium encoding tier, billed per output codec per minute of encoded video, and stream encode reencode regenerates every output for one video at the same rate. stream transcribe is billed at $0.10 per language-minute of audio, counting the source-language transcript and each target language once. Before it runs, it prints an estimate: exact with --source-language, and a range without it, because the auto-detected spoken language adds one more language unless it matches a target. If the library transcribes automatically, stream encode reencode and a fresh upload both trigger a transcription on their own, and both commands warn you not to run stream transcribe on top, which would bill it twice. stream smart generates a title, description, chapters, or moments from the transcript, and needs at least one of those flags. Its --source-language names the caption track to read: captions made by transcription are labelled <code>-auto (for example en-auto), uploaded caption files use the plain code, and en falls back to en-auto when that is the only English track. Without the flag, smart reads the video's only track or its single -auto transcript, and asks which to use when there are several (unattended runs stop and ask for --source-language) instead of letting the API take the first one. When the video has no captions yet, smart never transcribes silently: it shows the estimated cost (video minutes × the source language plus the library's default languages × $0.10) and asks whether to transcribe first. Saying yes sends one transcribe request carrying the same generate flags, so the fields come from the new transcript; saying no sends nothing, and stream caption add covers captions you already have. Unattended runs can't answer the prompt, so they stop with the cost unless --transcribe approves it up front. Note that --force on stream transcribe is the API's own force flag, which re-runs and overrides the library defaults, rather than a confirmation skip; on stream smart it only disables the pickers and never approves a transcription.
Every command that operates inside a library accepts --lib <library-id> (alias --library) and falls back to the linked directory, so none of the tables below repeat it.
Library flags (bunny stream library ...):
| Flag | Commands | Description |
|---|---|---|
--name |
create, update |
Library name; on create it is interchangeable with the positional |
--replication-regions |
create |
Regions besides DE, the fixed main region; default SG,LA,NY; DE alone means none |
--add-replication-regions |
update |
Add regions later; add-only, since regions can't be removed |
--mp4-fallback, --early-play, --keep-original, --multi-audio (and --no- forms) |
create, update |
Encoding settings; --early-play needs originals and exposes them |
--content-tagging / --no-content-tagging |
update |
Machine-learning content tagging (not accepted at create time) |
--player-version (1 | 2) |
create |
Player to start with: 1 = legacy, 2 = current |
--encoding-tier (free | premium) |
create, update |
Encoding tier; premium adds JIT encoding and extra codecs |
--jit / --no-jit |
create, update |
Just-in-time encoding |
--codecs |
create, update |
Output codecs, comma-separated: x264, vp9, hevc, av1 (all but x264 need premium) |
--resolutions |
create, update |
Enabled resolutions, comma-separated: 240p through 2160p |
--transcribing / --no-transcribing |
create, update |
Automatic audio transcribing (billed per use) |
--transcribing-languages |
create, update |
Output caption languages, comma-separated (e.g. en,de); checked against the live language list |
--transcribing-title, --transcribing-description, --transcribing-chapters, --transcribing-moments |
create, update |
Generate each field from the transcript |
--read-only, --show-secret |
credentials |
Use the read-only key; reveal the key (masked by default) |
--rotate |
credentials |
Reset the key (or the read-only key with --read-only), then show the new one |
--force, -f |
update, unlink, delete, credentials --rotate |
Skip the prompts (required when there is no TTY to answer them) |
Player flags (bunny stream library player update):
| Flag | Description |
|---|---|
--language |
Player UI language (ISO 639-1), checked against the live language list |
--font, --color |
Font family and primary control colour (hex) |
--caption-color, --caption-background, --caption-size |
Captions appearance |
--controls |
Controls to show, comma-separated; alone it restores the defaults; --no-controls turns every control off |
--speeds |
Playback speeds, comma-separated: presets 0.25–4 or custom values like 1.15 |
--custom-html |
File with custom HTML/CSS for the player head |
--heatmap, --remember-position, --compact-controls, --captions-in-playlist |
On/off toggles (with --no- forms) |
--legacy-player / --no-legacy-player, --player-version |
Legacy (1) or current (2) player |
player reset restores the dashboard defaults: en, Rubik, #FF7755, white captions on black at 20, the default controls and speeds. It confirms first; --force skips that.
Security flags (bunny stream library security update):
| Flag | Description |
|---|---|
--direct-play |
Allow playback from the direct video URL |
--allow-referrer, --remove-allowed-referrer |
Allowed domains (repeatable; wildcards like *.example.com) |
--block-referrer, --remove-blocked-referrer |
Blocked domains (repeatable) |
--block-direct-access |
Block requests with no Referer header |
--embed-token, --cdn-token, --token-ip |
Embed view tokens, CDN tokens, and binding CDN tokens to the viewer's IP |
--drm-basic |
MediaCage Basic DRM; libraries on Enterprise DRM are refused |
All boolean flags take --no- forms. security update sends the settings in one request, then each domain change on its own; changes already in place are skipped, and if one fails the command stops, reports what landed, and exits non-zero, so re-running the same command is safe. CDN token state and the token key are read from the library's linked Pull Zone. video embed signs with that key when embed tokens are on.
Video flags (bunny stream video ...):
| Flag | Commands | Description |
|---|---|---|
--title |
upload, fetch, update |
Video title; defaults to the file name (or the remote file name), prompts on update |
--collection |
upload, fetch, list, update |
Collection ID to put the video in, or to filter the listing by |
--thumbnail-time |
upload, fetch |
Video time in milliseconds to grab the main thumbnail from at ingest |
--header |
fetch |
Header to send with the fetch as "Name: value"; repeatable |
--search |
list |
Only list videos matching this search term |
--chapters, --moments |
update |
Replace the chapter or moment list with a JSON array |
--url, --file |
thumbnail |
Thumbnail image to download or upload; exactly one is required |
--resolutions, --non-configured, --all, --original, --mp4, --outputs (hls | mp4 | all) |
cleanup |
What to delete; at least one selector is required |
--dry-run |
cleanup |
List what would be deleted, deleting nothing |
--heatmap, --play-data |
stats |
Show the watch heatmap or the playback data instead of the statistics |
--from, --to, --hourly |
stats |
Statistics range (UTC) and bucket size |
--force, -f |
delete, cleanup |
Skip the prompts (required when there is no TTY to answer them) |
Collection and caption flags:
| Flag | Commands | Description |
|---|---|---|
--name |
collection create, collection rename |
Collection name; interchangeable with the name positional on both commands |
--search |
collection list |
Only list collections matching this search term |
--file, --label |
caption add |
Caption file to upload (.vtt or .srt), and the label shown in the player |
--force, -f |
collection delete, caption delete |
Skip the prompts (required when there is no TTY to answer them) |
Paid command flags:
| Flag | Commands | Description |
|---|---|---|
--jit, --codecs, --resolutions |
encode enable |
Applied in the same request that switches the library to premium |
--languages, --source-language |
transcribe |
Target languages (comma-separated) and the language spoken in the video |
--generate-title, --generate-description, --generate-chapters, --generate-moments |
transcribe |
Also generate each field from the new transcript |
--title, --description, --chapters, --moments |
smart |
What to generate; at least one is required |
--source-language |
smart |
Caption track to read, e.g. en; auto-generated tracks are <code>-auto (en falls back to en-auto) |
--transcribe |
smart |
Transcribe a captionless video first without asking (paid); required to do so unattended |
--force, -f |
transcribe |
The API's force flag: re-run and override the library's transcribing defaults |
--force, -f |
smart |
Disable the library and video pickers, so nothing is guessed |
Move an existing video library into Stream from Vimeo, AWS S3, Wistia, Mux, Cloudflare Stream, JW Player, or Brightcove. bunny.net fetches every file straight from the source over a URL, so nothing passes through your machine: the command hands each video to Bunny, tags it, and returns as soon as they are all queued. Encoding then happens on Bunny's side; bunny stream import status shows how far it has got, and --wait keeps the command running until every video is encoded. The destination library is the --library flag (alias --lib), a library name or ID; omitted, it resolves from the directory's linked library (.bunny/stream.json), then an interactive picker, which offers to link the directory to the library you pick (except on --dry-run).
bunny stream import # interactive: pick the library, the source, and enter credentials
bunny stream import --library 12345 --source vimeo --dry-run # show the plan: folders, videos, what is already imported
bunny stream import --library 12345 --source vimeo # confirms, then imports
bunny stream import list --source vimeo # browse the source's folders and their IDs
bunny stream import list --source vimeo --videos # every video, with its folder, duration, and size
bunny stream import --library 12345 --source vimeo --folder 987654 # one Vimeo project only
bunny stream import --library 12345 --source s3 --bucket my-videos --prefix 2024/ --force # unattended
bunny stream import --library 12345 --source vimeo --wait # stay until Bunny has encoded every video
bunny stream import --library 12345 --source vimeo --resume # continue an interrupted or partly failed run
bunny stream import status --library 12345 --source vimeo # what Bunny has finished, is encoding, or failedSource folders (Vimeo and Wistia projects, Brightcove folders, the first level of S3 prefixes) become collections of the same name, created when missing. Every imported video carries a per-source metaTag (vimeoId, s3Source, wistiaId, muxAssetId, cfStreamId, jwPlayerId, brightcoveId), and that tag is how a re-run knows what to skip, so running the import twice never duplicates a video. A re-run also looks at what Bunny did with each tagged video: finished ones are skipped, ones still fetching or encoding are reported and left alone, and ones Bunny failed on are imported again (the failed copy stays in the library). A video Bunny holds from a run that died before tagging it is recognised from the saved state and tagged rather than fetched twice. --folder is rejected for Mux, Cloudflare Stream, and JW Player, which have no folder concept.
Source credentials come from environment variables, listed below and in bunny stream import --help. Interactively, the command prompts only for required values that no variable or flag sets (secrets masked; optional values fall back to their defaults without a prompt), then, once the source accepts them, offers to save what you typed for the active profile, so later runs and --resume skip the prompts. Saved values go to $XDG_CONFIG_HOME/bunnynet/stream-import-credentials.json (default ~/.config/...), a file readable only by you and separate from the shared CLI config. They sit under the environment, which always wins, and are removed only when the source rejects them, so the next run prompts again; a timeout, rate limit, or outage keeps them. Decline, and it names the variables to set. Unattended runs (--output json, no TTY) fail naming the missing variables instead. With --source omitted, the command uses the one source whose credentials are all set; otherwise it prompts for a choice, or errors when unattended.
| Source | Variables |
|---|---|
vimeo |
VIMEO_ACCESS_TOKEN (scopes public, private, video_files; downloads need a Standard plan or above) |
s3 |
S3_BUCKET, AWS_REGION (or AWS_DEFAULT_REGION; defaults to us-east-1); optional S3_PREFIX, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, S3_PRESIGNED_URL_TTL, S3_ENDPOINT (or AWS_ENDPOINT_URL_S3) for S3-compatible providers |
wistia |
WISTIA_ACCESS_TOKEN |
mux |
MUX_TOKEN_ID, MUX_TOKEN_SECRET |
cloudflare |
CLOUDFLARE_API_TOKEN (Stream:Edit), CLOUDFLARE_ACCOUNT_ID |
jwplayer |
JWPLAYER_API_KEY (v2), JWPLAYER_SITE_ID |
brightcove |
BRIGHTCOVE_CLIENT_ID, BRIGHTCOVE_CLIENT_SECRET (CMS video read), BRIGHTCOVE_ACCOUNT_ID |
For S3, leave both AWS keys unset to use the default AWS credential chain (instance roles, ~/.aws); setting only one of them is an error. Point S3_ENDPOINT at an S3-compatible provider to import from it, in which case download URLs must be on that host. bunny.net fetches objects through pre-signed URLs, so the IAM policy needs s3:ListBucket and s3:GetObject. Only keys with a video extension are imported, and .ts only with --include-ts (or S3_INCLUDE_TS=true); --bucket, --prefix, and --url-ttl override the variables for one run.
Progress is saved after every status change to $XDG_STATE_HOME/bunnynet/stream-import/<account>/<source>-<library>.json (default ~/.local/state/...), one file per account, source, and library. A run that is interrupted, or finishes with failures, is resumed with --resume, which retries the failed videos, works the outstanding ones, and re-asserts the metaTag on any video that was fetched but not yet tagged. A run with videos still encoding exits 0 and stays open in the state file until status sees them finish. A resume keeps the --folder scope the run started with unless you pass a different one. --dry-run prints the plan and writes nothing: no state file, no directory link, nothing in the library. With --output json, every document carries an outcome. A run that stops before importing prints { outcome, library, source, folder, dryRun, truncated, summary }, with outcome one of no_videos, nothing_new, or dry_run; summary.processingOnBunny counts videos Bunny is still encoding, and the name lists stop at 10000 (truncated says when). A run that imports prints { outcome, library, source, folder, dryRun, status, waited, queued, completed, processing, failed, collections, warnings, statePath, elapsedMs }, with outcome imported, or paused when Ctrl-C stopped it (then library and statePath are null if it stopped before reaching them), where processing counts this run's journal entries still encoding and each failed entry is { name, sourceId, error }; declining the confirmation prints nothing. The confirmation before a real import defaults to No and needs --force when there is no TTY to answer it. After you confirm, the import walks the source a second time rather than reusing the plan, and, for a whole-source run without --folder or --resume, warns if the number of new videos it queued differs from the one you confirmed. Ctrl-C once the run starts stops queuing, saves progress, prints the --resume command, and exits 130; a second Ctrl-C exits at once. status leaves the state file alone while an import is running against it. A run with failed videos exits 1 and lists them, with the reason Bunny gave when it reported one; in a terminal it then offers to retry them straight away (skipped with --force). --resume does not retry a video Bunny rejected for a reason the same file cannot get past (over its limits, an invalid framerate, a corrupt original), one no longer listed at the source, or one that has failed 3 times; a plain re-run skips the rejected ones too rather than adding another broken copy. --clean-failed retries all of them, deleting the failed copies first so they do not pile up in the library, and --retry-stalled deletes and fetches again videos stuck at zero bytes; the confirmation says when a run will delete. Videos the source reports as beyond Bunny's limits (longer than 72 hours, or above 2160p where the source reports resolution: Vimeo, Wistia, Cloudflare Stream) are flagged before you confirm. Calls to Bunny retry rate limits, and reads also retry on a 5xx, so one bad gateway does not end the run. On a library with JIT encoding, a video counts as imported once its JIT playlists exist.
| Flag | Description |
|---|---|
--library, --lib |
Destination video library name or ID (defaults to the linked library) |
--source, -s |
Source platform: vimeo, s3, wistia, mux, cloudflare, jwplayer, brightcove |
--folder |
Import one source folder only (Vimeo, S3, Wistia, Brightcove) |
--dry-run |
Print the plan without importing |
--resume |
Continue the saved import for this source and library |
--wait |
Stay until Bunny has encoded every video, with live progress; the default returns once they are queued |
--force, -f |
Skip the confirmation prompt (required when there is no TTY) |
--concurrency, -c |
Videos imported in parallel, 1 to 20 (default 3) |
--bucket, --prefix, --url-ttl |
S3 overrides for the bucket, key prefix, and pre-signed URL lifetime in seconds (3600 to 604800) |
--include-ts |
S3: import .ts files too; off by default because HLS buckets hold thousands of .ts segments |
--clean-failed |
Delete a video's failed Bunny copies before importing it again, and retry videos Bunny rejected or that failed 3 times |
--retry-stalled |
Delete and import again videos stuck at zero bytes on Bunny past the 30 minute stall threshold |
--request-timeout |
Seconds before one HTTP request gives up, 1 to 3600 (default 30) |
--video-timeout |
Seconds before one video's hand-off to Bunny, or its encode wait with --wait, gives up, 60 to 86400 (default 5400) |
--processing-timeout |
With --wait, seconds to wait for Bunny to encode one video, 60 to 86400 (default 3600); raises --video-timeout when larger |
bunny stream import list browses a source before importing: with no flags it prints each folder's ID, name, and video count, the IDs --folder takes; --videos lists every video instead, and --folder <id> lists the videos in one folder. It resolves the source and its credentials the same way an import does, takes the S3 --bucket and --prefix overrides, needs no library, and prints { source, supportsFolders, folders, uncategorized, videos, truncated } with --output json.
bunny stream import status reads the saved run for the library (and --source, when more than one platform has been imported into it), asks Bunny for the current state of every video it queued, and prints one row each: Bunny's status, encode percentage, size, and when it was queued. A video still processing with no data after 30 minutes is marked (stalled?): Bunny gives no signal for a fetch that died silently, so that is the cue to re-run the import with --retry-stalled, which deletes and fetches it again. It exits 1 when any video has failed, so it doubles as a poll in scripts. With --output json it prints { library, source, status, completed, processing, failed, stalled, stalledAfterMinutes, saved, videos }, one videos row per queued video.
Manage on-demand cloud sandbox environments backed by Bunny Magic Containers. Each sandbox is a fully isolated Ubuntu container with Node.js, Bun, Python (plus uv), the bunny CLI, and Claude Code pre-installed, alongside the tooling agents reach for: git, gh, ripgrep, fd, jq, tmux, sqlite3, tree, and fzf. A 10 GB persistent volume is mounted at /workplace, your default working directory.
/workplace/bin is included in the PATH, so anything you put there runs by name after a redeploy without an absolute path.
Claude Code is pre-installed but needs your own Anthropic credentials before it can do anything: pass an API key at create time (prefer --env-file .env so the key stays out of your shell history), or run claude inside the sandbox and complete the login prompt it prints. Both survive restarts and redeploys: baked env vars live on the container, and config and credentials are pinned to the persistent volume — /workplace/.claude for Claude Code, and /workplace/.config for the bunny CLI and gh.
Sandbox credentials (app ID, SSH endpoint, agent token) are stored in the CLI's local config file (~/.config/bunnynet.json by default) so you can reconnect without re-creating.
Create and start a new sandbox. Waits for the container's SSH port to become reachable before returning.
# Create a sandbox with the default name "sandbox"
bunny sandbox create
# Create a named sandbox
bunny sandbox create my-sandbox
# Create in a specific region
bunny sandbox create my-sandbox --region NY
# Bake in environment variables (persisted for the sandbox's lifetime)
bunny sandbox create my-sandbox -e NODE_ENV=production -e PORT=8080
bunny sandbox create my-sandbox --env-file .env
# Give Claude Code your Anthropic API key at create time (.env holds ANTHROPIC_API_KEY)
bunny sandbox create my-sandbox --env-file .env| Flag | Alias | Description | Default |
|---|---|---|---|
--region |
Region ID to deploy in (e.g. AMS, NY, LA, …) |
AMS |
|
--env |
-e |
Environment variable as KEY=VALUE (repeatable) |
|
--env-file |
Load environment variables from a dotenv file |
Variables set at creation are baked into the container and persist across restarts. Values from --env override those loaded from --env-file. To change them later, use bunny sandbox env.
Once ready, the output shows the app ID and SSH address. Public URLs come later via bunny sandbox url add.
List all sandboxes saved in your local config.
bunny sandbox list
bunny sandbox ls # aliasColumns: Name, App ID, SSH.
Delete a sandbox and permanently destroy the underlying Magic Containers app.
bunny sandbox delete my-sandbox
# Skip the confirmation prompt
bunny sandbox delete my-sandbox --force
bunny sandbox rm my-sandbox -f # alias| Flag | Alias | Description | Default |
|---|---|---|---|
--force |
-f |
Skip confirmation prompt | false |
Run a shell command inside a sandbox over SSH. Defaults to /workplace as the working directory.
# Run a command
bunny sandbox exec my-sandbox ls -la
# Run in a different directory
bunny sandbox exec my-sandbox --cwd /tmp env
# Pipe-friendly: exit code is propagated
bunny sandbox exec my-sandbox -- cat /etc/os-release
# Inject temporary environment variables for this command only
bunny sandbox exec my-sandbox --env DEBUG=1 -- node app.js
bunny sandbox exec my-sandbox --env-file .env -- printenv
# Give up after 30 seconds (exit code 124)
bunny sandbox exec my-sandbox --timeout 30 -- bun run build| Flag | Alias | Description | Default |
|---|---|---|---|
--cwd |
Working directory inside the sandbox | /workplace |
|
--env |
Environment variable as KEY=VALUE (repeatable) |
||
--env-file |
Load environment variables from a dotenv file | ||
--timeout |
Close the SSH connection and exit 124 after this many seconds |
Variables passed here apply only to that single command and are not persisted. For persistent variables, use bunny sandbox env.
Manage files inside a sandbox over SFTP. A bare sandbox name targets /workplace; use <sandbox>:<path> for a specific directory (relative paths resolve against /workplace).
# List /workplace
bunny sandbox files list my-sandbox
bunny sandbox files ls my-sandbox # alias
# List a specific directory
bunny sandbox files list my-sandbox:/workplace/src
bunny sandbox files list my-sandbox:src
# Machine-readable listing (name, type, size, mode)
bunny sandbox files list my-sandbox --output jsonColumns: Name, Type (file/directory/symlink/other), Size, Mode (octal permissions).
Copy a single file between your machine and a sandbox with cp. Exactly one of the two paths must reference a sandbox as <sandbox>:<path>; the other is a local path.
# Upload a file into the sandbox
bunny sandbox files cp ./app.js my-sandbox:/workplace/app.js
# Upload relative to /workplace
bunny sandbox files cp ./app.js my-sandbox:app.js
# An existing remote directory (or a trailing slash) keeps the source filename
bunny sandbox files cp ./app.js my-sandbox:/workplace/src
# Download a file from the sandbox
bunny sandbox files cp my-sandbox:/workplace/out.log ./out.log
# Download into an existing directory (keeps the source filename)
bunny sandbox files cp my-sandbox:/workplace/out.log ./logs/Uploads preserve the local file's Unix mode, so executables stay executable. Only single files are supported: directory and sandbox-to-sandbox copies are not.
cp used to live at bunny sandbox cp. That path now errors with the equivalent bunny sandbox files cp command to run instead.
Open a full interactive SSH session. Drops you into a bash shell at /workplace. Type exit or press Ctrl-D to close.
bunny sandbox ssh my-sandbox
# Set temporary environment variables for the session
bunny sandbox ssh my-sandbox -e DEBUG=1 --env-file .env| Flag | Alias | Description | Default |
|---|---|---|---|
--env |
-e |
Environment variable as KEY=VALUE (repeatable) |
|
--env-file |
Load environment variables from a dotenv file |
Variables apply only to the session and are not persisted.
Manage public CDN endpoints for ports running inside a sandbox. Useful for exposing a dev server or API to the internet.
Expose a container port as a public HTTPS endpoint. Waits until the URL is provisioned and prints it.
# Expose port 3000 (endpoint named "port-3000")
bunny sandbox url add my-sandbox 3000
# Custom endpoint name
bunny sandbox url add my-sandbox 8080 --label my-api| Flag | Description | Default |
|---|---|---|
--label |
Display name for the endpoint | port-<port> |
List all user-created endpoints for a sandbox (built-in api and ssh endpoints are hidden).
bunny sandbox url list my-sandbox
bunny sandbox url ls my-sandbox # aliasColumns: ID, Name, Type, Port, URL.
Remove a public endpoint by name.
bunny sandbox url remove my-sandbox port-3000
# Skip confirmation
bunny sandbox url remove my-sandbox my-api --force
bunny sandbox url rm my-sandbox my-api -f # alias| Flag | Alias | Description | Default |
|---|---|---|---|
--force |
-f |
Skip confirmation prompt | false |
Manage a sandbox's persistent environment variables, the ones baked into the container. Unlike the temporary --env passed to exec/ssh, these survive across sessions. Changing them redeploys the sandbox with the new environment (running processes restart).
Set one or more persistent variables, merging with the existing set.
# Set a single variable
bunny sandbox env set my-sandbox NODE_ENV=production
# Set several at once
bunny sandbox env set my-sandbox API_URL=https://api.example.com LOG_LEVEL=debug
# Load from a dotenv file
bunny sandbox env set my-sandbox --env-file .env| Flag | Description | Default |
|---|---|---|
--env-file |
Load environment variables from a dotenv file |
List the sandbox's persistent variables. The internal AGENT_TOKEN is hidden.
bunny sandbox env list my-sandbox
bunny sandbox env ls my-sandbox # aliasColumns: Name, Value.
Remove one or more persistent variables. Names that are not set are reported and skipped; if none match, the command errors and nothing is redeployed.
bunny sandbox env remove my-sandbox NODE_ENV
bunny sandbox env rm my-sandbox API_URL LOG_LEVEL # alias
bunny sandbox env unset my-sandbox API_URL # aliasMake a raw authenticated HTTP request to any bunny.net API endpoint. Auth is handled automatically via your configured API key.
# List pull zones
bunny api GET /pullzone
# Get a specific pull zone
bunny api GET /pullzone/12345
# List databases
bunny api GET /database/v2/databases
# Create a database with a JSON body
bunny api POST /database/v2/databases --body '{"name":"test","storage_region":"DE","primary_regions":["DE"]}'
# Delete a DNS zone
bunny api DELETE /dnszone/12345
# Pipe body from stdin
echo '{"name":"test"}' | bunny api POST /database/v2/databases
# Show request/response details
bunny api GET /pullzone --verbose| Flag | Alias | Description |
|---|---|---|
--body |
-b |
JSON request body |
The method is case-insensitive (get and GET both work). Paths are relative to https://api.bunny.net — use /database/... for the Database API and /mc/... for Magic Containers.
Generate a shell completion script. Add the output to your shell profile to enable tab completion:
bunny completion >> ~/.zshrc # zsh; use ~/.bashrc for bashFish loads completion files from its own directory:
mkdir -p ~/.config/fish/completions && bunny completion > ~/.config/fish/completions/bunny.fishAfter bunny login succeeds, it prints the exact line for your shell (zsh, bash, or fish).
| Flag | Alias | Description | Default |
|---|---|---|---|
--profile |
-p |
Configuration profile to use | default |
--verbose |
-v |
Enable verbose output | false |
--output |
-o |
Output format: text, json, table, csv, or markdown |
text |
--api-key |
API key (takes priority over profile and environment) | ||
--version |
-V |
Show version (a bare bunny -v works too) |
|
--help |
Show help |
| Format | Description |
|---|---|
text |
Human-friendly borderless tables with bold headers (default) |
json |
Structured JSON for scripting and piping |
table |
Bordered ASCII table |
csv |
Comma-separated values with proper escaping |
markdown |
GitHub-flavored pipe tables |
| Variable | Description |
|---|---|
BUNNYNET_API_KEY |
API key (overrides profile-based key) |
BUNNYNET_API_URL |
API base URL (default: https://api.bunny.net) |
BUNNYNET_DASHBOARD_URL |
Dashboard URL for auth flow (default: https://dash.bunny.net) |
NO_COLOR |
Disable colored output (no-color.org) |