stackbone package
stackbone packageruns inside a project folder and targets no installation, so it takes no--agent. Its--jsonpayload uses the standard envelope. The folder it writes carries real secrets in its.env; the CLI itself prints none of them, see secrets are never printed.
Turn this project into a folder someone else can run, with no access to
Stackbone and no knowledge of it. The folder holds your compiled workspace and
five generated files: a two-line Dockerfile, a docker-compose.yml, an .env
carrying generated secrets, an .env.example you can commit, and a
README.md written for whoever receives it.
--target decides which of two folders you get.
| Target | Containers | Who operates it |
|---|---|---|
byoc (default) |
Your agent, a Postgres with pgvector, a Redis, a MinIO and the one-shot that seeds it |
You, from app.stackbone.ai, after registering the box. Your browser then calls it. |
self-host |
The same, plus a control-plane container serving Studio from inside your network |
Whoever holds the deployment's one static token. Nothing outside the folder. |
stackbone package # writes dist/deploy, the agent alone
stackbone package --target self-host # the agent plus the dashboard
stackbone package --out-dir build/handoff # anywhere else (relative to the project)
stackbone package --tag acme-agent:v3 # name the image the folder builds
stackbone package --offline # put the Stackbone images in the folder too| Flag | Type | Description |
|---|---|---|
--target |
string | byoc (agent only) or self-host (agent + control plane + Studio). Default: byoc. Any other value is refused before the compile runs. |
--out-dir |
string | Where to write the deploy folder. Relative paths resolve against the project. Default: dist/deploy. |
--tag |
string | Image tag the deliverable builds and runs. Default: <agent-slug>:latest, or stackbone-agent:latest if never linked. |
--base |
string | Runtime base image to derive from. Defaults to the published Stackbone runtime base. |
--offline |
boolean | Also save the Stackbone images into the folder: agent-image.tar (around 150 MB) for byoc, stackbone-images.tar (around 300 MB) for self-host. Default: false. |
Both targets ship the same compiled workspace, the same agent image and the
same three infrastructure services, and both mint STACKBONE_SECRET_KEY,
HMAC_SECRET and MINIO_ROOT_PASSWORD into the .env. Everything else that
differs follows from where the dashboard lives: inside the folder, or at
app.stackbone.ai.
byoc: the agent alone
The default, and the folder to hand over when the people who run it can open a
browser on app.stackbone.ai.
docker compose up -d
curl http://localhost:8080/healthThen register the box. Open Workspaces in the dashboard, choose Open on
the installation, and the wizard walks through it:
Connect your box. It asks for the
HMAC_SECRET line in this folder's .env, which is already filled in.
stackbone link writes the same record from a
terminal.
Registering records where the box lives. From then on your browser calls the box directly, and the box decides who you are by verifying the short-lived identity token the control plane minted for you, against that control plane's public keys. Two things follow from that, and both are in the generated README:
- The address you register has to resolve from the machine with the browser on it, not only from the host running Docker. Nothing is proxied.
- The dashboard's origin has to be on the box's CORS allowlist. Left unset it
already is;
STACKBONE_CORS_ALLOW_ORIGINSreplaces the built-in list rather than extending it, so setting it means listinghttps://app.stackbone.aiagain.
The folder ships no STUDIO_STANDALONE_TOKEN, and that absence arms the
identity gate. See The one variable that changes the gate.
self-host: the agent and the dashboard
--target self-host adds a second Stackbone container. control-plane is a
published image (ghcr.io/stackbone/stackbone-control-plane:node24) on port
3000: it serves the Studio SPA, the token login and the credential-reveal call,
and it reaches the agent over the Compose network at http://agent:8080. Both
containers read the same .env and share the Postgres, the Redis and the object
store.
docker compose up -d
curl http://localhost:3000/api/v1/self-host/health # the dashboard
curl http://localhost:8080/health # the agentThe dashboard is at http://localhost:3000. It asks for the
STUDIO_STANDALONE_TOKEN line in .env and nothing else: no account, no email,
no password. Both containers check that one string, so rotating it means
restarting both.
Putting this on hostnames or behind TLS adds rules the two containers cannot
enforce for you: the CORS allowlist, the five streaming routes, the
Authorization header, one scheme for both.
Self-host behind a proxy has them, with a
reverse-proxy sample.
What the folder contains
It always compiles first. package runs the same compile
stackbone build does, then packages what came out
of it, rather than whatever is sitting in dist/. The folder you hand over days
later still carries the code you packaged.
dist/deploy/
workspace/ # the compiled bundle, exactly what `stackbone build` writes
Dockerfile # FROM the published runtime base, COPY workspace/
docker-compose.yml # the agent (and the control plane, on self-host), a Postgres
# with pgvector, a Redis, a MinIO, a bucket-creating one-shot
.env # generated secrets; nothing you must fill in
.env.example # the same variables with no values, safe to commit
README.md # how to start it, use your own services, run it without Compose
agent-image.tar # byoc, only with --offline
stackbone-images.tar # self-host, only with --offlineThe Dockerfile is two lines because the published base already carries the server and its pinned dependencies. Your image adds only the workspace, so there is no install step and the build takes seconds.
The compose file creates the bucket for you. The agent never creates one, so
the file runs a one-shot container that creates it in the bundled MinIO and then
exits. Creating it again is a no-op, so a later docker compose up -d costs
nothing. The agent waits for that container to succeed, not just for MinIO to
answer: a first upload against a bucket that does not exist yet fails with a
NoSuchBucket nobody would trace back to the compose file.
Packaging for a machine with no registry access
By default whoever runs the folder pulls the Stackbone images, and every one of
them is public, so they have to reach a registry once.
--offline puts them in the folder instead, and the two targets save different
archives:
| Target | Archive | Holds |
|---|---|---|
byoc |
agent-image.tar |
The agent image, built on your machine. |
self-host |
stackbone-images.tar |
The agent image and the pulled control-plane image, together. |
The receiving side runs docker load -i <archive> before docker compose up -d
with nothing left to build. That variant needs Docker installed and running
where you package, and it emits no build: block in the compose file: the flag
exists to avoid building.
It is not a self-contained archive. The Postgres, the Redis and the MinIO are
third-party images, so the first docker compose up still pulls them from a
registry. A machine with no registry access at all needs those three saved and
loaded separately.
Three things to tell whoever runs it
All three are already in the generated README, and each fails in a way that reads like a bug in your agent.
The database needs the pgvector extension available. The bundled compose
file uses pgvector/pgvector:pg17 and is fine as it ships. Point the agent at a
database of your own and the extension has to be available there, which on many
managed databases only an administrator can create. See
What you set on the deployed container
below for the failure and the one-line fix.
Downloads from Studio's file browser need a reachable endpoint. The bundled
MinIO answers to http://minio:9000, a name that only exists inside the Compose
network. Everything the agent itself does with files works with that address,
and so does RAG ingestion. A download link is the exception: it is signed and
handed to a browser, which cannot resolve that name. If anyone will download
files, set STACKBONE_S3_ENDPOINT in .env to an address both the container
and the browser reach, such as http://the-host-you-run-this-on:9000. The
compose file publishes port 9000 for that purpose.
The agent serves on port 8080, or on PORT when the host sets one. Hosts that
assign a port and expect the application to adopt it (Cloud Run, Railway,
Heroku, App Runner) need no extra configuration. The generated compose file pins
PORT on purpose, because its port mapping and its health check are both
written against 8080. Point a managed platform's health check at /live rather
than /health: /live answers as soon as the server binds, while /health
also probes the database, which the very first boot is still creating.
You can point it at a managed bucket instead
Any S3-compatible storage works (AWS S3, Cloudflare R2, Azure Blob, Google Cloud
Storage, Railway Buckets, another MinIO). Create the bucket yourself first,
because the compose file makes one only for the bundled MinIO, then fill in the
STACKBONE_S3_* variables in .env. One of them decides how the bucket is
addressed. STACKBONE_S3_FORCE_PATH_STYLE defaults to true, which puts the
bucket in the path (https://host/bucket/key) and is what MinIO and Cloudflare
R2 accept. Set it to false for a backend that only answers to the subdomain
form (https://bucket.host/key), such as AWS S3 and Railway Buckets. The wrong
value fails every call with NoSuchBucket or a bare 403 while the credentials
are valid, so check it before you suspect the keys. See
Path-style or subdomain.
The secrets in the .env
Hand the folder over intact. The generated .env holds the only copy of
STACKBONE_SECRET_KEY, the key the agent encrypts everything it stores with.
| Secret | Target | What it is |
|---|---|---|
STACKBONE_SECRET_KEY |
both | Encrypts every secret the agent stores. There is no recovery if it is lost. |
HMAC_SECRET |
both | On byoc, what registration proves and what the control plane signs its calls with. On self-host, what the two containers sign to each other with. |
MINIO_ROOT_PASSWORD |
both | Root password of the bundled object store, and the agent's STACKBONE_S3_SECRET_KEY. |
STUDIO_STANDALONE_TOKEN |
self-host | The one credential guarding the dashboard, and the only value anyone types. |
Compose itself refuses to start on a missing HMAC_SECRET or
MINIO_ROOT_PASSWORD, and on self-host also on a missing
STUDIO_STANDALONE_TOKEN, STACKBONE_PUBLIC_URL or STACKBONE_SECRET_KEY; the
message names the variable. A byoc folder missing STACKBONE_SECRET_KEY
reaches the agent's own boot check instead, which stops the container before it
opens a port and prints the same name.
The .env.example next to it carries the same variables with no values, so the
folder can go into a repository while the .env stays out of it.
JSON payload
{
"schema_version": 1,
"outDir": "/abs/path/dist/deploy",
"files": ["Dockerfile", "docker-compose.yml", ".env", ".env.example", "README.md"],
"target": "byoc", // or "self-host"
"imageTag": "acme-agent:latest",
"baseImage": "ghcr.io/stackbone/stackbone-workspace-runtime:node24-base",
"controlPlaneImage": null, // the published image on self-host, null on byoc
"workflows": ["onboarding", "rag-ingest", "mapping-suggest"],
"deepAgents": ["support"],
"degradedWorkflows": [], // shipped, but they will not run
"port": 8080,
"controlPlanePort": null, // 3000 on self-host, null on byoc
"offline": false,
}Exit codes: 0 ok, 1 a compile failed, --target named something other
than byoc or self-host, a flag was passed with an empty value, or
--offline could not run Docker (the message carries Docker's own output).
What you set on the deployed container
The agent boots on three values and works the rest out for itself.
| Variable | What it is |
|---|---|
DATABASE_URL |
The Postgres the runtime keeps its runs, sessions and secrets in. A completely empty database is fine: the first boot creates every table it needs. It does need pgvector, see below. |
WORKFLOW_REDIS_URL |
The Redis backing the durable workflow engine. |
STACKBONE_SECRET_KEY |
The key that encrypts every secret the runtime stores. Generate one with openssl rand -base64 32. |
On a byoc box
Three more decide what address the box answers on and whose browsers it lets in.
The generated .env ships all three commented out, because every unset
behaviour is already the right one for a box registered from
app.stackbone.ai.
| Variable | What it is |
|---|---|
STACKBONE_PUBLIC_URL |
The box's own public origin. Nothing in the compose file needs it; Stackbone Connect builds an OAuth redirect_uri from it, as <value>/api/connect/callback. Set it once the box is behind its real address. |
STACKBONE_CONTROL_PLANE_URL |
Whose browser tokens the box trusts. Unset means https://api.stackbone.ai. Set it only for a control plane of your own: the box derives the issuer, the audience and the JWKS URL from this one value. |
STACKBONE_CORS_ALLOW_ORIGINS |
Comma-separated origins the box accepts browser calls from. It replaces the built-in list (https://app.stackbone.ai, https://chat.stackbone.ai, http://localhost:*), so anything you set is the whole allowlist. |
package generates HMAC_SECRET into the .env rather than leaving it to the
box, and that is the value the registration wizard asks for. A box started
without one mints its own and prints it in the log once, in the clear; a value
set before the first start is never printed at all.
On a self-host deployment
Running the dashboard beside the agent adds five more. The generated .env sets
STUDIO_STANDALONE_TOKEN, HMAC_SECRET and STACKBONE_PUBLIC_URL, and
documents the other two. The ones marked both have to hold the same value on
each container: that is what a shared .env is for.
| Variable | Read by | What it is |
|---|---|---|
STUDIO_STANDALONE_TOKEN |
both | The bearer the dashboard logs in with, and the only credential guarding the deployment. Any non-empty string. Change it and restart both containers, or they enforce two different tokens. |
HMAC_SECRET |
both | Signs the dashboard's server-side calls to the agent. A mismatch answers every one of them with 401 invalid_signature. |
STACKBONE_PUBLIC_URL |
both | Where a browser reaches the agent. Absolute http:// or https://, no credentials, no path, no query, no fragment. The dashboard is handed this at login and calls the agent with it. |
STACKBONE_RUNTIME_INTERNAL_URL |
dashboard | Where the dashboard container reaches the agent, server-side. Same shape rules. Defaults to http://agent:8080, the Compose service name. 127.0.0.1 here names the dashboard itself. |
STACKBONE_CORS_ALLOW_ORIGINS |
agent | Comma-separated origins the agent accepts browser calls from. It replaces the agent's built-in list, so anything you set is the whole allowlist. * matches one component. |
Two more name the tenant, and both containers read them from the same lines:
AGENT_ID and STACKBONE_ORG_SLUG. The dashboard seeds its organization and
agent rows from them; the agent prefixes its storage keys and log records with
them. Changing one after the first boot strands whatever the old value keyed. A
byoc folder sets neither, because there is no second container to agree with.
CONTROL_PLANE_PORT moves the host port the dashboard is published on. The
container always serves on 3000.
The one variable that changes the gate
STUDIO_STANDALONE_TOKEN decides which of two authentication strategies the
agent arms, and the agent arms exactly one:
- Unset, which is every
byocfolder: the agent verifies short-lived identity tokens against the control plane's published keys, and admits only the installation it was registered as. - Set, which is every
self-hostfolder: the agent turns that verification off, refuses every control-plane identity token, and accepts as its operator anyone presenting that one string as a bearer.
The variable belongs to the folder that ships its own dashboard, and only
there. If an orchestrator template, a secret store or a copied .env line feeds
it into a byoc container, that box admits anyone holding the string instead of
the operators you gave Stackbone accounts to. Nothing about the container looks
different: it stays healthy and it keeps answering.
The agent names the strategy it armed in its boot log. When a dashboard that
worked starts refusing you, run
docker compose logs agent | grep "identity gate". See
A self-host box takes one operator token.
There is no STACKBONE_INSTALLATION_ID in a generated folder
That is on purpose. Each container mints its own installation id on first boot
and keeps it, and nothing compares one against another. Setting it in .env
would reach the agent too, which validates it as a UUID and exits on anything
else.
Rules that hold on any host
Keep STACKBONE_SECRET_KEY stable and keep it safe. It cannot live in the
database it protects, so it is the one value you always supply, and changing it
makes every secret already stored unreadable, with no recovery.
The database needs the pgvector extension available. While creating its
schema the first boot runs CREATE EXTENSION IF NOT EXISTS vector. A stock
Postgres applies most of the schema and then stops with
extension "vector" is not available, which reads like a bug in your agent and
is not one. Nothing is damaged: add the extension and start it again. On many
managed databases only an administrator can create it, so if your application
role is not one, run this once as an administrator before the first boot:
CREATE EXTENSION IF NOT EXISTS vector;A deploy folder from stackbone package runs pgvector/pgvector:pg17 for you
and needs none of this.
What the agent works out for itself when you run it alone: on its first boot
it mints an installation id and the signing secret
stackbone link asks for, then stores both in its own
database so every restart reuses them. It reads the agent and workspace labels off
the manifest stackbone build stamped into the
bundle. A deploy folder pins HMAC_SECRET in .env instead, so nothing has to be
read back out of a log; a self-host folder pins AGENT_ID too, because the
dashboard beside it has to agree on the tenant. The installation id stays minted
per container in both.
You can still set any of those four yourself (STACKBONE_INSTALLATION_ID,
HMAC_SECRET, AGENT_ID, WORKSPACE_ID) and your value wins. Take that route
when your platform injects secrets from a vault and you want them decided before
the container starts. STACKBONE_INSTALLATION_ID has to be a UUID. The boot
rejects anything else and the container exits.
Setting both identity values is a special case: the boot then never reads the stored identity at all, which lets a deployment that predates the identity table come up unchanged. The boot cannot tell you that your values differ from what that database already holds, so if you pin them, pin them from one source and keep them stable. Set only one and the boot does compare, seeding the half you left out and warning when the half you pinned contradicts the stored one.
Your agents also need a model provider. That one comes from the Model
provider screen on the deployment, or from MODEL_PROVIDER_API_KEY and
MODEL_PROVIDER_BASE_URL on the container. See
Production: zero config.
A missing value stops the container before it opens a port. The boot prints one block per broken variable: its name, what it is for, and a command that produces a value. A misconfigured container crash-loops on start, so a half-wired box never serves traffic.
PORT is optional. The image serves on 8080 when nothing sets it, and
binds whatever PORT you do set, so a platform that assigns the port works
untouched. A PORT that is not a usable port number fails the boot with a
message naming the value, rather than falling back to 8080 and leaving the
platform health-checking a port nothing listens on.