Hosting several applications
One TesseraQL runtime serves one application; the framework’s own surfaces — the sign-in and
account pages, the portal, the operations console and IAM Admin — are the
stack’s, served once at the origin scope. To run several applications on one machine, start
them together with tesseraql host. Each gets its own runtime, and one port fronts them all.
Studio exists only in development: a hosted production stack mounts no Studio at all, and no
configuration changes that (studio-shell.md) — changes reach a host through
deploy, never an editor.
tesseraql host --stack /srv/tesseraql/apps --port 8080Every application the directory holds starts in its own runtime: its own runtime context, its own datasource set, its own traces. A gateway on the given port routes each request to the right one.
What --stack accepts
Section titled “What --stack accepts”Two shapes, and the directory’s contents decide which it is
(cli-surface.md):
an install root, which holds catalog.json, or a folder of application homes, which
holds none. They host identically — the second is how a stack runs from source trees without
being packaged first.
A stack is a directory that holds applications, never an application home itself, so an
application home is refused — the refusal prints the narrowing that would have worked.
--app-name <name> serves one member of the stack, at the same address it has beside its
neighbours: narrowing is a filter, not a second deployment shape, so the application emits the
same URLs either way.
The stack’s own settings — tesseraql-stack.yml
Section titled “The stack’s own settings — tesseraql-stack.yml”A stack may declare settings of its own in tesseraql-stack.yml, in the directory --stack
names — always that name, always that place. The file is read through the same configuration
machinery applications use, so ${ENV_VAR:default} and ${secret.…} resolve in it.
What belongs there is what fails silently when it diverges between applications or between development and production:
framework: datasource: # one sign-in across the stack rides this connection jdbcUrl: jdbc:postgresql://${DB_HOST:localhost}:5432/stack username: ${secret.env.STACK_DB_USER} password: ${secret.env.STACK_DB_PASSWORD} maximumPoolSize: 5 # sign-in is millisecond point queriesexternalOrigin: https://apps.example.comroot: redirect: orders # / -> /orders; omitted, / lands on the portalWhen the stack supplies framework.datasource, the host builds one pool and every application’s
framework state — sessions, tokens, preferences — rides it, so one sign-in carries by
construction. An application that explicitly declares tesseraql.framework.datasource in that
arrangement is refused (TQL-APP-4212) rather than silently repointed.
The pool takes the keys an application’s datasource takes, with the same defaults: 10
connections, a 30 s wait, and minimumIdle at the pool size
(deployment). Under dev --embedded-db the embedded server
supplies the coordinate, and the block still supplies the sizing.
The pool carries the whole of sign-in. The stack surface, which serves sign-in, the account pages
and IAM Admin at the origin, uses it as its own main instead of opening a pool of its own. So
the credential check, the TOTP check and the session all ride it. Every step is a short point
query, and the password hash is checked in the JVM, so a few connections carry a lot of sign-ins.
A stack that declares no framework.datasource gives the surface a pool of its own, 10
connections on the coordinate its applications agree on.
To see whether sign-in waits for a connection, give the surface a scrape. The surface is bundled,
so its scrape is configured in the stack file, with the keys an application uses under
tesseraql.metrics:
metrics: enabled: true # the origin's /_tesseraql/metrics unauthenticated: true # a scraper the network already guards; or, instead:security: jwt: secret: ${secret.env.STACK_JWT_SECRET} policies: ops.metrics.view: # a bearer holding this policy reads the scrape anyOf: - role: OPSThe origin’s /_tesseraql/metrics then reports the sign-in pool as pool="main": the framework
pool where framework.datasource is declared, and the surface’s own otherwise. Each member keeps
its own scrape at /<name>/_tesseraql/metrics, configured in its own tesseraql.metrics.
The host also migrates the framework’s security schema once, before any application starts;
each hosted runtime then validates it and refuses to start on a mismatch (TQL-APP-4214). A
runtime pointed at a framework database the host never migrated therefore fails loudly at boot,
instead of producing a stack where signing in silently does not carry.
Every part of the file is optional, and absence is checked rather than trusted: when the stack
supplies no framework datasource and more than one application runs, the host compares each
application’s own resolved framework coordinate and refuses to start on disagreement
(TQL-APP-4211), naming each application — because the alternative is a stack where signing in
silently does not carry. The gateway’s --port, --http2 and --trusted-proxies stay flags: a
wrong value there fails loudly at bind or handshake, which is exactly what the file exists to
avoid needing.
tesseraql new writes the file beside the application it creates — all guidance comments, which
is enough: the file is also the stack’s marker, and development commands find the stack by it.
A framework database that is not PostgreSQL
Section titled “A framework database that is not PostgreSQL”The stack’s pools — the framework datasource, the migration pool — are stack infrastructure, and
their driver resolves through DriverManager on the process classpath. An application’s
tesseraql.modules cannot serve them: those jars load on that application’s own classloader, and
the framework pool belongs to no application. PostgreSQL ships with every distribution; Oracle,
SQL Server and MySQL do not, and cannot, because their licences make redistribution the
organization’s decision rather than the framework’s.
So the stack declares the coordinate, and an operator places the jar:
framework: datasource: jdbcUrl: "jdbc:sqlserver://db:1433;databaseName=stack" username: ${secret.env.STACK_DB_USER} password: ${secret.env.STACK_DB_PASSWORD} modules: - com.microsoft.sqlserver:mssql-jdbc # version from the BOMThe declaration does not load anything — nothing resolves a jar at host start, by design. It is
what tesseraql modules fetch --stack collects for a disconnected machine, and what the start-time
refusal names. Placing it is one step, and each distribution reads one place:
| Distribution | Where the jar goes |
|---|---|
| Container image | COPY it into /opt/tesseraql/lib/ in the derived image (the runtime image starts with -cp 'lib/*') |
Developer CLI, tesseraql-host archive |
lib/ext/ beside the launcher, or any path in TESSERAQL_CLASSPATH |
| Windows app image | app\ext\, plus an app.classpath=$APPDIR\ext\<jar> line inside the [Application] section of app\tesseraql-host.cfg, beside the one already there — appended at the end of the file it lands in [JavaOptions] and is read as a JVM option, so the classpath never grows |
If no driver on the classpath accepts the URL, the host refuses to start with TQL-APP-4220,
naming the declared coordinate and this step — in place of the JDBC layer’s No suitable driver,
which names neither.
The install root
Section titled “The install root”An install root holds catalog.json — the list of installed applications, their versions,
and their tenant entitlements — beside one unpacked tree
per application version. Baking that directory into a container image gives every node
identical bytes with nothing to download at boot.
The runtime does not fetch application packages itself. Getting bytes onto a host is a
deployment concern with better tools than a runtime fetcher. A deployment either ships the
directory, or copies a .tqlapp package to the host and runs tesseraql deploy (below); the
AppInstaller/AppUpgrader library stays for tooling that wants to drive the same lifecycle
itself.
A stack on more than one node
Section titled “A stack on more than one node”One host process serves one stack. Running the stack on several nodes means several hosts, and what makes them one stack is the install root they read. There are two supported shapes:
| Topology | How a deploy reaches every node |
|---|---|
| Baked image | The install root is inside the image. Deploying is building a new image and rolling the nodes — your orchestrator’s job, and it already does it well. |
| Shared install root | Every node mounts one directory (NFS, SMB, a shared volume). A deploy writes there once — tesseraql deploy, or the ops console’s deploy page — and every node’s reconciler converges to it. |
| Kubernetes | The baked image as a Deployment: the chart or the rendered manifests, two replicas, the probes and the drain sized to the runtime (kubernetes). A deploy is a new image and a rolling update. |
A host reconciles on two signals: the filesystem watch, and a sweep every
stack.reconcile.interval (default 15 seconds, 0 disables it). The sweep is what makes the
shared root work — a watch service reports what its own host wrote, so a deploy performed by
another node is invisible to it. A pass reads the catalogue and diffs it against what is running,
so an idle sweep costs one small file read.
stack: reconcile: interval: 15s # or a plain number of seconds; 0 = filesystem events onlyWhat this does not give you: choosing which node runs which application, separating batch work onto its own nodes, or a fleet-wide view of who is running what. Every node hosts every member, and every node can run any member’s scheduled jobs — exactly one node runs each firing, by claim, so the work is done once wherever it lands.
Independent, node-local install roots are the shape to avoid. Nothing refuses it, and nothing
reconciles across it: a .tqlapp uploaded to the ops console lands on whichever node served the
request, that node converges, and the others keep serving the old version — a split fleet with no
signal. Bake the image or share the directory.
Modules are resolved before the host starts
Section titled “Modules are resolved before the host starts”An application’s tesseraql.modules (drivers and the pdf/excel/s3 codecs) are resolved before
hosting, because resolution reaches Maven repositories and a production host boots offline — the
host distribution carries no resolver at all. tesseraql package resolves the closure its
modules.lock pins and carries it inside the archive under .tesseraql/modules/, so deploying a
package is the whole step; an installed application loads that set and never consults
work/modules. The container template (deploy/Dockerfile) unpacks a package for this reason:
a copied source tree carries no module set, and an image built from one was refused at start.
An application installed as a directory rather than a package is the case that still needs the operator step, run on a machine that has the developer CLI:
tesseraql modules resolve --stack /opt/tesseraql/apps # every member, or --app for oneThe host refuses to start an application that declares modules and carries none
(TQL-APP-4216), or whose module set disagrees with its modules.lock (TQL-APP-4217) —
running it silently without the functions, codecs and drivers it declared is the failure mode
those refusals replace. Each hosted runtime then loads its own module set on its own
classloader: module visibility equals runtime scope, so two applications can carry the same
driver at different versions, and a custom expression function is visible exactly to the
application that declared it. Changing a member’s module set is a redeploy of that member, not a
live edit.
Deploying one application
Section titled “Deploying one application”Deploying is writing files. .upgrade/<name>.json names a candidate — a canary at its traffic
weight, or a version to replace the serving one — and catalog.json names each member’s
serving version. A running host watches the install root and applies what the candidate file
says, replacing exactly that member’s runtime while the stack keeps serving and moving the
catalogue to the version it now serves; the catalogue is the host’s write, so it never names a
version no host has started
(stack-architecture.md
Decision 29). tesseraql deploy is the pen:
tesseraql deploy ./orders-2.1.0.tqlapp --stack /opt/tesseraql/apps # replacetesseraql deploy ./orders-2.1.0.tqlapp --stack ... --canary --weight 10 # stage a canarytesseraql deploy weight orders 50 --stack ... # move the ramptesseraql deploy promote orders --stack ... # candidate goes activetesseraql deploy rollback orders --stack ... # discard, or last servedtesseraql deploy status orders --stack ... # read back both sidesThe package is a local path — getting bytes onto the host stays your deployment’s concern — and
--sha256 <hex> verifies it before anything is written. The command works with no host running:
the candidate is written, and the next host start applies it. --stack must be an install
root; a workspace of source trees has no version ledger and is refused (TQL-UPGRADE-4092),
because it deploys by restarting the stack.
Or deploy remotely, with a grant instead of install-root access. The stack’s origin serves
an authenticated deploy endpoint (POST /_tesseraql/deploy), and deploy --url is its pen:
export TESSERAQL_TOKEN=$(tesseraql token --url https://stack.example.com --login ci | tail -1)tesseraql deploy ./orders-2.1.0.tqlapp --url https://stack.example.comThe endpoint checks the caller’s tql.app.deploy.<name> grant against the package’s declared
name — never a request parameter, so a token scoped to orders cannot deploy billing by
renaming anything — runs the same preflight, and writes the same candidate on its own install
root; a deploy the preflight refuses answers as the response and writes nothing. The host
judges the candidate after the endpoint has answered, and its verdict is read back from
GET /_tesseraql/deploy/<name> behind the same grant: deploy --url --wait tails it, exactly
as the local mode tails the status file. This is how a pipeline deploys only the applications
it manages, with a scoped short-lived token and no login to the host machine. The same endpoint
has a browser face: the ops console’s Deploy page (/_tesseraql/ops/console/deploy), shown
to any signed-in holder of a tql.app.deploy grant, uploads a .tqlapp through the same
checks and shows the host’s last verdict per member (ops console). It
needs the stack file to carry the token issuer:
security: jwt: secret: ${secret.env.STACK_JWT_SECRET} audience: https://stack.example.com rolesClaim: roles permissionsClaim: permissions token: enabled: trueThe subtree configures the stack surface runtime — the origin’s token page, the
/_tesseraql/token exchange, and the deploy endpoint’s bearer validation ride it; each member
keeps its own declared JWT configuration. The authority is an operational guardrail, not
isolation between distrusting teams: those get separate stacks, which is what a stack means.
Install-root access on the host machine remains stack-root; the endpoint adds a narrower door,
it does not narrow the wide one.
The host replaces without a gap. The new version’s runtime starts beside the old one, with the
boot guards re-run for it alone: modules resolved, framework-datasource agreement, and the
framework-schema validation. It migrates its own business schema, and it must answer
/_tesseraql/health/ready before any traffic moves. Then the address swaps to it, and the old
runtime drains under its own declared tesseraql.shutdown.timeout — long-lived streams are cut
at the force timeout, and their clients reconnect onto the new version. In-flight batch runs are
asked to stop cooperatively at drain start: a run between steps stops with an exact resume
point, a chunk step stops at its next committed checkpoint, and a run in its final step
completes. Rerunning a stopped run goes through the operator’s existing rerun, deliberately not
automatically. A failed replace is a no-op: refused admission, a failed start, or a failed
probe leaves the old runtime serving and the catalogue naming it, and the refusal lands in the
status file with the action and version it refused. The candidate stays on disk, refused, until
you write something new — a rollback discards it, a re-deploy of the same package attempts it
again — and the next cold start comes up on the serving version, not on the refused one.
Two windows to know about. First, a staged canary’s --weight gates HTTP traffic only: the
candidate’s jobs, pollers and outbox work from the moment it starts, claim-arbitrated against
the old version’s, so a 10% canary’s background participation is not 10%. The overlap also means
migrations must stay expand/contract — the deploy window’s contract, see
deployment.md. Second, a replaced runtime answers on an ephemeral internal port
even when it declares server.port; the declared port returns at the next stack start. The
relay follows the slot either way — a declared port only matters for reaching an application
beside the gateway.
The host reports each attempt in .upgrade/<name>.status.json: applied, or refused with the
action, the version and the refusal’s own message. The CLI writes the candidate; the host
writes the catalogue and the status. deploy --wait, promote --wait and rollback --wait
tail that file so a pipeline gets a synchronous exit code, and deploy status renders both
sides — the host’s verdict only when it is about a version on disk now, so an old refusal never
reads as the current state. The sweep other nodes rely on does not re-attempt a candidate whose
refusal is on record, and the housekeeping that follows one — a canary the intent no longer
names is discarded on the next pass — leaves that refusal as the record. Membership stays
start-time: a new name in the catalogue, or one removed,
is the stack changing shape and waits for the next stack start (the host logs the owed restart).
Previous versions stay on disk — they are rollback’s working material, and rollback targets
the version that served, never one the host refused.
Install-root write access is deploy authority over the whole stack: catalog.json is one file,
so no permission arrangement scopes it per application. Hold the per-team line where teams
already differ — each application’s repository and its CD pipeline — with tesseraql deploy as
the pipeline’s tool.
One address per application
Section titled “One address per application”Every application is addressed as /<name>/ on one origin, and the stack shares one
sign-in across them. The address is derived from tesseraql.app.name, always, so an install or an
upgrade can never move it. The runtimes are told the prefix they serve under, so each answers at
the addresses it emits (base-path.md), and the session cookie is issued at the
origin root so one sign-in reaches every application.
An earlier --mode isolated gave each application its own hostname and no shared session. It is
gone: a stack is defined by sharing an origin and a sign-in, and a mode that undid both was a
second deployment shape to reason about, document and test — which
stack-architecture.md
Decision 12 removes so that development and production have one topology between them. An
application that must not share a session with its neighbours gets its own stack.
The root, the portal, and the origin scope
Section titled “The root, the portal, and the origin scope”The origin root does exactly one thing: it answers 307. By default it redirects to the
stack’s portal at /_tesseraql/portal — sign in, and the portal lists the applications you may
reach, each at its /<name> address. A stack whose users should land in one main application
declares that in tesseraql-stack.yml instead:
root: redirect: orders # / -> /ordersThe redirect is temporary on purpose. A permanent one would be cached by browsers past the
configuration change that retires it. Naming an application the stack does not hold refuses the
start (TQL-APP-4215), listing the names it does hold.
The portal, the stack’s sign-in, the account pages and IAM Admin are served by
the stack’s own runtime, which host and dev start beside your applications. It answers the
origin-scope /_tesseraql/* and /assets/* paths; /_tesseraql/health/live and
/_tesseraql/health/ready stay the gateway’s own answer, so a load balancer’s probe never
depends on it. These surfaces answer once, at the origin: a hosted member serves no sign-in,
account or IAM Admin copy of its own. An unauthenticated browser on a member page is bounced to
/_tesseraql/login?redirect=<the prefixed page> and returned there after signing in, and member
pages link the account chip and IAM Admin origin-absolute.
Reach into a member is a grant, not a URL. After authentication, a principal without
tql.app.use.<name> is refused by that member on every route — auth: public routes excepted,
service callers included — and the portal’s tiles filter by the same atom beside tenant
entitlement. Seed tql.app.use grants (or a tql.app.use.* baseline role) before your users
sign in; the bootstrap administrator’s default permissions carry the wildcard.
The gateway routes, the ingress protects
Section titled “The gateway routes, the ingress protects”The gateway is a route to the application that answers, not a guard in front of it. It fronts applications the operator installed, on one machine, behind whatever reverse proxy the deployment already runs — and that reverse proxy is where the protections belong:
| Concern | Belongs to |
|---|---|
| Request body limits | the ingress, and each application’s own declared limits |
| Rate limiting | the ingress |
| TLS termination | the ingress |
| Which application answers | the gateway |
| Tenant entitlement at the door | the gateway, as a convenience filter — the application’s own tenancy resolution is authoritative |
| Overwriting headers a caller must not set for itself | the ingress |
The gateway imposes no body limit of its own in either direction. It used to: 10 MB inbound and 64 MB outbound, which capped every attachment and import at a number the gateway picked and truncated large exports mid-download. An application keeps whatever limits it declares, and a deployment that wants a limit in front of every application sets one at the ingress, where it can be tuned per route and per client.
Speak HTTP/1.1 to the upstream. nginx forwards to its upstream over HTTP/1.0 unless
proxy_http_version 1.1 is set, and HTTP/1.0 has no chunked framing: a body the runtime cannot
size is written to EOF, and the gateway, which cannot forward chunks to such a client, buffers
that body whole in heap before answering. A download — an export, an attachment, a transfer
file — declares its length, so it streams through the gateway and a truncated one reads as
truncated on any version. An event stream has no length, so it needs the HTTP/1.1 hop, and
proxy_buffering off (the runtime also sends X-Accel-Buffering: no) so frames pass through
live. Envoy, HAProxy and the kamal-proxy path speak HTTP/1.1 upstream by default.
Forwarded headers pass through by default. The gateway does not strip the mTLS forwarded header an
application declares, because it cannot tell a caller’s copy from the edge’s without knowing which
sources are trusted — and stripping it unconditionally destroyed the edge’s own value, which made
mTLS authentication unusable behind the gateway. The trust contract
is the edge’s, and unchanged: the edge must overwrite or strip the forwardedHeader on every
inbound request, and the runtime must not be reachable except through that edge. A gateway
reachable from anywhere but the edge is a deployment error, and no amount of header filtering one
hop later repairs it.
HTTP/2
Section titled “HTTP/2”The gateway speaks HTTP/1.1 by default. --http2 serves and forwards cleartext HTTP/2 (h2c):
tesseraql host --stack /srv/tesseraql/apps --port 8080 --http2One switch moves both hops — the client’s connection to the gateway and the gateway’s connection to each application — because enabling it at one end alone breaks request framing. An application that does not offer h2c answers the upgrade over HTTP/1.1 and is reached exactly as before, so turning this on cannot make an application unreachable. HTTP/1.1 clients keep working against an h2c gateway: the upgrade is offered, not required.
Naming your edge adds a second line of defence. --trusted-proxies takes the addresses whose
forwarded headers are the edge’s rather than a caller’s:
tesseraql host --stack /srv/tesseraql/apps --port 8080 \ --trusted-proxies 10.0.0.0/8,192.168.1.5With it set, an application’s mtls.forwardedHeader is stripped from every request arriving from
anywhere else, so a caller reaching the gateway around the edge cannot present the assertion the
application is configured to believe. The comparison is against the peer of the connection,
never a header: a caller can write X-Forwarded-For, and cannot write the socket it connected
from. CIDR blocks and bare addresses are both accepted, IPv4 and IPv6.
Leaving it empty strips nothing, and that is deliberate. Reading “no edge named” as “trust nobody,
strip from everyone” is the unconditional strip that made mTLS unusable behind a gateway, and it
would be the default again. X-Tenant-Id is never stripped either way, because the application’s
own tenancy resolution is the authority on it.
Responses pass through unchanged: framing, chunked bodies, event streams, Location values and
cookie attributes all reach the client as the application wrote them. An event stream arrives frame
by frame rather than in bursts, which is what the ops console, Studio’s preview and the MCP
transport depend on.
What isolation gives you, and what it does not
Section titled “What isolation gives you, and what it does not”Each runtime is separate: a separate runtime context, URL space, trace buffer and configuration. A route in one application cannot collide with a route in another, and in development each application’s workshop edits exactly its own runtime’s source.
Data isolation is enabled, not guaranteed. The framework does not verify that co-hosted applications reach different data, and does not claim to. An application declares the shape of its datasource; the operator installing it supplies the connection, and a stack shares one by design.
Nor is this a security boundary. The applications share a JVM, which offers no in-process mechanism to confine bytecode. This is why the admission profile requires a shared application to be declarative-only: while isolation is arranged rather than enforced, “the framework is the sandbox” is the only ground on which an application nobody reviewed can be run.
Operating a host
Section titled “Operating a host”The operations console is the stack’s, at the origin scope:
/_tesseraql/ops/console is one shell whose sidebar is an application switcher — the
caller’s tql.ops.view.<name> grants applied to the member list, deny by default, with a
staged canary as a second entry. Selecting an application delegates that application’s pages
over loopback to its own runtime with the caller’s session, so authorization stays at the
member. tql.ops.view.<name> is the authority to see an application’s operational data and
tql.ops.run.<name> the authority to act on it — run and cancel jobs, redeliver outbox and
dead-lettered events — granted separately, so an on-call reader is not an acting pen. The
wildcard is a terminal * (tql.ops.view.*).
One console per stack is a topology rule, like the derived address: a hosted member serves no console of its own. Cross-application aggregate views beyond the switcher and the overview’s per-member cards belong to the metrics exposition, which already labels job runs by application.
Windows Server
Section titled “Windows Server”A Windows deployment does not use the container image. It runs the tesseraql-host
distribution — tesseraql-host-<version>-windows-x86_64.zip on each release — which carries
the host and the operator commands with a bundled Java
runtime, and none of the development tooling: no dev, no Studio, no embedded database.
Development on Windows uses the developer CLI (scoop install tesseraql); a server runs this
artifact.
Install and supervise it as a Windows service:
- Unzip under a program directory, e.g.
C:\Program Files\tesseraql. - The zip includes
tesseraql-host-service.xml, a service definition for WinSW. DownloadWinSW-x64.exe, place it beside the XML astesseraql-host-service.exe, and adjust the stack directory, port, and the environment values your applications’config/application.ymlplaceholders expect. tesseraql-host-service.exe install, thennet start tesseraql-host.
Stopping the service is the ordered drain, not a kill: the wrapper’s stop reaches the same
shutdown path SIGTERM reaches in a container, so in-flight work finishes before the process
exits. The service log (under logs\ beside the definition) records Stack stopping when the
drain begins — the stop path is asserted by CI on every release build.
Everything else on this page is platform-neutral. The install root, the catalog, deploy with
canary, promote and rollback work identically, because activating a version is an atomic
catalog-pointer switch and a placed version directory is never modified — no upgrade ever
replaces a file the running service holds open.
- deployment.md — shipping an application, bootstrap, migrations, TLS.
- ops-console.md — what each application’s console reports.
- admission.md — the bar an application must clear before someone else runs it.