Self-host behind a proxy
A self-host deployment runs two Stackbone containers. The control plane serves the dashboard, and the dashboard calls the agent from your browser, so both containers need a public address and rules on your reverse proxy.
Everything here applies to the folder
stackbone package --target self-host writes.
That flag puts a dashboard in the folder; without it package writes the agent
alone, for a deployment you operate from app.stackbone.ai instead
(Connect your box). On a laptop the
self-host folder runs untouched on http://localhost:3000 and
http://localhost:8080, with no proxy involved. Read this page when you put it
on a hostname or behind TLS.
The two services
| Service | Container port | What it serves |
|---|---|---|
control-plane |
3000 |
The Studio dashboard (the SPA itself), the token login, and the credential-reveal call. |
agent |
8080 |
Your workspace: runs, sessions, prompts, secrets, workflows, approvals, evals, its SSE streams, the chat wires. |
They share one Postgres, one Redis, one object store and one .env. Neither
proxies for the other: the control plane answers server-side calls of its own to
the agent over the Compose network, and the browser talks to both directly.
The browser opens connections to both hostnames. Publish only the dashboard and every screen in it fails.
The two public addresses
https://stackbone.example.com → control-plane:3000 the dashboard
https://agent.example.com → agent:8080 your workspaceTwo names on one proxy, or two names on two proxies. Either layout works, as
long as a browser can reach both. .env has to match:
| Variable | Set it to |
|---|---|
STACKBONE_PUBLIC_URL |
The agent's public address, e.g. https://agent.example.com. Both containers read it. |
STACKBONE_CORS_ALLOW_ORIGINS |
Every origin a browser opens the dashboard on, including https://stackbone.example.com. |
CONTROL_PLANE_PORT |
The host port the dashboard is published on, when 3000 is taken. The container always serves on 3000 inside. |
STACKBONE_RUNTIME_INTERNAL_URL |
Left alone. It is the Compose service name the control plane dials server-side, and it never leaves the network. |
The agent's host port has no variable of its own. It is the left-hand number of
the agent's ports: line in docker-compose.yml, and the port in
STACKBONE_PUBLIC_URL has to match it. Move one without the other and both
containers stay healthy while the dashboard sends every browser to a port
nothing listens on.
STACKBONE_PUBLIC_URL is an origin: absolute http:// or https://, no
credentials, no path, no query string, no fragment. Both containers refuse to
start on anything else and the message names the variable.
A path prefix looks harmless and breaks the deployment. Every caller appends its
own path to this value, so a prefix produces https://agent.example.com/stackbone/api/runs
where the agent serves /api/runs. The dashboard 404s on every screen, and the
redirect_uri the agent hands an OAuth provider for a Connect integration is a
URL that provider refuses. If your proxy serves the agent under a path, strip the
prefix in the proxy and point this at the origin.
The dashboard receives STACKBONE_PUBLIC_URL at login and calls it from the
browser for the rest of the session, so it has to resolve from an operator's
machine. Never a Compose service name, and 127.0.0.1 only when the browser
runs on the Docker host itself, which is what the generated value assumes.
Same scheme on both
If the dashboard is on https:// the agent has to be too. A browser blocks an
http:// request made from an https:// page as mixed content: the request
never leaves, and you see a dashboard that cannot reach the agent with nothing
on screen to say why. The login refuses that combination up front and says so,
rather than signing you in to a shell where nothing loads.
A reverse proxy that works
Two vhosts. The agent's vhost carries the rules. It holds connections open for minutes at a time and it accepts large uploads, and a proxy left on its defaults breaks both.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# The dashboard.
server {
listen 443 ssl http2;
server_name stackbone.example.com;
ssl_certificate /etc/letsencrypt/live/stackbone.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stackbone.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
# The agent.
server {
listen 443 ssl http2;
server_name agent.example.com;
ssl_certificate /etc/letsencrypt/live/agent.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/agent.example.com/privkey.pem;
# Uploads. The agent accepts 25 MiB; nginx's default is 1 MB, and a file over
# that is refused here with a bare 413 the agent never sees.
client_max_body_size 26m;
# The whole vhost, not a pattern. Buffering holds frames until the buffer
# fills, which for a stream that says nothing for minutes means the dashboard
# shows nothing for minutes; the read timeout closes a connection that is
# behaving correctly. Seven routes need this and three of them are not under
# /api, so a regex is a list that goes stale.
proxy_buffering off;
proxy_read_timeout 1d;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}The two settings sit on the whole agent vhost on purpose, which is what the
Stackbone dev tunnel does too. Narrowing them to a location ~ pattern means
keeping that pattern in step with the table below, and the entries you are most
likely to forget are the chat wires that need it most.
The streams
Seven routes on the agent hold a connection open. Three are tails the dashboard keeps on screen; four are chat turns, which can go a long time without sending anything.
| Route | What it carries | Heartbeat |
|---|---|---|
/api/events |
Live run, step and approval updates. | Every 15 s |
/api/logs/stream |
The log tail. | Every 15 s |
/api/runs/<id>/logs/stream |
The same tail, narrowed to one run. | Every 15 s |
/api/workflows/<name>/chat |
A workflow chat turn. | None |
/agui/v1/agents/<name> |
A deep-agent chat turn. | None |
/openai/v1/chat/completions |
The same turn on the OpenAI wire, when it asks to stream. | None |
/anthropic/v1/messages |
The same turn on the Anthropic wire, same condition. | None |
The first three write a ping frame every 15 seconds, so an idle one still
sends traffic. The four chat wires write nothing while the model is thinking, so
a 60-second read timeout cuts a slow turn mid-stream and buffering delivers a
fast one as a single lump when the turn ends. Three of the seven are not under
/api, which is the other reason to set the rules on the vhost.
The agent sends no buffering hint of its own. Buffering is your proxy's setting.
Keep Authorization intact
The agent reads the dashboard's credential from the Authorization header and
from nowhere else. There is no query-parameter fallback and no cookie. A proxy
that strips or rewrites that header turns every call into a 401, the streams
included. A stream that gets one retries once and then closes for good: self-host
has no token to re-mint, so the panel goes to its error state and stays there
until you reload the page.
The SSE reconnect also sends a Last-Event-ID header. It is a custom header, so
the browser preflights it, which is one more reason not to filter what reaches
the agent.
CORS replaces, it does not add
STACKBONE_CORS_ALLOW_ORIGINS is a comma-separated list, and whatever you set
becomes the whole allowlist. It does not extend the built-in one. Omit an
origin that used to work and it stops working.
Two lists follow, and they differ by one entry.
The agent's built-in list, which it uses when nothing sets the variable:
https://app.stackbone.ai,https://chat.stackbone.ai,http://localhost:*The self-host compose passes the agent this list when you leave the variable
unset. It is the same list plus 127.0.0.1, because a browser treats it as a
different origin from localhost and operators open the dashboard by both:
https://app.stackbone.ai,https://chat.stackbone.ai,http://localhost:*,http://127.0.0.1:*That value lives in docker-compose.yml as the fallback for
${STACKBONE_CORS_ALLOW_ORIGINS:-…}. The generated .env ships the line
commented out, so setting it there overrides the fallback. Start from the four
above:
STACKBONE_CORS_ALLOW_ORIGINS=https://app.stackbone.ai,https://chat.stackbone.ai,http://localhost:*,http://127.0.0.1:*,https://stackbone.example.com* matches one component and stops at a dot, so http://localhost:* matches
http://localhost:4200 and not http://localhost:4200.example.com.
The agent sends no 403 for an origin that is missing from the list. It
omits the Access-Control-Allow-Origin header, and the browser refuses the
response on its own. In DevTools that shows as a CORS error with a 200 behind
it, so the agent looks broken while it does what you configured.
CORS is not authentication
The allowlist decides which web pages a browser lets talk to the agent. It
stops nothing else. Anything that is not a browser ignores it: curl, a
script, a background job. The bearer token guards the agent, together with
whatever network boundary you put the containers behind. Adding an origin is
not granting access, and removing one is not revoking it.
The one token, on both containers
STUDIO_STANDALONE_TOKEN is the single credential for the whole deployment. The
control plane checks it on the login and the discovery call; the agent checks it
on every gated route, comparing the whole string in constant time. Both read it
from the same .env line, and both trim it, so a trailing space in the file
does not become two different secrets.
Rotating it is a two-container operation:
# 1. Write the new value into .env (one line, both services read it).
# 2. Restart BOTH.
docker compose up -d --force-recreate control-plane agentRestart one and not the other and the deployment enforces two different tokens:
the dashboard signs in against the control plane and then gets a 401 from the
agent on every screen. Everyone signs in again after a rotation: the browser
holds each session, and nothing on the server invalidates it.
HMAC_SECRET is the other shared value, and it rotates the same way. It signs
the control plane's own server-side calls to the agent, so a mismatch there
shows up as 401 invalid_signature on the credential reveal while everything
else keeps working.
The agent has no second gate here
A self-host agent authenticates the operator's token and nothing else. A box registered against a Stackbone control plane instead verifies short-lived identity tokens; the two strategies are exclusive, and the agent's boot log names which one it armed. See Security and auth.
Health checks
Point your platform at these. None of them needs a credential.
| Service | Route | Answers |
|---|---|---|
| control-plane | /api/v1/self-host/health |
200 {"status":"healthy"} once migrations and seeds ran. |
| agent | /live |
200 {"status":"ok"} immediately, with no probes. |
| agent | /health |
Every subsystem probe, or 503 naming the one that failed. |
Use /live for liveness and /health for readiness. The generated Compose file
probes the agent on /health and the control plane on its own route, both with a
60-second start period, because the agent's first boot creates its schema before
it serves. Compose does not kill a container for a failing health check. A short
grace instead leaves the agent marked unhealthy through that first migration,
which holds every depends_on: service_healthy shut and fails
docker compose up --wait. A managed platform will kill it: use /live there,
which answers before the schema exists.
What still runs on the dashboard's origin
Most of what you see comes from the agent. Three things do not, and they are why the dashboard's own origin has to keep working:
| Call | Why it stays |
|---|---|
GET /api/v1/self-host/context |
The login: it validates the token and answers with the agent's address. |
| The dashboard's own files | The SPA is served by the control plane, from the same origin. |
| Revealing a stored secret | The reveal goes through the control plane's custody path, which then forwards a signed call to the agent over the Compose network. |
The reveal is the only browser action that makes the control plane call the
agent, so it is the one that breaks when STACKBONE_RUNTIME_INTERNAL_URL or
HMAC_SECRET is wrong. Everything else you click goes straight to the agent.
When it does not work
| What you see | Where to look |
|---|---|
| The login says the control plane did not say where the agent is | The control plane is not running, or it is an older image with no /api/v1/self-host/context. It refuses to start at all without STACKBONE_PUBLIC_URL, so an unset variable is a crash loop, not this. |
The login refuses an http:// agent |
The dashboard is on HTTPS. Put the agent behind TLS too. |
| "Couldn't reach the agent", both containers healthy | The dashboard's origin is missing from STACKBONE_CORS_ALLOW_ORIGINS, or STACKBONE_PUBLIC_URL names an address your browser cannot dial. |
Every screen 401s right after signing in |
The two containers hold different STUDIO_STANDALONE_TOKEN values. Restart both. |
| Runs and logs never update, everything else works | The proxy is buffering the streams, or closing them on a read timeout. Set proxy_buffering off and a long read timeout on the whole agent vhost. |
| Reveal fails, nothing else does | HMAC_SECRET differs between the containers, or STACKBONE_RUNTIME_INTERNAL_URL does not resolve. |
Uploads over about 1 MB fail with a bare 413 |
Your proxy's body-size limit, not the agent's. The agent accepts 25 MiB; raise client_max_body_size on the agent vhost. |
Read more
stackbone package: both folders it writes, their files, and every variable the two containers read.- Security and auth: every credential in a deployment and who holds it.
- API: the routes the agent serves and what guards each one.
- Troubleshooting: the refusals the register wizard shows, for a box connected to a Stackbone control plane.