Deployment requirements
What a deployment has to provide for the portal, and what runs where once it does.
The portal runs inside the customer’s own AWS account. A Terragrunt stack in that account’s infrastructure repository describes the deployment, and the account’s owner applies it. The portal ships the application and states what a deployment must provide; it provisions nothing itself.
Key rules
- One public entrance. CloudFront serves the web app and forwards
/apiand/idp; nothing else is reachable from the internet. - Two hosts. A stateless host in a scaling group runs the backend and the agent runtime; a data host runs Postgres, Keycloak and LiteLLM.
- No public address on either host. Operators arrive through the AWS session service, which leaves an audit trail.
- Every boot value comes from the account’s secret store. A missing value fails the boot rather than defaulting.
- The application creates no database schema. The account’s infrastructure creates them first.
What runs where
- The browser calls CloudFront, the one internet address.
- By default CloudFront serves the built web app from a private bucket nothing else can read.
- Requests under
/apiand/idpgo to a load balancer. - The load balancer sends
/apito the stateless host and/idpto the data host. - The stateless host talks to the data host. Both read their configuration from the secret store.
| Host | Runs | Why there |
|---|---|---|
| Stateless host, in a scaling group | The backend and the agent runtime | Both are stateless: losing the host costs a restart |
| Data host | Postgres with the pgvector extension, Keycloak and LiteLLM | Keycloak and LiteLLM keep their state in that Postgres, and neither runs twice unaided |
The data host keeps its data on an attached EBS volume, so a rebuilt instance keeps it. The session cache is a managed service, reached over TLS with a token, since nothing in it outlives a session.
One entrance
A deployment has one internet address: the delivery network. The portal API needs that single origin. The backend keeps the access token and hands the browser only a session cookie; two hostnames would mean relaxing that.
CloudFront forwards /api unchanged, and the backend mounts every route under it. The address the browser called is the address the application answered on, so neither the sign-in redirect the Keycloak realm allows nor the provisioning address you give your directory needs a prefix re-added. The web bundle asks for a relative path and is told no hostname at build time.
Applications that sign each person in also pass through this entrance, under /api/mcp. How that consent round trip works is on Applications & MCP gateway.
Load balancer
The load balancer is there for lifecycle rather than load. CloudFront will not move an origin that names a machine while it serves, so through the balancer, replacing a host is just registering a new target.
It also gives Keycloak a prefix of its own, /idp, beside the portal’s /auth/login and /auth/callback, so a request for either reaches exactly one of them.
| Path | Goes to |
|---|---|
| Default | The private web-app bucket (served by CloudFront) |
/api | The stateless host |
/idp | The data host (Keycloak) |
Host access
Neither host is reachable from the internet: no public address, no key pair, no listening shell. Operators arrive through the AWS session service (Systems Manager Session Manager), which leaves an audit trail.
Readiness checklist
-
Provide the network
The stack creates no network. The account already has one, with addressing and routing somebody else is accountable for, and the stack reads its identifiers as given.
-
Create the database schemas
Nothing in the application creates a schema. The datasource names the schemas it may touch and fails against a missing one, so the account’s infrastructure creates them before the first schema apply:
agent,application,asset,chat,directory,gateway,governance,retrieval,tool,usage,visibility— pluspgboss, inside which the job queue builds its own tables. -
Put the configuration in the secret store
Every value a host needs at boot comes from the account’s secret store, fetched on startup. The infrastructure description grants the right to read those secrets and says nothing about their contents, so no value is committed or reaches infrastructure state. A missing value fails the boot.
-
Set up the entrance and load balancer
CloudFront with the private bucket as its default origin, and
/apiand/idpforwarded to the load balancer, which routes them to the two hosts as in the table above. -
Grant the backend's Bedrock permissions
The role attached to the backend needs catalogue reads, availability reads and model invocation. The policy is on Model catalog.
-
Prepare the hosts
The bootstrap prepares a host, gives it a container runtime and teaches it to fetch its configuration, then stops. It does not start the application.
-
Deploy a version
Deploying a version is a separate act from preparing a host, so a healthy host is never half-deployed. The agent image carries the builtin skills, and every runtime loads them at startup.
Branding
The organisation shows its own mark rather than the product’s. An Org Owner uploads a logo, a logo for the dark shell and a square mark in Admin console › Organisation — see Organisation.
- The marks are stored as files of the organisation in the object store and served from a public route.
- The sign-in screen shows them only when the organisation turns that on.
- An organisation that uploads nothing shows the product’s mark, which is compiled into the web bundle and the Keycloak login theme.
- Nothing about branding is chosen at build time, so a new mark is an upload, not a rebuild.
Artifact rendering
Agents produce Office documents, PDFs and web pages. The backend image carries what it takes to show them faithfully.
| Installed | Why |
|---|---|
| LibreOffice Writer, Calc and Impress | Converting Office documents to PDF |
| Chromium | The HTML inspector and the layout measurer |
| Liberation 2 fonts | Required for Vietnamese text. The older fonts-liberation package lacks those glyphs and can misplace them when LibreOffice switches fonts within a line |
| DejaVu | Symbols such as checkmarks |
| Symbola | The pictographs a report uses as bullets |
| Carlito, Caladea, Noto | Further text fonts for converted documents |
No colour emoji font is installed. Noto Color Emoji is a bitmap font the PDF renderer cannot rebuild, and fontconfig prefers it over the outline families, so adding it blanks the symbols that draw today. A pictograph therefore reaches the page as a monochrome outline — a red, an amber and a green circle come out as three near-identical rings.
Showing a document to a person
- An Office file is converted by LibreOffice to PDF — once, with a fresh temporary profile, macros disabled and a clean child environment.
- The PDF is sent whole.
- The browser draws the pages itself with pdf.js, so nothing is rasterised on the server for a person reading.
The frontend build copies pdf.js’s fonts, character maps, colour profile and wasm data beside the application; the browser fetches them only for a page that needs them.
Showing a page to the agent
Pixels go only to the one reader that needs them: the agent looking at a page before it saves the file. Such a page is capped by its longest edge — the dimension a model is charged on — and declares the format it was encoded in: a converted document’s pages come out of poppler as JPEG, a browser frame as PNG.
The hardened browser
Chromium serves the HTML inspector and the layout measurer through one hardened launch:
- no portal session, no external network;
- no service workers, no WebSockets, no downloads;
- a sandboxed frame.
The browser stays open between calls, since starting it costs more than rendering in it, and closes once a minute passes with nothing to draw. Isolation is per call: each inspection opens its own context, and the two widths an app is reviewed at load in that context at the same time. The page’s runtime and console errors are collected in the context, because a listener attached after the content loads has already missed a script that threw while parsing.
Safety limits
- Inspection reads the union of the installed fonts’ character sets once per process and reports any character outside it, because such a character reaches the page as an empty box.
- Caches hold immutable bytes; every request rechecks file access before using cached output.
- Conversion queues, render worker counts and process timeouts bound native work.
- A long page taller than the screenshot height reports the uncaptured portion explicitly.
Why it works this way
Why keep Keycloak and LiteLLM off the scaling group?
Neither runs twice unaided. Keycloak needs a cache cluster between copies, and LiteLLM is the single place a budget is enforced. So they live on the data host, beside the Postgres they keep their state in.
Why Postgres on a host rather than a managed service?
So the vector extension sits beside every other table. Knowledge, memory and the portal’s own data live in one database.
Under the hood
Why pdf.js is loaded from its legacy build
The current pdf.js build fingerprints a document with Uint8Array.prototype.toHex, which Chrome added in version 140, Firefox in 133 and Safari in 18.2. An older browser answers every document with n.toHex is not a function and draws nothing. A customer’s fleet is not guaranteed to be newer than that, so the portal loads the legacy build, which carries polyfills. It costs about 60KB more, in a chunk that loads only when somebody opens a document.
Builtin skills in the agent image
The builtin skills are baked into the agent image and loaded by every runtime at startup. Building and updating them is an engineering task done with the portal’s CLI harness.