--- title: 'Self-host behind a proxy' description: 'The two containers a self-host deployment runs, the two public addresses they need, and the reverse-proxy rules that make the dashboard, its streams and its logins work.' position: 3 --- # 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`](/docs/cli/reference/package) 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](/docs/home/deployments/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 workspace ``` Two 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. ```nginx 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//logs/stream` | The same tail, narrowed to one run. | Every 15 s | | `/api/workflows//chat` | A workflow chat turn. | None | | `/agui/v1/agents/` | 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: ```sh 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. > [!WARNING] 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: ```sh # 1. Write the new value into .env (one line, both services read it). # 2. Restart BOTH. docker compose up -d --force-recreate control-plane agent ``` Restart 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. > [!NOTE] 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](/docs/home/features/security-auth#a-self-host-box-takes-one-operator-token). ## 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 `401`s 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`](/docs/cli/reference/package): both folders it writes, their files, and every variable the two containers read. - [Security and auth](/docs/home/features/security-auth): every credential in a deployment and who holds it. - [API](/docs/home/features/api): the routes the agent serves and what guards each one. - [Troubleshooting](/docs/home/deployments/troubleshooting): the refusals the register wizard shows, for a box connected to a Stackbone control plane.